1. 程式人生 > 實用技巧 >學習筆記:左偏樹

學習筆記:左偏樹

左偏樹是一種可並堆,除了堆的基本功能,最大的特點就是支援合併堆,甚至比普通堆好寫。

下面敘述以小根堆為例,大根堆對稱。

支援的功能:

  1. \(O(\log n)\) 求一個數所在堆的根
  2. \(O(1)\) 求最小值
  3. \(O(\log n)\) 合併兩個堆
  4. \(O(\log n)\) 刪除最小值
  5. \(O(\log n)\) 插入一個數

\(n\) 是插入的總節點數(或當前堆的節點數)。

維護的資訊:

struct T{
    int l, r, v, d, f;
    // l, r 表示左右兒子, v 表示值
    // d 表示從當前節點到其子樹中最近葉子節點的距離 + 1, f 表示當前節點的父親
} t[N];

基本的結構還是堆,即對於任意節點,它的權值小於等於其子樹中任意權值,因此查詢最小值只需 \(O(1)\) 訪問根即可。

左偏的意義就是:對於任意一個節點的左兒子 \(ls\) 和右兒子 \(rs\),都有 \(t[ls].d \ge t[rs].d\),感性理解就是左子樹深的更長。

性質:對於一棵根節點 \(rt\) 滿足 \(t[rt].d = k\) 的堆而言,至少有 \(2^k - 1\) 個節點,即一個高度為 \(k\) 的滿二叉樹的節點樹,因為這些節點必不可少,否則 \(d\) 就小於 \(k\) 了。因此對於一個有 \(n\) 個節點的堆,根節點的 \(d\) 就是 \(\log n\)

級別的。

操作:

求一個數所在堆的根

樸素上我們可以一個個跳 \(t[x].f\)。不過我們可以把 \(t[x].f\) 看做一個並查集 \(fa\) 陣列,路徑壓縮一下:

int find(int x) {
    return t[x].f == x ? x : t[x].f = find(t[x].f);
}

這樣只要保證我們之後的賦值 \(fa\) 操作都是類似並查集的合併操作,那麼複雜度就是 \(O(\log n)\) 的。

求最小值:

找到一個數所在根,直接訪問根節點值即可。

合併兩個堆

合併 \(merge(x, y)\) 分別以 \(x, y\) 為根的兩個小根堆,並返回合併完的根編號:不妨設 \(t[x].v < t[y].v\)

(若不滿足 \(\text{swap}\) ) ,接著只需遞迴 \(merge(t[x].r, y)\)。回溯時檢查 \(x\) 左右兒子的 \(d\),若不滿足左偏樹關係交換,返回 \(x\) 即可。

時間複雜度,每次遞迴,\(x, y\) 之一的 \(d\) 必然減少 \(1\),做多減少到 \(0\),而 \(d\)\(\log n\) 量級的,所以複雜度是 \(O(\log n)\)

貌似網上的複雜度都不是很對,不能每次都賦 \(t[x].fa = x\),這樣複雜度就假了,而是函式呼叫之前把 \(t[y].f = x\),然後內部合併不改變 \(f\) 的值,這樣相當於合併兩個聯通塊,複雜度是對的。

int merge(int x, int y) { // 遞迴合併函式
    if (!x || !y) return x + y;
    if (t[x].v > t[y].v || (t[x].v == t[y].v && x > y)) swap(x, y);
    rs = merge(rs, y);
    if (t[ls].d < t[rs].d) swap(ls, rs);
    t[x].d = t[rs].d + 1;
    return x;
}

int work(int x, int y) { // 合併 x, y 兩個堆。
    if (t[x].v > t[y].v || (t[x].v == t[y].v && x > y)) swap(x, y);
    t[x].f = t[y].f = x;
    merge(x, y); return x;
}

刪除最小值

找到根節點後,合併兩個子樹。

void del(int x) {
    t[x].f = work(ls, rs);
}

插入一個數

直接單開一個節點,合併就好了。