现在的位置: 首页 > 综合 > 正文

《编程之美》学习笔记——2.1求二进制中1的个数

2018年03月30日 ⁄ 综合 ⁄ 共 9590字 ⁄ 字号 评论关闭

一、题目

  求二进制中1的个数。对于一个字节(8bit)的无符号整型变量,求其二进制表示中"1"的个数,要求算法的执行效率尽可能的高。

二、解法

 版本一(除法):最简单的思路,利用求余算法统计1的个数。

TYPE count_number_1_in_byte(TYPE n)
{
    TYPE sum = 0;
    while(n) {
        sum += n % 2 == 1 ? 1 : 0;
        n /= 2;
    }
    return sum;
}

时间复杂度:O(lgn)

特点:根据二进制的规则,求余取得每一个位的值。由此可以延伸到多种进制求法,如八进制,十六进制,十进制,统计该表示下中各个位(八进制:0-7;十六进制:0-E;十进制:0-9)的数量

 版本二(移位):计算机中采用二进制方式存储数据,可以利用位操作实现。

TYPE count_number_1_in_byte(TYPE n)
{
    TYPE sum = 0;
    while(n) {
        sum += n & 0x01;
        n >>= 1;
    }
    return sum;
}                                                                   

时间复杂度:O(log2n)

特点:利用计算机中存储特性,把除2变为移位运算,提高运算效率。可以利用这个关系,在算法中实现2^n的倍数的乘除运算,如向左移2位表示该数乘以2^2=4。需要注意的是:右移运算需要保证该数为unsigned型(C/C++),保证无符号性提高程序可移植性。(这点在《C和指针》、《C陷阱与缺陷》中有提及。)拓展:求一个数的2的n次幂运算可以采用与操作符实现。

 版本三(移位):上一个版本采用位操作,但是操作的位数仍是八位。这个版本的算法在每次判断中,仅与1进行判断,利用的是特别的与运算n &= (n - 1)。

TYPE count_number_1_in_byte(TYPE n)
{
    TYPE sum = 0;
    while(n) {
        n &= (n - 1);
        sum++;
    }
    return sum;
}                                                                           

