惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

C
CXSECURITY Database RSS Feed - CXSecurity.com
GbyAI
GbyAI
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Y
Y Combinator Blog
U
Unit 42
M
MIT News - Artificial intelligence
F
Fortinet All Blogs
MongoDB | Blog
MongoDB | Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Hugging Face - Blog
Hugging Face - Blog
B
Blog
T
Tailwind CSS Blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
H
Help Net Security
D
Docker
NISL@THU
NISL@THU
P
Privacy International News Feed
L
Lohrmann on Cybersecurity
Scott Helme
Scott Helme
P
Palo Alto Networks Blog
S
Securelist
WordPress大学
WordPress大学
B
Blog RSS Feed
Blog — PlanetScale
Blog — PlanetScale
有赞技术团队
有赞技术团队
L
LINUX DO - 热门话题
人人都是产品经理
人人都是产品经理
博客园 - 聂微东
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Security Latest
Security Latest
D
DataBreaches.Net
T
The Exploit Database - CXSecurity.com
Recorded Future
Recorded Future
Stack Overflow Blog
Stack Overflow Blog
T
The Blog of Author Tim Ferriss
The Hacker News
The Hacker News
I
Intezer
T
Threat Research - Cisco Blogs
Cyberwarzone
Cyberwarzone
C
Cyber Attacks, Cyber Crime and Cyber Security
P
Privacy & Cybersecurity Law Blog
Cisco Talos Blog
Cisco Talos Blog
L
LangChain Blog
Jina AI
Jina AI
量子位
V
V2EX
阮一峰的网络日志
阮一峰的网络日志
Last Week in AI
Last Week in AI
AWS News Blog
AWS News Blog
Apple Machine Learning Research
Apple Machine Learning Research

网络热度

LeetCode 75 学习计划清单和参考答案 AI辅助生成的统计博客文章浏览量的网络爬虫 程序员面试题精解(5)— 素数判定 程序员面试题精解(4)— 双指针 哈佛 CS50AI 课程介绍:从零开始学习人工智能 解决不蒜子文章阅读计数错误的问题 RSA的攻与防(三) RSA的攻与防(二) 启用 TLS 1.3 提升网络应用的安全性和性能 程序员面试题精解(3)— 二分查找 嵌入式Linux系统中uClibc标准库使用经验笔记 巧解picoCTF的RSA挑战题Sum-O-Primes 请马上停止使用TLS 1.0和TLS 1.1! 自己动手使用树莓派搭建家用NAS和流媒体服务器 iTerm2 + Oh-My-Zsh + Powerlevel10k 打造酷炫macOS终端 AddressSanitizer — 程序员检测内存访问错误的利器 程序员面试题精解(2)— 平方根运算 思科 Catalyst Wi-Fi 6 MU-MIMO 带你畅享速度与激情 C编程支持16位存取接口的存储设备
程序员面试题精解(1)— 比特位计数
子曦 · 2021-05-31 · via 网络热度

现代计算机的硬件设计建立于数字电路的基础之上,而数字电路采用以2为基数的二进制记数系统。由此,二进制及其数字位(称为比特)的运算构成计算机系统软件的基石。一个常见的程序员面试题,就是比特位计数,即计算给定整数的二进制表示中比特1的个数。

People who deal with bits should expect to get bitten.
Jon Bentley(乔恩·本特利,美国计算机科学家,计算机科学经典名著《编程珠玑》的作者,也以基于启发式的分区算法k-d树而知名)

这并不是一道很难的题目,但是要给出令人印象深刻的答案,却需要对二进制及比特位运算的深入理解,以及坚实的系统编程技巧和经验。实际上,比特位计数功能有悠久的历史。在早期的晶体管计算机时代,就有专门执行该功能的指令。如果主机的CPU指令集不支持此类指令,就必须使用软件代码实现。由于这一功能也常常被称为“种群统计”(population count),本文中所有解法的函数命名都以popcount_为前缀。下面我们来解析和评介这一面试题的各种解法和C语言实现。

如果熟悉基本的比特位运算操作(取反、与、或、异或、左/右移位),就可以很快找到一个解题的思路:生成一个单比特数0b1,将它与要计数的整数做位与运算,结果为1说明整数最右边的比特为1,否则为0;将此单比特数左移一位得到0b10,再与原整数做位与运算,用结果判定从右边起的第二位比特是否为1;重复此左移位与运算,就能够从右到左扫描到所有的比特1并计数。如下的代码实现了这一简单直接的解法:

