阶段 9 · 补课 · 第 46 章普及组 J

位运算:整数就是一排开关,以及 lowbit 为什么成立

★ 关键一步是异或的三条性质 —— 一样的两个数异或掉就没了、和 0 异或不变、顺序无所谓。⚠ 而这一章要还两笔账:lowbit 在第 38 章用了 113 次,为什么成立一次都没说过;「位运算更快」这句口诀,实测下来快的那两行其实算的不是同一件事。

需要先学:第 5 章 枚举与模拟第 10 章 排序:冒泡 → 归并 → 快排例题:2n+1 根筷子里,找那根落单的建议用时:110 分钟
这一章补的是两笔欠了很久的账

位运算在这本书里用得很重,可正文一次都没讲过它。2026-08-25 数了一遍:

哪一章 用了什么 出现次数
第 28 章 状压 DP 1 << iS & mask 44 / 29
第 38 章 树状数组 lowbit 113
第 42 章 快速幂 b & 1b >> 1 15 / 15
第 37 章 堆 >> 1(父子下标) 42

⇒ 而补码异或的性质这两样,在全书正文里 grep 出来是 0 命中。 也就是说 lowbit(i) = i & -i 被用了 113 次,为什么成立一次都没说过

★ 这一章把这两笔账还了。它和第 44(高精度)、45(复杂度估算)章是同一类: 不是新算法,是一直在用却从没讲过的地基。 (所以它也不依赖后面任何一章 —— 学完第 5 章之后随时可以来看。)

1一句话问题

桌上有 m 根筷子(m 是奇数,m ≤ 2×10⁶+1),每根有一个长度(1 ≤ 长度 ≤ 10⁸)。 除了一根落单的,其余每种长度正好两根。把那根落单的长度输出来。

输入第一行 m,第二行 m 个整数。⚠ 时限 1 秒,内存 256 MiB

输入

5
3 7 3 9 9

输出

7

33 一对、99 一对,落单的是 7

⚠ 注意成对的两根不一定挨在一起 —— 顺序是乱的。

输入

1
42

输出

42

★ 边界:只有一根筷子时,它自己就是落单的那个。

写完记得拿这一组试试 —— m = 1 时循环一次都不进,很多写法会在这儿出事。

⚠ 那行「内存 256 MiB」是这道题的第二个考点

第 45 章刚讲过:估内存和估时间是同一件事,都要在动手之前算一遍。 这道题把这件事摆到了明面上 —— 第 4 步会看到,一个非常自然的想法正好卡在这条线上。

2第一反应:每根都数一遍它出现了几次

题目说「只有一根出现一次」,那就把每根拿出来数一数,只出现一次的那个就是答案。

brute.cppO(n²):每根都数一遍
// 标准答案:对每个数,数一遍它出现了几次 —— O(n²)
//
// ★ 它和正解的思路**完全不同**(第 20 章那条:同一个思路写两遍只能验出打字错误):
// 正解靠的是异或的代数性质,这一份从头到尾没碰过任何一个位。
// 所以它当对拍的标准答案是合格的。
//
// ⚠ m = 2×10⁶ 时它要做 4×10¹² 次比较 —— 按第 45 章那张表估,是几个小时。
// 所以它只在小数据上当裁判。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int m;
cin >> m;
vector<int> a(m);
for (int i = 0; i < m; i++) cin >> a[i];
for (int i = 0; i < m; i++) {
int cnt = 0;
for (int j = 0; j < m; j++) if (a[j] == a[i]) cnt++;
if (cnt == 1) { cout << a[i] << '\n'; return 0; }
}
cout << -1 << '\n'; // 题面保证有解,走不到这里
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

本机实测(A 机:WSL2 6.18 / 8 核 / 8 GB,2026-08-25,独占;数据来自 ./genBig <n> 1):

