概要
本文是王爭的演算法訓練營《第一講 複雜度分析》的學習筆記,分享了時間複雜度的由來,大 O 時間複雜度表示法,幾種常見的時間複雜度量級,最好、最壞、平均時間複雜度,均攤時間複雜度和攤還分析,空間複雜度分析等等
目錄
- 時間複雜度的由來
- 大 O 時間複雜度表示法
- 幾種常見的時間複雜度量級
- 最好、最壞、平均時間複雜度
- 均攤時間複雜度和攤還分析
- 空間複雜度分析
時間複雜度的由來
如何計算下面一段程式碼的執行效率?
public int sum(int n){
int result = 0;
for (int i = 1; i <= n; i++){
result = result + i;
}
return result;
}
有一種方法就是在程式碼的前後加上時間的統計語句
public int sum(int n){
long startTime = System.currentTimeMillis();
int result = 0;
for (int i = 1; i <= n; i++){
result = result + i;
}
long endTime = System.currentTimeMillis();
long costTime = endTime - startTime;
System.out.println("執行程式碼花費時間為:" + costTime + "ms");
return result;
}
這種方法有兩個問題
- 依賴機器、測試資料(或資料規模)等測試環境
- 需要寫測試程式碼,需要真的執行程式碼測試
有沒有更加簡單地統計執行效率的方法呢?
時間複雜度分析
- 不依賴機器、測試資料(或資料規模)等測試環境
- 不需要寫測試程式碼,需要真的執行程式碼測試
- 通過肉眼、讀程式碼、粗略來分析
估算下面一段程式碼的執行效率
public int sum(int n){
int result = 0; // k1 * unit_time
for (int i = 1; i <= n; i++){ // k2 * unit_time
result = result + i; // k3 * unit_time
}
return result; // // k4 * unit_time
}
- 程式->編譯成->機器指令->CPU執行
- 機器指令平均執行時間 unit_time
總執行時間 = k1 * unit_time + n * k2 * unit_time + n * k3 * unit_time + k4 * unit_time
= (k1 + k4) * unit_time + n * (k2 + k3) * unit_time
用程式碼的執行時間來表示執行效率,還是比較繁瑣的,比較不方便拿來溝通的
大 O 時間複雜度表示法
什麼是大 O 時間複雜度表示法
- 只表示資料規模 n 很大時候的執行效率
- 忽略低階、常量、係數,只保留最高“量級”
- 表示執行時間隨資料規模的增長趨勢,而不是具體的執行時間
90 + 6 * n + 7 * n^2 + 8 * n^3 ≈ n^3
大 O 時間複雜度表示法的計算方法
public int sum(int n){
int result = 0; // k1 * unit_time 1次
for (int i = 1; i <= n; i++){ // k2 * unit_time n次
result = result + i; // k3 * unit_time n次
}
return result; // // k4 * unit_time 1次
}
總執行時間 = k1 * unit_time + n * k2 * unit_time + n * k3 * unit_time + k4 * unit_time
= (k1 + k4) * unit_time + n * (k2 + k3) * unit_time
O(n) 忽略低階、常量、係數
時間複雜度分析練習
public int f(int n) {
int result = 0; // k1 * unit_time 1次
for (int i = 1; i <= n; i++){ // k2 * unit_time n次
for (int j = 1; j <= n; ++j){ // k3 * unit_time n^2次
result = result + i * j; // k4 * unit_time n^2次
}
}
return result; // // k5 * unit_time 1次
}
總執行時間 = (k1 + k5) * unit_time + n * k2 * unit_time + n^2 * (k3 + k4) * unit_time
O(n^2) 忽略低階、常量、係數
實際上,時間複雜度跟執行次數最多的那段程式碼的執行次數成正比
能不能進行一些優化呢?
result = (1 * 1 + 1 * 2 + ... + 1 * n) + (2 * 1 + 2 * 2 + ... + 2 * n) + ... + (n * 1 + n * 2 + ... + n * n)
計算過程優化為:
result = 1 * (1 + 2 + ... + n) + 2 * (1 + 2 + ... + n) + ... + n * (1 + 2 + ... + n)
然後抽出同樣的 (1 + 2 + ... + n) 為 temp,修改程式碼如下
public int f2(int n) {
int tmp = 0;
for (int i = 1; i <= n; i++){
tmp = tmp + 1;
}
int result = 0;
for (int i = 1; i <= n; i++){
result = result + i * tmp;
}
return result;
}
兩端程式碼完成同樣的功能,程式碼 A 的時間複雜度是 O(n^2) ,程式碼 B 的時間複雜度是 O(n)。那麼,程式碼 B 的執行效率比程式碼 A 高。
時間複雜度的比較僅限於功能相同的程式碼之間,如果兩段程式碼功能都不同,比較時間複雜度就沒有意義了
幾種常見的時間複雜度量級
- O(1) 常量級 雜湊表上的各種操作
- O(logn) 對數級 二分查詢、平衡二叉查詢樹、跳錶
- O(n) 線性 陣列和連結串列的遍歷、二叉樹遍歷
- O(nlogn) 快速排序、歸併排序、堆排序
- O(n^2) 冒泡、插入、選擇排序
- O(2^n)指數級 回溯去窮舉演算法、比如八皇后問題、斐波那契數列
- O(n!) 比較少見,求全排列,實際上跟 n^n 同階
On(logn) 對數級時間複雜度
// 返回第一個比 n 大並且為 2 的 k 次方的數
public int f4(int n) { // k1 * unit_time 1次
int i = 1; // k2 * unit_time 1次
while (i <= n){ // k3 * unit_time ?次
i = i * 2; // k4 * unit_time ?次
}
return i; // // k5 * unit_time 1次
}
i = 1, 2, 4, 8, ... 2^k = 2^0 、2^1, 2^2, 2^3 ... 2^k
i = 2^k > n 時,while 迴圈結束
以 2 為底求對數
log2 2^k > log2 n
等於
k > log2 n
k = log2 n O(log2 n)-> 統一為 O(logn)
為什麼要把底數省略掉,統一表示為 O(logn) 呢?
我們來看下面這個例子
// 返回第一個比 n 大並且為 3 的 k 次方的數
public int f4(int n) { // k1 * unit_time 1次
int i = 1; // k2 * unit_time 1次
while (i <= n){ // k3 * unit_time ?次
i = i * 3; // k4 * unit_time ?次
}
return i; // // k5 * unit_time 1次
}
i = 1, 3, 9, 27, ... 3^k = 3^0 、3^1, 3^2, 3^3 ... 3^k
i = 3^k > n 時,while 迴圈結束
k = log3 n
O(log3 n) = O(log3 2 * log2 n)
常數可以省略,所以統一為 O(logn)
最好、最壞、平均時間複雜度
分析下面這段程式碼的時間複雜度
public int search(int a[], int n, int target) {
for (int i = 0; i < n; i++) { // ?次
if (a[i] == target) { // ?次
return i; // 1次
}
}
return -1; // 1次
}
第 2、3 行程式碼有可能執行了 1 次、2 次、3 次 ... n 次
執行效率並不是穩定的,分情況來看,有的時候很快,有的時候很慢
在不同的情況下,執行效率不同,針對這種情況,如何表示程式碼的執行效率
類比介面的響應時間,我們選取三個不同統計值來表示這段程式碼的執行效率:
- 最好情況下的時間複雜度 O(1),類比介面最小響應時間
- 最差情況下的時間複雜度 O(n),類比介面最大響應時間
- 平均情況下的時間複雜度 (1 + 2 + 3 + ... + n)/n = O(n),類比介面平均響應時間
均攤時間複雜度和攤還分析
均攤時間複雜度:一種特殊的平均時間複雜度
public class Demo{
private int n = 10;
private int a[] = new int[n];
private int count = 0;
public void insert(int data){
if (count == n){
int b[] = new int[n * 2];
for (int i = 0; i < n; i++) {
b[i] = a[i];
}
a = b;
n = n * 2;
}
a[count] = data;
count ++ ;
}
}
在我們不停呼叫 insert 方法的時候,耗時有一定的規律
Demo demo = new Demo();
demo.insert(1); // O(1)
demo.insert(2); // O(1)
// ...
demo.insert(10); // O(1)
demo.insert(11); // O(n) n = 10
demo.insert(12); // O(1)
// ...
demo.insert(20); // O(1)
demo.insert(21); // O(n) n = 20
demo.insert(22); // O(1)
// ...
demo.insert(40); // O(1)
demo.insert(41); // O(n) n = 40
demo.insert(42); // O(1)
// ...
當資料超過陣列容量的時候需要申請一個更大的空間,並把原來的資料拷貝到新的陣列裡面
申請空間不會特別耗時,但是迴圈拷貝資料比較耗時
我們把耗時比較多的操作,比如插入第 11 個元素的時候,因為需要申請空間,拷貝資料,把它的耗時均攤到耗時比較少的操作上面,均攤到插入第 12 個到第 20 個元素上
經過均攤之後,每個操作的耗時都是 O(1),所以時間複雜度就是 O(1)
總結一下
對某個資料結構進行一組連續的操作,大部分情況下時間複雜度都很低,只有個別情況下時間複雜度比較高,而且,這些操作之間存在前後連貫的時序關係,這個時候,我們就可以將這一組操作放在一塊分析,看是否能將耗時多的那次操作的耗時,均攤到其他耗時少的操作上
利用攤還分析法分析得到的平均時間複雜度,我們給它起了一個有區分度的名字:均攤時間複雜度。實際上,均攤時間複雜度就是一種特殊的平均時間複雜度。能夠應用攤還分析法分析均攤時間複雜度的程式碼不多,常見的就是支援動態擴容的一些資料結構
在能夠應用均攤時間複雜度分析的場景中,一般均攤(平均)時間複雜度就等於最好時間複雜度
空間複雜度分析
// 反轉陣列
public void reverse(int a[], int n){
int tmp[] = new int[n];
for (int i = 0; i < n; i++) {
tmp[i] = a[n-i-1];
}
for (int i = 0; i < n; i++) {
a[i] = tmp[i];
}
}
空間複雜度 O(n)
// 反轉陣列
public void reverse2(int a[], int n){
for (int i = 0; i < n/2; i++) {
int tmp = a[i];
a[i] = a[n-i-1];
a[n-i-1] = tmp;
}
}
空間複雜度 O(1)
- 空間複雜度 -> 峰值
- 時間複雜度 -> 累加值
原始碼
https://github.com/MingsonZheng/algorithm
參考
王爭的演算法訓練營(第5期)
https://www.xzgedu.com
本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。
歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。
如有任何疑問,請與我聯絡 (MingsonZheng@outlook.com) 。