算法设计与分析基础系列--生成子集与bitmask

转载文章,原文章来源于算法设计与分析基础系列--生成子集与bitmask(欢迎关注微信公众号,会定期更新内容)

============================================================

前言

本文内容基于书籍"算法设计与分析基础"(Introduction to The Design and Analysis of Algorithms,作者Anany Levitin),主要学习和讨论其中的生成子集算法与bitmask技巧。生成子集在一些面试题中可能会出现,而bitmask技巧相对有点难度,一般出现在ACM竞赛相关的题目里,本文暂时只进行一些简单的探讨。

问题描述

题目可以描述成,给定1到n一共n个正整数,设计一个算法来生成所有的2^n-1个非空子集。

解答

我们先以n=3作为例子进行说明。对于n=3,一共有2^3-1=7种非空子集,分别是(1),(2),(3),(1,2),(1,3),(2,3),(1,2,3)。

我们可能会想到一种比较直接的暴力枚举方法如下。

for(int i = 0; i < 2; i++){ // 这里i,j,k(取值为0或1)依次对应1,2,3是否应该出现在子集中,取值为1时表示该元素需要出现在子集中
     for(int j = 0; j < 2; j++){
         for(int k = 0; k < 2; k++){
              vector<int> s; // 存放当前生成的子集
              if(i == 1){ // i取值为1时表示子集中应当包含1
                   s.push_back(1);
              }

              if(j == 1){ // j取值为1时表示子集中应当包含2
                   s.push_back(2);
              }

              if(k == 1){ // k取值为1时表示子集中应当包含3
                   s.push_back(3);
              }

             if(!s.empty()){ // 子集非空 打印结果
                   for(int p = 0; p < s.size(); p++){
                          cout<<s[p]<<" ";
                   }
                   cout<<endl;
              }
         }
    }
}

上述代码采用三重循环来实现,对每个元素是否应该出现在子集中单独判断,简单明了。但是,随着n的增加,如果还按照上述思路的话,循环的嵌套数量也会线性增加,导致对于每个特定的n,代码都要独立编写,基本不具备实用性。但是,上述代码其实也给了我们一个启发,就是在生成子集的过程中,我们可以用n个辅助变量,依次对应每个元素,每个辅助变量取值为1表示当前对应元素要出现在子集中,否则不应出现在子集中。那么对于n=3的情况,每个子集和辅助变量的关系如下:

其中左边括号内为子集,右边括号内为辅助变量的取值,在辅助变量中,我们从右向左依次对应1,2,3(有些读者对从右向左可能会有些不习惯,其实无论从左向右或者从右向左都不影响,这里只是笔者的习惯)

(1)-(001), (2)-(010), (3)-(100), (1,2)-(011), (1,3)-(101), (2,3)-(110), (1,2,3)-(111)

比如子集(1,2),我们有三个辅助变量,因为是从右向左对应1,2,3,所以应该是011。

观察上述7个辅助变量的取值,如果我们将其看做二进制表达的话,那么在转成十进制后就变成(中括号内为其十进制表达)

(1)-(001)-[1], (2)-(010)-[2], (3)-(100)-[4], (1,2)-(011)-[3], (1,3)-(101)-[5], (2,3)-(110)-[6], (1,2,3)-(111)-[7]

此时我们会发现,这7个辅助变量的取值恰好是1到7的正整数,而n=3时的子集数量恰好为2^3-1=7。这里并非巧合,我们可以这么考虑,求n个元素的子集时,我们需要n个辅助变量,它们彼此绝对不会重复,而又恰好有2^n-1个(这正是子集数量之和),这不正是长度为n的所有二进制表达嘛(除了0)。

因此,对于给定的n,我们可以令m=2^n-1,接着我们可以从1开始遍历到m,对于其中的每一个正整数,我们都将其转换成总位数为m位的二进制表达,然后根据每一个bit位是否为1来决定对应位置的元素是否要出现在子集中。代码如下

int a[n]; // 存放了0到n-1共n个正整数(数组编号从0开始,所以我们也改成从0开始吧)

int main(){

    for(int i = 0; i < n; i++){ // 初始化

         a[i] = i;

    }

    int m = (1 << n) - 1; // 计算得到m=2^n-1,这里利用了位运算的性质

    for(int i = 1; i <= m; i++){ // 从1遍历到m

        vector<int> vr; // 存放子集结果

        for(int j = 0; j < n; j++){ // 查看m个bit位中的每一位

             if((i >> j) & 1){ // 利用了位运算,其作用为查看i的二进制表达的第j位是否为1

                   vr.push_back(a[j]); // 为1说明子集中应当包含该元素

             }

        }

        for(int j = 0; j < vr.size(); j++){ // 打印子集

             cout<<vr[j]<<" ";

        }

        cout<<endl;

    }

}

对于上述代码,可以将n=3代入并结合我们之前的论述分析每一步的作用和整体的过程,会有一些更加直观的理解。

子集生成可以说是bitmask思想(bitmask即利用正整数的二进制表达中的每个bit位来表示某个状态的取值,有m个bit位就可以表示m个状态取值的所有组合情况)的一个初步应用,如果读者有过一些编程竞赛的经验,对于一些基于bitmask思想的动态规划题目应该也不会陌生。在这类动态规划题目中,通常使用bitmask思想来"压缩"状态的维度从而简化状态的定义,而状态之间的转移往往通过各种位运算的组合来实现,在后续文章中我们会再进行讨论。

总结

1,子集生成可以通过二进制表达/位运算来实现,相应地,这里n不能太大,否则超过了int或者long long int的范围该方法就无效了。一般来说n也不会太大(常见的比如n<=20左右),因为子集总数为2^n-1,增长速度非常快。

2,从子集生成延伸而来的是bitmask思想,其在一些动态规划题目中有较为广泛的应用。

欢迎大家多多转载并关注后续更新,如果有其他算法相关的问题也欢迎留言讨论~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值