1. 程式人生 > 實用技巧 ># 資料結構與演算法(一):複雜度分析

# 資料結構與演算法(一):複雜度分析

什麼是資料結構與演算法?

資料結構

從廣義上講,資料結構就是指一組資料的儲存結構。

資料結構按照邏輯結構大致可以分為兩類:線性資料結構非線性資料結構

線性結構

​ 線性結構指的是資料之間存在著一對一的線性關係,是一組資料的有序集合。線性結構有且僅有一個開始結點和一個結束結點,並且每個結點最多隻有一個前驅和一個後繼。類比如現實生活中的排隊。

線性結構常見的有:陣列佇列連結串列等。

非線性結構

​ 非線性結構指的是資料間存在著一對多的關係,一個結點可能有多個前驅和後繼。如果一個結點至多隻有一個前驅且可以有多個後繼,這種結構就是樹形結構。類比如公司的組織結構。如果對結點的前驅和後繼的個數都不作限制,這種結構就是圖形結構。類比如社交網路的朋友關係。

非線性結構常見的有:廣義表等。

演算法

從廣義上講,演算法就是操作資料的一組方法

在我看來,演算法就是基於某種資料結構為了達到某種目的的實現步驟。

常見的演算法有哪些

舉個例子:

圖書館儲藏書籍你肯定見過吧?

​ 為了方便查詢,圖書管理員一般會將書籍分門別類進行“儲存”並按照一定規律編號,這就是書籍這種“資料”的儲存結構。那我們如何來查詢一本書呢?有很多種辦法,你當然可以一本一本地找,也可以先根據書籍類別的編號,是人文,還是科學、計算機,來定位書架,然後再依次查詢。籠統地說,這些查詢方法都是演算法,演算法有好壞之分,好的演算法可以提高查詢效率,節約查詢時間;壞的演算法對我們的查詢沒有任何幫助,甚至走進死迴圈。

資料結構和演算法的關係

​ 資料結構和演算法是相輔相成的。資料結構是為演算法服務的,演算法要作用在特定的資料結構之上。 因此,我們無法孤立資料結構來講演算法,也無法孤立演算法來講資料結構。比如,因為陣列具有隨機訪問的特點,常用的二分查詢演算法需要用陣列來儲存資料。但如果我們選擇連結串列這種資料結構,二分查詢演算法就無法工作了,因為連結串列並不支援隨機訪問。資料結構是靜態的,它只是組織資料的一種方式。如果不在它的基礎上操作、構建演算法,孤立存在的演算法就是沒用的。

再舉個例子,計算數字從1100之和,使用迴圈我們可能會寫出這樣的程式:

public int count(int number){
    int res = 0;
    for (int i = 1; i <= number; i++) {
        res += i;
    }
    return res;
}

如果這裡的100變成了十萬、百萬,那麼這裡計算量同樣也會隨之增加,但是如果使用這樣一個求和的公式:

100 *  (100 + 1) / 2 

​ 無論數字是多大,都只需要三次運算即可,演算法可真秒!同樣資料結構與演算法是相互依存的,資料結構為什麼這麼存,就是為了讓演算法能更快的計算。所以學習資料結構與演算法首先需要了解每種資料結構的特性,演算法的設計很多時候都需要基於當前業務最合適的資料結構。

為什麼要學習資料結構與演算法?

​ 當代程式設計師為了完成學業,為了更好的工作,為了寫出更優秀的程式碼等等。反正只要你想學,總能找到堅持下去的理由。

  • 每年湧現出大量計算機開發人員,如何在這麼多競爭者中突出重圍,獲取心儀的Offer,掌握資料結構與演算法已經成為必殺利器之一。

  • 不單單是為了面試,掌握資料結構和演算法,不管對於閱讀框架原始碼,還是理解其背後的設計思想,都是非常有用的,畢竟每個程式設計師都不想止步於 CRUD

  • 在平時的開發過程中,如果不知道這些類庫背後的原理,不懂得時間、空間複雜度分析,你如何能用好、用對它們?儲存某個業務資料的時候,你如何知道應該用 ArrayList,還是 Linked List 呢?呼叫了某個函式之後,你又該如何評估程式碼的效能和資源的消耗呢?

