並查集與伸展樹各自證明了「單次可以很慢,一串操作仍然很快」。那兩篇把位能算在特定結構上。本篇把三種通用算法寫清楚:聚集法、記帳法、位能法,並用二元計數器與動態陣列當可以手算的例子。之後再回去看並查集與伸展樹,會知道它們只是位能函數不同。
相關教材:Disjoint_Set_Teaching_Materials.md、Splay_Tree_Teaching_Materials.md、Heap_Priority_Queue_Teaching_Materials.md。
目錄
1. 攤還不是平均
平均情況對輸入分布取期望。換一組輸入,期望可以不成立。
攤還是對任意輸入的一串操作給上界:(n) 次操作的總成本是 (T(n)),則每次攤還成本是 (T(n)/n)。其中幾次可以很貴,貴的次數被便宜的次數補上。沒有機率。
最壞單次成本仍然可能是 (O(n))。呼叫端若要求每一次都是 (O(1)) 最壞(即時系統),攤還 (O(1)) 不夠,要換一個真正最壞 (O(1)) 的結構。
2. 聚集法
直接把 (n) 次操作的總成本加總,再除以 (n)。
二元計數器。 從 0 開始做 (n) 次加一。成本定義成「這一次翻轉了幾個位元」。
| 次數 | 計數器 | 翻轉數 |
|---|---|---|
| 1 | 0001 | 1 |
| 2 | 0010 | 2 |
| 3 | 0011 | 1 |
| 4 | 0100 | 3 |
| 5 | 0101 | 1 |
第 (k) 個位元(從 0 數)每 (2^k) 次才翻轉一次,(n) 次操作裡大約翻 (n/2^k) 次。總翻轉數
[ \sum_{k=0}^{\lfloor\log n\rfloor} \frac{n}{2^k} < n\sum_{k=0}^{\infty}\frac{1}{2^k} = 2n ]
所以 (n) 次加一的總成本 (< 2n),攤還每次 (O(1))。單次最壞仍是 (O(\log n))(從 (2^k-1) 進位到 (2^k))。
聚集法的缺點是:操作種類一多,總成本不容易閉式求和。記帳法與位能法把「預存的便宜」放在結構上,方便一次只看一個操作。
3. 記帳法
每次操作付一個固定的攤還費用。實際成本較低時,多餘的錢存成結構上的信用。實際成本較高時,用存好的信用付。規則:信用永遠不能變成負的。那麼攤還費用的總和就是總成本的上界。
動態陣列 push。 容量滿了就把容量加倍,把舊元素複製過去。
收費:每次 push 收 3 元。
- 1 元付這次寫入。
- 1 元存在這個新元素上,留給它以後被複製。
- 1 元存在陣列裡「較舊的那一半」某個還沒有信用的元素上。
容量從 (n) 倍增到 (2n) 時,要複製 (n) 個元素。這 (n) 個元素在上一次倍增之後的 (n) 次插入裡,已經各自被存了複製用的 1 元。信用夠付這次複製,不會變負。因此每次 push 的攤還成本是 3,(n) 次是 (O(n))。
縮容量要另外設計(例如降到 1/4 才縮回一半),否則在門檻附近來回 push/pop 會每次都重配,信用制度會破功。
4. 位能法
位能 (\Phi) 是結構狀態的函數,(\Phi \ge 0),初始通常為 0。一次操作的攤還成本定義成
[ \hat{c} = c + \Delta\Phi = c + \Phi_{\text{after}} - \Phi_{\text{before}} ]
把一串操作加起來,中間的 (\Phi) 消掉,只剩總實際成本 (= \sum\hat{c} + \Phi_0 - \Phi_n)。若 (\Phi_n \ge 0=\Phi_0),總實際成本 (\le \sum\hat{c})。
同一個動態陣列。 令 (n) 為元素個數、(c) 為容量,
[ \Phi = 2n - c ]
空陣列 (n=c=0),(\Phi=0)。只要 (c \ge n) 且倍增策略讓 (c < 2n) 在插入後成立,(\Phi) 不會長期為負;倍增當下要單獨算。
不倍增的 push:實際成本 1,(n) 加 1,(\Delta\Phi=2),攤還成本 (1+2=3)。
觸發倍增的 push:設當下 (n=c)(已滿)。複製成本 (c),然後 (n) 變成 (c+1)、容量變成 (2c)。
- 之前 (\Phi = 2c - c = c)
- 之後 (\Phi = 2(c+1) - 2c = 2)
- (\Delta\Phi = 2-c)
- 實際成本 (c+1)
- 攤還成本 ((c+1)+(2-c)=3)
兩種 push 的攤還都是 3。這和記帳法的 3 元是同一件事的兩種記法。
5. 動態陣列
把上面的結論收成可以使用的事實:
| 操作 | 最壞單次 | 攤還 |
|---|---|---|
push(倍增) |
(O(n)) | (O(1)) |
| 依索引讀寫 | (O(1)) | (O(1)) |
pop(不縮容量) |
(O(1)) | (O(1)) |
(n) 次從空陣列 push 的總複製成本是 (1+2+4+\cdots+n < 2n),聚集法直接得到同一個 (O(n))。三種方法應該得出同一階,若差了一階,是位能或收費設錯,不是資料結構突然變了。
std::vector 的成長就是這個策略。迴圈裡 push_back (n) 次是線性,不是每次 (O(n)) 的二次。若事先知道長度,reserve 把那一次配置提前付清,連攤還都不需要。
6. 和均攤到各資料結構的關係
| 結構 | 貴的那一次 | 位能在量什麼 | 攤還結果 |
|---|---|---|---|
| 動態陣列 | 倍增複製 | 元素數相對容量的「滿度」 | push (O(1)) |
| 二元計數器 | 連續進位 | 也可以用「1 的個數」當位能 | 加一 (O(1)) |
| 並查集 | 沿長路徑找根 | 見並查集教材;和秩、路徑壓縮綁在一起 | 近乎 (O(1)) |
| 伸展樹 | 一次 splay 沿路旋轉 | 節點深度的對數位能 | 搜尋 (O(\log n)) |
不要把伸展樹的攤還 (O(\log n)) 說成最壞 (O(\log n))。單次 splay 可以碰到 (\Theta(n)) 的鏈。
堆積的插入是最壞 (O(\log n)),不是攤還。不要為了用本篇的詞彙,把最壞界講成攤還界。
7. 常見陷阱
- 用期望代替攤還。 快速排序的 (O(n\log n)) 是平均,洗牌沒做就沒有。動態陣列的線性總成本不需要隨機。
- 位能最後是負的。 總和式不再是上界。設計完要檢查 (\Phi\ge 0)。
- 只分析
push、產品同時在門檻上pop。 縮容量的策略要一起寫進位能,否則攤還會被打壞。 - 把攤還界寫進程式的延遲保證。 使用者若在倍增那一次被卡住,攤還救不了那一幀。
- 聚集法的總和漏掉某一種操作。 (n) 次
push加上 (n) 次依索引寫入,後者每次 (O(1)),總和仍線性;若中間夾了會掃全表的操作,要另外加。
8. 練習題
- 計數器從 0 加到 16(做 16 次加一)。最低位元翻幾次?次低位元呢?總翻轉數的上界 (2\times 16=32),實際比 32 小還是可能超過?
- 動態陣列容量序列是 1,2,4,8。從空陣列
push8 次,複製了幾個元素? - 用 (\Phi=2n-c),說明不倍增的
push攤還成本是 3。 - 為什麼「滿了才倍增、一空一格就縮回一半」會讓攤還失敗?
- 並查集教材的近乎常數,是平均情況還是攤還?差在哪一句話?
參考答案
- 最低位元每次都翻,16 次。次低位元每兩次翻一次,8 次。總翻轉數小於 32,因為 (\sum n/2^k) 的上界是把無窮等比加滿才到 (2n),實際位元數有限,而且最後一次不會把所有理論上的高位都用完。不會超過這個上界。
- 容量 1→2 複製 1 個,2→4 複製 2 個,4→8 複製 4 個,共 7 個。第 8 次寫入本身不再複製。
- 實際成本 1,(n) 增加 1、(c) 不變,(\Delta\Phi=2),(1+2=3)。
- 容量 (n) 已滿時
push倍增到 (2n),接著pop一個就縮回 (n),再push又倍增。每次都複製 (\Theta(n)) 個元素,信用還沒存夠就被花掉,(n) 次這樣的配對是 (\Theta(n^2))。延後到剩 1/4 才縮,才能讓縮容也有信用可付。 - 是攤還。對任意的 Union/Find 交錯序列,總成本是近乎線性,不需要假設哪一種操作比較常出現。平均情況則要一個輸入分布。
小結
攤還是一串操作的總成本除以次數,對任意輸入都成立。聚集法直接求和,記帳法把多收的錢存在物件上,位能法用一個非負函數把貴的操作前後的落差算出來。動態陣列三種算法都得到每次 push 攤還 (O(1))。並查集與伸展樹是同一個位能法,只是 (\Phi) 長在樹高上。