☰
线段树分治详解:离线处理动态图连通性与可撤销并查集
2026/10/15 1:20:33 网站建设 项目流程

1. 这类题卡在哪:删除操作是动态结构的天敌

1.1 一个看似简单的动态图连通性问题

先放下形式化的定义,看一个我当年在训练中被反复按在地上摩擦的场景。假设你要维护一张无向图,一开始图上什么都没有。接下来会按顺序来一堆操作,大致分三类:在某个时间点加入一条边、在某个时间点删掉一条边、在某个时间点询问两个点是否连通。操作总量在十万这个量级。

看到这种题,第一反应几乎都是"拿并查集随便维护"。加边就是union,询问就是find,不是就完了吗?问题恰恰出在删除上——并查集本身不支持删除。你硬着头皮把那条边从数据结构里"删掉",会发现合并操作已经把连通信息揉在了一起,想拆开回到合并之前的状态,几乎等于把一锅粥重新煮回生米。

有人会说用动态树之类的在线结构硬解,这确实是路之一,但代码量和心智负担对于大多数普通选手来说都过于沉重。而线段树分治提供的是一条完全不同的路径——既然所有操作都是提前给好的,我为什么不能离线处理,把"时间"当成区间来切分?

1.2 在线做法的瓶颈在哪

在线做法的瓶颈,本质上是"删除操作不可逆"。并查集合并很容易,但撤销一次合并需要知道合并之前的状态;如果中途还穿插了别的合并,你根本分不清当前的连通关系里面有哪些副作用来自哪条边。

我最早尝试解决这个问题的时候,写过一个"带版本号的并查集",给每次合并加一个时间戳,删除时重构历史版本。结果代码越写越长,调试越来越痛苦,最后跑出来的性能也不太好看。后来才意识到:输入明明是离线的,每条边的生命周期我都提前知道,为什么非要实时去和"删除"硬刚?

1.3 离线思路的萌芽:每条边都是一段区间

我们退一步看。每条边(或者说每个操作)不是永远存在的,它有一个"出生时间"和一个"死亡时间"。如果把这条边的作用区间看成时间轴上的[l, r],那么问题就变成了:在每一个时间点t,把所有覆盖t的操作都作用到一个数据结构上,然后算答案。

这正是区间覆盖问题的模型。要高效处理"时间区间",自然会想到线段树——把整个时间轴建成一棵线段树,每个操作被拆分插入到覆盖它的那些节点上,然后深度优先遍历整棵树。走到某个叶子t的时候,从根到叶子这条路径上所有被激活节点的操作,合起来恰好就是覆盖时间点t的所有操作。

这里的关键转变是:我们彻底绕过了"删除",把删除变成了 DFS 回溯时的"撤销"。

2. 核心思想:把时间区间挂到线段树节点上

2.1 操作与时间区间的映射方式

形式化一点。假设时间范围是[1, T],每个操作只作用于[l, r]这段时间。我们构建一棵维护区间[1, T]的线段树,每个节点对应一个时间子区间。当一个操作作用区间是[l, r]时,就把它挂到线段树中那些完全被[l, r]覆盖、且进行区间拆分时恰好停留的节点上。

一个区间在线段树上最多被拆成O(log T)个节点,这是线段树区间更新的基本性质。经过这一步,原问题中的"操作在时间段[l, r]内有效"就变成了"操作挂在树上的若干节点上"。之后的一切,都建立在这个映射关系之上。

2.2 为什么选线段树,而不是树状数组或分块

有人会问:区间拆分成O(log T)份,树状数组也能做前缀拆分,分块也能处理区间,为什么偏偏是线段树?

原因是 DFS 的遍历顺序。线段树是一棵天然二叉树,递归遍历自带"进入节点时执行、离开节点时撤销"的栈语义。当你走到一个叶子节点时,当前递归路径上所有节点的"已执行操作"刚好构成了该时间点的有效操作集合,这是一种无冲突、无重叠的嵌套式遍历,树状数组的前缀拆分结构做不到这一点。分块虽然也能拆分区间,但块之间的覆盖关系不是严格的二叉树结构,很难保证撤销顺序和递归路径严格对应。

另外,线段树的递归深度只有O(log T),对于栈式撤销来说非常舒服。所有操作在每个节点上执行一次再逆序撤销一次,整体复杂度完全可控。

2.3 DFS 顺序执行与栈式撤销的对称结构

线段树分治的算法骨架,本质上就是一个带"进节点执行、出节点撤销"的 DFS:

void dfs(int o, int l, int r) { for (auto &op : seg[o].ops) { apply(op); // 进入节点,应用该节点上的所有操作 } if (l == r) { answer[l] = getAns(); // 到达叶子,收集当前时间点的答案 } else { int mid = (l + r) >> 1; dfs(o << 1, l, mid); dfs(o << 1 | 1, mid + 1, r); } for (int i = (int)seg[o].ops.size() - 1; i >= 0; --i) { rollback(seg[o].ops[i]); // 离开节点,逆序撤销本节点所有操作 } }

执行和撤销发生在同一个节点上,前者正序,后者逆序。之所以能这样,是因为线段树的 DFS 保证了同一时刻只有一条从根到叶子的路径处于激活状态,各节点之间的操作互不干扰。撤销时只要严格按应用顺序的逆序来,就能回到进入该节点之前的状态。

提示:这里的 apply 和 rollback 必须是一对可逆操作,这是线段树分治能跑通的大前提。如果你的数据结构不支持撤销,就得换成"保存快照"的方式。

3. 必备配件的实现细节:可撤销并查集

3.1 为什么路径压缩会失效

线段树分治最常见的搭档是可撤销并查集。普通并查集为了加速查找,都会写路径压缩,但在这个场景里,路径压缩直接毁掉可撤销性。

原因很直接:路径压缩会把多个节点的父指针直接指向根,一次合并的副作用被扩散到了很多节点上。假如有十次合并,其中三次压缩了路径,当你想要撤销其中某一次合并时,需要的是一整片原始父指针信息,而这些分散的副作用并没有被记录。栈式的Change节点根本存不下这么多种可能。

所以,可撤销并查集的 find 必须写成朴素版本,一路沿着父亲往上跳,不做任何压缩。

3.2 栈式记录合并轨迹

那怎么保证朴素版本的查询复杂度不过于拉胯?答案是启发式合并。每次合并时,把被修改的父指针和子树大小记录到一个栈里,撤销时从栈顶弹出并恢复。

struct DSU { static const int N = 100005; int fa[N], sz[N]; struct Change { int u, v, szU; }; std::stack<Change> st; int find(int x) const { while (fa[x] != x) x = fa[x]; return x; } void unite(int u, int v) { u = find(u), v = find(v); if (u == v) { st.push({-1, -1, -1}); return; } if (sz[u] < sz[v]) std::swap(u, v); st.push({v, fa[v], sz[u]}); fa[v] = u; sz[u] += sz[v]; } void rollback() { Change c = st.top(); st.pop(); if (c.u != -1) { fa[c.u] = c.v; sz[c.v] = c.szU; } } };

几个细节要特别注意。

合并时记录的是v的父亲和u的 size,撤销时先恢复 size 再恢复父指针,严格保证顺序和原操作相反。u == v这种"假合并"也必须压入一个空标记,否则撤销次数会和合并次数错位,后续所有节点全乱。这段代码我在不同题目里改写过不下十次,每次都会因为小细节出错,真的建议直接背下来。

3.3 按秩合并与按 size 合并的选择

上面对标准实现的选用是"按 size 合并"。原因首先是语义清晰——用秩合并维护的是树高上界,而不是真实节点数,一旦题目需要查询连通块大小,秩合并就相当尴尬;其次,按 size 合并的 size 数组本身就是可撤销状态的一部分,回滚时恢复 size 也顺手。

复杂度方面,按 size 合并配合朴素 find,单次查询是O(log n),叠加线段树分治的O(log T)因子,完全在可接受范围内。如果哪道题数据量特别大,可以把 find 写非递归形式减少常数,但别对回滚做任何"优化"。

4. 三大经典场景的实战拆解

4.1 场景A:动态图连通性离线版

回到开头的动态图连通性问题。做法分三步走:

  1. 把时间范围[1, T]建线段树。
  2. 对每条边,把它的存在区间[l, r]插入线段树。
  3. DFS 遍历线段树,进入节点时把该节点上的所有边union进可撤销并查集,到达叶子t时直接查询u、v是否同根。

这里最大的难点是"每条边的存在区间怎么算出来"。对于成对出现的"加入e"和"删除e"操作,可以用一个哈希表维护每条边当前"加入时间",当遇到删除操作时,就得到了这条边的完整存在区间[加入时间, 删除时间 - 1]。扫描完所有操作后,如果还有边没被删除,就补上区间[加入时间, T]。

我把这个流程在模拟项目里测过很多遍,1e5 级别数据和 1e6 级别数据都跑得很稳。唯一要提醒的是,一条从头活到尾的边会被挂到线段树根节点,它的效果会作用于所有叶子,这是符合预期的,不用担心复杂度爆炸。

4.2 场景B:带时间窗的背包问题

第二个经典场景是"带时间窗的背包"。假设有若干物品,每个物品只能在某个时间段[l, r]内获得,到了时间就消失;每个时间点都想知道当前能装下的最大价值。

线段树分治的处理思路一致:每个物品的可用区间插入线段树,DFS 进入节点时,对该节点上的所有物品做一次 0/1 背包转移,到达叶子t时,当前 dp 数组就是所有在t时刻可用物品叠加后的最终结果。

但这里有个必须正面处理的麻烦:背包转移不是可逆操作。一维 dp 数组被物品更新后,没有任何办法通过"逆转移"回到之前的状态。所以只能退而求其次——在进入节点时,把 dp 数组完整拷贝保存到栈里,离开节点时再拷贝回来。

void dfs(int o, int l, int r) { vector<int> backup = dp; // 保存快照 for (auto &item : seg[o].items) { for (int w = W; w >= item.w; --w) { dp[w] = max(dp[w], dp[w - item.w] + item.v); } } if (l == r) ans[l] = dp[W]; else { dfs(o << 1, l, mid); dfs(o << 1 | 1, mid + 1, r); } dp = backup; // 恢复快照 }

注意这种写法的额外复杂度:每个节点都要拷贝整个 dp 数组,总开销是O(T * W)。如果时间点数量和背包容量都到 1e5,这个拷贝会直接让你超时。所以背包场景下的线段树分治有个隐藏限制:要么W不能太大,要么T有限。

实测下来,当W * T在 1e7 量级时,这种写法是非常舒服的;一旦超过 1e8,就得找其他做法了。

4.3 场景C:区间线性基与字典树问题

第三种常见场景是线性基。假设每个数只在某个时间段内可用,每个时间点询问当前可用的数能异或出的最大值。线性基的插入同样不可逆,处理方式就是上面背包那套"快照"思路,进入节点时保存线性基副本,离开时恢复。

struct LinearBasis { int p[32]; void copyFrom(const LinearBasis &other) { memcpy(p, other.p, sizeof p); } void insert(int x) { ... } int maxXor() { ... } };

DFS 节点操作时就三行:保存副本、插入本节点所有数、递归、恢复副本。线性基本身很短,拷贝很快,这种场景下线段树分治用起来非常顺手。

字典树(trie)的撤销比线性基麻烦,因为节点分配和指针关系都要精心记录。如果对空间不太敏感,我建议无脑保存副本;空间紧张才考虑"带撤销的 trie"——在插入时把每个被修改的节点子指针压入栈,撤销时恢复。说实话,这类题只要会前两种场景,基本都是套模板,难度不在思路上,而在代码细节。

5. 复杂度视角与实测坑点记录

5.1 时间复杂度的直觉与严格分析

把整个算法拆成三块看复杂度。第一块是区间插入,每个操作被拆成O(log T)份,总插入复杂度是O(M log T)。第二块是 DFS 遍历,线段树节点数是O(T),每个节点进出各一次。第三块是每个操作在 DFS 路径上的应用与撤销,应用一次撤销一次,次数和分割的段数同阶。

总复杂度可以用下面两个公式来记:

  • 动态图连通性:O((M + Q) log T · log n),其中log n来自可撤销并查集的查找。
  • 带时间窗背包:O(M log T · W) + O(T · W),其中前一项来自物品转移,后一项来自快照拷贝。

两个 log 相乘看起来吓人,但实际跑数据时常数很小。我也见过不少选手因为担心复杂度而不敢写线段树分治,其实真到了题面上,1e5 级别的数据它都能轻松扛住。

5.2 坑点一:递归深度和 vector 的内存分配

线段树 DFS 的递归深度是O(log T),理论上不会爆栈。我实际踩过的坑不在栈深度,而在 vector 的零碎内存分配——如果每个线段树节点都挂着 vector,而且动态扩容,1e5 级别的操作会让内存分配次数多到影响性能。

我的做法是先在结构体里开一个vector,然后在插入操作之前统计一下每条边会被挂到多少个节点,二次遍历时直接resize预留空间,省去反复扩容。如果你不想写两步,也可以直接用一个全局大数组把所有操作串起来,每个节点只记录它在全局数组中的起始下标和长度,这样内存完全连续,cache 友好度直接上升一个档次。

5.3 坑点二:区间边界和插入条件

区间插入的边界条件是最容易写错的。线段树区间更新板子没背熟就去套分治,非常容易出现ql、qr在递归过程中被错误更新,或者if (ql <= l && r <= qr)的覆盖判断写反的情况。

我建议先把线段树区间更新的板子单独测一遍,再套分治思想。插入操作的传参尽量写成闭区间[ql, qr],和线段树的[l, r]保持一致,能减少不少混乱。

5.4 坑点三:状态保存的正确时机

关于状态快照的保存时机,正确的语义是:进入节点时记录"进入前"的状态;执行完该节点所有操作后,状态变成"包含本节点操作"的状态;离开节点时恢复到进入前的状态。如果你不小心把快照保存写在了操作应用之后,恢复的就不是正确的基线,而是"已经带上本节点操作"的状态——那整个撤销链路就全乱套了。

我曾经在模拟项目里因为这个错误调了一下午,最后是打印每个节点操作前后的fa[1]数组才发现时机的偏差。这个教训我想分享给每个准备开始写线段树分治的人:先想清楚"当前状态下一次备份发生在哪里",再动手写代码。

最后再分享一个我自己的习惯——写线段树分治的题目时,一定先把主 DFS 框架和可撤销数据结构分开测试。我会先写一个只包含区间插入和普通查询的小样例,跑通之后再接入并查集或背包。这样每一步的问题都能单独定位,不至于最后一团乱麻。线段树分治的思路说穿了就一层窗户纸,但真正写得顺手,是从你开始愿意为它写模板、存备份、压内存的那一刻开始的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询