位运算

位运算

二进制数

二进制数采用以2为基数的计数系统,仅使用两个符号,通常是“0”和“1”。

某一位的值为1时,称该位已置位;值为0时,称该位已清零。

二进制数(aₖ aₖ₋₁ … a₁ a₀)₂表示的数值为:

aₖ·2ᵏ + aₖ₋₁·2ᵏ⁻¹ + … + a₁·2 + a₀

例如,二进制数1101₂表示十进制数13:

1101₂ = 1·2³ + 1·2² + 0·2¹ + 1·2⁰ = 13

计算机把整数表示为二进制数。正整数(包括有符号和无符号类型)直接用其二进制位表示;有符号负整数通常使用补码表示。

unsigned int unsigned_number = 13;
assert(unsigned_number == 0b1101);

int positive_signed_number = 13;
assert(positive_signed_number == 0b1101);

int negative_signed_number = -13;
assert(negative_signed_number == 0b1111'1111'1111'1111'1111'1111'1111'0011);

CPU能通过专门的运算快速操作这些位。某些问题可以利用二进制表示缩短执行时间。在组合数学、动态规划等需要记录从给定集合中已选择哪些对象的问题里,还可以使用足够宽的整数:每一位代表一个对象,选择时置位,移除时清零。

位运算符

对于固定宽度整数,以下运算在CPU上都可快速完成,原文将其速度与加法相提并论。

按位运算符

  • &:按位与,逐位比较两个操作数。只有两位都为1时,结果对应位才为1;否则为0。

  • |:按位或,逐位比较两个操作数。只要有一位为1,结果对应位就为1;否则为0。

  • ⊕:按位异或(C++中写作^),逐位比较两个操作数。一位为0、另一位为1时,结果对应位为1;否则为0。

  • ~:按位取反,翻转整数的每一位,1变为0,0变为1。

示例:

n         = 01011000
n-1       = 01010111
--------------------
n & (n-1) = 01010000
n         = 01011000
n-1       = 01010111
--------------------
n | (n-1) = 01011111
n         = 01011000
n-1       = 01010111
--------------------
n ^ (n-1) = 00001111
n         = 01011000
--------------------
~n        = 10100111

移位运算符

移位运算符有两种。

  • >>:右移,移除整数末尾的若干二进制位。对非负整数而言,每右移一位相当于整除2,因此右移k位相当于整除2ᵏ。

    例如,5 >> 2 = 101₂ >> 2 = 1₂ = 1,与5 / 2² = 5 / 4的整数商1相同。原文指出,计算机移位通常比除法更快。

  • <<:左移,在末尾补零。与右移类似,左移k位相当于乘以2ᵏ,前提是结果在相关类型和语言规则允许的范围内。

    例如,5 << 3 = 101₂ << 3 = 101000₂ = 40,与5 × 2³ = 5 × 8 = 40相同。

    固定宽度整数的高位可能被丢弃。原文概括说,移位过多可能最终得到0;但C++还要求满足位宽与类型规则,具体限制见本节提示。

实用技巧

设置、翻转与清除某一位

利用移位和基本位运算,可以很容易地设置、翻转或清除某一位。1 << x只有第x位为1,~(1 << x)则只有第x位为0,其余位为1。

  • n | (1 << x):设置n的第x位。
  • n ^ (1 << x):翻转n的第x位。
  • n & ~(1 << x):清除n的第x位。

检查某一位是否置位

把整数右移x位,使第x位位于最低位,再与1按位与,即可获取该位的值。

bool is_set(unsigned int number, int x) {
    return (number >> x) & 1;
}

检查整数能否被2的幂整除

通过按位与可以判断奇偶:偶数满足n & 1 = 0,奇数满足n & 1 = 1。更一般地,n能被2ᵏ整除,当且仅当n & (2ᵏ − 1) = 0。

bool isDivisibleByPowerOf2(int n, int k) {
    int powerOf2 = 1 << k;
    return (n & (powerOf2 - 1)) == 0;
}

把1左移k位可以计算2ᵏ。此技巧成立,是因为2ᵏ − 1恰好包含k个连续的1,而能被2ᵏ整除的整数,在这些位置必须全为0。

检查整数是否为2的幂

2的幂只有一个置位,例如32 = 0010 0000₂。它的前一个整数,在该位为0、之后所有位为1,例如31 = 0001 1111₂。因此,二者没有共同置位,按位与的结果为0。这种情况仅发生在2的幂以及本来没有任何置位的0上,所以还要单独排除0。

bool isPowerOfTwo(unsigned int n) {
    return n && !(n & (n - 1));
}

清除最右侧的置位

表达式n & (n − 1)可以清除n最右侧的置位。n − 1会翻转该位以及其后的所有位,这些位置都与原数不同;按位与后,它们全部变为0,结果即原数清除最右侧置位后的值。

例如,考虑52 = 0011 0100₂:

n         = 00110100
n-1       = 00110011
--------------------
n & (n-1) = 00110000

Brian Kernighan 算法

利用上述表达式,可以统计置位数量。

每次只处理整数中为1的位:计数后清除最右侧的置位,下次循环再处理新的最右侧置位。

int countSetBits(int n)
{
    int count = 0;
    while (n)
    {
        n = n & (n - 1);
        count++;
    }
    return count;
}

统计0到n的置位总数

要统计从0到n(含n)所有整数的置位总数,可以逐个运行Brian Kernighan算法,但竞赛提交可能因此超时。

可以利用这一规律:从1到2ˣ − 1的所有整数,共有x × 2ˣ⁻¹个置位。下表展示了这个规律:

0 ->   0 0 0 0
1 ->   0 0 0 1
2 ->   0 0 1 0
3 ->   0 0 1 1
4 ->   0 1 0 0
5 ->   0 1 0 1
6 ->   0 1 1 0
7 ->   0 1 1 1
8 ->   1 0 0 0

除最左侧以外,每列都有4(即2²)个置位。因此,从0到2³ − 1,总置位数为3 × 2³⁻¹。

利用这一规律,可以构造以下算法:

  • 找到最大的指数x,使2ˣ不大于给定整数n。
  • 用公式x × 2ˣ⁻¹计算从1到2ˣ − 1的置位总数。
  • 统计2ˣ到n之间最高位的置位数,并加到总数中。
  • 从n中减去2ˣ,用新的n重复以上步骤。
long long popcount_sum(unsigned n) {
    long long count = 0;
    while (n > 1) {
        int x = std::bit_width(n) - 1;
        count += (long long)x << (x - 1); // set bits below 2^x
        n -= 1u << x;
        count += n + 1;                   // leading bits of 2^x..n
    }
    return count + n;
}

其他技巧

  • n & (n + 1)清除末尾所有连续的1:0011 0111₂ → 0011 0000₂。
  • n | (n + 1)设置最右侧的0:0011 0101₂ → 0011 0111₂。
  • n & −n提取最右侧的置位:0011 0100₂ → 0000 0100₂。

更多技巧见《Hacker’s Delight》。

语言与编译器支持

C++20起,标准库bit提供了一些相关操作:

  • has_single_bit:检查整数是否为2的幂。
  • bit_ceil / bit_floor:向上或向下取整到相邻的2的幂。
  • rotl / rotr:循环移动整数中的位。
  • countl_zero / countr_zero / countl_one / countr_one:统计前导或末尾连续的0、1。
  • popcount:统计置位数量。

一些编译器也提供了辅助位操作的内置函数。例如,GCC的Built-in Functions Provided by GCC列表中的函数,较旧C++版本也可使用:

  • __builtin_popcount(unsigned int)返回置位数,例如__builtin_popcount(0b0001'0010'1100) == 4。
  • __builtin_ffs(int)返回第一个(最右侧)置位的位置,位置从1开始,例如__builtin_ffs(0b0001'0010'1100) == 3。
  • __builtin_clz(unsigned int)返回前导零的数量。例如,在32位unsigned int上,__builtin_clz(0b0001'0010'1100) == 23。
  • __builtin_ctz(unsigned int)返回末尾零的数量,例如__builtin_ctz(0b0001'0010'1100) == 2。
  • __builtin_parity(x)返回二进制表示中1的数量的奇偶性。

原文提醒:如果没有用#pragma GCC target("popcnt")启用特定编译目标,部分操作(包括C++20函数和编译器内置函数)在GCC中可能较慢。

练习题

正文相关链接

来源与许可

原文:位运算。作者/维护者:cp-algorithms贡献者。

CC BY-SA 4.0;许可条款。本页为中文翻译或中文整理,保留原始示例;措辞与排版有调整。另加入明确标注的适用边界提示。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容