筷子数 耗时
4 001 0.001 秒
40 001 0.414 秒(数据 ×10,时间 ×400 = 10²
2 000 001 17 分钟(按 外推)

⇒ 题面上限 m = 2×10⁶+1,时限 1 秒。差了三个数量级,方向就死在这儿。 (第 45 章第 2 步那个动作:先估,再决定写不写。)

3第二反应:排完序,成对的就挨在一起了

排序之后一对一对地看,第一个「和后面那个不一样」的就是答案。

sortPair.cppO(n log n):排序后两两比
// 做法二:排完序,相同的两个一定挨在一起 —— O(n log n)
//
// 排序之后从头两个两个地看:只要 a[i] != a[i+1],落单的就是 a[i]。
// 走到最后都成对,那落单的就是最后一个。
//
// ⚠ 这一份里藏着这一章的头号坑:判断「下标是不是偶数」时,
// `i & 1 == 0` 是错的(`==` 比 `&` 优先级高,见 wrongPri.cpp)。
// 这里老老实实写成 `(i & 1) == 0`,括号一个都不能省。
//
// 空间:要把 m 个数全存下来(m = 2×10⁶ 时 8 MB),而且排序本身还要 O(n log n) 时间。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int m;
cin >> m;
vector<int> a(m);
for (int i = 0; i < m; i++) cin >> a[i];
sort(a.begin(), a.end());
for (int i = 0; i + 1 < m; i += 2)
if (a[i] != a[i + 1]) { cout << a[i] << '\n'; return 0; }
cout << a[m - 1] << '\n'; // 前面全成对 ⇒ 落单的是最后一个
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

本机实测:200 万根 0.179 秒 —— 这一份是能过的

★ 「能过」就已经拿满分了 —— 但它有两个代价,值得看清楚
  1. 它要把所有数存下来:200 万个 int = 8 MB(实测峰值内存 11.5 MiB)。
  2. 它要 O(n log n):排序本身就是这道题里最贵的一步。

而下面会看到,正解一个数组都不用开,时间还快三倍。 ⚠ 但先记住第 45 章那句话:能过的分和最优解的分一模一样。 这一份写出来就该交,剩下的时间去做别的题 —— 下面那些是给「还有时间」的你看的。

4⚠ 第三反应:开个计数数组 —— 时间对了,内存爆了

「数出现次数」最直接的写法就是开一个数组 cnt[长度]。时间是 O(n),比排序还快。 唯一的问题在那句声明上

   题面说长度最大 10^8
   ⇒  int cnt[100000001]  =  4 x 10^8 字节  =  381 MiB
   而内存限制是                                256 MiB
bucket.cpp⚠ O(n) 时间,但 381 MiB
页面上这一份把值域调小到 1000 才跑得快(它就是一份完全正常的 O(n) 解法)。⚠ 把参数换回题面那个 10⁸,它会打出真正的峰值内存 —— 本机实测 385.4 MiB。
// 做法三:开一个「每个长度出现几次」的计数数组 —— O(n) 时间,但**空间会要命**
//
// 想法本身没错,而且是很多人的第一反应。问题全在那一句声明上:
// 题面说长度最大 10⁸ ⇒ int cnt[100000001] = 4×10⁸ 字节 = 381 MiB
// 而题目给的内存限制是 **256 MiB**。⇒ **MLE**,一个字节都没超时就死了。
//
// ★★ 而它在开发机上是**跑得完的**(内存够)—— 这正是第 45 章那句话的第二现场:
// **本机跑得过 ≠ 评测机跑得过。** 所以这一份会把峰值内存打出来给你看。
//
// ⚠ 有人会说:把 int 换成 char(只要记奇偶)不就 95 MiB 了吗?对,能过。
// 但那已经是在**跟内存限制赛跑**了 —— 而正解只要 8 个字节,连数组都不用开。
//
// 用法:./bucket [值域上限] 默认 100000000(题面那个)。
// ⚠ 想在页面上快点跑完,把它调小(比如 1000000)——
// 调小之后它就是一份完全正常的 O(n) 解法,这也说明问题从来不在算法上,在那句声明上。
#include <bits/stdc++.h>
using namespace std;
/** 从 /proc/self/status 读峰值内存(VmHWM,单位 KB)—— 和第 45 章 mle.cpp 同一招 */
static long long peakKb() {
FILE* f = fopen("/proc/self/status", "r");
if (!f) return -1;
char line[256];
long long kb = -1;
while (fgets(line, sizeof(line), f))
if (strncmp(line, "VmHWM:", 6) == 0) { sscanf(line + 6, "%lld", &kb); break; }
fclose(f);
return kb;
}
int main(int argc, char** argv) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int lim = (argc > 1) ? atoi(argv[1]) : 100000000;
if (lim < 1) lim = 1;
int m;
cin >> m;
vector<int> cnt((size_t)lim + 1, 0); // ← 要命的就是这一行
int ans = -1;
for (int i = 0; i < m; i++) {
int x;
cin >> x;
if (x >= 0 && x <= lim) cnt[x]++;
}
for (int v = 0; v <= lim; v++) if (cnt[v] == 1) { ans = v; break; }
fprintf(stderr, "计数数组开到 %d:要 %.0f MiB,此刻峰值内存 %.1f MiB\n",
lim, ((double)lim + 1) * sizeof(int) / 1048576.0, peakKb() / 1024.0);
cout << ans << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 本机跑得过 ≠ 评测机跑得过(第 45 章那句话的第二现场)

本机实测:./genBig 1000000 1 | ./bucket 跑完只要 0.657 秒,峰值内存 385.4 MiB。 开发机内存够,它跑得好好的 —— 可在 256 MiB 的评测机上,这是一个没有任何提示的 MLE

⚠ 有人会说:只要记奇偶,把 int 换成 char 不就 95 MiB 了吗?对,能过。 但那已经是在跟内存限制赛跑了 —— 而下一步那份正解只用 8 个字节,连数组都不开。

⇒ 三种做法排下来,这道题真正在问的是: 能不能不把数据存下来,就把答案算出来?

5★ 关键的一步:异或的三条性质

^异或:逐位比较,两边不一样才是 1

     1 0 1 0      (10)
   ^ 0 1 1 0      (6)
   -----------
     1 1 0 0      (12)

它有三条性质,而这道题的答案整个压在这三条上:

★★ 三条性质
性质 说的是
a ^ a = 0 一样的两个数异或掉就没了
a ^ 0 = a 和 0 异或不变 —— 0 是异或的「单位元」(和加法的 0 一个位置)
③ 可交换、可结合 a ^ b = b ^ a(a ^ b) ^ c = a ^ (b ^ c)顺序无所谓

⇒ 把 m 个数全部异或起来:由 ③ 可以随便凑对,由 ① 每一对变成 0, 由 ② 那些 0 又不影响剩下的那个。剩下的就是答案。

★ 三条里最容易被跳过的是 ③。没有它,「成对的不挨在一起」这件事就绕不过去 —— 而正是它让这个做法不需要排序、不需要记住任何东西

fast.cpp正解:一个循环,一个变量
// 正解:把所有数异或起来,剩下的就是答案 —— O(n) 时间,O(1) 空间
//
// 靠的是异或的三条性质(verify.cpp 里当场验了两万组):
// ① a ^ a = 0 —— 一样的两个数,异或掉就没了
// ② a ^ 0 = a —— 和 0 异或不变
// ③ 可交换、可结合 —— 所以「先后顺序无所谓」,随便怎么凑对都行
//
// 于是把 2n+1 个数全部异或起来:n 对相同的各自变成 0(性质 ①、③),
// 0 再和落单的那个异或还是它自己(性质 ②)。**一个循环,一个变量。**
//
// ★ 注意它比另外三种做法少的不只是时间:它**一个数组都不用开**,
// 甚至可以边读边算 —— 这在 m 达到 2×10⁶ 时是实打实的差别(见第 6 步那张内存表)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int m;
cin >> m;
int res = 0, x;
for (int i = 0; i < m; i++) { cin >> x; res ^= x; }
cout << res << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 「性质」也要验,不能只是写出来

对拍验的是「两份实现算得一样」,验不了「我引用的那条性质本身成立吗」。 这一章整个立论都压在上面那三条上,所以它们要单独验一遍(第 44 章 verify.cpp 的同款分工)。

verify.cpp随机两万组,逐条验
五条:异或的三条性质,加上 ~x = −x−1 和「x & −x 恰好是最低位的那个 1」—— 后两条是第 9 步要用的。
// 第三把尺子:把这一章用到的每一条「性质」当场验一遍
//
// ★ 对拍验的是「两份实现算得一样」,验不了「我引用的那条性质本身成立吗」。
// 这一章整个立论都压在几条性质上,所以它们必须单独验(第 44 章 verify.cpp 的同款分工)。
//
// 验这五条,随机两万组:
// ① a ^ a == 0 (一样的两个数异或掉就没了)
// ② a ^ 0 == a (0 是异或的单位元 —— 正解的初值为什么是 0)
// ③ (a ^ b) ^ c == a ^ (b ^ c) 且 a ^ b == b ^ a (可结合、可交换 ⇒ 顺序无所谓)
// ④ ~x == −x − 1 (补码:按位取反再加一就是相反数)
// ⑤ x & −x 恰好只剩一个 1,而且是 x 最低位的那个 (lowbit 为什么成立)
//
// ⚠ ④⑤ 一律在 unsigned long long 上做(理由见 bits.cpp 文件头)。
// 用法:./verify [轮数] 默认 20000
#include <bits/stdc++.h>
using namespace std;
/** 按显示宽度补空格:ASCII 算 1 格,汉字算 2 格(printf 的 %-Ns 数的是字节,中文列会歪) */
static string padDisp(const string& s, int width) {
int disp = 0;
for (unsigned char c : s) {
if ((c & 0xC0) == 0x80) continue;
disp += (c < 0x80) ? 1 : 2;
}
return s + string(max(0, width - disp), ' ');
}
int main(int argc, char** argv) {
long long rounds = (argc > 1) ? atoll(argv[1]) : 20000;
if (rounds < 1) rounds = 1;
mt19937_64 rng(20260825u);
long long bad[5] = { 0, 0, 0, 0, 0 };
for (long long t = 0; t < rounds; t++) {
unsigned long long a = rng(), b = rng(), c = rng();
if ((a ^ a) != 0ull) bad[0]++;
if ((a ^ 0ull) != a) bad[1]++;
/* ⚠ 交换律这一句要绕一下:直接写 `(a ^ b) != (b ^ a)`,-Wall 会报
「self-comparison always evaluates to false」——它在语法层面就认出这是同一个表达式。
而这里要验的正是「它确实恒等」,所以把两个操作数放进数组,用下标取。 */
unsigned long long xs[2] = { a, b };
if (((a ^ b) ^ c) != (a ^ (b ^ c)) || (xs[0] ^ xs[1]) != (xs[1] ^ xs[0])) bad[2]++;
/* ④ ~x == -x - 1 */
if (~a != (0ull - a) - 1ull) bad[3]++;
/* ⑤ lowbit:只剩一个 1,而且位置就是 a 最低位那个 1 */
if (a != 0) {
unsigned long long lb = a & (0ull - a);
int lowPos = 0;
{ unsigned long long t2 = a; while (t2 % 2 == 0) { t2 /= 2; lowPos++; } } // 只用 % 和 /
if (__builtin_popcountll(lb) != 1 || lb != (1ull << lowPos)) bad[4]++;
}
}
const char* name[5] = {
"① a ^ a == 0",
"② a ^ 0 == a",
"③ 可结合、可交换",
"④ ~x == -x - 1(补码)",
"⑤ x & -x 恰好是最低位的那个 1",
};
printf("随机 %lld 组,逐条验这一章用到的性质:\n\n", rounds);
int fail = 0;
for (int i = 0; i < 5; i++) {
printf(" %s %s", padDisp(name[i], 34).c_str(), bad[i] == 0 ? "全部通过\n" : "");
if (bad[i]) { printf("✗ 有 %lld 组不成立\n", bad[i]); fail++; }
}
printf("\n%s\n", fail == 0 ? "五条全部通过 —— 这一章的立论是站得住的。"
: "★ 有性质不成立,这一章要重写。");
return fail == 0 ? 0 : 1;
}
点一下即可编辑
输出
点「运行 ▶」看结果

本机实测:五条全部通过(随机 20000 组)。

6动画一:看成对的怎么自己消失

盯住下面那行二进制:每吃进一个数,就有若干位被翻了一下; 同一个数吃第二次,翻过去的位一位不差地翻了回来

成对的自己消失,剩下的就是答案
第 1 / 7 步
数据
3
7
3
9
9
res = 0
res 的二进制
00000000000000000000000000000000
■ 这一步吃进去的 ■ 还挂在 res 上 ■ 已经和同伴抵消了
起手 res = 0。⚠ 初值必须是 0 —— 因为 a ^ 0 = a,0 是异或的「单位元」(和加法的 0 一个位置)。

7四种做法摆在一起

做法 时间(200 万根) 峰值内存 结论
O(n²) 每根数一遍 ≈ 17 分钟(外推) 8 MB TLE
O(n log n) 排序 0.179 秒 11.5 MiB ✓ 能过
O(n) 计数数组 0.657 秒 385.4 MiB MLE
O(n) 异或 0.061 秒 4.1 MiB ✓ 又快又省
★ 最后一行那个 4.1 MiB 是「进程本身」,不是数据

异或版边读边算,从来没有把 200 万个数存下来过 —— 它真正用到的内存是一个 int。 那 4.1 MiB 是 C++ 程序启动的固定开销,和数据量没关系。

⇒ 这就是位运算在这道题里的价值:不是快,是「不用记住任何东西」。 而它之所以能不记,是因为异或的三条性质替它把「配对」这件事做完了。

8位运算工具箱:七个动作

上面那道题只用到了 ^。但位运算真正的用处是把一个整数当成一排开关(第 28 章那套就是这么来的), 所以这一节把最常用的七个动作一次性摆清楚。

★ 七个动作,每个都是一行
想干什么 怎么写 为什么
取第 k (x >> k) & 1 先把它挪到最低位,再只留最低位
把第 k 位置 1 x | (1 << k) 那一位或上 1 一定是 1,别的位或上 0 不变
把第 k 位清 0 x & ~(1 << k) 那一位与上 0 一定是 0,别的位与上 1 不变
翻转第 k x ^ (1 << k) 那一位异或 1 就翻,别的位异或 0 不变
最低位的那个 1 x & -x(lowbit) 第 9 步讲为什么
有几个 1 __builtin_popcount(x) 第 38 章那条「sum(r) 的步数正好等于 popcount(r)」就是它
判奇偶 x & 1 ★ 比 x % 2 还稳,见第 10 步

★ 四行「改一位」的写法共用同一个套路:先用 1 << k 造一个只有第 k 位是 1 的数(掩码), 再挑一个「对别的位没影响」的运算| 配 0、& 配 1、^ 配 0)。记这一句就够了。

