Python 演算法 (17)
演算法
演算法概念
我們通過計算機進行程式設計,計算機多才多藝,但不太善於獨立思考,我們必須提供詳盡的細節,使用它們能夠明白的語言將演算法提供給它們。
如果將最終寫好執行的程式比作戰場,我們碼農便是指揮作戰的將軍,而我們所寫的程式碼便是士兵和武器。資料結構和演算法則是兵法。我們可以不看兵法在戰場上肉搏,如此,可能會勝利,可能會失敗。即使勝利,可能也會付出巨大的代價。我們寫程式亦然:如果不懂演算法,有時面對問題可能會沒有任何思路,不知如何下手去解決;大部分時間可能解決了問題,可是對程式執行的效率和開銷沒有意識,效能低下;有時會藉助別人開發的利器暫時解決了問題,可是遇到效能瓶頸的時候,又不知該如何進行鍼對性的優化。
如果我們常看兵法,便可做到胸有成竹,有時會事半功倍!同樣,如果我們常看演算法,我們寫程式時也能遊刃有餘、明察秋毫,遇到問題時亦能入木三分、迎刃而解。
演算法的提出
【示例】如果 a+b+c=1000,且 a^2+b^2=c^2(a,b,c 為自然數),如何求出所有 a、b、c 可能的組合?
import time start_time=time.time() for a in range(1001): for b in range(1001): for c in range(1001): if a+b+c==1000 and a**2+b**2==c**2: print('a,b,c:',a,b,c) end_time=time.time() print('所用時間:',(end_time-start_time)) |
執行結果如圖所示:
演算法是獨立存在的一種解決問題的方法和思想。對於演算法而言,實現的語言並不重要,重要的是思想。
演算法可以有不同的語言描述實現版本(如 C 描述、C++描述、Python 描述等),我們現在是在用 Python 語言進行描述實現。
演算法的五大特徵
- 輸入性:有零個或多個外部量作為演算法的輸入
- 輸出性: 演算法至少有一個量作為輸出
- 確定性:演算法中每條指令清晰,無歧義
- 有窮性:演算法中每條指令的執行次數有限,執行每條指令時間也有限
- 可行性:演算法原則上能夠精確的執行,而且人們用紙和筆做有限次運算後即可完成
【示例】如果 a+b+c=1000,且 a^2+b^2=c^2(a,b,c 為自然數),如何求出所有 a、b、c 可能的組合?
import time start_time=time.time() for a in range(1001): for b in range(1001): c=1000-a-b if a**2+b**2==c**2: print('a,b,c:',a,b,c) end_time=time.time() print('所用時間:',(end_time-start_time)) |
執行結果如圖所示:
演算法效率衡量
執行時間反應演算法效率
對於同一問題,我們給出了兩種解決演算法,在兩種演算法的實現中,我們對程式執行的時間進行了測算,發現兩段程式執行的時間相差懸殊( 210.9348847 秒相比於 1.653827 秒),由此我們可以得出結論:實現演算法程式的執行時間可以反應出演算法的效率,即演算法的優劣。
單靠時間值絕對可信嗎?假設我們將第二次嘗試的演算法程式執行在一臺配置古老效能低下的計算機中,情況會如何?很可能執行的時間並不會比在我們的電腦中執行演算法一的214.583347 秒快多少。
單純依靠執行的時間來比較演算法的優劣並不一定是客觀準確的!程式的執行離不開計算機環境(包括硬體和作業系統),這些客觀原因會影響程式執行的速度並反應在程式的執行時間上。那麼如何才能客觀的評判一個演算法的優劣呢?
時間複雜度
一般來說,一個演算法執行所消耗的時間從理論上是算不出來的,只有通過上機執行才能測試出來。當然,我們也沒必要知道一個演算法它具體執行的時間是多少,而我們又知道,一個演算法花費的時間與演算法中語句的執行次數是成正比的。哪個演算法語句執行的次數多,它花費的時間就多。
【示例】執行次數
def test(n): count = 0; for i in range(count,n): for j in range(count,n): count+=1 for k in range(0,2*n): count+=1 icount=10 while icount>0: count+=1 icount-=1 |
從上面的示例我們可以得到執行次數為:f(n)=n^2+2*n+10。
對於演算法進行特別具體的細緻分析雖然很好,但是實踐中的實際價值有限。對於演算法最重要的是數量級和趨勢,這些是分析演算法主要的部分。而計量演算法基本運算元量的規模函式中哪些常量因子可以忽略不計。
時間複雜度實際上就是一個函式,該函式計算的是執行基本操作的次數。一個演算法語句總的執行次數是關於問題規模 N 的某個函式,記為分 f(N),N 稱為問題的規模。語句總的執行次數。記為 T[N],當 N 不斷變化時,T[N]也在變化,演算法的執行次數的增長速率和 f(N)的增長速率相同。則 T[N]=O(f(N)),稱 O(f(N))為時間複雜度的 O 漸進表示法。
分析演算法時,存在幾種可能的考慮:
演算法完成工作最少需要多少基本操作,即最優時間複雜度演算法完成工作最多需要多少基本操作,即最壞時間複雜度演算法完成工作平均需要多少基本操作,即平均時間複雜度
對於最優時間複雜度,其價值不大,因為它沒有提供什麼有用資訊,其反映的只是最樂觀最理想的情況,沒有參考價值。
對於最壞時間複雜度,提供了一種保證,表明演算法在此種程度的基本操作中一定能完成工作。
對於平均時間複雜度,是對演算法的一個全面評價,因此它完整全面的反映了這個演算法的
性質。但另一方面,這種衡量並沒有保證,不是每個計算都能在這個基本操作內完成。而且,對於平均情況的計算,也會因為應用演算法的例項分佈可能並不均勻而難以計算。
時間複雜度的幾條基本計算規則:
- 基本操作,即只有常數項,認為其時間複雜度為 O(1)
- 順序結構,時間複雜度按加法進行計算(3) 迴圈結構,時間複雜度按乘法進行計算
- 分支結構,時間複雜度取最大值
- 判斷一個演算法的效率時,往往只需要關注運算元量的最高次項,其它次要項和常數項可以忽略
- 在沒有特殊說明時,我們所分析的演算法的時間複雜度都是指最壞時間複雜度演算法分析
【示例】第一種解決方式
import time start_time=time.time() for a in range(1001): for b in range(1001): for c in range(1001): if a+b+c==1000 and a**2+b**2==c**2: print('a,b,c:',a,b,c) end_time=time.time() print('所用時間:',(end_time-start_time)) |
時間複雜度:T(n) = O(n*n*n) = O(n^3)
【示例】第二種解決方式
import time start_time=time.time() for a in range(1001): for b in range(1001): c=1000-a-b if a**2+b**2==c**2: |
print('a,b,c:',a,b,c) end_time=time.time() print('所用時間:',(end_time-start_time)) |
時間複雜度:T(n) = O(n*n*(1+1)) = O(n*n) = O(n^2)
由此可見,我們嘗試的第二種演算法要比第一種演算法的時間複雜度好多的。
常見時間複雜度
執行次數函式舉例 |
階 |
非正式術語 |
12 |
O(1) |
常數階 |
2n+3 |
O(n) |
線性階 |
3n2+2n+1 |
O(n2) |
平方階 |
5log2n+20 |
O(logn) |
對數階 |
2n+3nlog2n+19 |
O(nlogn) |
nlogn階 |
6n3+2n2+3n+4 |
O(n3) |
立方階 |
2^n |
O(2^n) |
指數階 |
注意,經常將 log2n(以 2 為底的對數)簡寫成 logn 常見時間複雜度之間的關係
所消耗的時間從小到大:
O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)
【示例】時間複雜度示例
O(5)
O(2n + 1)
O(n²+ n + 1)
O(3n³+1)
空間複雜度
一個程式的空間複雜度是指執行完一個程式所需記憶體的大小。利用程式的空間複雜度,可以對程式的執行所需要的記憶體多少有個預先估計。一個程式執行時除了需要儲存空間和儲存本身所使用的指令、常數、變數和輸入資料外,還需要一些對資料進行操作的工作單元和儲存一些為現實計算所需資訊的輔助空間。程式執行時所需儲存空間包括以下兩部分。(1)固定部分。這部分空間的大小與輸入/輸出的資料的個數多少、數值無關。主要包括指令空間(即程式碼空間)、資料空間(常量、簡單變數)等所佔的空間。這部分屬於靜態空間。
(2)可變空間,這部分空間的主要包括動態分配的空間,以及遞迴棧所需的空間等。這部分的空間大小與演算法有關。
例如:要判斷某年是不是閏年,你可能會花一點心思來寫一個演算法,每給一個年份,就可以通過這個演算法計算得到是否閏年的結果。
另外一種方法是,事先建立一個有 2050 個元素的陣列,然後把所有的年份按下標的數字對應,如果是閏年,則此陣列元素的值是 1,如果不是元素的值則為 0。這樣,所謂的判斷某一年是否為閏年就變成了查詢這個陣列某一個元素的值的問題。
第一種方法相比起第二種來說很明顯非常節省空間,但每一次查詢都需要經過一系列的計算才能知道是否為閏年。第二種方法雖然需要在記憶體裡儲存 2050 個元素的陣列,但是每次查詢只需要一次索引判斷即可。這就是通過一筆空間上的開銷來換取計算時間開銷的小技巧。到底哪一種方法好?其實還是要看你用在什麼地方。
一個演算法所需的儲存空間用 f(n)表示。S(n)=O(f(n)) 其中 n 為問題的規模,S(n)表示空間複雜度。
【示例】空間複雜度
def reserse(a,b): n=len(a) for i in range(n): b[i]=a[n-1-i] |
上方的程式碼中,當程式呼叫 reserse() 方法時,要分配的記憶體空間包括:引用 a、引用 b、區域性變數 n、區域性變數 i。因此 f(n)=4 ,4 為常量。所以該演算法的空間複雜度 S(n)=O(1)
通常,我們都是用"時間複雜度"來指執行時間的需求,是用"空間複雜度"指空間需求。當直接要讓我們求"複雜度"時,通常指的是時間複雜度。顯然對時間複雜度的追求更是屬於演算法的潮流!
排序演算法
排序演算法(英語:Sorting algorithm)是一種能將一串資料依照特定順序進行排列的一種演算法。
排序演算法的穩定性
穩定性:穩定排序演算法會讓原本有相等鍵值的紀錄維持相對次序。也就是如果一個排序演算法是穩定的,當有兩個相等鍵值的紀錄 R 和 S,且在原本的列表中 R 出現在 S 之前,在排序過的列表中 R 也將會是在 S 之前。
當相等的元素是無法分辨的,比如像是整數,穩定性並不是一個問題。然而,假設以下的數對將要以他們的第一個數字來排序。
(4, 1) (3, 1) (3, 7)(5, 6)
在這個狀況下,有可能產生兩種不同的結果,一個是讓相等鍵值的紀錄維持相對的次序,而另外一個則沒有:
(3, 1) (3, 7) (4, 1) (5, 6) (維持次序)
(3, 7) (3, 1) (4, 1) (5, 6) (次序被改變)
不穩定排序演算法可能會在相等的鍵值中改變紀錄的相對次序,但是穩定排序演算法從來不會如此。不穩定排序演算法可以被特別地實現為穩定。作這件事情的一個方式是人工擴充鍵值的比較,如此在其他方面相同鍵值的兩個物件間之比較,(比如上面的比較中加入第二個標準:第二個鍵值的大小)就會被決定使用在原先資料次序中的條目,當作一個同分決賽。然而,要記住這種次序通常牽涉到額外的空間負擔。
氣泡排序
氣泡排序(英語:Bubble Sort)是一種簡單的排序演算法。它重複地遍歷要排序的數列,
一次比較兩個元素,如果他們的順序錯誤就把他們交換過來。遍歷數列的工作是重複地進行直到沒有再需要交換,也就是說該數列已經排序完成。這個演算法的名字由來是因為越小的元素會經由交換慢慢"浮"到數列的頂端。
氣泡排序演算法的運作如下:
比較相鄰的元素。如果第一個比第二個大(升序),就交換他們兩個。
對每一對相鄰元素作同樣的工作,從開始第一對到結尾的最後一對。這步做完後,最後的元素會是最大的數。
針對所有的元素重複以上的步驟,除了最後一個。
持續每次對越來越少的元素重複上面的步驟,直到沒有任何一對數字需要比較。
氣泡排序的分析
那麼我們需要進行 n-1 次冒泡過程,每次對應的比較次數如下圖所示:
【示例】氣泡排序
def bubble_sort(alist): for j in range(len(alist) - 1, 0, -1): # j 表示每次遍歷需要比較的次數,是逐漸減小的 for i in range(j): if alist[i] > alist[i+1]: |
alist[i], alist[i+1] = alist[i+1], alist[i] li = [54,26,93,17,77,31,44,55,20] bubble_sort(li) print(li) |
執行結果
時間複雜度:
最優時間複雜度:O(n) (表示遍歷一次發現沒有任何可以交換的元素,排序結束。)最壞時間複雜度:O(n^2)
穩定性:穩定選擇排序
選擇排序(Selection sort)
是一種簡單直觀的排序演算法。它的工作原理如下。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然後,再從剩餘未排序元素中繼續尋找最小(大)元素,然後放到已排序序列的末尾。以此類推,直到所有元素均排序完畢。
選擇排序的主要優點與資料移動有關。如果某個元素位於正確的最終位置上,則它不會被移動。選擇排序每次交換一對元素,它們當中至少有一個將被移到其最終位置上,因此對 n 個元素的表進行排序總共進行至多 n-1 次交換。在所有的完全依靠交換去移動元素的排序方法中,選擇排序屬於非常好的一種。
排序過程:
【示例】選擇排序
def selection_sort(alist): n = len(alist) # 需要進行 n-1 次選擇操作 for i in range(n-1): # 記錄最小位置 min_index = i # 從 i+1 位置到末尾選擇出最小資料 for j in range(i+1, n): if alist[j] < alist[min_index]: min_index = j # 如果選擇出的資料不在正確位置,進行交換 if min_index != i: alist[i], alist[min_index] = alist[min_index], alist[i] |
alist = [54,226,93,17,77,31,44,55,20] selection_sort(alist) print(alist)
執行結果
時間複雜度:
最優時間複雜度:O(n2) 最壞時間複雜度:O(n2)
穩定性:不穩定(考慮升序每次選擇最大的情況)插入排序
插入排序(英語:Insertion Sort)是一種簡單直觀的排序演算法。它的工作原理是通過構建有序序列,對於未排序資料,在已排序序列中從後向前掃描,找到相應位置並插入。插入排序在實現上,在從後向前掃描過程中,需要反覆把已排序元素逐步向後挪位,為最新元素提供插入空間。
插入排序分析:
【示例】插入排序
def insert_sort(alist): n=len(alist) for j in range(1,n): i=j while i>0: if alist[i]<alist[i-1]: alist[i],alist[i-1]=alist[i-1],alist[i] else: |
break i-=1 if __name__ == '__main__': alist=[54, 226, 93, 17, 77, 31, 44, 55, 20] print('原陣列:') print(alist) print('排序後:') insert_sort(alist) print(alist) |
執行結果
時間複雜度:
最優時間複雜度:O(n) (升序排列,序列已經處於升序狀態)最壞時間複雜度:O(n2)
穩定性:穩定快速排序
快速排序(英語:Quicksort),
又稱為交換排序,通過一趟排序將要排序的資料分割為獨立的兩部分。假設要排序的列表是 A[0]……A[N-1],首先任意選取一個數據(通常選用列表的第一個數)作為基準資料,然後將所有比它小的數都放到它左邊,所有比它大的數都放到它右邊,這個過程稱為一趟快速排序。值得注意的是,快速排序不是一種穩定的排序演算法,也就是說,多個相同的值的相對位置也許會在演算法結束時產生變動。
步驟為:
- 設定兩個變數 low、high,排序開始的時候:low=0,high=N-1;
- 以第一個列表元素作為基準資料,賦值給 mid,即 mid=A[0];
-
從 high 開始向前搜尋,即由後開始向前搜尋(high--),找到第一個小於 mid 的值
A[high],將 A[hight]和 A[low]的值交換;
-
從 low 開始向後搜尋,即由前開始向後搜尋(low++),找到第一個大於 mid 的
A[low],將 A[low]和 A[high]的值交換;
- 重複第 3、4 步,直到 low=high;
【示例】快速排序
def quick_sort(alist, start, end): """快速排序""" # 遞迴的退出條件 if start >= end: return # 設定起始元素為要尋找位置的基準元素 mid = alist[start] # low 為序列左邊的由左向右移動的遊標 low = start # high 為序列右邊的由右向左移動的遊標 high = end while low < high: # 如果 low 與 high 未重合,high 指向的元素不比基準元素小,則 high 向左移動 while low < high and alist[high] >= mid: high -= 1 # 將 high 指向的元素放到 low 的位置上 alist[low] = alist[high] # 如果 low 與 high 未重合,low 指向的元素比基準元素小,則 low 向右移動 while low < high and alist[low] < mid: low += 1 # 將 low 指向的元素放到 high 的位置上 alist[high] = alist[low] # 退出迴圈後,low 與 high 重合,此時所指位置為基準元素的正確位置 # 將基準元素放到該位置 alist[low] = mid # 對基準元素左邊的子序列進行快速排序 quick_sort(alist, start, low-1) # 對基準元素右邊的子序列進行快速排序 quick_sort(alist, low+1, end) alist = [54,26,93,17,77,31,44,55,20] quick_sort(alist,0,len(alist)-1) print(alist) |
執行結果
時間複雜度:
最優時間複雜度:O(nlogn)
最壞時間複雜度:O(n2)
穩定性:不穩定歸併排序
歸併排序
是採用分治法的一個非常典型的應用。歸併排序的思想就是先遞迴分解陣列,再合併陣列。
將陣列分解最小之後,然後合併兩個有序陣列,基本思路是比較兩個陣列的最前面的數,誰小就先取誰,取了後相應的指標就往後移一位。然後再比較,直至一個數組為空,最後把另一個數組的剩餘部分複製過來即可。
【示例】歸併排序
def merge_sort(alist): if len(alist) <= 1: return alist # 二分分解 num = len(alist)//2 left = merge_sort(alist[:num]) right = merge_sort(alist[num:]) # 合併 return merge(left,right) def merge(left, right): '''合併操作,將兩個有序陣列 left[]和 right[]合併成一個大的有序陣列''' #left 與 right 的下標指標 l, r = 0, 0 result = [] while l<len(left) and r<len(right): if left[l] < right[r]: result.append(left[l]) l += 1 else: result.append(right[r]) |
r += 1 result += left[l:] result += right[r:] return result alist = [54,26,93,17,77,31,44,55] sorted_alist = merge_sort(alist) print(sorted_alist) |
執行結果
時間複雜度
最優時間複雜度:O(nlogn) 最壞時間複雜度:O(nlogn)
穩定性:穩定
查詢演算法:
順序查詢法
最基本的查詢技術,過程:從表中的第一個(或最後一個)記錄開始,逐個進行記錄的關鍵字和給定值比較,若某個記錄的關鍵字和給定值相等,則查詢成功,找到所查的記錄;如果直到最後一個(或第一個)記錄,其關鍵字和給定值比較都不等時,則表示沒有查到記錄,查詢不成功。
【示例】順序查詢法
#從 a 列表中查詢值 v,如果找到則返回第一次出現的下標,否則返回-1 def sequenceSearch(a,v): for i in range(len(a)): if a[i] == v: return i return -1 if __name__ == '__main__': a=[11,22,33,44,55,11] v=22 index=sequenceSearch(a,v) print('查詢到的索引為:',index) |
二分查詢法
二分查詢又稱折半查詢,優點是比較次數少,查詢速度快,平均效能好;其缺點是要求待查表為有序表,且插入刪除困難。因此,折半查詢方法適用於不經常變動而查詢頻繁的有序列表。首先,假設表中元素是按升序排列,將表中間位置記錄的關鍵字與查詢關鍵字比較,如果兩者相等,則查詢成功;否則利用中間位置記錄將表分成前、後 fp 兩個子表,如果中間位置記錄的關鍵字大於查詢關鍵字,則進一步查詢前一子表,否則進一步查詢後一子表。重複以上過程,直到找到滿足條件的記錄,使查詢成功,或直到子表不存在為止,此時查詢不成功。
【示例】二分查詢法(非遞迴實現)
def binary_search(alist, item): first = 0 last = len(alist) - 1 while first <= last: midpoint = (first + last) // 2 if alist[midpoint] == item: return True elif item < alist[midpoint]: last = midpoint - 1 else: first = midpoint + 1 return False testlist = [0, 1, 2, 8, 13, 17, 19, 32, 42 ] print(binary_search(testlist, 12)) print(binary_search(testlist, 13)) |
執行結果
【示例】二分查詢法(遞迴實現)
def binary_search(alist, item): if len(alist) == 0: return False else: midpoint = len(alist)//2 if alist[midpoint]==item: return True else: if item<alist[midpoint]: return binary_search(alist[:midpoint],item) else: return binary_search(alist[midpoint+1:],item) testlist = [0, 1, 2, 8, 13, 17, 19, 32, 42,] print(binary_search(testlist, 3)) print(binary_search(testlist, 13)) |
執行結果