Harris
发布于 2026-03-19 / 15 阅读
0
0

状压 DP 进阶:求无向图中简单环的数量(CF11D)

状压 DP 进阶:求无向图环的数量 (CF11D 简单的任务)

在图论和动态规划中,统计图中简单环的数量是一个经典问题。当图的节点数 n \leq 19 时,极其契合 O(2^n \cdot n^2) 的时间复杂度,这正是典型的状态压缩 DP (状压 DP) 的应用场景。

本文将详细解析例题 CF11D A Simple Task 的核心解法——按秩转移与去重技巧。

1. 题目大意

给定一个 n 个点 m 条边的无向图(无重边和自环),点数 n \leq 19 ,求无向图中简单环的数量。(简单环的定义:至少包含 3 个点,且除了起点和终点相同外,其余顶点均不相同的路径)。

2. 状态定义与“按秩转移”技巧

为什么不能直接定义终点和起点?

一个直观的思路是:求环,也就是求起点和终点相连的路径。设 f[S][i][j] 表示当前经过的点集为 S ,起点为 i ,当前终点为 j 的路径条数。但由于 |S| = 2^n i,j \in [0,n) ,状态总数为 O(2^n \cdot n^2) 。对于空间和时间来说,都有超限的风险。

降维打击:按秩转移 (固定起点法)

为了压缩掉“起点”这一个维度,并且防止同一个环被多个不同的起点重复统计(例如环 A-B-C-A,不应该被视为 B 起点、C 起点各一次),我们需要一种强制的 “顺序”

核心思路:强制规定每一个环的起点,必须是该环所有节点中编号最小的那一个。

在二进制表示集合 S 时,集合中编号最小的点,恰好就是 lowbit(S) 对应的点! 因此,我们可以重新定义状态: f[S][i] 表示:当前经过的点集为 S ,当前停留在点 i ,且该路径的起点强制为 lowbit(S) 的路径条数。

这样,状态数锐减为 O(2^n \cdot n) ,完全可以接受。

3. 状态转移方程推导

我们在枚举点集 S 的时候,从小到大枚举。对于每一个状态 f[S][i]

  1. 枚举当前路径的末端点 ​ i (要求 ​ i \in S ​ f[S][i] > 0 )。
  2. 枚举 ​ i 的邻接点 ​ j 作为下一步要走的点。

此时会面临三种情况(重点理解):

  • 情况 A:​ j 的编号比起点(lowbit(S))还要小。 这破坏了我们“以最小编号为起点”的原则,直接 continue 跳过,不予转移。 代码体现:if (low(S) > (1 << j)) continue;
  • 情况 B:​ j 恰好等于起点(lowbit(S))。 说明我们绕了一圈又回到了起点,成功找到了一个环! 我们将当前路径数累加到总答案中:ans += f[S][i]
  • 情况 C:​ j 在集合 ​ S 之外,且编号大于起点。 说明路径可以继续向外延伸,我们把点 ​ j 加入集合,更新状态: f[S | (1 << j)][j] += f[S][i]

4. 答案的去重逻辑 (为什么输出 (ans - M) >> 1?)

跑完上述的 DP 之后,我们得到的 ans 并不是最终真正的环的数量,它包含了两种“假环”或“重复环”:

  1. 长度为 2 的路径被当成了环(单条边往返): 由于图是无向的,对于任意一条边 u - v(假设 ​ u < v )。 在 DP 中,起点为 ​ u ,走到了 ​ v ,此时集合为 ​ \{u, v\} 。下一步枚举邻接点时,发现 ​ v 的邻接点有 ​ u ,且 ​ u 恰好是起点,于是 ans 会加上 1。 图中有 ​ M 条边,所以 ans 中多算了 ​ M 个实际上只是单条边的“二元环”。
  2. 每个真正的环被顺时针、逆时针各算了一次: 对于一个真正的环 A-B-C-A(A 编号最小)。 我们的算法会统计到 A → B → C → A,也会在另一条路径扩展时统计到 A → C → B → A。因此每个真正的简单环被算了 2 次。

最终修正公式: 真正的简单环数量 = \frac{\text{ 总计 ans 数} - \text{边数} M}{2} ,即代码中的 (ans - M) >> 1

5. 完整代码及详尽注释

#include <bits/stdc++.h>
#define MAXN 20
#define MAXS 600010
#define LL long long
// 宏定义 lowbit,用于快速获取集合 s 中编号最小的节点 (即最低位的 1)
#define low(x) ((x) & (-(x))) 

using namespace std;

LL ans;
int N, M, Mx;
int A[MAXN][MAXN];     // 邻接矩阵
LL f[MAXS][MAXN];      // DP 数组 f[s][i]

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int i, j, s, u, v;
    if (!(cin >> N >> M)) return 0;
  
    // Mx 表示满状态,即 (1 << N) - 1
    Mx = (1 << N) - 1;
  
    // 读入边并建图,注意将编号从 1~N 映射为 0~N-1
    for (i = 1; i <= M; ++i) {
        cin >> u >> v; 
        u--; v--; 
        A[u][v] = A[v][u] = 1;
    }

    // 初始化:单个节点也视为一条路径的起点
    // 对于每一个节点 j,它构成的单元素集合表示为 i = 1 << j
    for (i = 1, j = 0; j < N; ++j, i <<= 1) {
        f[i][j] = 1; 
    }

    // 开始状压 DP,从小到大枚举状态 s
    for (s = 1; s <= Mx; ++s) {
        // 枚举当前所在节点 i
        for (i = 0; i < N; ++i) {
            // 如果节点 i 不在集合 s 中,或者状态 s 下以 i 结尾的路径数为 0,则跳过
            if (!( (1 << i) & s ) || !f[s][i]) continue;
  
            // 枚举下一步要走的节点 j
            for (j = 0; j < N; ++j) {
                // 如果 i 和 j 之间没有边,跳过
                // 如果 j 的编号比起点 low(s) 还要小,说明破坏了以最小编号为起点的原则,跳过
                if (!A[i][j] || low(s) > (1 << j)) continue;
      
                // 情况 1:如果下一步走到的节点 j 恰好等于起点
                if ((1 << j) == low(s)) {
                    ans += f[s][i]; // 闭合成环,累加答案
                }
                // 情况 2:如果 j 之前没有被访问过 (不在集合 s 中)
                else if (!( (1 << j) & s )) {
                    f[s | (1 << j)][j] += f[s][i]; // 扩展路径并转移状态
                }
            }
        }
    }
  
    // 扣除长度为 2 的往返路径 (即原图的 M 条边),并除以 2 消除正反两个方向的重复统计
    cout << ((ans - M) >> 1) << "\n";

    return 0;
}

总结

这道题是学习 “按秩转移 (固定极值点去重)” 非常经典的例题。在很多需要处理子集、路径或组合的状压 DP 中,为了避免排列导致的重复计算,我们常常强制选取一个特征值(如最小 / 最大索引)作为整个组合的“锚点”,这能有效将复杂度降低一个维度。


评论