算法知识图谱总览 / 动态规划基础族 / DAG上的DP

DAG上的DP

P1intermediatestandardG-A-11

What · 是什么

有向无环图上的最长/最短路,拓扑序转移

DAG上的DP是在有向无环图(DAG)上求最长路或最短路的动态规划。由于DAG天然无环,节点间的依赖关系定义了一个偏序——按拓扑序处理可保证每个节点的状态在处理前已全部就绪。实际问题中的二元依赖关系(如砖块堆叠、任务调度)可建模为DAG,转化为最长/最短路问题。

Why · 为什么

直觉 · Intuition

DAG没有环,所以从起点到终点的所有路径构成一个偏序结构。按拓扑序处理节点,每个节点的最优值只依赖于「指向它的节点」——这些节点在拓扑序中排在前面,已被处理。这就是DAG上DP无后效性的来源。

原理 · Principle

状态定义:d(u)表示从u出发(或到u结束)的最长/短路。转移:d(u)=max{d(v)+w(u,v) | v是u的后继}(最长路)或min同理。处理顺序:按拓扑序(或逆拓扑序)遍历。记忆化搜索也可:DFS(u)时递归处理所有后继v后取最优。时间O(V+E)。

历史 · History

DAG上的DP是最自然的DP模型——DP本质上就是在DAG上做最长/短路(如OI Wiki basic页面所述)。巴比伦塔(UVa 437)是经典教学题,将砖块堆叠建模为DAG最长路。关键路径(CPM)调度也是DAG最长路的应用。

How · 实现

伪代码

# 方法1: 记忆化搜索
d(u):
  if d[u] != -inf: return d[u]
  d[u] = 0
  for (v, w) in adj[u]:
    d[u] = max(d[u], d(v) + w)
  return d[u]

# 方法2: 拓扑序递推
toposort(G)  # 得到拓扑序列
for u in reversed(topo):
  for (v, w) in adj[u]:
    d[u] = max(d[u], d[v] + w)

Python 实现

from collections import defaultdict, deque


def dag_longest_path(n, edges, weights=None):
    """DAG最长路 (记忆化搜索)
    n: 节点数 (0-indexed)
    edges: [(u, v), ...] 有向边 u->v
    weights: { (u,v): w } 边权, 默认为1
    返回: (最长路长度, 最长路起点)
    """
    if weights is None:
        weights = {}
    adj = defaultdict(list)
    for u, v in edges:
        w = weights.get((u, v), 1)
        adj[u].append((v, w))

    memo = {}

    def dfs(u):
        if u in memo:
            return memo[u]
        best = 0
        for v, w in adj.get(u, []):
            best = max(best, dfs(v) + w)
        memo[u] = best
        return best

    best_len, best_start = 0, -1
    for u in range(n):
        val = dfs(u)
        if val > best_len:
            best_len = val
            best_start = u
    return best_len, best_start


def dag_longest_path_topo(n, edges, weights=None):
    """DAG最长路 (拓扑排序+递推)"""
    if weights is None:
        weights = {}
    adj = defaultdict(list)
    in_deg = [0] * n
    for u, v in edges:
        w = weights.get((u, v), 1)
        adj[u].append((v, w))
        in_deg[v] += 1

    # Kahn拓扑排序
    q = deque(u for u in range(n) if in_deg[u] == 0)
    topo = []
    while q:
        u = q.popleft()
        topo.append(u)
        for v, w in adj[u]:
            in_deg[v] -= 1
            if in_deg[v] == 0:
                q.append(v)

    # 逆拓扑序求最长路: dist[u] = max over v of (dist[v] + w)
    dist = [0] * n
    for u in reversed(topo):
        for v, w in adj[u]:
            if dist[v] + w > dist[u]:
                dist[u] = dist[v] + w

    return max(dist), dist.index(max(dist))


