算法(Algorithm)是指用來操作數據、解決程序問題的一組方法。對於同一個問題,使用不同的算法,也許最終得到的結果是一樣的,比如排序就有經典排序和幾種奇葩排序,雖然結果相同,但在過程中消耗的資源和時間卻會有很大的區別,比如快速排序與猴子排序:)。
那么我們應該如何去衡量不同算法之間的優劣呢?
主要還是從算法所占用的「時間」和「空間」兩個維度去考量。
-
時間維度:是指執行當前算法所消耗的時間,我們通常用「時間復雜度」來描述。
-
空間維度:是指執行當前算法需要占用多少內存空間,我們通常用「空間復雜度」來描述。
我們假設計算機運行一行基礎代碼需要執行一次運算。
1 int aFunc(void) { 2 printf("Hello, World!\n"); // 需要執行 1 次 3 return 0; // 需要執行 1 次 4 }
那么上面這個方法需要執行 2 次運算
1 int aFunc(int n) { 2 for(int i = 0; i<n; i++) { // 需要執行 (n + 1) 次 3 printf("Hello, World!\n"); // 需要執行 n 次 4 } 5 return 0; // 需要執行 1 次 6 }
這個方法需要 (n + 1 + n + 1) = 2n + 2 次運算。
我們把 算法需要執行的運算次數 用 輸入大小n 的函數 表示,即 T(n) 。
此時為了 估算算法需要的運行時間 和 簡化算法分析,我們引入時間復雜度的概念。
定義:存在常數 c 和函數 f(N),使得當 N >= c 時 T(N) <= f(N),表示為 T(n) = O(f(n)) 。
如圖:
大O符號(Big O notation)是用於描述函數漸進行為的數學符號。更確切地說,它是用另一個(通常更簡單的)函數來描述一個函數數量級(Orders of magnitude)的漸近上界。在數學中,它一般用來刻畫被截斷的無窮級數尤其是漸近級數的剩余項;在計算機科學中,它在分析算法復雜性的方面非常有用。
如圖:
當 N >= 2 的時候,f(n) = n^2 總是大於 T(n) = n + 2 的,於是我們說 f(n) 的增長速度是大於或者等於 T(n) 的,也說 f(n) 是 T(n) 的上界,可以表示為 T(n) = O(f(n))。
因為f(n) 的增長速度是大於或者等於 T(n) 的,即T(n) = O(f(n)),所以我們可以用 f(n) 的增長速度來度量 T(n) 的增長速度,所以我們說這個算法的時間復雜度是 O(f(n))。
算法的時間復雜度,用來度量算法的運行時間,記作: T(n) = O(f(n))。它表示隨着 輸入大小n 的增大,算法執行需要的時間的增長速度可以用 f(n) 來描述。
那么當我們拿到算法的執行次數函數 T(n) 之后怎么得到算法的時間復雜度呢?
- 我們知道常數項對函數的增長速度影響並不大,所以當 T(n) = c,c 為一個常數的時候,我們說這個算法的時間復雜度為 O(1);如果 T(n) 不等於一個常數項時,直接將常數項省略。
1 比如 2 第一個 Hello, World 的例子中 T(n) = 2,所以我們說那個函數(算法)的時間復雜度為 O(1)。 3 T(n) = n + 29,此時時間復雜度為 O(n)。
- 我們知道高次項對於函數的增長速度的影響是最大的。n^3 的增長速度是遠超 n^2 的,同時 n^2 的增長速度是遠超 n 的。 同時因為要求的精度不高,所以我們直接忽略低此項。
1 比如 2 T(n) = n^3 + n^2 + 29,此時時間復雜度為 O(n^3)。
- 因為函數的階數對函數的增長速度的影響是最顯著的,所以我們忽略與最高階相乘的常數。
1 比如 2 T(n) = 3n^3,此時時間復雜度為 O(n^3)。
綜合起來:如果一個算法的執行次數是 T(n),那么只保留最高次項,同時忽略最高項的系數后得到函數 f(n),此時算法的時間復雜度就是 O(f(n))。為了方便描述,下文稱此為 大O推導法。
由此可見,由執行次數 T(n) 得到時間復雜度並不困難,很多時候困難的是從算法通過分析和數學運算得到 T(n)。對此,提供下列四個便利的法則,這些法則都是可以簡單推導出來的,總結出來以便提高效率。
- 對於一個循環,假設循環體的時間復雜度為 O(n),循環次數為 m,則這個
循環的時間復雜度為 O(n×m)。
1 void aFunc(int n) { 2 for(int i = 0; i < n; i++) { // 循環次數為 n 3 printf("Hello, World!\n"); // 循環體時間復雜度為 O(1) 4 } 5 }
此時時間復雜度為 O(n × 1),即 O(n)。
- 對於多個循環,假設循環體的時間復雜度為 O(n),各個循環的循環次數分別是a, b, c...,則這個循環的時間復雜度為 O(n×a×b×c...)。分析的時候應該由里向外分析這些循環。
1 void aFunc(int n) { 2 for(int i = 0; i < n; i++) { // 循環次數為 n 3 for(int j = 0; j < n; j++) { // 循環次數為 n 4 printf("Hello, World!\n"); // 循環體時間復雜度為 O(1) 5 } 6 } 7 }
此時時間復雜度為 O(n × n × 1),即 O(n^2)。
- 對於順序執行的語句或者算法,總的時間復雜度等於其中最大的時間復雜度。
1 void aFunc(int n) { 2 // 第一部分時間復雜度為 O(n^2) 3 for(int i = 0; i < n; i++) { 4 for(int j = 0; j < n; j++) { 5 printf("Hello, World!\n"); 6 } 7 } 8 // 第二部分時間復雜度為 O(n) 9 for(int j = 0; j < n; j++) { 10 printf("Hello, World!\n"); 11 } 12 }
此時時間復雜度為 max(O(n^2), O(n)),即 O(n^2)。
- 對於條件判斷語句,總的時間復雜度等於其中 時間復雜度最大的路徑 的時間復雜度。
1 void aFunc(int n) { 2 if (n >= 0) { 3 // 第一條路徑時間復雜度為 O(n^2) 4 for(int i = 0; i < n; i++) { 5 for(int j = 0; j < n; j++) { 6 printf("輸入數據大於等於零\n"); 7 } 8 } 9 } else { 10 // 第二條路徑時間復雜度為 O(n) 11 for(int j = 0; j < n; j++) { 12 printf("輸入數據小於零\n"); 13 } 14 } 15 }
此時時間復雜度為 max(O(n^2), O(n)),即 O(n^2)。
時間復雜度分析的基本策略是:從內向外分析,從最深層開始分析。如果遇到函數調用,要深入函數進行分析。
對數是對求冪的逆運算,正如除法是乘法的倒數,反之亦然。


-
特別地,我們稱以10為底的對數叫做 常用對數(common logarithm),並記為lg。
-
稱以無理數e(e=2.71828...)為底的對數稱為 自然對數(natural logarithm),並記為ln。
-
零沒有對數。
若算法的T(n) =O(logn),則稱其具有對數時間。由於計算機使用二進制的記數系統,對數常常以2為底(即log2n,有時寫作lgn)。然而,由對數的換底公式,logan和logbn只有一個常數因子不同,這個因子在大O記法中被丟棄。因此記作O(logn),而不論對數的底是多少,是對數時間算法的標准記法。
我們來練習一下
一. 基礎題
求該方法的時間復雜度
1 void aFunc(int n) { 2 for (int i = 0; i < n; i++) { 3 for (int j = i; j < n; j++) { 4 printf("Hello World\n"); 5 } 6 } 7 }
參考答案:
當 i = 0 時,內循環執行 n 次運算,當 i = 1 時,內循環執行 n - 1 次運算……當 i = n - 1 時,內循環執行 1 次運算。
所以,執行次數 T(n) = n + (n - 1) + (n - 2)……+ 1 = n(n + 1) / 2 = n^2 / 2 + n / 2。
根據上文說的 大O推導法 可以知道,此時時間復雜度為 O(n^2)。
二. 進階題
求該方法的時間復雜度
1 void aFunc(int n) { 2 for (int i = 2; i < n; i++) { 3 i *= 2; 4 printf("%i\n", i); 5 } 6 }
參考答案:
假設循環次數為 t,則循環條件滿足 2^t < n。
可以得出,執行次數t = log(2)(n),即 T(n) = log(2)(n),可見時間復雜度為 O(log(2)(n)),即 O(log n)。
三. 再次進階
求該方法的時間復雜度
1 long aFunc(int n) { 2 if (n <= 1) { 3 return 1; 4 } else { 5 return aFunc(n - 1) + aFunc(n - 2); 6 } 7 }
參考答案:
顯然運行次數,T(0) = T(1) = 1,同時 T(n) = T(n - 1) + T(n - 2) + 1,這里的 1 是其中的加法算一次執行。
顯然 T(n) = T(n - 1) + T(n - 2) 是一個斐波那契數列,通過歸納證明法可以證明,當 n >= 1 時 T(n) < (5/3)^n,同時當 n > 4 時 T(n) >= (3/2)^n。
所以該方法的時間復雜度可以表示為 O((5/3)^n),簡化后為 O(2^n)。
可見這個方法所需的運行時間是以指數的速度增長的。
常見的時間復雜度量級:
-
常數階O(1)
-
線性階O(n)
-
平方階O(n²)
-
對數階O(logn)
-
線性對數階O(nlogn)
O(1):
無論代碼執行了多少行,其他區域不會影響到操作,這個代碼的時間復雜度都是O(1)
1 void swapTwoInts(int &a, int &b){ 2 int temp = a; 3 a = b; 4 b = temp; 5 }
O(n):
在下面這段代碼,for循環里面的代碼會執行 n 遍,因此它消耗的時間是隨着 n 的變化而變化的,因此可以用O(n)來表示它的時間復雜度。
1 int sum ( int n ){ 2 int ret = 0; 3 for ( int i = 0 ; i <= n ; i ++){ 4 ret += i; 5 } 6 return ret; 7 }
特別一提的是 c * O(n) 中的 c 可能小於 1 ,比如下面這段代碼:
1 void reverse ( string &s ) { 2 int n = s.size(); 3 for (int i = 0 ; i < n/2 ; i++){ 4 swap ( s[i] , s[n-1-i]); 5 } 6 }
O(n²):
當存在雙重循環的時候,即把 O(n) 的代碼再嵌套循環一遍,它的時間復雜度就是 O(n²) 了。
1 void selectionSort(int arr[],int n){ 2 for(int i = 0; i < n ; i++){ 3 int minIndex = i; 4 for (int j = i + 1; j < n ; j++ ) 5 if (arr[j] < arr[minIndex]) 6 minIndex = j; 7 8 swap ( arr[i], arr[minIndex]); 9 } 10 }
這里簡單的推導一下
- 當 i = 0 時,第二重循環需要運行 (n - 1) 次
- 當 i = 1 時,第二重循環需要運行 (n - 2) 次
- 。。。。。。
不難得到公式:
(n - 1) + (n - 2) + (n - 3) + ... + 0
= (0 + n - 1) * n / 2
= O (n ^2)
當然並不是所有的雙重循環都是 O(n²),比如下面這段輸出 30n 次 Hello,world:)
的代碼。
1 void printInformation (int n ){ 2 for (int i = 1 ; i <= n ; i++) 3 for (int j = 1 ; j <= 30 ; j ++) 4 cout<< "Hello,world:)"<< endl; 5 }
O(logn):
1 int binarySearch( int arr[], int n , int target){ 2 int l = 0, r = n - 1; 3 while ( l <= r) { 4 int mid = l + (r - l) / 2; 5 if (arr[mid] == target) return mid; 6 if (arr[mid] > target ) r = mid - 1; 7 else l = mid + 1; 8 } 9 return -1; 10 }
在二分查找法的代碼中,通過while循環,成 2 倍數的縮減搜索范圍,也就是說需要經過 log2^n 次即可跳出循環。
同樣的還有下面兩段代碼也是 O(logn) 級別的時間復雜度。
1 // 整形轉成字符串 2 string intToString ( int num ){ 3 string s = ""; 4 // n 經過幾次“除以10”的操作后,等於0 5 while (num ){ 6 s += '0' + num%10; 7 num /= 10; 8 } 9 reverse(s) 10 return s; 11 } 12 void hello (int n ) { 13 // n 除以幾次 2 到 1 14 for ( int sz = 1; sz < n ; sz += sz) 15 for (int i = 1; i < n; i++) 16 cout<< "Hello,五分鍾學算法:)"<< endl; 17 }
O(nlogn):
將時間復雜度為O(logn)的代碼循環N遍的話,那么它的時間復雜度就是 n * O(logn),也就是了O(nlogn)。
1 void hello (){ 2 for( m = 1 ; m < n ; m++){ 3 i = 1; 4 while( i < n ){ 5 i = i * 2; 6 } 7 } 8 }
不常見的時間復雜度
下面來分析一波另外幾種復雜度: 遞歸算法的時間復雜度(recursive algorithm time complexity),最好情況時間復雜度(best case time complexity)、最壞情況時間復雜度(worst case time complexity)、平均時間復雜度(average case time complexity)和均攤時間復雜度(amortized time complexity)。
遞歸算法的時間復雜度
如果遞歸函數中,只進行一次遞歸調用,遞歸深度為depth;
在每個遞歸的函數中,時間復雜度為T;
則總體的時間復雜度為O(T * depth)。
在前面的學習中,歸並排序 與 快速排序 都帶有遞歸的思想,並且時間復雜度都是O(nlogn) ,但並不是有遞歸的函數就一定是 O(nlogn) 級別的。從以下兩種情況進行分析。
① 遞歸中進行一次遞歸調用的復雜度分析
二分查找法
1 int binarySearch(int arr[], int l, int r, int target){ 2 if( l > r ) return -1; 3 4 int mid = l + (r-l)/2; 5 if( arr[mid] == target ) return mid; 6 else if( arr[mid] > target ) 7 return binarySearch(arr, l, mid-1, target); // 左邊 8 else 9 return binarySearch(arr, mid+1, r, target); // 右邊 10 }
比如在這段二分查找法的代碼中,每次在 [ l , r ] 范圍中去查找目標的位置,如果中間的元素 arr[mid]
不是 target
,那么判斷 arr[mid]
是比 target
大 還是 小 ,進而再次調用 binarySearch
這個函數。
在這個遞歸函數中,每一次沒有找到target
時,要么調用 左邊 的 binarySearch
函數,要么調用 右邊 的 binarySearch
函數。也就是說在此次遞歸中,最多調用了一次遞歸調用而已。根據數學知識,需要log2n次才能遞歸到底。因此,二分查找法的時間復雜度為 O(logn)。
求和:
1 int sum (int n) { 2 if (n == 0) return 0; 3 return n + sum( n - 1 ) 4 }
在這段代碼中比較容易理解遞歸深度隨輸入 n 的增加而線性遞增,因此時間復雜度為 O (n)。
求冪:
1 //遞歸深度:logn 2 //時間復雜度:O(logn) 3 double pow( double x, int n){ 4 if (n == 0) return 1.0; 5 6 double t = pow(x,n/2); 7 if (n %2) return x*t*t; 8 return t * t; 9 }
遞歸深度為 logn
,因為是求需要除以 2 多少次才能到底。
② 遞歸中進行多次遞歸調用的復雜度分析
遞歸算法中比較難計算的是多次遞歸調用。
先看下面這段代碼,有兩次遞歸調用。
1 // O(2^n) 指數級別的數量級,后續動態規划的優化點 2 int f(int n){ 3 if (n == 0) return 1; 4 return f(n-1) + f(n - 1); 5 }
遞歸樹中節點數就是代碼計算的調用次數。
比如 當 n = 3
時,調用次數計算公式為
1 + 2 + 4 + 8 = 15
一般的,調用次數計算公式為
20 + 21 + 22 + …… + 2n
= 2(n+1) - 1
= O(2n)
與之有所類似的是 歸並排序 的遞歸樹,區別點在於
-
- 上述例子中樹的深度為
n
,而 歸並排序 的遞歸樹深度為logn
。
- 上述例子中樹的深度為
-
- 上述例子中每次處理的數據規模是一樣的,而在 歸並排序 中每個節點處理的數據規模是逐漸縮小的
因此,在如 歸並排序 等排序算法中,每一層處理的數據量為 O(n) 級別,同時有 logn
層,時間復雜度便是 O(nlogn)。
最好、最壞情況時間復雜度
最好、最壞情況時間復雜度指的是特殊情況下的時間復雜度。
動圖表明的是在數組 array 中尋找變量 x 第一次出現的位置,若沒有找到,則返回 -1;否則返回位置下標。
1 int find(int[] array, int n, int x) { 2 for ( int i = 0 ; i < n; i++) { 3 if (array[i] == x) { 4 return i; 5 break; 6 } 7 } 8 return -1; 9 }
在這里當數組中第一個元素就是要找的 x 時,時間復雜度是 O(1);而當最后一個元素才是 x 時,時間復雜度則是 O(n)。
最好情況時間復雜度就是在最理想情況下執行代碼的時間復雜度,它的時間是最短的;最壞情況時間復雜度就是在最糟糕情況下執行代碼的時間復雜度,它的時間是最長的。
平均情況時間復雜度
最好、最壞時間復雜度反應的是極端條件下的復雜度,發生的概率不大,不能代表平均水平。那么為了更好的表示平均情況下的算法復雜度,就需要引入平均時間復雜度。
平均情況時間復雜度可用代碼在所有可能情況下執行次數的加權平均值表示。
還是以 find
函數為例,從概率的角度看, x 在數組中每一個位置的可能性是相同的,為 1 / n。那么,那么平均情況時間復雜度就可以用下面的方式計算:
((1 + 2 + … + n) / n + n) / 2 = (3n + 1) / 4
find
函數的平均時間復雜度為 O(n)。
均攤復雜度分析
我們通過一個動態數組的 push_back
操作來理解 均攤復雜度。
1 template <typename T> 2 class MyVector{ 3 private: 4 T* data; 5 int size; // 存儲數組中的元素個數 6 int capacity; // 存儲數組中可以容納的最大的元素個數 7 // 復雜度為 O(n) 8 void resize(int newCapacity){ 9 T *newData = new T[newCapacity]; 10 for( int i = 0 ; i < size ; i ++ ){ 11 newData[i] = data[i]; 12 } 13 data = newData; 14 capacity = newCapacity; 15 } 16 public: 17 MyVector(){ 18 data = new T[100]; 19 size = 0; 20 capacity = 100; 21 } 22 // 平均復雜度為 O(1) 23 void push_back(T e){ 24 if(size == capacity) 25 resize(2 * capacity); 26 data[size++] = e; 27 } 28 // 平均復雜度為 O(1) 29 T pop_back(){ 30 size --; 31 return data[size]; 32 } 33 34 };
push_back
實現的功能是往數組的末尾增加一個元素,如果數組沒有滿,直接往后面插入元素;如果數組滿了,即 size == capacity
,則將數組擴容一倍,然后再插入元素。
例如,數組長度為 n,則前 n 次調用 push_back
復雜度都為 O(1) 級別;在第 n + 1 次則需要先進行 n 次元素轉移操作,然后再進行 1 次插入操作,復雜度為 O(n)。
因此,平均來看:對於容量為 n 的動態數組,前面添加元素需要消耗了 1 * n 的時間,擴容操作消耗 n 時間 ,
總共就是 2 * n 的時間,因此均攤時間復雜度為 O(2n / n) = O(2),也就是 O(1) 級別了。
可以得出一個比較有意思的結論:一個相對比較耗時的操作,如果能保證它不會每次都被觸發,那么這個相對比較耗時的操作,它所相應的時間是可以分攤到其它的操作中來的。
空間復雜度
一個程序的空間復雜度是指運行完一個程序所需內存的大小。利用程序的空間復雜度,可以對程序的運行所需要的內存多少有個預先估計。一個程序執行時除了需要存儲空間和存儲本身所使用的指令、常數、變量和輸入數據外,還需要一些對數據進行操作的工作單元和存儲一些為現實計算所需信息的輔助空間。程序執行時所需存儲空間包括以下兩部分:
(1) 固定部分,這部分空間的大小與輸入/輸出的數據的個數多少、數值無關。主要包括指令空間(即代碼空間)、數據空間(常量、簡單變量)等所占的空間。這部分屬於靜態空間。
(2) 可變空間,這部分空間的主要包括動態分配的空間,以及遞歸棧所需的空間等。這部分的空間大小與算法有關。
一個算法所需的存儲空間用f(n)表示。S(n)=O(f(n)),其中n為問題的規模,S(n)表示空間復雜度。
空間復雜度可以理解為除了原始序列大小的內存,在算法過程中用到的額外的存儲空間。
以二叉查找樹為例,舉例說明二叉排序樹的查找性能。
平衡二叉樹
如果二叉樹的是以紅黑樹等平衡二叉樹實現的,則 n 個節點的二叉排序樹的高度為 log2n+1 ,其查找效率為O(Log2n),近似於折半查找。
列表二叉樹
如果二叉樹退變為列表了,則 n 個節點的高度或者說是長度變為了n,查找效率為O(n),變成了順序查找
一般二叉樹
介於「列表二叉樹」與「平衡二叉樹」之間,查找性能也在O(Log2n)到O(n)之間。
總結:
對於一個算法,其時間復雜度和空間復雜度往往是相互影響的。
比如說,要判斷某某年是不是閏年:
-
- 可以編寫一個算法來計算,這也就意味着,每次給一個年份,都是要通過計算得到是否是閏年的結果。
-
- 還有另一個辦法就是,事先建立一個有 5555 個元素的數組(年數比現實多就行),然后把所有的年份按下標的數字對應,如果是閏年,此數組項的值就是1,如果不是值為0。這樣,所謂的判斷某一年是否是閏年,就變成了查找這個數組的某一項的值是多少的問題。此時,我們的運算是最小化了,但是硬盤上或者內存中需要存儲這 5555 個 0 和 1 。
這就是典型的使用空間換時間的概念。
當追求一個較好的時間復雜度時,可能會使空間復雜度的性能變差,即可能導致占用較多的存儲空間;
反之,求一個較好的空間復雜度時,可能會使時間復雜度的性能變差,即可能導致占用較長的運行時間。
另外,算法的所有性能之間都存在着或多或少的相互影響。因此,當設計一個算法(特別是大型算法)時,要綜合考慮算法的各項性能,算法的使用頻率,算法處理的數據量的大小,算法描述語言的特性,算法運行的機器系統環境等各方面因素,才能夠設計出比較好的算法。
參考:
0.https://mp.weixin.qq.com/s/1rYK3urLuun5WqnibJ2t3g
1.https://www.jianshu.com/p/f4cca5ce055a
2.https://cxyxiaowu.com/articles/2019/04/04/1554345342924.html
3.https://cxyxiaowu.com/articles/2019/04/04/1554345100970.html
4.https://cxyxiaowu.com/articles/2019/04/04/1554345130826.html
5.https://www.jianshu.com/p/89f4f0831d6c
6.https://www.cnblogs.com/gaochundong/p/complexity_of_algorithms.html
7.https://www.zhihu.com/question/21387264
8.https://blog.csdn.net/CrankZ/article/details/84726408