欢迎光临
我们一直在努力

从一道 CSP-J 真题出发:聊聊补集转化与 01 背包计数

题源链接:洛谷 P14360 [CSP-J 2025] 多边形 / polygon


一、背景

在算法竞赛的组合计数专题中,有一类问题特别考验选手的"逆向思维"——它们不问你"满足条件的方案有多少",而是逼着你先想"不满足条件的方案有多少"。CSP-J 2025 的这道多边形题,就是这类问题的典型代表。

题目场景很生活化:小 R 有

n

n

n 根小木棍,想从中选出若干根拼成一个多边形。多边形的条件是经典的"两边之和大于第三边"的推广:所有木棍长度之和必须严格大于最长木棍的两倍,且至少选

3

3

3 根。

初看之下,这似乎需要枚举所有子集,然后判断每个子集是否满足条件。但当

n

=

5000

n = 5000

n=5000 时,子集数量达到

2

5000

2^{5000}

25000,完全不可行。我们需要一个更聪明的方法——补集转化。

本文就从这道多边形题出发,聊聊补集思想的威力,以及01 背包 DP在组合计数中的经典应用。


二、核心思想

2.1 补集转化:从"正面硬刚"到"曲线救国"

拿到这道题,很多选手的第一直觉可能是:枚举所有子集,检查每个子集是否满足多边形条件。但当

n

n

n 很大时,这是不可能的。

换个角度思考:所有可能的子集有多少个?

2

n

2^n

2n 个(包括空集)。如果我们能快速算出不满足多边形条件的子集数量,那么答案就是:

答案

=

2

n

不满足条件的子集数

1

\\text{答案} = 2^n – \\text{不满足条件的子集数} – 1

答案=2n不满足条件的子集数1

其中

1

-1

1 是去掉空集(空集显然不能拼成多边形)。

这个转化的美妙之处在于:不满足条件的子集有一个统一的特征——设子集中最长木棍为

L

L

L,其余木棍之和为

S

r

e

s

t

S_{rest}

Srest,则不满足条件意味着

S

r

e

s

t

L

S_{rest} \\leq L

SrestL。这个特征比"满足条件"更容易统计!

我们可以把这个问题想象成筛沙子:

  • 所有子集是一堆沙子(

    2

    n

    2^n

    2n 粒)

  • 满足条件的金子混在沙子里
  • 直接挑金子很难
  • 但我们可以筛掉沙子(不满足条件的子集),剩下的就是金子

2.2 排序 + 枚举最大值:锁定"罪魁祸首"

不满足条件的子集有一个关键特征:它们都有一个"罪魁祸首"——最长的那根木棍。如果我们将木棍按长度升序排序,那么对于排序后的第

i

i

i 根木棍

a

i

a_i

ai,以它为最长木棍的子集中,其他木棍只能从

a

1

,

a

2

,

,

a

i

1

a_1, a_2, \\ldots, a_{i-1}

a1,a2,,ai1 中选取。

为什么?因为

a

i

a_i

ai 是前

i

i

i 根中最长的(排序后),任何包含

a

j

a_j

aj

j

>

i

j > i

j>i)的子集,其最大值都会是

a

j

a_j

aj 而非

a

i

a_i

ai

这样一来,问题转化为:对于每个

i

i

i,统计从

{

a

1

,

,

a

i

1

}

\\{a_1, \\ldots, a_{i-1}\\}

{a1,,ai1} 中选出若干个数,使其和

a

i

\\leq a_i

ai 的方案数。

排序后的特征:

  • 单调性:

    a

    1

    a

    2

    a

    n

    a_1 \\leq a_2 \\leq \\dots \\leq a_n

    a1a2an

  • 锁定最大值:以

    a

    i

    a_i

    ai 为最大值的子集,其他元素只能从前

    i

    1

    i-1

    i1 个中选

  • 和约束:其余元素之和

    a

    i

    \\leq a_i

    ai 即不满足多边形条件

2.3 01 背包 DP:统计方案数

现在问题变成了:给定若干个数,选出若干个数使其和

S

\\leq S

S 的方案数。这正是 01 背包的计数版本。

定义

d

p

[

i

]

[

j

]

dp[i][j]

dp[i][j] 为前

i