1
2
3
4
5
6
7
8
9
10
11
int popcount_common1 (uint32_t n)
{
int c = 0;
int i = 0;
while (i < 32) {
if (n & (1 << i))
c++;
i++;
}
return c;
}

与之类似的解法,是固定单比特数0b1,而将输入整数逐次右移,这样所有的高位比特都可以移到最右边来检测。利用比特位右移运算时最左边补上符号位的特点,此解法可能不需要循环32次而提前结束,循环的次数等于最高位比特1与最右边比特的比特距离。以下的代码展示了这一解法:

1
2
3
4
5
6
7
8
9
int popcount_common2 (uint32_t n)
{
int c = 0;
while (n) {
c += n & 1;
n >>= 1;
}
return c;
}

考虑一个单字节8比特的整数n, 其数值为0x94,以二进制表示为0b10010100。如下图所示,如果将n减去1,得到0b10010011,即0x93。仔细观察二者的区别,减1后原整数最右边的比特1及后面的比特0全部取反,而其之前的比特位均保持不变。这时,如果将二者做位与运算,结果是0b10010000(0x90),最右的比特1被清除了!重复减1后位与操作,会得到0b10000000 (0x80),原整数从右边数第二个比特1被清除了。第三次同样的操作完成,得到0,即原来的全部三个比特1都被清零。

总结这一规律:将一个整数减去1,再同原整数做位与运算,得到的结果等于原整数最右边的比特1清零;在最终结果变成0之前,能重复进行这样的操作的次数,就是原整数中比特1的数目。基于这一规律,我们可以写出新的计数函数代码:

1
2
3
4
5
6
7
8
9
int popcount_fast1 (uint32_t n)
{
int c = 0;
while (n) {
c++;
n &= (n-1);
}
return c;
}

无独有偶,还有一种与之对称的解法,适用于比特1个数较多(即比特0较少)的情况。参考下图,整数数值为0xBD,二进制表示为0b10111101。如果将n加上1,得到0b10111110,即0xBE。对比二者,加1后原整数最右边的比特0及后面的比特1全部取反,而其之前的比特位均保持不变。这时,如果将二者做位或运算,结果是0b10111111(0xBF),最右的比特0被置位了!重复加1后位与操作,会得到0b11111111 (0xFF),原整数从右边数第二个比特0被置位了。这时原来的全部两个比特0都被置位,解法终止,原整数中比特1的个数等于总比特数减去重复操作的次数。

总结新的规律:将一个整数加上1,再同原整数做位或运算,得到的结果等于原整数最右边的比特0置位;在最终结果变成全比特1(等同于有符号数-1)之前,能重复进行这样的操作的次数,就是原整数中比特0的数目,而原整数中比特1的个数等于总比特数减去比特0的数目。这一规律对应的计数函数代码如下:

1
2
3
4
5
6
7
8
9
int popcount_fast2 (uint32_t n)
{
int c = 32;
while (n != -1) {
c--;
n |= (n+1);
}
return c;
}

在具体的应用场景中,如果要处理的数据量很大,达到GB甚至TB量级,并且已知绝大部分数值的比特1个数都是较少(或较多)的状况,还可以将循环语句展开,进一步加快运行速度。

以上两种快速解法的循环展开实现如下。代码先定义宏f(x)g(x)用来检测结果并即时返回计数值,然后在函数实现中拿掉while循环,取而代之的是分别反复调用f(x)g(x)。在任何一步满足if条件为真,就会马上返回。这实际上是一种以空间换时间的方案。虽然函数代码及编译后的指令数目会增加不少,但是在运行过程中,因为每次检测都减少了一个算术运算指令(用于更新计数变量),所以运行会更快。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

#define f(x) if ((n &= (n-1)) == 0) return x;
int popcount_fast1_unrolled (uint32_t n)
{
if (n == 0) return 0;
f( 1) f( 2) f( 3) f( 4) f( 5) f( 6) f( 7) f( 8)
f( 9) f(10) f(11) f(12) f(13) f(14) f(15) f(16)
f(17) f(18) f(19) f(20) f(21) f(22) f(23) f(24)
f(25) f(26) f(27) f(24) f(29) f(30) f(31)
return 32;
}


