1. 程式人生 > 實用技巧 >程式設計師的數學基礎課 時間和空間複雜度(中):優化效能是否只是“紙上談兵”?6 個通用法則

程式設計師的數學基礎課 時間和空間複雜度(中):優化效能是否只是“紙上談兵”?6 個通用法則


1.四則運演算法則
對於時間複雜度,程式碼的新增,意味著計算機操作的增加,也就是時間複雜度的增加。如果程式碼是平行增加的,就是加法。如果是迴圈、巢狀或者函式的巢狀,那麼就是乘法。比如二分查詢的程式碼中,第一步是對長度為 n 的陣列排序,第二步是在這個已排序的陣列中進行查詢。這兩個部分是平行的,所以計算時間複雜度時可以使用加法。第一步的時間複雜度是 O(nlogn),第二步的時間複雜度是 O(logn),所以時間複雜度是 O(nlogn)+O(logn)。你還記得在第 3 講我講的查字典的例子嗎?

import java.util.Arrays;

public class Lesson3_3 {
	
	/**
    * @Description:	查詢某個單詞是否在字典裡出現
    * @param dictionary-排序後的字典, wordToFind-待查的單詞
    * @return boolean-是否發現待查的單詞
    */
    public static boolean search(String[] dictionary, String wordToFind) {
    	
    	if (dictionary == null) {
    		return false;
    	}
    	
    	if (dictionary.length == 0) {
    		return false;
    	}
    	
    	int left = 0, right = dictionary.length - 1;
    	while (left <= right) {
    		int middle = left + (right - left) / 2;
    		if (dictionary[middle].equals(wordToFind)) {
    			return true;
    		} else {
    			if (dictionary[middle].compareTo(wordToFind) > 0) {
    				right = middle - 1;
    			} else {
    				left = middle + 1;
    			}
    		}
    	}
    	return false;
    }

	public static void main(String[] args) {
		
		
		String[] dictionary = {"i", "am", "one", "of", "the", "authors", "in", "geekbang"};
		
		Arrays.sort(dictionary);

		String wordToFind = "i";
		
		boolean found = Lesson3_3.search(dictionary, wordToFind);
		if (found) {
			System.out.println(String.format("找到了單詞%s", wordToFind));
		} else {
			System.out.println(String.format("未能找到單詞%s", wordToFind));
		}
		
	}

}

這裡面的 Arrays.sort(dictionary),我用了 Java 自帶的排序函式,時間複雜度為 O(nlogn),而 Lesson3_3.search 是我自己實現的二分查詢,時間複雜度為 O(logn)。兩者是並行的,並依次執行,因此總的時間複雜度是兩者相加。我們再來看另外一個例子。從 n 個元素中選出 3 個元素的可重複排列,使用 3 層迴圈的巢狀,或者是 3 層遞迴巢狀,這裡時間複雜度計算使用乘法。由於 nnn=n3,時間複雜度是 O(n3)。對應加法和乘法,分別是減法和除法。如果去掉平行的程式碼,就減掉相應的時間複雜度。如果去掉巢狀內的迴圈或函式,就除去相應的時間複雜度。
對於空間複雜度,同樣如此。需要注意的是,空間複雜度看的是對記憶體空間的使用,而不是計算的次數。如果語句中沒有新開闢空間,那麼無論是平行增加還是巢狀增加程式碼,都不會增加空間複雜度。


2.主次分明法則
這個法則主要是運用了數量級和運演算法則優先順序的概念。在剛剛介紹的第一個法則中,我們會對程式碼不同部分所產生的複雜度進行相加或相乘。使用加法或減法時,你可能會遇到不同數量級的複雜度。這個時候,我們只需要看最高數量級的,而忽略掉常量、係數和較低數量級的複雜度。在介紹第一個法則的時候,我說了先排序、後二分查詢的總時間複雜度是 O(nlogn) + O(logn)。實際上,我貼出的程式碼中還有陣列初始化、變數賦值、Console 輸出等步驟,如果細究的話,時間複雜度應該是 O(nlogn) + O(logn) + O(3),但是和 O(nlogn) 相比,常量和 O(logn) 這種數量級都是可以忽略的,所以最終簡化為 O(nlogn)。再舉個例子,我們首先通過隨機函式生成一個長度為 n 的陣列,然後生成這個陣列的全排列。通過迴圈,生成 n 個隨機數的時間複雜度為 O(n),而全排列的時間複雜度為 O(n!),如果使用四則運演算法則,總的時間複雜為 O(n)+O(n!)。不過,由於 n! 的數量級遠遠大於 n,所以我們可以把總時間複雜度簡化為 O(n!)。
這對於空間複雜度同樣適用。假設我們計算一個長度為 n 的向量和一個維度為[n*n]的矩陣之乘積,那麼總的空間複雜度可以由 (O(n)+O(n2)) 簡化為 O(n2)。注意,這個法則對於乘法或除法並不適用,因為乘法或除法會改變參與運算的複雜度的數量級


3.齊頭並進法則
這個法則主要是運用了多元變數的概念,其核心思想是複雜度可能受到多個因素的影響。在這種情況下,我們要同時考慮所有因素,並在複雜度公式中體現出來。我在之前的文章中,介紹了使用動態規劃解決的編輯距離問題。從解決方案的推導和程式碼可以看出,這個問題涉及兩個因素:參與比較的第一個字串的長度 n 和第二個字串的長度 m。程式碼使用了兩次巢狀迴圈,第一層迴圈的長度是 n,第二層迴圈的長度為 m,根據乘法法則,時間複雜度為 O(nm)。而空間複雜度,很容易從推導結果的狀態轉移表得出,也是 O(nm)。


