原文地址:冰與火之歌:「時間」與「空間」複雜度算法
算法(Algorithm)是指用來操做數據、解決程序問題的一組方法。對於同一個問題,使用不一樣的算法,也許最終獲得的結果是同樣的,好比排序就有前面的十大經典排序和幾種奇葩排序,雖然結果相同,但在過程當中消耗的資源和時間卻會有很大的區別,好比快速排序與猴子排序:)。數組
那麼咱們應該如何去衡量不一樣算法之間的優劣呢?bash
主要仍是從算法所佔用的「時間」和「空間」兩個維度去考量。函數
時間維度:是指執行當前算法所消耗的時間,咱們一般用「時間複雜度」來描述。性能
空間維度:是指執行當前算法須要佔用多少內存空間,咱們一般用「空間複雜度」來描述。學習
時間的流逝宛若寒冰的融化,散發着恐懼。優化
大O表示法:算法的時間複雜度一般用大O符號表述,定義爲 **T[n] = O(f(n)) **。稱函數T(n)以f(n)爲界或者稱T(n)受限於f(n)。ui
若是一個問題的規模是n,解這一問題的某一算法所須要的時間爲T(n)。T(n)稱爲這一算法的「時間複雜度」。spa
上面公式中用到的 Landau符號是由德國數論學家保羅·巴赫曼(Paul Bachmann)在其1892年的著做《解析數論》首先引入,由另外一位德國數論學家艾德蒙·朗道(Edmund Landau)推廣。Landau符號的做用在於用簡單的函數來描述複雜函數行爲,給出一個上或下(確)界。在計算算法複雜度時通常只用到大O符號,Landau符號體系中的小o符號、Θ符號等等比較不經常使用。這裏的O,最初是用大寫希臘字母,但如今都用大寫英語字母O;小o符號也是用小寫英語字母o,Θ符號則維持大寫希臘字母Θ。設計
大O符號是一種算法「複雜度」的「相對」「表示」方式。
這個句子裏有一些重要而嚴謹的用詞:
相對(relative):你只能比較相同的事物。你不能把一個作算數乘法的算法和排序整數列表的算法進行比較。可是,比較2個算法所作的算術操做(一個作乘法,一個作加法)將會告訴你一些有意義的東西;
表示(representation):大O(用它最簡單的形式)把算法間的比較簡化爲了一個單一變量。這個變量的選擇基於觀察或假設。例如,排序算法之間的對比一般是基於比較操做(比較2個結點來決定這2個結點的相對順序)。這裏面就假設了比較操做的計算開銷很大。可是,若是比較操做的計算開銷不大,而交換操做的計算開銷很大,又會怎麼樣呢?這就改變了先前的比較方式;
複雜度(complexity):若是排序10,000個元素花費了我1秒,那麼排序1百萬個元素會花多少時間?在這個例子裏,複雜度就是相對其餘東西的度量結果。
咱們先從常見的時間複雜度量級進行大O的理解:
常數階O(1)
線性階O(n)
平方階O(n²)
對數階O(logn)
線性對數階O(nlogn)
不管代碼執行了多少行,其餘區域不會影響到操做,這個代碼的時間複雜度都是O(1)
void swapTwoInts(int &a, int &b){
int temp = a;
a = b;
b = temp;
}
複製代碼
在下面這段代碼,for循環裏面的代碼會執行 n 遍,所以它消耗的時間是隨着 n 的變化而變化的,所以能夠用O(n)來表示它的時間複雜度。
int sum ( int n ){
int ret = 0;
for ( int i = 0 ; i <= n ; i ++){
ret += i;
}
return ret;
}
複製代碼
特別一提的是 c * O(n) 中的 c 可能小於 1 ,好比下面這段代碼:
void reverse ( string &s ) {
int n = s.size();
for (int i = 0 ; i < n/2 ; i++){
swap ( s[i] , s[n-1-i]);
}
}
複製代碼
void selectionSort(int arr[],int n){
for(int i = 0; i < n ; i++){
int minIndex = i;
for (int j = i + 1; j < n ; j++ )
if (arr[j] < arr[minIndex])
minIndex = j;
swap ( arr[i], arr[minIndex]);
}
}
複製代碼
這裏簡單的推導一下
不可貴到公式:
(n - 1) + (n - 2) + (n - 3) + ... + 0
= (0 + n - 1) * n / 2
= O (n ^2)
複製代碼
固然並非全部的雙重循環都是 O(n²),好比下面這段輸出 30n 次 Hello,五分鐘學算法:)
的代碼。
void printInformation (int n ){
for (int i = 1 ; i <= n ; i++)
for (int j = 1 ; j <= 30 ; j ++)
cout<< "Hello,五分鐘學算法:)"<< endl;
}
複製代碼
int binarySearch( int arr[], int n , int target){
int l = 0, r = n - 1;
while ( l <= r) {
int mid = l + (r - l) / 2;
if (arr[mid] == target) return mid;
if (arr[mid] > target ) r = mid - 1;
else l = mid + 1;
}
return -1;
}
複製代碼
在二分查找法的代碼中,經過while循環,成 2 倍數的縮減搜索範圍,也就是說須要通過 log2^n 次便可跳出循環。
一樣的還有下面兩段代碼也是 O(logn) 級別的時間複雜度。
// 整形轉成字符串
string intToString ( int num ){
string s = "";
// n 通過幾回「除以10」的操做後,等於0
while (num ){
s += '0' + num%10;
num /= 10;
}
reverse(s)
return s;
}
複製代碼
void hello (int n ) {
// n 除以幾回 2 到 1
for ( int sz = 1; sz < n ; sz += sz)
for (int i = 1; i < n; i++)
cout<< "Hello,五分鐘學算法:)"<< endl;
}
複製代碼
將時間複雜度爲O(logn)的代碼循環N遍的話,那麼它的時間複雜度就是 n * O(logn),也就是了O(nlogn)。
void hello (){
for( m = 1 ; m < n ; m++){
i = 1;
while( i < n ){
i = i * 2;
}
}
}
複製代碼
下面來分析一波另外幾種複雜度: 遞歸算法的時間複雜度(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) 級別的。從如下兩種狀況進行分析。
int binarySearch(int arr[], int l, int r, int target){
if( l > r ) return -1;
int mid = l + (r-l)/2;
if( arr[mid] == target ) return mid;
else if( arr[mid] > target )
return binarySearch(arr, l, mid-1, target); // 左邊
else
return binarySearch(arr, mid+1, r, target); // 右邊
}
複製代碼
好比在這段二分查找法的代碼中,每次在 [ l , r ] 範圍中去查找目標的位置,若是中間的元素 arr[mid]
不是 target
,那麼判斷 arr[mid]
是比 target
大 仍是 小 ,進而再次調用 binarySearch
這個函數。
在這個遞歸函數中,每一次沒有找到target
時,要麼調用 左邊 的 binarySearch
函數,要麼調用 右邊 的 binarySearch
函數。也就是說在這次遞歸中,最多調用了一次遞歸調用而已。根據數學知識,須要log2n次才能遞歸到底。所以,二分查找法的時間複雜度爲 O(logn)。
int sum (int n) {
if (n == 0) return 0;
return n + sum( n - 1 )
}
複製代碼
在這段代碼中比較容易理解遞歸深度隨輸入 n 的增長而線性遞增,所以時間複雜度爲 O (n)。
//遞歸深度:logn
//時間複雜度:O(logn)
double pow( double x, int n){
if (n == 0) return 1.0;
double t = pow(x,n/2);
if (n %2) return x*t*t;
return t * t;
}
複製代碼
遞歸深度爲 logn
,由於是求須要除以 2 多少次才能到底。
遞歸算法中比較難計算的是屢次遞歸調用。
先看下面這段代碼,有兩次遞歸調用。
// O(2^n) 指數級別的數量級,後續動態規劃的優化點
int f(int n){
if (n == 0) return 1;
return f(n-1) + f(n - 1);
}
複製代碼
遞歸樹中節點數就是代碼計算的調用次數。
好比 當 n = 3
時,調用次數計算公式爲
1 + 2 + 4 + 8 = 15
通常的,調用次數計算公式爲
2^0 + 2^1 + 2^2 + ...... + 2^n = 2^(n+1) - 1 = O(2^n)
與之有所相似的是 歸併排序 的遞歸樹,區別點在於
n
,而 歸併排序 的遞歸樹深度爲logn
。所以,在如 歸併排序 等排序算法中,每一層處理的數據量爲 O(n) 級別,同時有 logn
層,時間複雜度即是 O(nlogn)。
動圖代表的是在數組 array 中尋找變量 x 第一次出現的位置,若沒有找到,則返回 -1;不然返回位置下標。
int find(int[] array, int n, int x) {
for ( int i = 0 ; i < n; i++) {
if (array[i] == x) {
return i;
break;
}
}
return -1;
}
複製代碼
在這裏當數組中第一個元素就是要找的 x 時,時間複雜度是 O(1);而當最後一個元素纔是 x 時,時間複雜度則是 O(n)。
最好狀況時間複雜度就是在最理想狀況下執行代碼的時間複雜度,它的時間是最短的;最壞狀況時間複雜度就是在最糟糕狀況下執行代碼的時間複雜度,它的時間是最長的。
最好、最壞時間複雜度反應的是極端條件下的複雜度,發生的機率不大,不能表明平均水平。那麼爲了更好的表示平均狀況下的算法複雜度,就須要引入平均時間複雜度。
平均狀況時間複雜度可用代碼在全部可能狀況下執行次數的加權平均值表示。
仍是以 find
函數爲例,從機率的角度看, x 在數組中每個位置的可能性是相同的,爲 1 / n。那麼,那麼平均狀況時間複雜度就能夠用下面的方式計算:
((1 + 2 + ... + n) / n + n) / 2 = (3n + 1) / 4
find
函數的平均時間複雜度爲 O(n)。
咱們經過一個動態數組的 push_back
操做來理解 均攤複雜度。
template <typename T>
class MyVector{
private:
T* data;
int size; // 存儲數組中的元素個數
int capacity; // 存儲數組中能夠容納的最大的元素個數
// 複雜度爲 O(n)
void resize(int newCapacity){
T *newData = new T[newCapacity];
for( int i = 0 ; i < size ; i ++ ){
newData[i] = data[i];
}
data = newData;
capacity = newCapacity;
}
public:
MyVector(){
data = new T[100];
size = 0;
capacity = 100;
}
// 平均複雜度爲 O(1)
void push_back(T e){
if(size == capacity)
resize(2 * capacity);
data[size++] = e;
}
// 平均複雜度爲 O(1)
T pop_back(){
size --;
return data[size];
}
};
複製代碼
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)之間。
對於一個算法,其時間複雜度和空間複雜度每每是相互影響的。
好比說,要判斷某某年是否是閏年:
這就是典型的使用空間換時間的概念。
當追求一個較好的時間複雜度時,可能會使空間複雜度的性能變差,便可能致使佔用較多的存儲空間;
反之,求一個較好的空間複雜度時,可能會使時間複雜度的性能變差,便可能致使佔用較長的運行時間。
另外,算法的全部性能之間都存在着或多或少的相互影響。所以,當設計一個算法(特別是大型算法)時,要綜合考慮算法的各項性能,算法的使用頻率,算法處理的數據量的大小,算法描述語言的特性,算法運行的機器系統環境等各方面因素,纔可以設計出比較好的算法。