C++枚举算法入门:从暴力穷举到优化剪枝的实战指南
1. 项目概述:为什么从枚举开始学算法?
如果你刚开始接触算法,面对“动态规划”、“图论”、“贪心”这些词感到一头雾水,不知道从何下手,那么恭喜你,找对地方了。我见过太多新手一上来就想啃《算法导论》,结果被各种复杂的数学证明和抽象概念劝退,信心大受打击。其实,算法的世界有一扇非常友好的“后门”,那就是枚举。
枚举,说白了就是“把所有可能的情况都试一遍”。听起来很笨,对吧?但恰恰是这种“笨办法”,是理解计算机思维和算法设计最直观、最坚实的起点。它不要求你有多高的数学天赋,只要求你有耐心和清晰的逻辑。在C++中实现枚举,更是锻炼你基础语法(如循环、条件判断)、数据结构(如数组、向量)和问题建模能力的绝佳沙盒。很多复杂的算法,其核心思想里都藏着枚举的影子。比如动态规划,你可以理解为一种“聪明的枚举”,它避免了重复计算;回溯算法,则是一种“有组织的枚举”,按特定顺序尝试所有路径。
所以,别小看枚举。它能帮你建立起“暴力求解”的直觉,这是你未来优化算法、寻找更优解法的基准线。当你学会用枚举解决一个问题后,再去学习更高级的算法,你会恍然大悟:“哦,原来这个高级算法是为了解决我当初枚举时遇到的效率问题!”这种从具体到抽象的学习路径,远比直接灌输抽象概念要有效得多。今天,我们就用C++这把利器,把“枚举”这个算法基石彻底搞明白。
2. 枚举的核心思想与适用场景拆解
2.1 什么是枚举算法?
枚举算法,也称为穷举算法或暴力搜索,其核心思想是为了解决问题,系统地遍历所有可能的候选解,并检查每个候选解是否满足问题的条件。如果满足,则该候选解就是问题的一个有效解。
我们可以用一个生活中的例子来类比:假设你有一串钥匙,但不知道哪一把能打开面前的锁。最直接的方法是什么?那就是一把一把地试,直到找到能打开的那把为止。这个“一把一把试”的过程,就是枚举。
在计算机中,这个“试”的过程通过循环和条件判断来实现。枚举算法的框架通常包含三个关键部分:
- 确定枚举范围:明确我们要尝试的“所有情况”是什么。比如,是从1到100的所有整数?还是一个字符串的所有子串?或者是一个数组的所有排列组合?
- 生成候选解:通过循环结构(如
for、while)系统地生成枚举范围内的每一个元素。 - 验证候选解:对生成的每一个候选解,使用条件判断语句(如
if)检查它是否满足题目要求。
2.2 枚举能解决什么问题?——四大典型场景
枚举并非万能,但在以下场景中,它往往是首选或唯一的入门解法:
场景一:解空间有限的小规模问题这是枚举最擅长的领域。当问题的所有可能解的数量很少,以至于计算机可以在极短的时间内(如毫秒级)遍历完毕时,枚举就是最直接、最不容易出错的方案。
- 例子:求100以内所有的素数。可能的数字只有100个,逐个判断是否素数即可。
- 例子:经典的“鸡兔同笼”问题(已知头数和脚数,求鸡兔各几只)。鸡的数量范围是0到头数总数,在这个小范围内枚举完全可行。
场景二:作为验证更高阶算法正确性的“对拍器”当你设计出一个复杂的、高效的算法时,如何确保它在各种边界情况下都是正确的?一个非常有效的方法就是,同时写一个枚举算法(确保逻辑简单正确)来解决同一个问题的小规模实例。用你的高效算法和枚举算法分别运行,对比结果。如果对于成千上万个小规模测试用例结果都一致,你对高效算法的信心就会大大增强。这个枚举程序就是你的“真理标准”。
场景三:辅助理解问题,寻找规律有些问题直接思考最优解很困难。不妨先写一个枚举程序,把规模较小的情况的所有解都打印出来观察。解的数量、分布规律往往能给你巨大的启发,甚至直接引导你发现问题的数学本质或最优解的结构。这相当于让计算机帮你做“数学实验”。
场景四:竞赛中的“部分分”策略在算法竞赛中,题目通常会设计多个测试点,对应不同的数据规模。对于最大的数据规模,可能需要高级算法。但对于较小的数据规模,枚举往往就能拿到可观的分数。一个稳健的策略是:即使想不到满分算法,也一定要确保能写出正确的枚举解法,先拿下基础分。
注意:枚举最大的敌人是时间复杂度。如果解空间随着问题规模呈指数级增长(例如,求n个元素的所有子集,有2^n种可能),那么即使n=30,2^30也超过了10亿,枚举就不再可行。这时就必须寻找更聪明的算法。因此,判断一个问题能否用枚举,第一步就是估算其解空间的大小。
3. 从零构建枚举算法的通用框架与C++实现
理解了思想,我们来看看在C++里如何把枚举的骨架搭起来。一个健壮的枚举程序通常遵循以下步骤,我们用一个具体问题来贯穿讲解:找出1~100之间所有能被3或5整除的数。
3.1 第一步:问题分析与建模
动手敲代码之前,必须彻底弄清问题。
- 输入是什么?本题没有动态输入,范围固定是1~100。
- 输出是什么?所有满足条件的整数,按顺序输出。
- 条件是什么?“能被3或5整除”,翻译成C++条件表达式就是:
(i % 3 == 0) || (i % 5 == 0)。 - 枚举对象是什么?是1到100这100个整数。
- 枚举范围有多大?100个,很小,枚举完全可行。
3.2 第二步:确定枚举对象与范围
这是最关键的一步,直接决定了循环怎么写。
- 枚举对象:整数
i。 - 枚举范围:
i从1开始,到100结束,每次增加1。这对应一个for循环:for(int i = 1; i <= 100; ++i)。 - 为什么是
<=100而不是<100?因为题目要求是1~100之间,通常包含100。务必仔细审题,区分“小于”、“小于等于”、“之间”等表述。
3.3 第三步:构建循环与生成解
用循环结构生成每一个待检查的候选解。
#include <iostream> using namespace std; int main() { // 步骤2 & 3: 确定范围并构建循环 for (int i = 1; i <= 100; ++i) { // 步骤4: 验证条件(见下一步) } return 0; }这里使用++i而非i++是C++中一个微小的效率习惯(对于内置类型差异可忽略,但养成好习惯)。
3.4 第四步:编写条件判断语句
在循环体内,对每一个i进行条件判断。
if (i % 3 == 0 || i % 5 == 0) { // 满足条件,进行处理(见下一步) }%是取模运算符,i % 3 == 0为真表示i能被3整除。||是逻辑或运算符。
3.5 第五步:处理符合条件的解
对于满足条件的i,我们需要输出它。
cout << i << " ";为了输出美观,可以在所有循环结束后输出一个换行。
3.6 完整代码示例与运行
将以上步骤组合起来:
#include <iostream> using namespace std; int main() { cout << "1~100之间能被3或5整除的数有:" << endl; for (int i = 1; i <= 100; ++i) { if (i % 3 == 0 || i % 5 == 0) { cout << i << " "; } } cout << endl; // 输出换行,让结束更美观 return 0; }运行结果:程序会输出一串数字:3 5 6 9 10 12 15 18 20 21 ...
实操心得:在写枚举时,我习惯在循环开始前和结束后用
cout输出一些提示信息(如“开始枚举...”、“结果为:”),这对于调试和让别人(或几天后的自己)看懂程序输出非常有帮助。另外,对于更复杂的问题,在条件判断部分,可以临时加上调试输出,比如cout << “正在检查 i=” << i << “,条件结果为” << (i%3==0) << endl;,这是定位逻辑错误的神器。
4. 枚举算法实战:三大经典案例深度剖析
掌握了框架,我们通过三个由浅入深的经典案例,来感受枚举如何解决实际问题。每个案例我都会带你走一遍完整的分析、实现和优化思路。
4.1 案例一:水仙花数(基础循环与数位分解)
问题描述:输出所有的“水仙花数”。所谓“水仙花数”是指一个三位数,其各位数字的立方和等于该数本身。例如,153 = 1^3 + 5^3 + 3^3。
分析与建模:
- 枚举对象:所有的三位数。范围明确:100到999。
- 条件判断:对每个数,需要分离出它的个位、十位、百位,分别计算立方和,再与原数比较。
- 解空间:999-100+1=900个,很小。
C++实现: 关键点在于如何分解一个三位数n的各个数位。
- 百位:
hundred = n / 100(整数除法) - 十位:
ten = (n / 10) % 10 - 个位:
digit = n % 10
#include <iostream> using namespace std; int main() { cout << "所有的水仙花数有:" << endl; for (int n = 100; n <= 999; ++n) { int hundred = n / 100; int ten = (n / 10) % 10; int digit = n % 10; // 计算立方和 int sum_of_cubes = hundred*hundred*hundred + ten*ten*ten + digit*digit*digit; if (sum_of_cubes == n) { cout << n << " "; } } cout << endl; return 0; }运行结果:153 370 371 407
避坑技巧:数位分解是基础中的基础。务必熟练掌握对任意整数取特定位数的方法。对于正整数
n,n % 10永远得到个位,n / 10相当于去掉个位。以此类推。
4.2 案例二:百钱买百鸡(多重循环与约束优化)
问题描述:公鸡5文钱一只,母鸡3文钱一只,小鸡1文钱三只。现在要用100文钱买100只鸡,请问公鸡、母鸡、小鸡各有多少只?(每种鸡至少一只)
分析与建模:
- 枚举对象:公鸡数量
x,母鸡数量y,小鸡数量z。 - 约束条件:
- 数量约束:
x + y + z = 100 - 金钱约束:
5*x + 3*y + z/3 = 100 - 类型约束:
x, y, z都是正整数,且z必须是3的倍数(因为小鸡1文钱三只,不能单买)。
- 数量约束:
- 暴力枚举思路:如果直接三重循环,
x,y,z都从1循环到100,那么循环次数是100100100=100万次。虽然现代计算机瞬间完成,但我们可以优化。
优化策略:
- 策略一(减少循环层数):利用
x + y + z = 100,当x和y确定后,z = 100 - x - y。这样可以将三重循环优化为两重循环。 - 策略二(缩小枚举范围):
- 公鸡最多买多少只?假设全买公鸡,100文最多买
100/5=20只。所以x的范围是[1, 20]。 - 母鸡最多买多少只?假设全买母鸡,100文最多买
100/3=33只(取整)。所以y的范围是[1, 33]。 - 小鸡数量
z由计算得出,但必须满足是3的倍数且为正数。
- 公鸡最多买多少只?假设全买公鸡,100文最多买
C++实现(优化后):
#include <iostream> using namespace std; int main() { cout << "百钱买百鸡的可能方案有:" << endl; cout << "公鸡\t母鸡\t小鸡" << endl; for (int x = 1; x <= 20; ++x) { // 公鸡范围 for (int y = 1; y <= 33; ++y) { // 母鸡范围 int z = 100 - x - y; // 小鸡数量 if (z > 0 && z % 3 == 0) { // 小鸡必须为正且是3的倍数 // 检查金钱约束 if (5*x + 3*y + z/3 == 100) { cout << x << "\t" << y << "\t" << z << endl; } } } } return 0; }运行结果:
公鸡 母鸡 小鸡 4 18 78 8 11 81 12 4 84核心要点:这个案例展示了枚举算法的核心优化思想——减少枚举范围和减少循环层数。通过问题自带的约束条件(方程),我们主动缩小了搜索空间,去掉了大量明显不可能的解。在算法设计中,这种“剪枝”思想无处不在。即使是用枚举,也要做一个“聪明的”枚举者。
4.3 案例三:完美立方(多层循环与等式判断)
问题描述:找到所有满足 a^3 = b^3 + c^3 + d^3 的形式(其中a, b, c, d是大于1的整数,且b <= c <= d)。要求对于任意给定的正整数N(N<=100),输出所有满足条件的四元组(a, b, c, d),按a的值从小到大输出。
分析与建模:
- 枚举对象:四个整数
a,b,c,d。 - 约束条件:
a^3 = b^3 + c^3 + d^31 < b <= c <= d < a <= N
- 思路:最外层循环枚举
a(从2到N)。对于每个固定的a,我们需要找到所有可能的b, c, d组合,使得它们的立方和等于a^3,且满足大小关系。 - 暴力枚举:四重循环,
a,b,c,d各自循环。复杂度约为 O(N^4),当N=100时,100^4=1亿,勉强可接受但效率低。 - 优化:利用
b <= c <= d这个条件,我们可以让循环变量有序递增,避免重复枚举相同的组合(如 (2,3,4) 和 (3,2,4))。同时,最内层d的循环可以基于a和b,c来设定上限。
C++实现(带优化):
#include <iostream> using namespace std; int main() { int N; cout << "请输入N的值(N<=100):"; cin >> N; cout << "满足完美立方的四元组有:" << endl; // 枚举a for (int a = 2; a <= N; ++a) { int a_cube = a * a * a; // 枚举b,b最大不超过a-2(因为c和d至少比b大或等于) for (int b = 2; b < a; ++b) { int b_cube = b * b * b; // 如果b的立方已经大于等于a的立方,后面的c、d更大,直接跳出 if (b_cube * 3 >= a_cube) break; // 重要优化! // 枚举c,c从b开始,保证b<=c for (int c = b; c < a; ++c) { int c_cube = c * c * c; if (b_cube + c_cube * 2 >= a_cube) break; // 优化 // 枚举d,d从c开始,保证c<=d for (int d = c; d < a; ++d) { int d_cube = d * d * d; int sum = b_cube + c_cube + d_cube; if (sum > a_cube) break; // 和已经太大,d再增大会更大,跳出 if (sum == a_cube) { cout << "Cube = " << a << ", Triple = (" << b << "," << c << "," << d << ")" << endl; } } } } } return 0; }运行结果(输入N=50):你会看到如Cube = 6, Triple = (3,4,5)这样的输出。
深度解析:这个案例引入了多重循环和循环剪枝的强力优化。注意代码中的几个
break语句:
if (b_cube * 3 >= a_cube) break;:如果最小的数b的立方的3倍都已经大于等于a的立方,那么b, c, d(都>=b)的立方和必然更大,当前b及更大的b都不用考虑了。if (b_cube + c_cube * 2 >= a_cube) break;:类似的逻辑,固定了b和c后,如果b^3 + c^3 * 2已经太大,那么d(>=c)只会让和更大。if (sum > a_cube) break;:在最内层循环,一旦和超过目标,立即跳出。这些
break利用单调性(数字增大,立方和增大)提前终止了不可能产生解的循环分支,极大地提升了效率。这是从“无脑枚举”到“智能搜索”的关键一步。
5. 枚举算法的优化技巧与效率提升实战
通过百钱买百鸡和完美立方的案例,我们已经接触了优化。现在系统性地总结一下枚举算法的“提速”心法。
5.1 优化心法一:缩小枚举范围
这是最立竿见影的优化。不要一上来就按题目字面意思的最大范围去循环。
- 利用数学关系推导边界:如百钱买百鸡中,通过总价和单价推导出每种鸡数量的上限。
- 利用物理/现实意义:很多问题中的变量有自然限制(如人数不能为负,零件数为整数等)。
- 利用对称性减少重复:在完美立方中,我们约定
b <= c <= d,避免了像 (2,3,4) 和 (4,3,2) 这样的重复枚举。对于排列组合问题,这一点尤其重要。
5.2 优化心法二:减少循环层数
每多一层循环,时间复杂度通常就多一个数量级。能减少一层,效率提升巨大。
- 利用等式消元:百钱买百鸡中,我们用
z = 100 - x - y消去了对z的循环。 - 将问题转化为查找:有时内层循环的目的是在一个集合里查找某个值。如果集合是有序的,可以用二分查找替代线性遍历,将内层循环的O(n)降为O(log n)。这是质的飞跃。
5.3 优化心法三:避免重复计算
在循环体内,如果有些表达式被重复计算,且其值在循环中不变,就应该提到循环外面。
- 案例:在完美立方的代码中,我们计算了
a_cube = a * a * a,并将其放在b循环之外。因为对于同一个a,它的立方值是不变的。如果在最内层d的循环里每次都重新计算a*a*a,就浪费了。 - 更复杂的场景:如果判断条件中有一个复杂的函数调用,比如
if (isPrime(i) && isPrime(i+2)),而isPrime函数计算量很大,可以考虑用预处理(打表)的方式。先一次性计算出范围内所有数字是否为素数,存到一个布尔数组里,后续判断就变成了if (primeTable[i] && primeTable[i+2]),这是典型的“空间换时间”。
5.4 优化心法四:剪枝(Pruning)
这是搜索算法(枚举是搜索的一种)的核心优化技术。其思想是:在搜索过程中,一旦发现当前路径不可能导出最终的正确解,就立即回溯或跳出,不再继续深入。
- 可行性剪枝:当前部分解已经违反了问题的约束条件。例如,在凑钱问题中,如果已经选择的钱数超过了目标总额,那么无论后面怎么选都不可能成功。
- 最优性剪枝:在求解最优解的问题中(如求最短路径),如果当前路径的成本已经超过了目前已知的最优解的成本,那么这条路径也没必要继续了。
- 我们在完美立方中使用的
break,就是基于单调性的剪枝。因为数字递增,立方和也递增,所以一旦和超过目标,后面的数字只会让和更大,绝无可能相等,故立即跳出。
5.5 一个综合优化案例:求素数(埃拉托斯特尼筛法)
问题:求1到n之间所有的素数。
- 最朴素的枚举:对每个数i,用2到i-1去除,看能否整除。时间复杂度O(n^2)。
- 初级优化:判断i是否为素数时,只需用2到sqrt(i)去除即可。因为如果i有因数a和b(a<=b),那么a一定小于等于sqrt(i)。复杂度降到O(n*sqrt(n))。
- 高级优化——埃氏筛法:这是一种基于枚举思想的高效预处理算法,其核心是“标记”。
- 假设所有数初始都是素数。
- 从2开始,如果当前数字是素数,则将其所有的倍数标记为非素数。
- 继续下一个未被标记的数。
#include <iostream> #include <vector> using namespace std; void findPrimes(int n) { vector<bool> isPrime(n + 1, true); // 创建标记数组,初始全为true(是素数) isPrime[0] = isPrime[1] = false; // 0和1不是素数 for (int i = 2; i * i <= n; ++i) { // 优化:只需筛到sqrt(n) if (isPrime[i]) { // 如果i是素数 // 从i*i开始标记,因为2*i, 3*i, ..., (i-1)*i 已经被更小的素数标记过了 for (int j = i * i; j <= n; j += i) { isPrime[j] = false; // 标记i的倍数为非素数 } } } // 输出结果 cout << "1到" << n << "之间的素数有:"; for (int i = 2; i <= n; ++i) { if (isPrime[i]) { cout << i << " "; } } cout << endl; } int main() { int n; cout << "请输入n: "; cin >> n; findPrimes(n); return 0; }为什么这是枚举的优化?传统枚举是“针对每个数,枚举所有可能的因数”。而筛法是“针对每个已知的素数,枚举它的倍数并标记”。后者通过一种巧妙的、系统性的“标记”方式,避免了大量重复的取模运算,时间复杂度是O(n log log n),效率极高。这展示了枚举思想可以衍生出非常高效的算法。
6. 枚举算法常见“坑点”与调试技巧实录
即使思路正确,在实现枚举时也极易掉入一些陷阱。下面是我在多年刷题和教学中总结的常见问题。
6.1 坑点一:整数溢出
这是C++新手(甚至老手)在枚举时最容易忽略,也最致命的问题。
- 场景:计算乘积、平方、立方,或者累加和时,结果可能超过
int类型所能表示的范围(-2^31 ~ 2^31-1,约±21亿)。 - 案例:在完美立方中,如果
a接近100,a*a*a是100万,还在int范围内。但如果问题规模变大,比如a接近10000,a^3就是10^12,远超int范围,计算结果会溢出,变成错误的值。 - 解决方案:
- 预估范围:在编写代码前,先估算中间结果和最终结果的最大可能值。
- 使用更大类型:将关键变量声明为
long long(64位整数,范围约±9e18)。例如:long long a_cube = (long long)a * a * a;。注意,(long long)a将a转换为long long,这样整个表达式都会以long long类型计算,避免中途溢出。 - 警惕隐式转换:
int a = 1000000; long long b = a * a;这行代码会先以int类型计算a*a,此时已经溢出,再将溢出的结果赋给b,b的值是错误的。正确写法是long long b = (long long)a * a;。
6.2 坑点二:边界条件处理不当
循环的起始值、终止条件、等号是否包含,直接决定了枚举是否完整或越界。
- “差一错误”:是边界错误的典型。例如,要求枚举1到n,写成
for(int i=1; i<n; ++i)就漏掉了n。 - 多解或漏解:在百钱买百鸡中,如果小鸡
z的计算公式是z = 100 - x - y,就必须检查z > 0,否则可能得到负数的解。同时,z % 3 == 0这个条件必须在判断金钱约束之前检查,因为z/3在z不是3的倍数时是整数除法,会丢失精度,导致逻辑错误。 - 检查清单:
- 循环开始时,初始值对吗?(通常从0还是1开始?)
- 循环结束时,终止条件包含等号吗?(
i <= N还是i < N?) - 循环步长对吗?(是
++i还是i+=2?) - 所有候选解都生成到了吗?有没有重复或遗漏?
- 条件判断中,等于号(
==)和赋值号(=)有没有写错?(经典错误:if (a = b))
6.3 坑点三:循环嵌套与效率陷阱
多重循环是枚举的常态,但也容易写出低效甚至死循环的代码。
- 死循环:确保循环变量在循环体内有朝终止条件变化的趋势。例如
while循环忘了更新变量,或者for循环的步长设成了0。 - 低效循环:内层循环的范围如果依赖于外层循环变量,要仔细分析。在完美立方中,内层
d的循环从c开始,而不是从2开始,就是一种优化。不假思索地都从固定值开始,会做大量无用功。 - 调试技巧:对于复杂的多重循环,如果结果不对,可以在循环开始时打印出关键的变量值。例如:
通过观察输出,你可以清楚地看到程序的执行流程,很容易发现哪层循环的范围错了,或者哪个条件判断提前退出了。for (int a=2; a<=N; ++a){ cout << “外层 a=” << a << endl; for(int b=2; b<a; ++b){ cout << “ 内层 b=” << b << endl; // ... } }
6.4 坑点四:浮点数比较
如果问题涉及浮点数计算(如几何问题),要特别小心。
- 问题:浮点数在计算机中存储有精度误差,直接使用
==比较两个浮点数是否相等很可能失败。 - 案例:判断
sqrt(2) * sqrt(2) == 2.0可能返回false。 - 解决方案:比较浮点数时,通常判断它们的差的绝对值是否小于一个极小的数(称为“精度容忍度”或“epsilon”)。
在枚举算法中,如果可能,尽量通过等式变形,将问题转化为纯整数运算,彻底避免浮点数。例如,判断const double EPS = 1e-9; // 定义一个很小的数 double a, b; // 判断a和b是否“相等” if (fabs(a - b) < EPS) { // fabs是求绝对值的函数 // 认为a等于b } // 判断a是否大于b if (a - b > EPS) { // 认为a > b }sqrt(n)是否为整数,不要用int(sqrt(n)) == sqrt(n),而是用int(sqrt(n)) * int(sqrt(n)) == n。
7. 枚举进阶:从“暴力”到“智能搜索”的桥梁
掌握了基础的枚举和优化后,你会发现很多经典算法其实是枚举思想的深化和系统化。理解这一点,对你后续学习至关重要。
7.1 深度优先搜索是“有顺序的路径枚举”
想象一个走迷宫的问题,枚举所有从起点到终点的路径。最笨的办法是随机乱走,可能永远走不到或者重复走。DFS(深度优先搜索)则规定了一种枚举顺序:“一条路走到黑,碰壁再回头”。它系统地枚举了所有可能的路径,是一种系统性的、不重复的枚举。回溯算法就是在DFS的基础上,加上了“剪枝”(如果当前路径明显不行,就回头)。
7.2 广度优先搜索是“分层递进的状态枚举”
还是走迷宫,BFS(广度优先搜索)的枚举顺序是:“先走完所有一步能到的地方,再走所有两步能到的地方...”。它枚举的是从起点出发,到达每个位置的最短距离。BFS常用于寻找最短路径,其本质是按距离层次枚举所有可达状态。
7.3 动态规划是“避免重复计算的记忆化枚举”
以经典的斐波那契数列为例,f(n) = f(n-1) + f(n-2)。用递归枚举会重复计算大量子问题(如计算f(5)要算f(4)和f(3),计算f(4)又要算f(3)和f(2))。动态规划的做法是:从f(1), f(2)开始,按顺序计算并保存每个f(i)的值。当需要f(i)时,直接查表。这相当于按特定顺序枚举了所有子问题,并把结果存下来供后续使用,避免了重复枚举。
7.4 二分查找是“在有序解空间中的快速枚举”
如果你知道解在一个有序范围内(比如一个排序好的数组),并且可以判断当前猜测的值是偏大还是偏小,那么你就不需要逐个枚举。二分查找每次猜中间值,根据反馈将解空间缩小一半,从而以O(log n)的效率找到目标。你可以把它看作一种每次都能排除一半无效解的、极其高效的枚举策略。
给你的建议:当你学习DFS、BFS、动态规划、二分查找这些算法时,不妨时常回想一下枚举。思考:“如果不用这个高级算法,用最笨的枚举该怎么写?会遇到什么困难(比如重复、低效)?这个高级算法是如何巧妙地解决这些困难的?”这样对比着学,你对算法本质的理解会深刻得多。
枚举是算法世界的基石。它可能不是最快的,但往往是最直接、最可靠的起点。把枚举练熟了,你不仅掌握了解决一大批简单问题的能力,更为理解所有更高级的算法打下了坚实的思维基础。从今天起,试着用枚举的思路去审视你遇到的每一个问题,先想想“如果我把所有可能都试一遍,该怎么做?”,然后再问自己“怎样才能试得更快、更聪明?”。这个过程,就是算法能力成长的真正路径。