简介
这一部分主要讨论组合生成问题的一个应用,就是子集的生成。具体的问题要求就是给定一个集合,要求枚举出它所有可能的子集。这个问题实际上本质上还是一个组合生成问题的应用。结合前面一篇文章里所讨论的,其实我们这个问题无非就是要求出包含有1个元素,2个元素到n个元素的所有可能组合。
我们这里针对具体的一些实现方法做一个讨论。
分析
有了前面文章分析的基础,我们很可能一开始想到的就是,既然是列举出所有可能的组合。我们完全可以通过传入参数,调用来求出一个元素,两个元素等等到所有元素的组合。这是一种方法。不过这里还有一些更加简单直观的方法。可以作为一个比较和参考。
增量构造法
增量构造法的思路就有点像我们一次挑选一个元素的过程。关键点就在于我们该怎么来构造这么一个递归的关系。以元素集合{1, 2, 3, 4}为例。取一个元素的组合数为C(4, 1),也就是分别为1, 2, 3, 4。从最开始的情形取1开始,如果按照一个递增的顺序,第二个元素可以选择的是2, 3, 4中间的任何一个。但是后面一个元素的选择必须满足一个条件,它必须比前面一个元素大。这样,如果让输入元素按照排序的方式传入,这样可以得到一个不重复的递增元素选取序列。所以当我们选取到某个位置比如说i的时候,当要选择i +1位置的元素时,必须要选择一个比a[i]大的那个。
另外,既然这里是要输出所有可能组合,比如说选取元素1的时候,它对应一个组合,12的时候对应另外一个。所以每次只要有选择一个元素,就要输出这种对应的组合。而按照前面的思路,一个迭代的大致过程如下:
void printSubset(int[] a, int[] b, int cur) { // a为源数据, b为被被选择元素数据
从i =0到cur, 输出b的内容
选取大于当前位置前一个元素的元素索引, 即保证a[s] > b[cur -1],返回得到的s。
for s后面所有的元素 {
bpcur[ = a[i]
printSubset(a, b, cur +1);
}
}
这里递归的关系是,对于选取的任意一个点,循环选取所有比前面一个元素大的所有元素。然后再递归到下一个。
在详细的实现里,要考虑的就是,一个排序的源数组,一个保存选取元素的目标数组,还有一个就是当前的位置,这3个即为方法调用的参数。具体的实现如下:
public static void generateSubset(int[] a, int[] b, int cur) {
printListRange(b, cur);
int s = getBiggerPosition(a, b, cur);
for(int i = s; i < a.length; i++) {
b[cur] = a[i];
generateSubset(a, b, cur + 1);
}
}
这部分代码几乎是对前面伪码的一个直接翻译。每次我们都通过printListRange输出到当前位置所选取的数组。然后尝试在当前位置选取所有比前一个位置大的元素。
输出的printListRange方法如下:
public static void printListRange(int[] a, int p) {
for(int i = 0; i < p; i++) {
System.out.print(a[i] + " ");
}
System.out.println();
}
而获取比前面一个位置大的元素位置的实现则如下:
public static int getBiggerPosition(int[] a, int[] b, int cur) {
if(cur == 0)
return 0;
else {
int i;
for(i = 0; i < a.length; i++) {
if(a[i] > b[cur - 1])
return i;
}
return i;
}
}
这个实现里要考虑几个地方。一个是如果是最开始的元素,索引是0, 这个没法取它的前一个,所以只要直接返回0就可以了。对于找到比当前元素大的实现很好理解。只是后面循环结束后返回那个元素i,它有什么作用呢?
这个循环外返回的i就相当于我们找不到比给定元素大的元素了,所以它应该就等于当前数组a的长度,假设为n。在我们的代码里有一个for(int i = s; i < a.length; i++)的循环,而如果一开始s就已经是s了。则直接就跳过了这个循环,也相当于这一部分函数调用执行结束。如果不是这么看的话,粗看起来感觉这代码没有办法返回,会进入一个无限循环递归的状态。
所以说,这种办法的实现要点就是定义这么一个递归关系,对于任何一个位置的元素,它选取的可以是任意一个比前一个大的元素。所以对当前位置要循环的设置所有比前一个大的元素,然后来做递归。
位向量法
除了前面的那种实现所有组合的方式,还有位向量的办法。这种办法看起来更加符合我们一贯的思维方式一些。对于一个数组,要取里面所有元素的组合。我们可以这样来想,对于任意一个元素来说,对它的选取就是要么它被选择了,要么它没有被选择。所以如果我们用一种方式来遍历某个目标数组的话,针对一个位置i来说,它当前可以是被选择,然后也可以是不被选择,这两种情况都对应着一个向后面i+1的递归情况。而到最后递归结束的时候,怎么来检查验证哪些选择了哪些没选择呢?
我们可以用一个额外的数组,比如说是定义成boolean类型的数组,如果某个位置的元素被选择了,则它被设置为true,否则为false。这样每次只要去遍历一下这个boolean数组,根据里面选择的情况去选择输出对应位置的元素。
这里对应的递归关系是
printComb(a, b, i) = {
b[i] = true;
printComb(a, b, i +1)}
b[i] = false;
printComb(a, b, i +1);
}
按照这个关系,对应的详细实现如下:
public static void bitVector(int[] a, boolean[] b, int cur) {
if(cur == a.length) {
printListItems(a, b, cur);
return;
}
b[cur] = true;
bitVector(a, b, cur + 1);
b[cur] = false;
bitVector(a, b, cur + 1);
}
里面的printListItems是输出到给定当前位置cur的目的数组元素。实现如下:
public static void printListItems(int[] a, boolean[] b, int cur) {
for(int i = 0; i < cur; i++) {
if(b[i])
System.out.print(a[i] + " ");
}
System.out.println();
}
二进制法
除了前面的那两种办法,我们还有一种更加简便的方法。这种方法其实是利用了我们以前学过的一些数学的知识以及一个一一对应的关系。我们知道,对于给定一个集合里,所有元素的集合它们应该满足这样一个公式: 假设所有的组合数之和为sum,则有sum = C(n, 0) + C(n, 1) + ...+ C(n, n); 分别对应取集合中的一个元素,两个元素...n个元素。而通过数学公式二项式定义,这个和是等于2 ** n(2的n次方)。就是说,我们所有取的组合数为一个指数函数。
而这时,如果用二进制的方式来表示这些数字的话,我们可以将这n个元素里选取任意个元素对应成一个这样的数组{0, 1, 0, 1...}等等。这里总共的长度是n,每个位置的0或者1分别表示该位置被选取或者未选取。
所以,在实现的时候就只要定义一个从0到2**n这个数字的循环,然后针对每个数字,计算它当前二进制里为1所在的位,该位为1表示对应位置的元素被选取了。按照这个方式,代码的实现就已经很简单了。判断当前位置和输出的代码如下:
public static void printComb(int[] a, int s) {
for(int i = 0; i < a.length; i++) {
if((s & (1 << i)) != 0)
System.out.print(a[i] + " ");
}
System.out.println();
}
这里参数s表示给定的一个数字,然后它和每个位置为1的数字,比如0, 10, 100, 1000进行与操作,如果结果不为0, 表示对应的第i位不为0, 然后输出这一位就可以了。
调用这部分代码的方式更简单,这里为了省事就和其它代码放在一起了,其实也可以分开再封装成一个函数的:
public static void main(String[] args) {
int[] a = {1, 2, 3, 4};
for(int i = 0; i < (1 << a.length); i++)
printComb(a, i);
}
总结
求一个给定集合里所有元素的可能组合是一个看似比较简单的问题。实际上它牵涉到的地方还是有很多的。比如有最开始通过传入一个个参数来调用固定选项的组合算法,到后面递归的调用和定义选择规则。再到后面我们利用一些组合的数学关系,建立一种元素间的映射关系而得出的方法。实际上当我们挖掘的层次越深入会发现越是有更加有效的办法。这种问题本身的时间复杂度是一个指数函数级别。更多的时候只是通过多种解决方法来锻炼一下不同的思考方式和问题解决思路。
参考材料
算法竞赛入门经典