i

i 个数中选出若干个数使其和恰好为

j

j

j 的方案数。状态转移:

d

p

[

i

]

[

j

]

=

d

p

[

i

1

]

[

j

]

+

d

p

[

i

1

]

[

j

a

i

]

dp[i][j] = dp[i-1][j] + dp[i-1][j-a_i]

dp[i][j]=dp[i1][j]+dp[i1][jai]

其中:

  • d

    p

    [

    i

    1

    ]

    [

    j

    ]

    dp[i-1][j]

    dp[i1][j]:不选第

    i

    i

    i 个数

  • d

    p

    [

    i

    1

    ]

    [

    j

    a

    i

    ]

    dp[i-1][j-a_i]

    dp[i1][jai]:选第

    i

    i

    i 个数(要求

    j

    a

    i

    j \\geq a_i

    jai

初始化

d

p

[

0

]

[

0

]

=

1

dp[0][0] = 1

dp[0][0]=1(空集的和为

0

0

0,有

1

1

1 种方案)。

对于每个

i

i

i,以

a

i

a_i

ai 为最大值的非法子集数为:

j

=

0

a

i

d

p

[

i

1

]

[

j

]

\\sum_{j=0}^{a_i} dp[i-1][j]

j=0aidp[i1][j]

这表示前

i

1

i-1

i1 个数中选出和为

0

0

0

a

i

a_i

ai 的所有方案,每种方案加上

a

i

a_i

ai 后,总和

2

a

i

\\leq 2a_i

2ai,不满足多边形条件。

我们可以把 01 背包想象成天平称重:

  • 你有若干砝码(木棍),每个只能用一次
  • 你想知道能称出哪些重量(和)
  • d

    p

    [

    i

    ]

    [

    j

    ]

    dp[i][j]

    dp[i][j] 记录称出重量

    j

    j

    j 的方案数

  • 对于每个

    a

    i

    a_i

    ai,我们问:用比它轻的砝码,能称出不超过

    a

    i

    a_i

    ai 的重量有多少种方案?


三、算法模板

3.1 算法到底在干什么?——直觉解释

我们的算法是一台"非法子集计数器":

  • 计算总数:

    2

    n

    2^n

    2n 是所有子集的数量(包括空集)

  • 排序锁定:将木棍升序排列,确定每个木棍作为"最大值"时的候选集
  • 背包计数:用 01 背包 DP 统计每个候选集中和

    \\leq

    最大值的方案数

  • 累加非法:把所有非法方案累加起来
  • 补集得答案:

    2

    n

    非法方案

    1

    2^n – \\text{非法方案} – 1

    2n非法方案1(去掉空集)

  • 整个过程就像海关查验:

    • 所有货物(子集)先过一遍
    • 挑出违禁品(非法子集)
    • 剩下的就是合法货物(答案)

    3.2 万能模板 —— 伪代码 + 实战代码

    伪代码:

    function 多边形方案数(木棍 a[1..n]):
    mod = 998244353
    ans = 2^n mod mod // 所有子集数
    sort(a, 升序)

    dp[0][0] = 1 // 空集
    bad_ans = 0

    for i = 1 to n:
    for j = 0 to MAX_SUM:
    dp[i][j] = dp[i-1][j]
    if j >= a[i]:
    dp[i][j] = (dp[i][j] + dp[i-1][j-a[i]]) mod mod

    for j = 0 to a[i]:
    bad_ans = (bad_ans + dp[i-1][j]) mod mod

    return (ans – bad_ans – 1 + mod) mod mod

    实战代码(一维优化通用模板):

    #include <bits/stdc++.h>
    using namespace std;

    #define int long long
    const int N = 5005;
    const int mod = 998244353;

    int n;
    int a[N];
    int dp[N];
    int bad_ans;
    int ans = 1;

    signed main()
    {
    cin >> n;
    for (int i = 1; i <= n; i++)
    {
    cin >> a[i];
    ans = ans * 2 % mod;
    }

    sort(a + 1, a + n + 1);

    dp[0] = 1;

    for (int i = 1; i <= n; i++)
    {
    // 先统计以 a[i] 为最大值的非法子集
    for (int j = 0; j <= a[i]; j++)
    bad_ans = (bad_ans + dp[j]) % mod;

    // 再更新 dp(逆序,01 背包)
    for (int j = 5000; j >= a[i]; j)
    dp[j] = (dp[j] + dp[j a[i]]) % mod;
    }

    cout << (ans bad_ans 1 + mod) % mod << endl;
    return 0;
    }

    3.3 例题实现 —— 本题完整代码

    #include <bits/stdc++.h>
    using namespace std;

    #define int long long // 使用长整型防止溢出
    const int N = 5005; // 定义最大数组长度
    const int mod = 998244353; // 模数

    int n; // 数组元素个数
    int a[N]; // 存储输入的数字数组
    int dp[N][N]; // 动态规划数组,dp[i][j]表示前i个元素和为j的子集数
    int bad_ans; // 统计不满足条件的子集数量
    int ans = 1; // 总子集数(2^n),初始化为1

    signed main()
    {
    // 输入数组长度
    cin >> n;

    // 输入数组元素并计算总子集数(2^n)
    for (int i = 1; i <= n; i++)
    {
    cin >> a[i];
    ans *= 2; // 计算2的n次方
    ans %= mod; // 取模防止溢出
    }

    // 对数组进行升序排序,保证从小到大排序
    sort(a + 1, a + n + 1);

    // 初始化动态规划数组:空集的和为0,有1种方式
    dp[0][0] = 1;

    // 动态规划:计算子集和分布
    for (int i = 1; i <= n; i++)
    {
    for (int j = 0; j <= 5000; j++) // 遍历所有可能的和(0到5000)
    {
    // dp[i][j]表示选到前i个数,所选数字的和为j的方案数
    // 状态转移:不选a[i]
    dp[i][j] = dp[i 1][j];

    // 选择当前元素a[i](需要j>=a[i])
    if (j >= a[i])
    {
    dp[i][j] = (dp[i][j] + dp[i 1][j a[i]]) % mod;
    }

    // 统计不满足条件的子集:和j <= 当前元素a[i]
    // 对于第i个数,它作为最大值的方案数是前i-1个数中
    // 选出来的总和 <= a[i] 的方案数
    if (j <= a[i])
    {
    bad_ans = (bad_ans + dp[i 1][j]) % mod;
    }
    }
    }

    // 计算最终答案:总子集数 – 不满足条件的子集数 – 空集
    // 公式:ans = 2^n – bad_ans – 1
    cout << (ans bad_ans 1 + mod) % mod << endl;

    return 0;
    }

    3.4 对比实现 —— 暴力枚举 vs 补集 DP

    本题从 40 分到 100 分,经历了三个版本的优化:

    方案核心思想时间复杂度适用场景
    DFS 暴力枚举(40 分) 枚举所有

    2

    n

    2^n

    2n 个子集

    O

    (

    2

    n

    n

    )

    O(2^n \\cdot n)

    O(2nn)

    n

    20

    n \\leq 20

    n20

    组合数近似(64 分) 假设大部分子集都合法

    O

    (

    n

    2

    )

    O(n^2)

    O(n2)

    骗分策略
    补集 + 01 背包 DP(100 分) 统计非法子集数

    O

    (

    n

    S

    )

    O(n \\cdot S)

    O(nS)

    n

    5000

    ,

    S

    5000

    n \\leq 5000, S \\leq 5000

    n5000,S5000

    对于本题,

    n

    5000

    n \\leq 5000

    n5000

    a

    i

    5000

    a_i \\leq 5000

    ai5000,所以

    S

    =

    5000

    S = 5000

    S=5000 是可行的上界。01 背包的

    O

    (

    n

    S

    )

    =

    O

    (

    2.5

    ×

    10

    7

    )

    O(n \\cdot S) = O(2.5 \\times 10^7)

    O(nS)=O(2.5×107) 完全在可接受范围内。

    值得注意的是,本题的一维优化版本可以将空间复杂度从

    O

    (

    n

    S

    )

    O(n \\cdot S)

    O(nS) 降到

    O

    (

    S

    )

    O(S)

    O(S),但二维版本更直观,适合理解。

    3.5 变体清单 —— 常见变形

    变体类型题目描述关键变化解法调整
    三角形条件 任意三边能构成三角形 条件变化 更复杂的约束,可能需要其他方法
    恰好选

    k

    k

    k

    必须选恰好

    k

    k

    k 根木棍

    增加数量约束 在 DP 中增加一维记录选取个数
    木棍有颜色 同颜色木棍不能同时选 增加互斥约束 分组背包或容斥
    多边形边数限制 必须恰好

    m

    m

    m 边形

    增加边数约束 在 DP 中增加选取个数维度
    木棍长度极大

    a

    i

    a_i

    ai 达到

    10

    9

    10^9

    109

    数据范围变化 需要离散化或其他优化
    求具体方案 输出所有合法子集 目标变化 需要记录决策路径,回溯输出
    在线查询 多次添加/删除木棍 动态变化 需要支持动态修改的 DP

    3.6 什么时候不能用?——边界条件和反例

    补集转化虽然强大,但也有需要注意的边界:

    • n

      <

      3

      n < 3

      n<3 时:无法选出

      3

      3

      3 根木棍,答案为

      0

      0

      0。但补集公式

      2

      n

      b

      a

      d

      _

      a

      n

      s

      1

      2^n – bad\\_ans – 1

      2nbad_ans1 在这种情况下也会正确输出

      0

      0

      0(因为

      b

      a

      d

      _

      a

      n

      s

      bad\\_ans

      bad_ans 会包含所有非空子集)。

    • 所有木棍相同:如

      a

      =

      [

      1

      ,

      1

      ,

      1

      ,

      1

      ]

      a = [1, 1, 1, 1]

      a=[1,1,1,1],任意

      3

      3

      3 根的和为

      3

      >

      2

      3 > 2

      3>2,都满足条件。补集 DP 会正确统计。

    • 一根木棍极大:如

      a

      =

      [

      1

      ,

      1

      ,

      1

      ,

      100

      ]

      a = [1, 1, 1, 100]

      a=[1,1,1,100],选

      100

      100

      100 和任意两根

      1

      1

      1:和

      =

      102

      = 102

      =102,最大值

      =

      100

      = 100

      =100

      102

      >

      200

      102 > 200

      102>200?不成立。所以包含

      100

      100

      100 的子集都不合法(除非只选

      100

      100

      100 和一根

      1

      1

      1,但元素个数

      <

      3

      < 3

      <3)。

    • 模数处理:减法取模时需要注意负数,(ans – bad_ans – 1 + mod) % mod 确保结果非负。
    • 空集处理:

      b

      a

      d

      _

      a

      n

      s

      bad\\_ans

      bad_ans 不包含空集(因为空集没有最大值),所以最后统一减去

      1

      1

      1(空集)。


    四、底层逻辑

    4.1 为什么补集转化是正确的?

    这是基于集合划分的基本原理。

    U

    U

    U 为所有子集的集合(包括空集),

    U

    =

    2

    n

    |U| = 2^n

    U=2n。将

    U

    U

    U 划分为三个互不相交的子集:

    • A

      A

      A:满足多边形条件的子集(

      S

      3

      |S| \\geq 3

      S3

      >

      2

      max

      \\sum > 2 \\max

      >2max

    • B

      B

      B:不满足多边形条件的非空子集(

      S

      <

      3

      |S| < 3

      S<3

      2

      max

      \\sum \\leq 2 \\max

      2max

    • C

      C

      C:空集

    U

    =

    A

    B

    C

    U = A \\cup B \\cup C

    U=ABC,且

    A

    ,

    B

    ,

    C

    A, B, C

    A,B,C 两两不交。因此:

    A

    =

    U

    B

    C

    =

    2

    n

    B

    1

    |A| = |U| – |B| – |C| = 2^n – |B| – 1

    A=UBC=2nB1

    我们的 DP 统计的就是

    B

    |B|

    B(所有不满足条件的非空子集)。

    为什么

    B

    B

    B 可以这样统计?对于每个非空子集

    S

    B

    S \\in B

    SB,设其最大元素为

    a

    i

    a_i

    ai(排序后)。则

    S

    {

    a

    i

    }

    S \\setminus \\{a_i\\}

    S{ai} 是从

    {

    a

    1

    ,

    ,

    a

    i

    1

    }

    \\{a_1, \\ldots, a_{i-1}\\}

    {a1,,ai1} 中选出的一个子集,且其和

    a

    i

    \\leq a_i

    ai。反之,任何从

    {

    a

    1

    ,

    ,

    a

    i

    1

    }

    \\{a_1, \\ldots, a_{i-1}\\}

    {a1,,ai1} 中选出的和

    a

    i

    \\leq a_i

    ai 的子集,加上

    a

    i

    a_i

    ai 后都构成一个以

    a

    i

    a_i

    ai 为最大值的非法子集。

    因此,

    B

    =

    i

    =

    1

    n

    j

    =

    0

    a

    i

    d

    p

    [

    i

    1

    ]

    [

    j

    ]

    |B| = \\sum_{i=1}^n \\sum_{j=0}^{a_i} dp[i-1][j]

    B=i=1nj=0aidp[i1][j],这正是我们的 DP 所计算的。

    4.2 与经典问题的对比

    这道题和经典的组合计数问题家族有密切联系:

    问题核心思想统计对象解法
    本题:多边形子集计数 补集 + 01 背包 非法子集(和

    \\leq

    最大值)

    O

    (

    n

    S

    )

    O(nS)

    O(nS) DP

    子集和等于

    k

    k

    k

    01 背包计数 和恰好为

    k

    k

    k 的子集

    O

    (

    n

    S

    )

    O(nS)

    O(nS) DP

    子集和

    k

    \\leq k

    k

    01 背包前缀和 和不超过

    k

    k

    k 的子集

    O

    (

    n

    S

    )

    O(nS)

    O(nS) DP + 前缀和

    划分数 完全背包计数 和为

    n

    n

    n 的划分方案

    O

    (

    n

    2

    )

    O(n^2)

    O(n2) DP

    子集异或和为

    k

    k

    k

    按位 DP 异或和为

    k

    k

    k 的子集

    O

    (

    n

    2

    b

    i

    t

    )

    O(n \\cdot 2^{bit})

    O(n2bit)

    可以看到,01 背包的计数版本是组合计数中最基础、最通用的工具之一。它的核心思想是:用 DP 数组记录"达到某个状态"的方案数,而非仅仅记录"是否可达"。

    4.3 隐含约束的分析

    题目中有几个容易被忽略但至关重要的细节:

    • a

      i

      5000

      a_i \\leq 5000

      ai5000:这个约束决定了 DP 的容量上界。如果

      a

      i

      a_i

      ai 很大(如

      10

      9

      10^9

      109),需要离散化或其他优化。

    • 严格大于:条件是

      >

      2

      max

      \\sum > 2 \\max

      >2max,而非

      2

      max

      \\sum \\geq 2 \\max

      2max。这意味着和恰好等于

      2

      max

      2 \\max

      2max 的子集也是非法的,我们的

      j

      a

      i

      j \\leq a_i

      jai 条件正确处理了这一点。

    • 至少

      3

      3

      3 根:

      m

      3

      m \\geq 3

      m3 的条件。在补集统计中,

      b

      a

      d

      _

      a

      n

      s

      bad\\_ans

      bad_ans 自动包含了单元素和双元素子集(因为

      d

      p

      [

      i

      1

      ]

      [

      0

      ]

      =

      1

      dp[i-1][0] = 1

      dp[i1][0]=1 对应只选

      a

      i

      a_i

      ai 的情况,而

      d

      p

      [

      i

      1

      ]

      [

      a

      j

      ]

      dp[i-1][a_j]

      dp[i1][aj]

      j

      <

      i

      j < i

      j<i)对应选

      a

      i

      a_i

      ai

      a

      j

      a_j

      aj 的情况)。

    • 下标集合不同即不同方案:这意味着即使长度相同但下标不同,也是不同方案。01 背包天然处理这一点(每个元素是独立的)。

    五、决策表

    面对"子集选择 + 约束判定 + 计数"类问题,如何快速选型?

    场景特征推荐方案时间复杂度备注
    约束可转化为"和

    \\leq

    某值"

    补集 + 01 背包计数

    O

    (

    n

    S

    )

    O(nS)

    O(nS)

    本题场景
    约束为"和恰好为

    k

    k

    k"

    01 背包计数

    O

    (

    n

    S

    )

    O(nS)

    O(nS)

    经典背包
    约束为"和

    k

    \\geq k

    k"

    补集:和

    <

    k

    < k

    <k

    O

    (

    n

    S

    )

    O(nS)

    O(nS)

    转化为

    \\leq

    元素可重复选取 完全背包计数

    O

    (

    n

    S

    )

    O(nS)

    O(nS)

    正序遍历
    需要选恰好

    k

    k

    k 个元素

    01 背包 + 数量维度

    O

    (

    n

    2

    S

    )

    O(n^2S)

    O(n2S)

    二维 DP

    n

    n

    n 很小(

    20

    \\leq 20

    20

    暴力枚举 / 折半搜索

    O

    (

    2

    n

    )

    O(2^n)

    O(2n)

    O

    (

    2

    n

    /

    2

    )

    O(2^{n/2})

    O(2n/2)

    直接枚举
    需要输出具体方案 记录决策路径

    O

    (

    n

    S

    )

    O(nS)

    O(nS)

    增加路径数组

    一句话总结:子集计数想背包,约束复杂想补集,数量限制加维度,数据小则直接枚举。


    六、工程视角

    补集转化和 01 背包计数的思想在实际工程中有着广泛的应用:

  • 资源分配与预算约束:在项目组合管理中,需要从若干项目中选出子集,使得总投资不超过预算且满足某些约束。01 背包计数可以统计所有满足预算约束的方案数,帮助决策者评估选择空间。

  • 密码学中的子集和问题:子集和问题是 NP-complete 的,但在小数据范围内,01 背包 DP 是高效的解法。在密码分析中,统计满足特定和约束的子集数量有助于评估密码系统的安全性。

  • 电路设计中的元件选择:在电子工程中,需要从若干规格的元件中选择组合,使得总功耗、总成本等满足约束。01 背包计数可以枚举所有可行方案,供工程师选择最优解。

  • 金融投资组合优化:在投资管理中,需要从若干资产中选择子集,使得风险调整后收益最大化。补集思想可以用于排除高风险组合,缩小搜索空间。


  • 七、小结

    本文从一道 CSP-J 真题出发,探讨了补集转化与01 背包计数问题。

    核心认知可以总结为:

    当"满足条件"难以直接统计时,尝试统计"不满足条件"的方案数往往更简单;01 背包的计数版本是处理子集约束问题的通用武器,而排序后的单调性则是锁定问题结构的关键。

    用公式化的语言概括:

    答案

    =

    (

    2

    n

    i

    =

    1

    n

    j

    =

    0

    a

    i

    d

    p

    [

    i

    1

    ]

    [

    j

    ]

    1

    )

    m

    o

    d

    998244353

    \\text{答案} = \\left(2^n – \\sum_{i=1}^n \\sum_{j=0}^{a_i} dp[i-1][j] – 1\\right) \\bmod 998244353

    答案=(2ni=1nj=0aidp[i1][j]1)mod998244353

    其中

    d

    p

    [

    i

    ]

    [

    j

    ]

    dp[i][j]

    dp[i][j] 为前

    i

    i

    i 个数中和为

    j

    j

    j 的子集数,

    d

    p

    [

    i

    ]

    [

    j

    ]

    =

    d

    p

    [

    i

    1

    ]

    [

    j

    ]

    +

    d

    p

    [

    i

    1

    ]

    [

    j

    a

    i

    ]

    dp[i][j] = dp[i-1][j] + dp[i-1][j-a_i]

    dp[i][j]=dp[i1][j]+dp[i1][jai]

    这道题教会我们的,不仅是如何写 01 背包和取模运算,更是一种**“正难则反”**的数学思维:在算法竞赛中,很多计数问题的正面攻击会遇到组合爆炸,但换个角度——统计反面——往往能找到多项式时间的解法。补集转化、容斥原理、对偶问题,都是这种"逆向思维"的具体体现。掌握这种思维方式,比记住任何具体算法都更重要。


    如果这篇文章对你有帮助,欢迎点赞收藏!有任何问题欢迎在评论区留言交流。

    赞(0)
    未经允许不得转载:171主机测评 » 从一道 CSP-J 真题出发:聊聊补集转化与 01 背包计数
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址