算法與數據結構(三)數組與鏈表

這次來說說數組與鏈表。在說數組與鏈表之前,先來介紹一下線性表和非線性表。

線性表 LinearList

顧名思義,線性表的結構是線性的。就像圖書館書架上的書一樣,每一行的書都是整齊的排列在直直的書板上。高樓在垂直方向上也可以說是線性的,每一層樓都可以看作是一個單位,依次上下排列。

說到線性表的本質,就是每個元素之間,只有前後關係。書架上的書,相鄰的兩本書,其中一本必然在另一本的前邊或者後邊(不考慮上下層)。相鄰的兩層樓,其中一層必然在另一層的上邊或者下邊。

由此看來,數組、鏈表都屬於線性表,另外棧、隊列也屬於此類。

非線性表

與線性表對應的就是非線性表。非線性表的每個元素之間關係更加多元,不只是有前後關係。

一棵樹,樹幹長出樹枝,樹枝又可以分叉,最後長出樹葉。那樹枝之間有父子關係、兄弟關係。一張地圖,主要地點之間的關係則更加複雜。

數據結構中的樹、圖等都是非線性結構。

數組與鏈表

先來看一張圖。

這是一個抽屜櫃。但這也能夠反應硬盤空間的結構。抽象來看,硬盤本質上就是一個一維的、每個元素相等大小緊密排列的結構,雖然硬盤我們看到是一個 3D 實物,但最終總能夠映射成一維的空間。

數組則完全是硬盤結構的映射,使用連續的內存空間存儲數據。可以說數組這種數據結構在使用內存上非常直接,申請一塊連續的內存空間,然後存儲數據即可。就好像 a、b、c 三人的房屋在一條街上各自相鄰,去完 a 家,往右走兩步就是 b 家,再走兩步就是 c 家。

但是這也帶來了一個核心問題——如果內存中沒有一塊連續的內存空間可供使用怎麼辦。在內存中我們運行着操作系統和衆多軟件。軟件運行會加載到內存中,結束時會釋放掉。經過反覆的這個過程,我們的內存空間會變得十分零碎。很可能空餘的空間有 100 個空位,但是這 100 個空位是不連續的、被其他正在使用中的內存分割開的,那我們申請 100 個空間的數組時也會失敗。

這也導致了鏈表的產生。

鏈表爲了解決數組強制要求分配連續空間的問題,通過在當前元素中記錄下一個元素地址的方式,將多個分散在內存空間中的元素聯繫起來。就好像 a、b、c 三人的房屋並不是相連,而是隔了很遠,但是 a 知道 b 家的地址,b 又知道 c 家的地址,這樣我們只要知道 a 的地址,總能找到 b、c 的位置。

天下沒有免費的午餐,鏈表雖然不需要連續的內存空間,但是每個元素需要記憶下一個元素的位置,這增加了鏈表單個元素的空間佔用。相當於鏈表通過單個元素的空間佔用來解綁整體內存空間連續的強制要求。算法與數據結構中充滿了這種 Trade-Off。

總結一下,數組要求內存空間連續,但是隻要通過簡單的 base_address + k * size 的方式,就能夠馬上訪問第 k 個元素,即具有 O(1) 隨機訪問的能力。鏈表不要求內存空間連續,但是需要從頭開始,一個個依次訪問元素之後才能夠找到第 k 個元素。

對比

不管是數組還是鏈表,我們對其進行操作一般包括:插入數據、刪除數據、查找數據。接下來我們通過這三個操作來對比一下兩者。而分析的時候必然會涉及到時間複雜度,我們先來簡單說說時間複雜度如何分析。

時間複雜度

我們一般會使用大 O 表示法來作爲時間複雜度分析的工具,或者說表示方式。我們在進行時間複雜度分析時,並不關注算法的實際執行時間,而是關注代碼執行時間在數據規模增長時的變化趨勢。簡單來說,我們分析的是在數據規模 n 下,代碼的執行次數。最後用大 O 表示法表示。 結合如下僞代碼,介紹一下其分析方法:

  1. 只關注循環執行次數最多的代碼,忽略其常量、低階、係數;
  2. 加法法則:一段代碼的總複雜度,等於量級最大的那段代碼的複雜度;
  3. 乘法法則:嵌套代碼的複雜度等於內外代碼複雜度的乘積。
