位运算在这本书里用得很重,可正文一次都没讲过它。2026-08-25 数了一遍:
| 哪一章 | 用了什么 | 出现次数 |
|---|---|---|
| 第 28 章 状压 DP | 1 << i / S & mask |
44 / 29 |
| 第 38 章 树状数组 | lowbit |
★ 113 |
| 第 42 章 快速幂 | b & 1 / b >> 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
3 和 3 一对、9 和 9 一对,落单的是 7。
⚠ 注意成对的两根不一定挨在一起 —— 顺序是乱的。
输入
1 42
输出
42
★ 边界:只有一根筷子时,它自己就是落单的那个。
写完记得拿这一组试试 —— m = 1 时循环一次都不进,很多写法会在这儿出事。
第 45 章刚讲过:估内存和估时间是同一件事,都要在动手之前算一遍。 这道题把这件事摆到了明面上 —— 第 4 步会看到,一个非常自然的想法正好卡在这条线上。
2第一反应:每根都数一遍它出现了几次
题目说「只有一根出现一次」,那就把每根拿出来数一数,只出现一次的那个就是答案。
// 标准答案:对每个数,数一遍它出现了几次 —— 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;}点「运行 ▶」看结果
本机实测(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 分钟(按 n² 外推) |
⇒ 题面上限 m = 2×10⁶+1,时限 1 秒。差了三个数量级,方向就死在这儿。
(第 45 章第 2 步那个动作:先估,再决定写不写。)
3第二反应:排完序,成对的就挨在一起了
排序之后一对一对地看,第一个「和后面那个不一样」的就是答案。
// 做法二:排完序,相同的两个一定挨在一起 —— 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;}点「运行 ▶」看结果
本机实测:200 万根 0.179 秒 —— 这一份是能过的。
- 它要把所有数存下来:200 万个
int= 8 MB(实测峰值内存 11.5 MiB)。 - 它要
O(n log n):排序本身就是这道题里最贵的一步。
而下面会看到,正解一个数组都不用开,时间还快三倍。 ⚠ 但先记住第 45 章那句话:能过的分和最优解的分一模一样。 这一份写出来就该交,剩下的时间去做别的题 —— 下面那些是给「还有时间」的你看的。
4⚠ 第三反应:开个计数数组 —— 时间对了,内存爆了
「数出现次数」最直接的写法就是开一个数组 cnt[长度]。时间是 O(n),比排序还快。
唯一的问题在那句声明上:
题面说长度最大 10^8
⇒ int cnt[100000001] = 4 x 10^8 字节 = 381 MiB
而内存限制是 256 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;}点「运行 ▶」看结果
本机实测:./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 又不影响剩下的那个。剩下的就是答案。
★ 三条里最容易被跳过的是 ③。没有它,「成对的不挨在一起」这件事就绕不过去 —— 而正是它让这个做法不需要排序、不需要记住任何东西。
// 正解:把所有数异或起来,剩下的就是答案 —— 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;}点「运行 ▶」看结果
对拍验的是「两份实现算得一样」,验不了「我引用的那条性质本身成立吗」。
这一章整个立论都压在上面那三条上,所以它们要单独验一遍(第 44 章 verify.cpp 的同款分工)。
// 第三把尺子:把这一章用到的每一条「性质」当场验一遍//// ★ 对拍验的是「两份实现算得一样」,验不了「我引用的那条性质本身成立吗」。// 这一章整个立论都压在几条性质上,所以它们必须单独验(第 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动画一:看成对的怎么自己消失
盯住下面那行二进制:每吃进一个数,就有若干位被翻了一下; 同一个数吃第二次,翻过去的位一位不差地翻了回来。
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 | ✓ 又快又省 |
异或版边读边算,从来没有把 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)。记这一句就够了。
// 位运算工具箱:七个最常用的动作,每个都是一行//// 输入:第一行 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;}点「运行 ▶」看结果
输入
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) = 8;255 有 8 个 1;−7 / 2 = −3(★ 注意是 −3 不是 −4,第 10 步会说)。
自己拧一拧(下面这个不是动画,是实验台 —— 改哪个数字都会立刻重算):
9★ 补码:只在两个非讲不可的地方
这本书不单列一节讲原码 / 反码 / 补码 —— 那样它必然变成背诵。 它只在两个「不讲就说不通」的地方出现,而这两处你都已经用过很多次了。
计算机存整数用的是补码,规则只有一句:
32 位有符号数,从高到低每一位的权重是
-2^31 2^30 2^29 ... 4 2 1
^^
最高位这一个是负的,别的都是正的于是 -1 就是全 1(-2^31 + 2^30 + ... + 1 = -1),而 ~x = -x - 1(verify.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 >> 1 和 x / 2 在负数上不是一回事 —— 下一步就量给你看。
10★ 口诀复核:位运算真的更快吗
「位运算比算术快」大概是每个人学位运算听到的第一句话。 这本书的立场一向是口诀要拿实测复核(第 29、32、33、34、41、45 章各犯过一次),所以现场量。
// ★ 口诀复核:「位运算比算术快」到底成不成立//// 这句口诀几乎是每个人学位运算时听到的第一句话。这本书的立场一向是「口诀要拿实测复核」// (第 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 * 2 和 x << 1 在 -O2 下生成的是同一条指令,所以一样快。
x % 2 和 x & 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 << k 里的 1 是 int(32 位)。k 一旦到 31 就出界,到 32 以上更离谱:
★ x86 的移位指令只看位数的低 5 位,于是 1 << 40 实际执行的是 1 << 8 ——
看着像位号自己绕回去了,而且不崩、不报警。
⇒ 写成 1LL << k 或 1ull << k:让那个 1 先变成 64 位,再移。
本书里凡是可能移超过 30 位的地方都是这么写的。
非负数上它们一样;负数上差一个 1。二分里写 (l + r) >> 1 而区间可能有负数时,
差的那 1 就是死循环。⇒ 确定非负才用 >>,而且那时它也不会更快。
a ^= b; b ^= a; a ^= b; // 三行,不用临时变量它是对的 —— 前提是 a 和 b 是两个不同的变量。
一旦是同一个(swap(v[i], v[j]) 而 i == j),第一行 a ^= a 直接把它清零,后面救不回来。
// 「不用第三个变量就能交换」—— 这个著名技巧,以及它为什么不该用//// 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(每根数一遍)——
它和正解的思路完全不同:正解靠的是异或的代数性质,它从头到尾没碰过任何一位。
// 正解:把所有数异或起来,剩下的就是答案 —— 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,
一位一位地改再拼回去,全程一个位运算符都不用。
第 20 章那条:标准答案最好用完全不同的思路写出来。
这一份和 bits.cpp 唯一的共同点是「读同样的输入、写同样的输出」——
所以它们对不上时,说的一定是「那一行位运算写错了」,而不是两份一起错。
★★ 而它被逼着把补码写出来(这也是第 9 步敢不单列补码一节的底气):
拆位时 (unsigned long long)x 让负数按 2⁶⁴ 取模变成一个大正数,那个大正数的二进制就是补码;
拼回去时最高位的权重取 −2⁶³。两句话合起来就是补码的全部。
300 轮实测(种子 1..300):
| 故意写错的地方 | 被抓 | 靠什么现形 |
|---|---|---|
① wrongInit:异或的初值写成 a[0] |
300 / 300 | 不挑数据(下面有账) |
② wrongPri:u & bit == 0 优先级 |
163 / 300 | 不挑数据 |
③ wrongLast:排序版漏了「落单的在最后」那一行 |
★ 133 / 300 | 落单的正好最长 |
④ wrongShift:1 << k 用了 int 的 1 |
★ 208 / 300 | 位号 ≥ 32 |
⑤ wrongHalf:拿 >> 1 当除以 2 |
★ 69 / 300 | 负的奇数 |
★ 每一份的文件头都写着「它靠什么现形」—— 那不是注释,那是下一步调生成器的依据。
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 |
① 「大位号」这个旋钮,单独决定 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 章那条「生成器造完,先看一眼答案像不像话」的现场 —— 而这次是三份正确实现互相打架把它抖出来的,比看答案还灵。
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 & 1 / b >> 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自测
- 洛谷 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,应该一眼就明白了
- 异或的三条性质:
a ^ a = 0、a ^ 0 = a、顺序无所谓。 这道题的正解整个压在这三条上 —— 而它们让程序不需要记住任何东西。 - ★★ 位运算的优先级比比较运算还低。 和
==/<撞在一起时一律加括号; 而-Wall会替你看着这一条,打开它是免费的。 - ★ 位运算值钱的地方不是「快」。 实测
x * 2和x << 1一样快; 真正快的那两行,快是因为它们算的不是同一件事(负数上差一个 1)。 它真正的价值是:一个整数就是一排开关,一次表达一整个集合、一整个状态。