组合(Combination)指的是对于一个元素个数为\(n\)的集合\(A\),从集合的\(n\)个元素中任取\(m(0 \leq m \leq n)\)个元素组成所有集合得到的结果。全组合(Full Combination)为从元素个数为\(n\)的集合\(A\)中所有元素数量为\(m(0 \leq m \leq n)\)的子集的全集,实际上指的就是组合,故全组合一般都简称为组合。所有组合的个数为\(C_n^m=\frac{n!}{m!(n-m)!}\)。

组合算法实现与全排列算法相似,也分为递归(回溯法)和非递归两种,递归和非递归实现方式这里分别记录若干种方法。

递归实现组合算法

1. 动态规划法

根据组合数的性质可以得到递推式:

\(C^m_n=C^m_{n-1}+C^{m-1}_{n-1}\)

通过以上递推式,我们可以将从\(n\)个元素中任取\(m(m \geq 0)\)个元素的元素规模为\(n\)的组合问题,分解为两个元素规模为\(n-1\)的两个组合子问题,具体算法实现为

事先在集合的所有\(n\)个元素中确定一个标志元素\(a\),则两个子问题如下:

  1. 若元素\(a\)不需要在待求的组合集合中,那么接下来应在原集合剩下的\(n-1\)个元素中任取\(m\)个元素,这个子问题对应递推式中的\(C^m_{n-1}\);
  2. 若元素\(a\)需要在待求的组合集合中,那么接下来应在原集合剩下的\(n-1\)个元素中再任取\(m-1\)个元素与标志元素\(a\)组成组合,这个子问题对应递推式中的\(C^{m-1}_{n-1}\)。

对所有的组合问题重复上面的步骤,最后必然会得到两种情况(若最终原集合剩余有\(k(1 \leq k \leq m)\):

  1. 剩余的\(k\)个元素全部被选择出来,对应的组合数表达式为\(C^k_k\);
  2. 剩余的\(k\)个元素没有一个被选择出来,对应的组合数表达式为\(C^0_k\)。

由此,递归算法的递推形式和终止条件都已知晓,可以根据上述递归算法编写程序求解给定集合的全组合子集。

C++代码实现:

这种求组合的方法体现了动态规划的基本思想,即将原问题分解为相似的子问题,通过迭代求解子问题来得到原问题的解。

2. 顺序法(递归方式)

顺序法是我们平时求解组合时常用的一种方式。实际的思想与上面方法的思想类似。在顺序法中选择元素一般为从左到右依次选择,然后对剩余的元素再重复之前的步骤,直到剩余需要选的元素个数为\(m=1\),此时遍历取出原集合中剩余的元素与并入到之前已取出的\(m-1\)个元素的子集中,得到组合子集。具体算法实现如下

  1. 遍历取出集合中的第\(1\)到第\(n-m+1\)个元素;
  2. 对于步骤1的所有情况,遍历取出集合剩余元素中的第\(1\)到第\(n-m+2\)个元素;
  3. 重复步骤2直至需要取的元素个数\(m=1\),此时遍历取出集合中剩余未被选中的元素并入到之前已取出的\(m-1\)个元素组成的子集中,得到一种组合结果。

C++代码实现:

非递归实现组合算法

3. 顺序法(非递归方式)

非递归方式的顺序法求解全组合即暴力破解法,对于组合问题中待取出元素的个数\(m\),利用\(m\)层循环,每一层循环选取一个元素,总共选取所有的\(m\)个元素。这种暴力破解法的缺陷为根据算法得到的程序代码不具有普适性,每一种代码只能对应一个特定的待取出元素的值\(m\),如果需要更改\(m\)的值,则需要在代码中更改循环嵌套的层数。

例如对于任意元素个数为\(n\)的集合\(A\),要求取出元素个数为\(3\)的全组合,可以写出如下C++代码:

4. 全排列按位取数法

求组合问题的关键在于找到所有从\(n\)个元素任取\(m\)个元素的全部情况。从\(m\)元素整体的角度考虑可能会使问题变得复杂;但是如果从单个元素的角度考虑问题的话,可以得到一种求解组合问题的另一种思路:

从元素个数为\(n\)集合\(A\)取出\(m\)个元素的得到一个组合子集\(B\),对于集合\(A\)的任意一个元素\(a_i\)来说,其只存在两种情况:\(a_i \in B\)或者\(a_i \notin B\)。也就是说,对于从集合\(A\)中任取\(m\)得到的任意一种组合情况,都唯一对应着集合\(A\)上所有元素是否属于组合子集\(B\)的一组状态。设定集合\(A\)中的元素属于\(B\)的状态记为\(1\),元素不属于\(B\)的状态记为\(0\),在集合\(A\)之外另创建一个长度与集合\(A\)元素个数相同的序列\(C\)来存放集合\(A\)所有元素的状态。此时我们无须关心从集合\(A\)取出元素的顺序等因素,只须关心序列\(C\)的排列情况,序列\(C\)的每一种排列情况与一种组合情况对应,序列\(C\)的全排列对应原问题中的所有组合情况。故我们将一个求集合的组合问题,转化为了一个求序列的全排列的问题。

例如:对于给定的集合\(A=\left\{1,2,3,4\right\}\),欲求从中任取两个元素得到的全部组合情况,解法如下:

  1. 先创建一个序列\(C\)保存集合\(A\)所有元素的状态,设定初始状态为取前两个元素得到组合子集\(B\),即\(C=\left\{1, 1, 0, 0\right\}\);
  2. 求序列\(C\)的全排列,便可以得到集合\(A\)中的所有元素的每一种组合情况的状态:

\(1, 1, 0, 0\) 取元素\(1, 2\)
\(1, 0, 1, 0\) 取元素\(1, 3\)
\(1, 0, 0, 1\) 取元素\(1, 4\)
\(0, 1, 1, 0\) 取元素\(2, 3\)
\(0, 1, 0, 1\) 取元素\(2, 4\)
\(0, 0, 1, 1\) 取元素\(3, 4\)

求给定序列的全排列算法在之前已经介绍过,这里不再赘述,由于我们要通过非递归方式求解全组合问题,因此使用的全排列算法为字典序排列算法,具体算法实现如下

  1. 创建一个序列\(C\)保存集合\(A\)中元素的状态,不妨设序列\(C\)的前\(m\)个为状态\(1\),其余为状态\(0\);
  2. 利用prev_permutation算法依次求出序列\(C\)的下一个反字典序排列,然后根据求出的排列来遍历集合\(A\),根据对应的状态来取出相应的\(m\)个元素;
  3. 重复步骤2直至得到步骤1中序列\(C\)的反向序列为止。

C++代码实现:

各算法时间消耗

To be continued