GESP2026年6月认证C++八级( 第三部分编程题(2、堆石子))精讲



这道题,很多同学第一次看到这道题都会想到:

  • DFS?

  • 动态规划?

  • 递归搜索?

实际上,这些方向都会越想越复杂。

这道题真正考察的是:

组合数学(隔板法 / 组合数)+ 数学转化。


第三部分 第二题《堆石子》

------石子王国里的神奇变身术


第一幕:石子王国

1、石子王国举行了一场比赛。

国王拿来了很多石子。

他说:

"今天,你们要把石子堆成很多堆。"


2、但是有要求:

① 第1堆固定有 n 个石子。


② 后面的每一堆都必须比前一堆少。


例如:

复制代码
7
5
3
1

可以。


因为:

复制代码
7>5>3>1

但是:

复制代码
7
6
6
2

不行。


因为:

复制代码
6=6

不是严格递减。


3、题目问:

一共有多少种合法方案?


第二幕:先不要急着写程序

1、有的同学第一反应:

"枚举所有方案!"


2、例如:

复制代码
6

↓

5

↓

4

↓

...

不停DFS。

但是你会发现:

数据范围非常大。

DFS根本不可能。


3、找一找:

看看有没有数学规律。


第三幕:观察样例

1、例如:

第一堆固定:

复制代码
5

要求:

一共三堆。


2、那么:

后面还能怎么放?

例如:

复制代码
5

4

3

可以。


还可以:

复制代码
5

4

2

也可以。


还可以:

复制代码
5

3

2

......

规律是啥呢?


第四幕:换一种思考方式

1、这道题神奇的一步。

(1)第一堆已经固定:

复制代码
a1 = n

例如:

复制代码
第一堆 = 7

那后面的石子数还能是多少?


(2)只能是:

复制代码
1
2
3
4
5
6

是不是?


(3)也就是说:

后面其实就是从

复制代码
1 ~ n-1

里面挑数字。


2、还需要考虑数字顺序吗?

(1)整道题最关键的一句话。

例如我们选了:

复制代码
2
5
6

题目要求:

复制代码
严格递减

(2)所以它最后一定会变成:

复制代码
7
6
5
2

(3)注意!

我们根本没有选择顺序。

因为:

一旦数字选出来,顺序就唯一确定了。

这一点非常重要。


(4)因此:

不用排列,只需要组合。


第五幕:方案数是C(n-1,m-1)

1、现在问题已经变得非常简单了。

(1)第一堆固定:

复制代码
n

(2)后面还有:

复制代码
m-1

堆。


(3)那么:

只需要从:

复制代码
1 ~ n-1

里面选择:

复制代码
m-1

个不同数字。


(4)因此:

方案数就是:


第六幕:参考程序

cpp 复制代码
#include <iostream>
using namespace std;

// 模数(题目要求结果对 1000000007 取模)
const int MOD = (int)1e9 + 7;

///////////////////////////////////////////////////////
// 快速幂
//
// 功能:计算
//
//      base^exp % MOD
//
// 时间复杂度:O(log exp)
//
// 这里不仅计算乘方,后面还会用它求模逆元。
///////////////////////////////////////////////////////
int qpow(int base, int exp)
{
    // 指数等于0
    // 根据数学规定:
    //
    // a^0 = 1
    if (!exp)
        return 1;

    // 如果指数是奇数
    //
    // 例如:
    //
    // 3^13
    //
    // =3×(3²)^6
    //
    // 需要多乘一个base
    if (exp & 1)
    {
        return (long long)base *
               qpow((long long)base * base % MOD, exp >> 1)
               % MOD;
    }

    // 如果指数是偶数
    //
    // 例如:
    //
    // 3^12
    //
    // =(3²)^6
    //
    // 不需要额外乘base
    return qpow((long long)base * base % MOD,
                exp >> 1);
}

