Files

7.0 KiB
Raw Permalink Blame History

C++ 算法:图论(四)- 反向图

一、 引言

1.1 问题引入:寻找可达的最大顶点

【例18-4】 图的遍历

给出一个 N 个点、M 条边的有向图。对于图中的每一个顶点 v,我们需要求出 A(v),它表示从点 v 出发,沿着有向边能到达的所有顶点中,编号最大的那一个。

例如,在一个图中,如果从顶点3开始出发只能到达 {3, 5, 8, 2},那么 A(3) 的值就是8。我们需要为图中所有的点(从1到N)都计算出这个值。


二、 解题思路

2.1 思路一:暴力遍历

面对这个问题,我们的第一反应通常是:对每一个点 u(从1到N),都从它出发做一次完整的 DFS 或 BFS,记录下所有能访问到的顶点,然后从中找出一个最大值。

  • 复杂度分析:
    • 一次完整的图遍历,复杂度为 O(N+M)。
    • 我们需要对 N 个点都执行一次。
    • 总时间复杂度 = N * O(N+M) = O(N² + N*M)。
  • 结论:题目给出的数据范围 N, M 高达 10⁵。N² 级别的复杂度会达到 10¹⁰,程序必然超时。暴力方法虽然思路正确,但效率过低,不是可行解。
2.2 思路二:记忆化搜索

既然暴力不行,我们来优化。分析 A(u) 的答案构成: A(u) = max(u, A(v₁), A(v₂), ...),其中 v₁, v₂... 是 u 的所有邻居。

这是一个递归的结构,但纯粹的递归会产生海量的重复计算。例如,计算A(0)时可能需要算A(2),而计算A(1)时可能也需要算A(2)。A(2)会被重复计算多次。

优化方案:记忆化搜索 我们创建一个Answer数组,Answer[i] 用来存储 A(i) 的计算结果。

int A(int u) {
    if (Answer[u] != 0) { // 如果算过了
        return Answer[u]; // 直接返回记好的答案
    }
    int ans = u;
    for (int v : adj[u]) {
        ans = max(ans, A(v));
    }
    Answer[u] = ans; // 算出来后,记在笔记本上
    return ans;
}
2.3 缺陷:有环图中的循环依赖

记忆化搜索看似能解决问题,但在有向有环图中,它会陷入死循环。 考虑这样一个环:2 -> 4 -> 3 -> 2

  • 为了计算 A(2),程序需要先知道 A(4)。
  • 为了计算 A(4),程序需要先知道 A(3)。
  • 为了计算 A(3),程序需要先知道 A(2)。

这就形成了一个逻辑上的循环:一个尚未完成计算的函数,被它自己的调用链反向依赖。就像老板问经理要项目结果,经理问员工,员工却反过来问老板结果是什么。程序会因无限递归而崩溃。


三、 解决方案:反向图

既然正向求解(我能到哪?)的思路在有环时碰壁了,我们不妨逆转思维。

3.1 从“我去哪”到“谁来找我”
  • **正向思考:我(顶点u)出发,能到达的最大编号是多少?
  • **反向思考:对于一个编号很大的顶点 k,有哪些点可以到达它?对于所有能到达k的点u来说,A(u)的候选答案里就必然包含了k。
3.2 反向图的定义与构建

为了实现“谁能到我”的查询,我们引入反向图。

  • 定义:对于一个有向图 G,其反向图 G_rev 拥有和 G 完全相同的顶点。对于 G 中的任意一条边 u -> v,在 G_rev 中都对应一条方向相反的边 v -> u。
  • 构建:在读入原图的边 u -> v 时,我们不执行 adj[u].push_back(v),而是执行 rev_adj[v].push_back(u),即在终点v的邻接表中加入起点u。
  • 功能:在反向图上从顶点 k 开始遍历,所有能被访问到的点,就是在原图中所有能到达 k 的点。
3.3 结合贪心思想

我们的目标是求最大值,一个很自然的贪心想法是:让编号越大的顶点,越有优先权去宣告自己是答案。

算法流程:

  1. 反向建图:读入 m 条边 u -> v,构建反向图(添加边 v -> u)。
  2. 初始化:创建一个 ans 数组,全部初始化为-1,表示所有点的答案待定。
  3. 倒序遍历与广播:
    • 我们从大到小遍历所有顶点 i (从 N 到 1)。
    • 对于当前的顶点 i,检查 ans[i]。
    • 如果 ans[i] 仍为-1:这说明 i 没有被任何比它更大的顶点“覆盖”过。它就是它所在“可达区域”的最大顶点。此时,我们从 i 出发,在反向图上进行一次遍历(DFS或BFS),将所有能访问到且ans值仍为-1的顶点u,都标记上答案:ans[u] = i。
    • 如果 ans[i] 不为-1:这说明 i 已经被一个比它更大的顶点(比如 j > i)“覆盖”了,那么 i 就不可能是它自己的最终答案。我们直接跳过,什么也不做。
  4. 输出结果:倒序循环结束后,ans数组中就存储了所有点的最终答案。

四、 代码实现

vector<vector<int> > rev_adj; // 存储反向图的邻接表
vector<int> ans;             // 存储最终答案

// 在反向图上进行DFS,将答案i“广播”出去
void dfs(int u, int max_node_val) {
    ans[u] = max_node_val; // 标记当前点的答案

    for (int v : rev_adj[u]) {
        if (ans[v] == 0) { // 如果邻居还未被广播过
            dfs(v, max_node_val);
        }
    }
}

int main() {
    int n, m;
    cin >> n >> m;
	rev_adj.resize(n + 1);
    ans.resize(n + 1);
    // 反向建图
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        rev_adj[v].push_back(u); // 建立 v -> u 的反向边
    }

    // 倒序遍历与广播
    for (int i = n; i >= 1; i--) {
        if (ans[i] == 0) { // ans[i]为0代表未被访问过
            // i没有被更大的节点覆盖,它就是自己区域的最大顶点
            // 开始以i为答案,在反向图上传播
            dfs(i, i);
        }
    }

    for (int i = 1; i <= n; i++) {
        cout << ans[i] << " ";
    }

    return 0;
}

五、 反向图的应用与总结

5.1 什么时候应该用反向图?

反向图本质上是一种视角转换工具。当遇到以下情况时,可以考虑使用它:

  1. 反向可达性查询:当问题关心的不是“从A能到哪”,而是“谁能到A”时。
  2. 正向求解遇阻:当正向的递归或递推因为“循环依赖”或需要“未来状态”而陷入困境时,可以尝试从确定的终点或最优解出发,反向地去影响和更新前端节点。
5.2 总结
  • 面对一个复杂的图问题,直接的暴力解法往往因为效率问题而不可行。
  • 记忆化搜索是优化递归、避免重复计算的强大技巧,但要警惕它在有环图中可能导致的循环依赖问题。
  • 反向图提供了一种全新的解题视角,能将“A能到哪”的问题转化为“谁能到A”,从而将一个复杂的依赖问题转化为标准的图遍历问题。