bits.cpp七个动作,一份能跑的工具箱
每行一个 op x k。⚠ 里面两处写法是有意的:位运算一律在 unsigned 上做;换回有符号数那两行就是补码的定义。
// 位运算工具箱:七个最常用的动作,每个都是一行
//
// 输入:第一行 q,接下来 q 行,每行 `op x k`(low / cnt / half 用不到 k,随便填一个)
// get x k 取 x 的第 k 位(0 或 1)
// set x k 把第 k 位置成 1
// clr x k 把第 k 位清成 0
// flip x k 把第 k 位翻过来
// low x lowbit(x):最低位的那个 1(连同它后面的 0)
// cnt x popcount(x):二进制里有几个 1
// half x x 除以 2(★ 负数在这儿咬人,见 wrongHalf.cpp)
// 输出:每行一个结果。
//
// ★★ 两处写法是**有意**的,它们就是这一章要讲的东西:
//
// 一、**位运算一律在 unsigned long long 上做。** 有符号数的符号位交给位运算,
// C++17 里是「实现定义」的(C++20 才把补码写进标准)。第 44 章那条规矩在这儿又用一次:
// **要可复现,就别把边界交给未定义 / 实现定义的行为。**
//
// 二、**换回有符号数时,那两行就是补码的定义**(toSigned 里):
// 最高位(第 63 位)的权重是 −2⁶³,其余第 i 位是 +2^i。
// 这本书不单列一节讲原码 / 反码 / 补码 —— 它只在**非讲不可**的地方出现,
// 而这里和 lowbit 就是那两处。
//
// 位号 k 限定在 [0, 62]:不碰符号位,省得每个操作都要单独讨论一遍。
#include <bits/stdc++.h>
using namespace std;
/** 补码:最高位的权重是 −2⁶³,其余是 +2^i */
static long long toSigned(unsigned long long u) {
const unsigned long long TOP = 1ull << 63;
if (u < TOP) return (long long)u;
return (long long)(u - TOP) - 9223372036854775807LL - 1; // 减掉 2⁶³,不溢出地减
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int q;
if (!(cin >> q)) return 0;
string op;
long long x;
int k;
while (q-- && (cin >> op >> x >> k)) {
if (k < 0) k = 0;
if (k > 62) k = 62;
unsigned long long u = (unsigned long long)x; // 这一步是有定义的:对 2⁶⁴ 取模
unsigned long long bit = 1ull << k;
if (op == "get") cout << ((u >> k) & 1ull) << '\n';
else if (op == "set") cout << toSigned(u | bit) << '\n';
else if (op == "clr") cout << toSigned(u & ~bit) << '\n';
else if (op == "flip") cout << toSigned(u ^ bit) << '\n';
else if (op == "low") cout << toSigned(u & (~u + 1ull)) << '\n'; // 就是 x & -x
else if (op == "cnt") cout << __builtin_popcountll(u) << '\n';
else if (op == "half") cout << x / 2 << '\n'; // 是除法,不是 >> 1
else cout << "?" << '\n';
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

输入

7
get 42 3
set 42 0
clr 42 3
flip 42 5
low 40 0
cnt 255 0
half -7 0

输出

1
43
34
10
8
8
-3

42 = 0b101010。取第 3 位是 1;置第 0 位得 43;清第 3 位得 34;翻第 5 位得 10; lowbit(40) = 8255 有 8 个 1;−7 / 2 = −3(★ 注意是 −3 不是 −4,第 10 步会说)。

自己拧一拧(下面这个不是动画,是实验台 —— 改哪个数字都会立刻重算):

x
00000000000000000000000000001100
12
y
00000000000000000000000000001010
10
x & y
00000000000000000000000000001000
8
x | y
00000000000000000000000000001110
14
x ^ y
00000000000000000000000000000110
6
~x
11111111111111111111111111110011
-13
x << k
00000000000000000000000000011000
24
x >> k
00000000000000000000000000000110
6
lowbit(x) = x & -x = 4 (x 最低位的那个 1,连同它后面的 0)
popcount(x) = 2 (x 的二进制里有几个 1)
~x = -13 = −x − 1 = -13 (补码:按位取反再加一就是相反数)

9★ 补码:只在两个非讲不可的地方

这本书不单列一节讲原码 / 反码 / 补码 —— 那样它必然变成背诵。 它只在两个「不讲就说不通」的地方出现,而这两处你都已经用过很多次了。

★ 一句话:最高位的权重是负的

计算机存整数用的是补码,规则只有一句:

   32 位有符号数,从高到低每一位的权重是
       -2^31   2^30   2^29   ...   4   2   1
         ^^
         最高位这一个是负的,别的都是正的

于是 -1 就是全 1-2^31 + 2^30 + ... + 1 = -1),而 ~x = -x - 1verify.cpp 验过)。

