谈谈递归和回溯算法的运用
递归和回溯算法的运用
题目描述
有n个士兵站成一列,从第1个士兵前面向后望去,刚好能看到m个士兵,如果站在后面的士兵身高小于或者等于前面某个士兵的身高,那么后面的这个士兵就不能被看到,问这n个士兵有多少种排列方式,刚好在观测位能看到m个士兵?
第一行输入 n 个士兵和 m 个可以看到的士兵(n >= m),第二行输入 n 个士兵的身高,输出为排列方式的种数。
输入:
4 3
1 1 2 3
输出:
6
也就是说,输入数 n, m (n < m),然后输入 n 个正整数到一个数组 a 中,a 数组中下标小的值如果小于后面某个数的话,后面这个数才可见。
我的思路是,
- 把数组 a 中的序列所有可能的排列情况列出来
- 然后对每一个可能的情况分析,如果某种排列能够恰好使其中可见的数为 m,说明这种情况是符合要求的。
排列组合
那么先来考虑一个子问题:
Q:如何输出一个数组的所有可能排列方式。
首先回顾一下我们是怎么进行全排列的。
一个大小为 n 的数组的全排列有 n! 种,
假设有 3 个各不相同的球,
1 2 3
要将他们放到 3 个不同的盒子中
就有 3! = 3x2x1 种方式,数学解题的思路如下:
首先把第一个球放到第 1 个盒子中,再考虑填入第 2 个盒子,把第 2 个球放入第 2 个盒子,剩下最后一个球只能放进最后一个盒子了,这是第一种情况;
然后回到放第 2 个盒子这一步,同样的这个盒子可以先放第 3 个球,这样第 2 个球就只能放入第 3 个盒子了,这就是第 2 种情况;
然后再回到填第 1 个盒子的地方,放入第 2 个球……
这样总共还有 4 种可能的情况,那么总共的排列方式就是 6 种,分别的情形是(按球的序号进行排序):
- 123
- 132
- 213
- 231
- 312
- 321
这样所有可能的排列方式就全部列出来了。可以看到,实际上将 1 2 3 这个序列中的数交换位置,可以得到后面的几种排列。
尝试交换位置
假设输入的数组是a,a[0]=1, a[1]=2, a[2]=3
先尝试如果单纯的用循环加上交换数组数值的方法能否遍历所有情况:
1 #include <iostream> 2 using namespace std; 3 int n = 3; 4 5 void print_arr(const int * a) { 6 cout << "array: "; 7 for(int i = 0; i < n; i++) { 8 cout << a[i] << " "; 9 } 10 cout << endl; 11 } 12 13 void swap(int &a, int &b) { 14 int t = a; 15 a = b; 16 b = t; 17 } 18 19 int main() { 20 int a[n] = {1, 2, 3}; 21 22 // 该数组本身的顺序排列就是全排列中的一种 23 print_arr(a); 24 25 // 交换位置 26 for(int i = 0; i < n; i++) { // outer for 27 for(int j = n-1; j > 0; j--) { // inner for 28 swap(a[j], a[j-1]); 29 print_arr(a); 30 } 31 swap(a[0], a[i]); 32 } 33 34 return 0; 35 }
得到的结果为:
出现了 7 个序列,其中只有 5 个是不重复的,也就是说还少一种情况。
分析其原因:
在 inner for 循环结束的时候,将a[0]与a[i]进行交换,
这样做实际上是类似于把第2个球放入第1个盒子的步骤,但是这没有考虑到此时的数组已经不是最开始的 1, 2, 3 这样的序列了。
那么如果每次找到一个序列后,将数组重新设为 1, 2, 3 这样的序列行不行呢?
仔细想一想,其实也不行,因为 inner for 里面的代码仅仅交换了相邻两个数,这样就遗漏了很多种情况。
递归和回溯
需要遍历所有情况的话,最容易想到的应该就是递归了。
而且在思考排列球的方法中,很重要的一点就是
当所有球都放到了盒子中,要回到前一个盒子的那一步,选择另一种方式放入小球。
这种方法就是回溯。
很容易想到如果是 1 个球放 1 个盒子,只有 1 种情况,这是递归的终点(也就是把所有球都放到盒子中,这一次递归就结束了)。
那么,当序号最后的球(比如序号为 3,3 个球放入 3 个盒子)放到了第一个盒子中,而这趟递归也结束了。就认为所有可能的情况都已经遍历过了,回溯递归也就结束了。
加入一个 bool 型数组,用于保存球的使用状态(true 表示球已经放入盒子里)。
1 #include <iostream> 2 #include <string.h> 3 using namespace std; 4 int n = 3, m; 5 int res = 0; 6 7 void print_arr(const int * a) { 8 cout << "array: "; 9 for(int i = 0; i < n; i++) { 10 cout << a[i] << " "; 11 } 12 cout << endl; 13 } 14 15 /** 16 * b 需要被分配数值的数组 17 * i b 数组中需要被设置的序号 18 * used 用来进行回溯的数组标志位,true 表示 a 数组中该序号的元素已经被使用 19 * in 现在使用的 a 数组中的序号 20 * 需要用到递归和回溯, 21 * 若 i == n ,意味着 b 数组所有的元素都被分配了,此时可以尝试打印数组 22 * 设置一个标志位 in,意味着 b[i] 空格将要被 a[in] 球占据。 23 * 每一个 b[i] 空格都要循环整个 a 中的球。 24 * 但是在 inner 的循环过程中 in 的值有可能超过 n,这个时候就需要直接退出循环了。 25 */ 26 int order(const int * a, int * b, bool used[], int i) { 27 /* all used */ 28 if(i == n) { 29 print_arr(b); 30 /* 如果满足条件,进行自定义的处理 */ 31 // if( getCoverd(b) == n - m) { 32 // res++; 33 // } 34 return 1; 35 } 36 int in = 0; 37 38 while (in < n) { // outter 39 while(used[in]) { // inner 40 in++; 41 } 42 /* 43 * 如果在 inner 循环里 in 就已经达到 n 了 44 * 直接退出 outter 循环 45 */ 46 if(in >= n) { 47 break; 48 } 49 b[i] = a[in]; 50 used[in] = true; 51 if( order(a, b, used, i+1) == 1 ) 52 { 53 used[in] = false; 54 in++; 55 } 56 } 57 return 1; 58 } 59 60 int main() { 61 int a[n] = {1, 2, 3}; 62 int b[n] = {}; 63 bool used[n] = {false}; 64 order(a, b, used, 0); 65 cout << res ; 66 }
试着运行一下:
果然所有的可能情况都遍历到了,并且没有重复,没有遗漏。然后把代码中的 n 改为其它数,给数组 a 添加相应的元素,也能够遍历所有情况。
到这一步,全排列就已经实现了。
不过其实这里的代码还有改进的地方,仔细观察 order(const int*, int *, bool[], int) 这个函数,能够发现它的返回值其实并没有什么作用,可以考虑去掉返回值,将函数类型改为 void,这样能够减少堆栈的内存使用。
完成算法题
现在还需要的一步就是要算出每种可能的排列中,可见士兵的数量。用一个 getUncovered(int *a); 函数算出可见的士兵数,然后比较是否等于 m 就可以了。
完整的程序:
1 #include <iostream> 2 #include <string.h> 3 using namespace std; 4 int n , m; 5 int res = 0; 6 7 void print_arr(const int * a); 8 int getCoverd(const int * a); 9 10 void print_arr(const int * a) { 11 cout << "array: "; 12 for(int i = 0; i < n; i++) { 13 cout << a[i] << " "; 14 } 15 cout << endl; 16 } 17 18 /* 一个序列中出现的没有被挡住的人 */ 19 int getUncovered(const int * a) { 20 int uncovered = 1; 21 // 指向当前能看到的最高的人 22 int point = 0; 23 for(int i = 1; i < n; i++) { 24 if(a[i] > a[point]) { 25 uncovered++; 26 point = i; 27 } 28 } 29 return uncovered; 30 } 31 32 /** 33 * b 需要被分配数值的数组 34 * i b 数组中需要被设置的序号 35 * used 用来进行回溯的数组标志位,true 表示 a 数组中该序号的元素已经被使用 36 * in 现在使用的 a 数组中的序号 37 * 需要用到递归和回溯, 38 * 若 i == n ,意味着 b 数组所有的元素都被分配了,此时可以尝试打印数组 39 * 设置一个标志位 in,意味着 b[i] 空格将要被 a[in] 球占据。 40 * 每一个 b[i] 空格都要循环整个 a 中的球。 41 * 但是在 inner 的循环过程中 in 的值有可能超过 n,这个时候就需要直接退出循环了。 42 */ 43 void order(const int * a, int * b, bool used[], int i) { 44 /* all used */ 45 if(i == n) { 46 print_arr(b); 47 /* 如果满足条件,进行自定义的处理 */ 48 if( getUncovered(b) == m) { 49 res++; 50 } 51 // return 1; 52 } 53 int in = 0; 54 55 while (in < n) { // outter 56 while(used[in]) { // inner 57 in++; 58 } 59 /* 60 * 如果在 inner 循环里 in 就已经达到 n 了 61 * 直接退出 outter 循环 62 */ 63 if(in >= n) { 64 break; 65 } 66 b[i] = a[in]; 67 used[in] = true; 68 order(a, b, used, i+1); 69 used[in] = false; 70 in++; 71 } 72 } 73 74 int main() { 75 cin >> n >> m; 76 77 int a[n] = {}; 78 int b[n] = {}; 79 bool used[n] = {false}; 80 81 for(int i = 0; i < n; i++) { 82 cin >> a[i]; 83 } 84 85 order(a, b, used, 0); 86 cout << res ; 87 }
最后进行一个简单的测试,结果非常棒!
总结
- 把数学问题转化成程序问题,要多做尝试,尽可能的用自己熟悉的方法去理解问题,用易于实现的方法一步步地来解决问题
- 实际上最开始我的思路很乱,又想着将数组 a 中的数赋值到 b 中,又想着 b 中的数应该存放 a 中的哪个数,这样导致想得太复杂,递归算法的实现也很乱。后来用两个数 1,2 作为数组的两个元素进行调试,在几个断点之间观察变量的值,根据变量的值不断地对递归函数实现进行修改,最终实现了正确的递归。
- 通过对函数的观察,改进了函数,去掉了无用的返回值。
本博客由 BriFuture 原创,并在个人博客(WordPress构建) BriFuture's Blog 上发布。欢迎访问。
欢迎遵照 CC-BY-NC-SA 协议规定转载,请在正文中标注并保留本人信息。