只要講到數據結構和算法,就必定離不開時間、空間複雜度分析。複雜度分析是整個算法學習的精髓,只要掌握了它,數據結構和算法的內容基本上就掌握了一半。算法
過後分析法:把代碼跑一遍,經過統計、監控、就能獲得算法執行的時間、佔用的內存大小,可是這種統計方法具備很大的侷限性。數組
測試環境中,硬件的不一樣會對測試結果有很大的影響,好比 i9 的運算速度會比 i3 快,或者在 A 計算機上執行某個代碼塊a 的速度比另外一個代碼塊 b 的速度要快,放到 B 計算機上可能又會獲得不一樣的結果。數據結構
若是是在測試排序算法,測試數據的有序性極可能會影響不一樣排序算法的執行時間;若是測試數據規模過小,測試結果有可能沒法真實地反映算法的性能。數據結構和算法
咱們須要一個不須要用具體的測試數據來測試,就能夠粗略的估計算法的執行效率的方法。這就是時間、空間複雜度分析。函數
算法的執行效率,粗略地講,就是算法的執行時間。可是如何在不運行代碼的狀況下,用「肉眼」獲得一段代碼的執行時間呢?性能
這裏有一段很是簡單的代碼例 1,求 1,2,3...n 的累加和:學習
例 1: 1 int cal(int n){ 2 int sum = 0; 3 int i = 1; 4 for(; i <= n; i ++){ 5 sum = sum + i; 6 } 7 8 return sum; 9 }
從 CPU 的角度來看,例 1 的代碼的每一行都執行着相似的操做:讀數據 -- 運算 -- 寫數據。儘管每一行代碼對應的 CPU 執行的個數、執行時間都不同,可是,咱們這裏只是粗略估計,因此能夠假設每行代碼執行的時間都爲 unit_time。測試
例 1 中,第 二、三、8 行代碼須要 3 * unit_time 的執行時間;第 四、5 行都運行了 n 遍,須要 2n * unit_time 的執行時間;總的執行時間就是T(n) = ( 2n + 3 ) * unit_time 。能夠看出,全部代碼的執行時間 T(n) 與每行代碼的執行次數成正比。spa
再看例 2:code
例 2: 1 int cal(int n){ 2 int sum = 0; 3 int i = 1; 4 int j = 1; 5 for(; i <= n; i ++){ 6 j = 1; 7 for(; j <= n; j ++){ 8 sum = sum + i; 9 } 10 } 11 12 return sum; 13 }
例 2 中,第 二、三、四、12 行代碼須要 4 * unit_time 的執行時間;第 五、6 行代碼須要 2n * unit_time 的執行時間;第 七、8 行代碼循環執行了 n² 遍,須要 2 * n² * unit_time 的執行時間;總的執行時間就是 T(n) = ( 2n² + 2n + 4 ) * unit_time 。
根據例1 和例 2 的推導過程,能夠獲得一個很是重要的規律:全部的代碼的執行時間 T(n) 與每行代碼的執行次數 n 成正比。
咱們把這個規律總結成一個公式,就是大 O 複雜度表示法:
其中,T(n) 表示代碼執行的時間,n 表示數據規模的大小,f(n) 表示每行代碼執行的次數總和;O 表示代碼的執行時間 T(n) 與代碼的執行次數 f(n) 成正比。
用大 O 複雜度表示法來表達時間複雜度,例 1 爲 T(n) = O( 2n + 3),例 2 爲 T(n) = O( 2n² + 2n + 4 )。大 O 時間複雜度實際上並不具體表示代碼真正的執行時間,而是表示代碼執行時間隨數據規模增加的變化趨勢,因此也叫作漸進時間複雜度(asymptotic time complexity),簡稱時間複雜度。
當 n 很大時,公式中的低階、常量、係數三部分並不左右增加趨勢,因此都能夠忽略,只須要記錄一個最大量級就能夠了。例 一、例 2 的時間複雜度,就分別能夠記爲: T(n) = O( n );T(n) = O( n² )。
大 O 複雜度表示方法,只是表示一種變化趨勢,咱們一般會忽略掉公式中的常量、低階、係數,只需記錄一個最大階的量級就能夠了。
咱們在分析一個算法、一段代碼的時間複雜度的時候,只關注循環執行次數最多的那一段代碼就能夠了。這段核心代碼執行次數的 n 的量級,就是整段要分析代碼的時間複雜度。
仍以例 1 爲例:
例 1: 1 int cal(int n){ 2 int sum = 0; 3 int i = 1; 4 for(; i <= n; i ++){ 5 sum = sum + i; 6 } 7 8 return sum; 9 }
其中第 二、三、8 行代碼都是常量級的執行時間,與 n 的大小無關,因此對於時間複雜度並沒有影響。循環執行次數最多的是第 四、5 行代碼,因此這塊代碼要重點分析。因爲這兩行代碼被執行了 n 次,因此總的時間複雜度就是 O( n )。
下面你們看例 3:
例 3: 1 int cal(int n){ 2 int sum_1 = 0; 3 int p = 1; 4 for(; p < 100; p ++){ 5 sum_1 = sum_1 + p; 6 } 7 8 int sum_2 = 0; 9 int q = 1; 10 for(; q < n; q ++){ 11 sum_2 = sum_2 + q; 12 } 13 14 int sum_3 = 0; 15 int i = 1; 16 int j = 1; 17 for(; i <= n; i ++){ 18 j = 1; 19 for(; j <= n; j ++){ 20 sum_3 = sum_3 + i * j; 21 } 22 } 23 24 return sum_1 + sum_2 + sum_3; 25 }
例 3 的代碼分爲三部分,分別求 sum_1 、sum_2 、sum_3 。
綜合這三段代碼的時間複雜度,咱們取其中最大的量級,因此整段代碼的時間複雜度爲 T(n) = O( n² )。也就是說,總的時間複雜度,等於量級最大的那段代碼的時間複雜度。
將這個規律抽象成公式,以下:
若是:
那麼:
有以下代碼例 4:
例 4: 1 int cal(int n){ 2 int ret = 0; 3 int i = 1; 4 for(; i < n; i ++){ 5 ret = ret + f(i); 6 } 7 8 return ret; 9 } 10 11 int f(int n){ 12 int sum = 0; 13 int i = 1; 14 for(; i < n; i ++){ 15 sum = sum + i; 16 } 17 18 return sum; 19 }
例 4 的代碼爲嵌套循環代碼。假設 f() 只是一個普通的操做,那 四、5 行的時間複雜度就是T1(n) = O( n )。可是,f() 函數自己不是一個簡單的操做,它的時間複雜度爲T2(n) = O( n )。因此,例 4 中整個 cal() 函數的時間複雜度就是T(n) = T1(n) * T2(n) = O( n * n ) = O( n² )。
也就是說,總的時間複雜度,等於循環調用代碼的時間複雜度的乘積。
將這個規律抽象成公式,以下:
若是:
那麼:
常見的複雜度量級並很少,粗略的分爲兩類:多項式量級和非多項式量級。
多項式量級:
非多項式量級:
咱們把時間複雜度爲非多項式量級的算法問題叫作 NP(Non-Deterministic Polynomial,非肯定多項式)問題。
當數據規模愈來愈大時,非多項式量級算法的執行時間和急劇增長,求解問題的執行時間會無線增加。因此,非多項式時間複雜度的算法實際上是很是低效的算法。瞭解幾種常見的多項式時間複雜度便可:
一、O(1) 二、O(㏒n)、O(n ㏒n) 三、O(m+n)、O(m\*n)
前面提到,時間複雜度的全稱是漸進時間複雜度,表示算法的執行時間與數據規模之間的增加關係。
類比一下,空間複雜度全稱就是漸進空間複雜度,表示算法的存儲空間與數據規模之間的增加關係。
相對時間複雜度來講,空間複雜度分析要簡單得多。
下面看一下例 5:
例 5: 1 void print(int n){ 2 int i = 0; 3 int[]a= new int[n]; 4 for(; i < n; i ++){ 5 a[i] = i * i; 6 } 7 8 for(i = n - 1; i >= 0; i --){ 9 System.out.println(a[i]); 10 } 11 }
跟時間複雜度分析同樣,能夠看到,第 2 行代碼中,咱們申請了一個空間存儲變量 i,可是它是常量階的,與數據規模 n 無關,因此能夠忽略;第 3 行申請了一個大小爲 n 的 int 類型數組;除此以外,剩下的代碼都沒有佔用更多的空間,因此整段代碼的空間複雜度就是 O( n )。
咱們常見的空間複雜度就是O( 1 )、O( n )、O( n² )。
例 6:
例 6: 1 int find(int[]array, /*n 表示數組 array 長度*/int n, /*x 表示須要尋找的數字*/int x){ 2 int i = 0; 3 int pos = -1; 4 for(; i < n; i ++){ 5 if(array[i] == x){ 6 pos = i; 7 } 8 } 9 10 return pos; 11 }
例 6 要實現的功能,是在一個無序的數組中,查找變量 x 出現的位置,若是沒有找到,就返回 -1;按照大 O 表示法,例 6 的時間複雜度是 O( n ),其中,n 表明數組長度。
可是,咱們在數組中查找某一個數字,不用所有都遍歷一遍,有可能半途中就找到了,就能夠提早結束循環了。
將例 6 改進後,獲得例 7:
例 7: 1 int find(int[]array, /*n 表示數組 array 長度*/int n, /*x 表示須要尋找的數字*/int x){ 2 int i = 0; 3 int pos = -1; 4 for(; i < n; i ++){ 5 if(array[i] == x){ 6 pos = i; 7 break; 8 } 9 } 10 11 return pos; 12 }
不一樣的狀況下,例 7 的時間複雜度是不同的:
下面引入三個概念,用來表示代碼在不一樣狀況下的不一樣時間複雜度:
最好狀況時間複雜度,就是在最理想的狀況下,執行這段代碼的時間複雜度。
例 7 中,若是數組中第一個數字就是 x,那這時候的時間複雜度就是最好狀況時間複雜度。
最壞狀況時間複雜度,就是在最不理想的狀況下,執行這段代碼的時間複雜度。
例 7 中,若是數組中 x 是最後一個數字,那這時候的時間複雜度就是最壞狀況時間複雜度。
最好狀況時間複雜度和最壞狀況時間複雜度都是出如今極端環境下的代碼複雜度,發生的機率並不大。爲了更好地表示平均狀況下的複雜度,咱們須要分析平均狀況時間複雜度,簡稱平均時間複雜度。
繼續看例 7:
例 7: 1 int find(int[]array, /*n 表示數組 array 長度*/int n, /*x 表示須要尋找的數字*/int x){ 2 int i = 0; 3 int pos = -1; 4 for(; i < n; i ++){ 5 if(array[i] == x){ 6 pos = i; 7 break; 8 } 9 } 10 11 return pos; 12 }
在例 7 中,咱們要查找的 x 所在的位置有 n+1 種狀況:
咱們把每種狀況下,查找須要遍歷的元素個數累加起來,而後除以 n+1,就能夠獲得須要遍歷的元素個數的平均值:
因此,總共的遍歷個數爲:
再除以總共的遍歷方法 n + 1,即:
在大 O 表示法中,能夠省略掉係數、低階、常量,平均複雜度爲 O( n )。
可是有一個問題,就是這 n+1 種狀況,並非等機率的。
將機率因素考慮進去,平均時間複雜度爲:
這樣的計算結果叫作加權平均值,也叫作指望值,因此平均時間複雜的的其實是加權平均時間複雜度或指望時間複雜度。
舍掉係數和常量,獲得的平均時間複雜度爲 O( n )。
在大多數狀況下,咱們並不須要區分最好、最壞、平均時間複雜度。只有同一塊代碼在不一樣狀況下,時間複雜度出現了量級的差距,纔會使用這三種時間複雜度來表示。
大部分狀況下,並不須要區分最好、最壞、平均時間複雜度,平均時間複雜度只在某些特殊場景有使用, 均攤時間複雜度的應用場景比平均時間複雜度更特殊、更有限。
事實上,均攤時間複雜度就是一種特殊的平均時間複雜度。
例 8:
例 8: 1 int[]array = new int[n]; // 長度爲 n 的數組 2 int count = 0; 3 4 void insert(int val){ 5 if(count == array.length){ 6 int sum = 0; 7 for(int i = 0; i < array.length; i ++){ 8 sum = sum + array[i]; 9 } 10 array[0] = sum; 11 count = 1; 12 } 13 array[count] = val; 14 count ++; 15 }
例 8 實現了往數組中插入數據的功能:
因此例 8 的時間複雜度:
最後經過幾率計算一下平均時間複雜度:假設數組長度是 n,根據數據插入位置的不一樣,能夠分爲 n 種狀況,每種狀況的時間複雜度爲 O( 1 );另外還有 1 種狀況,假如數組中沒有空閒空間時,插入一個數據的時間複雜度爲O( n )。並且,這 n+1 種狀況發生的機率同樣,都是 1/(n+1) 。根據加權平均的計算方法,能夠求得平均時間複雜度:
使用大 O 表示法,舍掉常量、係數、低階,平均時間複雜度爲 O( 1 )。
可是例 8 中的平均複雜度分析其實並不須要這麼麻煩。再將例 7 和例 8 的代碼放在一塊兒看一下:
例 7: 1 int find(int[]array, /*n 表示數組 array 長度*/int n, /*x 表示須要尋找的數字*/int x){ 2 int i = 0; 3 int pos = -1; 4 for(; i < n; i ++){ 5 if(array[i] == x){ 6 pos = i; 7 break; 8 } 9 } 10 11 return pos; 12 }
例 8: 1 int[]array = new int[n]; // 長度爲 n 的數組 2 int count = 0; 3 4 void insert(int val){ 5 if(count == array.length){ 6 int sum = 0; 7 for(int i = 0; i < array.length; i ++){ 8 sum = sum + array[i]; 9 } 10 array[0] = sum; 11 count = 1; 12 } 13 array[count] = val; 14 count ++; 15 }
因此,針對這樣一種特殊場景的複雜度分析,並不須要以前的計算平均時間複雜度那樣,引入機率計算加權平均值。由一種更加簡單的分析方法:攤還分析法。經過攤還分析獲得的時間複雜度,叫作均攤時間複雜度。
例 8 中的 insert() 函數,每一次 O(n) 插入操做後,都會跟着 n-1 次的 O(1) 插入操做,因此把耗時多的那次操做均攤到接下來的 n-1 次耗時少的操做上,均攤下來,這一組連續的操做的均攤時間複雜度就是 O(1)。
均攤時間複雜度和攤還分析的應用場景比較特殊,因此咱們不會常常用到。它們的應用場景通常以下:
對一個數據結構進行一組連續操做中,大部分狀況下時間複雜度都很低,只有個別狀況下時間複雜度比較高,並且這些操做之間存在先後連貫的時序關係,這個時候咱們就能夠將這一組操做放在一起分析,看是否能將較高時間複雜度那次操做的耗時,均攤到其餘那些時間複雜度比較低的操做上。並且,在可以應用均攤時間複雜度分析的場合, 通常狀況下均攤時間複雜度就等於最好狀況時間複雜度。
有以下代碼例 9 ,試着分析一下 add() 函數的時間複雜度:
例 9: 1 int[]array = new int[10]; 2 int len = 10; 3 int i = 0; 4 5 void add(int element){ 6 if(i >= len){ 7 int[]new_array = new int[len * 2]; 8 for(int j = 0; j < len; j ++){ 9 new_array[j] = array[j]; 10 } 11 array = new_array; 12 len = len * 2; 13 } 14 array[i] = element; 15 i ++; 16 }
解答:
函數 add() 的做用,有兩點:
- 代碼 1四、15 行,將給定的數據 element 按順序放入數組 array 中,這個過程當中的時間複雜度爲 O(1)
- 代碼 6-13 行,假如數組 array 已經存滿,將數組 array 的容量擴充到原來容量的 2 倍,而後將原數組 array 元素賦值過去,再將給定的數據 element 按順序放入數組 array 中,這個過程當中的時間複雜度爲O(n),n 爲數組 array 擴容前的長度。
由上述可知,最好時間複雜度爲O(1),最壞時間複雜度爲O(n)
通過觀察,能夠發現,1 、 2兩步是有規律執行的。沒有擴容時,先執行 len 次第 1 步,再執行 1 次第 2 步,此時完成一次擴容;而後執行 2*len-1 次第 1 步,再執行 1 次第 2 步,此時完成二次擴容;概括可得,執行的規律應該爲 n 次 O(1),1 次 O(n),2*n-1 次 O(1),1 次 O(2n),4*n-1 次 O(1),1 次 O(4n)...
因此均攤複雜度應爲O(1)