///////////////////////////////////////////////////////
// 计算组合数
//
// 返回:C(n,m)
//
// 使用乘法公式
//
//          n(n-1)...(n-m+1)
// C(n,m)=--------------------
//                m!
//
// 由于有MOD
//
// 不能直接除法
//
// 所以使用模逆元。
///////////////////////////////////////////////////////
int comb(int n, int m)
{
    // 如果选择数量比总数还多
    // 显然没有方案
    if (m > n)
        return 0;

    // 保存答案
    int ans = 1;

    ////////////////////////////////////////////////////
    // 逐步计算组合数
    ////////////////////////////////////////////////////
    for (int i = 0; i < m; ++i)
    {
        //////////////////////////////////////////////////
        // 乘分子
        //
        // 第一次:
        // n
        //
        // 第二次:
        // n-1
        //
        // 第三次:
        // n-2
        //////////////////////////////////////////////////
        ans = (long long)ans * (n - i) % MOD;

        //////////////////////////////////////////////////
        // 再除以
        //
        // i+1
        //
        // 但是:
        //
        // 模运算不能直接除法
        //
        // 所以:
        //
        // ×逆元
        //
        // 根据费马小定理:
        //
        // x^(MOD-2)
        //
        // 就是:
        //
        // x 的逆元
        //////////////////////////////////////////////////
        ans = (long long)ans *
              qpow(i + 1, MOD - 2)
              % MOD;
    }

    return ans;
}

///////////////////////////////////////////////////////
// 主函数
///////////////////////////////////////////////////////
int main()
{
    // m:一共多少堆
    // n:第一堆有多少石子
    int m, n;

    cin >> m >> n;

    ////////////////////////////////////////////////////
    // 第一堆固定是 n
    //
    // 后面还需要 m-1 堆
    //
    // 每一堆必须严格递减
    //
    // 等价于:
    //
    // 从
    //
    // 1~n-1
    //
    // 中选择
    //
    // m-1
    //
    // 个不同数字
    //
    // 顺序自动确定
    //
    // 所以答案就是:
    //
    // C(n-1,m-1)
    ////////////////////////////////////////////////////
    cout << comb(n - 1, m - 1) << endl;

    return 0;
}

程序里面用到了竞赛的"压缩写法",例如:

  • 快速幂递归写法

  • 费马小定理

  • 模逆元

  • 边循环乘法计算组合数。


第七幕:快速幂到底在干什么?


1、暴力乘法

假设老师问大家:

请计算:

复制代码
2¹⁰

最普通的方法就是:

复制代码
int ans = 1;

for(int i = 1; i <= 10; i++)
    ans *= 2;

乘了:

复制代码
10 次

没有问题。


2、如果老师问:

复制代码
2¹⁰⁰

就要乘:

复制代码
100 次

还是可以。


3、如果老师问:

复制代码
2¹⁰⁰⁰⁰⁰⁰⁰⁰⁰

怎么办?

是不是要乘:

复制代码
100000000 次

显然不可能。

所以:

一定有更快的方法。


4、观察规律

我们把指数写出来:

复制代码
2¹

2²

2⁴

2⁸

2¹⁶

其实就是:

不断平方。

所以:

复制代码
2²

=(2¹)²

2⁴

=(2²)²

2⁸

=(2⁴)²

5、快速幂最核心的一句话:

指数减半,底数平方。

请同学们一定把这句话记住。


6、那指数是奇数怎么办?

复制代码
奇数:

先乘一个base

剩下指数减半继续算。

7、看代码:

cpp 复制代码
///////////////////////////////////////////////////////
// 快速幂
//
// 功能:计算
//
//      base^exp % MOD
//
// 时间复杂度:O(log exp)
//
// 这里不仅计算乘方,后面还会用它求模逆元。
///////////////////////////////////////////////////////
int qpow(int base, int exp)
{
    // 指数等于0
    // 根据数学规定:
    //
    // a^0 = 1
    if (!exp)
        return 1;

    // 如果指数是奇数
    //
    // 例如:
    //
    // 3^13
    //
    // =3×(3²)^6
    //
    // 需要多乘一个base
    if (exp & 1)
    {
        return (long long)base *
               qpow((long long)base * base % MOD, exp >> 1)
               % MOD;
    }

    // 如果指数是偶数
    //
    // 例如:
    //
    // 3^12
    //
    // =(3²)^6
    //
    // 不需要额外乘base
    return qpow((long long)base * base % MOD,
                exp >> 1);
}

