跳到主要內容

資料結構

資料怎麼排,決定了哪些操作便宜

主題 · 陣列型與指標型

陣列型與指標型

所有資料結構都建立在兩種擺放方式上:放在一段連續的記憶體裡、用索引直接算出位置,或是散在各處、用指標一個接一個串起來。這個選擇決定了哪些操作便宜、哪些昂貴。

操作
串列節點
1/13

陣列:一整段

0x100050121723394145261170x104068891310411

鏈結串列:節點散在各處

0x80000x80409#4→0x80800x80C011#7→0x81000x81402#6→3#3→0x81807#2→6#8→0x81C00x82004#11∅0x824012#1→14#5→0x82808#9→0x82C05#0→13#10→
正在讀正在寫已經碰過已經搬進快取的那一列

同樣 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 · boolean
typeof 42; // → "number"
typeof 42n; // → "bigint"
typeof null; // → "object"
7 / 2; // → 3.5
Math.trunc(7 / 2); // → 3
0.1 + 0.2; // → 0.30000000000000004
1 / 0; // → Infinity
Number.MAX_SAFE_INTEGER; // → 9007199254740991
2 ** 53 + 1; // → 9007199254740992
Number.isSafeInteger(2 ** 53); // → false
2n ** 64n; // → 18446744073709551616n
1n + 1; // → throws TypeError
"5" + 1; // → "51"
"5" - 1; // → 4
"a".charCodeAt(0); // → 97
String.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 On = 1,000n = 4,000n = 16,000成長倍數:實測(理論)
陣列:讀第 i 個O(1)111×1.0 (×1.0)
串列:讀第 i 個(平均)O(n)4741,8947,576×16 (×16)
陣列:開頭插入O(n)2,0018,00132,001×16 (×16)
串列:開頭插入O(1)111×1.0 (×1.0)
陣列:加總的快取未命中O(n)1255002,000×16 (×16)
串列(分散):加總的快取未命中O(n)7093,63415,605×22 (×16)

最後兩列的 Big O 一樣,常數卻差了將近 8 倍:Big O 不看快取,實際跑起來看。

和其他做法比

讀第 i 個(平均)開頭插入加總:快取未命中加總:估計時間
陣列,n = 1,00012,00112513.4 µs
串列(分散),n = 1,000474170971.2 µs
陣列,n = 4,00018,00150053.5 µs
串列(分散),n = 4,000189413,634363.8 µs
陣列,n = 16,000132,0012,000214 µs
串列(分散),n = 16,0007576115,6051560.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)。
  • 串列的每個值都多帶一個指標(雙向串列兩個),小型資料的記憶體用量會是陣列的兩、三倍。

LeetCode 練習