7.3 子集生成

第7.2节中介绍了排列生成算法。本节介绍子集生成算法:给定一个集合,枚举所有可能的子集。为了简单起见,本节讨论的集合中没有重复元素。

7.3.1 增量构造法

第一种思路是一次选出一个元素放到集合中,程序如下:


void print_subset(int n, int* A, int cur) {
  for(int i = 0; i < cur; i++) printf("%d ", A[i]);     //打印当前集合   
  printf("\n");
  int s = cur ? A[cur-1]+1 : 0;            //确定当前元素的最小可能值
  for(int i = s; i < n; i++) {
    A[cur] = i;
    print_subset(n, A, cur+1);             //递归构造子集
  }
}

和前面不同,由于A中的元素个数不确定,每次递归调用都要输出当前集合。另外,递归边界也不需要显式确定——如果无法继续添加元素,自然就不会再递归了。

上面的代码用到了定序的技巧:规定集合A中所有元素的编号从小到大排列,就不会把集合{1, 2}按照{1, 2}和{2, 1}输出两次了。

提示7-4:在枚举子集的增量法中,需要使用定序的技巧,避免同一个集合枚举两次。

这棵解答树上有1024个结点。这不难理解:每个可能的A都对应一个结点,而n元素集合恰好有2n个子集,210=1024。

7.3.2 位向量法

第二种思路是构造一个位向量B[i],而不是直接构造子集A本身,其中B[i]=1,当且仅当i在子集A中。递归实现如下:


void print_subset(int n, int* B, int cur) {
  if(cur == n) {
    for(int i = 0; i < cur; i++)
      if(B[i]) printf("%d ", i);              //打印当前集合
    printf("\n");
    return;
  }
  B[cur] = 1;                            //选第cur个元素
  print_subset(n, B, cur+1);
  B[cur] = 0;                             //不选第cur个元素
  print_subset(n, B, cur+1);
}

必须当“所有元素是否选择”全部确定完毕后才是一个完整的子集,因此仍然像以前那样当if(cur == n)成立时才输出。现在的解答树上有2047个结点,比刚才的方法略多。这个也不难理解:所有部分解(不完整的解)也对应着解答树上的结点。

提示7-5:在枚举子集的位向量法中,解答树的结点数略多,但在多数情况下仍然够快。

这是一棵n+1层的二叉树(cur的范围从0~n),第0层有1个结点,第1层有2个结点,第2层有4个结点,第3层有8个结点,……,第i层有2i个结点,总数为1+2+4+8+…+2n=2n+1-1,和实验结果一致。如图7-2所示为这棵解答树。

图7-2 位向量法的解答树

这棵树依然符合前面的观察结果:最后几层结点数占整棵树的绝大多数。

7.3.3 二进制法

另外,还可以用二进制来表示{0, 1, 2,…,n-1}的子集S:从右往左第i位(各位从0开始编号)表示元素i是否在集合S中。图7-3展示了二进制0100011000110111是如何表示集合{0, 1, 2, 4, 5, 9, 10, 14}的。

图7-3 用二进制表示子集

注意:为了处理方便,最右边的位总是对应元素0,而不是元素1。

提示7-6:可以用二进制表示子集,其中从右往左第i位(从0开始编号)表示元素i是否在集合中(1表示“在”,0表示“不在”)。

此时仅表示出集合是不够的,还需要对集合进行操作。幸运的是,常见的集合运算都可以用位运算符简单实现。最常见的二元位运算是与(&)、或(|)、非(!),它们和对应的逻辑运算非常相似,如表7-1所示。

表7-1 C语言中的二元位运算

表7-1中包括了“异或(XOR)”运算符“^”,其规则是“如果A和B不相同,则A^B为1,否则为0”。异或运算最重要的性质就是“开关性”——异或两次以后相当于没有异或,即A^B^B=A。另外,与、或和异或都满足交换律:A&B=B&A,A|B=B|A,A^B=B^A。

与逻辑运算符不同的是,位运算符(bitwise operator)是逐位进行的——两个32位整数的“按位与”相当于32对0/1值之间的运算。表7-2中表示了二进制数10110(十进制为22)和01100(十进制为12)之间的按位与、按位或、按位异或的值,以及对应的集合运算的含义。

表7-2 位运算与集合运算

不难看出,A&B、A|B和A^B分别对应集合的交、并和对称差。另外,空集为0,全集{0, 1, 2,…, n-1}的二进制为n个1,即十进制的2n-1。为了方便,往往在程序中把全集定义为ALL_BITS= (1<<n)-1,则A的补集就是ALL_BITS^A。当然,直接用整数减法ALL_BITS -A也可以,但速度比位运算“^”慢。

提示7-7:当用二进制表示子集时,位运算中的按位与、或、异或对应集合的交、并和对称差。

这样,不难用下面的程序输出子集S对应的各个元素:


void print_subset(int n, int s) {    //打印{0, 1, 2,..., n-1}的子集S
  for(int i = 0; i < n; i++)
    if(s&(1<<i)) printf("%d ", i);    //这里利用了C语言"非0值都为真"的规定
  printf("\n");
}

而枚举子集和枚举整数一样简单:


for(int i = 0; i < (1<<n); i++)    //枚举各子集所对应的编码0, 1, 2,..., 2n-1
  print_subset(n, i);

提示7-8:从代码量看,枚举子集的最简单方法是二进制法。