陣列型與指標型
所有資料結構都建立在兩種擺放方式上:放在一段連續的記憶體裡、用索引直接算出位置,或是散在各處、用指標一個接一個串起來。這個選擇決定了哪些操作便宜、哪些昂貴。
操作
串列節點
1/13
陣列:一整段
鏈結串列:節點散在各處
正在讀正在寫已經碰過已經搬進快取的那一列
同樣 12 個數字,左邊是陣列:一段連續的記憶體,第 i 個就在「起點+8×i」。右邊是鏈結串列:每個節點 16 個位元組(值+下一個節點的位址),散在記憶體各處。每一列是一條 64 位元組的快取線,CPU 一次就搬一整條。按播放看「全部加總」兩邊各碰了哪些位址。
陣列:記憶體存取
12
陣列:快取線
2
串列:記憶體存取
12
串列:快取線
8
模型:每個值 8 位元組、每個節點 16 位元組、每條快取線 64 位元組;一次操作中載入過的快取線就留著。新插入的節點配置在畫面外。
亮起來的是這一步執行的程式碼
// Array: element i lives at base + 8 * i, so reaching it is one step.function arrayGet(a: number[], i: number): number { return a[i];} function arraySum(a: number[]): number { let total = 0; for (let i = 0; i < a.length; i++) total += a[i]; return total;} function arrayInsert(a: number[], at: number, value: number): void { a.push(0); for (let i = a.length - 1; i > at; i--) a[i] = a[i - 1]; a[at] = value;} // Linked list: each node knows only where the next one is.class ListNode { next: ListNode | null = null; constructor(public value: number) {}} function listGet(head: ListNode, i: number): number { let node = head; for (let k = 0; k < i; k++) node = node.next!; return node.value;} function listSum(head: ListNode | null): number { let total = 0; for (let node = head; node; node = node.next) total += node.value; return total;} function listInsert(head: ListNode | null, at: number, value: number): ListNode { const fresh = new ListNode(value); if (at === 0) { fresh.next = head; return fresh; } let prev = head!; for (let k = 1; k < at; k++) prev = prev.next!; fresh.next = prev.next; prev.next = fresh; return head!;}每個資料結構是哪一型
模型假設與範圍
- Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。
什麼時候用
- 需要用位置直接跳到第 i 個、或常常從頭掃到尾:選陣列型。連續的記憶體讓 CPU 一次搬一整條快取線,還能預先抓下一條。
- 手上已經握著某個節點、要在它前後頻繁插入刪除,或元素很大搬不動:選指標型。改兩個指標就好,其他元素一個都不用動。
- 兩種都要:用組合型。LRU(Least Recently Used,最近最少使用)快取用雜湊表(陣列)一步找到節點,再用雙向串列(指標)調整順序。
和其他主題的關係
語言內建的版本
number · bigint · string · booleantypeof 42; // → "number"typeof 42n; // → "bigint"typeof null; // → "object"7 / 2; // → 3.5Math.trunc(7 / 2); // → 30.1 + 0.2; // → 0.300000000000000041 / 0; // → InfinityNumber.MAX_SAFE_INTEGER; // → 90071992547409912 ** 53 + 1; // → 9007199254740992Number.isSafeInteger(2 ** 53); // → false2n ** 64n; // → 18446744073709551616n1n + 1; // → throws TypeError"5" + 1; // → "51""5" - 1; // → 4"a".charCodeAt(0); // → 97String.fromCharCode(98); // → "b"每個 → 後面的結果都是實際執行這段程式碼驗證過的。
- JavaScript 只有一種數字:
number是 64 位元浮點數(IEEE 754 double)。整數只有在 ±2^53 以內是精確的,再大就會悄悄失準;要更大的整數用bigint(數字後面加n),但bigint不能和number混著算。 - 整數除法要自己取整:
Math.trunc(a / b)往零取整,Math.floor(a / b)往下取整,負數時兩者不同。 +只要一邊是字串就變成串接:"5" + 1是"51","5" - 1卻是4。- 陣列裡的數字不一定連續擺放;要真正連續、固定大小的數字,用 typed array,例如
Int32Array、Float64Array。
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 讀第 i 個:陣列 | O(1) | O(1) |
| 讀第 i 個:串列 | O(n) | O(n) |
| 開頭或中間插入:陣列 | O(n) | O(n) |
| 在手上的節點後插入:串列 要先走到那個節點的話,走的那段是 O(n) | O(1) | O(1) |
| 從頭到尾掃一遍:兩者 同樣 O(n),但陣列每 8 個值才一次快取未命中,分散的串列幾乎每個節點一次 | O(n) | O(n) |
空間:O(n),串列每個值多一個 8 位元組的指標,記憶體是陣列的兩倍
Big O 實測:n 變大時步數怎麼長
數的是:記憶體存取次數或快取未命中
| Big O | n = 1,000 | n = 4,000 | n = 16,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 陣列:讀第 i 個 | O(1) | 1 | 1 | 1 | ×1.0 (×1.0) |
| 串列:讀第 i 個(平均) | O(n) | 474 | 1,894 | 7,576 | ×16 (×16) |
| 陣列:開頭插入 | O(n) | 2,001 | 8,001 | 32,001 | ×16 (×16) |
| 串列:開頭插入 | O(1) | 1 | 1 | 1 | ×1.0 (×1.0) |
| 陣列:加總的快取未命中 | O(n) | 125 | 500 | 2,000 | ×16 (×16) |
| 串列(分散):加總的快取未命中 | O(n) | 709 | 3,634 | 15,605 | ×22 (×16) |
最後兩列的 Big O 一樣,常數卻差了將近 8 倍:Big O 不看快取,實際跑起來看。
和其他做法比
| 讀第 i 個(平均) | 開頭插入 | 加總:快取未命中 | 加總:估計時間 | |
|---|---|---|---|---|
| 陣列,n = 1,000 | 1 | 2,001 | 125 | 13.4 µs |
| 串列(分散),n = 1,000 | 474 | 1 | 709 | 71.2 µs |
| 陣列,n = 4,000 | 1 | 8,001 | 500 | 53.5 µs |
| 串列(分散),n = 4,000 | 1894 | 1 | 3,634 | 363.8 µs |
| 陣列,n = 16,000 | 1 | 32,001 | 2,000 | 214 µs |
| 串列(分散),n = 16,000 | 7576 | 1 | 15,605 | 1560.9 µs |
存取次數是記錄下來的軌跡長度(讀第 i 個在 200 個隨機位置取平均);未命中是把加總的位址流過一個 512 條快取線(32 KiB)的 LRU 快取數出來的。估計時間假設命中 1 ns、未命中 100 ns。兩邊加總都是 O(n),但陣列每 8 個數字才踩到一條新的快取線,分散的串列幾乎每個節點都踩到一條,n = 16,000 時估計慢了約 7 倍。剛配置的串列節點一個接一個,每條快取線放 4 個節點,加總時未命中是 4,000 次,介於兩者之間。
真實世界裡的它
- Python 的 list、C++ 的 std::vector、Java 的 ArrayList 都是陣列型;C++ 的 std::list、Java 的 LinkedList 是指標型。
- 遊戲引擎把同類型的資料排成連續陣列(資料導向設計),就是為了讓每一幀掃過幾萬個物件時都留在快取裡。
- 作業系統核心大量使用侵入式串列:節點直接嵌在物件裡,插入刪除不用另外配置記憶體。
取捨與陷阱
- 同樣是 O(n) 不代表一樣快:掃一遍分散的串列可能比掃陣列慢上好幾倍,差別全在快取未命中。
- 串列「插入是 O(1)」的前提是手上已經有那個節點;如果要先找位置,找的那一步就是 O(n)。
- 串列的每個值都多帶一個指標(雙向串列兩個),小型資料的記憶體用量會是陣列的兩、三倍。