尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

蓝桥杯真题解析:离线LCA与Tarjan算法在版本分支问题中的应用

蓝桥杯真题解析:离线LCA与Tarjan算法在版本分支问题中的应用 1. 从一道蓝桥杯国赛真题说起版本分支与离线LCA如果你参加过蓝桥杯国赛或者刷过历年的真题大概率会对“版本分支”这道题有印象。它出现在2018年的国赛C/C A组是一道典型的图论与数据结构结合的应用题。题目本身描述了一个软件版本管理的场景有一个初始版本1后续每个新版本都从某个旧版本衍生分支出来形成一个树形结构。然后会有一系列查询问版本A是否是版本B的祖先或者说B是否在A的分支上。乍一看这题似乎很简单——不就是判断树上的祖先关系吗直接DFS预处理深度然后从B向上跳父亲节点看能不能跳到A不就行了这个朴素的思路对于一次查询是O(深度)的复杂度。但问题在于国赛的数据规模往往不简单查询次数Q和节点数N都可能达到10^5级别。如果对每次查询都执行一次“向上跳”的操作最坏情况下比如一条链复杂度就是O(N*Q)直接超时。这就是这道题的精妙之处它逼着你必须寻找更高效的算法。而离线LCA最近公共祖先配合Tarjan算法正是解决此类“树上多组节点对关系查询”的利器。今天我们就来彻底拆解这道题不仅告诉你答案更要讲清楚为什么是它以及如何从零开始实现。你会发现掌握了这个组合技你能解决一大类类似的树上查询问题。2. 问题本质抽象为什么是LCA我们先抛开“版本”、“分支”这些业务描述把问题还原到数据结构层面。给定一棵有根树根节点是版本1树上有N个节点。然后给出Q个询问每个询问是(A, B)问A是否是B的祖先。关键转化在树结构中A是B的祖先等价于什么呢等价于A在从根到B的路径上。这又等价于A和B的最近公共祖先LCA就是A本身。想想看如果A是B的祖先那么从根到B的路径必然经过A它们俩的公共祖先中离它们最近的那个LCA自然就是A。反之如果LCA(A, B) A那么A肯定是B的祖先。所以原问题“判断A是否为B的祖先”被完美转化成了“计算A和B的LCA并判断其结果是否等于A”。这样一来我们就把一个特殊的判断问题规约到了一个更通用的、有成熟高效算法的问题——求LCA。那么求LCA有哪些方法呢暴力向上标记法就是我们最初想到的从B向上跳到A或者从A和B分别向上跳到根并记录路径。单次查询O(H)H是树高最坏O(N)。倍增法Binary Lifting预处理每个节点向上2^k级的祖先。查询时先将两个节点调整到同一深度然后一起向上跳。预处理O(N log N)单次查询O(log N)。这是在线算法边问边答。Tarjan算法离线利用并查集和深度优先搜索在一次DFS遍历中回答所有查询。预处理O(NQ)处理所有查询几乎O(1)均摊总复杂度近似O(NQ)但必须一次性知道所有查询。对于蓝桥杯这道题查询是全部已知的非常适合使用离线算法。而Tarjan算法正是离线求LCA的经典算法效率极高。因此“版本分支”这道题几乎就是为“离线LCA Tarjan”这个知识点量身定制的练习题。3. Tarjan算法核心原理如何利用DFS“顺便”回答查询Tarjan算法理解起来有点抽象但它的思想非常巧妙。它不是单独处理每个查询而是把查询“挂”在相关的节点上然后在深度优先遍历整棵树的过程中利用并查集动态维护节点的“集合”关系当遍历到某个节点时一些查询的答案就自然浮现了。我们来一步步拆解它的工作原理。假设我们有一棵树以及若干关于(u, v)的LCA查询。3.1 状态定义与并查集的作用算法为每个节点定义三种状态未访问还没被DFS碰到。访问中当前DFS栈中的节点即正在处理这个节点及其子树。访问完成这个节点及其所有后代都已经被DFS回溯完毕。算法的核心在于当一个节点u被“访问完成”即它的所有子树都处理完后将它与其父节点进行并查集合并merge(u, parent[u])。并查集在这里维护的是什么它维护的是已经访问完成的、且连向其当前最近未完成祖先的节点集合。更直观地说在DFS回溯的过程中并查集会逐渐把一棵棵已经处理完的子树“打包”起来这个集合的“代表元”并查集的根节点被设置为这棵子树的最顶端的那个节点这个节点当时可能还未完成访问。3.2 查询回答的时机对于任意一个查询LCA(u, v)它会在什么时候被回答 答案是当DFS遍历到u和v中的第二个节点时。假设DFS先访问到u。当它访问到v时此时u可能已经访问完成也可能还在栈中为了回答LCA(u, v)我们需要知道u当前属于哪个集合。这个集合的代表元就是u和v的最近公共祖先为什么这需要分情况讨论但可以直观理解DFS是深度优先的它一定是从某个公共祖先下去先遍历完其中一个分支比如包含u的分支然后回溯到公共祖先再去遍历另一个分支包含v的分支。当我们在遍历v时包含u的那个分支一定已经被“打包”进了并查集并且这个集合的代表元被设置在了它们最后的那个公共祖先处因为回溯过程中不断向上合并。3.3 算法步骤与模拟让我们结合一个超简单的例子来模拟。考虑一棵树1是根有孩子2和32有孩子4。 查询LCA(4, 3)。初始化每个节点自成一个并查集。所有节点标记为“未访问”。将查询(4,3)双向地关联到节点4和节点3上即节点4的记录里有“需要和3求LCA”节点3同理。DFS从根1开始访问节点1状态“访问中”。递归访问孩子2节点2状态“访问中”。递归访问孩子4节点4状态“访问中”。节点4是叶子开始回溯节点4的所有子树无访问完成。检查与节点4相关的所有查询发现有一个查询(4,3)。此时另一个节点3的状态是“未访问”。这个查询还无法回答因为v3还没被访问到。这是关键只有当查询的两个节点都被访问过才能尝试回答。回溯前将节点4与其父节点2进行并查集合并。假设合并后集合的代表元是2。回溯到节点2节点2的所有子树以4为根的子树访问完成。检查与节点2相关的查询假设无。回溯前将节点2与其父节点1合并。合并后集合{2,4}的代表元变为1。DFS从节点1继续访问孩子3节点3状态“访问中”。它是一个叶子节点。在访问节点3时或完成时检查与节点3相关的所有查询发现查询(4,3)。此时另一个节点4的状态是“访问完成”。此时可以回答查询查找节点4在并查集中的代表元Find(4)。根据步骤4节点4所在集合的代表元是1。所以LCA(4,3) 1。答案正确。节点3访问完成将其与父节点1合并。整个过程中查询(4,3)是在遍历到第二个节点3时通过查找第一个节点4所在并查集的代表元来得到答案的。并查集巧妙地记录了DFS回溯的路径使得代表元恰好就是那个“最近的、尚未完成访问的祖先”也就是LCA。关键理解点Tarjan算法是“离线”的因为它需要预先知道所有查询并将它们绑定到节点上。它的高效性来自于将多次查询的求解过程完全压缩到一次DFS遍历中利用并查集近乎O(1)的查询操作实现了总体近似线性的时间复杂度O(NQ)。4. 实战“版本分支”代码实现与逐行解析理解了原理我们来看如何用C实现来解决蓝桥杯原题。我们会使用邻接表存树、向量数组存查询、并查集以及一个vis数组来标记节点状态。4.1 数据结构定义#include iostream #include vector #include cstring using namespace std; const int MAXN 100010; // 根据题目规模设定通常10^510 vectorint tree[MAXN]; // 树的邻接表 vectorpairint, int query[MAXN]; // query[u]存储所有与u相关的查询 (v, id) int ans[MAXN]; // 存储每个查询的答案ans[id] LCA(u,v) int parent[MAXN]; // 并查集父节点 bool vis[MAXN]; // 标记节点是否已访问完成Tarjan算法意义下的“完成” int n, q; // 节点数查询数这里注意query[u]的设计。对于每个查询(A, B)我们将其双向存储既在query[A]里加入(B, query_id)也在query[B]里加入(A, query_id)。query_id是这个查询的编号用于将答案存到正确的ans[id]位置。4.2 并查集实现并查集在Tarjan算法中实现非常简单只有“查找”和“合并”操作不需要按秩合并或路径压缩当然用了更好因为合并顺序是确定的子节点合并到父节点。void initDSU() { for (int i 1; i n; i) { parent[i] i; // 初始化每个节点是自己的父亲 } } int find(int x) { // 路径压缩 if (parent[x] ! x) { parent[x] find(parent[x]); } return parent[x]; } void merge(int x, int y) { // 这里通常将子节点集合合并到父节点集合 // 在Tarjan的DFS回溯过程中调用x是子节点y是父节点 int rootX find(x); int rootY find(y); if (rootX ! rootY) { parent[rootX] rootY; // 将x所在集合挂到y所在集合下 } }在Tarjan算法中merge(u, parent_of_u)的调用确保了已经处理完的子树u被“打包”进了其父节点的集合。4.3 Tarjan算法的DFS核心这是整个算法的灵魂所在。void tarjan(int u) { vis[u] true; // 标记当前节点为“访问中”在递归栈里 // 遍历当前节点的所有子节点 for (int v : tree[u]) { if (!vis[v]) { tarjan(v); // 递归处理子树 merge(v, u); // 关键子树v处理完后将其合并到当前节点u } } // 当前节点u及其所有子树处理完毕现在可以回答所有与u相关的查询 for (auto q : query[u]) { int v q.first; int id q.second; if (vis[v]) { // 如果查询的另一个节点v已经被访问过无论是否完成 // 那么v的LCA就是v所在并查集的当前根节点 ans[id] find(v); } // 如果v未被访问这个查询留到访问v时再回答 } // 注意这里不需要显式标记“访问完成”因为vis[u]在递归回溯后依然为true // 表示“已访问”在Tarjan语境下即等同于“已完成处理”。 }逐行解析vis[u] true;一进入节点就标记代表这个节点进入了DFS递归栈。递归处理所有子节点v。merge(v, u);这是算法的关键操作。在子节点v的整个子树都处理完成后即tarjan(v)调用返回将v所在的集合合并到其父节点u的集合中。这意味着所有v的后代现在在并查集里都“属于”u了。处理完所有子树后遍历所有与u相关的查询(u, v)。if (vis[v])如果另一个节点v已经被访问过vis[v]true说明v要么是u的祖先正在栈中要么是u的某个已处理完成的旁系节点的后代。无论是哪种情况v当前所在并查集的根节点find(v)就是u和v的最近公共祖先。为什么因为并查集的合并操作merge(v, u)是自底向上的find(v)返回的就是v所在集合的最顶端的、尚未完成所有子树处理的节点这个节点正是u和v的LCA。将计算出的LCA存入ans[id]。4.4 主函数与问题适配对于“版本分支”问题我们需要根据输入构建树并将每个查询(A, B)转化为求LCA(A, B)然后判断结果是否等于A。int main() { // 假设输入第一行n, q。接下来n-1行每行两个整数a b表示b是a的直接后继a是b的父节点。 // 接下来q行每行两个整数A B表示询问。 cin n q; initDSU(); memset(vis, false, sizeof(vis)); // 建树 for (int i 0; i n - 1; i) { int a, b; cin a b; // 题目描述是b从a分支所以a是父b是子 tree[a].push_back(b); // 如果需要也可以记录父节点关系这里用邻接表就够了 } // 存储查询 for (int i 0; i q; i) { int A, B; cin A B; // 为查询赋予唯一id (i) query[A].emplace_back(B, i); query[B].emplace_back(A, i); // 双向添加 // 我们还需要记录这个查询的原始信息用于最后判断。 // 可以另开两个数组originA[i]A, originB[i]B。 } // 从根节点1开始Tarjan算法 tarjan(1); // 输出答案 for (int i 0; i q; i) { // 假设我们记录了originA[i]和originB[i] // LCA结果存储在ans[i]中 if (ans[i] originA[i]) { cout YES endl; // A是B的祖先 } else { cout NO endl; } } return 0; }5. 算法对比、边界处理与调试技巧5.1 为什么不用倍增法倍增法在线算法当然可以解决这道题预处理O(N logN)每次查询O(logN)总复杂度O(N logN Q logN)对于10^5的数据量也完全能过。那为什么强调Tarjan理论复杂度更优Tarjan是近似O(NQ)的常数小在极端大数据下更有优势。练习目的蓝桥杯这道题几乎是离线LCA的模板题考察的就是这个知识点。掌握Tarjan算法能让你对DFS和并查集有更深的理解。思维锻炼Tarjan算法的思想离线处理、利用遍历过程统一求解非常经典在解决其他离线查询问题如树上路径查询、子树统计等时很有用。5.2 边界情况与注意事项根节点的父节点在并查集合并时根节点如节点1没有父节点。我们的merge(v, u)操作在u是根节点时依然有效因为parent[1]初始为1find(1)返回1。但要注意不要在tarjan函数外去合并根节点到一个不存在的父节点。查询的存储与去重一个查询(A,B)被存储了两次。当DFS先后处理A和B时这个查询会被尝试回答两次。但if (vis[v])保证了只有在第二个节点被处理时才会真正计算答案并且两次计算的结果是相同的。这是一种简洁有效的设计。vis数组的含义在这个Tarjan LCA实现中vis[u]在tarjan(u)开头就被设为true并且永远不会被重置为false。它表示的是“该节点是否已被DFS进入过”这与我们之前说的“访问完成”状态在时间点上略有差异但在这个算法流程中当我们在tarjan(u)中处理查询时u的所有子树确实已经处理完成因此vis[v]true足以判断节点v是否已被访问并可用于计算LCA。多组数据初始化如果是多组测试数据务必清空tree、query、ans、vis数组并重新初始化并查集。vector的.clear()操作是O(1)的但释放内存需要vectorint().swap(tree[i])这种技巧或者直接声明在局部变量中让系统回收。5.3 调试技巧画图与手动模拟Tarjan算法逻辑绕容易写错。最好的调试方法是画一棵小树5-6个节点。列出所有查询。拿一张纸手动模拟DFS过程记录每个时刻vis数组的状态、并查集的状态以及查询答案是在哪个节点的哪个时刻被计算出来的。与你的程序输出对比。可以在关键位置如merge前后、回答查询时添加打印语句输出节点、集合代表元等信息。一个常见的错误是合并顺序。一定要在递归调用tarjan(v)之后才进行merge(v, u)。这保证了在处理u的查询时子树v已经被完全“打包”好了。6. 举一反三Tarjan算法还能解决什么问题掌握了Tarjan求LCA你就打开了一扇门。LCA本身是许多高级树上问题的基础组件。例如树上两点间距离dist(u, v) depth[u] depth[v] - 2 * depth[LCA(u, v)]。预处理深度即可。树上路径查询询问树上两点路径上所有点的权值和、最大值等。可以通过LCA结合树上前缀和、倍增等数据结构实现。判断节点是否在另一节点的子树中这就是“版本分支”问题的原形。更一般化可以判断一个节点是否在连接另外两个节点的路径上。此外Tarjan这个名字在算法领域非常响亮同一个发明者还有著名的Tarjan算法求有向图的强连通分量SCC。虽然此Tarjan求LCA与彼Tarjan求SCC在细节上不同但它们都体现了深度优先搜索与栈/并查集结合的强大威力都是一种“在遍历过程中通过巧妙的回溯处理来解决问题”的离线思想。回过头看“版本分支”这道蓝桥杯国赛题它不仅仅是一道题更是一个引子引导你去学习并查集、深度优先搜索、最近公共祖先这些基础但强大的算法组件并理解它们如何组合起来高效解决实际问题。在竞赛和工程中这种将实际问题抽象为经典模型并运用高效算法解决的能力才是最重要的收获。下次再遇到“树上多组关系查询”的问题不妨先想想能不能转化成LCA能不能用离线的Tarjan算法来一次搞定。
返回列表