如何系統高效地學習資料結構與演算法?

​ 很多人都感覺資料結構和演算法很抽象,晦澀難懂,宛如天書。還因為看不懂資料結構與演算法,而一度懷疑自己太笨?正是這些原因,讓我們對資料結構和演算法望而卻步。

​ 其實學習資料結構和演算法並不是很難,只要找到好的學習方法,抓住學習的重點,並且堅持下去,終有一天我們會征服這座高山。

那麼學習資料結構與演算法哪些是重點呢?

  • 掌握複雜度分析方法 - 首先要掌握資料結構與演算法中最重要的概念—複雜度分析,複雜度分析方法是考量效率和資源消耗的方法。所以,如果你只掌握了資料結構和演算法的特點、用法,但是沒有學會複雜度分析,那就相當於只知道操作口訣,而沒掌握心法。

  • 學習資料結構與演算法是一個長期的過程,並且內容有很多,掌握了這些基礎的資料結構和演算法,再學更加複雜的資料結構和演算法,就會非常容易、非常快。

  • 資料結構與演算法的誕生都是為了解決實際問題,無數先輩解決問題留下的寶貴財富,才有了我們我們今天看到的這麼多資料結構與演算法,如果你深入瞭解了,你也可以發明新的資料結構與演算法。所以在學習的過程中一定要結合實際場景分析,才能抓住核心,記得更牢靠。

一些可以讓你事半功倍的學習技巧

1、邊學邊練,適度刷題

2、多問、多思考、多互動

3、打怪升級學習法

4、知識需要沉澱,不要想試圖一下子掌握所有

複雜度分析

​ 我們都知道,資料結構和演算法本身解決的是“快”和“省”的問題,即如何讓程式碼執行得更快,如何讓程式碼更省儲存空間。所以,執行效率是演算法一個非常重要的考量指標。那如何來衡量你編寫的演算法程式碼的執行效率呢?這裡就要用到我們今天要講的內容:時間、空間複雜度分析。

大 O 複雜度表示法

​ 演算法的執行效率,粗略地講,就是演算法程式碼執行的時間。但是,如何在不執行程式碼的情況下,用“肉眼”得到一段程式碼的執行時間呢?

這裡有段非常簡單的程式碼,求 1,2,3…n 的累加和。現在,我就帶你一塊來估算一下這段程式碼的執行時間。

public int cal(int n) {
    int sum = 0;
    int i = 1;
    for (; i <= n; i++) {
        sum += i;
    }
    return sum;
}

​ 從 CPU 的角度來看,這段程式碼的每一行都執行著類似的操作:讀資料-運算-寫資料。儘管每行程式碼對應的 CPU 執行的個數、執行的時間都不一樣,但是,我們這裡只是粗略估計,所以可以假設每行程式碼執行的時間都一樣,為 unit_time。在這個假設的基礎之上,這段程式碼的總執行時間是多少呢?

​ 第 2、3 行程式碼分別需要 1 個 unit_time 的執行時間,第 4、5 行都運行了 n 遍,所以需要 2n * unit_time 的執行時間,所以這段程式碼總的執行時間就是 (2n+2) * unit_time。可以看出來,所有程式碼的執行時間 T(n) 與每行程式碼的執行次數成正比。

​ 按照這個分析思路,我們再來看這段程式碼。

public int cal(int n) {
    int sum = 0;
    int i = 1;
    int j = 1;
    for (; i <= n; ++i) {
        j = 1;
        for (; j <= n; ++j) {
            sum += i * j;
        }
    }
    return sum;
}

​ 我們依舊假設每個語句的執行時間是 unit_time。那這段程式碼的總執行時間 T(n) 是多少呢?

​ 第 2、3、4 行程式碼,每行都需要 1 個 unit_time 的執行時間,第 5、6 行程式碼迴圈執行了 n 遍,需要 2n * unit_time 的執行時間,第 7、8 行程式碼迴圈執行了 n2遍,所以需要 2n2 * unit_time 的執行時間。所以,整段程式碼總的執行時間 T(n) = (2n2+2n+3)*unit_time。

