本篇介绍一个非常给力的求组合的算法!

本篇摘要

本篇介绍一个非常给力的求组合的算法!上一篇“c_c++刁钻问题各个击破之位运算及其实例(2)”介绍了6个比较复杂的位操作,但是没有给出任何应用实例,本篇就之前谈到的位操作进行应用,其主要内容是用位操作来实现求组合

引例

先来看一道题目,这个题目是理解利用位操作求组合的关键。它是POJ的2453。英文原题就不贴了,我用中文描述一下吧:给定一个正整数N,求最小的、比N大的正整数M,使得M与N的二进制表示中有相同数目的1。

上面的题目描述或许有点拗口,举个例子把,假如给定的N为78,其二进制表示为1001110,包含4个1,那么最小的比N大的并且二进制表示中只包含4个1的数是83,其二进制是1010011,因此83就是答案。那么如何求解这个问题呢?

(2) 非常给力的方法

即将要介绍的方法到底有多给力呢?它神奇到只用如下几行代码(事实上可以合并为一行代码)就能实现上述所有代码的功能,这几行代码是:

[cpp] view plaincopyprint?
int NextN(int N)  
{  
    int x = N&(-N);        
    int t = N+x;  
    int ans = t | ((N^t)/x)>>2;  
    return ans;  
}  

 看到了不,这就是所有代码,如果省去临时变量,它就只包含一行代码,但是为了后面讲述方便,我将它写成三行代码。它的强大之处并非只是代码少,如你所见,它无需调用count1Bits函数(效率!),也没有前面枚举法中GetNextN函数中的while循环(效率!)。是的,我们这里的NextN函数比之前的GetNextN函数效率要高很多,它的时间复杂度是O(1)!

如果您是大牛,请不要笑话我在这里的大惊小怪,如果你跟两三天前的我一样不懂这几句代码的话,那么请往下看,我将尝试着把我的理解详细的表述出来,力求细致,简单,易懂。

以N=78为例(其二进制表示为1001110),我们的任务是求得最小的比N大,二进制表示中1的个数与N相同的数:83(其二进制表示为1010011)。首先我们要总结出来从78变成83的规律,为了方便,将78和83的二进制写成竖式形式:

78:1 0 0 1 1 1 0

83:1 0 1 0 0 1 1

可以看出,为了得到83,我们只需要对N(78)的二进制中最右边的连续的1位串(加粗标红)进行操作!其过程是:将连续的1位串中最左边的1向左“移动”一位,其他的1位串“移动”到最右边!这即保证了二进制表示中1的个数不变,又保证了新得到的数比原来的数大,并且是最小的。其过程可以用如下图示表示:

本篇介绍一个非常给力的求组合的算法!_第1张图片

在上面的描述中我用引号把两个“移动”引起来了,原因是,具体实现时,我们并不是对这些二进制位进行移动,而是通过位操作来达到同样的目的,而这些位操作就是本问题的关键。接下来我将分析前面那个“非常给力的代码”看看它是如何用位操作来实现对这些位的“移动”的。

首先来看语句int x = N&(-N);它的功能是找到N(即78)的二进制表示中最右边的1(这个1必定是N的二进制表示中最右边的连续的1位串的开始)。该过程图示如下:

本篇介绍一个非常给力的求组合的算法!_第2张图片

接下来看看int t = N+x;该语句实现了“将连续的1位串中最左边的1向左“移动”一位”的功能,当然它带来了副作用:使得连续的1位串中其他的1丢失了!其过程如下:

本篇介绍一个非常给力的求组合的算法!_第3张图片

       最后的任务就是要将上面丢失的1补上,并放到最右边,这就是语句int ans = t | ((N^t)/x)>>2;的功能。首先,要知道需要补多少个1,通过分析可以知道需要补上的1的个数等于N的二进制表示中最右边的连续的1位串中1的个数减1,然而如果通过位操作来求得呢?这就是N^t的功能了,如下图所示,N^t的二进制表示只包含1个连续的1串,并且1的个数正好等于N的二进制表示中最右边的连续的1位串中1的个数加1:

本篇介绍一个非常给力的求组合的算法!_第4张图片

由上面的分析可知,N^t中的1的个数实际上比我们需要补的1的个数多2!这就使得我们可以通过N^t求得需要补的1的个数,接下来的任务就是如何补上这些1了。

进步一分析得知,N^t的二进制表示中最低位的1正好与x中那个1对应,因此我们就可以通过(N^t)/x将这些1全部移到最右边了,然而此时1的个数比我们要补的个数多了2,没关系我们在把结果右移2位就可以了,也就是((N^t)/x)>>2。如此一来我们求得了要补的1的个数和其位置。本段的描述可以用下图形象地表示:

本篇介绍一个非常给力的求组合的算法!_第5张图片

 

最后我们只需要用t | ((N^t)/x)>>2;就能得到所求之数了!其过程如下图:

本篇介绍一个非常给力的求组合的算法!_第6张图片

以上就是我对这个非常给力的代码的分析。短短3句代码(省去中间变量的话,就一句代码)居然包含了如此之多的东西,这就是位运算的强大之处,也是位运算的难学之处。本人以前也很少关注位运算,像这样给力的代码我是写不出来的,因此我也只能按照如上步骤那么去读懂这个代码。至此,本篇的引例算是完成了,不可思议吧,为了求组合,我居然用了这么多篇幅来讲一个引例,这会不会本末倒置啊?我自信不会的,因为有了这个引例,下面求组合就太easy了。

