一、第三章簡單回顧
中間略過了第三章, 第三章主要是介紹如何從數學層面上科學地定義算法復雜度,以致於能夠以一套公有的標准來分析算法。其中,我認為只要記住三個符號就可以了,其他的就看個人情況,除非你需要對一個算法剖根問底,不然還真用不到,我們只需有個印象,知道這玩意是用來分析算法性能的。三個量分別是:確定一個函數漸近上界的Ο符號,漸近下屆Ω符號,以及漸近緊確界Θ符號,這是在分析一個算法的界限時常用的分析方法,具體的就詳看書本了,對於我們更多關注上層算法的表達來說,這些顯得不是那么重要,我的理解是Ο可以簡單看成最壞運行時間,Ω是最好運行時間,Θ是平均運行時間。一般我們在寫一個算法的運行時間時,大多是以Θ符號來表示。參考下面這幅經典的圖:
二、第四章兩大板塊
第四章講遞歸,也是數學的東西太多了,我准備這樣來組織這章的結構:先用一個例子(最大子數組和)來講解用到遞歸的一個經典方法——分治法,然后在引入如何解遞歸式,即引入解遞歸式的三種方法。
1、由分治法引發的
這一章提出了一個在現在各大IT公司在今天依然很喜歡考的一道筆試面試題:
求連續子數組的最大和 題目描述: 輸入一個整形數組,數組里有正數也有負數。 數組中連續的一個或多個整數組成一個子數組,每個子數組都有一個和。 求所有子數組的和的最大值。要求時間復雜度為O(n)。 例如輸入的數組為1, -2, 3, 10, -4, 7, 2, -5,和最大的子數組為3, 10, -4, 7, 2, 因此輸出為該子數組的和18。
要求時間復雜度是O(n),我們暫且不管這個,由淺入深地分析一下這道題,時間復雜度從O(n^2)->O(nlgn)->O(n)。
1)、第一,大部分人想到的肯定是暴力法,兩個for循環,時間復雜度自然是O(n^2),如下:
1 /************************************************************************/ 2 /* 暴力法 3 /************************************************************************/ 4 void MaxSubArraySum_Force(int arr[], vector<int> &subarr, int len) 5 { 6 if (len == 0) 7 return; 8 int nMax = INT_MIN; 9 int low = 0, high = 0; 10 for (int i = 0; i < len; i ++) { 11 int nSum = 0; 12 for (int j = i; j < len; j ++) { 13 nSum += arr[j]; 14 if (nSum > nMax) { 15 nMax = nSum; 16 low = i; 17 high = j; 18 } 19 } 20 } 21 for (int i = low; i <= high; i ++) { 22 subarr.push_back(arr[i]); 23 } 24 }
2)、第二,看了《算法導論》,你可能會想到分治法,看完之后你肯定會為該分治思想而驚嘆,尤其是“橫跨中點”的計算思想。簡單說下該分治思想,其實很簡單,最大和子數組無非有三種情況:左邊,右邊,中間。
時間復雜度分析:
根據分治的思想,時間復雜度的計算包括三部分:兩邊+中間。由於分治的依托就是遞歸,我們可以寫出下面的遞推式(和合並排序的遞推式是一樣的):
其中的Θ(n)為處理最大和在數組中間時的情況,經過計算(怎么計算的,請看本節第二部分:解分治法的三種方法),可以得到分治法的時間復雜度為Θ(nlgn)。代碼如下:
1 /************************************************************************/ 2 /* 分治法 3 最大和子數組有三種情況: 4 1)A[1...mid] 5 2)A[mid+1...N] 6 3)A[i..mid..j] 7 /************************************************************************/ 8 //find max crossing left and right 9 int Find_Max_Crossing_Subarray(int arr[], int low, int mid, int high) 10 { 11 const int infinite = -9999; 12 int left_sum = infinite; 13 int right_sum = infinite; 14 15 int max_left = -1, max_right = -1; 16 17 int sum = 0; //from mid to left; 18 for (int i = mid; i >= low; i --) { 19 sum += arr[i]; 20 if (sum > left_sum) { 21 left_sum = sum; 22 max_left = i; 23 } 24 } 25 sum = 0; //from mid to right 26 for (int j = mid + 1; j <= high; j ++) { 27 sum += arr[j]; 28 if (sum > right_sum) { 29 right_sum = sum; 30 max_right = j; 31 } 32 } 33 return (left_sum + right_sum); 34 } 35 36 int Find_Maximum_Subarray(int arr[], int low, int high) 37 { 38 if (high == low) //only one element; 39 return arr[low]; 40 else { 41 int mid = (low + high)/2; 42 int leftSum = Find_Maximum_Subarray(arr, low, mid); 43 int rightSum = Find_Maximum_Subarray(arr, mid+1, high); 44 int crossSum = Find_Max_Crossing_Subarray(arr, low, mid, high); 45 46 if (leftSum >= rightSum && leftSum >= crossSum) 47 return leftSum; 48 else if (rightSum >= leftSum && rightSum >= crossSum) 49 return rightSum; 50 else 51 return crossSum; 52 } 53 }
3)、第三,看了《算法導論》習題4.1-5,你又有了另外一種思路:數組A[1...j+1]的最大和子數組,有兩種情況:a) A[1...j]的最大和子數組; b) 某個A[i...j+1]的最大和子數組,假設你現在不知道動態規划,這種方法也許會讓你眼前一亮,確實是這么回事,恩,看代碼吧。時間復雜度不用想,肯定是O(n)。和暴力法比起來,我們的改動僅僅是用一個指針指向某個使和小於零的子數組的左區間(當和小於零時,區間向左減小,當和在增加時,區間向右增大)。因此,我們給這種方法取個名字叫區間法。
1 /************************************************************************/ 2 /* 區間法 3 求A[1...j+1]的最大和子數組,有兩種情況: 4 1)A[1...j]的最大和子數組 5 2)某個A[i...j+1]的最大和子數組 6 /************************************************************************/ 7 void MaxSubArraySum_Greedy(int arr[], vector<int> &subarr, int len) 8 { 9 if (len == 0) 10 return; 11 int nMax = INT_MIN; 12 int low = 0, high = 0; 13 int cur = 0; //一個指針更新子數組的左區間 14 int nSum = 0; 15 for (int i = 0; i < len; i ++) { 16 nSum += arr[i]; 17 if (nSum > nMax) { 18 nMax = nSum; 19 low = cur; 20 high = i; 21 } 22 if (nSum < 0) { 23 cur += 1; 24 nSum = 0; 25 } 26 } 27 for (int i = low; i <= high; i ++) 28 subarr.push_back(arr[i]); 29 }
第四,你可能在平常的學習過程中,聽說過該問題最經典的解是用動態規划來解,等你學習之后,你發現確實是這樣,然后你又一次為之驚嘆。動態規划算法最主要的是尋找遞推關系式,大概思想是這樣的:數組A[1...j+1]的最大和:要么是A[1...j]+A[j+1]的最大和,要么是A[j+1],據此,可以很容易寫出其遞推式為:
sum[i+1] = Max(sum[i] + A[i+1], A[i+1])
化簡之后,其實就是比較sum[i] ?> 0(sum[i] + A[i+1] ?> A[i+1]),由此,就很容易寫出代碼如下:
1 /************************************************************************/ 2 /* 動態規划(對應着上面的貪心法看,略有不同) 3 求A[1...j+1]的最大和子數組,有兩種情況: 4 1)A[1...j]+A[j+1]的最大和子數組 5 2)A[j+1] 6 dp遞推式: 7 sum[j+1] = max(sum[j] + A[j+1], A[j+1]) 8 /************************************************************************/ 9 int MaxSubArraySum_dp(int arr[], int len) 10 { 11 if (len <= 0) 12 exit(-1); 13 int nMax = INT_MIN; 14 int sum = 0; 15 16 for (int i = 0; i < len; i ++) { 17 if (sum >= 0) 18 sum += arr[i]; 19 else 20 sum = arr[i]; 21 if (sum > nMax) 22 nMax = sum; 23 } 24 return nMax; 25 }
可以看出,區間法和動態規划有幾分相似,我覺得兩種方法的出發點和終點都是一致的,只不過過程不同。動態規划嚴格遵循遞推式,而區間法是尋找使區間變化的標識,即和是否小於零,而這個標識正是動態規划采用的。
由於光這一部分就已經寫得足夠長了,為了方便閱讀,所以本節第二部分:解遞歸式的三種方法 轉 算法導論第四章編程實踐(二)。
我的公眾號 「Linux雲計算網絡」(id: cloud_dev),號內有 10T 書籍和視頻資源,后台回復 「1024」 即可領取,分享的內容包括但不限於 Linux、網絡、雲計算虛擬化、容器Docker、OpenStack、Kubernetes、工具、SDN、OVS、DPDK、Go、Python、C/C++編程技術等內容,歡迎大家關注。