枚举算法是我们在日常中使用到的较多的一个算法,它简单粗暴,算法的核心是暴力枚举所有可能情况,并进一步从中筛选出满足条件的方案。因为枚举法编程实现最简单,并且得到的结果往往是正确的,所以虽然枚举算法非常暴力,而且速度可能很慢(导致超时),但却是我们编程以及竞赛时应该甚至是优先考虑的解决方案,特别是没有更好的优化方案的时候。当然如果能够将暴力枚举算法更好地优化,也完全有可能在设定的时间内求出正解。
本章节将依次介绍暴力枚举的三种策略:循环枚举、子集枚举和排列枚举。
循环枚举的策略是借助多重循环结构枚举所有可能的情况,是最简单的暴力枚举方法。和模拟算法策略一样,循环枚举也没有固定的做法,这里也通过例题来介绍循环枚举策略。但是需要注意的是,对于同一个问题,循环枚举的做法往往不止一种,其中有效率高运行速度快的,也有算法原始效率低下的,这也往往会导致超时。
因此在使用循环枚举暴力求解时也要想办法尽可能提高算法的效率。最简单的策略当然是减少多重循环的嵌套层级,例如原始的循环枚举算法需要三重循环,如果可以优化改进成双重循环甚至简单循环就能实现,那么程序效率会大大提升。也可以从减少循环次数的角度出发提高枚举效率,特别是越外层的循环,如果能减少循环次数,枚举效率会得到明显提升。
来看例题:P2089 烤鸡
方案:10种配料,每种配料可以放1~3克,问题要求的就是所有配料的质量之和为\(n\)的所有方案。最简答的做法当然是暴力循环枚举,使用一个10层循环来枚举每种配料的质量,因为每层循环都只枚举3个数,所以最内层循环执行的次数为\(3^{10}\),虽然循环嵌套层次很大,但是执行次数在可控范围内,不会超时。实际操作时也可以只使用九重循环嵌套来暴力枚举,因为枚举了9种配料,最后一种配料可以直接计算出来。
#include<cstdio> int main(){ int n,tot=0; scanf("%d",&n); //特判 if(n>30 || n<10){ printf("0\n"); return 0; } //第一次暴力枚举,计算方案数 for(int i1=1;i1<=3;i1++){ for(int i2=1;i2<=3;i2++){ for(int i3=1;i3<=3;i3++){ for(int i4=1;i4<=3;i4++){ for(int i5=1;i5<=3;i5++){ for(int i6=1;i6<=3;i6++){ for(int i7=1;i7<=3;i7++){ for(int i8=1;i8<=3;i8++){ for(int i9=1;i9<=3;i9++){ int x = n-i1-i2-i3-i4-i5-i6-i7-i8-i9; if(x>=1 && x<=3) tot++; } } } } } } } } } printf("%d\n",tot); //第二次暴力枚举,输出具体方案 for(int i1=1;i1<=3;i1++){ for(int i2=1;i2<=3;i2++){ for(int i3=1;i3<=3;i3++){ for(int i4=1;i4<=3;i4++){ for(int i5=1;i5<=3;i5++){ for(int i6=1;i6<=3;i6++){ for(int i7=1;i7<=3;i7++){ for(int i8=1;i8<=3;i8++){ for(int i9=1;i9<=3;i9++){ int x = n-i1-i2-i3-i4-i5-i6-i7-i8-i9; if(x>=1 && x<=3){ printf("%d %d %d %d %d %d %d %d %d %d\n",i1,i2,i3,i4,i5,i6,i7,i8,i9,x); } } } } } } } } } } return 0; }
很显然,上面的程序出现了重复的计算,我们可以想办法在最内层循环中使用数组记录下方案配料情况,这样只需要一次暴力枚举即可,这是典型的空间换时间策略(下面的程序去掉了一些不必要的括号):
#include<cstdio> //存储多个字符串的二维char数组 char s[20000][25]; int main(){ int n,tot=0; scanf("%d",&n); //特判 if(n>30 || n<10){ printf("0\n"); return 0; } //暴力枚举,记录方案 for(int i1=1;i1<=3;i1++) for(int i2=1;i2<=3;i2++) for(int i3=1;i3<=3;i3++) for(int i4=1;i4<=3;i4++) for(int i5=1;i5<=3;i5++) for(int i6=1;i6<=3;i6++) for(int i7=1;i7<=3;i7++) for(int i8=1;i8<=3;i8++) for(int i9=1;i9<=3;i9++){ int x = n-i1-i2-i3-i4-i5-i6-i7-i8-i9; if(x>=1 && x<=3){ //使用sprintf输出到字符串中存储 sprintf(s[tot++],"%d %d %d %d %d %d %d %d %d %d\0",i1,i2,i3,i4,i5,i6,i7,i8,i9,x); } } printf("%d\n",tot); for(int i=0;i<tot;i++){ printf("%s\n",s[i]); } return 0; }
#include<cstdio> //存储多个字符串的二维char数组 char s[20000][25]; int main(){ int n,tot=0; scanf("%d",&n); //特判 if(n>30 || n<10){ printf("0\n"); return 0; } //暴力枚举,记录方案 for(int i1=1;i1<=3;i1++){ for(int i2=1;i2<=3 && i1+i2<n;i2++){ for(int i3=1;i3<=3 && i1+i2+i3<n;i3++){ for(int i4=1;i4<=3 && i1+i2+i3+i4<n;i4++){ for(int i5=1;i5<=3 && i1+i2+i3+i4+i5<n;i5++){ for(int i6=1;i6<=3 && i1+i2+i3+i4+i5+i6<n;i6++){ for(int i7=1;i7<=3 && i1+i2+i3+i4+i5+i6+i7<n;i7++){ for(int i8=1;i8<=3 && i1+i2+i3+i4+i5+i6+i7+i8<n;i8++){ for(int i9=1;i9<=3 && i1+i2+i3+i4+i5+i6+i7+i8+i9<n;i9++){ int x = n-i1-i2-i3-i4-i5-i6-i7-i8-i9; if(x<=3){ //使用sprintf输出到字符串中 sprintf(s[tot++],"%d %d %d %d %d %d %d %d %d %d\0",i1,i2,i3,i4,i5,i6,i7,i8,i9,x); } } } } } } } } } } printf("%d\n",tot); for(int i=0;i<tot;i++){ printf("%s\n",s[i]); } return 0; }
上面的程序数组究竟开多大呢?假设内层循环条件都满足,那么会有\(3^9\)(19683)种方案存储到数组中,所以可以开一个20000行的二维数组来存储方案(实际远远用不到这么多)。甚至可以专门编写一个程序通过打擂台来计算10~30的每一个数作为输入时的最多方案数,来作为开数组时数组的行数,这也是竞赛时的一些小技巧。
优化方案:上面的暴力枚举其实可以进一步优化的,例如i1+i2+i3+i4就已经超过\(n\)了,那么完全没有必要再进一步枚举剩下的配料了,可以使用break语句来避免这样的情况(也可以修改每层循环的条件),这样做限定了循环枚举的范围,当然能提高程序的执行效率:
#include<cstdio> //存储多个字符串的二维char数组 char s[20000][25]; int main(){ int n,tot=0; scanf("%d",&n); //特判 if(n>30 || n<10){ printf("0\n"); return 0; } //暴力枚举,记录方案 for(int i1=1;i1<=3;i1++){ for(int i2=1;i2<=3;i2++){ if(i1+i2>=n) break; //可以不要 for(int i3=1;i3<=3;i3++){ if(i1+i2+i3>=n) break; //可以不要 for(int i4=1;i4<=3;i4++){ if(i1+i2+i3+i4>=n) break; for(int i5=1;i5<=3;i5++){ if(i1+i2+i3+i4+i5>=n) break; for(int i6=1;i6<=3;i6++){ if(i1+i2+i3+i4+i5+i6>=n) break; for(int i7=1;i7<=3;i7++){ if(i1+i2+i3+i4+i5+i6+i7>=n) break; for(int i8=1;i8<=3;i8++){ if(i1+i2+i3+i4+i5+i6+i7+i8>=n) break; for(int i9=1;i9<=3;i9++){ int x = n-i1-i2-i3-i4-i5-i6-i7-i8-i9; if(x>=1 && x<=3){ //使用sprintf输出到字符串中 sprintf(s[tot++],"%d %d %d %d %d %d %d %d %d %d\0",i1,i2,i3,i4,i5,i6,i7,i8,i9,x); } } } } } } } } } } printf("%d\n",tot); for(int i=0;i<tot;i++){ printf("%s\n",s[i]); } return 0; }
*使用宏构造语句
观察程序排版情况就可以看出,程序嵌套层次太多,不方便阅读。这里介绍一种特殊的宏构造语句来简化程序,先来看程序代码:
#include<cstdio> #define rep(i,a,b) for(int i=a;i<=b;i++) //存储多个字符串的二维char数组 char s[60000][25]; int main(){ int n,tot=0; scanf("%d",&n); //特判 if(n>30 || n<10){ printf("0\n"); return 0; } //暴力枚举,记录方案 rep(i1,1,3) rep(i2,1,3) rep(i3,1,3) rep(i4,1,3) rep(i5,1,3) rep(i6,1,3) rep(i7,1,3) rep(i8,1,3) rep(i9,1,3) rep(i10,1,3) if(i1+i2+i3+i4+i5+i6+i7+i8+i9+i10==n){ //使用sprintf输出到字符串中 sprintf(s[tot++],"%d %d %d %d %d %d %d %d %d %d\0",i1,i2,i3,i4,i5,i6,i7,i8,i9,i10); } printf("%d\n",tot); for(int i=0;i<tot;i++){ printf("%s\n",s[i]); } return 0; }
程序开始处定义了宏构造语句define rep(i,a,b) for(int i=a;i<=b;i++)
,在程序里使用rep(i1,1,3)
,程序编译时会被编译器按照宏定义语句自动替换成for(int i1=1;i1<=3;i1++)
,就像字符串替换一样,其它使用rep的语句类似。
需要注意的是,在编译处理时,编译器只是简单的将宏调用语句按宏定义规则执行字符串替换。例如定义了一个宏define muilt(a,b) a*b
,使用语句muilt(c+d,e)
,会被替换成c+d*e
,并不是想要的(c+d)*e
。解决方法就是在宏定义时勤加括号,例如这里使用define muilt(a,b) (a)*(b)
,就能有效避免宏替换时带来的运算优先级问题。