Aug.21
树上启发式合并用于解决「询问每个节点关于其子树的某些信息」的问题。
标题二者分别指
树上启发式合并 (DSU on Tree):专有名词,做法里需要删除轻子树 树上启发式合并 (Small-to-Large):把各个子树当作集合,按小给大合并两个集合容器 为避免歧义,下文分别以「DSU on Tree」与「Small-to-Large」称呼之。而「树上启发式合并」则表示这类通用思路。
* 启发式:基于人类的经验和直观感觉,优化算法
DSU on Tree Idea 因简单的树形 DP 在空间上不能满足,所以不能存储每个节点的信息,而是要资源复用。
又因为在压缩空间后,需要对每个节点暴力统计子树的信息,在时间上又不能满足,所以 启发式地保留重子树的信息 ,以节约时间。
*DSU:并不存在真正的并查集结构,只是借用了 DSU 以启发式合并解决问题的名号(笑
Algorithm 加入重子树 → \rightarrow → 加入轻子树 → \rightarrow → 回答询问 → \rightarrow → 删除轻子树
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 std::vector<int > parent (N, -1 ) , siz (N) ;[&](this auto && self, int x) -> void { if (parent[x] != -1 ) { adj[x].erase (std::ranges::find (adj[x], parent[x])); } siz[x] = 1 ; for (auto & y : adj[x]) { parent[y] = x; self (y); siz[x] += siz[y]; if (siz[y] > siz[adj[x][0 ]]) { std::swap (y, adj[x][0 ]); } } } (0 ); auto modify = [&](int x, int delta) { }; auto calculate = [&](int x) -> void { }; [&](this auto && self, int x, bool keep) -> void { for (auto y : adj[x] | std::views::drop (1 )) { self (y, false ); } if (adj[x].size ()) { self (adj[x].front (), true ); } modify (x, 1 ); for (auto y : adj[x] | std::views::drop (1 )) { [&](this auto && self, int x) -> void { modify (x, 1 ); for (auto y : adj[x]) self (y); } (y); } calculate (x); if (!keep) { [&](this auto && self, int x) -> void { modify (x, -1 ); for (auto y : adj[x]) self (y); } (x); } } (0 , true );
Complexity 假设数组修改为 O ( 1 ) \mathcal{O}(1) O ( 1 ) ,DSU on Tree 的时间复杂度是标准的 O ( N log N ) \mathcal{O}(N \log N) O ( N log N ) 。常数极小,可与二分、树状数组相媲美。
口胡证明:只有轻子树需要重新递归遍历修改,因此节点 x x x 的修改次数等于它到 root 的路径上轻边的数量,这不超过 ⌊ log 2 N ⌋ \lfloor \log_2 N \rfloor ⌊ log 2 N ⌋ 条。因此复杂度 O ( N log N ) \mathcal{O}(N \log N) O ( N log N ) 。
Small-to-Large Idea 启发式合并 有 N N N 个集合,欲按特定顺序将其合并,同时计算跨集合的信息。 做法:始终将较小集合的元素逐个取出,先在大集合中查询 / 计算与该元素的跨集合贡献,再将其插入大集合。
应用在树上,把各个子树当作集合。用小子树查大集合,算出不同子树之间的跨集合贡献。这里可以直接把轻子树合并到重子树的里面,就保证了每次都是小并大。
Algorithm 为每个节点维护一个容器,具体有两种写法:
继承重子树的容器 → \to → 遍历所有轻子树的容器逐个加入 → \rightarrow → 回答询问 不管轻重,直接比较容器大小 → \rightarrow → 将较小集合的元素逐个加入较大的 → \rightarrow → 回答询问 显然后者更好写,但前者有利于理解。
代码是直接比较容器大小:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 [&](this auto && self, int x) -> void { for (auto y : adj[x]) { self (y); if (mp[x].size () < mp[y].size ()) { std::swap (mp[x], mp[y]); } for (auto & [v, val] : mp[y]) { } } } (0 );
Complexity 使用 STL,时间复杂度为 O ( N log 2 N ) O(N \log^{2} N) O ( N log 2 N ) 。
口胡证明:任意一个元素每被移动一次,它所在集合的规模至少翻倍,因此每个元素至多被移动 O ( log N ) O(\log N) O ( log N ) 次,而 set 等 STL 自带 log,总体时间复杂度为 O ( N log 2 N ) O(N \log^{2} N) O ( N log 2 N ) ,空间复杂度 O ( N ) O(N) O ( N ) 。
多一个 log 这个事情可以上一个数据结构来解决。使用某些数据结构(如线段树合并),复杂度可能为 O ( N log N ) \mathcal{O}(N \log N) O ( N log N ) 或 O ( N log 2 N ) O(N \log^{2} N) O ( N log 2 N ) ,常数较大。
另外需要动态分配内存,空间常数也较大。
Comparison 本质都是保留重子树的贡献,只重新遍历 / 合并轻子树。
DSU on Tree :加入重子树 → \rightarrow → 加入轻子树 → \rightarrow → 回答询问 → \rightarrow → 删除轻子树,复用全局数组。利用重新 DFS 访问 log N \log N log N 次保证复杂度。Small-to-Large :继承重子树的容器 → \to → 遍历所有轻子树的容器逐个加入 → \rightarrow → 回答询问。利用至多合并 log N \log N log N 次保证复杂度。实际上,DSU on Tree 是省去了 Small-to-Large 里「已计算、但暂时用不到」的那些轻子数的信息,转「存储」为「重算」。
最直白:
DSU on Tree Small-to-Large 核心 撤销、重算(需支持删除操作) 存储、合并(保留每个子树历史状态) 数据结构 无 / 树状数组(简单) STL / 线段树合并、主席树(复杂) 时间 小 大(常数或 log \log log ) 空间 小 大 码量 适中,模板较多 STL 小,复杂数据结构大 在线 / 离线 只能离线 在线
Problems CF375D https://codeforces.com/contest/375/problem/D
有根树,点有颜色。多次询问 ( u , k ) (u, k) ( u , k ) :在子树 u u u 中,出现次数 ⩾ k \geqslant k ⩾ k 的颜色有多少种。
维护两个东西:
cnt[c]:当颜色 c c c 出现的具体次数。sum[f]:当前出现次数 ≥ f \ge f ≥ f 的颜色有多少种。DSU on Tree 的修改非常简单,大致是 cnt[c]++; sum[cnt[c]]++; 和 sum[cnt[c]]--; cnt[c]--;。
Small-to-Large 维护 sum[f] 有些繁琐,但也是可以做的。
CF 炸了所以没有代码。
2025 CCPC Beijing Inv. D https://codeforces.com/gym/105851/problem/D https://qoj.ac/contest/1986/problem/10516
两棵有根树 S S S 和 T T T ,点数均为 N N N 。求二元组 ( x , y ) (x,y) ( x , y ) 的数量,满足 x < y x<y x < y 且 L C A S ( x , y ) = L C A T ( x , y ) = u \mathcal{LCA}_{S}(x,y)=\mathcal{LCA}_{T}(x,y) = u LC A S ( x , y ) = LC A T ( x , y ) = u 。
对树 S S S 树上启发式合并。
枚举到 u u u 的子树,需要合并 u u u 的两个小子树。假设正在枚举 x x x ,什么样的 y y y 才能满足 L C A S ( x , y ) = L C A T ( x , y ) = u \mathcal{LCA}_S(x,y) = \mathcal{LCA}_T(x,y) = u LC A S ( x , y ) = LC A T ( x , y ) = u ?如何快速查询这些 y y y 的数量?
对树 S S S ,点对 ( x , y ) (x, y) ( x , y ) 满足 x x x 和 y y y 属于 u u u 的不同子树,或者其中一个是 u u u 本身。而全局数据结构(或另一个子树的数据结构)只包含之前已处理过的子树中的节点 y y y 。即天然保证了每个符合条件的 ( x , y ) (x,y) ( x , y ) 只会被统计一次。
对树 T T T ,
若 x = u x = u x = u :y y y 可以是 subtree T ( u ) \operatorname{subtree}_T(u) subtree T ( u ) 中除了 u u u 以外的任意节点。形式化地,Ans = Query ( L T ( u ) , R T ( u ) ) − Query ( L T ( u ) ) \text{Ans} = \text{Query}(L_T(u), R_T(u)) - \text{Query}(L_{T}(u)) Ans = Query ( L T ( u ) , R T ( u )) − Query ( L T ( u ))
若 x ≠ u x \neq u x = u :设 v = firstChild T ( u , x ) v=\operatorname{firstChild}_{T}(u, x) v = firstChild T ( u , x ) 是 T T T 中 u u u 的子节点,且是 x x x 的祖先。则 y y y 必须在 subtree T ( u ) \operatorname{subtree}_T(u) subtree T ( u ) 内,但不能在 subtree T ( v ) \operatorname{subtree}_T(v) subtree T ( v ) 内(否则 L C A T ( x , y ) \mathcal{LCA}_T(x,y) LC A T ( x , y ) 会落入 v v v 或其子树中)。形式化地,Ans = Query ( L T ( u ) , R T ( u ) ) − Query ( L T ( v ) , R T ( v ) ) \text{Ans} = \text{Query}(L_T(u), R_T(u)) - \text{Query}(L_{T}(v), R_{T}(v)) Ans = Query ( L T ( u ) , R T ( u )) − Query ( L T ( v ) , R T ( v ))
指向很明显了,要快速查询树上区间被激活的点,上数据结构。
如果用 DSU on Tree,需要树状数组;如果用 Small-to-Large,需要线段树合并。复杂度都是 O ( N log 2 N ) \mathcal{O}(N \log^{2}N) O ( N log 2 N ) 。
代码是 DSU on Tree(完整代码见 AC ):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 auto modify = [&](int x, int delta) { bit.add (T.dfn[x], delta); }; auto query = [&](int x, int u) -> i64 { if (!T.is_ancestor (u, x)) return 0 ; i64 cnt = 0 ; T.for_each_subtree(u, [&](int L, int R) { cnt += bit (L, R); }); if (x != u) { int v = T.first_child (u, x); T.for_each_subtree(v, [&](int L, int R) { cnt -= bit (L, R); }); } return cnt; }; [&](this auto && self, int u, bool keep) -> void { for (auto & v : S.adj[u] | std::views::drop (1 )) { self (v, false ); } if (S.adj[u].size ()) { self (S.adj[u].front (), true ); } res += query (u, u); modify (u, 1 ); for (auto & v : S.adj[u] | std::views::drop (1 )) { [&](this auto && self, int x) -> void { res += query (x, u); for (auto y : S.adj[x]) self (y); } (v); [&](this auto && self, int x) -> void { modify (x, 1 ); for (auto y : S.adj[x]) self (y); } (v); } if (!keep) { [&](this auto && self, int x) -> void { modify (x, -1 ); for (auto y : S.adj[x]) self (y); } (u); } } (0 , true );
有根树,每个节点有 A i , B i A_{i}, B_{i} A i , B i 。点权中 0 表示通配符,0 与任何数相等。对每个子树 T u T_{u} T u 判断:多重集 S A = { A v : v ∈ subtree ( u ) } S_{A}=\lbrace A_{v} : v \in \text{subtree}(u) \rbrace S A = { A v : v ∈ subtree ( u )} 和 S B S_{B} S B 是否相等。
设 c A , x c_{A,x} c A , x 表示多重集 S A S_{A} S A 中 x x x 的数量,c B , x c_{B,x} c B , x 类似。通配符比较无敌不可能剩下,可以只考虑 x ≠ 0 x \neq 0 x = 0 ,那么 A i = B j A_{i}=B_{j} A i = B j 需要占用恰好一个位置,A i A_{i} A i 找不到伴也需要占用恰好一个位置,所以总共占用 ∑ x ≠ 0 max { c A , x , c B , x } \sum_{x \neq 0} \max \lbrace c_{A,x},c_{B,x} \rbrace ∑ x = 0 max { c A , x , c B , x } 个位置。条件等价于
∑ x ≠ 0 max { c A , x , c B , x } ⩽ ∣ T u ∣ \sum_{x \neq 0} \max \lbrace c_{A,x},c_{B,x} \rbrace \leqslant |T_{u}| x = 0 ∑ max { c A , x , c B , x } ⩽ ∣ T u ∣
下面的问题是,对每个子树计算 ∑ x ≠ 0 max { c A , x , c B , x } \sum_{x \neq 0} \max \lbrace c_{A,x},c_{B,x} \rbrace ∑ x = 0 max { c A , x , c B , x } 。
体感 Small-to-Large 更好写。
DSU on Tree:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 string res (N, '0' ) ;vector<int > cntA (N) , cntB (N) ;int cur = 0 ;auto modify = [&](int x, int delta) { if (int v = A[x]; v != -1 ) { cur -= max (cntA[v], cntB[v]); cntA[v] += delta; cur += max (cntA[v], cntB[v]); } if (int v = B[x]; v != -1 ) { cur -= max (cntA[v], cntB[v]); cntB[v] += delta; cur += max (cntA[v], cntB[v]); } }; auto calculate = [&](int x) -> void { res[x] = '0' + (cur <= siz[x]); };
Small-to-Large:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 std::vector<int > parent (N, -1 ) , siz (N) ;std::vector<std::map<int , std::pair<int , int >>> mp (N); std::vector<int > cur (N) ;std::string res (N, '0' ) ;[&](this auto && self, int x) -> void { if (parent[x] != -1 ) { adj[x].erase (std::ranges::find (adj[x], parent[x])); } auto add = [&](int v, int dA, int dB) { if (v == -1 ) return ; auto & [cA, cB] = mp[x][v]; cur[x] -= std::max (cA, cB); cA += dA; cB += dB; cur[x] += std::max (cA, cB); }; siz[x] = 1 ; for (auto & y : adj[x]) { parent[y] = x; self (y); siz[x] += siz[y]; if (mp[x].size () < mp[y].size ()) { std::swap (mp[x], mp[y]); std::swap (cur[x], cur[y]); } for (auto & [v, _] : mp[y]) { auto & [a, b] = _; add (v, a, b); } } add (A[x], 1 , 0 ); add (B[x], 0 , 1 ); res[x] = '0' + (cur[x] <= siz[x]); } (0 );