集合元素的排列和组合

一、集合的排列

       给定一个集合S,含有n个不重复的元素,输出该集合元素的所有排列,leetcode对应题目为:http://oj.leetcode.com/problems/permutations/。打印所有排列的复杂度为O(n*n!),因为共有n!个不同的排列,打印每个排列的复杂度为O(n)。打印所有的排列一般采用深搜策略,先给出一个常规的方法:

  1. void perm(int start,int end,vector<int> &num,vector<vector<int>> &result)  
  2. {  
  3.         if(start==end)  
  4.         {  
  5.             result.push_back(num);  
  6.             return;  
  7.         }  
  8.           
  9.         for(int i=start;i<=end;i++)  
  10.         {  
  11.             swap(num[start],num[i]);   
  12.             perm(start+1,end,num,result);  
  13.             swap(num[start],num[i]);  
  14.         }  
  15. }  

该代码可看成是标准深搜的一个具体实例:

  1. void DFS(int i,int n,…)  
  2.     if i=n  
  3.         print;  
  4.         return;  
  5.     for j=i to k do  
  6.         DFS(j,n,…);  

深搜的第一步是判断是否已经达到递归结束的条件,之后针对不同的解空间进行递归。在某些情况下,在递归之前也伴随着剪枝,以加速算法的执行速度。上述保存集合排列的代码思想很简单:将起始位置的元素置成集合的每一个元素,然后递归下一个位置。该问题的解空间是一个排列树。例如,针对集合{1,2,3}的解空间为:

1排列树形式的解空间

排列树形式的解空间有个规律,就是随着递归深度的增加,每个节点的子节点个数都逐次减一,这也是为什么排列算法的复杂度是O(n!)。

此外,还有一种生成排列的方法,思想不太好懂,下面只给出代码:

  1. void perm(int n,vector<int> &num,vector<vector<int>> &result)  
  2.     {  
  3.         if(n==1)  
  4.         {  
  5.             result.push_back(num);  
  6.             return;  
  7.         }  
  8.           
  9.         for(int i=0;i<n;i++)  
  10.         {  
  11.             perm(n-1,num,result);  
  12.               
  13.             if(n%2!=0)  
  14.             {  
  15.                 swap(num[0],num[n-1]);  
  16.             }  
  17.             else  
  18.             {  
  19.                 swap(num[i],num[n-1]);  
  20.             }  
  21.         }  
  22. }  

算法正确性证明的基本思想是:数组长度n为奇数时,操作完成后,数组不变,n为偶数时,操作完成后,数组循环右移一位。

二、集合的k元素子集

给定一个集合S,含有n个不重复的元素,生成所有的含有k个元素的子集,也即求组合数,leetcode对应题目为: http://oj.leetcode.com/problems/combinations/ 。该问题也可以通过深搜完成,在写代码之前先分析一下其解空间的构造。针对每一个元素,我们都有两种选择:选择该元素或者不选该元素,由此问题的解空间是一棵二叉树。例如,对集合{1,2,3},选择2个数的子集的解空间如下图:

图2 子集树形式的解空间

根据上面的分析,我们可以知道求k个元素的子集,我们只需要判断当前已经选择的元素个数,如果已经选择了k个元素,则找到一个符合的子集,无需再遍历子节点。如果尚未选择k个元素,但是已经达到叶节点(n个元素已经判断一遍),我们可以直接返回,这说明此次的遍历没有找到符合的子集。按照这个思路,代码如下:

  1. void com(int depth,int n,int k,vector<int>& r,vector<vector<int> >& result)  
  2.     {  
  3.         if(r.size()==k)  
  4.         {  
  5.             result.push_back(r);  
  6.             return;  
  7.         }  
  8.         if(depth==n) return;  
  9.         r.push_back(depth+1);  
  10.         com(depth+1,n,k,r,result);  
  11.         r.pop_back();  
  12.         com(depth+1,n,k,r,result);  
  13. }  

其中参数depth表示当前深度,参数n表示最大深度,参数k表示当前保存的元素个数。如上所述,代码有两个终止条件:找到符合的子集,或者达到叶节点。遍历时,只需要考虑两种情况:选择该元素或者不选该元素,然后遍历下一层节点。

上述代码是最原始的代码,因为提交已经AC,所以无需再做优化,但实际上代码还可以有很大的优化余地。事实上,在某些路径中,我们无需遍历到叶节点也可以知道后面的遍历是不符合要求的,如果需要添加的元素个数大于剩余路径上所有元素的个数,则即使添加剩余所有的元素也不符合要求,此时我们可以直接进行剪枝,避免不必要的搜索。如果不剪枝复杂度最坏为O(2n),剪枝之后的复杂度为O(nk)。


在《挑战程序设计竞赛》一书的157页,有介绍一种非递归枚举{1,2,…,n}所包含的所有大小为k的子集的方法。有兴趣的读者可自行阅读,下面只给出代码:
  1. vector<vector<int> > combine(int n, int k) {  
  2.         vector<vector<int> > result;  
  3.         int comb= (1<<k)-1;//comb是每一个k元素的子集,从0…01…1开始  
  4.         while(comb<1<<n)  
  5.         {  
  6.             vector<int> r;  
  7.             for(int j=0;j<n;j++)  
  8.             {  
  9.                 if(comb>>j&1)  
  10.                 {  
  11.                     r.push_back(j+1);  
  12.                 }  
  13.             }  
  14.             result.push_back(r);  
  15.               
  16.             int x=comb&-comb,y=comb+x;  
  17.             comb=((comb&~y)/x>>1)|y;  
  18.         }  
  19.           
  20.         return result;  
  21. }  

三、集合的所有子集

问题二只求集合的k元素子集,现在要求集合的所有子集,leetcode对应的题目为:http://oj.leetcode.com/problems/subsets/,该问题更加简单,只需要将图2的子集树完整遍历一遍即可,从根节点到叶节点的每一条路径都表示一个可能的子集。代码如下:

  1. void sub(int depth,vector<int> &S,vector<int>& r,vector<vector<int>>& result)  
  2.     {  
  3.         if(depth==S.size())  
  4.         {  
  5.             result.push_back(r);  
  6.             return;  
  7.         }  
  8.         r.push_back(S[depth]);  
  9.         sub(depth+1,S,r,result);  
  10.         r.pop_back();  
  11.         sub(depth+1,S,r,result);  
  12. }  

与问题二的区别是,不需要考虑当前已经保存的元素个数,只需要判断是否已经到达叶节点。当然,我们也可以通过遍历一个数的二进制来获得集合的所有子集。

  1. vector<vector<int> > subsets(vector<int> &S) {  
  2. sort(S.begin(),S.end());//给定的集合可能未排序  
  3.           
  4.         vector<vector<int>> result;  
  5.           
  6.         for(int i=0;i<1<<S.size();i++)//每一个二进制数都是一个子集  
  7.         {  
  8.             vector<int> r;  
  9.             for(int j=0;j<S.size();j++)//获得为1的位置  
  10.             {  
  11.                 if(i>>j&1)  
  12.                 {  
  13.                     r.push_back(S[j]);  
  14.                 }  
  15.             }  
  16.               
  17.             result.push_back(r);  
  18.         }  
  19.           
  20.         return result;  
  21. }  

上述三个问题都可以通过深搜完美解决,后两个问题还可以通过位运算解决,不管是深搜还是位运算都有比较相似的模式,希望通过上面的分析,大家能深刻理解深搜和位运算。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值