时间复杂度:O(N)(N是n二进制表达中含1的位数个数,最差情况下是O(lgn)

特点:采用了特定的位运算操作,这个思想来源于两个想法:判断一个数是否是2的某整数次幂可以利用判断语句if (n & (n-1) == 0),同时使每次操作后的结果都减去一个1位直到最后的数变成0。

 版本四(查表):查表法,利用空间换取时间,罗列并直接给出值。(程序中也包含生成表的代码)

/**
 * @file count_number_1_in_byte_v1.c
 * @brief count the number 1 in binary form of a byte(8 bits).
 * @author chenxilinsidney
 * @version 1.0
 * @date 2014-12-28
 */

#include <stdlib.h>
#include <limits.h>
#include <stdio.h>

typedef unsigned char TYPE;
TYPE N = 234;
TYPE search_list[UCHAR_MAX] = {0};

TYPE count_number_1_in_byte(TYPE n)
{
    TYPE sum = 0;
    while(n) {
        n &= (n - 1);
        sum++;
    }
    return sum;
}

int main(void)
{
    /// 预先生成表
    TYPE i = 0;
    for (i = 0; i < UCHAR_MAX; i++)
        search_list[i] = count_number_1_in_byte(i);
    /// 查表法求值
    TYPE sum = 0;
    sum = search_list[N];
    printf("sum of number 1 in %d is %d.\n", N, sum);
    return EXIT_SUCCESS;
}

时间复杂度:O(1)

特点:利用了空间快速换取结果。书中提及重要的一点:在一个需要频繁使用这个算法的应用中,通过“空间换时间”来获取高的时间效率时一个常用的方法,具体的算法还应针对不同应用进行优化。

(这篇文章对查表法进行了另外一个方面的评价:http://blog.csdn.net/justpub/article/details/2292823

摘抄一:首先是对算法的衡量上,复杂度真的是唯一的标准吗?尤其对于这种数据规模给定,而且很小的情况下,复杂度其实是个比较次要的因素。

摘抄二:查表法看似一次地址计算就能解决,但实际上这里用到一个访存操作,而且第一次访存的时候很有可能那个数组不在cache里,这样一个cache miss导致的后果可能就是耗去几十甚至上百个cycle(因为要访问内存)。所以对于这种“小操作”,这个算法的性能其实是很差的。)

三、扩展问题

给定两个正整数A,B。判断A和B中有多少位是不同的?

 方案:首先对A,B求异或,再利用上面版本三或版本四算法统计结果中“1”的位数,即可得到答案。

四、对题目深入学习

学习参考资料(使用了下面两种高级的算法):

http://blog.csdn.net/justpub/article/details/2292823

http://blog.csdn.net/GSYzhu/article/details/8095174

http://blog.csdn.net/jiqiren007/article/details/6403133

http://blog.csdn.net/msquare/article/details/4536388

 版本五(Hamming weight
认真阅读: http://en.wikipedia.org/wiki/Hamming_weight
:

在二进制表达中, 距离又称为:population count, popcount or sideways sum。算法实际上采用的是二分法,首先相邻的两位为一组各自相加统计各自两位上1的个数和,接下来相邻的四位为一组各自相加再统计各自四位上的个数和,如此下去到二进制左半位数和右半位数求和相加,得到的值和即为该数"1"的位数和。

时间复杂度:O(lgN)(这里N代表表示数据n的数据类型位数,此题中N
= 8)

特点:采用分治的思想,逐步缩小运算规模,求解问题的过程像是一棵倒置的二叉树。

算法实现(基本操作:shift and add,仅针对8位数据类型。针对更高字节数的数据类型,可以参考维基百科中的实现,并且维基百科还对它进行了进一步的优化,减少更多的运算操作):

  9 #include <stdlib.h>
 10 #include <stdio.h>
 11 
 12 typedef unsigned char TYPE;
 13 TYPE N = 234;
 14 
 15 TYPE count_number_1_in_byte(TYPE n)
 16 {
 17     n = (n & 0x55) + ((n >> 1) & 0x55);
 18     n = (n & 0x33) + ((n >> 2) & 0x33);
 19     n = (n & 0x0f) + ((n >> 4) & 0x0f);
 20     return n;
 21 }
 22 
 23 int main(void)
 24 {
 25     TYPE sum = 0;
 26     sum = count_number_1_in_byte(N);
 27     printf("sum of number 1 in %d is %d.\n", N, sum);
 28     return EXIT_SUCCESS;
 29 }

下面代码片来自维基百科,通过对代码片分析和学习,进一步认识hamming weight 算法及其优化:

//types and constants used in the functions below
 
const uint64_t m1  = 0x5555555555555555; //binary: 0101...
const uint64_t m2  = 0x3333333333333333; //binary: 00110011..
const uint64_t m4  = 0x0f0f0f0f0f0f0f0f; //binary:  4 zeros,  4 ones ...
const uint64_t m8  = 0x00ff00ff00ff00ff; //binary:  8 zeros,  8 ones ...
const uint64_t m16 = 0x0000ffff0000ffff; //binary: 16 zeros, 16 ones ...
const uint64_t m32 = 0x00000000ffffffff; //binary: 32 zeros, 32 ones
const uint64_t hff = 0xffffffffffffffff; //binary: all ones
const uint64_t h01 = 0x0101010101010101; //the sum of 256 to the power of 0,1,2,3...
 
//This is a naive implementation, shown for comparison,
//and to help in understanding the better functions.
//It uses 24 arithmetic operations (shift, add, and).
int popcount_1(uint64_t x) {
    x = (x & m1 ) + ((x >>  1) & m1 ); //put count of each  2 bits into those  2 bits 
    x = (x & m2 ) + ((x >>  2) & m2 ); //put count of each  4 bits into those  4 bits 
    x = (x & m4 ) + ((x >>  4) & m4 ); //put count of each  8 bits into those  8 bits 
    x = (x & m8 ) + ((x >>  8) & m8 ); //put count of each 16 bits into those 16 bits 
    x = (x & m16) + ((x >> 16) & m16); //put count of each 32 bits into those 32 bits 
    x = (x & m32) + ((x >> 32) & m32); //put count of each 64 bits into those 64 bits 
    return x;
}
 
//This uses fewer arithmetic operations than any other known  
//implementation on machines with slow multiplication.
//It uses 17 arithmetic operations.
int popcount_2(uint64_t x) {
    x -= (x >> 1) & m1;             //put count of each 2 bits into those 2 bits
    x = (x & m2) + ((x >> 2) & m2); //put count of each 4 bits into those 4 bits 
    x = (x + (x >> 4)) & m4;        //put count of each 8 bits into those 8 bits 
    x += x >>  8;  //put count of each 16 bits into their lowest 8 bits
    x += x >> 16;  //put count of each 32 bits into their lowest 8 bits
    x += x >> 32;  //put count of each 64 bits into their lowest 8 bits
    return x & 0x7f;
}
 
//This uses fewer arithmetic operations than any other known  
//implementation on machines with fast multiplication.
//It uses 12 arithmetic operations, one of which is a multiply.
int popcount_3(uint64_t x) {
    x -= (x >> 1) & m1;             //put count of each 2 bits into those 2 bits
    x = (x & m2) + ((x >> 2) & m2); //put count of each 4 bits into those 4 bits 
    x = (x + (x >> 4)) & m4;        //put count of each 8 bits into those 8 bits 
    return (x * h01)>>56;  //returns left 8 bits of x + (x<<8) + (x<<16) + (x<<24) + ... 
}
//This is better when most bits in x are 0
//It uses 3 arithmetic operations and one comparison/branch per "1" bit in x.
int popcount_4(uint64_t x) {
    int count;
    for (count=0; x; count++)
        x &= x-1;
    return count;
}
static uint8_t wordbits[65536] = { /* bitcounts of integers 0 through 65535, inclusive */ };
static int popcount(uint32_t i)
{
    return (wordbits[i&0xFFFF] + wordbits[i>>16]);
}

代码片学习笔记:

popcount_1函数是最基本的实现,采用移位和求和两种操作实现8字节数据中“1”个数统计,用于理解算法分治的思想(分治算法的基本思想是将一个规模为N的问题分解为K个规模较小的子问题,这些子问题相互独立且与原问题性质相同。求出子问题的解,就可得到原问题的解。)。

popcount_2函数相比上一个函数实现减少了更多的操作步骤:

  1.x = (x & m1) + ((x >> 1) & m1); 优化为: x -= (x >> 1) & m1;  这一步优化后少了一个与运算操作,它基于一个判定:ab - 0a = a + b,证明:当a = 0时,0b - 00 = 0 + b 等式成立,当a = 1时,若b = 0,10 - 01 = 1 + 0等式成立,若b
= 1,11 - 01 = 1 + 1 = 10等式也成立。

  2.x = (x & m4) + ((x >> 4) & m4); 优化为:x = (x + (x >> 4)) & m4;这一步优化后少了一个与运算操作,原理是此时各自4位先求和(最大值为8)不会引起前面高4的位加一(需要和达到16),所以可以先求和再求与运算。而对于x
= (x & m2) + ((x >> 2) & m2);这一步不能实现相似的优化就是因为2位先求和(最大值为4时)会引起前面高2位加一(需要和达到4)导致计算错误。

  3.x += x >> 8;x += x >> 16;x += x >> 32;x & 0x7f; 能够实现这一优化的原因是:对于8字节数据,“1”的个数最大位64,只需只用7个bit位就可以表示完全,各个位移后相加其他位情况不需考虑,并不会影响底7位的正确计数,最后只需与0x7f求与运算即可保留并得到正确的计数结果。

popcount_3函数相比上一个函数实现减少了更多的操作步骤,但是有一个高位乘法运算,因此适用于乘法运算速度较快的机器:

  1.x += x >> 8;x += x >> 16;x += x >> 32;x & 0x7f;优化为:(x * h01)>>56;原理是:此步骤前x=0x0a0b0c0d0e0f0g0h(将每四个位求和的结果a,b,c,d,e,f,g,h表示为16进制形式),x
* h01 = x * 0x01010101010101... = x * (0x0100000... + 0x0001.... + 0x000001... + .... + 0x01) = x * 0x01000.. + x * 0x0001... + ... + x * 0x01 = x << 56 + x << 48 + ... + x << 8 + x;此时最高的8位其值便是a + b + c + d + e + f + g + h,再右移56位即可得到正确的计数结果。

popcount_4函数与上述版本四一致,不过提供了一个重要的信息,对于一个大多数位都是零值的数来说,版本四的代码效率实际上会更高些。

popcount_5函数与上述版本五都采用了查表法的方法,却能够实现更多字节数数据类型的运算。值得学习的是它采用了分治的思想,把多字节数据数据类型,分解为各个小字节数据类型的组合(移位运算),再通过查表法统计求和。

 版本六(HAKMEM):这个方法从数学问题的角度出发去考虑,最后进行实现。

1.定义一个以n为底的多项式:P(k) = a0 * n^0 + a1 * n^1 + a2 * n^3 + ... + ak * n^k

 定义对P(k)进行多项式消权后得到的结果为:S = a0 + a1 + a2 + ... + ak

 若把这个想法和“求解二进制“1”的个数”这一个问题相结合,则我们考虑的问题中:P(k)是一个以2为底的多项式,a1 .. ak代表各个位上的数值,包括1和0两种,消权后的结果S即为问题的解:“1”的个数。

2.我们证明一个接下来要用到的等式:

 对任何自然数n的N次幂,用n-1取模得数为1:(n^N) % (n - 1) = 1。

 证明:若 (n^(k-1)) % (n-1) = 1 成立,则:

     (n^k) % (n-1) = (n^k - n^(k-1) + n^(k-1)) % (n-1)

                   = ((n-1)*n^(k-1) + n^(k-1)) % (n-1)

                   = 0 + (n^(k-1)) % (n-1)

                   = 1 也成    

   又当k = 1 时:(n^(k - 1)) % (n-1) = (n^0) % (n-1) = 1

   得证。

3.P(k) % (n-1)

  = (a0 * n^0) % (n-1) + (a1 * n^1) % (n-1) +(a2 * n^0) % (n-1) + ... +(ak * n^k) % (n-1)

 上面等式:若a0, a1, a2...ak 每个系数均小于n-1,则这些系数不会影响到求余运算,等式可以继续转化为:

  P(k) % (n-1) = a0 * 1 + a1 * 1 + a2 * 1 + ... + ak * 1 =a0 + a1 + a2 + ... + ak

  即对多项式完成了消权,并求得消权后系数的和。

  进一步可推断,若(a0 + a1 + a2 + ... + ak) < (n - 1),则上述式子必然成立。

4.在“求解二进制“1”的个数”这一个问题时,若利用计算机二进制表达方式,我们的多项式以2为底,n = 2,显然上式中(a0
+ a1 + a2 + ... + ak) <
(n - 1) 无法满足条件(针对所有情况)。但是,如果我们的多项式不以2为底,而换成更大的数,满足条件时有可能的。对于32位整数(4字节)来说,(a0 + a1 + a2 + .. + ak) <= 32 ,故取n > 33 便可以满足条件。考虑到
计算机二进制移位运算效率比乘除运算效率高这个特点,同时针对实现多个位求“1”个数这个问题,我们可以取n = 2^6 = 64.

5.要应用上面这个思路,我们必须对下面初始的二进制表式先进行转化:

 P(k) = a0 * 2^0 + a1 *
2^1 + a2 * 2^3 + ... + ak * 2^k   (n = 2)

 我们针对32位整数着一个情况,我们可以把它转化为:

 P(k)
= b0 * 64^0 + b1 * 64^1 + b2 * 64^3 + ... + bk * 64^k   (n = 64)

 这样我们再对转换后的多项式进行求模运算,即可得到:

 P(k)
% (64 - 1) = b0 + b1 + b2 + .. + bk(n
= 64)

 那么,此时a0,a1,a2
... ak 和b0,b1,b2 ... bk之间的关系是什么呢?因为我们的问题是求解二进制数中“1”的个数,所以a1 .. ak代表各个位上的数值,包括1和0两种,为了使b0 + b1 + b2 + ... + bk这个
值的结果也表示为二进制数中“1”的个数,我们可以对a1
... a6 这6个位求和得到b0,对a7 ... a12这6个位求和得到b1,(有:n = 2^6 = 64),如此下去,那么最后的表达式P(k)
% (64 - 1)
就是问题的解:二进制数中“1”的个数!

 具体实现如下:对于六位二进制数ti(n = 2),转换为b(n = 64)

 b = (ti>>5)&(000001) + (ti&>>4)(000001) + (ti>>3)&(000001) + (ti>>2)&(000001)
+ (ti>>1)&(000001) + ti&(000001)

算法实现:通过以上分析,我们可以得到32位整型数据的初级版的HAKMEM的算法:

  9 #include <stdlib.h>
 10 #include <stdio.h>
 11 
 12 typedef unsigned int TYPE;
 13 TYPE N = 234;
 14 
 15 TYPE count_number_1_in_byte(TYPE n)
 16 {
 17     TYPE m1 = 0x41041041;
 18     n = (n & m1) +
 19         ((n >> 1) & m1) +
 20         ((n >> 2) & m1) +
 21         ((n >> 3) & m1) +
 22         ((n >> 4) & m1) +
 23         ((n >> 5) & m1);
 24     return n % 63;
 25 }   
 26 
 27 int main(void)
 28 {
 29     TYPE sum = 0;
 30     sum = count_number_1_in_byte(N);
 31     printf("sum of number 1 in %d is %d.\n", N, sum);
 32     return EXIT_SUCCESS;
 33 }                                                                               

(解释下m1 = 0x41041041,这个值是十六进制从低位开始每6个位置000001得到的,因为是6个位求和,但为了更好的表示含义可以修改为8进制数(每3个二进制位求和),即m1 = 010101010101(C语言0开头的常数为8进制数),后面的算法均采用八进制表达)

优化一:对于6位二进制数来说,“1”个数最大值为6,仅需占用3位便可以表达最大值(二进制数110)。因此,可以采用分治的思想,把6位二进制数二分,对每三位求“1”的个数和,再利用移位求和得到6位二进制“1”的个数和。由此得到优化后的算法:

  9 #include <stdlib.h>
 10 #include <stdio.h>
 11 
 12 typedef unsigned int TYPE;
 13 TYPE N = 234;
 14 
 15 TYPE count_number_1_in_byte(TYPE n)
 16 {
 17     n = (n & 011111111111) +
 18         ((n >> 1) & 011111111111) +
 19         ((n >> 2) & 011111111111);
 20     n = (n + (n >> 3)) & 030707070707;
 21     return n % 63;
 22 }
 23 
 24 int main(void)
 25 {
 26     TYPE sum = 0;
 27     sum = count_number_1_in_byte(N);
 28     printf("sum of number 1 in %d is %d.\n", N, sum);
 29     return EXIT_SUCCESS;
 30 }                                                                               

(注意上一个版本中的常数为:010101010101(6位求和),这个版本中变化为变化为011111111111(3位求和))

优化二:下面的优化减少了更多的操作数,基于这一个原理:对于3位的二进制数4a+2b+c来说,我们求解的是a
+ b + c,可以通过以下等式求解,a + b + c = (4a + 2b + c) - (2a + b) - a,这里(4a + 2b + c)表示原始的3位二进制数,(2a + b)表示原始的3位二进制数右移一位的结果,a表示原始的3位二进制数右移两位的结果。利用这个等式可以简化运算步骤,得出以下的算法:

 9 #include <stdlib.h>
 10 #include <stdio.h>
 11 
 12 typedef unsigned int TYPE;
 13 TYPE N = 234;
 14 
 15 TYPE count_number_1_in_byte(TYPE n)
 16 {
 17     n = n - ((n >> 1) & 033333333333) -
 18         ((n >> 2) & 011111111111);
 19     n = (n + (n >> 3)) & 030707070707;
 20     return n % 63;
 21 }
 22 
 23 int main(void)
 24 {
 25     TYPE sum = 0;
 26     sum = count_number_1_in_byte(N);
 27     printf("sum of number 1 in %d is %d.\n", N, sum);
 28     return EXIT_SUCCESS;
 29 }                                                                               

抱歉!评论已关闭.