前言
看到 N O I \rm NOI NOI 系列支持 builtin \text{builtin} builtin 函数,喜出望外,赶紧去学了一下。拾来的别人的牙慧。
免责声明:我是看别人说的可以用,一切后果由使用者自行承担。
注意 builtin \text{builtin} builtin 函数的参数都是 u n s i g n e d i n t \tt unsigned\;int unsignedint 类型。函数名的末尾加上 l l \rm ll ll 则变为 u n s i g n e d l o n g l o n g \tt unsigned\;long\;long unsignedlonglong 类型。
最低二进制位
原意应该是 f i n d f i r s t s e t \rm find\; first\; set findfirstset,找到第一个被 s e t \rm set set 为 1 1 1 的二进制位。
__builtin_ffs
,若参数为
n
n
n,返回值为
x
x
x,表示
2
x
−
1
2^{x-1}
2x−1 是
n
n
n 的最低二进制位。
为了某种统一性, n = 0 n=0 n=0 时的返回值为 x = 0 x=0 x=0 。
性能测试
测试环境为 学校机房的电脑
W
i
n
d
o
w
s
10
\rm Windows10
Windows10 系统
64
64
64 位机,配置
Inter(R) Core(TM) i5-9500T
\text{Inter(R) Core(TM) i5-9500T}
Inter(R) Core(TM) i5-9500T
CPU @ 2.20GHz 2.21 GHz
\text{CPU @ 2.20GHz 2.21 GHz}
CPU @ 2.20GHz 2.21 GHz 。
参赛选手是我们最喜欢的查表法(预处理 2 16 2^{16} 216 以内的所有数的结果)和内置函数。
使用 m t 19937 \rm mt19937 mt19937 随机(两次的种子相同)生成 32 32 32 位无符号整形,进行 1 0 8 10^8 108 次查询,不开 O 2 O2 O2 时,查表法约 2.9 2.9 2.9 秒,内置函数约 2.4 2.4 2.4 秒;开启 O 2 O2 O2 后,即使进行 1 0 9 10^9 109 次查询,运行速度差异不到 0.1 0.1 0.1 秒。可见查表法极快,效率差异的原因或许是函数调用。
而使用 mt19937_64 \text{mt19937\_64} mt19937_64 生成 64 64 64 位无符号整形时,进行 5 × 1 0 8 5\times 10^8 5×108 次查询,开启 O 2 O2 O2 的情况下查表法仍然是以约 6 6 6 秒的成绩略落后于内置函数 5.4 5.4 5.4 秒的成绩。所以有了结论:随机数据下内置函数最快!
当然,事实上二者的差距比较小。我的建议是用内置函数,因为方便。
最高二进制位
这个不能直接查。但是我们可以 c o u n t l e a d i n g z e r o \rm count\; leading\; zero countleadingzero,数前导零!
__builtin_clz
,返回前导零的数量。注意它是从第
32
32
32 位(毕竟参数是无符号整形)开始数的。
2022/8/22 update \texttt{2022/8/22 update} 2022/8/22 update:参数为 0 0 0 会导致 runtime error \text{runtime error} runtime error,难以置信! sanitizer \text{sanitizer} sanitizer 也会提醒你这一点。
另:一位著名选手也有一种快速求最高二进制位的方法,而且是
O
(
1
)
\mathcal O(1)
O(1) 的,无任何内联函数。虽然跑得更慢就是啦。
二进制位计数
这是最常用的。它听上去很简单,却成了自由发挥想象力的舞台!
下面是瞎讲一通。具体只要记住
p
o
p
u
l
a
t
i
o
n
c
o
u
n
t
\rm population\;count
populationcount,即 __builtin_popcount
就好了。
查表法
一般自己的程序就这么实现——预处理一个 2 8 2^8 28 或者更长的表,按照每 8 8 8 位直接查表。
事实上还有一种离谱的写法:利用 宏递归展开,如
# define BIT2(n) n, n+1, n+1, n+2
# define BIT4(n) BIT2(n), BIT2(n+1), BIT2(n+1), BIT2(n+2)
# define BIT6(n) BIT4(n), BIT4(n+1), BIT4(n+1), BIT4(n+2)
# define BIT8(n) BIT6(n), BIT6(n+1), BIT6(n+1), BIT6(n+2)
static const uint8_t table[256] = {BIT8(0)};
每次考虑最高的 2 2 2 个二进制位,剩下的部分递归。
并行计算
从未听说过的技巧,但是极具想象力。考虑到这样一个事实: 2 k > k ( k ∈ N ) 2^k>k\;(k\in\N) 2k>k(k∈N),也就是说,任意 k k k 位二进制数,二进制下 1 1 1 的数量可以直接存储到它原来所占用的二进制位里。
那么,类似 f f t \rm fft fft 去掉递归的方法,我们从最底层开始,逐步合并。最底层是只考虑 1 1 1 个二进制位,那么原本的 b i t \rm bit bit 就是二进制下 1 1 1 的数量。然后我们合并相邻的两个:
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 ) & 0x000FFFF );
就能得到最终结果了!真是富有创造力!
一级优化
观察到最后一步等价于 n = ( n m o d 2 16 ) + n 2 16 n=(n\bmod 2^{16})+\frac{n}{2^{16}} n=(nmod216)+216n,考虑将其转化为 n m o d ( 2 16 − 1 ) n\bmod(2^{16}-1) nmod(216−1) 。显然二者是相等的,因为结果不超过 32 32 32,模 2 16 − 1 = 65535 2^{16}-1=65535 216−1=65535 也没什么问题。
二级优化
仍然运用上面的思考, n m o d ( 2 k − 1 ) n\bmod(2^k-1) nmod(2k−1) 在 2 k − 1 > 32 2^k-1>32 2k−1>32 时等价于 k k k 位为一组的二进制值相加。显然应当取 k = 6 k=6 k=6,如何实现呢?
第一步要解决长度为 3 3 3 的分组。事实上我们可以使用 n − ∑ i = 1 + ∞ ⌊ n 2 i ⌋ n-\sum_{i=1}^{+\infty}\lfloor\frac{n}{2^i}\rfloor n−∑i=1+∞⌊2in⌋ 得到任意数的二进制位 1 1 1 的个数,因为 2 k − 2 k − 1 − ⋯ − 2 0 = 1 2^k-2^{k-1}-\cdots-2^0=1 2k−2k−1−⋯−20=1,就恰好会贡献 1 1 1 。
那么第一步无非是
n = n - ( (n >> 1) & 033333333333 ) - ( (n >> 2) & 011111111111 );
由于是 3 3 3 位为一组,采用了更容易理解的八进制数字常量。然后接下来,并行计算与取模,放在一起即可。
return ( ( n + ( n >> 3 ) ) & 030707070707 ) % 63;
分支预测
__builtin_expect(exp,c)
表示表达式
e
x
p
exp
exp 的结果更可能是
c
∈
{
0
,
1
}
c\in\{0,1\}
c∈{0,1},一般常用于
i
f
\rm if
if 语句的判断。例如
if(__builtin_expect(zxy == sister, true))
puts("I've already know that it's definitely right!");
则编译器认为,这个
i
f
\rm if
if 语句很可能会成立,那么在汇编中就成了:如果该语句不成立,跳过
i
f
\rm if
if 内的语句块。由于该
i
f
\rm if
if 很可能成立,所以跳转的次数就会很少。习得卡常新技巧!