CC++ & Algorithm

当数字开始排队跨步:重新理解 C++ 位移运算的底层逻辑

先抛一个结论:位移运算不是"乘除法的快捷方式",它是直接操作二进制位的底层指令。 把它当作乘除法的替代品来学,你会踩坑;把它当作"对二进制位排布的直接操控"来理解,很多看似奇怪的题目会变得理所当然。

我见过太多学生把 << 和 >> 背成"左移乘2、右移除2",然后在遇到溢出、负数、精度丢失时一脸茫然。问题出在认知顺序上——他们先记了结论,后理解原理。这篇文章我们反过来走一遍。

二进制位就是一排位置,位移就是整体挪动

一个 int 在内存里是 32 个二进制位排成一排。每个位有一个"权重":最右边是 2⁰,往左依次是 2¹、2²、2³……

位:   7   6   5   4   3   2   1   0
权重: 2⁷  2⁶  2⁵  2⁴  2³  2²  2¹  2⁰

数字 5 的二进制是 00000101,意思是 2² + 2⁰ = 4 + 1 = 5。

左移一位,就是所有位整体向左挪一格,右边补 0:

00000101  (5)
00001010  (10)  ← 左移1位

原来在 2⁰ 位置的 1 跑到了 2¹ 位置,权重从 1 变成 2;原来在 2² 位置的 1 跑到了 2³ 位置,权重从 4 变成 8。每个 1 的权重都翻倍了,所以总和翻倍。

这就是"左移等于乘2"的本质——不是乘法规则碰巧和位移一致,而是二进制的位置计数法天然具有这个性质。

右移同理,每个位向右挪,权重减半,低位被丢弃。所以右移等于除以2并向下取整。

一道题看清右移的"丢弃"本质

来看这道题:

int x = 23;
int y = x >> 2;  // y = ?

23 的二进制是 00010111。右移 2 位:

00010111  (23)
00000101  (5)   ← 右移2位,低2位丢弃,高位补0

结果是 5。

有人会算:23÷2 = 11.5,再÷2 = 5.75,四舍五入得 6。错。右移不是"除法后取整",而是直接砍掉低位的二进制数字。00010111 砍掉最后两位 11,剩下 000101,也就是 5。

这跟整数除法 23 / 4 = 5 结果一致,但机制完全不同。整数除法是数学运算后截断,右移是物理上把位扔掉。对于正数两者等价,但对于负数,差异就出来了——这也是为什么我建议初学阶段只对正数或无符号数做右移。

左移的溢出:不是"结果不对",而是"位放不下了"

unsigned int a = 5;
a = a << 3;  // a = ?

5 的二进制 00000101,左移 3 位:

00000101  (5)
00101000  (40)  ← 左移3位

结果 40,即 5 × 2³ = 40。

但如果左移的位数太多,把有意义的位挤出了数据类型的边界呢?

int big = 1 << 31;   // 1 跑到了符号位
int huge = 1 << 32;  // 未定义行为

int 只有 32 位,你把唯一的那个 1 一直往左推,推到第 32 位时它已经不在 int 的表示范围里了。这不是"算错了",而是"这个位没有地方放了"。C++ 标准对超出位宽的左移定义为未定义行为(UB),编译器可以做任何事。

实用建议:左移位数始终小于数据类型的位宽。 对于 int,不超过 31;对于 unsigned int,不超过 31;对于 long long,不超过 63。

位移在算法题里的真正价值

位移运算在算法竞赛中出现的频率远比你想象的高。它不只是"快速乘除",更常用于状态压缩、位掩码、二进制枚举等场景。

天平问题:二进制视角下的砝码

有一道题是这样的:左端放了 N 个砝码,每个砝码的质量都是 2 的幂次(1g、2g、4g、8g……),问右端最少放几个砝码能使天平平衡。

样例:左端放了 2 个 8g 砝码,总重 16g。右端最少放几个?答案是 1 个——放一个 16g 的就行。

再看一个:左端放了 6 个砝码:1、1、1、4、1、1,总重 1+1+1+4+1+1 = 9g。右端最少放几个?

