數據結構之算法分析

算法分析

什麼是算法分析

問題:如何對比兩個程序?看起來寫法不同,但解決同一個問題的程序,哪個“更好”?

程序和算法的區別

算法是對問題解決的分步描述

程序則是採用某種編程語言實現的算法,同一個算法通過不同的程序員採用不同的編程語言,能產生很多程序。

累計求和問題

我們來寫一個累加求和的程序,就是從1到n累加輸出和。

示例1:

def sum_num(n):
    sum = 0
    for i in range(1,n+1):
        sum += i
    return sum

print(sum_num(10))

示例2:

def foo(tom):
    fred = 0
    for bill in range(1,tom+1):
        barney = bill
        fred = fred + barney
    return fred

print(foo(10))

看完示例1再看示例2,是不會死感覺怪怪的,但是實際上兩個程序是相同的,示例2的失敗之處在於變量命名詞不達意,而且包含了無用的垃圾代碼。

算法分析的概念

比較程序的“好壞”,有更多因素,比如代碼風格,可讀性等等。我們主要感興趣的是算法本身特性。

算法分析主要就是從計算資源消耗的角度來評判和比較算法。

更高效利用計算資源,或者更少佔用計算資源的算法,就是好算法。從這個角度,上面的兩段程序實際上是基本相同,他們都採用了一樣的算法來解決累加求和問題。

計算資源指標

何爲計算資源

一種是算法結局問題過程中需要存儲空間和內存,但存儲空間受到問題自身數據規模的變化影響,要區分哪些存儲空間是問題本身描述所需,哪些是算法佔用,不容易區分。

另一種是算法的執行時間,我們可以對程序進行實際運行測試,獲得真是的運行時間。

迭代累加

我們可以利用Python中的time模塊,對程序運行時間進行統計。

import time

def sum_num(n):
    start = time.time()   # 程序開始時間
    sum = 0
    for i in range(1,n+1):
        sum += i
    end = time.time()  # 結束時間
    return sum,end - start

for i in range(5):
    print("sum is %d required %10.7f seconds" % sum_num(10000))

輸出結果:

sum is 50005000 required  0.0009971 seconds
sum is 50005000 required  0.0010009 seconds
sum is 50005000 required  0.0000000 seconds
sum is 50005000 required  0.0009973 seconds
sum is 50005000 required  0.0000000 seconds

我們看到1到10000累加,每次運行大約需要0.0009s

那麼累加到100000呢?以及累加到1000000呢?

# 累加到100000
sum is 5000050000 required  0.0049376 seconds
sum is 5000050000 required  0.0049913 seconds
sum is 5000050000 required  0.0050364 seconds
sum is 5000050000 required  0.0049746 seconds
sum is 5000050000 required  0.0049860 seconds
# 累加到1000000
sum is 500000500000 required  0.0538669 seconds
sum is 500000500000 required  0.0499117 seconds
sum is 500000500000 required  0.0588810 seconds
sum is 500000500000 required  0.0499055 seconds
sum is 500000500000 required  0.0498645 seconds

我們不難發現運行時間以10的倍數增加。

無迭代累加

我們可以利用求和公式,對從1到n的數進行累加。

def sum_num(n):
    start = time.time()
    sum = (n * (n + 1)) / 2
    end = time.time()
    reutrn sum, end-start

採用上述同樣的方法進行檢測運行時間。

分別對10000,100000,1000000,10000000,100000000進行累加檢測運行時間

sum is 50005000 required  0.0000000 seconds
sum is 500000500000 required  0.0000000 seconds
sum is 50000005000000 required  0.0000000 seconds
sum is 5000000050000000 required  0.0000000 seconds

我們發現這種無迭代的算法,運行時間幾乎與需要累加的數目無關,而使用迭代,運行時間與累加對象n的大小是倍數增長關係。

運行時間檢測的分析

我們先看第一種迭代算法,包含了一個循環,可能會執行更多語句,這個循環運行次數跟累加n是有關係的,n增加,循環次數也增加。

但是關於運行時間的實際檢測,是有點問題的,同一算法,採用不同的編程語言編寫,放在不同的機器上運行,得到的運行時間會不一樣,有時候會大不一樣。比如把非迭代算法放在老舅機器上跑,甚至可能慢過新機器上的迭代算法。所以我們需要更好的方法來很亮算法運行時間,這個指標與具體的機器,程序運行時段都無關。

大O表示法

算法時間度量指標

一個算法所實施的操作數量或步驟可作爲獨立於具體程序/機器的度量指標。

哪種操作跟算法的具體實現無關?

這是我們需要一種通用的基本操作來作爲運行步驟的計量單位。

