「Air Raid」 空袭

GitHub跳转原题关系图返回列表

把「最少士兵走遍所有点且每点只走一次」建模为 DAG 最小路径点覆盖,拆点建二分图跑匈牙利算法,答案 = N - 最大匹配。

OJ: roj

题目 ID: 3221

难度:提高

标签:图论二分图匹配匈牙利算法DAG最小路径点覆盖

创建: 2026-10-02 02:46

更新: 2026-10-10 11:30

形式化题目

给定一张 NN 个点、MM 条边的有向无环图 G=(V,E)G=(V,E)。要求选出若干条有向路径,满足:

  • 路径之间两两没有公共点(每个交叉口只被一个士兵走过一次);
  • 所有路径的点集并起来覆盖全部 NN 个点。

求所需路径条数的最小值,这个量称为 DAG 的最小路径点覆盖。

注意路径的连续性指的是边上连续:士兵只能沿一条真实存在的道路 u→vu\to v 从 uu 走到 vv, 所以路径上相邻两点之间必须有一条直接边,而不是“存在一条路径”即可。

正解

思路

第一步:朴素做法为什么不可行

把每个点指派为“某条路径的起点”或“接在某个前驱后面”。最直接的枚举是:每个点从 NN 个前驱 (或“不接”)里选一个,共约 NNN^N 种方案;即使用状压 DP,状态也是“已被覆盖的点集”, 需要 O(2N⋅N)O(2^N \cdot N)。N=120N=120 时两者都完全不可行。

瓶颈很清楚:链的起点集合和终点集合事先不知道,于是只能连链的划分一起枚举。

第二步:只看“接边”,不看链

换个角度数数。设一共用了 kk 条路径,把每条路径内部相邻两点的连接叫做一条接边。 一条含 LL 个点的路径恰好有 L−1L-1 条接边,所以所有路径的接边总数为

ν=∑(Li−1)=N−k.\nu = \sum (L_i-1) = N - k .

这个恒等式说明:只要数出接边数,路径条数就被确定了:k=N−νk = N-\nu。 要让 kk 最小,只需让接边数 ν\nu 最大——这样就绕开了“枚举链”这件事。

同时,一组接边要真的能拼成不相交的路径,必须满足:

  • 每个点至多有一个后继(不能同时接两个点在自己后面);
  • 每个点至多有一个前驱(不能被两个点同时接在后面)。

第三步:把“接边”翻译成二分图匹配

把每个点 uu 拆成两个副本:

  • 左副本 uLu_L:负责 uu 的出边;
  • 右副本 uRu_R:负责 uu 的入边。

对原图的每条有向边 u→vu\to v,在二分图里连一条边 uL→vRu_L \to v_R。 那么一条接边“uu 的后继是 vv”就对应选中二分边 uL→vRu_L\to v_R:

  • “每个点至多一个后继”= 每个左副本最多出现在一条被选中的边里;
  • “每个点至多一个前驱”= 每个右副本最多出现在一条被选中的边里。

这正是匹配的定义。所以“能接出的最多边数”就是这张二分图的最大匹配 ν\nu。

还剩最后一个担心:匹配边可能连成一个环。比如 a→b→c→aa\to b\to c\to a 会选中三条边, 每点度数都合法,却拼不出链。此时题面的无环条件就用上了: 沿着匹配箭头一直走,等价于在原图中沿有向边走一条路,原图无环,所以永远走不回起点。 因此任意匹配都自动分解成若干条点不相交的有向路径,不会出现环。

两个方向合起来:任意一组合法路径拆出接边就得到一个同大小的匹配, 而任意一个匹配又能拼回一组合法路径,所以最大接边数恰好等于最大匹配 ν\nu。 于是最终的公式是

答案=N−ν,ν=拆点二分图的最大匹配数.\text{答案} = N - \nu, \qquad \nu = \text{拆点二分图的最大匹配数}.

第四步:样例 1 对照

样例 1 是 N=4N=4,边为 3→4, 1→3, 2→33\to4,\ 1\to3,\ 2\to3,图长这样:

