[算法竞赛入门]第七章_暴力求解法
第7章 暴力求解法【教学内容相关章节】
7.1简单枚举 7.2枚举排列 7.3子集生成 7.4回溯法 7.5隐式图搜索
【教学目标】
(1)掌握整数、子串等简单对象的枚举方法;
(2)熟练掌握排列生成的递归方法;
(3)熟练掌握用“下一个排列”枚举全排列的方法;
(4)理解解答树,并能估算典型解答树的结点数;
(5)熟练掌握子集生成的增量法、位向量法和二进制法;
(6)熟练掌握回溯法框架,并能理解为什么它往往比生成-测试法高效;
(7)熟练掌握解答树的宽度优先搜索和迭代加深搜索;
(8)理解倒水问题的状态图与八皇后问题的解答树的本质区别;
(9)熟练掌握八数码问题的BFS实现;
(10)熟练掌握集合的两种典型实现——hash表和STL集合。
【教学要求】
掌握整数、子串等简单对象的枚举方法;熟练掌握排列生成的递归方法;熟练掌握用“下一个排列”枚举全排列的方法;理解子集树和排列树;熟练掌握回溯法框架;熟练掌握解答树的宽度优先搜索和迭代搜索;熟练掌握集合的两种典型实现——hash表和STL集合。
【教学内容提要】
本章主要讨论暴力法(也叫穷举法、蛮力法),它要求调设计者找出所有可能的方法,然后选择其中的一种方法,若该方法不可行则试探下一种可能的方法。介绍了排列生成的递归方法;在求解的过程中,提出了解答树的概念(如子集树和排列树);介绍了回溯法的基本框架;介绍了集合的两种典型实现——hash表和STL集合。
【教学重点、难点】
教学重点:
(1)熟练掌握排列生成的递归方法;
(2)理解解答树,并能估算典型解答树的结点数;
(3)熟练掌握子集生成的增量法、位向量法和二进制法;
(4)熟练掌握回溯法框架,并能理解为什么它往往比生成-测试法高效;
(5)熟练掌握解答树的宽度优先搜索和迭代搜索;
(6)熟练掌握集合的两种典型实现——hash表和STL集合。
教学难点:
(1)熟练掌握子集生成的增量法、位向量法和二进制法;
(2)熟练掌握回溯法框架,并能理解为什么它往往比生成-测试法高效;
(3)熟练掌握解答树的宽度优先搜索和迭代搜索;
(4)熟练掌握集合的两种典型实现——hash表和STL集合。
【课时安排】
7.1简单枚举 7.2枚举排列 7.3子集生成 7.4回溯法 7.5隐式图搜索
引 言
暴力法也称为穷举法、蛮力法,它要求调设计者找出所有可能的方法,然后选择其中的一种方法,若该方法不可行则试探下一种可能的方法。
暴力法也是一种直接解决问题的方法,常常直接基于问题的描述和所涉及的概念定义。
暴力法不是一个最好的算法,但当我们想不出更好的办法时,它也是一种有效的解决问题的方法。
暴力法的优点是逻辑清晰,编写程序简洁。在程序设计竞赛时,时间紧张,相对于高效的、巧妙的算法,暴力法编写的程序简单,能更快地解决问题。同时蛮力法也是很多算法的基础,可以在蛮力法的基础上加以优化,得到更高效的算法。
而且,某些情况下,算法规模不大,使用优化的算法没有必要,而且某些优化算法本身较复杂,在规模不在时可能因为复杂的算法浪费时间,反而不如简单的暴力搜索。
使用暴力法常用如下几种情况:
(1)搜索所有的解空间;
(2)搜索所有的路径;
(3)直接计算;
(4)模拟和仿真。
7.1 简 单 枚 举
在枚举复杂对象之前,先尝试着枚举一些相对简单的东西,如整数、子串等。暴力枚举对问题进行一定的分析往往会让算法更加简洁、高效。
7.1.1 除法
输入正整数n,按从小到大的顺序输出所有形如abcde/fghij=n的表达式,其中a~j恰好为数字0~9的一个排列,2≤n≤79。
样例输入:
62
样例输出:
79546/01283=62
94736/01528=62
【分析】
只需要枚举fghij就可以计算出abcde,然后判断是否所有数字都不相同即可。不仅程序简单,而枚举量也从10!=3628800降低至不到1万。由此可见,即使采用暴力枚举,也是需要认真分析问题。
完整的程序如下:
#include <iostream>
using namespace std;
bool test(int i,int j){ //用数组t存放i,j的各位数字
int t[10]={0}; //初始化数组t,使得各位数字为0,好处是使得fghij<10000时f位置为0
int ia = 0;
while(i) { //取i中各位数字存放在数组t中
t[ia++] = i % 10;
i = i / 10;
}
while(j) { //取j中各位数字存放在数组t中
t[ia++] = j % 10;
j = j / 10;
}
//判断a~j是否恰好为数字的0~9的一个排列
for(int m = 0; m < 10; ++m)
for(int n = m+1; n < 10; ++n)
if(t[n] == t[m]) return false;
return true;
}
int main(){
int n;
int k;
while(cin >> n && n >=2 && n <= 79) {
k = 1234;
while(k <= 98765) {
int j = k * n;
if(j < 100000) { //若fghij<10000,满足题目的条件,f位置输出0
if(test(j,k)) {
cout << j << "/" ;
if(k < 10000) cout <<"0";
cout << k << "=" << n <<endl;
}
}
++k;
}
}
return 0;
}
7.1.2 最大乘积
输入n个元素组成的序列S,你需要找出一个乘积最大的连续子序列。如果这个最大的乘积不是正整,应输出-1(表示无解)。1≤n≤18,-10≤Si≤10。
样例输入:
3
2 4 -3
5
2 5 -1 2 -1
样例输出:
8
20
【分析】
连续子序列有两个要素:起点和终点,因此只需要枚举起点和终点即可。由于每个元素的绝对值不超过10,一共又不超过18个元素,最大可能的乘积示会超过1018,可以用long long存下。
完整的程序如下:
#include <stdio.h>
#include <tiff.h>
int main(){
int a[20]; //存放输入序列的每一个元素
int64 ans = 0; //存放最大的乘积
int n; //输入元素的个数
int64 tmp; //存放乘积的中间结果
while(scanf("%d",&n)!=EOF){ //输入序列中元素的个数n
for(int i = 0;i < n; i++) //输入序列中的元素
scanf("%d",&a[i]);
for(int i = 0; i < n; i++) {
//从序列中的第0个元素开始,枚举最大连续乘积,并用ans存储
tmp = 1;
for(int j = i; j < n; j++) {
tmp *= a[j];
if(tmp > ans) ans = tmp;
}
}
if(ans>0)
printf("%I64d\n",ans);
else
printf("%d\n",-1);
}
return 0;
}
7.1.3 分数拆分
7.1.3 分数拆分
输入正整数k,找到所有的正整数x≥y,使得
。
样例输入:
2
12
样例输出:
2
1/2=1/6+1/3
1/2=1/4+1/4
8
1/12=1/156+1/13
1/12=1/84+1/14
1/12=1/60+1/15
1/12=1/48+1/16
1/12=1/36+1/18
1/12=1/30+1/20
1/12=1/28+1/21
1/12=1/24+1/24
【分析】
找出所有有x,y,枚举完成了就行了。但是从1/12=1/156+1/13可以看出,x可以比y大很多。由于x≥y,有,因此,即y≤2k。这样,只需要在2k范围内枚举y,然后根据y尝试计算出x即可。
完整的程序如下:
#include <stdio.h>
int main(){
int k;
int x, y, count = 0;//变量count统计等式的个数
while(scanf("%d", &k) != EOF) {
for(y = k+1;y <= 2 * k; y++){ //判断1/k=1/x+1/y等式的个数,
x=(k * y) / (y - k);
if(x * (y-k) == k * y){
count++;
}
}
printf("%d\n",count); //输出满足条件的等式的个数
for(y = k+1; y <= 2 * k; y++){ //输出满足条件的等式
x=(k * y) / (y - k);
if(x * (y - k) == k * y){
printf("1/%d=1/%d+1/%d\n",k,x,y);
}
}
}
return 0;
}
7.1.4 双基回文数
如果一个正整数n至少有两个不同的进位制b1和b2下都是回文数(2≤b1,b2≤10),则称n是双基回文数(注意,回文数不以包含前导零)。输入正整数S<106,输出比S大的最小双基回文数。
样例输入:1600000
样例输出:1632995
【分析】
最自然的想法是:从n+1开始依次判断每个数是否为双基回文数,而在判断时要枚举所有可能的基数(2~10)。意外的是:对于S<106的“小规模数据”来说是足够快的——双基回文数太多太密。
完整的程序如下:
#include <iostream>
using namespace std;
bool huiwen(int n){
int i,j,a[30],s;
int total = 0; //存放数s是回文数的基数个数
for(int base = 2; base <= 10; base++) {
i = 1;
s = n;
while(s) {
a[i] = s % base;
s = s / base;
i++;
}
i--;
for(j = 1; j <= i/2; j++) //判断数s在基base下是否是回文数
if(a[j] != a[i-j+1]) break;
if(j > i/2) total++; //数s在基base下是回文数,则total++
if(total >= 2) return true;
}
return false;
}
int main(){
int s;
while(scanf("%d",&s) == 1) {
for(s = s+1; ; s++) {
if(huiwen(s)) {
cout << s << endl; break;
}
}
}
return 0;
}
7.2 枚 举 排 列
输入整数n,按字典序从大到小的顺序输出前n个数的所有排列。两个序列的字典序大小关系等价于从头开始第一个不相同位置处的大小关系。例如,(1,3,2)<(2,1,3),字典序最小的排列是(1,2,3,4,…,n),最大的排列是(n,n-1,n-2,…,1)。n=3时,所有排列的排序结果是:(1,2,3)、(1,3,2)、(2,1,3)、(2,3,1)、(3,1,2)、(3,2,1)
7.2.1 生成1~n的排列
对此问题用递归的思想解决:先输出所有以1开头的排列(递归调用),然后输出以2开头的排列(递归调用),接着以3开头的排列,…,最后才是以n开头的排列。
以1开头的排列的特点是:第一位是1,后面是按字典序的2~9的排列。所以在设计递归函数需要以下参数:
(1)已经确定的“前缀”序列,以便输出;
(2)需要进行全排列的元素集合,以便依次选做第一个元素。
这样,写出以下的伪代码:
void print_permutation(序列A,集合S) {
if(S为空) 输出序列A;
else 按照从小到大顺序依次考虑S的每个元素v {
print_permutation(在A的末尾填加v后得到的新序列,S-{v});
}
}
上面的递归函数中递归边界是S为空的情形,可以直接输出序列A;若S不为空,则按从小到大的顺序考虑S中的每个元素,每次递归调用以A开头。
下面考虑程序的实现。用数组表示序列A,集合S可以由序列A完全确定——A中没有出现的元素都可以选。C语言中的函数在接受数组参数时无法得到数组的元素个数,所以需要传一个已经填好的位置个数,或者当前需要确定的元素位置cur。声明一个足够大的数组A,然后调用print_permutation(n,A,0),即可按字典序输出1~n的所有排列。
完整的程序如下:
#include<stdio.h>
int A[100];
// 输出1~n的全排列的递归函数
void print_permutation(int n, int* A, int cur) {
int i, j;
if(cur == n) { // 递归边界
for(i = 0; i < n; i++) printf("%d ", A[i]);
printf("\n");
}
else for(i = 1; i <= n; i++) { // 尝试在A[cur]中填各种整数i
int ok = 1;
for(j = 0; j < cur; j++)
if(A[j] == i) ok = 0; // 如果i已经在A[0]~A[cur-1]出现过,则不能再选
if(ok) {
A[cur] = i;
print_permutation(n, A, cur+1); // 递归调用
}
}
}
int main() {
print_permutation(4, A, 0);
return 0;
}
在递归函数print_permutation中循环变量i是当前考虑的A[cur]。为了检查元素i是否已经用过,还用到了一个标志变量ok,初始值为1(真),如果发现有某个A[j]==i时,则改为0(假)。如果最终ok仍为1,则说明i没有在序列中出现过,把它添加到序列末尾(A[cur]=i)后递归调用。
7.2.2 生成可重集的排列
如果把问题改成:输入数组A,并按字典序输出数组A各元素的所有全排列,则需要对上述递归函数print_permutation修改——把P加到print_permutation的参数列表中,然后把代码中的if(A[j]i)和A[cur]=i分别改成if(A[j]P[i])和A[cur]=P[i]。只有把P的所有元素按从小到大的顺序排序,然后调用print_permutation(n,P,A,0)即可。
但是上述递归函数print_permutation中,禁止A数组中出现重复,而在P中可能就有重复元素时,所以输出数组A时就会出现问题。
解决方法是统计A[0]~A[cur-1]中P[i]的出现次数c1,以及P数组中P[i]的出现次数c2。只要c1<c2,就能递归调用。
枚举的下标i应不重复、不遗漏地取遍所有P[i]值。由于P数组已经排过序,所以只需检查P的第一个元素和所有“与前一个元素不相同”的元素,即只需在for(i=0;i<n;i++)和其后的花括号之前加上if(!i||P[i]!=P[i-1])即可。
完整的程序如下:
#include<stdio.h>
int P[100], A[100];
// 输出数组P中元素的全排列。数组P中可能有重复元素
void print_permutation(int n, int* P, int* A, int cur) {
int i, j;
if(cur == n) {
for(i = 0; i < n; i++) printf("%d ", A[i]);
printf("\n");
}
else for(i = 0; i < n; i++)
if(!i || P[i] != P[i-1]) {
int c1 = 0, c2 = 0;
for(j = 0; j < cur; j++) if(A[j] == P[i]) c1++;
for(j = 0; j < n; j++) if(P[i] == P[j]) c2++;
if(c1 < c2) {
A[cur] = P[i];
print_permutation(n, P, A, cur+1);
}
}
}
int main() {
int i, n;
scanf("%d", &n);
for(i = 0; i < n; i++)
scanf("%d", &P[i]);
print_permutation(n, P, A, 0);
return 0;
}
7.2.3 解答树
假设n=4,序列为{1,2,3,4},如图7-1所示的树显示出了递归函数的调用过程。其中,结点内部的序列表示为A,位置cur用高亮表示,另外,由于从该处开始的元素和算法无关,因此用星号表示。
图7-1 排列生成算法的解答树
从图7-1可以看出,它是一棵二叉树。第0层(根)结点有n个儿子,第1层结点各有n-1个儿子,第2层结点各有n-2个儿子,第3层结点各n-3个儿子,…,第n层结点都没有儿子(即都是叶子),而每个叶子对应于一个排列,共有n!个叶子。由于这棵树展示的是逐步生成完整解的过程,因此将其称为解答树。
提示7-1:如果某问题的解可以由多个步骤得到,而每个步骤都有若干种选择(这些候选方案集可能会依赖于先前作出的选择),且可以用递归枚举法实现,则它的工作方式可以用解答树描述。
通过逐层查看,第0层有1个结点,第1层有n个结点,第2层有n(n-1)个结点,第3层有n(n-1)(n-2)个结点,…,第n层有n(n-1)(n-2)…21=n!个。
为了推导方便,把n(n-1)(n-2)…(n-k)写成n!/(n-k-1)!,则所有结点之和为:
根据高等数学中的泰勒展开公式,,因此T(n)<n!·e=O(n!)。由于叶子有n!个,倒数第二层也有n!个结点,因此上面的各层全部来源于最后一两层,所以上面的结点数可以忽略不计。
7.2.4 下一个排列
枚举所有排列的另一个方法是从字典序最小排列开始,不停调用“求下一个排列”的过程,在C++的STL中提供了一个库函数next_permutation。
完整的程序如下:
#include <cstdio>
#include <algorithm>
using namespace std;
int main() {
int n, p[10];
scanf("%d", &n);
for(int i = 0; i < n; i++) scanf("%d", &p[i]);
sort(p, p+n); // 排序,得到p的最小排列
do {
for(int i = 0; i < n; i++) printf("%d ", p[i]); // 输出排列p
printf("\n");
} while(next_permutation(p, p+n)); // 求下一个排列
return 0;
}
说明:(1)STL里面有个sort函数,可以直接对数组进行随机化快速排序,复杂度为n*log2n,效率高。使用这个函数,需要包含头文件:
#include <algorithm>
using namespace std;
它的函数原型如下:
①template
这个sort函数可以传两个参数。第一个参数是要排序的区间首地址,第二个参数是区间尾地址的下一地址。也就是说,排序的区间是[a,b)。简单来说,有一个数组int a[100],要对从a[0]到a[99]的元素进行排序,只要写sort(a,a+100)就行了,默认的排序方式是升序。
如果需要对数组t的第0到len-1的元素排序,就写sort(t,t+len);,对向量v(定义vector
②template <class RandomAccessIterator, class StrictWeakOrdering>
void sort(RandomAccessIterator first, RandomAccessIterator last, StrictWeakOrdering comp);
这个sort函数可以传三个参数。第一个参数是要排序的区间首地址,第二个参数是区间尾地址的下一地址。也就是说,排序的区间是[a,b)。简单来说,有一个数组int a[100],要对从a[0]到a[99]的元素进行排序,只要写sort(a,a+100)就行了,默认的排序方式是升序。
如果是没有定义小于运算的数据类型,或者想改变排序的顺序,就要用到第三参数——比较函数。比较函数是一个自己定义的函数,返回值是bool型,它规定了什么样的关系才是“小于”。想把刚才的整数数组按降序排列,可以先定义一个比较函数cmp
bool cmp(int a,int b) {
return a>b;
}
排序的时候就写sort(a,a+100,cmp);。
(2)在C++的STL中定义的next_permutation和prev_permutation函数则是非常灵活且高效的方法,它被广泛的应用于为指定序列生成不同的排列。next_permutation和prev_permutation函数需要包含algorithm.h头文件。需要注意的是“如果要走遍所有的排列,必须先将元素排序”。
(3)按照STL文档的描述,next_permutation函数将按字母表顺序生成给定序列的下一个较大的排列,直到整个序列为降序为止。 prev_permutation函数与之相反,是生成给定序列的上一个较小的排列(前一个排列)。二者原理相同,仅遍历顺序相反。这两个函数据可以对整型数组或字符数组操作。
(4)next_permutation函数原型
①template
②template<class BidIt, class Pred> bool next_permutation(BidIt first, BidIt last, Compare comp);
next_permutation函数取由[first,last)标记的排列,并将其重新排序为下一个排列。如果不存在下一个排列,则返回false;否则,返回true。第一个版本(①)使用底层类型的小于操作符来确定下一个排列,第二个版本(②)根据comp对元素进行排序。如果原始字符串是排过序的,则连续调用next_permutation函数会生成整个排列集合。prev_permutation函数原型与next_permutation函数型类似。
7.3 子 集 生 成
本节介绍子集生成算法:给定一个集合,枚举它所有可能的子集。为了简单起见,本节讨论的集合中没有重复元素。
7.3.1 增量构造法
第一种思路是一次选出一个元素放到集合中,完整程序如下:
#include <stdio.h>
void print_subset(int n, int* A, int cur) {
int i;
for(i = 0; i < cur; i++) printf("%d ", A[i]); // 打印当前集合
printf("\n");
int s = cur ? A[cur-1]+1 : 0; // 确定当前元素的最小可能值
for(i = s; i < n; i++) {
A[cur] = i;
print_subset(n, A, cur+1); // 递归构造子集
}
}
int A[10];
int main() {
print_subset(5, A, 0);
return 0;
}
注意:由于A中的元素个数不确定,每次递归调用都要输出当前集合。另外,递归边界也不需要显式确定——如果无法继续添加元素,自然不会再递归了。
上面的代码用到了定序的技巧:规定集合A中所有元素的编号从小到大排列,就不会把集合{1,2}按照{1,2}和{2,1}输出两次了。
这棵解答树上有1024个结点,由于每个可能的A都对应一个结点,而n元素集合恰好有2n个子集,210=1024。
7.3.2 位向量法
第二种思路是构造一个位向量B[i],而不是直接构造子集A本身,其中B[i]=1当且仅当i在子集A中。完整程序如下:
#include <stdio.h>
void print_subset(int n, int* B, int cur) {
if(cur == n) {
for(int i = 0; i < cur; i++)
if(B[i]) printf("%d ", i); // 打印当前集合
printf("\n");
return;
}
B[cur] = 1; // 选第cur个元素
print_subset(n, B, cur+1);
B[cur] = 0; // 不选第cur个元素
print_subset(n, B, cur+1);
}
int B[10];
int main() {
print_subset(5, B, 0);
return 0;
}
必须当“所有元素是否选择”全部确定完毕后才是一个完整的子集,因此当if(cur==n)成立时才输出。所有部分解(不完整的解)也对应着解答树上的结点。
这是一棵n+1层的二叉树(cur从0~n),第0层有1个结点,第1层有2个结点,第2层有4个结点,第3层有8个结点,…,第i层有2i个结点,总数为1+2+4+…+2n=2n+1-1。图7-2就是这棵解答树。最后几层结点数占整棵树的绝大多数。
图7-2 位向量法的解答树
7.3.3 二进制法
用二进制来表示{0,1,2,…,n-1}的子集S:从右往左第i位(各位从0开始编号)表示元素i是否在集合S中。用图7-3展示了二进制010001100011011是如何表示集合{0,1,2,4,
5,9,10,14}的。
图7-3 用二进制表示子集
注意:为了处理方便,最右边那个位总是对应元素0,而不是元素1。
提示7-2:可以用二进制表示子集,其中从右往左第i位(从0开始编号)表示元素i是否在集合中(1表示“在”,0表示“不在”)。
不仅要表示集合,还在对集合进行操作。对集合的运算都可以用位运算符简单实现。最常见的二元运算是与(&)、或(|)、非(!)、异或(∧)。
“异或(XOR)”运算符∧,它的规则是“如果A和B不相同,则A∧B为1,否则为0”。异或运算最重要的性质就是“开关性”——异或两次以后相当于没有异或,即A∧B∧B=A。
由于A&B、A|B、A∧B分别对应着集合的交、并和对称差。另外,空集为0,全集{0,1,2,…,
n-1}的二进制为n个1,即十进制的2n-1。在程序中把全集定义为ALL_BITS=(1<<n)-1,则A的补集为ALL_BITS∧A。也可以直接用整数减法ALL_BITS-A表示,但速度比位运算慢。
提示7-3:当用二进制表示子集时,位运算中的按位与、或、异或对应集合的交、并和对称差。
输出子集S对应的各个元素的完整程序如下:
#include <stdio.h>
void print_subset(int n, int s) { // 打印{0, 1, 2, ..., n-1}的子集S
for(int i = 0; i < n; i++)
if(s&(1<<i)) printf("%d ", i); // 这里利用了C语言“非0值都为真”的规定
printf("\n");
}
int main() {
int n = 5;
for(int i = 0; i < (1<<n); i++) // 枚举各子集所对应的编码 0, 1, 2, ..., 2n-1
print_subset(n, i);
return 0;
}
7.4 回 溯 法
无论是排列生成还是子集枚举,两种思路:递归构造和直接遍历。直接遍历的优点是思路和程序都很简单,缺点在于无法简便地减少枚举量——必须生成(generate)所有可能的解,然后一一检查(test)。
另一方面,在递归构造中,生成和检查过程可以有机结合起来,从而减少不必要的枚举,这就是本节的主题——回溯法(backtracking)。
回溯法是一种系统的搜索问题的解的方法。它的基本思想是:从一条路前行,能进则进,不能进则退回来,换一条路再试。回溯法是一种通用的解题方法。
应用回溯法的时候,首先明确定义问题的解空间。解空间至少应该包含问题的一个解。确定了解空间后,回溯法从开始结点出发,以深度优先的方法搜索整个解空间。
对于回溯法一般可以采用递归方式来实现。
7.4.1 八皇后问题
在棋盘上放置8个皇后,使得它们互不攻击,此时每个皇后的攻击范围为同行同列和对角线,要求找出所有解,如图7-4所示。
(a)皇后的攻击范围 (b)一个可行解
图7-4 八皇后问题
分析】
思路一:把问题转化为“从64个格子中选一个子集”,使得“子集中恰好有8个格子,且任意两个选出的格子都不在同一行、同一列或同一个对角线上”。这是子集枚举问题,不是一个好的模型。
思路二:把问题转化为“从64个格子中选8个格子”,这是组合生成问题。比思路一好,但是仍然不是很好。
思路三:由分析可得,恰好每行每列各放置一个皇后。如果用C[x]表示第x行皇后的列编号,则问题变成了全排列生成问题。
提示7-4:在编写递归枚举程序之前,需要深入分析问题,对模型精雕细琢。一般还应对解答树的结点数有一个粗略的估计,作为评价模型的重要依据,如图7-5的所示。
图7-5 在四皇后问题的解答树
图7-5中给出了四皇后问题的完整解答树。它只有18个结点,比4!=24小。原因是有些结点无法继续扩展。
在各种情况下,递归函数将不再递归调用本身,而是返回上一层调用,称这种现象为回溯(backtracking)。
提示7-5:当把问题分成若干步骤并递归求解时,如果当前步骤没有合法选择,则函数将返回上一级递归调用,这种现象称为回溯。正是因为这个原因,递归枚举算法常称为回溯法,它的应用十分普遍。
在主程序中读入n,并为tot清0,然后调用search(0),即可得到解的个数tot。当n=8,则tot=92,状态空间结点数nc=2057。完整的程序如下:
#include <stdio.h>
int C[50], tot = 0, n, nc = 0;
void search(int cur) {
int i, j;
nc++; //nc状态空间结点数
if(cur == n) tot++; //递归边界。只要走到了这里,所有皇后必然不冲突
else for(i = 0; i < n; i++) {
int ok = 1;
C[cur] = i; //尝试把cur行的皇后放在第i列
for(j = 0; j < cur; j++) //检查是否和前面的皇后冲突
if(C[cur] == C[j] || cur-C[cur] == j-C[j] || cur+C[cur] == j+C[j]) {
ok = 0;
break;
}
if(ok) search(cur+1); //如果合法,则继续递归
}
}
int main() {
scanf("%d",&n);
search(0);
printf("%d\n", tot);
printf("%d\n", nc);
return 0;
}
注意:既然是逐行放置的,则皇后肯定不会横向攻击,因此只需检查是否纵向和斜向攻击即可。条件cur-C[cur] == j-C[j] || cur+C[cur] == j+C[j]用来判断皇后(cur,C[cur])和(j,C[j])是否在同一条对角线上。其原理可以用图7-26说明。
(a)格子(x,y)的y-x值标识了主对角线 (b)格子(x,y)的x+y值标识了副对角线
图7-6 棋盘中的对角线标识
上面的程序还可改进:利用二维数组vist[2][]直接判断当前尝试的皇后所在的列和两个对角线是否已有其他皇后。注意到主对角线标识y-x可能为负,存取时要加上n。完整的程序如下:
Include <stdio.h>
int C[50], vis[3][50], tot = 0, n, nc = 0;
void search(int cur) {
int i, j;
nc++;
if(cur == n) tot++;
else for(i = 0; i < n; i++) {
if(!vis[0][i] && !vis[1][cur+i] && !vis[2][cur-i+n]) {
//利用二维数组直接判断
C[cur] = i; //如果不打印解,整个C数组都可以省略
vis[0][i] = vis[1][cur+i] = vis[2][cur-i+n] = 1; //修改全局变量
search(cur+1);
vis[0][i] = vis[1][cur+i] = vis[2][cur-i+n] = 0; //切记!一定要改回来
}
}
}
int main() {
scanf("%d",&n);
memset(vis, 0, sizeof(vis));
search(0);
printf("%d\n", tot);
printf("%d\n", nc);
return 0;
}
上面的程序关键的地方是vis数组的使用。vis数组表示已经放置的皇后占据了哪些列、主对角线和副对角线。将来放置的皇后不应该修改这些值。一般地,如果要回溯法中修改了辅助的全局变量,则一定要及进把它们恢复原状(除非故意保留修改)。另外,千万不要忘记在调试之前把vis数组清空。
提示7-6:如果在回溯法中使用了辅助的全局变量,则一定要及时把它们恢复原状。例如,若函数有多个出口,则需在每个出口处恢复被修改的值。
7.4.2 素数环
输入正整数n,把整数1,2,3,…,n组成一个环,使得相邻两个整数之和均为素数。输出时从整数1开始逆时针排列。同一个环应恰好输出一次。n≤16。
样例输入:
6
样例输出:
1 4 3 2 5 6
1 6 5 2 3 4
【分析】
素数环问题的程序实际上主要由求素数和整数1,2,3,…,n的排列构成。生成-测试法的完整程序如下:
#include <cstdio>
#include <algorithm>
using namespace std;
int is_prime(int x) { //判数一个整数x是否是一个素数
for(int i = 2; i*i <= x; i++)
if(x % i == 0) return 0;
return 1;
}
int main() {
int n, A[50], isp[50];
scanf("%d", &n);
for(int i = 2; i <= n*2; i++) //生成素数表,加快后继判断
isp[i] = is_prime(i);
for(int i = 0; i < n; i++) A[i] = i+1; //第一个排列
do {
int ok = 1;
for(int i = 0; i < n; i++) if(!isp[A[i]+A[(i+1)%n]]) { //判断合法性
ok = 0;
break;
}
if(ok) {
for(int i = 0; i < n; i++) printf("%d ", A[i]); //输出序列
printf("\n");
}
}while(next_permutation(A+1, A+n));
return 0;
}
运行后,发现当n=12时就已经很慢了,而当n=16根本出不来。下面采取回溯法来解决此问题,完整的回溯法的程序如下:
#include <cstdio>
#include <algorithm>
#include <cstring>
using namespace std;
int is_prime(int x) { //判数一个整数x是否是一个素数
for(int i = 2; i*i <= x; i++)
if(x % i == 0) return 0;
return 1;
}
int n, A[50], isp[50], vis[50];
void dfs(int cur) {
if(cur == n && isp[A[0]+A[n-1]]) {//递归边界,别忘测试第一个数和最后一个数
for(int i = 0; i < n; i++) printf("%d ", A[i]);
printf("\n");
}
else for(int i = 2; i <= n; i++) //尝试放置每个数i
if(!vis[i] && isp[i+A[cur-1]]) {
//如果i没有用过,并且与前一个数之和为素数
A[cur] = i;
vis[i] = 1; //设置标志
dfs(cur+1);
vis[i] = 0; //清除标志
}
}
int main() {
scanf("%d", &n);
for(int i = 2; i <= n*2; i++) isp[i] = is_prime(i);
memset(vis, 0, sizeof(vis));
A[0] = 1;
dfs(1);
return 0;
}
测试后,回溯法比生成-测试法要快很多。回溯法是按照深度优先的顺序在遍历解答树。
7.4.3 困难串
如果一个字符串包含两个相邻的重复子串,则称它是“容易的串”,其它的串称为“困难的串”。
输入正整数n和L,输出由前L个字符组成的、字典序第k小的困难的串。例如,当L=3时,前7个困难的串分别为:A、AB、ABA、ABAC、ABACA、ABACAB、ABACABA。输入保证字符答案不超过80个字符。
样例输入:
7 3
30 3
样例输出:
ABACABA
ABACABCACBABCABACABCACBACABA
【分析】
从左到右依次考虑每个位置上的字符。因此,主要问题是如何判断当前字符串是否已存在连续的重复子串。所在只需判断当前串的后缀,而非所有子串。
完整的程序如下:
#include<stdio.h>
int n, L, cnt = 0;
int S[100];
int dfs(int cur) { // 返回0表示已经得到解,无须继续搜索
if(cnt++ == n) {
for(int i = 0; i < cur; i++) printf("%c", 'A'+S[i]); // 输出方案
printf("\n");
return 0;
}
for(int i = 0; i < L; i++) {
S[cur] = i;
int ok = 1;
for(int j = 1; j*2 <= cur+1; j++) { // 尝试长度为j*2的后缀
int equal = 1;
for(int k = 0; k < j; k++) // 检查后一半是否等于前一半
if(S[cur-k] != S[cur-k-j]) { equal = 0; break; }
if(equal) { ok = 0; break; } // 后一半等于前一半,方案不合法
}
if(ok) if(!dfs(cur+1)) return 0; // 递归搜索。如果已经找到解,则直接退出
}
return 1;
}
int main() {
scanf("%d%d", &n, &L);
dfs(0);
return 0;
}
7.4.4 带宽
给出一个n个结点的图G和一个结点的排列,定义结点i的带宽b(i)为i和相邻结点在排列中的最远距离,而所有b(i)的最大值就是整个图的带宽。给定图G,求出让带宽最小的结点排列。
【分析】
可以记录下目前已经找到的最小带宽k。如果发现已经有某两个结点的距离大于或等于k,再怎么扩展也不可能比当前解更优,应当强制把它“剪掉”,即为解答树“剪枝(prune)”。
除此之外,还可以剪掉更多的枝叶。如果在搜索到结点u时,u结点还有m个相邻接点没有确定位置,那么对于结点u来说,最理想的情况就是这m个结点紧跟在u后面,这样的结点带宽为m,而其他任何“非理想情况”的带宽至少为m+1。这样,如果m≥k,即“最理想的情况下都不能得到比当前最优解更好的方案”,则显然应当剪枝。
7.5 隐式图搜索
对很多问题可以用图的遍历算法解决,但这些问题的图却不是事先给定、从程序读入的,而是由程序动态生成的。
7.5.1 隐式树的遍历
到目前为止,本章中的解答树都有是树,有着严格的“层次”概念——从根往下走,你永远都无法回到根结点。
回溯法是按照深度优先顺序遍历的,它的优点是空间很节省:只有递归栈中结点需要保存。换句话说,回溯法的空间开销和访问的最深结点的深度成正比。
树不仅可以深度优先遍历,还可以宽度优先遍历。好处是找到的第一个解一定是离根最近的解,但空间开销却大增加(队列中结点很多)。
例7-1 最优程序。
有一台只有一个数据栈的计算机,支持整数的5种运算:ADD、SUB、MUL、DIV、DUP。假设栈顶的3个元素分别为a、b、c,则5种运算的效果依次如下:
ADD:a和b依次出栈,计算a+b,把结果压栈。
SUB:a和b依次出栈,计算b-a,把结果压栈。
MUL:a和b依次出栈,计算a×b,把结果压栈。
DIV:a和b依次出栈,计算b/a并向下取整,把结果压栈。
DUP:a出栈,再连续把两个a压栈。
遇到以下情况之一,机器会产生错误:执行DIV操作时,栈顶元素为0;执行ADD、SUB、MUL、DIV操作时,栈中只有一个元素;运算结果的绝对值大于30000。
给出n个整数对(ai,bi)(ai互不相同),你的任务是设计一个最短的程序,使得对于1和n之间的所有i,如果程序执行前栈中只有一个元素ai,则执行后栈顶元素是bi。n≤5。
【分析】
本题没有办法用回溯法来解决。本题的解答树是无穷大的——多长的程序都是合法的。题目要求最短的程序,最简单可行的方法是利用宽度优先遍历解答树。
例7-2 埃及分数。
在古埃及,人们使用单位分数的和(如1/a,a是自然数)表示一切有理数。例如,2/3=1/2+1/6,但不允许2/3=1/3+1/3,因为在加数中不允许有相同的。
对于一个分数a/b,表示方法有很多种,其中加数少的比加数多的好,如果加数个数相同,则最小的分数越大越好。例如,19/45=1/5+1/6+1/18是最优方案。
输入整数a,b(0<a<b<1000),试编程计算最佳表达式。
【分析】
本题的解决方案是采用迭代加深搜索(iterative deepening):从小到大枚举深度上限d,每次只考虑不超过d的结点。这样,只要解的深度有限,则一定可以在有限时间内枚举得到。
深度上限还可以用来剪枝。按照分母递增的顺序来进行扩展,如果扩展到i层时,前i个分数之和为c/d,而第i个分数为1/e,则接下来至少还需要(a/b-c/d)/(1/e)个分数,总和才能达到a/b。使用上述算法,题目规模中的任意数据都能很快解出。
7.5.2 一般隐式图的遍历
有很多问题难以建立严格层次的解答树,于是就只能把状态之间的关系理解成一般意义下的图。
例7-3 倒水问题。
有装满水的6升的杯子、空的3升杯子和1升杯子,3个杯子中都没有刻度。在不使用其他道具的情况下,是否可以量出4升的水呢?
方法如图7-7所示。
图7-7 倒水问题:一种方法是(6,0,0)→(3,3,0)→(3,2,1)→(4,2,0)
注意:由于没有刻度,用杯子x给杯子y倒水时必须一直持续到把杯子y倒满或者把杯子x倒空,而不能中途停止。
你的任务是解决一般性的问题:设大、中、小3个杯子的容量分别为a,b,c,最初只有大杯子装满水,其他两个杯子为空。最少需要多少步才能让某一个杯子中的水有x升呢?你
需要打印出每步操作后各个杯子中的水量(0<c<b<a<1000)。
【分析】
假设在某一时刻,大杯子中有v0升水,中杯子有v1升水,小杯子有v2升水,称当时的系统状态为(v0,v1,v2)。把“状态”想象成图中的结点,可以得到如图7-8所示的状态图(stata graph)。
图7-8 倒水问题状态图
注意图7-8所示的状态图和迷宫是不同的。在迷宫中,只在可以成功往上走,走完后往下一定会回到刚才的地方,但此图并不相同:从状态(4,2,0)可以通过把水从中杯子倒入大杯子而一步到达(6,0,0),但是从(6,0,0)却无法一步到达(4,2,0)。换句话说,迷宫图是无向图(undirected graph),而本题“倒水状态图”是有向图(directed graph)
由于无论如何倒,杯子中的水量都是整数(按照倒水次数归纳即可),因此水杯的小量最多只有0,1,2,…,c共c+1种可能;同理,中杯子的水量一共只有b+1种可能,大杯子一共只有a+1种可能,因此理论上状态最多有(a+1)(b+1)(c+1)种可能性。但是由于水量x永远不变,如果有两个状态的小杯子和中杯小量都相同,则大杯子小量相同。换句话说,最多可能的状态数不会超过(b+1)(c+1),因此结点数不超过106。
完整的程序如下:
#include <stdio.h>
#include <string.h>
#define MAXN 105
int cap[3], x;
int vis[MAXN][MAXN];
struct Node {
int v[3];
int fa, dist, last_op;
};
Node q[MAXN*MAXN];
void print_path(int idx) {
if(q[idx].fa != idx)
print_path(q[idx].fa);
printf("%d %d %d\n", q[idx].v[0], q[idx].v[1], q[idx].v[2]);
}
void bfs() {
int front=0, rear=1, i, j, k;
q[0].v[0] = cap[0];
q[0].v[1] = q[0].v[2] = q[0].dist = q[0].fa = 0;
vis[0][0] = 1;
while(front<rear) {
Node& u = q[front];
if(u.v[0] == x || u.v[1] == x || u.v[2] == x) {
printf("%d\n", u.dist);
print_path(front);
return;
}
for(i = 0; i < 3; i++)
for(j = 0; j < 3; j++) if(i!=j) {
Node& v = q[rear];
int amount = u.v[i]+u.v[j]<cap[j]?u.v[i]: (cap[j]-u.v[j]); for(k = 0; k < 3; k++) v.v[k] = u.v[k];
v.v[i] -= amount;
v.v[j] += amount;
if(!vis[v.v[1]][v.v[2]]) {
vis[v.v[1]][v.v[2]] = 1;
v.fa = front;
v.dist = q[front].dist+1;
v.last_op = i*10+j;
rear++;
}
}
front++;
}
}
int main() {
int i, j;
scanf("%d%d%d%d", &cap[0], &cap[1], &cap[2], &x);
memset(vis, 0, sizeof(vis));
bfs();
return 0;
}
7.5.3 八数码问题
编号为1~8的8个正方形滑块摆成3行3列(有一个格式留空),如图7-9所示。每次可以把与空格相邻的滑块(有公共边才算相邻)移动到空格中,而它原来的位置就成为了新的空格。给定初始局面和目标局面(用0表示空格),你任务是计算出最少的移动步数。如果无法到达目标局面,则输出-1。
图7-9 八数码问题举例
样例输入:
2 6 4 1 3 7 0 5 8
8 1 5 7 3 6 4 0 2
样例输出:
31
【分析】
把八数码问题归结为图上的最短路问题,其中每个状态就是9个格式中的滑块编号(从上到下、从左到右地把它们放在一个包含9个元素的数组中)。具体程序如下:
#include <cstring>
typedef int State[9]; //定义“状态”类型
const int MAXSTATE = 1000000;
State st[MAXSTATE], goal; //状态数组。所有状态都保存在这里
int dist[MAXSTATE]; //距离数组
//如果需要打印方案,可以在这里加一个“父亲编号”数组int fa[MAXSTATE]
const int dx[] = {-1, 1, 0, 0};
const int dy[] = {0, 0, -1, 1};
//BFS,返回目标状态在st数组下标
int bfs() {
init_lookup_table(); //初始化查找表
int front = 1, rear = 2; //不使用下标0,因为0被看作“不存在”
while(front < rear) {
State& s = st[front]; //用“引用”简化代码
if(memcmp(goal, s, sizeof(s)) == 0) //找到目标状态,成功返回
return front;
int z;
for(z = 0; z < 9; z++) if(!s[z]) break; //找“0”的位置
int x = z/3, y = z%3; //获取行列位置(0~2)
for(int d = 0; d < 4; d++) {
int newx = x + dx[d];
int newy = y + dy[d];
int newz = newx * 3 + newy;
if(newx >= 0 && newx < 3 && newy >= 0 && newy < 3) { //如果移动合法
State& t = st[rear];
memcpy(&t, &s, sizeof(s)); //扩展新结点
t[newz] = s[z];
t[z] = s[newz];
dist[rear] = dist[front] + 1; //更新新结点的距离值
if(try_to_insert(rear)) rear++; //如果成功插入查找表,修改队尾指针
}
}
front++; //扩展完毕后再修改队首指针
}
return 0; //失败
}
注意,此处用到了string.h中的memcmp和memcpy完成整块内存的比较和复制,比用循环比较和循环赋值要快。主程序很容易实现:
int main() {
for(int i = 0; i < 9; i++) //起始状态
scanf("%d", &st[1][i]);
for(int i = 0; i < 9; i++) //目标状态
scanf("%d", &goal[i]);
int ans = bfs(); //返回目标状态的下标
if(ans > 0) printf("%d\n", dist[ans]);
else printf("-1\n");
return 0;
}
注意,在调用bfs函数之前设置好st[1]和goal。对于上面的程序还有init_lookup_tab
le()和try_to_insert(rear)没有实现,这两个函数的功能是初始化查找表和插入元素。在查找表中,避免将同一个结点访问多次,所以要判重复,如果不判重复,时间和空间将产生极大的浪费。这两个函数将会在下节讲到。
7.5.4 结点查找表
下面通过3种方法来解决空间浪费的问题,同时将它们用到八数码问题中。
(1)方法一
把排列“变成”整数,然后只开一个一维数组。即设计一套排列的编码(encoding)和解码(decoding)函数,把0~8的全排列和0~362879的整数一一对应起来。
int vis[36288], fact[9];
void init_lookup_table() {
fact[0] = 1;
for(int i = 1; i < 9; i++) fact[i] = fact[i-1] * i;
}
int try_to_insert(int s) {
int code = 0; //把st[s]映射到整数code
for(int i = 0; i < 9; i++) {
int cnt = 0;
for(int j = i+1; j < 9; j++)
if(st[s][j] < st[s][i]) cnt++;
code += fact[8-i] * cnt;
}
if(vis[code]) return 0;
return vis[code] = 1;
}
说明:上面尽管原理巧妙,时间效率也非常高,但编码解码法的适用范围不大:如果隐式图的总结数非常大,编码也将会很大,数组还是存不下。
(2)方法二
使用哈希(hash)技术。只需要设计一个所谓的哈希数h(x),然后将任意结点x映射到某个给定范围[0,M-1]的整数即可,其中M是程序员根据可用内存大小自选的。在理想情况下,只需开一个大小为M的数组就能完成判重,但此时往往会有不同结点的哈希值相同,因此需要把哈希值相同的状态组织成链表。
const int MAXHASHSIZE = 1000003;
int head[MAXHASHSIZE], next[MAXSTATE];
void init_lookup_table() { memset(head, 0, sizeof(head)); }
int hash(State& s) {
int v = 0;
for(int i = 0; i < 9; i++) //随便算。例如,把9个数字组合成9位数
v = v * 10 + s[i];
return v % MAXHASHSIZE; //确保hash函数值不超过hash表的大小的非负整数
}
int try_to_insert(int s) {
int h = hash(st[s]);
int u = head[h]; //从表头开始查找链表
while(u) {
if(memcmp(st[u], st[s], sizeof(st[s])) == 0) //找到了,插入失败
return 0;
u = next[u]; //顺着链表继续找
}
next[s] = head[h]; //插入到链表中
head[h] = s;
return 1;
}
哈希表的执行效率高,适用范围广,还可以用到其它需要快速查找的地方。在哈希表中,对效率起到关键作用的是哈希函数。
3)方法三
用STL的集合。set
vis.count(s))来判断s是否在集合vis中,并用vis.insert(s)把s加入集合,用vis.remove
(s)从集合中移除s。STL要求set地元素类型必须定义“<”运算符,如int、string或前面自定义的bign,但C语言原生的数组(包含字符数组)却不行。下面是一种使用int的方法:
set<int> vis;
void init_lookup_table() { vis.clear(); }
int try_to_insert(int s) {
int v = 0;
for(int i = 0; i < 9; i++) v = v * 10 + st[s][i];
if(vis.count(v)) return 0;
vis.insert(v);
return 1;
}
有很多情况下,数组是没有办法简单转化成整数的,自已声明一个结构体,并重载“括号”来比较两个状态。在下面的程序中,整数a和b分别是两个状态在状态数组st中的下标,在比较时直接使用memcpy来比较整个内存块。
struct cmp{
bool operator()(int a, int b) const{ //重新定义a<b的含义
return memcmp(&st[a], &st[b], sizeof(st[b])) < 0;
}
};
set<int, cmp> vis; //使用新的“a<b”来定义“整数集合”
void init_lookup_table() { vis.clear(); }
int try_to_insert(int s) {
if(vis.count(s)) return 0;
vis.insert(s);
return 1;
}
说明:调用memcmp比直接比较两个整数要慢得多。使用STL集合的代码最简单,但时间效率也最低。所以建议在时间紧迫或对效率要求不太高的情况下使用,或者仅仅把它作为“跳板”——先写一个STL版的程序,确保主算法正确,然后把set替换成自己写的哈希表。
本 章 小 结
本章介绍了在解决实际问题中的一种方法是暴力法,就是把问题中所有可能性一一列举出来。但是对于有些问题的解空间非常大,要是只用暴力法在有限的时间求出解,不可能的。所以与剪枝函数相结合,剪去解空间树中不符合问题的解,从而减少程序的运行时间。这就是解决实际问题的另一种方法叫回溯法,它也是解决实际问题的一种方法。
对每一个内容进行了相关的题目的讲解,给出了分析过程和源程序,还给出在解决问题的一些小技巧,这对进行在线练习非常有用。
布 置 作 业
可以在UVaOJ上进行在线练习。如果能完成总题目的80%,说明对暴力法求解法的掌握就比较扎实。可以练习8道基础题、14道回溯法题、12道(难)回溯法题、6道隐式图搜索题、7道哈希表题。