统计32位数中1的个数
- 《编程之美》 $2.1
- http://blog.solrex.org/articles/population-count-problem.html
- http://www.cnblogs.com/graphics/archive/2010/06/21/1752421.html
- http://en.wikipedia.org/wiki/Hamming_weight
- http://en.wikipedia.org/wiki/SSE4
- java 中 Integer实现
- 一般实现。
int popCount1(int i) { int num=0; while(i!=0) { num += i & 0x01; i >>>= 1; } return num; }
可以看出算法中展现的是一种通过位运算每位运算然后逐步右移得到最后个数。当然很多人一般的想象应该是对2取模循环,然后除以2,这种算法不值得提倡。因为取模的方式在针对2的指数的时候,一定要使用位移操作,个人认为是做编程的基本素质。
这种算法一般看来还不错,算法复杂度是n,但是细化的话有3*n的复杂度,而且n是全部32,即至少有 3*32个操作在里面,而且不包括循环的操作。 - 快速法。
int popCount2(int i) { int num = 0; while(i!=0) { i &= (i-1); num++; } return num; }
快速法的实现,明显看到了一种巧妙的影子,这种巧妙明显能透彻出设计此算法人对二进制了解相当有灵感。
看上去 跟普通法 类似,算法复杂度也是n,但是细节到位操作运算的时候算法复杂度能表示的也许真的不够准确,还是要依靠计算机基础知识,例如 后面我们会看到操作数更少的算法也许不是最快的。细化的复杂度可以看出为 3*n,但是这个n不是32,只有最坏的情况下才会是32,即全部位都为1,即它是跟i本身的位数是一致的,那样平均看待的话 复杂度应该是 1.5*n(这里细化的复杂度,指计算机指令,如&=),的确降低了操作次数。
看上去已经足够的巧妙,但是还有没有更好的办法呢。在突破一般算法的时候,为了更快的速度,一般会分成两种可能,一种是根据计算机基本特性设计简单粗暴的解决办法,特定环境下非常有效。另一种则是走更深入的数学道路,利用计算机固有属性设计的更加巧妙。
- 查表法。
这种方法就非常简单粗暴,但是应该说在特定环境下真的可以发挥很大的作用,它的基本思想就是用空间换时间。如32位的数字的所有可能你先定义好2的32次方长度的数组,其算法复杂度可以降到1(当然这么长的数组,指针寻址也有消耗,暂且不记)只需要 intArr[i]就得到了对应的个数。
当然2的32次方的数组显然有些恐怖,空间占用也相当大。
一般定义一个256长度(2的8次方,一个字节)的数组还是常用的查表法。这样通过把32位先分解成4个byte再计算。
int popCount3(int i) { int[] bitCounts = new int[256]{...}; return (bitCounts[(i>>>24)] + bitCounts[(i&0x00ff0000)>>16] + bitCounts[(i&0x0000ff00)>>8] + bitCounts[i&0x000000ff]); }
当然这种算法由于java的限制,并没有把粗暴发挥到极致,如果是c可以直接通过位截断进行操作,就是java的操作也不过就6个,当然这并没有算数组创建开销等,因为如果方法很多次访问,数组可以设置为公共访问的。还有就是数组寻址开销没有计算,所以说此方法只适合特定环境才能发挥作用,不然是既浪费内存又浪费时间。一般不清楚环境的情况下不应该使用,还要有个真是的测试数据对应才好。
- 数学分析法。起名数学分析法的原因个人觉得如果没有非常好的数学基础和对二进制规律的了解很难创作出这样的算法。先看这个:
int popCount4(int i) { i = (i & 0x55555555) + ((i >>> 1) & 0x55555555) ; i = (i & 0x33333333) + ((i >>> 2) & 0x33333333) ; i = (i & 0x0f0f0f0f) + ((i >>> 4) & 0x0f0f0f0f) ; i = (i & 0x00ff00ff) + ((i >>> 8) & 0x00ff00ff) ; i = (i & 0x0000ffff) + ((i >>> 16) & 0x0000ffff) ; return i ; }
是不是有种摸不到头脑的感觉,为了实现巧妙,当然也付出了更加抽象的代价。
其实这个算法的基本逻辑就是相邻位的想加,然后不断扩大相邻范围,最后得到了实际1的个数。借用一个例子:来源(http://www.cnblogs.com/graphics/archive/2010/06/21/1752421.html)
以217(11011001)为例,有图有真相,下面的图足以说明一切了。217的二进制表示中有5个1
就是这样一个逻辑,总共的操作数复杂度为 20,跟其他任何情况无关,就是多了4个数字常量,方法是不是非常赞!
还有更优化的:对的这是事实。int popCount5(int i) { i -= ((i >>> 1) & 0x55555555); i = (i & 0x33333333) + ((i >>> 2) & 0x33333333); i = (i + (i >>> 4)) & 0x0f0f0f0f; i += (i >>> 8); i += (i >>> 16); return i & 0x3f; }
这个算法更加巧妙的分析了二进制的一些规律,和要计算的目的,对小于8位的合并通过一些规律减少了操作,而对于大于8位的合并则通过特殊的方式,使其想加的数在尾部的一个byte中正确即可,因为个数远远没有超过256,最后通过一个 & 得到了正确的数。
java的类库中Integer中的实现就是这样的,非常的简练只有17次操作,可见java基本类库的算法发挥到的极致。另外也是这样算法思考的变种,可见算法的强大。
int popCount6(int i) { i -= ((i >>> 1) & 0x55555555); i = (i & 0x33333333) + ((i >>> 2) & 0x33333333); i = (i + (i >>> 4)) & 0x0f0f0f0f; return (i *0x01010101) >> 24; }
和
int popCount7(int i) { i = i - ((i >>> 1) & 033333333333) - ((i >>> 2) & 011111111111); return ((i+ (i>>> 3)) & 030707070707) % 63; }
这里说明一下,这里使用了8进制,0开头为8进制。
下面的解释同样来源于 ( http://www.cnblogs.com/graphics/archive/2010/06/21/1752421.html ) :
先说明一点,以0开头的是8进制数,以0x开头的是十六进制数,上面代码中使用了三个8进制数。 将n的二进制表示写出来,然后每3bit分成一组,求出每一组中1的个数,再表示成二进制的形式。比如n = 50,其二进制表示为110010,分组后是110和010,这两组中1的个数本别是2和3。2对应010,3对应011,所以第一行代码结束后,tmp = 010011,具体是怎么实现的呢?由于每组3bit,所以这3bit对应的十进制数都能表示为2^2 * a + 2^1 * b + c的形式,也就是4a + 2b + c的形式,这里a,b,c的值为0或1,如果为0表示对应的二进制位上是0,如果为1表示对应的二进制位上是1,所以a + b + c的值也就是4a + 2b + c的二进制数中1的个数了。举个例子,十进制数6(0110)= 4 * 1 + 2 * 1 + 0,这里a = 1, b = 1, c = 0, a + b + c = 2,所以6的二进制表示中有两个1。现在的问题是,如何得到a + b + c呢?注意位运算中,右移一位相当于除2,就利用这个性质! 4a + 2b + c 右移一位等于2a + b 4a + 2b + c 右移量位等于a 然后做减法 4a + 2b + c –(2a + b) – a = a + b + c,这就是第一行代码所作的事,明白了吧。 第二行代码的作用 在第一行的基础上,将tmp中相邻的两组中1的个数累加,由于累加到过程中有些组被重复加了一次,所以要舍弃这些多加的部分,这就是&030707070707的作用,又由于最终结果可能大于63,所以要取模。 需要注意的是,经过第一行代码后,从右侧起,每相邻的3bit只有四种可能,即000, 001, 010, 011,为啥呢?因为每3bit中1的个数最多为3。所以下面的加法中不存在进位的问题,因为3 + 3 = 6,不足8,不会产生进位。 tmp + (tmp >> 3)-这句就是是相邻组相加,注意会产生重复相加的部分,比如tmp = 659 = 001 010 010 011时,tmp >> 3 = 000 001 010 010,相加得 001 010 010 011 000 001 010 010 --------------------- 001 011 100 101 001 + 101 = 1 + 5 = 6,所以659的二进制表示中有6个1 注意我们想要的只是第二组和最后一组(绿色部分),而第一组和第三组(红色部分)属于重复相加的部分,要消除掉,这就是&030707070707所完成的任务(每隔三位删除三位),最后为什么还要%63呢?因为上面相当于每次计算相连的6bit中1的个数,最多是111111 = 77(八进制)= 63(十进制),所以最后要对63取模。
这两个算法被修饰的更加简洁,但是为什么没有被java类库采用呢?是不是没有发现这两种算法呢,我想应该不是,这两个算法的确更加精简的减少了操作数,但是问题是popCount6中有一个*乘法操作,而popCount7中有一个%取模的操作,这两个操作的在cpu上的效率是跟位操作没法比的。然而会不会哪天cpu突然在解决这两个操作的效率上极大提高也有可能,说到CPU,也许算法的终结也是CPU。
- 一切的总结 CPU (http://en.wikipedia.org/wiki/SSE4#POPCNT_and_LZCNT)
如果cpu直接支持了这样一个指令,所有的算法是不是都显得暗淡呢,如果cpu不再是二进制是不是我们的算法都要做重要的调整呢。如果cpu将来不再使用所谓的”进制“,那么我们的所有计算机编程是不是将全部颠覆呢?
好像扯远了,就这样结束吧。