非讲不可之一:lowbit(x) = x & -x 为什么成立(还第 38 章那 113 次的账)

   x        = 0 1 0 1 1 0 0 0        (88)
   ~x       = 1 0 1 0 0 1 1 1
   -x = ~x+1= 1 0 1 0 1 0 0 0        (加一之后,最低位那个 1 右边的 0 全变回 0,
                                      它自己保持 1,它左边全部反过来)
   x & -x   = 0 0 0 0 1 0 0 0        (8)  <-- 只剩最低位那个 1

⇒ 关键在加一那一步:它让 x-x 在「最低位那个 1」上都是 1,在其它每一位上都相反。 所以一与起来,只剩那一位。

非讲不可之二:-1 >> 1 为什么还是 -1

负数的右移是算术右移:左边补进来的是符号位(1),不是 0。 所以 -1(全 1)右移多少次都还是全 1,永远变不成 0。 ★ 这也是为什么 x >> 1x / 2 在负数上不是一回事 —— 下一步就量给你看。

10★ 口诀复核:位运算真的更快吗

「位运算比算术快」大概是每个人学位运算听到的第一句话。 这本书的立场一向是口诀要拿实测复核(第 29、32、33、34、41、45 章各犯过一次),所以现场量。

speed.cpp★ 四对写法,量「快多少」也量「结果一样吗」
每一对只差一个运算符。⚠ 结果必须累加进 volatile,否则 -O2 会把整个循环当死代码删掉(第 45 章 ops.cpp 同款)。
// ★ 口诀复核:「位运算比算术快」到底成不成立
//
// 这句口诀几乎是每个人学位运算时听到的第一句话。这本书的立场一向是「口诀要拿实测复核」
// (第 29、32、33、34、41、45 章各犯过一次),所以这一章也现场量。
//
// 四对写法,每一对都量两件事 —— **一样快吗** 和 **结果一样吗**:
// ① 无符号 x * 2 vs x << 1
// ② 无符号 x % 2 vs x & 1
// ③ 有符号 x % 2 vs x & 1 ★ 负数上结果**不一样**
// ④ 有符号 x / 2 vs x >> 1 ★ 负奇数上结果**不一样**
//
// ⇒ 预告结论(别背,看下面跑出来的数):**前两对一样快** —— 编译器早就把 `* 2` 换成移位了。
// 位运算真正值钱的地方从来不是这里,而是③④那两行「结果不一样」。
//
// ⚠ 防编译器的写法和第 45 章 ops.cpp 一样:结果必须累加进 volatile,
// 否则整个循环会被当成死代码删掉,量出来是 0 秒。
//
// 用法:./speed [每个内核的目标毫秒数,默认 200] [csv]
#include <bits/stdc++.h>
#include <chrono>
using namespace std;
using namespace std::chrono;
static const int N = 1 << 16;
static vector<int> a; // 有正有负
static volatile long long sink = 0;
static string padDisp(const string& s, int width) {
int disp = 0;
for (unsigned char c : s) {
if ((c & 0xC0) == 0x80) continue;
disp += (c < 0x80) ? 1 : 2;
}
return s + string(max(0, width - disp), ' ');
}
/** 同上,补在左边(右对齐用) */
static string padLeftDisp(const string& s, int width) {
int disp = 0;
for (unsigned char c : s) {
if ((c & 0xC0) == 0x80) continue;
disp += (c < 0x80) ? 1 : 2;
}
return string(max(0, width - disp), ' ') + s;
}
template <class F>
double bench(F kernel, double targetMs) {
double ms = 0;
long long ops = 0;
auto t0 = steady_clock::now();
while (ms < targetMs) {
sink += kernel();
ops += N;
ms = duration<double, milli>(steady_clock::now() - t0).count();
}
return ops / (ms / 1000.0);
}
int main(int argc, char** argv) {
double targetMs = (argc > 1) ? atof(argv[1]) : 200.0;
bool csv = (argc > 2 && string(argv[2]) == "csv");
if (targetMs < 1) targetMs = 1;
a.resize(N);
/* ⚠ 数据里必须有**负的奇数**,否则最后两行的「结果一样吗」会假绿:
第一版写的是 (i*37)%100000+1,负数那一半正好全是偶数,两种写法当然一样。 */
{
unsigned seed = 20260825u;
for (int i = 0; i < N; i++) {
seed = seed * 1103515245u + 12345u;
int mag = (int)((seed >> 8) % 100000u) + 1;
a[i] = (i % 2) ? -mag : mag; // 一半负数,奇偶都有
}
}
double v[8];
v[0] = bench([&] { long long s = 0; for (int i = 0; i < N; i++) s += (unsigned)a[i] * 2u; return s; }, targetMs);
v[1] = bench([&] { long long s = 0; for (int i = 0; i < N; i++) s += (unsigned)a[i] << 1; return s; }, targetMs);
v[2] = bench([&] { long long s = 0; for (int i = 0; i < N; i++) s += (unsigned)a[i] % 2u; return s; }, targetMs);
v[3] = bench([&] { long long s = 0; for (int i = 0; i < N; i++) s += (unsigned)a[i] & 1u; return s; }, targetMs);
v[4] = bench([&] { long long s = 0; for (int i = 0; i < N; i++) s += a[i] % 2; return s; }, targetMs);
v[5] = bench([&] { long long s = 0; for (int i = 0; i < N; i++) s += a[i] & 1; return s; }, targetMs);
v[6] = bench([&] { long long s = 0; for (int i = 0; i < N; i++) s += a[i] / 2; return s; }, targetMs);
v[7] = bench([&] { long long s = 0; for (int i = 0; i < N; i++) s += a[i] >> 1; return s; }, targetMs);
/* 结果一样吗:拿同一批数据逐个比 */
bool same[4] = { true, true, true, true };
for (int i = 0; i < N; i++) {
unsigned u = (unsigned)a[i];
if (u * 2u != (u << 1)) same[0] = false;
if (u % 2u != (u & 1u)) same[1] = false;
if (a[i] % 2 != (a[i] & 1)) same[2] = false;
if (a[i] / 2 != (a[i] >> 1)) same[3] = false;
}
const char* name[4] = { "无符号 x * 2 vs x << 1",
"无符号 x % 2 vs x & 1",
"有符号 x % 2 vs x & 1",
"有符号 x / 2 vs x >> 1" };
if (csv) {
const char* key[4] = { "mul2", "mod2u", "mod2s", "div2s" };
for (int p = 0; p < 4; p++)
printf("%s,%.4e,%.4e,%d\n", key[p], v[p * 2], v[p * 2 + 1], same[p] ? 1 : 0);
return 0;
}
printf("位运算真的更快吗?(本机实测,每个内核跑约 %.0f 毫秒)\n\n", targetMs);
printf(" %s %s %s %s %s\n", padDisp("两种写法", 30).c_str(),
padLeftDisp("算术 次/秒", 12).c_str(), padLeftDisp("位运算 次/秒", 12).c_str(),
padLeftDisp("快多少", 11).c_str(), padLeftDisp("结果一样吗", 12).c_str());
for (int p = 0; p < 4; p++)
printf(" %s %12.2e %12.2e %8.2f 倍 %s\n", padDisp(name[p], 30).c_str(),
v[p * 2], v[p * 2 + 1], v[p * 2 + 1] / v[p * 2],
padLeftDisp(same[p] ? "一样" : "★ 不一样", 12).c_str());
printf("\n★ 前两行「快多少」都在 1.0 上下 —— 编译器早就把 * 2 和 %% 2 换成移位和与了。\n");
printf("★★ 真正的差别在最后两行的**最右边一列**:那已经不是快慢问题,是**算的不是同一件事**。\n");
printf(" -7 %% 2 = %d,而 -7 & 1 = %d;-7 / 2 = %d,而 -7 >> 1 = %d。\n",
-7 % 2, -7 & 1, -7 / 2, -7 >> 1);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

本机实测(A 机:WSL2 6.18 / 8 核 / 8 GB,2026-08-25,独占):

两种写法 位运算快多少 结果一样吗
无符号 x * 2 vs x << 1 1.0 倍(一样快) 一样
无符号 x % 2 vs x & 1 1.0 倍(一样快) 一样
有符号 x % 2 vs x & 1 1.5 倍 不一样
有符号 x / 2 vs x >> 1 1.4 倍 不一样
★★ 前两行:口诀不成立 —— 编译器早就替你换过了

x * 2x << 1-O2生成的是同一条指令,所以一样快。 x % 2x & 1 在无符号上也一样。

为了「更快」而把 * 2 写成 << 1,一分钟都省不下来,只是让代码更难读。 (第 45 章那条:常数优化救的是「差一点点」,而这里连一点点都没有。)

⚠⚠ 后两行才是重点:它快,是因为它算的不是同一件事
   -7 % 2  = -1        而   -7 & 1  = 1
   -7 / 2  = -3        而   -7 >> 1 = -4

/%向零取整>>向下取整(往 −∞ 走)。它们在负数上差一个 1。 ⇒ 那 1.4~1.5 倍的「快」,买的是换了一个语义,不是同一件事做得更快。

★ 而这里有一个位运算真正胜出的地方,和快慢无关:

判奇偶要用 x & 1,不要用 x % 2 == 1 因为 -7 % 2 == -1,所以 if (x % 2 == 1)负奇数是 false —— 一个很难查的 bug。 而 x & 1 对正负都给 1。

11⚠ 四个坑,每一个都真的会咬人

① 优先级 —— 位运算的头号坑
   if (x & 1 == 0)          // 你以为的:(x & 1) == 0
                            // 实际的:  x & (1 == 0)  =  x & 0  =  0  -->  这个 if 永远不成立

位运算的优先级比比较运算还低。 记不住整张优先级表没关系,记住这一句: ⇒ 位运算和 == / < 撞在一起时,一律加括号。

★★ 而这一章最值钱的一句话是:g++ -Wall 一眼就能看出来。

warning: suggest parentheses around comparison in operand of '&' [-Wparentheses]
   43 |  cout << ((u & bit == 0) ? 0 : 1) << '\n';
      |                ~~~~^~~~

本章那份 wrongPri.cpp 为了能通过本仓库「零警告」的检查,不得不手动把这条警告关掉 —— 关掉的那一行,就是它想告诉你的事。⇒ 打开 -Wall 是免费的。

② 1 << 31 —— 那个 1 是 int

1 << k 里的 1int(32 位)。k 一旦到 31 就出界,到 32 以上更离谱: ★ x86 的移位指令只看位数的低 5 位,于是 1 << 40 实际执行的是 1 << 8 —— 看着像位号自己绕回去了,而且不崩、不报警。

⇒ 写成 1LL << k1ull << k让那个 1 先变成 64 位,再移。 本书里凡是可能移超过 30 位的地方都是这么写的。

③ 别拿 >> 当除法(见第 10 步)

非负数上它们一样;负数上差一个 1。二分里写 (l + r) >> 1 而区间可能有负数时, 差的那 1 就是死循环。⇒ 确定非负才用 >>,而且那时它也不会更快。

④ 「不用第三个变量就能交换」—— 它有一个没写出来的前提
   a ^= b;  b ^= a;  a ^= b;        // 三行,不用临时变量

它是对的 —— 前提是 a 和 b 是两个不同的变量。 一旦是同一个(swap(v[i], v[j])i == j),第一行 a ^= a 直接把它清零,后面救不回来。

swapDemo.cpp三段:它对在哪、它错在哪、多久错一次
第三段是实测:选择排序 n = 20 跑 1000 组,看「自己和自己交换」发生多频繁。
// 「不用第三个变量就能交换」—— 这个著名技巧,以及它为什么不该用
//
// a ^= b; b ^= a; a ^= b; // 三行,不用临时变量
//
// 它是对的:把三步展开就能看见(下面会打出来)。可它有一个**没写出来的前提**:
// a 和 b 必须是**两个不同的变量**。
// 一旦它们是同一个(`swap(v[i], v[j])` 而 i == j),三行下来那一格就变成了 0 ——
// 因为第一行 `a ^= a` 直接把它清零了,后面两行再也救不回来。
//
// ★★ 而 `i == j` 在真实代码里一点都不罕见:选择排序里 `swap(v[i], v[minIdx])`,
// 只要这一轮的最小值本来就在 i 上,就是自己和自己交换 —— 下面第三段把它量出来了。
//
// ⇒ 结论很干脆:**用 std::swap。** 它一行、没有前提、而且编译器生成的代码不比三行异或差。
// ★ 更一般的一课:**看着聪明的写法,往往有一个没写出来的前提。**
// (第 19 章那条「凡是只和上一个比的写法,都藏着一个没写出来的排序前提」是同一件事。)
#include <bits/stdc++.h>
using namespace std;
static void xorSwap(int& a, int& b) { a ^= b; b ^= a; a ^= b; }
int main() {
printf("一、两个不同的变量:它确实是对的\n");
{
int a = 12, b = 25;
printf(" 开始 a = %2d b = %2d\n", a, b);
a ^= b; printf(" a ^= b a = %2d b = %2d (a 现在记着「两人的异或」)\n", a, b);
b ^= a; printf(" b ^= a a = %2d b = %2d (b 变成了原来的 a)\n", a, b);
a ^= b; printf(" a ^= b a = %2d b = %2d (a 变成了原来的 b)✓\n", a, b);
}
printf("\n二、同一个变量:它把那一格清零了\n");
{
vector<int> v = { 7, 8, 9 };
printf(" 交换前 v = [%d, %d, %d]\n", v[0], v[1], v[2]);
xorSwap(v[1], v[1]); // i == j
printf(" xorSwap(v[1], v[1]) 之后 v = [%d, %d, %d] ★ 8 没了\n", v[0], v[1], v[2]);
v = { 7, 8, 9 };
swap(v[1], v[1]);
printf(" std::swap(v[1], v[1]) 之后 v = [%d, %d, %d] ✓ 什么都没发生\n", v[0], v[1], v[2]);
}
printf("\n三、这在真实代码里多久发生一次?选择排序,n = 20,跑 1000 组随机数据:\n");
{
unsigned seed = 20260825u;
auto rnd = [&]() { seed = seed * 1103515245u + 12345u; return (seed >> 16) % 100u; };
long long total = 0;
int roundsWithAny = 0;
const int ROUNDS = 1000, N = 20;
for (int t = 0; t < ROUNDS; t++) {
vector<int> v(N);
for (int& x : v) x = (int)rnd();
int selfSwap = 0;
for (int i = 0; i + 1 < N; i++) {
int mn = i;
for (int j = i + 1; j < N; j++) if (v[j] < v[mn]) mn = j;
if (mn == i) selfSwap++; // ← 自己和自己交换
swap(v[i], v[mn]);
}
total += selfSwap;
if (selfSwap) roundsWithAny++;
}
printf(" 平均每组发生 %.2f 次,%d / %d 组**至少发生一次**。\n",
(double)total / ROUNDS, roundsWithAny, ROUNDS);
printf(" ⇒ 换成三行异或,那就是平均每排一次序清掉 %.2f 个数。\n", (double)total / ROUNDS);
printf(" ★ 这不是「理论上可能」—— 它几乎每次排序都会发生。\n");
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

★ 本机实测:选择排序 n = 20、1000 组随机数据,「自己和自己交换」平均每组发生 2.77 次964 / 1000 组至少发生一次。⇒ 这不是「理论上可能」,是几乎每次排序都会发生

⇒ 结论很干脆:std::swap ★ 更一般的一课:看着聪明的写法,往往有一个没写出来的前提。 (第 19 章那条「凡是只和上一个比的写法,都藏着一个没写出来的排序前提」是同一件事。)

12★ 对拍:这一章有两条线

主线:找那根落单的筷子。标准答案用 brute.cpp(每根数一遍)—— 它和正解的思路完全不同:正解靠的是异或的代数性质,它从头到尾没碰过任何一位。

对拍器
★ 生成器不给档位时跑的就是最终档(第 13 步那张表里的档位 2):它会专门造「落单的那根正好最长」这一档。
// 正解:把所有数异或起来,剩下的就是答案 —— O(n) 时间,O(1) 空间
//
// 靠的是异或的三条性质(verify.cpp 里当场验了两万组):
// ① a ^ a = 0 —— 一样的两个数,异或掉就没了
// ② a ^ 0 = a —— 和 0 异或不变
// ③ 可交换、可结合 —— 所以「先后顺序无所谓」,随便怎么凑对都行
//
// 于是把 2n+1 个数全部异或起来:n 对相同的各自变成 0(性质 ①、③),
// 0 再和落单的那个异或还是它自己(性质 ②)。**一个循环,一个变量。**
//
// ★ 注意它比另外三种做法少的不只是时间:它**一个数组都不用开**,
// 甚至可以边读边算 —— 这在 m 达到 2×10⁶ 时是实打实的差别(见第 6 步那张内存表)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int m;
cin >> m;
int res = 0, x;
for (int i = 0; i < m; i++) { cin >> x; res ^= x; }
cout << res << '\n';
return 0;
}
点一下即可编辑

★ 工具线:七个动作的对拍。标准答案是 bitsNaive.cpp —— 它把整数摊成 64 个 0/1, 一位一位地改再拼回去,全程一个位运算符都不用

bitsNaive.cpp标准答案:不用任何位运算
★ 为什么这一份值得单独写

第 20 章那条:标准答案最好用完全不同的思路写出来。 这一份和 bits.cpp 唯一的共同点是「读同样的输入、写同样的输出」—— 所以它们对不上时,说的一定是「那一行位运算写错了」,而不是两份一起错。

★★ 而它被逼着把补码写出来(这也是第 9 步敢不单列补码一节的底气): 拆位时 (unsigned long long)x 让负数按 2⁶⁴ 取模变成一个大正数,那个大正数的二进制就是补码; 拼回去时最高位的权重取 −2⁶³。两句话合起来就是补码的全部。

300 轮实测(种子 1..300):

故意写错的地方 被抓 靠什么现形
wrongInit:异或的初值写成 a[0] 300 / 300 不挑数据(下面有账)
wrongPriu & bit == 0 优先级 163 / 300 不挑数据
wrongLast:排序版漏了「落单的在最后」那一行 ★ 133 / 300 落单的正好最长
wrongShift1 << k 用了 int 的 1 ★ 208 / 300 位号 ≥ 32
wrongHalf:拿 >> 1 当除以 2 ★ 69 / 300 负的奇数
五个错误版本(点开看)
wrongInit.cpp① 异或的初值写成了 a[0]
wrongLast.cpp③ 漏了「前面全成对 ⇒ 落单在最后」
wrongPri.cpp② 优先级:u & bit == 0
wrongShift.cpp④ 1 << k 而不是 1ull << k
wrongHalf.cpp⑤ x >> 1 当除以 2

★ 每一份的文件头都写着「它靠什么现形」—— 那不是注释,那是下一步调生成器的依据。

13★★ 生成器:两笔干净的账,和一次白干

工具线的六个档位(每一档 300 轮):

档位 相对上一档拧了什么 ② Pri ④ Shift ⑤ Half
0(顺手写法) x ∈ [0,1000],位号 k ∈ [0,10] 183 0 0
1 ★ 三成概率位号拧到 [32,62] 198 191 0
2 ★ 三成概率 x 取负 167 0 53
3 ★ 最终档 = 1 + 2 + 值域放大到 ±10¹⁸ 163 208 69
4 对照 = 3 − 大位号 169 0 59
5 对照 = 3 − 负数 176 216 0
★★★ 两笔账,而且两个 0 都是精确的 0

① 「大位号」这个旋钮,单独决定 wrongShift 的死活。

档位 4(最终档去掉它):0 / 300;加回去:208 / 300。 ⇒ 顺手写的测试只试 0~10 位,它一辈子全绿

② 「负数」这个旋钮,单独决定 wrongHalf 的死活。

档位 5(最终档去掉它):0 / 300;加回去:69 / 300。 ⇒ 只造非负数的话,>> 1/ 2 的差别根本没有出场机会。

★★ 两个都是「随机数据抓不到」,但缺的东西完全不同:一个缺位号的范围,一个缺符号。 这就是为什么动笔前那张「每个 bug 靠什么现形」的清单必须先写 —— 不写下来,你只会一股脑「把数据调大」,而这两个 bug 一个都抓不到。

⚠ 顺带一笔:wrongHalf 只有 69 / 300,因为 half 只占七个操作里的一个, 还得撞上负的数。抓获率低不等于旋钮没用 —— 0 和 69 之间隔的是「能不能测到」。

⚠⚠ 两次老实账:一次预判白干,一次生成器造出了废数据

一、那个旋钮白设计了。 动手前的清单里写着:「wrongInit(初值写成 a[0]) 应该在『落单的正好排在第一个』时蒙对」,于是特地做了一个旋钮去造那一档。 实测三档全是 300 / 300 —— 因为那时它算出来的是 0,而长度都 ≥ 1,0 从来不是合法答案。 ⇒ 旋钮撤掉,换成了「落单的正好最长」(那个是真有用的:43 → 159)。 ★ 第 39 章那条「我知道这个 bug 靠什么现形 ≠ 我该去造那个性质」的又一次。

二、生成器第一版造出了没有唯一解的数据。 为了让落单的成为最大值, 第一版去「另造一个比最大值还大的数」—— 值域小的时候造不出来,兜底又和已有的撞上, 于是那一组里有两根一样长的「落单」筷子。三份正确实现当场对不上,80 / 300。 ⇒ 改成「从已经挑好的那些数里选最大的那个当落单的」,一行解决。 ★ 第 24 章那条「生成器造完,先看一眼答案像不像话」的现场 —— 而这次是三份正确实现互相打架把它抖出来的,比看答案还灵。

genOps.cpp(六个档位)两个旋钮,每一个都写清了它是为哪个 bug 拧的

14回头看:那 113 次 lowbit,和这一章没讲的

★ 现在回去看前面几章,那些位运算都能读懂了
哪一章 那一行 现在你知道它在干什么
第 28 章 S | (1 << i) 把第 i 个元素加进集合 —— 就是第 8 步那张表的第二行
第 28 章 S & ~(1 << i) 从集合里去掉 —— 第三行
第 28 章 (1 << n) - 1 全集:n 个 1 —— ★ 「2^n 减一等于低 n 位全是 1」,值得单独记住
第 38 章 i & -i lowbit —— 第 9 步刚证过
第 42 章 b & 1b >> 1 取最低位、去掉最低位 = 把 b 一位一位地读二进制
第 37 章 i >> 1 堆里父亲的下标 = i / 2(★ 下标是正数,所以这里 >> 是安全的)
⚠ 这一章没讲的
没讲的 一句话
枚举子集的子集 for (int t = S; t; t = (t - 1) & S) —— 状压 DP 进阶要用,总复杂度 3ⁿ
__builtin_ctz / clz 数末尾 / 开头有几个 0,配合 lowbit 能直接算出「第几位」
异或前缀和、线性基 提高组内容:a[l..r] 的异或和 = s[r] ^ s[l-1](和第 6 章前缀和同一个形状)
bitset 把「一排开关」直接开成几万位,常数除以 64

★ 但这一章那三条性质和七个动作,已经够你读懂本书里所有出现过的位运算了。

15自测

自测清单0 / 14
配套练习
  • 洛谷 P1469 找筷子 —— ★ 就是这一章那道题的原题。⚠ 数据量大,记得按第 45 章那节加读入优化
  • 洛谷 P1100 高低位交换 —— ★ 把 32 位数的高 16 位和低 16 位换个位置。两行搞定:(x >> 16) | (x << 16) —— ⚠ 想清楚这里为什么必须用 unsigned
  • 洛谷 B2143 转换字母 —— 大小写转换其实就是翻转第 5 位('a' 和 'A' 只差 32 = 1 << 5)。一道让你相信「字符也是整数」的小题
  • 洛谷 P1582 倒水 —— ★★ 答案就是「popcount(n) 怎么降到不超过 k」。这道题是 popcount 和 lowbit 的第一次正式登场
  • 洛谷 P2114 [NOI2014] 起床困难综合症 —— ⚠ 提高组:每一位互相独立,所以可以一位一位地贪心决定填 0 还是 1。★ 「按位拆开」是位运算最重要的一个套路
  • 洛谷 P1226 【模板】快速幂 —— 第 42 章那道题。现在回去看它的 b & 1 和 b >>= 1,应该一眼就明白了
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  1. 异或的三条性质a ^ a = 0a ^ 0 = a、顺序无所谓。 这道题的正解整个压在这三条上 —— 而它们让程序不需要记住任何东西
  2. ★★ 位运算的优先级比比较运算还低。== / < 撞在一起时一律加括号; 而 -Wall 会替你看着这一条,打开它是免费的
  3. 位运算值钱的地方不是「快」。 实测 x * 2x << 1 一样快; 真正快的那两行,快是因为它们算的不是同一件事(负数上差一个 1)。 它真正的价值是:一个整数就是一排开关,一次表达一整个集合、一整个状态。