flowchart LR
    N1((1)) --> N3((3))
    N2((2)) --> N3
    N3 --> N4((4))

图中 33 同时被 11 和 22 指向。1→31\to3 与 2→32\to3 争夺同一个入点 33, 不可能同时成为接边,所以最多只能接出 3→43\to4 和 1→31\to3 两条边,ν=2\nu=2。 “入点冲突导致有人接不上”就是路径条数增加的直接原因。

拆点建图后,uLu_L 连向所有出边终点的右副本,匈牙利算法的匹配结果如下表。 表中每一行是一条候选接边 u→vu\to v:左列写出它对应的二分边,中间是它在原图中的含义, 右列说明匈牙利算法最终是否选中了它。

二分边(候选接边) 含义 是否进入最大匹配
1L→3R1_L\to 3_R 11 的后面接 33 是
3L→4R3_L\to 4_R 33 的后面接 44 是
2L→3R2_L\to 3_R 22 的后面接 33 否,33 已被 11 占用

被选中的两条边恰好各占用一个不同的左副本(1L,3L1_L,3_L)和右副本(3R,4R3_R,4_R), 说明它们构成合法匹配;而 2L→3R2_L\to 3_R 与 1L→3R1_L\to 3_R 共用右副本 3R3_R, 是唯一被挤掉的候选,因此 22 只能自己起一条新路径。

把匹配边当作链内箭头,就还原出覆盖全部 4 个点的路径。 下表每行是一条路径:中间列出路径上的点,右列是该路径贡献的接边, “点数 −1-1”应当等于接边数。

路径 点数 链内接边
1→3→41\to 3\to 4 3 1→3, 3→41\to3,\ 3\to4
22(单独起一条路径,它指向的 33 已被占用) 1 无

接边总数 2+0=2=ν2+0 = 2 = \nu,所以答案 =4−2=2=4-2=2,与样例输出一致。 注意 22 并非孤立点(它有出边 2→32\to3),它单独成路只是因为入点 33 被 11 占用后它的出边当不上接边; 这正说明“入点冲突”就是链数增加的原因。

算法流程

  1. 读入 N,MN,M 与 MM 条有向边;对每条边 u→vu\to v,在 adj[u] 里加入 vv。
  2. 用匈牙利算法对 u=1…Nu=1\dots N 依次寻找增广路,得到最大匹配 ν\nu。
  3. 输出 N−νN-\nu。

实现上按编号顺序为每个左副本增广一次即可:处理完前 ii 个左副本后, 当前匹配已经是它们诱导子图上的最大匹配,不需要反复扫描。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-10 11:30
 * update_at: 2026-10-10 11:30
 */
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

vector<int> adj[125];   // 左副本 u 喜欢的右副本
int match_r[125];
bool seen[125];

bool find_augment(int u) {
    for (size_t i = 0; i < adj[u].size(); i++) {
        int v = adj[u][i];
        if (seen[v]) continue;
        seen[v] = true;
        if (match_r[v] == 0 || find_augment(match_r[v])) {
            match_r[v] = u;
            return true;
        }
    }
    return false;
}

int max_matching(int n) {
    for (int i = 0; i <= n; i++) match_r[i] = 0;
    int matched = 0;
    for (int u = 1; u <= n; u++) {
        for (int i = 0; i <= n; i++) seen[i] = false;
        if (find_augment(u)) matched++;
    }
    return matched;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int T;
    cin >> T;
    while (T--) {
        int n, m;
        cin >> n >> m;
        for (int i = 1; i <= n; i++) adj[i].clear();
        for (int i = 0; i < m; i++) {
            int x, y;
            cin >> x >> y;
            adj[x].push_back(y);
        }
        // 最小路径点覆盖 = 顶点数 - 最大匹配数
        cout << n - max_matching(n) << "\n";
    }
    return 0;
}
python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-02 02:46
# update_at: 2026-10-02 02:46

import sys

FREE = 0  # 右副本未匹配的哨兵;顶点编号从 1 开始,0 永远不会和真实顶点撞上


