NeFut Logo NeFut
EN 管理员登录

动态规划与有向无环图的深度解析:拓扑序列驱动的算法设计

发布于:2026-05-27 09:17 最后更新:2026-06-06 13:04
#algorithm #DP #DAG

核心逻辑与数学原理

有向无环图(Directed Acyclic Graph, DAG)是动态规划(DP)的天然拓扑载体。任何一个带有状态转移方程的 DP 问题,只要其状态之间不存在环形依赖,其状态转移空间在本质上都可以抽象为一张 DAG。

在 DAG 上运行动态规划(通常称为 拓扑序列驱动的 DAG-DP),其核心代数原理在于:拓扑排序的一维线性序列,严格满足动态规划所必须的“无后效性”约束。

1. 拓扑序对状态空间的熟化保障

设状态 $v$ 的计算依赖于前置状态 $u_1, u_2, ext{...}, u_k$。在 DAG 拓扑图上,表现为存在有向边 $(u_i, v)$。

2. 状态转移的两种拓扑触发战术

在具体编码实现时,DAG-DP 存在两种经典的拓扑驱动战术:


算法推导与状态设计

以经典的有向无环图最长路径与方案数统计为例进行状态设计。

1. 状态定义

2. 拓扑增量状态转移方程(贡献法)

当零入度节点 $u$ 从拓扑队列中弹出时,其状态 $dp[u]$ 和 $cnt[u]$ 已经绝对熟化。遍历以 $u$ 为起点的每一条出边 $(u, v)$,边权为 $w$:

  1. 最长路松弛(发现更优拓扑路径)
    若 $dp[u] + w > dp[v]$,说明通过 $u$ 中转可以为 $v$ 带来更长的路径。

$$dp[v] = dp[u] + w$$

$$cnt[v] = cnt[u] \\ ( ext{方案数完全由 } u ext{ 继承})$$

  1. 方案数叠加(发现并列最优拓扑路径)
    若 $dp[u] + w == dp[v]$,说明找到了另一条到达 $v$ 且长度相同的并列最优路径。

$$cnt[v] = (cnt[v] + cnt[u]) \\ ext{mod } MOD$$


C++ 标准源码(拓扑序驱动 DAG-DP 标准模板)

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

const int MAXN = 100005;
const long long MOD = 1000000007LL;
const long long INF = 0x3f3f3f3f3f3f3f3fLL;

struct Edge {
    int to;
    long long weight;
};

std::vector<Edge> adj[MAXN];
int in_degree[MAXN];
int out_degree[MAXN];

long long dp[MAXN];  // dp[i] 表示到达节点 i 的最长路
long long cnt[MAXN]; // cnt[i] 表示到达节点 i 且满足最长路条件下的方案数

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

    // 1. 初始化物理边界:将所有入度为 0 的点(拓扑源点)作为 DP 的底座
    for (int i = 1; i <= n; ++i) {
        if (in_degree[i] == 0) {
            q.push(i);
            dp[i] = 0;   // 起点初始最长路径为 0
            cnt[i] = 1;  // 起点初始方案数为 1
        } else {
            dp[i] = -INF; // 其余悬空点初始化为负无穷,防止非法转移
            cnt[i] = 0;
        }
    }

    // 2. 拓扑驱动执行 DP 转移
    while (!q.empty()) {
        int u = q.front();
        q.pop();

        // 遍历 u 的所有出边更新后续节点 v (推式/贡献法)
        for (size_t i = 0; i < adj[u].size(); ++i) {
            int v = adj[u][i].to;
            long long w = adj[u][i].weight;

            // 过滤未熟化的非法起点转移(如果起点本身不可达则放弃松弛)
            if (dp[u] == -INF) continue; 

            // 分支 A:发现更长的路径,重置 dp 和方案数
            if (dp[u] + w > dp[v]) {
                dp[v] = dp[u] + w;
                cnt[v] = cnt[u];
            }
            // 分支 B:发现并列的最优路径,叠加方案数
            else if (dp[u] + w == dp[v]) {
                cnt[v] = (cnt[v] + cnt[u]) % MOD;
            }

            // 维护拓扑队列
            in_degree[v]--;
            if (in_degree[v] == 0) {
                q.push(v);
            }
        }
    }
}

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

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

    for (int i = 0; i < m; ++i) {
        int u, v;
        long long w;
        std::cin >> u >> v >> w;
        adj[u].push_back(Edge{v, w});
        in_degree[v]++;
        out_degree[u]++;
    }

    dag_dp(n);

    // 3. 全局极值汇总
    // 统计所有“出度为 0 的汇点”的最优状态
    long long max_path_len = -INF;
    for (int i = 1; i <= n; ++i) {
        if (out_degree[i] == 0) {
            max_path_len = std::max(max_path_len, dp[i]);
        }
    }

    long long total_schemes = 0;
    for (int i = 1; i <= n; ++i) {
        if (out_degree[i] == 0 && dp[i] == max_path_len) {
            total_schemes = (total_schemes + cnt[i]) % MOD;
        }
    }

    if (max_path_len < 0) {
        std::cout << "No valid path\n";
    } else {
        std::cout << max_path_len << " " << total_schemes << "\n";
    }

    return 0;
}

NOIP 实战避坑指南

  1. 未区分“图的拓扑源点”与“题目指定的逻辑起点”
    如果在运行期,题目显式指定了路径必须从某个固定点 $S$ 开始,很多选手会习惯性地在初始化时把所有 in_degree == 0 的点都赋值为 dp = 0, cnt = 1。这样会导致其他本不该被纳入统计的零入度源点混入转移,计算出来的方案数和路径长会比真实值偏大。
  1. 状态转移时没有拦截 -INF 的无效废弃节点
    如上所述,若非题目指定起点的其他零入度点保持为 -INF,当它们出队时,如果代码没有进行 if (dp[u] == -INF) continue; 的条件拦截,直接去执行 dp[u] + w 可能会因为负溢出,从而错误地松弛、污染了其他节点的合法状态。

经典 NOIP/洛谷 真题

1. 洛谷 P1137 旅行计划

2. 洛谷 P1954 [NOI2010] 航空管制

  1. 第一步:建立反向图,原来的 $A \to B$ 变更为 $B \to A$。将飞机的最晚起飞限制转化为反向图中的限制。
  2. 第二步:利用最大堆(优先队列)维护当前反向入度为 0 的飞机。每次起飞,我们都贪心地选择最晚起飞限制 $k_i$ 最大的那架飞机放在反向序列的最后面。这样可以把更多的“宽容度”留给后面那些限制严格的飞机。
  3. 第三步(求单架飞机 $X$ 的最早位置):为了让 $X$ 尽可能靠前,在反向拓扑中就要让它尽可能靠后。我们可以直接在反向拓扑的迭代中,故意不让 $X$ 进入起飞队列,直到队列变空、且不安排 $X$ 算法再也无法推进时,此时腾出来的反向空位,本质上就是 $X$ 在反向图上能被卡住的最早时点。经过整型平移后,即为原图上的最早起飞位置。整个解法完美压制了 DAG 的拓扑依赖。
原文链接: local://12.2

[h] 返回首页