​ 儘管我們不知道 unit_time 的具體值,但是通過這兩段程式碼執行時間的推導過程,我們可以得到一個非常重要的規律,那就是,所有程式碼的執行時間 T(n) 與每行程式碼的執行次數 n 成正比。我們可以把這個規律總結成一個公式。注意,大 O 就要登場了!
$$
T(n)=O(f(n))
$$
​ 我來具體解釋一下這個公式。其中,T(n) 我們已經講過了,它表示程式碼執行的時間;n 表示資料規模的大小;f(n) 表示每行程式碼執行的次數總和。因為這是一個公式,所以用 f(n) 來表示。公式中的 O,表示程式碼的執行時間 T(n) 與 f(n) 表示式成正比。

​ 所以,第一個例子中的 T(n) = O(2n+2),第二個例子中的 T(n) = O(2n2+2n+3)。這就是大 O 時間複雜度表示法。大 O 時間複雜度實際上並不具體表示程式碼真正的執行時間,而是表示程式碼執行時間隨資料規模增長的變化趨勢,所以,也叫作漸進時間複雜度(asymptotic time complexity),簡稱時間複雜度

​ 當 n 很大時,你可以把它想象成 10000、100000。而公式中的低階、常量、係數三部分並不左右增長趨勢,所以都可以忽略。我們只需要記錄一個最大量級就可以了,如果用大 O 表示法表示剛講的那兩段程式碼的時間複雜度,就可以記為:T(n) = O(n); T(n) = O(n2)。

時間複雜度分析

如何分析一段程式碼的時間複雜度?

1、只關注迴圈執行次數最多的一段程式碼

2、加法法則:總複雜度等於量級最大的那段程式碼的複雜度

3、乘法法則:巢狀程式碼的複雜度等於巢狀內外程式碼複雜度的乘積

幾種常見時間複雜度例項分析

  • O(1): 常數級別,不會影響增長的趨勢,一般情況下,只要演算法中不存在迴圈語句、遞迴語句,即使有成千上萬行的程式碼,其時間複雜度也是Ο(1)
  • O(logn): 對數級別,執行效率僅次於O(1),例如從一個100萬大小的數組裡找到一個數,順序遍歷最壞需要100萬次,而logn級別的二分搜尋樹平均只需要20次。二分查詢或者說分而治之的策略都是這個時間複雜度。
  • O(n): 一層迴圈的量級,這個很好理解,1s之內可以完成千萬級別的運算。
  • O(nlogn): 歸併排序、快排的時間複雜度,O(n)的迴圈裡面再是一層O(logn),百萬數的排序能在1s之內完成。
  • O(n²): 迴圈裡巢狀一層迴圈的複雜度,氣泡排序、插入排序等排序的複雜度,萬數級別的排序能在1s內完成。
  • O(2ⁿ): 指數級別,已經是很難接受的時間效率,如未優化的斐波拉契數列的求值。
  • O(!n): 階乘級別,完全不能嘗試的時間複雜度。

空間複雜度分析

​ 如果能理解時間複雜度的分析,那麼空間度的分析就會顯示的格外的好理解。它指的是一段程式執行時,需要額外開闢的記憶體空間是多少,我們來看下這段程式:

function test(arr) {
	const a = 1
    const b = 2
    let res = 0
    for (let i = 0; i < arr.length; i++) {
    	res += arr[i]
    }
    return res
}

​ 我們定義了三個變數,空間複雜度是O(3),又是常數級別的,所以這段程式的空間複雜度又可以表示為O(1)。只用記住是另外開闢的額外空間,例如額外開闢了同等陣列大小的空間,陣列的長度可以表示為n,所以空間複雜度就是O(n),如果開闢的是二維陣列的矩陣,那就是O(n²),因為空間度基本也就是以上幾種情況,計算會相對容易。

常見的空間複雜度就是O(1)O(n)O(n²),像O(logn)O(nlogn)這樣的對數階複雜度平時基本用不到

總結

​ 常見時間複雜度對比:

  • 複雜度也叫漸進複雜度,包括時間複雜度空間複雜度,用來分析演算法執行效率與資料規模之間的增長關係
  • 越高階複雜度的演算法,執行效率越低
  • 常見的複雜度並不多,從低階到高階有:O(1)O(logn)O(n)O(nlogn)O(n^2)

參考文章

  1. 資料結構與演算法之美 | 極客時間