一些 Update
Update 2021/12/16:修改垃圾回收部分的描述,改為更一般的描述空間回收並且加了一些解釋說明。
1. 前言
線段樹合並,是一種聽起來高大上實際上難度並不大的算法,專門用於一些 DS 題目,可以在一定的復雜度內合並兩棵線段樹,這過程中有時會用啟發式合並。
當然,如果你學過任何一種需要合並的數據結構(如 FHQ Treap),那么學習線段樹合並將會非常簡單。
前置知識:動態開點線段樹,樹上差分。
2. 詳解
先上例題:P4556 [Vani有約會]雨天的尾巴。
題意簡述:給出 \(n\) 點連通樹,\(q\) 次操作,每次操作對 \(x \to y\) 路徑上所有點加入一個大小為 \(z\) 的數,問加完后每個點上哪個大小的數最多,\(n,q \leq 10^5,z \leq 10^5\)。
這道題首先有個最直接的方式就是暴力跑所有路徑,然后每個點開一個值域線段樹存一下,每個點維護兩個值 \(Maxn,ans\) 表示最大數的個數以及這個數,最后每個點查一下就好了,復雜度 \(O(qn \log n)\),這個做法總應該會的吧qwq,不會好好想想()
發現這樣只有 50pts,於是想想怎么優化,這就需要用到線段樹合並了。
首先簡要說一下線段樹合並的作用:對於兩棵動態開點線段樹,設這兩棵樹點數均為 \(x\),那么線段樹合並可以在 \(O(x)\) 的時間內將兩棵線段樹的信息合並到一棵上。如果兩棵樹點數不一樣就需要看樹的結構了,但是可以保證會至多遍歷較大樹一次。
那么怎么合並呢,就是對兩棵樹同時暴力 dfs,自底向上更新即可。
聽起來確實很暴力呢,說白了就是將兩棵樹對應的點合到一起,那么他們相關的信息也被合到了一起,只不過實現的時候我們是先合並左右兒子然后再合並這個點,學過別的需要合並的數據結構的應該能很快理解。
畫個圖解釋下:
假設現在我們有兩棵線段樹,每個點維護區間和(每個點上的數字就是區間和):
那么現在將這兩棵線段樹進行合並,結果就是這樣的:
然后實現過程就是暴力!
回到例題,我們發現每次修改都是對路徑的修改,於是這里我們可以套上一個樹上差分,\(x,y,lca(x,y),fa_{lca(x,y)}\) 這四個點單點修改,然后對這棵樹重新 dfs 然后自底向上進行線段樹合並即可。
至於為啥復雜度是對的,因為你總共只有 \(4n\) 個節點對吧,因此自底向上合並的時候由於復雜度一般是點數較小樹決定的,復雜度不會過 \(O(4n)\),空間復雜度 \(O(4n \log n)\)。
關於線段樹合並的寫法就有兩種形式了:
先貼一下結構體和 Update 函數:
struct SgT
{
int l, r, Maxn, ans;
#define l(p) tree[p].l
#define r(p) tree[p].r
#define Maxn(p) tree[p].Maxn
#define ans(p) tree[p].ans
}tree[MAXN * 100];
void Update(int p)
{
if (Maxn(l(p)) >= Maxn(r(p))) { Maxn(p) = Maxn(l(p)); ans(p) = ans(l(p)); }
else { Maxn(p) = Maxn(r(p)); ans(p) = ans(r(p)); }
}
一種寫法是直接合並,在原樹上修改值:
void Merge(int &p1, int p2, int l, int r) // 表示將樹 p2 的信息合並到樹 p1 上
{
if (!p1 || !p2) { p1 = p1 + p2; return ; }
if (l == r) { Maxn(p1) = Maxn(p1) + Maxn(p2); return ; }
int mid = (l + r) >> 1;
Merge(l(p1), l(p2), l, mid);
Merge(r(p1), r(p2), mid + 1, r);
Update(p1);
}
這種做法的好處是節省了空間,但是壞處是如果你需要詢問這個點的相關內容需要在修改之后立刻詢問才行,即使是像例題一樣做在差分完畢之后統一 dfs 自底向上合並,也需要在這個點合並完之后立刻存下答案,否則就會造成答案錯誤,因為該做法合並的時候會出現多個樹共用一個節點的情況,此時一旦任何一棵樹被合並,所有的樹答案都會被影響。
該做法只適用於修改完之后即刻詢問的做法。
另外一種做法是不修改這個點點值,而是動態開點接着做(邊合並邊動態開點):
int Merge(int p1, int p2, int l, int r)
{
if (!p1 || !p2) { p1 = p1 + p2; return p1; }
int p = ++cnt_SgT; // cnt_SgT 是計數器
if (l == r) { Maxn(p) = Maxn(p1) + Maxn(p2); ans(p) = l; return p; }
int mid = (l + r) >> 1;
l(p) = Merge(l(p1), l(p2), l, mid);
r(p) = Merge(r(p1), r(p2), mid + 1, r);
Update(p); return p;
}
這個做法會比較耗空間,但是好處就是你可以邊修改邊詢問,詢問修改互不干擾,因為你每次都動態開點了。
當然該做法耗空間是可以避免的,比如你使用一個空間回收的 Trick,這個 Trick 可以將那些再也用不到的點拿回來重復利用,其實相當於指針空間釋放再開指針,而且空間回收是不會影響總復雜度的(頂多影響一點常數)。
但是特別的,如果你用了空間回收的話你就需要跟第一個做法一樣合並完立刻存下答案了,理由就是空間回收會清空一個點的數據,這對以該節點為根的點的詢問會有影響。
不過因為這道題空間復雜度不過 \(O(4n \log n)\),所以現在不用空間回收也能過。
需要注意的是,如果線段樹合並的過程中將一個點的信息存到另一個點上(信息移植,第一種做法)的復雜度與將兩者信息提取出來合並到新點上(信息合並,第二種做法)的復雜度相同,那么兩種做法都可以用,但是如果不同,則顯然信息移植是比信息合並要優的,此時只能采用第二種做法。
比如說有的題,我們只需要單點操作也就是查詢葉子結點的值,但是葉子結點的合並需要啟發式合並,此時如果采用信息合並做法復雜度就是錯的(除非用指針),因為信息合並復雜度是 \(O(Size_1+Size_2)\),而如果采用信息移植就可以正常啟發式合並,復雜度為 \(O(\min\{Size_1,Size_2\})\)。
下面貼模板題代碼:
CodeBase:CodeBase-of-Plozia
Code:
/*
========= Plozia =========
Author:Plozia
Problem:P4556 【模板】線段樹合並
Date:2021/12/5
========= Plozia =========
*/
#include <bits/stdc++.h>
int Max(int fir, int sec) { return (fir > sec) ? fir : sec; }
int Min(int fir, int sec) { return (fir < sec) ? fir : sec; }
typedef long long LL;
const int MAXN = 1e5 + 5;
int n, q, Head[MAXN], cnt_Edge = 1, Root[MAXN], cnt_SgT, fa[MAXN][21], dep[MAXN];
struct node { int To, Next; } Edge[MAXN << 1];
struct SgT
{
int l, r, Maxn, ans;
#define l(p) tree[p].l
#define r(p) tree[p].r
#define Maxn(p) tree[p].Maxn
#define ans(p) tree[p].ans
}tree[MAXN * 100];
int Read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = sum * 10 + (ch ^ 48);
return sum * fh;
}
void add_Edge(int x, int y) { ++cnt_Edge; Edge[cnt_Edge] = (node){y, Head[x]}; Head[x] = cnt_Edge; }
void Update(int p)
{
if (Maxn(l(p)) >= Maxn(r(p))) { Maxn(p) = Maxn(l(p)); ans(p) = ans(l(p)); }
else { Maxn(p) = Maxn(r(p)); ans(p) = ans(r(p)); }
}
void dfs(int now, int father)
{
dep[now] = dep[father] + 1; fa[now][0] = father;
for (int i = Head[now]; i; i = Edge[i].Next)
{
int u = Edge[i].To;
if (u == father) continue ;
dfs(u, now);
}
}
void init()
{
for (int j = 1; j <= 20; ++j)
for (int i = 0; i <= n; ++i)
fa[i][j] = fa[fa[i][j - 1]][j - 1];
}
int LCA(int x, int y)
{
if (dep[x] < dep[y]) std::swap(x, y);
for (int i = 20; i >= 0; --i)
if (dep[fa[x][i]] >= dep[y]) x = fa[x][i];
if (x == y) return x;
for (int i = 20; i >= 0; --i)
if (fa[x][i] != fa[y][i]) x = fa[x][i], y = fa[y][i];
return fa[x][0];
}
void Insert(int &p, int x, int k, int l, int r)
{
if (!p) p = ++cnt_SgT;
if (l == r && l == x) { Maxn(p) += k; ans(p) = x; return ; }
int mid = (l + r) >> 1;
if (x <= mid) Insert(l(p), x, k, l, mid);
else Insert(r(p), x, k, mid + 1, r);
Update(p);
}
int Merge(int p1, int p2, int l, int r)
{
if (!p1 || !p2) { p1 = p1 + p2; return p1; }
int p = ++cnt_SgT;
if (l == r) { Maxn(p) = Maxn(p1) + Maxn(p2); ans(p) = l; return p; }
int mid = (l + r) >> 1;
l(p) = Merge(l(p1), l(p2), l, mid);
r(p) = Merge(r(p1), r(p2), mid + 1, r);
Update(p); return p;
}
void dfs2(int now, int father)
{
for (int i = Head[now]; i; i = Edge[i].Next)
{
int u = Edge[i].To;
if (u == father) continue ;
dfs2(u, now); Root[now] = Merge(Root[now], Root[u], 1, MAXN - 5);
}
}
int main()
{
n = Read(), q = Read();
for (int i = 1; i < n; ++i)
{
int x = Read(), y = Read();
add_Edge(x, y); add_Edge(y, x);
}
add_Edge(0, 1); add_Edge(1, 0);
dfs(0, 0); init();
for (int i = 1; i <= q; ++i)
{
int x = Read(), y = Read(), z = Read();
int l = LCA(x, y);
Insert(Root[fa[l][0]], z, -1, 1, MAXN - 5);
Insert(Root[l], z, -1, 1, MAXN - 5);
Insert(Root[x], z, 1, 1, MAXN - 5);
Insert(Root[y], z, 1, 1, MAXN - 5);
}
dfs2(0, 0);
for (int i = 1; i <= n; ++i)
{
if (Maxn(Root[i]) == 0) printf("0\n");
else printf("%d\n", ans(Root[i]));
}
return 0;
}
3. 總結
線段樹合並就是一種暴力合並算法,結合動態開點線段樹完成,但是我們可以通過諸如計算空間復雜度最大值,啟發式合並等思路降低復雜度。