4.排列組合法則
排列組合的思想不僅出現在數學模型的設計中,同樣也會出現在複雜度分析中,它經常會用在最好、最壞和平均複雜度分析中。我們來看個簡單的演算法題。給定兩個不同的字元 a 和 b,以及一個長度為 n 的字元陣列。字元數組裡的字元都只出現過一次,而且一定存在一個 a 和一個 b,請輸出 a 和 b 之間的所有字元,包括 a 和 b。假設我們的演算法是按照陣列下標從低到高的順序依次掃描陣列,那麼時間複雜度是多少呢?這裡時間複雜度是由被掃描的陣列元素之數量決定的,但是要準確的求解並不容易。仔細思考一下,你會發現被掃描的元素之數量存在很多可能的值。
首先,考慮字母出現的順序,第一個遇到的字母有 2 個選擇,a 或者 b。而第二個字母只有 1 個選擇,這就是 2 個元素的全排列。下面我們把兩種情況分開來看.
第一種情況是 a 在 b 之前出現。接下來是 a 和 b 之間的距離,這會決定我們要掃描多少個字元。兩者之間的距離最大為 n-1,最小為 1,所以最壞的時間複雜度為 O(n-1),根據主次分明法則,簡化為 O(n),最好複雜度為 O(1)。
平均複雜度的計算稍微繁瑣一些。如果距離為 n-1,只有 1 種可能,a 為陣列中第一個字元,b 為陣列中最後一個字元。如果距離為 n-2,那麼 a 字元的位置有 2 種可能,b 在 a 位置確定的情況下只有 1 種可能,因此排列數是 2。以此類推,如果距離為 n-3,那麼有 3 種可能,一直到距離 1,有 n-1 種可能。所以平均的掃描次數為 (1 *(n-1) + 2 *(n-2) + 3 (n -3) + … + (n-1) 1) / (1 + 2 + … + n),最後時間複雜度簡化為 O(n)。
第二種情況是 b 在 a 之前出現。這個分析過程和第一種情況類似。我們假設第一種和第二種情況出現的機率相等,那麼綜合兩種情況,可以得出平均複雜度為 O(n)。
(1.找位置下標,2.輸出對應及包含下標的字元)


5.一圖千言法則
在之前的文章中,我提到了很多數學和演算法思想都體現了樹這種結構,通過畫圖它們內在的聯絡就一目瞭然了。同樣,這些樹結構也可以幫助我們分析某些演算法的複雜度
就以我們之前介紹的歸併排序為例。這個演算法分為資料的切分和歸併兩大階段,每個階段的資料劃分不同,分組數量也不同,感覺上時間複雜度不太好計算。下面我們來看一個例子,幫助你理解。
假設等待排序的陣列長為 n。首先,看資料切分階段。資料切分的次數,就是切分階段那棵樹的非葉子結點之數量。這個切分階段的樹是一棵滿二叉樹,葉子結點是 n 個,那麼非葉子結點的數量就是 n-1 個,所以切分的次數也就是 n-1 次。如果我們切分資料的時候,並不重新生成新的資料,而只是生成切分邊界的下標,那麼時間複雜度就是 O(n-1)。

在資料歸併階段,我們看二叉樹的高度,為 log2n,因此歸併的次數為 log2n。另外,無論陣列被細分成多少個小的部分,每次歸併都需要掃描整個長度為 n 的陣列,因此歸併階段的時間複雜度為 nlog2n。

兩個階段加起來的時間複雜度為 O(n-1)+nlog2n,最終簡化為 nlogn。是不是很直觀?我再放出我們之前講二分查詢所用的圖,你可以結合這個例子進一步理解。

當然,除了圖論,很多簡單的圖表也能幫助到我們的分析。
例如,在使用動態規劃法的時候,我們經常要畫出狀態轉移的表格。看到這類表格,我們可以很容易地得出該演算法的時間複雜度和空間複雜度。以編輯距離為例,參看下面這個示例的圖表,我們可以發現每個單元格都對應了 3 次計算,以及一個儲存單元,而總共的單元格數量為 mn,m 為第一個字串的長度,n 為第二個字串的長度。所以,我們很快就能得出這種演算法的時間複雜度為 O(3mn),簡寫為 O(mn),空間複雜度為 O(mn)。


6.時空互換法則
在給定的計算量下,通常時間複雜度和空間複雜度呈現數學中的反比關係。這就說明,如果我們沒法降低整體的計算量,

那麼也許可以通過增加空間複雜度來達到降低時間複雜度的目的,或者反之,通過增加時間複雜度來降低空間複雜度。

對於這個規則最直觀的例子就是快取系統。在沒有快取系統的時候,每次請求都要伺服器來處理,因此時間複雜度比較高。如果使用了快取系統,那麼我們會消耗更多的記憶體空間,但是降低了請求相應的時間。
說到這,你也許會問,在使用廣度優先策略優化聚合操作的時候,無論是時間還是空間複雜度,都大幅降低了啊?請注意,這裡時空互換法則有個前提條件,就是計算量固定。而聚合操作的優化,是利用了廣度優先的特點,大幅減少了整體的計算量,因此可以保證時間和空間複雜度都得到降低。


小結
時間複雜度和空間複雜度的概念,你一定不陌生。可是,在實際運用中,你可能就會發現複雜度分析並不是那麼簡單。
這一節我通過個人的一些經驗,從數學思維的角度出發,總結了幾條常用的法則,對你會有所幫助。這些總結可能還是過於抽象,下一講中,我會通過幾個案例分析,來講講如何使用這些法則。