📖 第 5.2b 章:函数图(Functional Graphs)

⏱ 预计阅读时间:35 分钟 | 难度:🟡 中等(USACO Silver 重要考点)


前置条件

  • BFS 与 DFS(第 5.2 章)
  • 图的基本表示(第 5.1 章)

🎯 学习目标

学完本章后,你将能够:

  1. 识别函数图的特征结构(每节点恰好一条出边)
  2. 找到函数图中的所有环(环检测)
  3. 计算图中每个节点距离其所在环的步数
  4. 解决"从节点 x 出发走 k 步到哪里"的跳跃问题(含快速幂加速)
  5. 应用到 USACO Silver 中的函数图专题

5.2b.1 什么是函数图?

定义

函数图(Functional Graph) 是每个节点恰好有一条出边的有向图。

node[i] → next[i],每个节点有且仅有一个后继节点。

等价地,函数图就是一个映射 f: {1..N} → {1..N},从 i 走一步到 f(i)。

结构特征:ρ 形(rho 形)

由于每个节点只有一条出边,从任意节点出发最终必然进入(因为节点有限,走无限步必然重复)。

典型结构(ρ 形):

1 → 2 → 3 → 4 → 5
                ↑   ↓
                8 ← 6
                ↑
                7

节点 5, 6, 8 构成环
节点 1, 2, 3 是"尾巴"(最终进入环)
节点 4 是环的入口
节点 7 指向 8(悬挂在环上)

关键结论:

  • 每个连通分量恰好包含一个环
  • 其他节点都是"树"状悬挂在环上的

5.2b.2 找环(Floyd 判环 / 着色法)

方法一:着色法(推荐,适合 USACO)

用三种颜色追踪 DFS 状态(与有向图环检测完全相同):

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
int nxt[MAXN];      // nxt[i] = i 的后继节点
int color[MAXN];    // 0=未访问, 1=访问中, 2=已完成
int on_cycle[MAXN]; // on_cycle[i] = i 是否在环上
int cycle_id[MAXN]; // 所在环的编号(-1表示不在环上)
int dist_to_cycle[MAXN]; // 到所在环的距离(环上节点为0)

int n;
int num_cycles = 0;

void find_cycle(int start) {
    // 沿着出边走,找到环的起点
    vector<int> path;
    int cur = start;
    
    while (color[cur] == 0) {
        color[cur] = 1;   // 标记为"访问中"
        path.push_back(cur);
        cur = nxt[cur];
    }
    
    if (color[cur] == 1) {
        // cur 是环的入口,标记整个环
        int cycle = num_cycles++;
        int v = cur;
        do {
            on_cycle[v] = true;
            cycle_id[v] = cycle;
            dist_to_cycle[v] = 0;
            v = nxt[v];
        } while (v != cur);
    }
    
    // 将路径上的节点标记为"已完成"
    for (int v : path) color[v] = 2;
}

int main() {
    cin >> n;
    fill(cycle_id, cycle_id + n + 1, -1);
    fill(color, color + n + 1, 0);
    
    for (int i = 1; i <= n; i++) cin >> nxt[i];
    
    for (int i = 1; i <= n; i++)
        if (color[i] == 0) find_cycle(i);
    
    // 计算非环节点到环的距离(BFS 从环向外)
    queue<int> q;
    // 建反向图
    vector<vector<int>> rev(n + 1);
    for (int i = 1; i <= n; i++) rev[nxt[i]].push_back(i);
    
    for (int i = 1; i <= n; i++)
        if (on_cycle[i]) { dist_to_cycle[i] = 0; q.push(i); }
    
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : rev[u]) {
            if (!on_cycle[v] && dist_to_cycle[v] == 0 && v != u) {
                // 尚未计算距离
                dist_to_cycle[v] = dist_to_cycle[u] + 1;
                cycle_id[v] = cycle_id[u];  // 继承所在环
                q.push(v);
            }
        }
    }
    
    // 输出结果
    for (int i = 1; i <= n; i++) {
        cout << "节点 " << i
             << (on_cycle[i] ? " [在环上]" : " [不在环上]")
             << ",到环距离=" << dist_to_cycle[i]
             << ",所在环=" << cycle_id[i] << "\n";
    }
    return 0;
}

方法二:Floyd 判环(快慢指针)

适合判断单条路径是否有环(不需要找具体哪个节点在环上):

// Floyd 快慢指针判环
// 从节点 start 出发,判断是否存在环
bool has_cycle_floyd(int start) {
    int slow = start, fast = start;
    do {
        slow = nxt[slow];
        fast = nxt[nxt[fast]];
    } while (slow != fast);
    return true;  // 函数图一定有环(节点有限)
}

// 找环的入口节点(环起点)
int find_cycle_entry(int start) {
    int slow = start, fast = start;
    do {
        slow = nxt[slow];
        fast = nxt[nxt[fast]];
    } while (slow != fast);
    // 相遇后,slow 从 start 重新出发,fast 留在原地,同速走
    slow = start;
    while (slow != fast) {
        slow = nxt[slow];
        fast = nxt[fast];
    }
    return slow;  // 环的入口
}

5.2b.3 跳 k 步问题(倍增加速)

问题: 从节点 x 出发,走 k 步后到哪里?

朴素做法:循环 k 次,O(k) 可能很慢(k ≤ 10^18)。

倍增加速: 预处理 jump[v][j] = 从 v 出发跳 2^j 步后的节点,类似 LCA 倍增。

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
const int LOG = 40;  // log2(10^18) ≈ 60,按需调整

int n;
long long nxt_arr[MAXN];
long long jump[MAXN][LOG];  // jump[v][j] = 从 v 走 2^j 步