def find_augment(u: int, adj: list[list[int]], match_r: list[int], seen: list[bool]) -> bool:
    """从右副本一侧给左副本 u 找增广路:能腾出一个位置就返回 True。"""
    for v in adj[u]:
        if seen[v]:
            continue
        seen[v] = True                                        # 本轮已试过 v,不再重复访问
        if match_r[v] == FREE or find_augment(match_r[v], adj, match_r, seen):
            match_r[v] = u                                    # v 改配 u;原来占 v 的点已另找出路
            return True
    return False


def max_matching(n: int, adj: list[list[int]]) -> int:
    """匈牙利算法:依次为每个左副本找增广路,返回二分图最大匹配数。"""
    match_r = [FREE] * (n + 1)
    matched = 0
    for u in range(1, n + 1):
        if find_augment(u, adj, match_r, [False] * (n + 1)):
            matched += 1
    return matched


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    out: list[str] = []
    T = next(data)

    for _ in range(T):
        n, m = next(data), next(data)
        # 拆点建图:每条有向边 u → v 对应一条二分边 u_L — v_R(左管出边,右管入边)
        adj: list[list[int]] = [[] for _ in range(n + 1)]
        for _ in range(m):
            adj[next(data)].append(next(data))

        # 最小路径点覆盖 = 顶点数 - 最大匹配数
        out.append(str(n - max_matching(n, adj)))

    print('\n'.join(out))


if __name__ == "__main__":
    solve()

复杂度

设点数为 NN、边数为 MM。

  • 最大匹配:匈牙利算法,每个左副本增广一次,每次 DFS 最多扫过全部 MM 条边, 时间 O(NM)O(NM)。本题 N≤120N\le 120,官方数据 M≤200M\le 200,实测最慢 0.019s。
  • 空间:邻接表 O(N+M)O(N+M),匹配数组与访问标记各 O(N)O(N),实测峰值约 14.7MB, 远低于 128MB 限制。

与标准 C++ 正解(拆点 + 匈牙利)同阶,没有降级为暴力。

总结

  • 每个交叉口恰好走一次、士兵沿街道连续前进,等价于用点不相交的有向路径覆盖全部点, 即 DAG 最小路径点覆盖;
  • 关键恒等式:kk 条路径的接边总数恒为 N−kN-k。于是最小化路径条数 ⇔ 最大化接边数, 问题从“枚举链的划分”变成“选尽量多的接边”;
  • 把点 uu 拆成 uL,uRu_L,u_R,边 u→vu\to v 变成二分边 uL→vRu_L\to v_R, “每点至多一个前驱、至多一个后继”就是匹配的度数约束,所以最大接边数 = 最大匹配 ν\nu;
  • 原图无环保证匹配不会连成环,链分解一定成立;
  • 答案 =N−ν=N-\nu,用匈牙利算法在 O(NM)O(NM) 内求出;
  • 与 roj/3206 的区别:那题把“存在一条路径”也算连通,必须先做传递闭包; 本题士兵必须走直接边,直接用原图建二分图即可。

图示解析

整道题只有一条主线,三次等价改写:

text
题面:最少几个士兵,走遍所有点且每点只走一次
        │  士兵沿有向边连续前进,路径两两不相交
        ▼
DAG 最小路径点覆盖:用最少的点不相交有向路径覆盖全部点
        │  k 条路径的接边总数恒为 N-k
        │  ⇒ 最小化 k  等价于  最大化接边数 ν
        ▼
拆点二分图:边 u→v 变成 u_L→v_R
        │  "每点至多一个前驱/后继" = 匹配的度数约束
        ▼
最大匹配 ν(匈牙利算法)⇒ 答案 = N - ν

读图要点:每一步都是等价改写而不是近似,所以最终输出的 N−νN-\nu 就是最少士兵数。 第二到第三步之间的恒等式 N−k=νN-k=\nu 是整道题的枢纽——它把“求最少路径条数”这个整体问题, 拆成了可以逐边独立判断的匹配问题;而无环性则保证了匹配一定能被还原成合法的路径。