賦值語句是一個不錯的選擇,一條賦值語句同時包含了(表達式)計算和(變量)存儲兩個基本資源,仔細觀察程序設計語言特性,除了與計算資源無關的定義語句外,主要就是三種控制流語句和賦值語句,而控制流僅僅氣到了組織語句的作用,並不實施處理。

賦值語句執行次數

我們繼續上面的累加,分析sum的賦值語句執行次數。

第一次初始化賦值爲0,接着開始循環,每循環一次就賦值一次,那麼賦值語句數量T = n +1。

問題規模影響算法執行時間

問題規模:影響算法執行時間的主要因素

在前n個整數累加求和的算法中,需要累計的整數個數合適作爲問題規模的指標。前100000個整數求和對比前1000個整數求和,算是同一問題的更大規模。

算法分析的目標是要找出問題規模會怎麼影響一個算法的執行時間。

數量級函數Order of Magnitude

基本操作數量函數T(n)的精確值並不是特別重要,重要的是T(n)中起決定性因素的主導部分。用動態的眼光看,就是當問題規模增大的時候,T(n)中的一些部分會蓋過其他部分的貢獻。

數量級函數描述了T(n)中隨着n增加速度最快的主導部分,稱作“大O”表示法,記作O(f(n)),其中f(n)表示了T(n)中主導部分。

確定運行時間數量級大O的方法

例1:T(n) = 1 + n

當n增大時,常數1在最終結果中顯得越來越無足輕重,所以可以去掉1,保留n作爲主要部分,運行時間數量級就是O(n)

例2:T(n) = 5n² +27n=1005

當n很小的時候,常數1005起決定性作用,但當n越來越大,n²項就越來越重要,其他兩項對結果的影響也就越來越小,同樣,n² 項中的係數5,對於n² 的增長速度來說也影響不大,所以 可以在數量級中去掉27n+1005,以及係數5,最終確定爲O(n²)

影響算法運行時間的其他因素

有時候決定運行時間的不僅是問題規模,某些具體數據也會影響算法運行時間,分爲最好,最差和平均情況體現了算法的主流性能,對算法的分析要看主流,而不被某幾個特定的運行狀態所迷惑。

常見的大O數量級函數

通常當n較小時,難以確定其數量級,當n增長到較大時,容易看出其變化量級。

f(n) 名稱
1 常數
log(n) 對數
n 線性
n*log(n) 對數線性
n² 平方
n³ 立方
2ⁿ 指數

“變位詞”判斷問題

問題描述

所謂“變位詞",是指兩個詞之間存在組成字母的重新排列關係。如heart和earth,python和typhon。

爲了簡單起見,假設參與判斷的兩個詞僅由小寫字母構成,而且長度相等。

逐字檢查

將詞1中的字符逐個到詞2中檢查是否存在,若存在就“打勾”標記(防止重複檢查),如果每個字符都能找到,則兩個詞是變位詞,只要有一個字符找不到,就不是變位詞。

實現“打勾”標記:可以將詞2對應的字符設爲None,由於字符串是不可變類型,需要先複製到列表中。

def foo(s1, s2):
    l = list(s2)  # 由於字符串是不可變類型,將s2轉換爲列表
    p1 = 0
    ok = True
    while p1 < len(s1) and ok:  # 循環編譯s1的每個字符
        p2 = 0
        found = False
        while p2 < len(l) and not found:
            if s1[p1] == l[p2]: # 對s2逐個對比
                found = True
            else:
                p2 += 1
            if found:
                l[p2] = None   # 找到進行打勾
            else:
                ok = False   # 未找到,失敗
            p1 += 1
        return ok

上面的代碼中,詞中包含的字符個數爲n,主要部分在於兩重循環,外層循環遍歷s1每個字符,將內層循環執行n次,而內層循環在s2中查找字符,每個字符的對比次數,分別是1,2...n中的一個,而且各不相同。所以總執行次數是1+2+3+...+n。這個式子是不是很熟悉,是不是又回到數字累加的問題上了?那麼我們就可以知道其數量級爲O(n²)。

1+2+...+n=n(n+1)/2=1/2n²+1/2n-->O(n²)

排序比較

將兩個字符串都按照字母的順序排好序,再逐個字符對比是否相同,如果相同則是變位詞,有任何不同就不是變位詞。

def foo(s1,s2):
    l1 = list(s1)
    l2 = list(s2)  # 轉換爲列表    
    l1.sort()
    l2.sort()  # 排序  
    p = 0
    result = True
    while p < len(s1) and result:  # 這邊直接判斷兩個列表是否相等不是更好麼 哈哈
        if l1[p] == l2[p]:
            p += 1
        else:
            result = False
    return result

粗略的看上去,這個算法只有一個循環,最多執行n次,數量級也就是O(n)。

