




这道题,很多同学第一次看到这道题都会想到:
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)。
程序真正干的事情只有两件:
-
计算组合数
C(n-1,m-1); -
由于结果需要对
10^9+7取模,因此使用快速幂求模逆元,避免直接做除法。
第十幕:这道题考察了哪些知识?
| 知识点 | 是否重点 | 本题作用 |
|---|---|---|
| 数学建模 | ⭐⭐⭐⭐⭐ | 将石子问题转化组合问题 |
| 正整数拆分 | ⭐⭐⭐⭐⭐ | 理解为什么使用组合数 |
| 组合数计算 | ⭐⭐⭐⭐⭐ | 求 (C(n-1,m-1)) |
| 快速幂 | ⭐⭐⭐⭐ | 求模逆元 |
| 费马小定理 | ⭐⭐⭐⭐ | 模意义下实现除法 |
总结:
这道题是一道综合题,写代码前,需要同学们先要,完成数学转化。