NeFut Logo NeFut
EN 管理员登录

基环树的拓扑排序与动态规划:高效求解最大独立集

发布于:2026-05-27 09:17 最后更新:2026-06-06 13:04
#C++ #Tutorial

核心逻辑与数学原理

拓扑排序在图论的高级应用中,常常与基环树(Functional Graph / Pseudo-tree)的拓扑分解无缝结合。

基环树是指一个包含 $N$ 个点和 $N$ 条边的弱连通图。由于边数恰好等于点数,它在数学拓扑上具有极其独特的性质:整张图有且仅有一个有向(或无向)环,而环上的每个节点都作为根节点,向外衍生出一棵棵独立的树状分支(子树)

在内向基环树(每个点有且仅有一条出边)或无向基环树中,利用拓扑排序进行环与树的分离(剥离战术)是解决绝大多数基环树问题的标准前置动作。

1. 拓扑剪枝的动力学收敛

2. 基环树森林的降维打击

经过拓扑排序的洗礼后,复杂的基环树被优雅地拆解为两部分:

  1. 一个核心环(由残留点组成)。
  2. 若干棵独立的子树(由被拓扑排序抽离的点组成),每棵树的根节点正好是环上的某个节点。

这种空间拆解使得我们可以使用“树上 DP + 环上滚动松弛”的组合拳战术:先在拓扑排序抽离出来的每棵子树上独立运行树形 DP,将树枝上的能量(状态值)全量归拢、收敛到环上的根节点上;随后将核心环拉平为一维数组,在环上运行动态规划或双指针滚动,从而斩获全局最优解。


算法推导与状态设计

以无向基环树最大独立集(基环树上的没有上司的舞会)为例进行状态设计。

1. 拓扑剪枝状态设计

定义一维计数数组 deg[i] 记录每个节点的度数。

  1. 初始化:将所有 deg[i] == 1(叶子节点)的点压入标准队列。
  2. 状态松弛与收敛
    while (!q.empty()) {
     int u = q.front(); q.pop();
     in_ring[u] = false; // 标记该点已被踢出核心环,属于树枝部分
     for (int v : adj[u]) {
         if (deg[v] > 1) {
             // 在树枝向环收敛的过程中,顺便在子树上做状态转移 (树形 DP)
             dp[v][0] += std::max(dp[u][0], dp[u][1]);
             dp[v][1] += dp[u][0];
             if (--deg[v] == 1) q.push(v);
         }
     }
    }

C++ 标准源码(拓扑排序剥离基环树核心环模板)

#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>

const int MAXN = 100005;

std::vector<int> adj[MAXN];
int deg[MAXN];
bool in_ring[MAXN]; // 记录节点最终是否留在核心环中

// dp[i][0] 表示不选 i 号点时,以 i 为根的子树能获得的全局最大价值
// dp[i][1] 表示选择 i 号点时,以 i 为根的子树能获得的全局最大价值
long long dp[MAXN][2]; 
long long weight[MAXN];

void topo_dress_basal_tree(int n) {
    std::queue<int> q;

    // 1. 初始化物理边界:将所有度数为 1 的叶子树枝节点并入队列
    for (int i = 1; i <= n; ++i) {
        in_ring[i] = true; // 默认所有人都在环上
        dp[i][0] = 0;
        dp[i][1] = weight[i]; // 选择根节点则初始获得自身价值
        if (deg[i] == 1) {
            q.push(i);
        }
    }

    // 2. 拓扑剪枝:自外向内剥离树枝,同步自底向上做树形 DP
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        in_ring[u] = false; // 被拓扑序列吞噬,说明不是环上节点

        for (size_t i = 0; i < adj[u].size(); ++i) {
            int v = adj[u][i];
            if (deg[v] > 1) {
                // 树形 DP 经典状态机转移:
                // 节点 v 不选,则其树枝子节点 u 可选可不选
                dp[v][0] += std::max(dp[u][0], dp[u][1]);
                // 节点 v 选择,则其树枝子节点 u 绝对不能选
                dp[v][1] += dp[u][0];

                deg[v]--;
                if (deg[v] == 1) {
                    q.push(v);
                }
            }
        }
    }
}

