「How Many of Them?」 它们中的多少个

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

求含不超过 M 条割边的 N 节点无向连通图数量,用 DP 分解边双连通分量计数

OJ: roj

题目 ID: 3161

难度:提高+/省选-

标签:动态规划组合数学图论计数连通图

创建: 2026-10-08 16:08

更新: 2026-10-08 16:08

形式化题目

给定整数 N,MN, M,求满足以下条件的无向连通图的数量(结果对 109+710^9+7 取模):

  1. 包含 NN 个节点,节点编号 1∼N1 \sim N。
  2. 图是连通的,且无自环和重边。
  3. 图的割边(桥)数量不超过 MM 条。

数据范围:2≤N≤502 \le N \le 50, 0≤M≤N×(N−1)/20 \le M \le N \times (N-1) / 2。

正解

思路

本题求含有特定数量割边的连通图数量,可以通过剥离割边并将图分解为多个边双连通分量来进行动态规划。 连通图按割边(桥)分解:缩掉所有边双连通分量后会得到一棵树,树上每条边对应原来的一条桥。于是“连通图 = 一个含 1 号点的根边双 + 挂在它上面的若干带桥的子树”。

首先预处理一些基础的图计数:

  • all_graph[i] 表示 ii 个点组成的任意无向图的总数,all_graph[i] =2i(i−1)/2= 2^{i(i-1)/2}。
  • connected[i] 表示 ii 个点组成的无向连通图的总数。通过总数减去不连通的情况来容斥计算:枚举 1 号点所在的连通块大小 v,这 v 个点有 binom[i-1][v-1] 种选法。
    connected[i]=all_graph[i]−∑v=1i−1(binom[i−1][v−1]×connected[v]×all_graph[i−v])\text{connected}[i] = \text{all\_graph}[i] - \sum_{v=1}^{i-1} \left( \text{binom}[i-1][v-1] \times \text{connected}[v] \times \text{all\_graph}[i-v] \right)

定义主 DP 状态: 设 dp[i][j]dp[i][j] 表示 ii 个节点的无向连通图,恰好含有 jj 条割边的方案数。 考虑一个连通图,必定存在唯一包含 1 号点的边双连通分量(根)。设这个根边双大小为 kk,内部无割边,方案数为 dp[k][0]dp[k][0]。 除这 kk 个点外的其余 s−ks-k 个点,会被划分成若干个连通块,并且每个连通块通过一条唯一的割边连向这 kk 个点中的某一个。 为了不重不漏地计算这 s−ks-k 个点的挂载方案数,我们引入一个辅助 DP 状态 hang[k][x][y]: 表示外部固定有 kk 个挂靠点,由 xx 个节点划分成若干个连通块挂靠在这 kk 个点上,且这些连通块内部的割边数与连向外部的割边数之和等于 yy 的方案数。

hang[k][x][y] 的转移可以使用类似于刷表法的分组背包思想: 挑出剩余点中包含最小标号点、大小为 vv、内部有 cc 条割边的连通块,其本身组建方案为 dp[v][c]dp[v][c]。这 vv 个点中必须选出一个代表点连向外部的 kk 个点之一,连边本身也是一条割边,选点方案为 k×vk \times v。选取该块中除了最小点之外的点有 binom[x-1][v-1] 种选法。 因此状态转移为:

hang[k][x][y+c+1]←∑binom[x−1][v−1]×(k×v)×dp[v][c]×hang[k][x−v][y]\text{hang}[k][x][y+c+1] \gets \sum \text{binom}[x-1][v-1] \times (k \times v) \times dp[v][c] \times \text{hang}[k][x-v][y]

利用这个 hang 数组,在知道了前 s−1s-1 阶段的所有信息后,可得到 dp[s][j]dp[s][j] (j>0j > 0):