complexity(int n) {
    int[] array = new int[n];

    // 1: O(1)
    int a = array[0];
    int b = array[1];

    // 2: O(n)
    for (int i = 0; i < n; i++) {
        print(array[i]);
        print(array[i]);
    }

    // 3: o(n^2)
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            print(array[i] * array[j]);
        }
    }
}

這裏我們的數據量爲 n。

首先我們看註釋 1 處,雖然這裏訪問了兩次 array,但是我們忽略其常量,所以這段代碼複雜度爲 O(1)。

我們再看註釋 2 處,這裏通過循環對數組進行打印了兩次,訪問了數組的所有元素,所以其代碼複雜度爲 O(2n),但是我們會忽略係數,所以這段代碼時間複雜度爲 O(n)。

我們最後看註釋 3 處,通過兩層循環嵌套,每個循環均訪問了數據所有元素,打印數組元素的乘積,每層循環的時間複雜度爲 O(n),根據乘法法則,這段代碼的時間複雜度爲 O(n) * O(n),即 O(n^2)。

那整體來看,這個函數的時間複雜度是多少呢?根據加法法則,同時我們會忽略低階、常量的時間複雜度,最終我們的時間複雜度爲 O(n^2)。

空間複雜度的分析方式和時間複雜度類似,只不過是把代碼執行次數換成空間佔用。

常見的時間複雜度有:O(1)、O(logn)、O(n)、O(nlogn)、O(n^2)、O(n!)。

接下來我們繼續對比數組和鏈表。

插入數據

數組的插入數據分爲兩種,一種是有序數組插入後仍要保持數組元素有序,另一種是無序數組插入數據。先來看看第一種,爲了保證數組的有序,所以我們需要將插入位置之後的數據往後搬移位置,最優情況是插入數組尾部,無需搬移數據,時間複雜度爲 O(1),最壞的情況是插入頭部,需要搬移所有的數據,時間複雜度爲 O(n)。平均下來時間複雜度爲 O(n),其平均時間複雜度可以通過權重的方式計算,在此不再詳述。對於第二種無需數組則比較簡單,假如我們插入的位置爲 k,只需將第 k 個元素移到尾部,然後插入數據即可,時間複雜度爲 O(1)。

鏈表的插入就比較簡單,直接操作一下元素的指針指向即可,在 O(1) 時間複雜度即可完成。當然這裏說的是已經知道插入位置的情況,不包含查找插入位置的過程。

刪除數據

數組刪除數據時,爲了保證佔用空間的連續,刪除後需要搬移後續數據,所以其時間複雜度爲 O(n)。當然這裏可以做一下優化,即刪除數據後不要馬上搬移數據,而是先記錄下來,空間不夠時再進行一次搬移數據的操作。這個就是 Java 虛擬機垃圾回收機制中 標記清除算法 的核心思想。

鏈表刪除數據也比較簡單,直接操作元素的指針指向即可,時間複雜度爲 O(1)。

訪問數據

假設我們現在要訪問第 k 個元素,數組因爲空間連續的特性,通過上述地址計算公式直接拿到第 k 個元素的地址,直接訪問即可,時間複雜度爲 O(1)。鏈表則比較麻煩,因爲其空間不連續,我們需要從頭開始,一個一個的依次拿到後續元素的地址,直到第 k 個。所以其時間複雜度爲 O(n)。

綜上所述,我們可以得到下表。

數組 鏈表
隨機讀取 O(1) O(n)
插入 O(n) O(1)
刪除 O(n) O(1)

總結

最後我們可以得出結論,數組與鏈表的主要區別在於內存空間是否連續。數組要求內存空間連續,所以分配內存時條件更加苛刻,但是這讓數組能夠 O(1) 隨機訪問元素。鏈表無需內存空間連續,分配內存的條件比較寬鬆,但是這導致鏈表佔用空間更大,訪問元素時間複雜度較高。

最後,我正在編寫一個小程序,它能夠可視化一些算法與數據結構,讓你更直觀的學習。目前支持了主要的排序算法,更多內容擴充中,敬請期待。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章