本篇主题:利用位操作求组合

组合就是从N各对象中选取m个对象,问有多少种选法,并且要求输出每次的选法。比如给定1,2,3,4四个数,从中选择2个的选法有:{1,2},{1,3},{1,4},{2,3},{2,4},{3,4}共6种选法。当然求组合的方法非常之多,我这里只介绍如何利用位操作来求,其思路是:用2进制bit位来标识某个对象是否被选中,1代表选中,0代表没选中。比如前面的例子的组合可以用下图来表示(最低位为1表示选中第一对象,以此类推)。

根据上面的分析,我们可以用一个包含N个bit位的数C来求N个对象中选取m个对象的组合:首先让C的最低m位全部为1(对应到从N个对象中选择前m个对象的组合情况),然后用我们引例的方法求出最小的比C大并且二进制表示中包含的1的个数与C相同的数K,就能求得下一个组合情况。其流程如下:

1、  初始化C=(1<<m)-1;(选择N个对象中的前m个作为第一个组合);

2、  根据C的二进制表示输出其所对应的组合;

3、  调用C=NextN(C);

4、  通过判断C是否小于等于(1<<N)-(1<<(N-m))来确定是不是输出了所有的组合(注意,当C==(1<<N)-(1<<(N-m))时,C就对应着从N个对象中选择后m个对象的组合情况,也就是最后一个组合),如果C小于等于(1<<N)-(1<<(N-m)),则转2,否则转5;

5、  结束(已经输出所有组合)。

上面流程中的关键部分我都标粗了,如果对该流程有疑问,可以与我联系([email protected])。下面我将根据上面的流程给出代码:

#include <stdio.h>  
#include"iostream"  
using namespace std;  
//定义包含4个元素的集合   
char set[] ={'a','b','c','d','e','f','g','h','i'};  
//根据C的二进制表示输出一个组合   
void print(char* set,int C)  
{  
    int i = 0;  
    int k;  
    while((k=1<<i)<=C)  
    {//循环测试每个bit是否为1   
        if((C&k)!=0)  
        {  
            cout<<set[i];  
        }  
        i++;  
    }  
}   
//这个NextN跟之前我们讨论的是一样的,只不过省去了临时变量   
int NextN(int N)  
{  
    return (N+(N&(-N))) | ((N^(N+(N&(-N))))/(N&(-N)))>>2;  
}   
//求从set中前N个元素 中选择m个的组合   
void Combination(char* set,int N,int m)  
{  
    int C = (1<<m)-1;  
    while(C<=((1<<N)-(1<<(N-m))))  
    {  
        print(set,C);  
        cout<<endl;  
        C = NextN(C);  
    }       
}                
void main()  
{  
    Combination(set,4,2);  
}    

上面的代码在VC6.0中测试通过,其运行结果如下:

本篇介绍一个非常给力的求组合的算法!_第7张图片

       最后,或许您对Combination函数中的while中的条件表达式:C<=((1<<N)-(1<<(N-m)))不理解,那么请看下图,该图示意了最后一个组合所对应的C,其值正好等于(1<<N)-(1<<(N-m))

本篇介绍一个非常给力的求组合的算法!_第8张图片

分析

由于我这里没有给出求组合数的其他算法,因此无法对该算法与其他算法的性能做对比,有兴趣的朋友可以做一个对比。事实上这个算法的效率相当之高,因为它直接根据前一个组合一步就能求得后面一个组合。当然它也并非没有一点瑕疵,我个人认为它有两点不足:

1、  由于它需要用一个整数的二进制位来标识哪些对象被选中,而整数是有范围的,因此如果N比较大(大于32),那么该算法就不能直接利用了。

2、  得到的组合并非有序的,如上面的结果所示输出ac之后并非输出ad,而是bc,其原因是NextN(N)函数,它返回的是“最小的、比N大的、二进制表示中1的个数与N相同的数”然而这个约束并不能保证根据它求得的组合是有序的。如果一定要求有序的组合,那么,可以修改NextN这个函数,但本文的核心就是它,因此修改它很可能就失去了意义,当然您可以想出另外一个位运算,在不损失效率的情况下改变NextN的功能,从而得到有序的组合,这个也是我在思考的问题。

结束语

到此本篇即将结束,个人感觉对引例说得比较清晰透彻,如果您有不清楚的地方或者发现有不妥之处,请留言,最后感谢您的阅读!

申明

本人的所有原著博文的版权均归本人和CSDN所有。除侵权行为外,欢迎各位朋友评论指正转载分享,分享时请标明作者和出处,本人昵称:语过添情(w57w57w57)。其中语过添情”是我一直用的QQ昵称,从2001年至今,它跟我已经10个年头了。后面那一串古怪的字符的来历是:字母w和数字5分别是本人姓(伍)的拼音首字母和谐音数字,7是因为我的启蒙女友(初一时的,非初恋女友,因为那时我完全不懂恋爱)的姓(柒)的谐音数字。以前天真的以为以后生个小孩可以取名伍陆柒(567)的,可惜一学期后她就辍学了……

 

 

                                                        作者:    语过添情 (w57w57w57)

                                                        Email:  [email protected]

                                                        QQ:       11335457

                           

                                          2011-08-03


你可能感兴趣的:(算法)