1. 程式人生 > 實用技巧 >【科技】單 log 合併兩棵有交集 FHQ-Treap 的方法

【科技】單 log 合併兩棵有交集 FHQ-Treap 的方法

維護可分裂 & 合併的可重集

考慮這樣一個問題:

維護 \(n\)可重集 \(S_1, S_2, \cdots, S_n\),元素值域為 \([1, U]\),初始集合為空。支援一下操作:

  • \(S_p\)\(\in [x, y]\) 的所有元素 割離 出來並插入集合 \(S_q\) 中,注意不用複製;
  • \(S_q\) 中所有元素 合併\(S_p\) 中並將 \(S_q\) 清空;
  • 計算 \(S_p\)\(\in[x, y]\) 的元素個數;
  • \(S_p\) 中第 \(k\) 小的元素;
  • \(S_p\) 中插入 \(x\)\(y\) 元素。

一共需要進行 \(Q\) 次操作。

題目:Luogu P5494【模板】線段樹分裂

一般的維護方法

動態開點權值線段樹

這種問題最主流的做法,它可以方便支援以上所有操作。

時空複雜度都是 \(O(n\log n)\)

平衡樹

主要會用 FHQ-Treap 來寫,因為 split 和 merge 操作非常方便。

一般來說合並會使用啟發式合併,每次都是小的到大的合併,可以證明每個元素最多被合併 \(O(\log n)\) 次。於是時間複雜度為 \(O(n\log^2 n)\),但是平衡樹只需要 \(O(n)\) 的空間。

\(O(\log n)\) 的有交集平衡樹合併

這裡使用 FHQ-Treap 作為維護的資料結構。這個 \(O(\log n)\)

一次是均攤的,然而我並不會這個證明。

先放一個 評測結果(I/O 優化,O2)。這個排在時間最優解第一頁,去掉 I/O 優化的話空間用的也很少。因為這是一個優秀的 \(O(n\log n)\) 時間,\(O(n)\) 空間的優秀做法。

我們發現上面平衡樹的解法的瓶頸在於合併,然而這好像並沒有優化餘地。於是嘗試設計一個新的合併思路。在這裡感謝 Mr_Spade 給我介紹這個(並不算非常複雜的)科技。

考慮現在有兩棵 Treap,根為 \(x, y\)。我們先比較兩個結點的隨機值,欽定隨機值小的作為當前的根。這裡假定為 \(x\)。然後我們需要搞出 \(x\) 的左右子樹,分別為兩棵 Treap 並集(除去 \(x\)

)中 \(< x\)\(>x\) 的權值的結點構成的 Treap(\(=x\) 的可以特殊處理)。

對於 \(x\),顯然它的左右子樹(\(l_1, r_1\))就滿足上面那個要求;而對於 \(y\) 我們則可以直接按 \(x\) 的權值 split,得到 \(l_2, r_2\) 兩棵樹。

最後我們發現這是一個可以遞迴處理的問題,因為我們再對 \(l_1, l_2\)\(r_1,r_2\) 分別做這樣的合併即可,兩次合併的結果就可以作為 \(x\) 的兩個子樹。

參考程式碼實現:

int join(int x, int y) {
    if (!x || !y) return x | y; // 有一個空間的即可返回
    if (t[x].pty > t[y].pty) swap(x, y); // 取隨機權值小的作為根
    int L1 = t[x].ch[0], R1 = t[x].ch[1], L2 = 0, R2 = 0, equ = 0; // x 直接是左右子樹
    split(y, t[x].val, L2, R2), split(L2, t[x].val - 1, L2, equ); // y 按權值 split
    if (equ) t[x].cnt += t[equ].siz, t[x].siz += t[equ].siz; // 相等特殊處理
    t[x].ch[0] = join(L1, L2), t[x].ch[1] = join(R1, R2); // 遞迴合併
    return pushup(x), x; // 更新資訊
}

實際上這樣常數並不大,在空間優於線段樹的同時也不會比線段樹慢。注意 rand() 很慢,使用不建議每次都隨機一下。

後記