复杂度
CPU 很快,但不够快。
复杂度
具体衡量、比较算法优劣的指标有以下两个:
- 空间复杂度
,程序在执行时所需要的存储空间大小,这个大小往往和输入规模 有关。空间复杂度过大的算法需要大量的资源,你得买更多的内存条或硬盘。 - 时间复杂度
,程序在执行时所耗费的时间长度,也和输入规模 有关。时间复杂度过大的算法可能导致我们有生之年都看不到结果。
当你在 OJ 的程序无法在规定时间内返回答案时,网站便会返回 TLE(时间超限);如果申请了超过限制的内存,网站会返回 RE(运行异常) 或 MLE(内存超限)。
在分析算法效率时,经常关注两种复杂度
- 最坏情况复杂度
; - 平均复杂度
。
比如当我们在书架上顺序查找一本书时,最好的情况是一次找到,最坏的情况是查遍了
对
渐进表示法
精确的比较程序执行的步数是没有意义的,因为每一步执行时间可能不同;对计算资源进行完整的分析太繁琐,也无法捕捉到关键信息。
所以在比较算法优劣时,一般只考虑宏观性质,即当输入规模
为此引入数学中的渐进符号:
- 如果存在常数
,使得当 时有 ,则称 。 - 如果存在常数
,使得当 时有 ,则称 。 - 如果存在
同时 ,则称 。
我们一般分别称
这种记法可以让我们更准确的把握程序所需的计算资源和数据规模
例题 CCF 1034 钞票兑换 · 枚举优化
我们以一道题目为例,题目链接:CCF 1034 钞票兑换。
题目大意:将任意给定的整百元钞票,兑换成10元、20元、50元小钞票形式。输出兑换方案总数。
数据范围:输入需要兑换的钞票总数
时间限制:400 毫秒。
思路 0
这道题显然是可以通过穷举(枚举)解决。简单起见,记兑换得到
只需遍历所有的
#include <stdio.h>
typedef long long i64;
int main() {
i64 n, sum = 0;
scanf("%lld", &n);
for (i64 c = 0; c <= n / 50; c++) {
for (i64 b = 0; b <= n / 20; b++) {
for (i64 a = 0; a <= n / 10; a++) {
if (a * 10 + b * 20 + c * 50 == n)
sum++;
}
}
}
printf("%lld", sum);
}
#include <stdio.h>
typedef long long i64;
int main() {
i64 n, sum = 0;
scanf("%lld", &n);
for (i64 c = 0; c <= n / 50; c++) {
for (i64 b = 0; b <= n / 20; b++) {
for (i64 a = 0; a <= n / 10; a++) {
if (a * 10 + b * 20 + c * 50 == n)
sum++;
}
}
}
printf("%lld", sum);
}
三层循环一共需要枚举
由于枚举中进行检验的代码足够简单,我们简单的估算为每秒可以进行
针对题目的四个样例,我们可以进行大概的估算:
数据规模 | 枚举次数 | 估算时间 | 实际测量 |
---|---|---|---|
100 纳秒 | 0 毫秒 | ||
6400 毫秒 | 3147 毫秒 | ||
222.2 小时 | - | ||
25367833587 年 | - | ||
留做习题 | - | - |
这份代码预期需要运行 253 亿年才能得到答案,可见我们的算法效率有多糟糕了,怎么可能不超时呢?我们需要优化。
优化 1
注意到我们在枚举三个数字判断其和是否是
注意到
#include <stdio.h>
typedef long long i64;
int main() {
i64 n, sum = 0;
scanf("%lld", &n);
for (i64 c = 0; c <= n / 50; c++) {
for (i64 b = 0; b <= n / 20; b++) {
i64 a = n / 10 - 5 * c - 2 * b;
if (a >= 0)
sum++;
}
}
printf("%lld", sum);
}
#include <stdio.h>
typedef long long i64;
int main() {
i64 n, sum = 0;
scanf("%lld", &n);
for (i64 c = 0; c <= n / 50; c++) {
for (i64 b = 0; b <= n / 20; b++) {
i64 a = n / 10 - 5 * c - 2 * b;
if (a >= 0)
sum++;
}
}
printf("%lld", sum);
}
两层循环一共需要
我们可以继续估算:
样例规模 | 枚举次数 | 估算时间 | 实际测量 |
---|---|---|---|
10 纳秒 | 0 毫秒 | ||
1.6 毫秒 | 1 毫秒 | ||
4000 毫秒 | 2227 毫秒 | ||
4629 天 | - | ||
留做习题 | - | - |
不错,我们成功的从 253 亿年优化到了 4629 天,起码能活着看到结果了。能不能再给力一点呢?
优化 2
对
又消掉一层。
#include <stdio.h>
typedef long long i64;
int main() {
i64 n, sum = 0;
scanf("%lld", &n);
for (i64 c = 0; c <= n / 50; c++) {
sum += (n - 50 * c) / 20 + 1;
}
printf("%lld", sum);
}
#include <stdio.h>
typedef long long i64;
int main() {
i64 n, sum = 0;
scanf("%lld", &n);
for (i64 c = 0; c <= n / 50; c++) {
sum += (n - 50 * c) / 20 + 1;
}
printf("%lld", sum);
}
现在只需
样例规模 | 枚举次数 | 估算时间 | 实际测量 |
---|---|---|---|
2 纳秒 | 0 毫秒 | ||
0.8 微秒 | 0 毫秒 | ||
40 微秒 | 0 毫秒 | ||
400 毫秒 | 290 毫秒 | ||
3400 毫秒 | 2134 毫秒 |
非常精彩的优化,从 4629 天优化到了 3.4 秒。但是很遗憾我们离最终目标 400 毫秒还有一定的距离。可以再给力一点吗?
优化 3
优化 2 中我们得到了一个求和式,其实可以使用一定的数学技巧对求和式直接化简。提出首项
分奇偶展开有
于是可以写出代码
#include <stdio.h>
typedef long long i64;
int main() {
i64 n, sum = 0;
scanf("%lld", &n);
sum = (5 * k + 4) * k + 1;
printf("%lld", sum);
}
#include <stdio.h>
typedef long long i64;
int main() {
i64 n, sum = 0;
scanf("%lld", &n);
sum = (5 * k + 4) * k + 1;
printf("%lld", sum);
}
此时枚举已经完全被我们优化掉了,无论怎样的数据范围都可以直接的计算出答案。
提示
直接抄是过不了题的,还有需要修正,观察一下数据范围。有时候拿不了全分,也要争取部分分。
习题
思考的深度不同,对应代码的效率差距天差地别。学会分析自己思路的复杂度,是一个必须掌握的技能。
- 求下面函数的复杂度。
int f(int n) {
int sum = 0;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= n / i; ++j) {
sum += 1;
}
}
return sum;
}
int f(int n) {
int sum = 0;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= n / i; ++j) {
sum += 1;
}
}
return sum;
}
- 求下面函数的复杂度。
int f(int n) {
if (n <= 2)
return 1;
else
return f(n - 1) + f(n - 2);
}
int f(int n) {
if (n <= 2)
return 1;
else
return f(n - 1) + f(n - 2);
}