但是循環前面的兩個sort並不是無代價的,所以本算法時間主導的步驟是排序步驟。

暴力破解

暴力破解的解題思路就是窮盡所有的可能組合,將s1中出現的字符進行全排列,再查看s2是否出現在全排列列表中。

這裏最大的困難是產生s1所有字符的全排列,根據組合數學的結論,如果n個字符進行全排列,其所有可能的字符串個數爲n!。

我們已知n!的增長速度甚至超過2ⁿ。例如對於20個字符長的詞來說,將產生20!=3432902008156640000個候選詞,如果一微秒處理一個候選詞的話,需要近8萬年時間來昨晚所有匹配。所以這個方法坤怕不是個好算法。

計數比較

對於兩個詞中每隔字母出現的次數,如果26個字母出現的次數都相同的話,這兩個字符串一定是變位詞。

我們可以爲每個詞設置一個26位的計數器,選檢查每個詞,在計數器中設定好每隔字母出現的次數,計數完成後,進入比較階段,看兩個字符串的計數器是否相同,如果相同則輸出是變位詞的結論。

def foo(s1,s2):
    l1 = [0] * 26
    l2 = [0] * 26
    # 分別計數
    for i in range(len(s1)):
        p = ord(s1[i]) - ord("a")
        l1[p] += 1
    for i in range(len(s2)):
        p = ord(s2[i]) - ord("a")
        l2[p] += 1
    j = 0
    result = True
    # 進行比較
    while j < 26 and result:
        if l1[j] == l2[j]:
            j += 1
        else:
            result = False
    return result

這個算法中有三個循環迭代,但是不像逐字檢測那樣存在嵌套循環,這個算法中前兩個循環用於對字符串進行計數,操作次數等於字符串長度n,第三個循環用於計數器比較,操作次數總共爲26次。

所以總操作次數爲T(n) = 2n +26,那麼其數量級爲O(n)。

由此可見這是一個線性數量級的算法,是4個變位詞判斷算法中性能最優的。

但是需要注意的是,此算法依賴於兩個長度爲26的極速器列表,來保存字符計數,這相對前三個算法需要更多的存儲空間。如果考慮由大字符集構成的詞(如中文具有上萬個不同字符),這需要更多存儲空間。

犧牲存儲空間來換取運行時間,或者相反,這種在時間空間之間的取捨和權衡,在選擇問題解法的過程中經常出現。

Python數據類型的性能比較

前面我們瞭解了“大O表示法”以及對不同的算法評估。

下面我們來討論下Python中list和dict兩種內置數據類型上各種操作的大O數量級。

對比list和dict的操作

類型 list dict
索引 自然數i 不可變類型值key
添加 append、extend、insert b[k] = v
刪除 pop,remove pop
更新 a[i] = v b[k] = v
正查 a[i]、a[i:j] b[k]、copy
反查 index(v)、count(v) 無
其他 reverse、sort has_key、update

list列表數據類型

list類型各種操作的實現方法有很多,如何選擇具體哪種實現方法?總的方案是讓最常用的操作性能最好,犧牲不太常用的操作。

常用性能操作

最常用的是按照索引取值和賦值v=a[i]和a[i]=v。

由於列表的隨機訪問特性,這兩個操作執行時間與列表大小無關,均爲O(1)。

另一個是列表增長,可以選擇append()和__add__() 或 "+"

l.append(v) 執行時間是O(1)

l = l + [v] 執行時間是O(n+k),其中k是被加的列表長度

四種生成前n個整數列表的方法

  • 方法一:
    def foo1():
    l = []
    for i in range(1000):
    l = l + [i]
  • 方法二:
    def foo2():
    l = []
    for i in range(1000):
    l.append(i)
  • 方法三:
    def foo3():
    l = [i for i in range(1000)]
  • 方法四:
    def foo4():
    l = list(range(1000))

使用timeit模塊對函數計時

創建一個Timer對象,指定需要反覆運行的語句和只需要運行一次的"安裝語句"。然後調用這個對象的timeit方法,其中可以指定返回運行多少次

if __name__ == '__main__':
    from timeit import Timer
    t1 = Timer("foo1()","from __main__ import foo1")
    print("concat %f seconds\n" % t1.timeit(number=1000))  # cconcat 1.099056 seconds
    t2 = Timer("foo2()", "from __main__ import foo2")
    print("append %f seconds\n" % t2.timeit(number=1000))  # append 0.064581 seconds
    t3 = Timer("foo3()", "from __main__ import foo3")
    print("comprehension %f seconds\n" % t3.timeit(number=1000))  # comprehension 0.039818 seconds
    t4 = Timer("foo4()", "from __main__ import foo4")
    print("list range %f seconds\n" % t4.timeit(number=1000))  # list range 0.023467 seconds