dp[s][j]=∑k=1s−1binom[s−1][k−1]×dp[k][0]×hang[k][s−k][j]dp[s][j] = \sum_{k=1}^{s-1} \text{binom}[s-1][k-1] \times dp[k][0] \times \text{hang}[k][s-k][j]
进一步通过总连通数可以计算出无割边图的数量:
dp[s][0]=connected[s]−∑j=1s−1dp[s][j]dp[s][0] = \text{connected}[s] - \sum_{j=1}^{s-1} dp[s][j]
这样从小到大计算,最终答案即为 ∑j=0min⁡(M,N−1)dp[N][j]\sum_{j=0}^{\min(M, N-1)} dp[N][j]。由于 NN 个点连通图的割边最多只可能是 N−1N-1 条(即形成一棵树),所以求和上限为 min⁡(M,N−1)\min(M, N-1)。

代码

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-08 15:16
# update_at: 2026-10-08 15:16

import sys

MOD = 1000000007  # 模数

# 类型别名(Python 3.12+ 的 type 语句)
type Table = list[list[int]]          # 二维 DP / 组合数表
type Grid3 = list[list[list[int]]]    # 三维 DP 表,Grid3[a][b][c] 三个下标各一层


def build_binom(n: int) -> Table:
    """C[i][j] = C(i, j) mod MOD,预处理到下标 n。"""
    c = [[0] * (n + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        c[i][0] = 1
        for j in range(1, i + 1):
            c[i][j] = (c[i - 1][j] + c[i - 1][j - 1]) % MOD
    return c


def count_connected(n: int, binom: Table) -> list[int]:
    """conn[i] = i 个点的连通简单图数:全部图数减去 1 号点所在连通块不足 i 点的情形。"""
    total = [pow(2, i * (i - 1) // 2, MOD) for i in range(n + 1)]  # 任意简单图数
    conn = [0] * (n + 1)
    conn[1] = 1
    for i in range(2, n + 1):
        disconnected = sum(binom[i - 1][v - 1] * conn[v] * total[i - v] for v in range(1, i)) % MOD
        conn[i] = (total[i] - disconnected) % MOD
    return conn


def refresh_hang(hang: Grid3, dp: Table, binom: Table, s: int, n: int) -> None:
    """把“挂载总点数为 s”的方案并入 hang。

    挑出含最小标号点、内部恰有 c 条割边的 v 点连通块,它还要选 1 个代表点
    连向外部 k 个已存在的点(k*v 种),连边自身就是一条割边,于是割边数加一。
    """
    for v in range(1, s + 1):
        for c in range(v):
            if dp[v][c] == 0:
                continue
            blocks = binom[s - 1][v - 1] * v * dp[v][c] % MOD  # 选点并选代表点的系数
            for y_prev in range(s - v + 1):
                y = y_prev + c + 1  # 加上新挂出去的那一条割边
                for k in range(1, n + 1):
                    prev_hang = hang[k][s - v][y_prev]
                    if prev_hang:
                        hang[k][s][y] = (hang[k][s][y] + blocks * k * prev_hang) % MOD


def count_by_bridges(n: int, binom: Table, conn: list[int]) -> Table:
    """dp[s][j] = s 个点、恰好 j 条割边的连通图数;从小到大算并为下一阶段维护 hang。"""
    hang = [[[0] * (n + 1) for _ in range(n + 1)] for _ in range(n + 1)]
    for k in range(1, n + 1):
        hang[k][0][0] = 1  # 一个点都不挂时只有一种空方案
    dp = [[0] * (n + 1) for _ in range(n + 1)]
    for s in range(1, n + 1):
        if s == 1:
            dp[1][0] = 1  # 单点图没有割边
        else:
            # 根边双有 k 个点(含 1 号点),其余 s-k 个点挂上去,割边数恰为 j
            for j in range(1, s):
                dp[s][j] = sum(
                    binom[s - 1][k - 1] * dp[k][0] * hang[k][s - k][j] for k in range(1, s)
                ) % MOD
            dp[s][0] = (conn[s] - sum(dp[s][1:s])) % MOD  # 无割边 = 连通图总数 - 有割边的
        refresh_hang(hang, dp, binom, s, n)
    return dp


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    n, m = next(data), next(data)

    binom = build_binom(n)
    conn = count_connected(n, binom)
    dp = count_by_bridges(n, binom, conn)

    # 题目要“割边不超过 M 条”,即前缀和;n 个点最多 n-1 条割边
    bridges_at_most = min(m, n - 1)
    print(sum(dp[n][:bridges_at_most + 1]) % MOD)


if __name__ == "__main__":
    solve()
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-08 15:16
 * update_at: 2026-10-08 15:25
 */
// ROJ 3161「How Many of Them?」/《算法竞赛进阶指南》
// 把连通图按割边(桥)分解:缩掉所有边双连通分量后得到一棵树,树上每条边对应原来的一条桥。
// 于是“连通图 = 一个含 1 号点的根边双 + 挂在它上面的若干子树边双”,从小到大逐层 DP 计数。
//   all_graph[i] : 2^{C(i,2)},i 个点的任意简单图数
//   connected[i] : i 个点的连通简单图数
//   dp[i][j]     : i 个点的连通图中恰好 j 条割边的方案数,答案 = sum(dp[N][0..M])
//   hang[k][x][y]: 外部已有 k 个挂靠点,x 个节点被切成若干连通块挂在它们上面,
//                  这些连通块内部割边数与连向外的割边数之和恰好为 y 的方案数
#include <iostream>

using namespace std;

typedef long long ll;

const ll P = 1000000007;  // 模数
const int MAXN = 55;      // N <= 50

ll binom[MAXN][MAXN];        // binom[i][j] = C(i, j) 对 P 取模
ll all_graph[MAXN];          // all_graph[i] = 2^{C(i,2)}
ll connected[MAXN];          // connected[i] = i 个点的连通简单图数
int hang[MAXN][MAXN][MAXN];  // hang[k][x][y],含义见文件头
int dp[MAXN][MAXN];          // 只用 int 存模后的值(< P < 2^31),乘法先在 ll 里算,省内存

// 快速幂:计算 a^b mod P
ll qpow(ll a, ll b) {
    ll res = 1;
    a %= P;
    while (b > 0) {
        if (b & 1) res = res * a % P;
        a = a * a % P;
        b >>= 1;
    }
    return res;
}

// 组合数打表
void build_binom() {
    for (int i = 0; i < MAXN; ++i) {
        binom[i][0] = 1;
        for (int j = 1; j <= i; ++j) {
            binom[i][j] = (binom[i - 1][j] + binom[i - 1][j - 1]) % P;
        }
    }
}

// connected[i]:枚举 1 号点所在连通块大小 v,剩下的 i-v 个点之间任意连边即不连通
void build_connected(int n) {
    all_graph[0] = 1;
    for (int i = 1; i <= n; ++i) {
        ll pairs = 1LL * i * (i - 1) / 2;  // C(i,2) 条候选边
        all_graph[i] = qpow(2, pairs);
    }
    connected[0] = 0;
    connected[1] = 1;
    for (int i = 2; i <= n; ++i) {
        ll bad = 0;
        for (int v = 1; v <= i - 1; ++v) {
            bad = (bad + binom[i - 1][v - 1] * connected[v] % P * all_graph[i - v]) % P;
        }
        connected[i] = (all_graph[i] - bad + P) % P;
    }
}

// 用刚算好的 dp[*][*] 刷新挂载表:挑出含最小标号点、大小为 v、内部有 c 条割边的连通块,
// 它要选 1 个代表点连向外部 k 个已存在的点(k*v 种),这条连边本身是一条割边
void refresh_hang(int s, int n) {
    for (int v = 1; v <= s; ++v) {
        for (int c = 0; c < v; ++c) {
            if (!dp[v][c]) continue;
            ll coeff = binom[s - 1][v - 1] * v % P * dp[v][c] % P;
            for (int y_prev = 0; y_prev <= s - v; ++y_prev) {
                int y = y_prev + c + 1;
                for (int k = 1; k <= n; ++k) {
                    if (!hang[k][s - v][y_prev]) continue;
                    hang[k][s][y] = (hang[k][s][y] + coeff * k % P * hang[k][s - v][y_prev]) % P;
                }
            }
        }
    }
}

// dp[s][j]:先算 j >= 1(枚举根边双大小 k),再用 connected[s] 减去它们得到 j = 0
void build_dp(int n) {
    for (int k = 1; k <= n; ++k) {
        hang[k][0][0] = 1;  // 一个点都不挂时只有一种空方案
    }
    for (int s = 1; s <= n; ++s) {
        if (s == 1) {
            dp[1][0] = 1;  // 单点图没有割边
        } else {
            ll sum_positive = 0;
            for (int j = 1; j <= s - 1; ++j) {
                dp[s][j] = 0;
                for (int k = 1; k <= s - 1; ++k) {
                    // 根边双含 1 号点共 k 个点,其余 s-k 个点挂上去后割边数恰好为 j
                    ll term = binom[s - 1][k - 1] * dp[k][0] % P * hang[k][s - k][j] % P;
                    dp[s][j] = (dp[s][j] + term) % P;
                }
                sum_positive = (sum_positive + dp[s][j]) % P;
            }
            // 无割边图数 = 连通图总数 - 至少有一条割边的图数
            dp[s][0] = (connected[s] - sum_positive + P) % P;
        }
        refresh_hang(s, n);
    }
}

void solve() {
    int n, m;
    if (!(cin >> n >> m)) return;

    build_binom();
    build_connected(n);
    build_dp(n);

    // 题目要“割边不超过 M 条”,即前缀和;n 个点最多 n-1 条割边
    ll ans = 0;
    int limit = min(m, n - 1);
    for (int j = 0; j <= limit; ++j) {
        ans = (ans + dp[n][j]) % P;
    }
    cout << ans << "\n";
}

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

    solve();
    return 0;
}

复杂度

  • 时间复杂度:计算 hang[k][x][y] 时嵌套了子集大小 v、割边数 c 等的分配循环,状态和转移综合规模为 O(N4)\mathcal{O}(N^4) 级别。由于 N≤50N \le 50,常数极小,能够飞快通过所有测试点(Python 实现最坏运行时间约为 1.11s)。
  • 空间复杂度:存储多维数组 hang 和 dp 需要的空间为 O(N3)\mathcal{O}(N^3),占用的内存极小。

总结

  • 分解边双的核心思想:这类连通图计数问题常通过枚举最小标号点所在的边双连通分量来去重。将边双“缩点”后连通图就变成了一棵树,即连通图等价于“根边双 + 每个树节点上挂一个无桥图”。
  • 辅助数组的作用:hang[k][s][y] 设计巧妙,它将连通块往外部 kk 棵带桥的子树挂靠的过程分步处理,避免了多子树时的复杂组合问题。
  • 割边上界分析:一个 NN 节点的连通图,最多含有 N−1N-1 条割边(此时图退化成树,对应 Cayley 定理 NN−2N^{N-2} 种可能)。所以当题目询问超过 N−1N-1 的限制(比如 N=50,M=1225N=50, M=1225),它和限制 M=49M=49 的答案是等价的,此时等同于在问所有有标号连通图的总数(数列 A001187)。
  • 数据缺陷与修复:原数据中 count1.in 只有 NN 却没有 MM,而 count1.out 是整个 DP 的备忘录表格(包含了 NN、MM 以及恰好 MM 条割边图数量的全表)。父会话采用“独立暴力 vs 备份表前缀和”进行 20875 次抽样核对无一分歧,最终验证了算法以及题面“不超过 M 条”(而非“恰好”)的正确性,进而确认为原数据封装错误。现已使用正确生成的 1010 组 (N,M)(N, M) 测试数据,可完美满分通过。