圖解Redis之資料結構篇——連結串列
前言
Redis連結串列為雙向無環連結串列!
ofollow,noindex" target="_blank">圖解Redis之資料結構篇——簡單動態字串SDS 提到Redis使用了簡單動態字串,連結串列,字典(散列表),跳躍表,整數集合,壓縮列表這些資料結構來操作記憶體,並且簡單介紹了Redis簡單動態字串。本篇文章我們繼續來分析連結串列。
連結串列是一種非常常見的資料結構,在Redis中使用非常廣泛,列表物件的底層實現之一就是連結串列。其它如慢查詢,釋出訂閱,監視器等功能也用到了連結串列。
一、複習連結串列
1.1 陣列與連結串列
陣列需要一塊連續的記憶體來儲存,這個特性有利也有弊。好處是其支援根據索引下標"隨機訪問"(時間複雜度為O(1)),但是其插入與刪除操作為了保證在記憶體中的連續性將會變得非常低效(時間複雜度為O(N)),並且其一經宣告就要佔用整塊連續記憶體空間,如果宣告過大,系統可能記憶體不足,宣告過小又可能導致不夠用,而當陣列的空間不足的時候需要對其進行擴容(申請一個更大的空間,將原陣列拷貝過去)。
而連結串列恰恰相反,其不需要一塊連續的記憶體空間,其通過"指標"將一組零散的記憶體連線起來使用。其優點在於本身沒有大小限制,天然支援擴容,插入刪除操作高效(時間複雜度為O(1)),但缺點是隨機訪問低效(時間複雜度為O(N))。並且由於需要額外的空間儲存指標。
連結串列的實現方式有很多種,常見的主要有三個,單向連結串列、雙向連結串列、迴圈連結串列。
1.2 單鏈表
單鏈表中每個節點除了包含資料之外還包含一個指標,叫後繼指標,因此需要額外的空間來儲存後繼節點的地址。有兩個特殊的節點,頭結點和尾節點,其中頭節點用來記錄連結串列的基地址,有了它就可以遍歷整個連結串列,尾節點的後繼指標不是指向下一個節點,而是指向一個空地址NULL表示這是連結串列上最後一個節點。與陣列一樣,單鏈表也支援資料的查詢、插入和刪除操作,其中插入和刪除操作只需要考慮相鄰節點指標的變化,因此為常數級時間複雜度O(1)。要想隨機訪問第 k 個元素,就沒有陣列那麼高效了。因為連結串列中的資料並非連續儲存的,所以無法像陣列那樣,根據首地址和下標,通過定址公式就能直接計算出對應的記憶體地址,而是需要根據指標一個結點一個結點地依次遍歷,直到找到相應的結點,因此時間複雜度為O(N)。
1.3 雙向連結串列
雙向連結串列和單鏈表不同的是多了一個前驅指標,雙向連結串列需要額外的兩個空間來儲存後繼結點和前驅結點的地址。因此儲存同樣多的資料,雙向連結串列佔用比單鏈表更多的空間。但其優點在於支援雙向遍歷,體現在以下兩個方面。
- 在有序連結串列中查詢某個元素,單鏈表由於只有後繼指標,因此只能從前往後遍歷查詢時間複雜度為O(N),而雙向連結串列可以雙向遍歷,因此可以採用二分的思想進行查詢,時間複雜度為O(logn)。
- 刪除給定指標指向的結點。假設已經找到要刪除的節點,要刪除就必須知道其前驅節點和後繼節點,單鏈表想要知道其前驅節點只能從頭開始遍歷,時間複雜度為0(n),而雙向連結串列由於儲存了其前驅節點的地址,因此時間複雜度為0(1)。
1.4 迴圈連結串列
顧名思義。迴圈連結串列與單、雙鏈表不同的是其呈環狀,單迴圈連結串列中其尾節點並非指向NULL而是指向頭結點。雙迴圈連結串列中其頭節點的前驅指標指向尾節點,尾節點的後繼指標指向頭結點。迴圈連結串列的優勢在於鏈尾到鏈頭,鏈頭到鏈尾比較方便適合處理的資料具有環型結構特點。
二、Redis連結串列
2.1 雙向無環連結串列
Redis連結串列使用雙向無環連結串列。
如圖所示,Redis使用一個listNode結構來表示。
typedef struct listNode { // 前置節點 struct listNode *prev; // 後置節點 struct listNode *next; // 節點的值 void *value; } listNode;
2.2 list結構
同時Redis為了方便的操作連結串列,提供了一個list結構來持有連結串列。如下圖所示
typedef struct list{ //表頭節點 listNode *head; //表尾節點 listNode *tail; //連結串列所包含的節點數量 unsigned long len; //節點值複製函式 void *(*dup)(void *ptr); //節點值釋放函式 void *(*free)(void *ptr); //節點值對比函式 int (*match)(void *ptr,void *key); }list;
Redis連結串列結構其主要特性如下:
- 雙向:連結串列節點帶有前驅、後繼指標獲取某個節點的前驅、後繼節點的時間複雜度為0(1)。
- 無環: 連結串列為非迴圈連結串列表頭節點的前驅指標和表尾節點的後繼指標都指向NULL,對連結串列的訪問以NULL為終點。
- 帶表頭指標和表尾指標:通過list結構中的head和tail指標,獲取表頭和表尾節點的時間複雜度都為O(1)。
- 帶連結串列長度計數器:通過list結構的len屬性獲取節點數量的時間複雜度為O(1)。
- 多型:連結串列節點使用void*指標儲存節點的值,並且可以通過list結構的dup、free、match三個屬性為節點值設定型別特定函式,所以連結串列可以用來儲存各種不同型別的值。
2.3 雙向無環連結串列在Redis中的使用
連結串列在Redis中的應用非常廣泛,列表物件的底層實現之一就是連結串列。此外如釋出訂閱、慢查詢、監視器等功能也用到了連結串列。我們現在簡單想一想Redis為什麼要使用雙向無環連結串列這種資料結構,而不是使用陣列、單向連結串列等。既然列表物件的底層實現之一是連結串列,那麼我們通過一個表格來分析列表物件的常用操作命令。如果分別使用陣列、單鏈表和雙向連結串列實現列表物件的時間複雜度對照如下:
操作\時間複雜度 | 陣列 | 單鏈表 | 雙向連結串列 |
---|---|---|---|
rpush(從右邊新增元素) | O(1) | O(1) | O(1) |
lpush(從左邊新增元素) | 0(N) | O(1) | O(1) |
lpop (從右邊刪除元素) | O(1) | O(1) | O(1) |
rpop (從左邊刪除元素) | O(N) | O(1) | O(1) |
lindex(獲取指定索引下標的元素) | O(1) | O(N) | O(N) |
len (獲取長度) | O(N) | O(N) | O(1) |
linsert(向某個元素前或後插入元素) | O(N) | O(N) | O(1) |
lrem (刪除指定元素) | O(N) | O(N) | O(N) |
lset (修改指定索引下標元素) | O(N) | O(N) | O(N) |
我們可以看到在列表物件常用的操作中雙向連結串列的優勢所在。但雙向連結串列因為使用兩個額外的空間儲存前驅和後繼指標,因此在資料量較小的情況下會造成空間上的浪費(因為資料量小的時候速度上的差別不大,但空間上的差別很大)。這是一個時間換空間還是空間換時間的思想問題,Redis在列表物件中小資料量的時候使用壓縮列表作為底層實現,而大資料量的時候才會使用雙向無環連結串列。(關於列表物件後續會有文章繼續介紹可訪問我的個人部落格持續關注 www.kxamm.com )
小結
連結串列作為一種非常常用的資料結構,內建在許多程式語言裡面,更是找工作過程中經常問的面試題之一。本篇文章簡單複習了連結串列這種資料結構常見的幾種形式,並且簡單分析了Redis中連結串列的使用。下篇文章將繼續分享Redis中用到的資料結構Hash。敬請關注!
參考
《Redis設計與實現》
《Redis開發與運維》
《Redis官方文件》