时间复杂度从 O(n) 变成了 O(log n)。


第八幕:逆元到底在干什么?

1、我们有新同学,会有这样的疑问!

逆元是什么?费马小定理是什么?为什么是 MOD-2?


2、先回答一个最简单的问题:

为什么需要逆元?

然后再讲:

逆元是什么?

最后我再讲:

为什么用 MOD-2

这样学生们就能真正理解。


一、首先: 为什么需要逆元?


1、假设我们要求:


2、数学公式:


3、程序如果没有取模。

直接写:

复制代码
ans*=5;
ans*=4;
ans/=2;
ans/=1;

是不是一点问题都没有?

答案:

没有。


4、我们的比赛里面。

题目会写:

复制代码
答案对1000000007取模

于是:

我们每一步都会写:

复制代码
ans%=MOD;

但是:

模运算里面不能直接做除法。

这一条必须记住。


二、 那怎么办?

数学家想到:

1、除法转乘法

例如:

复制代码
20÷5

其实就是:

复制代码
20×(1/5)

是不是?


2、所以:

如果我们能找到:

复制代码
1/5

就不用除法了。

全部变乘法。


3、于是:

数学家给它起了名字:

逆元(Inverse)

就是说:

逆元就是模意义下的"倒数"。

这一句话非常重要。


三、 什么叫倒数?

1、我们小学生已经学过。

例如:

复制代码
2

倒数:

复制代码
1/2

为什么?

因为:

复制代码
2×1/2=1

例如:

复制代码
5

倒数:

复制代码
1/5

因为:

复制代码
5×1/5=1

是不是?


2、所以,逆元其实就是:

模运算里的倒数。


四、 可是在模里面没有小数呀!

1、终于问到重点了。

例如:

模7。

我们找:

复制代码
2

的逆元。


2、意思就是:

找一个数:

复制代码
x

满足:

cpp 复制代码
2 * x

≡1(mod7)

试试看。

复制代码
2×1=2

不是。

复制代码
2×2=4

不是。

复制代码
2×3=6

不是。

复制代码
2×4=8

模7:

复制代码
1

成功!


4、所以:

cpp 复制代码
2的逆元 = 4

因为:

复制代码
2×4

≡1(mod7)

5、再试3

复制代码
3×5=15

模7:

复制代码
1

所以:

cpp 复制代码
3的逆元  = 5

是不是很神奇?


五、 费马小定理

1、如果MOD:

复制代码
1000000007

一个一个试。

要试:

十亿次。

当然不行。


2、数学家证明了:

(1)如果MOD是质数。

那么:

根据:

费马小定理

有:


(2)两边同时乘:

得到:


(3)也就是说:

a 的逆元 = a^(MOD-2)


(4)所以:

程序:

复制代码
qpow(a,MOD-2)

其实就是:

求:

复制代码
1/a

是不是同学们一下就清楚了?


六、 回到comb()

1、我们已经能看懂这一句了。

复制代码
ans*=qpow(i+1,MOD-2);

2、例如:

第一次:

复制代码
÷1

变成:

复制代码
×1

第二次:

复制代码
÷2

变成:

复制代码
×2的逆元

第三次:

复制代码
÷3

变成:

复制代码
×3的逆元

3、于是:

整个组合数:

程序实际上写成了:

复制代码
×

1的逆元

×

2的逆元

×

3的逆元

...

完全没有除法。


4、所以:

再模运算下,使用逆元来算组合数。


七、循环怎么写?

1、例如:

求:


数学:


2、程序:

第一次:

复制代码
×6

÷1

第二次:

复制代码
×5

÷2

第三次:

复制代码
×4

÷3

是不是:

正好得到:

复制代码
6×5×4

---------

1×2×3

所以:

整个循环就是:

一边乘分子,一边乘分母的逆元。


八、再看下代码:

cpp 复制代码
///////////////////////////////////////////////////////
// 计算组合数
//
// 返回:C(n,m)
//
// 使用乘法公式
//
//          n(n-1)...(n-m+1)
// C(n,m)=--------------------
//                m!
//
// 由于有MOD
//
// 不能直接除法
//
// 所以使用模逆元。
///////////////////////////////////////////////////////
int comb(int n, int m)
{
    // 如果选择数量比总数还多
    // 显然没有方案
    if (m > n)
        return 0;

    // 保存答案
    int ans = 1;

    ////////////////////////////////////////////////////
    // 逐步计算组合数
    ////////////////////////////////////////////////////
    for (int i = 0; i < m; ++i)
    {
        //////////////////////////////////////////////////
        // 乘分子
        //
        // 第一次:
        // n
        //
        // 第二次:
        // n-1
        //
        // 第三次:
        // n-2
        //////////////////////////////////////////////////
        ans = (long long)ans * (n - i) % MOD;

        //////////////////////////////////////////////////
        // 再除以
        //
        // i+1
        //
        // 但是:
        //
        // 模运算不能直接除法
        //
        // 所以:
        //
        // ×逆元
        //
        // 根据费马小定理:
        //
        // x^(MOD-2)
        //
        // 就是:
        //
        // x 的逆元
        //////////////////////////////////////////////////
        ans = (long long)ans *
              qpow(i + 1, MOD - 2)
              % MOD;
    }

    return ans;
}

送给新同学一句最好记的话

⭐⭐⭐⭐⭐ 不要把逆元想得太神秘。

只要记住:

普通数学里:

除以 5

=

乘 ( 1/5)

而在模运算里,没有真正的小数,所以:

除以 5

=

乘 5 的逆元

至于 5 的逆元怎么求

如果模数是质数(像本题的 1000000007),可以直接使用快速幂:

复制代码
qpow(5, MOD - 2)

即可。


第九幕:整道程序其实最后只有三步

1、我们画流程图。

就是:

复制代码
输入

↓

第一堆固定

↓

后面需要选 m-1 个数字

↓

答案=C(n-1,m-1)

↓

计算组合数

↓

输出

2、你现在会发现:

真正难的不是程序,而是知道答案为什么是 C(n-1,m-1)

程序真正干的事情只有两件:

  1. 计算组合数 C(n-1,m-1)

  2. 由于结果需要对 10^9+7 取模,因此使用快速幂求模逆元,避免直接做除法。


第十幕:这道题考察了哪些知识?

知识点 是否重点 本题作用
数学建模 ⭐⭐⭐⭐⭐ 将石子问题转化组合问题
正整数拆分 ⭐⭐⭐⭐⭐ 理解为什么使用组合数
组合数计算 ⭐⭐⭐⭐⭐ 求 (C(n-1,m-1))
快速幂 ⭐⭐⭐⭐ 求模逆元
费马小定理 ⭐⭐⭐⭐ 模意义下实现除法

总结:

这道题是一道综合题,写代码前,需要同学们先要,完成数学转化


相关推荐
信竞星球_少儿编程题库1 小时前
2026年全国青少年信息素养大赛算法应用主题赛(星火征途C++小学组复赛真题)
c++
盐焗鹌鹑蛋2 小时前
【C++】AVL树
c++
杜子不疼.3 小时前
【C++】继承—C++的秘密武器,get父类的智慧
开发语言·c++
ShineWinsu3 小时前
对于Linux:基于UDP实现简单聊天室功能
linux·c++·面试·udp·笔试·进程·简单聊天室
chase_my_dream3 小时前
2D-SLAM 真实数据处理与多传感器工程落地:时间同步、异常过滤、标定对齐和系统调试
c++·人工智能·2d-slam
c238564 小时前
《序列 DP:C++ 中的“最长”套路与编辑距离》
c++·算法·动态规划
鱼子星_4 小时前
【C++】类和对象(下)——初始化列表,类型转换,static成员,友元,内部类,匿名对象,编译器的优化拓展
c语言·c++·笔记
王老师青少年编程12 小时前
2025年【江苏“信息与未来”编程思维】真题及题解(T2:坐标变换)
c++·题解·真题·坐标变换·编程思维·江苏·信息与未来
2023自学中15 小时前
C++ 内存追踪器
linux·c++