def babylon_tower(blocks):
    """UVa 437 巴比伦塔: 每种砖块可3种摆放, 求最大堆叠高度
    blocks: [(a, b, c), ...] 各砖块三条边
    返回: 最大塔高
    """
    # 每个砖块拆为3种摆放方式(选不同边为高)
    # 底面取较小边作为长, 较大为宽
    items = []  # (length, width, height)
    for a, b, c in blocks:
        for h, (l, w) in [(a, sorted([b, c])), (b, sorted([a, c])), (c, sorted([a, b]))]:
            items.append((l, w, h))

    n = len(items)
    # 建DAG: i可放在j上, 当j底面严格大于i底面
    adj = defaultdict(list)
    for i in range(n):
        for j in range(n):
            if i == j:
                continue
            if items[i][0] > items[j][0] and items[i][1] > items[j][1]:
                # i底面更大, j可放在i上, 边 i->j, 权=j的高度
                adj[i].append((j, items[j][2]))

    memo = {}
    def dfs(u):
        if u in memo:
            return memo[u]
        best = items[u][2]  # u本身的高度
        for v, w in adj.get(u, []):
            best = max(best, dfs(v) + items[u][2])
        # 注意: 边权应包含u的高度而非v的
        memo[u] = best
        return best

    # 重新计算: d(u) = u为最底时最大高度
    memo.clear()
    def dfs2(u):
        if u in memo:
            return memo[u]
        best = 0  # 上面还能堆多高
        for v, _ in adj.get(u, []):
            if items[u][0] > items[v][0] and items[u][1] > items[v][1]:
                best = max(best, dfs2(v))
        memo[u] = best + items[u][2]  # 加上自身高度
        return memo[u]

    return max(dfs2(i) for i in range(n))


if __name__ == "__main__":
    # DAG最长路测试
    # 0->1->3, 0->2->3, 1->2
    # 0->1(w=1)->3(w=1) = 2
    # 0->2(w=1)->3(w=1) = 2
    # 最长路: 0->1->2->3 = 3 (边(0,1),(1,2),(2,3))
    edges = [(0, 1), (1, 3), (0, 2), (2, 3), (1, 2)]
    weights = {(0,1):1, (1,3):1, (0,2):1, (2,3):1, (1,2):1}
    length, start = dag_longest_path(4, edges, weights)
    assert length == 3, f"Expected 3, got {length}"

    length2, _ = dag_longest_path_topo(4, edges, weights)
    assert length2 == 3, f"Expected 3, got {length2}"

    # 带权DAG: 0->1(w=3)->2(w=4) = 7
    edges2 = [(0, 1), (1, 2)]
    weights2 = {(0,1):3, (1,2):4}
    length3, _ = dag_longest_path(3, edges2, weights2)
    assert length3 == 7, f"Expected 7, got {length3}"

    # 巴比伦塔: 1个砖块(1,1,1) -> 高度1
    assert babylon_tower([(1, 1, 1)]) == 1
    # 2个砖块: (10,10,1)和(5,5,5)
    # 方案1: 块1竖放(h=10,base=1x10) -> 高10
    # 方案2: 块1平放(h=1,base=10x10)+块2(h=5,base=5x5) -> 高6
    # 最优=10
    assert babylon_tower([(10, 10, 1), (5, 5, 5)]) == 10

    print("All tests passed.")
PyPy 提示PyPy对记忆化搜索(dict+递归)优化好,V+E=1e6时约0.3s。拓扑排序的Kahn算法用deque高效。注意递归深度需setrecursionlimit。
时间复杂度O(V+E) — 每个节点和边各处理一次
空间复杂度O(V+E) — 邻接表+记忆化数组
注意 · 局限DAG上DP需保证图确实无环,否则记忆化搜索会死循环。有环时需先用SCC缩点。Python递归深度限制需注意。

易错点

例题

OJ题号难度考点链接
UVa4373巴比伦塔:DAG最长路经典题,砖块拆解为3种摆放方式https://onlinejudge.org/external/4/437.pdf
LuoguP40172最大食物链计数:DAG上DP计数,拓扑序https://www.luogu.com.cn/problem/P4017
LuoguP11372_STA_旅行计划:DAG最长路,拓扑序递推https://www.luogu.com.cn/problem/P1137
步骤迷你图(节点 = 算法步骤 · 边 = 流程 · 绿色 = 起始步骤)
上游 · 本知识点依赖(前置/变体/组合/共现)

信源

✓ = 已核验 · △ = 未核验