我們可以看到,列表連接(concat)最慢,list range最快,速度相差50倍左右

append也要比concat快的多,另外我們注意到推導式的速度是append兩倍的樣子。

List基本操作的大O數量級

Operation Big-O Efficiency
index[] O(1)
index assignment O(1)
append O(1)
pop() O(1)
pop(l) O(n)
insert(i,item) O(n)
del operator O(n)
iteration O(n)
contain(in) O(n)
get slice[x:y] O(k)
del slice O(n)
set slice O(n+k)
reverse O(n)
concatenate O(k)
sort O(n log n)
multiply O(nk)

list.pop的計時試驗

我們注意到pop這個操作,pop()從列表末尾移除元素,數量級爲O(1),pop(i)從列表中部移除元素,數量級爲O(n)。

原因在於Python所選擇的實現方法,從中部移除元素的話,要把移除元素後面的元素全部向前活動複製一遍,這個看起來有些笨拙,但是這種實現方法能保證列表按索引取值和賦值的操作很快,數量級達到O(1),這也算是一種對常用和不常用操作的折中方案。

爲了驗證上表中的大O數量級,我們把兩種情況下的pop操作來實際計時對比,相對同一個大小的list,分別調用pop()和pop(0)。對不同大小的list做計時,期望結果是pop()的時間不隨list大小變化,pop(0)的時間隨着list變大而邊長。

示例1:

#  我們對長度兩百萬的列表,執行1000次
if __name__ == '__main__':

    import timeit
    x = list(range(2000000))
    popzero = timeit.Timer("x.pop(0)", "from __main__ import x")
    print("x: %f seconds\n" % popzero.timeit(number=1000))   # x: 1.452277 seconds
    y = list(range(2000000))
    popend = timeit.Timer("y.pop()", "from __main__ import y")
    print("y: %f seconds\n" % popend.timeit(number=1000))  # y: 0.000058 seconds

我們發現pop()的時間是0.000058秒,pop(0)的時間是1.452277秒

示例2:我們通過改變列表的大小來測試兩個操作的增長趨勢

if __name__ == '__main__':

    import timeit

    popzero = timeit.Timer("x.pop(0)", "from __main__ import x")
    popend = timeit.Timer("x.pop()", "from __main__ import x")

    print("pop(0)  pop()")
    for i in range(1000000,10000001,1000000):
        x = list(range(i))
        pt = popend.timeit(number=1000)
        x = list(range(i))
        pz = popzero.timeit(number=1000)
        print("%15.5f,%15.5f" % (pz, pt))

輸出結果:

pop(0)  pop()
        0.41731,        0.00007
        1.38946,        0.00006
        2.46675,        0.00006
        3.48518,        0.00014
        4.51965,        0.00006
        5.54893,        0.00006
        6.80396,        0.00007
        8.26509,        0.00007
        8.88511,        0.00007
        9.87580,        0.00007

不難發現,pop()是平坦的常數,pop(0)是線性增長的趨勢。

dict數據類型

字典與列表不同,根據key找到數據項,而列表是根據位置(index).

最常用的取值get和賦值set,其性能爲O(1),另一個重要的操作contains(in)是判斷字典中是否存在某個key,這個性能也是O(1)。

operation Big-O Efficiency
copy O(n)
get item O(1)
set item O(1)
delete item O(1)
contains(in) O(1)
iteration O(n)

List和dict的in操作對比

設計一個性能試驗來驗證list中檢索一個值,以及dict中檢索一個值的計時對比。生成包含連續值的list和包含連續key的dict,用隨機數來檢驗操作符in的耗時。

if __name__ == '__main__':
    import timeit
    import random

    for i in range(10000, 100001, 20000):
        t = timeit.Timer("random.randrange(%d) in x" %i,
                         "from __main__ import random,x")
        x = list(range(i))
        l_time = t.timeit(number=1000)
        x = {j:None for j in range(i)}
        d_time = t.timeit(number=1000)
        print("%d,%10.3f,%10.3f" %(i, l_time, d_time))

輸出結果:

10000,     0.059,     0.001
30000,     0.188,     0.001
50000,     0.274,     0.001
70000,     0.395,     0.001
90000,     0.521,     0.001
110000,     0.614,     0.001
130000,     0.725,     0.001
150000,     0.859,     0.001
170000,     0.968,     0.001
190000,     1.068,     0.001
210000,     1.280,     0.001
230000,     1.398,     0.001
250000,     1.458,     0.001
270000,     1.562,     0.001
...

我們可以清楚的看到字典的執行時間與規模無關,是常數。

而列表的執行時間則隨列表的規模加大而線性上升。

更多Python類型操作複雜度

Python官方的算法複雜度網站:https://wiki.python.org/moin/TimeComplexity

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章