当数字开始排队跨步:重新理解 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。
思路需要用到位运算的组合:
- 找到从右往左第一个 "01" 模式的位置(即一个 0 后面跟着 1)
- 把这个 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 位置为 1n & ~(1 << k):把第 k 位置为 0n ^ (1 << k):翻转第 k 位
这些操作组合起来,就是状态压缩 DP、位掩码枚举、二进制优化的基础。而理解它们的起点,就是今天讲的这件事:二进制位是独立的位置,位移是整体挪动,每个位置有自己的权重。
把这个图景建立起来,后面的一切都是自然推论。
关于作者
我是赵老师,持有 NOI 信息学奥赛教练证书,拥有 15 年以上的软件开发经验,从事信息学少儿编程教学已有 8 年时间。
这些年累计帮助 多名 学生通过编程特长升入自己心仪的目标学校。
如果你在编程学习上有任何疑问,欢迎联系我:18620372957(微信同号)