#define g(x) if ((n |= (n+1)) == -1) return 32-x;
int popcount_fast2_unrolled (uint32_t n)
{
if (n == -1) return 32;
g( 1) g( 2) g( 3) g( 4) g( 5) g( 6) g( 7) g( 8)
g( 9) g(10) g(11) g(12) g(13) g(14) g(15) g(16)
g(17) g(18) g(19) g(20) g(21) g(22) g(23) g(24)
g(25) g(26) g(27) g(24) g(29) g(30) g(31)
return 0;
}

如前所述,快速解法是比特1个数较少或较多的情况下的优选。在平均计数值为总比特数的一半时,仍然需要相当多的指令完成任务。对于前例的popcount_fast1popcount_fast2函数,平均需要循环16次,每次执行三次算术运算和一次比较/分支操作,最少64条操作指令。还有没有更好的、需要更少指令的通用解法呢?

当然有!早在40多年以前,三位美国计算机科学家Reingold、Nievergelt和Deo所著的《组合算法:理论和实践》1一书中,就详细讨论了一种应用“分而治之”(Divide and Conquer)策略的解法。这种解法是如此精妙,乃至应该将其升格以算法称呼才合适。下图演示了以16位的双字节数0xBFA6为例的计算过程,算法首先把相邻的两个比特位组合为一个位段,将这两个比特位的值相加,结果置于这个宽度为2的比特位段中。下一步把相邻的两个双比特位段的值相加,结果置于宽度为4的比特位段中。以此类推,四步之后就得到数值11(0x000B),正好就是原整数中比特1的总数。

这一看似帽子戏法的算法,是“分而治之”策略的典型应用。一个16位整数的比特1计数问题,被转化为两个8位整数的计数问题,分别解决后再将结果相加合并。递归运用此策略,直至分解到单个比特计数的问题。然后反复运用并行位运算求解再合并,即可求出最终的计数值。很明显,给定N比特位整数,此算法的时间复杂性是\(O(\log N)\)

1
2
3
4
5
6
7
8
9
10

int popcount_dnq1 (uint32_t n)
{
n = (n & 0x55555555) + ((n >> 1) & 0x55555555);
n = (n & 0x33333333) + ((n >> 2) & 0x33333333);
n = (n & 0x0F0F0F0F) + ((n >> 4) & 0x0F0F0F0F);
n = (n & 0x00FF00FF) + ((n >> 8) & 0x00FF00FF);
n = (n & 0x0000FFFF) + ((n >> 16) & 0x0000FFFF);
return n;
}