void preprocess() {
    // 第 0 层:直接后继
    for (int v = 1; v <= n; v++) jump[v][0] = nxt_arr[v];
    // 倍增构建
    for (int j = 1; j < LOG; j++)
        for (int v = 1; v <= n; v++)
            jump[v][j] = jump[jump[v][j-1]][j-1];
}

// 从节点 v 出发,走 k 步
long long walk(long long v, long long k) {
    for (int j = 0; j < LOG; j++)
        if ((k >> j) & 1)
            v = jump[v][j];
    return v;
}

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> nxt_arr[i];
    preprocess();
    
    int q; cin >> q;
    while (q--) {
        long long v, k;
        cin >> v >> k;
        cout << walk(v, k) << "\n";
    }
    return 0;
}

5.2b.4 USACO Silver 典型题型

题型 1:函数图的连通性

问题模式: 判断两个节点是否能相互到达,或最终是否落在同一环中。

解法: 找出每个节点所属的环(cycle_id[]),同一环 = 可以相互到达。

// 两节点 u, v 是否最终落在同一环?
bool same_cycle(int u, int v) {
    return cycle_id[u] == cycle_id[v] && cycle_id[u] != -1;
}

题型 2:从每个节点走恰好 k 步后的分布

// 统计走 k 步后,每个节点各有多少节点落在上面
vector<int> count_after_k_steps(int n, int k) {
    vector<int> cnt(n + 1, 0);
    for (int i = 1; i <= n; i++) {
        cnt[walk(i, k)]++;
    }
    return cnt;
}

5.2b.5 完整例题:奶牛跳跃(USACO Silver 风格)

题目: N 头奶牛编号 1~N,每头奶牛 i 喜欢奶牛 nxt[i](构成函数图)。
定义「喜欢链」:从 i 出发沿喜欢关系走 k 步。
Q 次查询,每次给出 (i, k),输出走 k 步后到达的奶牛编号。

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005, LOG = 20;
int n, q;
int jump[MAXN][LOG];

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    
    cin >> n >> q;
    for (int i = 1; i <= n; i++) cin >> jump[i][0];
    
    for (int j = 1; j < LOG; j++)
        for (int i = 1; i <= n; i++)
            jump[i][j] = jump[jump[i][j-1]][j-1];
    
    while (q--) {
        int v, k; cin >> v >> k;
        for (int j = 0; j < LOG; j++)
            if ((k >> j) & 1) v = jump[v][j];
        cout << v << "\n";
    }
    return 0;
}

⚠️ 常见错误

错误原因修复方案
死循环朴素走 k 步但不考虑环倍增法;或找到环后取模
jump 数组越界jump[0][j] 未初始化(若节点编号从0开始)确保哨兵节点正确
循环检测不完整只检测从某个起点出发的路径对所有未访问节点都执行 find_cycle
有向图判环用无向图方法函数图是有向图不能用「父节点排除法」

💪 练习题

🟢 题目 1:找所有环

给定函数图,输出所有环的节点集合(每行一个环,节点按访问顺序)。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; cin >> n;
    vector<int> nxt(n + 1);
    for (int i = 1; i <= n; i++) cin >> nxt[i];
    
    vector<int> color(n + 1, 0);
    vector<bool> on_cycle(n + 1, false);
    
    for (int start = 1; start <= n; start++) {
        if (color[start] != 0) continue;
        
        vector<int> path;
        unordered_map<int,int> pos;  // 节点在 path 中的位置
        int cur = start;
        
        while (color[cur] == 0) {
            color[cur] = 1;
            pos[cur] = path.size();
            path.push_back(cur);
            cur = nxt[cur];
        }
        
        if (color[cur] == 1 && pos.count(cur)) {
            // 找到环:从 cur 开始到 path 末尾
            cout << "环:";
            for (int i = pos[cur]; i < (int)path.size(); i++) {
                on_cycle[path[i]] = true;
                cout << path[i] << " ";
            }
            cout << "\n";
        }
        
        for (int v : path) color[v] = 2;
    }
    return 0;
}

🟡 题目 2:走 k 步

给定函数图和 Q 次查询 (v, k),每次输出从 v 出发走 k 步后的节点(k ≤ 10^9)。

✅ 完整解答

直接使用 5.2b.3 的倍增模板(LOG=30 处理 k≤10^9)。


🔴 题目 3:最远公共祖先(函数图上的 LCA)

函数图中,定义节点 u 和 v 的「公共后继」为第一个同时是 u 的后继和 v 的后继的节点。
给 Q 次查询,找 (u, v) 的最近公共后继。

✅ 解题思路
  1. 先找每个节点所属的环及到环的距离 dist_to_cycle[v]
  2. 若 u, v 在同一个环:最近公共后继就在环上,用倍增在环上找
  3. 若在不同环:无公共后继(输出 -1)
  4. 若一个在环外,先让环外的走 dist_to_cycle[v] 步进入环,再求环上的公共后继
// 核心:把两节点都走到相同位置后,找第一个相同节点
int lca_in_func_graph(int u, int v) {
    // 让 u 和 v 距离环相同(先走到距离较大的那个)
    if (dist_to_cycle[u] < dist_to_cycle[v]) swap(u, v);
    u = walk(u, dist_to_cycle[u] - dist_to_cycle[v]);  // 使两者到环距离相同
    
    // 同步走直到相遇
    for (int j = LOG - 1; j >= 0; j--)
        if (jump[u][j] != jump[v][j]) { u = jump[u][j]; v = jump[v][j]; }
    
    if (u == v) return u;
    return jump[u][0];  // 下一步相遇
}

💡 章节联系: 函数图是 USACO Silver 的独特题型,每季赛约出现 1 道。它结合了图遍历(第 5.2 章)和 LCA 倍增(第 5.4 章)的思想,是 Gold 拓扑排序(第 8.2 章)的前置知识。