什麼是「連結串列科普」?
連結串列是一種物理儲存單元上非連續、非順序的儲存結構,資料元素的邏輯順序是通過連結串列中的指標連結次序實現的。
什麼是「順序儲存結構科普」?
在計算機中用一組地址連續的儲存單元依次儲存線性表的各個資料元素,稱作線性表的順序儲存結構。
多數高階語言的「陣列」使用「順序儲存結構」,不過早期的 javascript 引擎用了「鏈式儲存結構」。Chrome 的 V8 的陣列使用了「順序儲存結構」與「鏈式儲存結構」混合模式;大多數情況下,V8 下的陣列是「順序儲存結構」,所以我們就假裝 V8 的陣列使用的是「順序儲存結構」吧!(-_-! 心虛)
javascript 開發需要「連結串列」嗎?自問自答
大多數情況下 javascript 開發關心的是「資料的邏輯結構」而非「資料的儲存結構」,似乎「連結串列」跟 javascript 開發沒什麼關係。But…「連結串列」在一些情況下能有效提升程式碼的效能,特別是在H5遊戲的過程中。
假設有一個業務需要高頻率地向一張「線性表科普」插入或刪除節點。通常筆者會用陣列表示「線性表」,因為 javascript 的陣列有一系列成熟好用的 APIs (如:unshift / push / shift / pop / splice 等)可以完成插入與刪除節點的操作。但是陣列(順序儲存結構)的 unshift
& shift
& splice
的演算法時間複雜度是 O(n) ,這情況可能「連結串列」是更好的選擇。
圖解連結串列
先看一下最簡單的單向連結串列:
往連結串列裡插入一個節點:
剔除連結串列裡的節點:
往連結串列裡插入一條連結串列:
剪除連結串列的一段切片:
通過上面的圖示,可以很清晰地瞭解到單連結串列的優勢:插入節點或連結串列片段的演算法時間複雜度為O(2);刪除節點或連結串列片段的演算法時間複雜度為O(1)
實現雙向連結串列
「單向連結串列」效率雖然高,不過侷限性比較大。所以筆者想實現的是「雙向連結串列」。雙向連結串列插入節點或連結串列的演算法時間複雜度為 O(4),刪除節點或連結串列片段的演算法時間複雜度為O(2)。
雙向連結串列的結構如下:
- 節點指標 ——「前驅」與「後繼」
- 連結串列指標 —— 「頭指標」、「尾指標」和「遊標指標」
用一個匿名物件作為連結串列上的節點,如下虛擬碼:
1 2 3 4 5 6 7 |
function generateNode(data) { return { data: data, // 資料域 next: null, // 前驅指標 prev: null // 後繼指標 } } |
宣告變數 HEAD
, TAIL
, POINTER
& length
分別指代「頭指標」,「尾指標」,「遊標指標」和 「連結串列長度」,那麼構建一個雙向連結串列如下虛擬碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
let HEAD, TAIL, POINTER, length = 0; // 建立一條長度為5的雙向連結串列 [0, 1, 2, 3, 4].forEach((data, index, arr) => { let node = generateNode(data); // 第一個節點 if(index === 0) { HEAD = node; } else { // 指定前驅後繼指標 [node.prev, POINTER.next] = [POINTER, node]; // 最後一個節點 index === arr.length - 1 && (TAIL = node) } // 指向當前節點 POINTER = node; ++length; }); // 遊標指標回退到頭部 POINTER = HEAD; |
連結串列結構本身是個很簡單的結構,20行左右程式碼可以完成雙向連結串列資料結構的構建。
定製 APIs
上一節雖然實現了「雙向連結串列」的資料結構,但連結串列還處在很原始的狀態,操作起來比較麻煩,為了方便操作連結串列得為連結串列量身定做一套 APIs。陣列有一系列成熟且好用的 APIs,筆者想借鑑陣列的 APIs 為連結串列定製以下的 APIs:
Name | Detail | Name | Detail | Name | Detail | Name | Detail |
---|---|---|---|---|---|---|---|
shift | 參見陣列 | unshift | 參見陣列 | pop | 參見陣列 | push | 參見陣列 |
slice | 參見陣列 | splice | 參見陣列 | concat | 參見陣列 | reverse | 參見陣列 |
sort | 參見陣列 | indexOf | 參見陣列 | length[屬性] | 參見陣列 | – | – |
at | 指標定位 | prev | 指標前移 | next | 指標後退 | curr | 當前指標 |
first | 頭節點 | last | 尾節點 | remove | 刪除節點 | clone | 克隆連結串列 |
insertAfter | 插入節點 | insertBefore | 插入節點 | insertChainAfter | 插入連結串列 | insertChainBefore | 插入連結串列 |
HEAD[屬性] | 頭指標 | TAIL[屬性] | 尾指標 | setHead | 重置頭指標 | setTail | 重置尾指標 |
POINTER[屬性] | 遊標指標(當前位置) | setPointer | 設定當前指標 | – | – | – | – |
上表與陣列同名的 APIs,表示用法與功能與陣列一樣。
為了突顯「連結串列性」筆者新增了四個 insert*
。insert*
的作用是向主連結串列指定位置插入節點或連結串列。APIs 不小心被筆者寫多了,這裡就不展開介紹它們的實現過程了。有興趣的同學可以移步:https://github.com/leeenx/es6-utils/blob/master/modules/Chain_v2.js
迴圈連結串列
筆者以往都是用陣列來模擬迴圈連結串列,如下:
1 2 3 4 5 6 7 8 9 10 |
Array.prototype.next = function() { var cur = this[0]; this.push(this.shift()); return cur; } var arr = [1, 2, 3, 4, 5]; var count = 0; while(count++<20) { console.log(arr.next()); } |
有了 Chain
類後,可以直接這樣寫:
1 2 3 4 5 6 |
let circle = new Chain([1, 2, 3, 4, 5]); // 連結串列頭咬尾 circle.TAIL.next = circle.HEAD; for(let i = 0; i < 20; ++i) { console.log(chain.next()); } |
結語
近期有些同學在問筆者,使用 Chain
類真的可以提升效能嗎?這個需要分情況,如果是比較長的「線性表」做高頻的「刪除」或「插入」操作,自然是使用 Chain
有演算法上的優勢。但是,對於短的「線性表」來說,使用陣列更快一些,因為 V8 的陣列效能相當高,筆者認為小於 200 的「線性表」都可以直接使用陣列。
本文實現的 Chain
類託管在:https://github.com/leeenx/es6-utils/blob/master/modules/Chain_v2.js
感謝耐心閱讀完本文章的讀者。本文僅代表筆者的個人觀點,如有不妥之處請不吝賜教。