值得说明的一点是,此函数实现中的圆括号都是必要的。这是因为在C语言运算符的优先级次序表里,加减(+-)要高于移位(<<>>),而后者又高于比特位运算(&^|2。在n >> 1两边加圆括号则有助于增强代码的可读性。

另外,函数的第一行(上面代码段行号4)按照算法描述本来应该写成(n & 0xAAAAAAAA) >> 1,之所以这么写是为了避免在寄存器中生成两个大的常数。如果使用0xAAAAAAAA,在不支持单个“and not”指令的计算机里,就必须多耗费一条指令先将0x55555555取反,再与n做位与运算。后面四行的写法也出于同样的考虑。统计下来,这种“分而治之”算法的实现运算量恒定,总共只有20次算术运算操作,运行速度达到了快速解法平均速度的三倍多!

1
2
3
4
5
6
7
8
9
10

int popcount_dnq2 (uint32_t n)
{
n = n - ((n >> 1) & 0x55555555);
n = (n & 0x33333333) + ((n >> 2) & 0x33333333);
n = (n + (n >> 4)) & 0x0F0F0F0F;
n = n + (n >> 8);
n = n + (n >> 16);
return n & 0x3F;
}

真的有!有经验的系统程序员都知道,许多现代计算机处理器的设计都支持单条乘法指令。由此把一个数乘以0x01010101,等于将其每个长度为8的比特位段相加求和,结果置于最高的字节中,所以只要右移24位就可以得到我们要的计数值。根据这一点,我们可以得到“分而治之”算法的最优实现版本:

1
2
3
4
5
6
7
8

int popcount_dnq3 (uint32_t n)
{
n = n - ((n >> 1) & 0x55555555);
n = (n & 0x33333333) + ((n >> 2) & 0x33333333);
n = (n + (n >> 4)) & 0x0F0F0F0F;
return (n * 0x01010101) >> 24;
}

使用GCC编译器特定选项,可以生成混合C代码及对照汇编语言的列表(list)文件,用来验证popcount_dnq3实现的优越性。在运行于Intel处理器的Ubuntu Linux x86-64虚拟机上,执行以下命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
106:popcount.c    **** int popcount_dnq2 (uint32_t n)
107:popcount.c **** {
...
111:popcount.c **** n = n + (n >> 8);
972 .loc 1 111 16
973 0786 8B45FC movl -4(%rbp), %eax
974 0789 C1E808 shrl $8, %eax
975 .loc 1 111 7
976 078c 0145FC addl %eax, -4(%rbp)
112:popcount.c **** n = n + (n >> 16);
977 .loc 1 112 16
978 078f 8B45FC movl -4(%rbp), %eax
979 0792 C1E810 shrl $16, %eax
980 .loc 1 112 7
981 0795 0145FC addl %eax, -4(%rbp)
113:popcount.c **** return n & 0x3F;
982 .loc 1 113 14
983 0798 8B45FC movl -4(%rbp), %eax
984 079b 83E03F andl $63, %eax
...
117:popcount.c **** int popcount_dnq3 (uint32_t n)
118:popcount.c **** {
...
122:popcount.c **** return (n * 0x01010101) >> 24;
1034 .loc 1 122 15
1035 07e7 8B45FC movl -4(%rbp), %eax
1036 07ea 69C00101 imull $16843009, %eax, %eax
1036 0101

可以看到,popcount_dnq2实现里右移8/16位相加各自有三条指令(行号973、974、976和978、979、981),最后的位与0x3F有两条指令(行号983、984),一共8条指令;而在popcount_dnq3实现中只有两条:一条加载指令movl和一条乘法指令imullpopcount_dnq3完胜!

还有一种以空间换时间的“分而治之”方案,虽然看上去比较无趣,但是其执行效率却可与上面的其他算法比肩。这种解决方案预设一个256项的数组,填入0~255之间每个数的比特位计数值,之后对于输入的整数依次取出单个字节查表,然后将结果相加。以下是无比较/分支指令的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const char popcount_tab[256] =
{
0,1,1,2,1,2,2,3,1,2,2,3,2,3,3,4,1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,
1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,
1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,
2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,
1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,
2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,
2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,
3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,4,5,5,6,5,6,6,7,5,6,6,7,6,7,7,8
};


int popcount_tablelookup (uint32_t n)
{
return popcount_tab[n & 0xFF] +
popcount_tab[(n >> 8) & 0xFF] +
popcount_tab[(n >> 16) & 0xFF] +
popcount_tab[(n >> 24)];
}

透彻掌握本节的内容,一定可以让面试官对你刮目相看,因为你写出的代码,实现的就是广为认可的高效算法。不信去下载GCC最新的11.1.0版本,看看库文件libgcc/libgcc2.c里的比特位计数函数(复制如下),与前面的代码实质上是一样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int
__popcountSI2 (UWtype x)
{



#if __SIZEOF_INT__ > 2 && defined (POPCOUNTCST) && __CHAR_BIT__ == 8
x = x - ((x >> 1) & POPCOUNTCST (0x55));
x = (x & POPCOUNTCST (0x33)) + ((x >> 2) & POPCOUNTCST (0x33));
x = (x + (x >> 4)) & POPCOUNTCST (0x0F);
return (x * POPCOUNTCST (0x01)) >> (W_TYPE_SIZE - __CHAR_BIT__);
#else
int i, ret = 0;

for (i = 0; i < W_TYPE_SIZE; i += 8)
ret += __popcount_tab[(x >> i) & 0xff];

return ret;
#endif
}

比特位计数当然不仅仅是用来考验面试者的,它广泛应用于信息学、纠错编码和密码学等多个领域。实际上,“种群统计”就是二进制符号数据的汉明重量4。而两个数据比特串的汉明距离,就定义为二者对应位置上不同比特的个数,即二者相异或后的汉明重量。基于汉明距离的分析是密码分析学的一个关键技术,是历史上破解许多密码的关键。从比特位计数的算法,也衍生出一些快速计算汉明距离的算法,感兴趣者可以留言讨论。

事实上,比特位计数是如此重要,乃至GCC和Clang编译器都提供了内建库函数__builtin_popcount。Intel、AMD和ARM处理器也都为之设定了专门的指令。如下在运行于Intel处理器的Ubuntu Linux x86-64虚拟机上,指定GCC选项-march=native__builtin_popcount函数编译成单一指令popcntl

1
2
3
159:popcount.c    ****         count = __builtin_popcount(num);
1204 .loc 1 159 17
1205 08b4 F30FB845 popcntl -8(%rbp), %eax