从排列到组合——深度优先搜索

来源:互联网 发布:快走丝线切割编程人门 编辑:程序博客网 时间:2024/06/05 21:07

  前段时间在洛谷3.0上刷到一个题,让本人挠头了一段时间,RT:

题目描述

已知 n 个整数 x1,x2,…,xn,以及一个整数 k(k<n)。从 n 个整数中任选 k 个整数相加,可分别得到一系列的和。例如当 n=4,k=3,4 个整数分别为 3,7,12,19 时,可得全部的组合与它们的和为:

3+7+12=22  3+7+19=29  7+12+19=38  3+12+19=34。

现在,要求你计算出和为素数共有多少种。

例如上例,只有一种的和为素数:3+7+19=29。


  首先解决这个问题显然需要把输入的所有组合罗列出来,求和再判读素数就好啦。这篇文章主要是解决组合排列问题,所以判断素数这里就忽略啦o(^▽^)o,但对于我这个算法小白来说这个题可不太容易,如何用通俗易懂的方式理解呢?

  在这里先向大家摘取《啊哈!算法》书中第四章第一节中用深度优先搜索解决全排列的方法。从书中的全排列例子,我们再自己推到排列,再由此推及到组合。

  例·1、2、3的全排列是:

    123、132、213、231、312、321。

  求1、2、3的全排列:


for(a=1; a<=3; a++)for(b=1; b<=3; b++)for(c=1; c<=3; c++)if(a!=b && a!=c && b!=c)cout << a << b << c << endl;

  这个很简单,三重循环嵌套就可以搞定,这里用for a循环来枚举第1位,用for b循环来枚举第2位,用for c循环来枚举第3位。再用一个if语句来判断,只有当a、b、c互不相等的时候才能输出。

  OK,要是输入一个指定的数n,输出1~n的全排列,又该怎么办呢?这样的话循环的嵌套层数是个动态的值,似乎用循环不太好解决,下面让我们用深度优先搜索试一试。


  例·输入一个数n,输出1~n的全排列。

  我们将问题形象化,假如你手里有编号为1、2、3的3张扑克牌和编号为1、2、3的三个盒子。现在需要将这3张扑克牌分别放入3个盒子里,并且每个盒子有且只有一张扑克牌。总共有几种放法呢?

  [box_1]   [box_2]  [box_3]  [box_4]

  首先你来到了1号盒子面前,你现在手里有3张扑克牌,先放哪一张好呢?很显然三者都要尝试,那就姑且约定一个顺序:每次到一个盒子面前,都先放1号,再放2号,最后放3号。于是你在一号盒子里放入了编号为1的扑克牌。来到2号盒子面前,由于之前的1号扑克牌已经不在手中,按照之前约定的顺序,你将2号牌放到了2号盒子里。3号也是同样。你又往后走当你来到第4个盒子面前,诶,没有第四个盒子,其实我们不需要第4个盒子,因为手中的扑克牌已经放完了。

  你发现了吗?当你走到第四个盒子前的时候,已经完成了一种排列,即“1 2 3”。然而并没有到此结束,产生了一种排列之后,你需要立即返回。现在你已经退到了3号盒子面前,你需要取回之前放在3号盒子中的扑克牌,再去尝试看看还能否放别的扑克牌,从而产生一个新的排列。于是你取回了3号牌,但由于你手中只有3号牌,你只能再次退回到2号盒子面前。

  你回到2号盒子后,收回了2号牌。现在你的手中有2张牌了,分别是2号和3号牌。按照之前的约定,现在需要往2号盒子中放3号扑克牌(上次放的是2号牌)。放好后,你来到3号盒子面前,将手中仅剩的2号牌放入了3号盒子。又来到了4号盒子面前,当然没有4号盒子。此时又产生了一个新的排列“1 3 2”。

   接下来按照刚才的步骤去模拟,便会依次生成所有排列:“2 1 3”、“2 3 1”、“3 1 2”和“3 2 1”。

   明白了基本思路,到了用程序实现的时候了。首先解决最基本的问题:如何往小盒子中放入扑克牌?这里用一个for循环解决:

for(i = 1; i <= n; i++){a[step]=i;//将i号扑克牌放入到第step个盒子中}

  数组a用来表示小盒子,变量step表示当前正处在第step个小盒子面前。这里还需要考虑,如果一张扑克牌已经放到别的小盒子中了,那么此时就不能放入同样的扑克牌到当前小盒子中,因为此时手中已经没有这张牌了。因此还需要一个数组book来标记哪些牌已经使用过了。

for(i = 1; i <= n; i++){if(book[i] == 0)//表示i号扑克牌仍然在手上{a[step]= i;//将i号扑克牌放入到第step个盒子中book[i] = 1;//表示i号扑克牌已经不在手上了}}
 OK,现在已经处理完第step个小盒子了,接下来还要再往下走一步,继续处理第step+1个小盒子。那么如何处理呢?处理方法其实和我们刚刚处理第step个小盒子的方法是一样的。因此这里我们可以想到把刚刚处理第step个小盒子的代码封装成一个函数,如下:

void dfs(int step)//step表示现在站在第几个盒子面前{for(i = 1; i <= n; i++){if(book[i] == 0)//表示i号扑克牌仍然在手上{a[step]= i;//将i号扑克牌放入到第step个盒子中book[i] = 1;//表示i号扑克牌已经不在手上了}}}
  把这个过程写成函数后,刚才的问题就好办了。在处理完第step个小盒子后,紧接着处理第step+1个小盒子,处理的方法就是dfs(step+1)。

void dfs(int step)//step表示现在站在第几个盒子面前{for(i = 1; i <= n; i++){if(book[i] == 0)//表示i号扑克牌仍然在手上{a[step]= i;//将i号扑克牌放入到第step个盒子中book[i] = 1;//表示i号扑克牌已经不在手上了dfs(step+1);//这里通过函数的递归调用来实现(自己调用自己)book[i] = 0;//这里非常重要,一定要将刚才尝试的扑克牌收回,才能进行下一次尝试</strong>}}}
  还剩下一个问题,就是什么时候该输出一个满足要求的序列呢?其实当我们处理到第n+1个小盒子的时候(即step等于n+1),那么说明前n个盒子都已经放好扑克牌了,这里就将1~n个小盒子中的扑克牌打印出来就好啦。要注意的是,打印完毕后一定要return,不然程序就无休止地运行下去了!

  完整代码如下。

#include <iostream>using namespace std;int a[10],book[10],n;//C语言全局变量值默认为0void dfs(int step)//step表示现在站在第几个盒子面前{int i;if(step == n+1)//如果站在第n+1个盒子面前,则表示前n个盒子已经放好了扑克牌{//输出一种排列for(i=1;i<=n;i++)cout << a[i];cout << endl;return;//返回之前的一步(最近一次调用dfs函数的地方)}//站在第step个盒子的面前,按照1、2、3...n的顺序一一尝试for(i = 1; i <= n; i++){if(book[i] == 0)//表示i号扑克牌仍然在手上{a[step]= i;//将i号扑克牌放入到第step个盒子中book[i] = 1;//表示i号扑克牌已经不在手上了dfs(step+1);book[i] = 0;}}return;}int main(){cin >> n;//由于数组大小的限制,输入的时候要注意为1~9之间的整数dfs(1);//首先站在1号小盒子面前return 0;}
  这个核心代码不超过20行的例子,饱含深度优先搜索(Depth First Search,DFS)的基本模型。理解深度优先搜索的关键在于解决“当下该如何做”。至于“下一步如何做”则与“当下该如何做”是一样的。比如我们这里写的dfs(step)函数的主要功能就是解决当你在用step个盒子的时候你该怎么办。通常的方法就是把每一种可能都去尝试一遍(一般用for循环遍历)。当前这一步解决后便进入下一步dfs(step+1)。下一步的解决方法和当前这一步的解决方法是完全一样的。下面的代码就是深度优先搜索的基本模型。

void dfs(int step){    判断边界    尝试每一种可能 for(i=1; i<=n; i++)    {        继续下一步 dfs(step+1);    }    返回}
  每一种尝试就是一种“扩展”。每次站在一个盒子面前的时候,其实都有n种扩展方法,但是并不是每种扩展都能够扩展成功。


  下面,我们考虑一下n个数中选k个排列如何实现呢?例如从1、2、3中选2个排列的结果是:12、13、23

  在这里k是小于等于n的,那么这就意味这每个箱子放一张扑克牌,所有的箱子都放上牌,手里的牌可能刚好全部用掉,也可能将会剩下来一些牌。也就是说,原先箱子数和牌数是正好相等的,而现在箱子数和牌数由用户指定,可以相等也可以不相等(相等时即为全排列,注意这里讨论的是排列,可以存在箱子数和牌数相等的情况,而本文章最先提到的的题目讨论的是组合,不考虑两者相等的情况,实际上两者相等时组合将只有一种)。对于每个盒子的处理办法其实和之前是一样的,变化的无非是两个。一个是排列的数字不是从1~n了,而是用户输入的一组数据(整数),这样的话,我们引入数组储存用户输入的数据,将原来的1~n作为数组下标即可;另一个是盒子和牌的数量关系变了,之前已经讨论过了。下面我们就看一下修改过的代码,请注意一下加粗的部分。

#include <iostream>using namespace std;int a[10],book[10],b[10],n,k;//a表示小盒子,b表示手中的牌,牌上的数字由用户指定,n表示牌数,k表示盒子数void dfs(int step)//step表示现在站在第几个盒子面前{int i;if(step == k+1)//如果站在第k+1个盒子面前,则表示前k个盒子已经放好了扑克牌{//输出一种排列for(i=1;i<=k;i++)//注意这里只输出cout << a[i];cout << endl;return;//返回之前的一步(最近一次调用dfs函数的地方)}//站在第step个盒子的面前,按照1、2、3...n的顺序一一尝试for(i = 1; i <= n; i++){if(book[i] == 0)//表示i号扑克牌仍然在手上{a[step]= b[i];//第i个扑克牌放入到第step个盒子中book[i] = 1;//表示i号扑克牌已经不在手上了dfs(step+1);//走到下一个小盒子面前book[i] = 0;//收回盒子中的牌}}return;}int main(){cin >> n >> k;for(int i=1; i<=n; i++)cin >> b[i];dfs(1);//首先站在1号小盒子面前return 0;}


  现在排列问题也顺利解决啦,我们已经离成功越来越近啦!下面我们就来看一下组合到底怎么解决呢?

  刚开始小白我也是伤透了脑筋,但通过比较排列和组合的关系,似乎有了一些头绪。让我们观察一下从5张牌中取3张的排列。

123132213231312321124142214 241412421125152215251512521134143314341413431135153315351513531145154415451514541234243324342423432235253325352523532245254425452524542345354435453534543

  由表格我们可以看出第一列的10个组合即是我们所需要的组合,与排列相比我们需要考虑其重复度。在这种组合中,每一行的6个组合被视为一种情况。我们在设计程序时,就要考虑如何防止多余的情况产生呢?

  让我们再观察一下第一列的10种组合。组合是“不考虑顺序的方法”,相对应排列是“考虑顺序的方法”。在组合中,你同样来到了第一个箱子面前,放入了1号牌,按照之前的逻辑,你又在第二个箱子里放入了2号牌,再到第三个箱子放了3号牌,来到4号箱子,实际上是发现没箱子了,然后得到了一个组合后再回到3号箱子...如此反复。不同的是什么呢?如果现在一号箱子中已经有了1号牌,二号箱子中也放入了2号牌,符合一号和二号箱子里的牌仍分别是1、2号牌的条件的所有情况都已经尝试过了,即123、124、125,那么接下来我们就不能再考虑当一号箱子中是1号牌时,在剩下的箱子中再放入2号牌的情况了。如果仍然要固执地使用2号牌呢?按照之前的约定,我们按照牌号从小到大的顺序来放牌,这时候二号箱子不能再放入2号牌,而应放入3号牌(因为在二号箱子里是2号牌的情况我们已经考虑过了,不过请注意在这一前提是一号箱子里一直都是1号牌)。这时我们又来到了三号箱子面前,按照从小号到大号的顺序放牌,我们应该放1号,但别忘了1号牌已经在1号箱子中用过啦!接下来我们把手头上还有的2号牌放进去。一个我们不愿意看到的情况发生了:产生了132组合!很明显它和我们之前已经得到的123组合重复啦!在此我们也可以理解为当三张牌中两张已经相同了,在剩余的牌中选择一张作为第三张牌,一定会出现1次组合重复的情况(如123和132)。所以说我们不能如此任性哦~这里有点绕,毕竟没有人真的会这么闲,来回倒腾纸牌玩(●◡●)。如果没读懂请好好理解一下哦,这里实际上是由排列到组合的一个关键。

  假设你已经读懂了上段文字我在扯什么,请往下看(如果没读懂,我表示深深的歉意^-^)。

  1-2-x的所有情况我们都考虑完后,我们就可以在排除2的情况下考虑所有1-3-x的情况。然后是1-4-x,但会出现1-5-x吗?不会啦~因为一共只有5张牌哦~而所有的牌此时都被标记为已用哦,即book为1,所以第三个箱子里是没有可以放的牌的!程序会直接跳过滴,我们就不用担心啦。此时带有1的所有组合我们都考虑完毕啦,于是给它对应的book标记上1。于是我们顺利退回到一号箱子,在一号箱子中放入了2号牌,接下来在不考虑1号的情况下排列出2-x-x的组合。思路已经和上面完全一样啦!我们将会得到234、235和245。得到的最后一个组合就是345了,上代码。

void dfs(int step)//step表示现在站在第几个盒子面前{int i;if(step == k+1)//如果站在第k+1个盒子面前,则表示前k个盒子已经放好了扑克牌{//输出一种排列for(i=1;i<=k;i++)//注意这里只输出cout << a[i];cout << endl;return;//返回之前的一步(最近一次调用dfs函数的地方)}//站在第step个盒子的面前,按照1、2、3...n的顺序一一尝试for(i = 1; i <= n; i++){if(book[i] == 0)//表示i号扑克牌仍然在手上{a[step]= b[i];//第i个扑克牌放入到第step个盒子中book[i] = 1;//表示i号扑克牌已经不在手上了dfs(step+1);//走到下一个小盒子面前book[i] = 0;//收回盒子中的牌if(flag == 1){book[i] = 1;flag = 0;}}if(i == n)//表示在某个箱子上已经遍历完了从1到n号的所有扑克牌flag = 1;}return;}
  在for循环的最后我们加入了一个判断,若i与牌数n相等,则表示在某个箱子上已经完成了所有的遍历,然后我们给它做个标记。由于该循环执行的条件是i<=n,这样做完标记后,i++,i已经大于n了,函数返回了,即退回到了上一个箱子(最近的dfs()),然后收回盒子中的牌。这时当标号为i的牌在我们手中时,我们我们给当前牌标上1,表示这种情况我们已经全部考虑完了,这张牌暂时不能再用了,注意是暂时哦。然后把flag再变回0,以便之后重复使用。

  大功告成了吗?No!这样就造成了一个问题:比如说在6选4的组合中,得到1234、1235、1236后,3号牌被标记成了1后,就不会再得到1345、1346、1356这三个组合。所以我们需要将部分数字恢复成可用状态。我们用一个for循环消除从当前牌号的下一位到最后一张牌的标记,以便以后再次使用。我把代码拿出来,至于为什么这样实现相信大家能思考得出来。这里是终极代码。

#include <iostream>using namespace std;int a[10],book[10],b[10],n,k,flag;//a表示小盒子,b表示手中的牌,牌上的数字由用户指定,n表示牌数,k表示盒子数void dfs(int step)//step表示现在站在第几个盒子面前{int i;if(step == k+1)//如果站在第k+1个盒子面前,则表示前k个盒子已经放好了扑克牌{//输出一种排列for(i=1;i<=k;i++)//注意这里只输出cout << a[i];cout << endl;return;//返回之前的一步(最近一次调用dfs函数的地方)}//站在第step个盒子的面前,按照1、2、3...n的顺序一一尝试for(i = 1; i <= n; i++){if(book[i] == 0)//表示i号扑克牌仍然在手上{a[step]= b[i];//第i个扑克牌放入到第step个盒子中book[i] = 1;//表示i号扑克牌已经不在手上了dfs(step+1);//走到下一个小盒子面前book[i] = 0;//收回盒子中的牌if(flag == 1){book[i] = 1;flag = 0;for(int j=i+1; j<=n; j++)//消除从当前牌号的下一位到最后一张牌的标记,以便以后再次使用book[j] = 0;}}if(i == n)//表示在第step个箱子上已经遍历完了从1到n号的所有扑克牌flag = 1;}return;}int main(){cin >> n >> k;for(int i=1; i<=n; i++)cin >> b[i];dfs(1);//首先站在1号小盒子面前return 0;}


  我们用深度优先搜索的方法解决了这个问题,如果有对排列组合不太明白的,可以参考结城浩的《程序员的数学》中第5章排列组合中的内容。感觉《程序员的数学》和《啊哈!算法》都是很适合小白入门的书呢,在这里推荐给和我一样的小白们哦~



1 0
原创粉丝点击