完成了回贵州的老家之行,也该回学校啦=w=
这次的题目是我个人认为最棘手的一道题,当然从表面上看这道题还是很容易的
提交程序时,注意选择所期望的语言类型和编译器类型。
这道题如果要直接分析计算有多少个数无法被凑出来,似乎非常难。
那么能不能求出哪些数是无法被凑出来的呢?也很难。
但是哪些数能被凑出来是可以通过给定的数去用程序硬凑的。
这样,我们就可以去判断一个数能不能被凑出来了。
那我们就先实现一个吧。
那么这个数可以被凑出来要符合哪些条件呢?
1.这个数是给定的n个数之一。
2.这个数减去给定的n个数中的其中一个之后,依然可以被凑出来。
第1条显然成立,第2条我们稍作思考也会发现是对的,而且这两条包含了所有的情况。
当然,这种做法无法解决凑不出来的数是否无限。
#include
using namespace std;
const int MAX_N = 1e2 + 5;
int n, a[MAX_N];
bool judge(int x) {
if (x <= 0) return false;
for (int i = 0; i < n; i++) {
if (x == a[i]) return true;
if (judge(x - a[i])) return true;
}
return false;
}
int main() {
int ans = 0;
scanf("%d", &n);
for (int i = 0; i < n; i++) scanf("%d", &a[i]);
for (int i = 1; i <= 1000; i++) {
if (!judge(i)) ans++;
}
printf("%d\n", ans);
return 0;
}
观察代码我们可以发现,这段判断函数其实就是一个深搜。
在不考虑无限多个数无法凑出来的情况下,这种做法看上去并没有什么问题。
但是我们仔细分析一下时间复杂度,对于每一个数,都有n种可能进入另外一个数,那么在数很大的情况下,计算每一个数的时间复杂度就会呈指数级增长。
还有一个问题就是,在这段代码中,我们默认只要判断到1000就足矣。但事实果真如此吗?
现在我们来着手解决这些问题。
先考虑时间复杂度的问题。
我们会发现在从1计算到1000的过程中,每一个数都只考虑比它小的数的情况,不需要考虑比它大的数的情况。那么,我们在之前既然已经解决了这些比它小的数的情况,何不把它们记下来呢?这样对于每一个数,我们最多就只需要执行n次判断了。
那么这个时候我们再来考虑判断的范围。题目限制1s,也就是1e8次运算。那么每个数最多执行n次的情况下,保险起见我们也可以算到5e5个数。虽然我们无法确定这个范围是否足够,但是比1000就要保险得多。
#include
using namespace std;
const int MAX_N = 1e2 + 5, MAX_M = 5e5 + 5;
int n, a[MAX_N];
bool able[MAX_M];
// 下面这段代码通常被称为记忆化搜索
bool judge(int x) {
if (x <= 0) return false;
if (able[x]) return true;
for (int i = 0; i < n; i++) {
if (x == a[i]) return able[x] = true;
if (able[x - a[i]]) return able[x] = true;
}
return false;
}
int main() {
int ans = 0;
scanf("%d", &n);
for (int i = 0; i < n; i++) scanf("%d", &a[i]);
for (int i = 1; i <= 5e5; i++) {
if (!judge(i)) ans++;
}
printf("%d\n", ans);
return 0;
}
之所以被称为记忆化搜索,是因为它在搜索的过程中记录了所有的结果。
这样做的好处是,如果每次搜索都是基于前面搜索的结果得出的话,效率就会被大大提高。
相较而言,这种做法的答案更大(从而更准确),速度也更快。
但是,我们依旧没有从根本解决之前所述的几个问题。当然,如果时间不允许你深入思考,做到这里也是可以的。
现在,我们来尝试进一步优化程序。
首先考虑INF的情况。
样例提出的INF是由于给出的数都是偶数,所以无法凑出奇数。
那么我们很容易发现,其实只要给出的数都是某一个数的倍数,即它们的最大公约数为k>1,那么不是k的倍数的数就无法被凑出来。
但是如果它们的最大公约数为1呢?我们无法确定。
当然,我们现在就可以将我们的发现加入到程序当中,来提高我们的得分。
那么问题来了,怎么才能求出最大公约数呢?
如果你听过我讲的上一届的省赛题,你一定会记得最后一题的解法:模拟辗转相减。
那么我们用辗转相减,就可以完成求最大公约数了。不过,其实我们有效率更高的方法:辗转相除法。
实际上,辗转相除就是将辗转相减中的多个减法连在了一起。因为一个较大数减去一个较小数直到差小于较小数为止,这种操作就等同于模运算(取余)。
它的时间复杂度是多少呢?考虑到被除数=除数*商+余数,余数小于除数,也就是说最劣情况下余数也不会超过被除数的一半。所以辗转相除法的时间复杂度是O(logn)的,其中n为被除数,也就是求最大公约数里两个数中较大的那个。
#include
using namespace std;
const int MAX_N = 1e2 + 5, MAX_M = 5e5 + 5;
int n, a[MAX_N];
bool able[MAX_M];
// 以下这段代码被称为辗转相除法(欧几里得算法)
int gcd(int x, int y) {
if (y == 0) return x;
else return gcd(y, x % y);
}
bool judge(int x) {
if (x <= 0) return false;
if (able[x]) return true;
for (int i = 0; i < n; i++) {
if (x == a[i]) return able[x] = true;
if (able[x - a[i]]) return able[x] = true;
}
return false;
}
int main() {
int ans = 0, GCD = 0;
scanf("%d", &n);
for (int i = 0; i < n; i++) {
scanf("%d", &a[i]);
GCD = gcd(a[i], GCD);
}
if (GCD > 1) {
printf("INF\n");
}
else {
for (int i = 1; i <= 5e5; i++) {
if (!judge(i)) ans++;
}
printf("%d\n", ans);
}
return 0;
}
实际上,到目前为止,我们已经不用再往下研究了,因为这个程序足以让我们获得满分。当然,在比赛当中,如果你有充裕的时间,往下想也是理所应当的。
作为拓展内容,下面的内容会涉及到比较多的算法及数学知识。
首先我们来探究在给定的数最大公约数为1的情况下无法凑出的数是否是有限的。
根据上面的讲解:如果一个数可以被凑出来,那么它肯定是给定的数或者一个可以凑出来的数加上一个给定的数。
同理,如果一个数不可以被凑出来,那么它减去一个给定的数也是不能被凑出来的。
那么我们就会发现,不可以被凑出来的数在最稀疏的情况下也应该隔x出现一次(x为一个给定的数)。
我们来考虑这个性质的本质:对于x来说,所有的数其实都可以分成x类:根据对x的余数来分。
只要某类里的一个数被凑出来,那么这类数中无法被凑出来的数就一定是有限的。
这种划分出来的类被称为x的完全剩余系。
那么我们只需要考虑模x=0,1,2,3……x-1的数能不能凑出来就可以了。
很明显,如果模x=1可以被凑出来,其它就一定能被凑出来,所以我们只需要考虑这一种情况。
那么也就是说,我们考虑的是这些数凑出一个数减去若干个x能否等于1。
现在我们来学习一个新知识:裴蜀定理
以上摘自维基百科
如果你擅长离散数学,那么你会发现裴蜀定理在整环上不证自明:在主理想环中,a和b的最大公约元被定义为理想aA + bA的生成元。
现在我们假设x以外的n-1个数为$y_1$,$y_2$……$y_{n-1}$。那么对于这n个数,裴蜀定理能否成立呢?
由于最大公约数这种运算本身具有结合律,所以我们将任意两个数合并,替换成它们的最大公约数,这样进行n-2次操作,就会转化为裴蜀定理的形式了。
也就是说,如果这n个数互素,那么它们凑不出来的数一定是有限个。
下一个问题,如何确定我们判断的范围呢?
考虑我们凑数的过程,实际上就是凑x的完全剩余系。
我们假定x的完全剩余系中,每个类的数被凑出来之后就不再凑这一类。
那么最大的无法凑出来的数加上x就是最后一个被凑出来的数。
所以我们考虑这里最后一个被凑出来的数,假设它是由m个给定的数相加而成。
按照顺序,我们记为$a_1$,$a_2$,$a_3$……$a_m$,并记$\sum_{i=1}^{k}a_i$为$S_k$
我们可以发现一个性质:如果$S_i$和$S_j$模x同余,那么$a_{i+1}+a_{i+2}+……a_j$就是不必要的,它们的和模x=0。
根据这个性质,我们可以得到一个结论:在最优情况下,m必定小于等于x。
这里用到了组合数学里的一个著名定理:鸽巢原理
以上摘自维基百科
我们可以发现,由于要满足每一个$S_k$都不相同,它们的数量必然不会超过x的完全剩余系的大小:x。
这样我们就可以确定无法凑出的数的上限了:x*剩余数里的最大数。
带回题中,我们考虑x不超过100,而剩余的数最大也不超过100,我们只要判断到10000即可。
我们前面提到的记忆化搜索,实际上是一种动态规划的实现形式。而动态规划,是对一类算法的总称。
以上摘自维基百科
如果一个问题可以用动态规划解决,它需要具有无后效性:每一个问题的答案只由它的子问题答案构成,未来问题的答案不会对其产生影响。
例如当前这道题:每一个可以凑出来的数只由比它小的那些数决定,比它大的数无论能不能凑出来都不会对它本身产生影响。
动态规划除去记忆化搜索以外,还有一种解法:递推,这种方法写起来会更加简便。
而递推中需要有递推式,正如记忆化搜索中的递归式。对于这道题而言,我们将每一个给定的数都更新所有的数,那些可以被凑出来的数加上给定的这个数若干倍,其和都可以被凑出来。
#include
using namespace std;
const int MAX_N = 1e2 + 5, MAX_M = 1e4 + 5;
int n, a[MAX_N];
bool able[MAX_M];
int gcd(int x, int y) {
if (y == 0) return x;
else return gcd(y, x % y);
}
int main() {
int ans = 0, GCD = 0;
able[0] = true;
scanf("%d", &n);
for (int i = 0; i < n; i++) {
scanf("%d", &a[i]);
GCD = gcd(a[i], GCD);
// 以下这段代码被称为动态规划的递推式
for (int j = 0; j + a[i] <= 1e4; j++) {
able[j + a[i]] = (able[j + a[i]] || able[j]);
}
}
if (GCD > 1) {
printf("INF\n");
}
else {
for (int i = 1; i <= 1e4; i++) {
if (!able[i]) ans++;
}
printf("%d\n", ans);
}
return 0;
}
测试一下:
$\Gamma(n) = (n-1)!\quad\forall n\in\mathbb N$
$$ x = \dfrac{-b \pm \sqrt{b^2 - 4ac}}{2a} $$