// 在核心环上进行切断环路的收敛计算
long long solve_ring(int start_node) {
    // 沿着残留的 deg > 1 的边将环路节点按顺序提取到一个一维一字型数组中
    std::vector<int> ring_nodes;
    int curr = start_node;

    while (true) {
        ring_nodes.push_back(curr);
        in_ring[curr] = false; // 强行打上访问标记,防止死循环
        int next_node = -1;
        for (size_t i = 0; i < adj[curr].size(); ++i) {
            int v = adj[curr][i];
            if (in_ring[v]) { // 寻找下一个依然在环上的邻接点
                next_node = v;
                break;
            }
        }
        if (next_node == -1) break;
        curr = next_node;
    }

    int m = ring_nodes.size();
    if (m == 0) return 0;
    if (m == 1) return std::max(dp[ring_nodes[0]][0], dp[ring_nodes[0]][1]);

    // 对环上节点进行两次一维 DP(强制切断思想)
    // 分支 A:强制不选环上第一个节点 ring_nodes[0]
    std::vector<long long> f0(m, 0), f1(m, 0);
    f0[0] = dp[ring_nodes[0]][0];
    f1[0] = -1e15; // 赋予极小值,代表绝对不选
    for (int i = 1; i < m; ++i) {
        int u = ring_nodes[i];
        f0[i] = dp[u][0] + std::max(f0[i-1], f1[i-1]);
        f1[i] = dp[u][1] + f0[i-1];
    }
    long long resA = std::max(f0[m-1], f1[m-1]);

    // 分支 B:强制选择环上第一个节点 ring_nodes[0] -> 则最后一个节点绝对不能选
    f0[0] = -1e15;
    f1[0] = dp[ring_nodes[0]][1];
    for (int i = 1; i < m; ++i) {
        int u = ring_nodes[i];
        f0[i] = dp[u][0] + std::max(f0[i-1], f1[i-1]);
        f1[i] = dp[u][1] + f0[i-1];
    }
    long long resB = f0[m-1]; // 强制限制最后一个节点不能选,只能取 f0

    return std::max(resA, resB);
}

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n;
    if (!(std::cin >> n)) return 0;

    for (int i = 1; i <= n; ++i) {
        int v;
        std::cin >> weight[i] >> v;
        adj[i].push_back(v);
        adj[v].push_back(i);
        deg[i]++;
        deg[v]++;
    }

    // 1. 跑拓扑排序,剥离所有的树枝分支,并将子树能量全量收敛到核心环的节点上
    topo_dress_basal_tree(n);

    // 2. 遍历全图,若点依然在环上,说明找到了一个新的独立基环树的核心环
    long long total_max_value = 0;
    for (int i = 1; i <= n; ++i) {
        if (in_ring[i]) {
            total_max_value += solve_ring(i);
        }
    }

    std::cout << total_max_value << "\n";
    return 0;
}

NOIP 实战避坑指南

  1. 混淆“内向基环树”与“无向基环树”的初始入队门槛: 在有向内向基环树中(每个点有且仅有一条出边),拓扑排序应当按照有向图入度 in_degree == 0 进队。而在无向基环树中,由于边没有方向,初始进队门槛必须严格写成 deg[i] == 1(叶子节点)。如果将无向图的度数误写成 == 0,会导致拓扑队列初始直接为空,整个剪枝系统完全瘫痪,核心环根本无法暴露。
  2. 基环树森林(非连通)遗漏扫描: 很多竞赛题给出的图并不是一棵完整的基环树,而是由多棵互不连通的基环树拼凑而成的基环树森林。选手在拓扑剪枝完毕后,如果只习惯性地从 1 号点出发去拉取环,会漏掉其他孤立基环树的环路状态。铁律:必须在 main 函数中用 for (int i = 1; i <= n; ++i) 全局滚动扫描,只要 in_ring[i] 仍为真,就说明触发了新环,必须单独调用环路处理函数。

经典 NOIP/洛谷 真题

1. 洛谷 P2607 [ZJOI2008] 骑士

  1. 建立无向图,统计每个点的度数,利用 Kahn 拓扑排序从外向内暴力裁剪掉所有非环路的树枝。在裁剪过程中,顺便执行动态规划:将树枝上骑士去留的战斗力贡献滚动累加到他们位于核心环上的“直属上司”根节点上。
  2. 拓扑排序变空后,未被访问的点严格锁定为核心环。遍历全图找出每个环,通过“两次一维 DP 强制断环”法,分别计算“强制不选环上首节点”和“强制选择首节点(此时尾节点必不选)”两种自洽分枝下的极大值,累加即为最终答案。

2. 洛谷 P1453 城市美化 / 城市往事

原文链接: local://12.5

[h] 返回首页