9 的二进制是 1001,也就是 8 + 1。右端放一个 8g 和一个 1g,共 2 个砝码。答案就是 2。

这道题的本质是:把左端总质量用二进制表示,数一数有几个 1。 因为每种 2 的幂次砝码只有一个时,二进制表示中每个 1 对应一个砝码。但题目说砝码可以重复,所以需要先做"进位合并"——两个 1g 合并成一个 2g,两个 2g 合并成一个 4g,以此类推。合并完之后,二进制中 1 的个数就是最少砝码数。

这道题完美展示了位移思维:把数的二进制表示当作一组"开关"来读,每个开关对应一个 2 的幂次。

找下一个"1的个数相同"的数

另一道经典题:给定正整数 N,求最小的比 N 大的数 M,使得 M 和 N 的二进制表示中 1 的个数相同。

比如 N = 78,二进制 1001110,有 4 个 1。比它大的最小数中,二进制也含 4 个 1 的是 83,二进制 1010011。

思路需要用到位运算的组合:

  1. 找到从右往左第一个 "01" 模式的位置(即一个 0 后面跟着 1)
  2. 把这个 0 变成 1,同时把它右边的所有位重新排列——把原来右边的 1 全部移到最右边

用位运算表达就是:

// 以 78 (1001110) 为例
// 找到最右边的 01 模式,翻转成 10
// 然后将右侧所有 1 移到最低位

这道题考察的不是"左移等于乘2",而是把二进制位当作可独立操控的对象来使用。<<、>>、&、| 在这里是操作工具,不是计算捷径。

优先级陷阱:位移运算符的"社交位置"

C++ 中运算符有优先级。<< 和 >> 的优先级低于加减法,高于关系运算符。这意味着:

cout << 5 + 3 << 2;  // 实际是 ((5+3) << 2) = 32

+ 先算得 8,然后 8 << 2 得 32。但如果你写:

cout << (5 << 2) + 3;  // 20 + 3 = 23

加括号后语义就清晰了。

更隐蔽的坑是 cout 的 << 和位移的 << 是同一个符号,编译器靠上下文区分。当它们混在一起时:

cout << a << 2;  // 这是输出 a,然后输出 2?还是输出 a<<2?

答案是:cout << a 返回 cout,然后 cout << 2 输出 2。如果你想要位移结果,必须加括号:cout << (a << 2)。

拿不准就加括号。 这不是代码风格问题,是正确性问题。

一个容易忽略的事实:位移不改变原变量

int a = 5;
a << 1;        // 这行代码什么都没发生
cout << a;     // 还是 5

a << 1 产生了一个临时值,但没有赋给任何变量。这跟 a + 1 不改变 a 是一样的道理。要改变 a,得写 a = a << 1 或 a <<= 1。

这个错误之所以常见,是因为它不报错、不崩溃、不警告,程序安安静静地跑出了错误结果。

进阶方向

位移运算只是一个入口。真正强大的是它背后的整套位运算体系:

  • n & (n-1):消除最低位的 1,可以用来判断一个数是否是 2 的幂
  • n & (-n):提取最低位的 1,树状数组的核心操作
  • n | (1 << k):把第 k 位置为 1
  • n & ~(1 << k):把第 k 位置为 0
  • n ^ (1 << k):翻转第 k 位

这些操作组合起来,就是状态压缩 DP、位掩码枚举、二进制优化的基础。而理解它们的起点,就是今天讲的这件事:二进制位是独立的位置,位移是整体挪动,每个位置有自己的权重。

把这个图景建立起来,后面的一切都是自然推论。


关于作者

我是赵老师,持有 NOI 信息学奥赛教练证书,拥有 15 年以上的软件开发经验,从事信息学少儿编程教学已有 8 年时间。

这些年累计帮助 多名 学生通过编程特长升入自己心仪的目标学校。

如果你在编程学习上有任何疑问,欢迎联系我:18620372957(微信同号)

这篇文章对你有帮助吗?

成为第一个评价的人

评论0

还没有评论,来抢沙发~

评论加载中...

想系统学习这个知识点?查看完整知识点 →