1. 什麼是空間複雜度
在運行一段程序時,我們不僅要執行各種運算指令,同時也會根據需要,存儲一些臨時的中間數據 ,以便後續指令可以更方便地繼續執行。
在什麼情況下需要這些中間數據呢?讓我們來看看下面的例子。
給出下圖所示的n個整數,其中有兩個整數是重複的,要求找出這兩個重複的整數。
對於這個簡單的需求,可以用很多種思路來解決,其中最樸素的方法就是雙重循環,具體如下。
遍歷整個數列,每遍歷到一個新的整數就開始回顧之前遍歷過的所有整數,看看這些整數裏有沒有與之數值相同的。
第1步,遍歷整數3,前面沒有數字,所以無須回顧比較。
第2步,遍歷整數1,回顧前面的數字3,沒有發現重複數字。
第3步,遍歷整數2,回顧前面的數字3、1,沒有發現重複數字。
後續步驟類似,一直遍歷到最後的整數2,發現和前面的整數2重複。
雙重循環雖然可以得到最終結果,但它顯然並不是一個好的算法。
它的時間複雜度是多少呢?
我們不難得出結論,這個算法的時間複雜度是O(n2) 。
如何利用中間數據呢?
當遍歷整個數列時,每遍歷一個整數,就把該整數存儲起來,就像放到字典中一樣。當遍歷下一個整數時,不必再慢慢向前回溯比較,而直接去“字典”中查找,看看有沒有對應的整數即可。
假如已經遍歷了數列的前7個整數,那麼字典裏存儲的信息如下。
“字典”左側的Key代表整數的值,“字典”右側的Value代表該整數出現的次數(也可以只記錄Key)。
接下來,當遍歷到最後一個整數2時,從“字典”中可以輕鬆找到2曾經出現過,問題也就迎刃而解了。
由於讀寫“字典”本身的時間複雜度是O(1) ,所以整個算法的時間複雜度是O(n) ,和最初的雙重循環相比,運行效率大大提高了。
而這個所謂的“字典”,是一種特殊的數據結構,叫作散列表 。這個數據結構需要開闢一定的內存空間來存儲有用的數據信息。
但是,內存空間是有限的,在時間複雜度相同的情況下,算法佔用的內存空間自然是越小越好。如何描述一個算法佔用的內存空間的大小呢?這就用到了算法的另一個重要指標——空間複雜度(space complexity)。
2. 空間複雜度的計算
常見的空間複雜度有下面幾種情形。
這回不弄喫的栗子啦~
1. 常量空間
當算法的存儲空間大小固定,和輸入規模沒有直接的關係時,空間複雜度記作O(1) 。
例如下面這段程序:
inline void fun1(int n){
int num = 3;
…
}
2.線性空間
當算法分配的空間是一個線性的集合(如數組),並且集合大小和輸入規模n成正比時,空間複雜度記作O(n) 。
例如下面這段程序:
inline void fun2(int n){
int arr[n];
…
}
3. 二維空間
當算法分配的空間是一個二維數組集合,並且集合的長度和寬度都與輸入規模n成正比時,空間複雜度記作O(n 2 ) 。
例如下面這段程序:
inline void fun3(int n){
int arr[n][n];
…
}
4. 遞歸空間
遞歸是一個比較特殊的場景。雖然遞歸代碼中並沒有顯式地聲明變量或集合,但是計算機在執行程序時,會專門分配一塊內存,用來存儲“調用棧”。
當函數返回時,執行出棧操作,把調用的信息從棧中彈出。
下面這段程序是一個標準的遞歸程序:
inline void fun4(int n){
if(n <= 1) return;
f(n - 1);
…
}
假如初始傳入參數值n=5,那麼方法fun4(參數n=5)的調用信息先入棧。
接下來遞歸調用相同的方法,方法fun4(參數n=4)的調用信息入棧。
以此類推,遞歸越來越深,入棧的元素就越來越多。
當n=1時,達到遞歸結束條件,執行return指令,進行出棧。
最終,“調用棧”的全部元素會一一出棧。
由上面“調用棧”的出入棧過程可以看出,執行遞歸操作所需要的內存空間∝遞歸的深度。純粹的遞歸操作的空間複雜度也是線性的,如果遞歸的深度是n,那麼空間複雜度就是O(n) 。
3. 時間與空間的取捨
人們之所以花大力氣去評估算法的時間複雜度和空間複雜度,其根本原因是計算機的運算速度和空間資源是有限的。
就如一個大財主,基本不必爲日常花銷傷腦筋;而一個沒多少積蓄的普通人,則不得不爲日常花銷精打細算。
對於計算機系統來說也是如此。雖然目前計算機的CPU處理速度不斷飆升,內存和硬盤空間也越來越大,但是面對龐大而複雜的數據和業務,我們仍然要精打細算,選擇最有效的利用方式。
但是,正所謂魚和熊掌不可兼得。很多時候,我們不得不在時間複雜度和空間複雜度之間進行取捨。
👇雙重循環的時間複雜度是O(n2),空間複雜度是O(1),這就屬於犧牲時間來換取空間的情況。
👇相反,字典法的空間複雜度是O(n),時間複雜度是O(n),這屬於犧牲空間來換取時間 的情況。
但在絕大多數時候,時間複雜度更爲重要一些,我們寧可多分配一些內存空間,也要提升程序的執行速度。