C++ 竞赛入门
bitset 位集
位数固定的二进制开关:置位、翻转、计数与整块按位运算。
bitset 位集
读完能做什么
- 能创建固定位数的
bitset,说出每一位是 0 还是 1,并算出一共有几个 1。 - 能用
set、reset、flip打开、关闭、翻转某一位,也能一次处理全部位。 - 能分清“显示字符串的左端是高位、右端是第 0 位”,并手算按位与、按位或的结果。
- 能说出位下标越界时
test抛异常、operator[]不检查,以及移位会丢弃越出固定宽度的位。
为什么需要它
很多问题可以用“一串开关”来记录状态:某个数出现过没有、某个人到场没有、某几个选项选中没有。用 bool 数组当然也行,但每个开关其实只需要 1 个二进制位,而 bool 通常要占 1 个字节;bitset 把这些位打包在一起,更省内存,还能整块做按位运算。
比如“用一个 8 位串记录 8 个灯的开灭”,用数组要写循环逐个设置;如果把它看成一个整体,就能用 set、flip 一次操作某一位,用 &、| 一次合并两组开关。标准库为此提供了 bitset:一个位数固定、每一位只存 0/1 的小容器。
它是什么
一句话:bitset<N> 是固定 N 位的二进制开关,每一位是 0 或 1。
几个必须就地看懂的概念:
位数写死在模板尖括号里。 bitset<8> 表示“8 位”,bitset<32> 表示“32 位”。这里的 <8> 是模板参数,创建之后位数不能改,不能像 vector 那样 push_back。这是 bitset 和 vector<bool> 的主要区别。
位下标从 0 开始,0 是最右边那位。 第 0 位是最低位,在显示字符串的最右端;位数减 1 是最高位,在最左端。
显示字符串的方向。 用字符串构造时,字符串从左到右对应“从高位到低位”。以 bitset<8>("00000101") 为例:
| 显示字符串 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 |
|---|---|---|---|---|---|---|---|---|
| 位下标 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
| 位置 | 最左端(高位) | 最右端(低位) |
graph LR
L["字符串左端 高位 下标 7"] --> R["字符串右端 低位 第 0 位"]
所以 "00000101" 里,从左往右第 6 个字符是第 2 位,最后一个是第 0 位。字符串的读法和位下标的读法是反的,这是最容易看错的地方。
operator[] 与 test。 b[i] 像数组下标一样读写第 i 位;b.test(i) 只读第 i 位,并且会检查范围。区别见后面“哪里容易错”。
适用场景:位数固定、需要逐位操作或整块按位运算。使用时要 #include <bitset>,本页示例用 #include <bits/stdc++.h> 一次包含全部标准头文件。
最小可运行示例
用字符串 "00000101" 构造一个 8 位 bitset,读出几位并数 1 的个数:
#include <bits/stdc++.h>
using namespace std;
int main() {
bitset<8> b("00000101");
cout << "内容:" << b << '\n';
cout << "第 0 位:" << b[0] << '\n';
cout << "第 1 位:" << b[1] << '\n';
cout << "第 2 位:" << b[2] << '\n';
cout << "1 的个数:" << b.count() << '\n';
return 0;
}
输出:
内容:00000101
第 0 位:1
第 1 位:0
第 2 位:1
1 的个数:2
逐行看:
bitset<8> b("00000101");用 8 个字符的字符串构造。字符串右端是第 0 位,所以第 0 位是1、第 2 位是1,其余都是 0。cout << b直接打印整个bitset,输出00000101,左边是高位、右边是第 0 位。b[0]、b[1]、b[2]是位下标访问,分别得到 1、0、1。b.count()数出 1 的个数,这里是 2。
一步步看变化
沿 "00000101" 依次做三次单位操作:
| 步骤 | 操作 | 操作后显示 | 说明 |
|---|---|---|---|
| 0 | 初始 | 00000101 |
第 0、2 位是 1 |
| 1 | b.set(1) |
00000111 |
第 1 位置 1 |
| 2 | b.reset(2) |
00000011 |
第 2 位清 0 |
| 3 | b.flip(0) |
00000010 |
第 0 位翻转 |
#include <bits/stdc++.h>
using namespace std;
int main() {
bitset<8> b("00000101");
b.set(1); // 第 1 位置 1
cout << "set(1) 后:" << b << '\n';
b.reset(2); // 第 2 位清 0
cout << "reset(2) 后:" << b << '\n';
b.flip(0); // 第 0 位翻转
cout << "flip(0) 后:" << b << '\n';
return 0;
}
输出:
set(1) 后:00000111
reset(2) 后:00000011
flip(0) 后:00000010
对照表格逐步核对:
set(1)把第 1 位从 0 改成 1,00000101→00000111。reset(2)把第 2 位从 1 改成 0,00000111→00000011。flip(0)把第 0 位从 1 翻成 0,00000011→00000010。
逐项扩展核心操作
按位与、按位或
bitset 支持整块的按位运算。把 00000101 和 00000011 逐位对齐:
| 位下标 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|
a |
0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 |
b |
0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 |
a & b(都为 1 才是 1) |
0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
a | b(有一个 1 就是 1) |
0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 |
#include <bits/stdc++.h>
using namespace std;
int main() {
bitset<8> a("00000101");
bitset<8> b("00000011");
cout << "a & b:" << (a & b) << '\n';
cout << "a | b:" << (a | b) << '\n';
return 0;
}
输出:
a & b:00000001
a | b:00000111
按位与 00000101 & 00000011 得到 00000001:只有第 0 位两边都是 1。按位或 00000101 | 00000011 得到 00000111:第 0、1、2 位至少有一边是 1。运算结果的位数不变,仍是 8 位。
test、any、none、all、count
#include <bits/stdc++.h>
using namespace std;
int main() {
bitset<8> b("00000101");
cout << "test(0):" << b.test(0) << '\n';
cout << "any():" << b.any() << '\n';
cout << "none():" << b.none() << '\n';
cout << "all():" << b.all() << '\n';
cout << "count():" << b.count() << '\n';
cout << "size():" << b.size() << '\n';
b.set(); // 全部置 1
cout << "set() 后:" << b << ",all():" << b.all() << '\n';
b.reset(); // 全部置 0
cout << "reset() 后:" << b << ",none():" << b.none() << '\n';
return 0;
}
输出:
test(0):1
any():1
none():0
all():0
count():2
size():8
set() 后:11111111,all():1
reset() 后:00000000,none():1
test(0)读第 0 位,是 1。any()问“有没有 1”,有;none()问“是不是全 0”,不是;all()问“是不是全 1”,不是。三者都是bool,打印成 0/1。count()是 1 的个数(2),size()是位数(8)。- 不带参数的
set()把所有位置 1,reset()把所有位置 0。所以set()后all()为 1,reset()后none()为 1。
移位会丢弃越界的位
#include <bits/stdc++.h>
using namespace std;
int main() {
bitset<8> b("10000001");
cout << "左移 1 位:" << (b << 1) << '\n';
cout << "右移 1 位:" << (b >> 1) << '\n';
cout << "原值不变:" << b << '\n';
return 0;
}
输出:
左移 1 位:00000010
右移 1 位:01000000
原值不变:10000001
b 是 10000001:第 7 位和第 0 位是 1。
- 左移 1 位:所有位往高位方向移动一格,第 7 位的 1 移出宽度之外被丢弃,原来的第 0 位来到第 1 位,结果
00000010。 - 右移 1 位:所有位往低位方向移动一格,第 0 位的 1 被丢弃,原来的第 7 位来到第 6 位,结果
01000000。 - 移位不会改变原来那个
bitset,b仍是10000001。要保存结果得用另一个变量接住,或写b <<= 1。
什么时候选它、哪里容易错
位数固定,不能改。 bitset<8> 永远是 8 位,不能加一位、减一位。位数需要随运行情况变化时,用 vector<bool> 或别的结构。
字符串方向和位下标是反的。 字符串从左到右是“高位到低位”,位下标从右往左是“0 到 N-1”。看到 "00000101" 时先数右边:最后一位是第 0 位。
operator[] 不检查边界,test 会检查。 b[i] 像数组下标一样,i 超出 [0, N) 是未定义行为:
// 错误示例:位下标 8 超出了 bitset<8> 的范围,operator[] 不检查边界
#include <bits/stdc++.h>
using namespace std;
int main() {
bitset<8> b("00000101");
b[8] = 1; // 越界,未定义行为
return 0;
}
这段能编译,但 b[8] 越界,结果不确定,本页不给它写运行结果。相比之下,test(i) 在 i 越界时会抛出 out_of_range 异常。下面用 try { … } catch (…) { … } 把它接住:这段代码一旦抛出异常,就跳到 catch 里继续执行,而不是让程序当场结束(异常是进阶话题,这里只要看懂“越界会被发现”这一点)。
#include <bits/stdc++.h>
using namespace std;
int main() {
bitset<8> b("00000101");
try {
b.test(8); // 下标 8 越界,test 会抛异常
cout << "没有异常" << '\n';
} catch (const out_of_range&) {
cout << "越界时抛出了 out_of_range 异常" << '\n';
}
return 0;
}
输出:
越界时抛出了 out_of_range 异常
所以:要安全读一位,用 test 并保证下标合法;要改写一位,用 set(i, v) 或 reset(i),并自己保证下标在范围内。
移位会丢位。 左移、右移时,移出固定宽度的位直接消失,不会“存到别处”。位数固定意味着 bitset<8>("10000001") << 1 的结果是 00000010,最高位那个 1 没了。
按位运算要求位数相同。 a & b 里的两个 bitset 必须位数一致(模板参数相同),否则编译不过。
to_ulong / to_ullong 有位数限制(选读)。 它们把整个 bitset 转成一个整数:
#include <bits/stdc++.h>
using namespace std;
int main() {
bitset<8> b("00000101");
cout << "to_ulong():" << b.to_ulong() << '\n';
cout << "to_ullong():" << b.to_ullong() << '\n';
return 0;
}
输出:
to_ulong():5
to_ullong():5
to_ulong 要求位数不超过 unsigned long 的宽度,to_ullong 要求不超过 unsigned long long 的宽度;超出可表示范围时它们会抛出 overflow_error,所以位数多的时候不要随便调用。日常按位操作不必用到它们。
选读。 bitset 内部把若干位打包进机器字,所以 count、any 这类整体操作很快。具体怎么打包是实现细节,不影响你怎么使用。
操作速查表
| 写法 | 作用 | 备注 |
|---|---|---|
bitset<8> b; |
8 位,全 0 | 位数写死在模板里 |
bitset<8> b("00000101"); |
用字符串构造 | 字符串左端是高位、右端是第 0 位 |
b[i] |
读/写第 i 位 |
不检查边界 |
b.test(i) |
读第 i 位 |
越界抛 out_of_range |
b.set(i) |
第 i 位置 1 |
下标要合法 |
b.set(i, v) |
第 i 位设为 v |
v 是 true/false |
b.set() / b.reset() |
全部置 1 / 全部置 0 | 不带参数就是全部 |
b.reset(i) |
第 i 位清 0 |
下标要合法 |
b.flip(i) / b.flip() |
翻转第 i 位 / 全部翻转 |
不带参数就是全部翻转,下标要合法 |
b.count() |
1 的个数 | 返回值是无符号整数 |
b.any() / b.none() / b.all() |
有 1 / 全 0 / 全 1 | 返回 bool |
b.size() |
位数 | 固定值 N |
b & c / b | c / b ^ c |
按位与 / 或 / 异或 | 位数必须相同 |
b << k / b >> k |
左移 / 右移 | 越界的位被丢弃 |
b.to_ulong() / b.to_ullong() |
转成整数 | 选读,有位数限制 |
动手检查
第 1 题:预测输出
下面是一段片段(省略了头文件和 main),请先手推出输出,再运行核对:
bitset<8> b("00001111");
b.flip(0);
b.set(4);
cout << b << ' ' << b.count() << '\n';
第 2 题:修改代码
下面这段片段从 "00000101" 出发。请补上“把最高位(第 7 位)置 1”的操作,让输出变成 10000101 3:
bitset<8> b("00000101");
// 在这里补一行:把第 7 位置 1
cout << b << ' ' << b.count() << '\n';
验证条件:先说出补完之后一共几个 1,再运行核对。
参考结果与解释
第 1 题:输出是 00011110 4。逐步推:
| 步骤 | 操作 | 显示 |
|---|---|---|
| 初始 | — | 00001111 |
| 1 | b.flip(0) |
00001110 |
| 2 | b.set(4) |
00011110 |
00011110 里第 1、2、3、4 位是 1,共 4 个,所以 count() 是 4。完整程序与实测输出:
#include <bits/stdc++.h>
using namespace std;
int main() {
bitset<8> b("00001111");
b.flip(0);
b.set(4);
cout << b << ' ' << b.count() << '\n';
return 0;
}
输出:
00011110 4
第 2 题:补上 b.set(7);。第 7 位是最高位,在显示字符串的最左端,置 1 后变成 10000101;原来第 0、2 位各有一个 1,加上第 7 位共 3 个。
#include <bits/stdc++.h>
using namespace std;
int main() {
bitset<8> b("00000101");
b.set(7);
cout << b << ' ' << b.count() << '\n';
return 0;
}
输出:
10000101 3
立即练习
| 难度 | 题目 | 题解 | 训练目标 |
|---|---|---|---|
| 普及- | 洛谷 P1100:高低位交换 | 题解 | 把一个 32 位整数的低 16 位和高 16 位交换:练习位下标与移位,注意 bitset 里第 0 位是最低位、字符串左端是高位。输入可能超过 int 的范围,用 unsigned 或 long long 读入;输出要求十进制,用 to_ulong()(或 to_ullong())转回去。 |
这道题要用到本页的位下标、移位和整数转换。做完后请说明:交换高低 16 位时,为什么“往左移 16 位”和“往右移 16 位”就能把两半调换位置?