C++ STL bitset 和位图详解

C++ STL bitset 和位图详解

文章目录

bitset 是从位图问题引出来的

先看一个经典问题,给 40 亿个不重复的无符号整数,数据没有排序,现在再给一个无符号整数,要求快速判断这个数是否在这 40 亿个数里面

第一反应可能有两种

  • 把所有数据排序,然后用二分查找
  • 把所有数据放进 unordered_set,然后用 find 查找

从时间复杂度看,这两种思路都能说得通,排序加二分,建表阶段大概是 O(NlogN),查找阶段是 O(logN),unordered_set 建表平均 O(N),查找平均接近 O(1)

但这个题真正麻烦的不在时间,而是在空间,一个无符号整数通常占 4 字节,40 亿个整数大概要占 160 亿字节,也就是 16GB 左右,如果再考虑容器本身的额外开销,unordered_set 需要的空间只会更夸张

所以这类题不能只盯着查找速度,还要先问一句,数据能不能放得下

用一个比特位表示一个数是否存在

这个问题里,我们只关心一个数在不在集合中,也就是说每个数只有两种状态

  • 存在
  • 不存在

既然只有两种状态,其实一个比特位就够表示了,比特位为 1,表示这个数出现过,比特位为 0,表示这个数没有出现过

无符号整数的取值范围一共有 2^32 种可能,如果给每个可能的无符号整数准备一个比特位,就需要 2^32 个比特位,换算一下:

text 复制代码
2^32 bit = 2^29 byte = 512MB

原来直接存 40 亿个整数需要 16GB 左右,现在只需要 512MB 左右,这就是位图的核心价值,用很小的空间记录大量数据的存在状态

位图的概念

位图就是用每一个二进制位来存放某种状态,它特别适合这类场景:

  • 数据量很大
  • 数据范围相对明确
  • 只关心存在或者不存在
  • 通常不需要记录重复次数

比如要记录整数 x 是否出现过,就可以把第 x 个比特位设置成 1,查找 x 是否存在时,再去检查第 x 个比特位是不是 1,这个过程本质上不需要比较,也不需要哈希冲突处理,定位到对应的位就行

不过位图也有明显限制,它适合整数范围比较集中的场景,如果数据是一些很分散的超大整数,或者字符串,直接拿位图就不太合适,如果要处理字符串,一般要先经过哈希映射,但这样又会带来哈希冲突问题

位图的应用

位图的典型应用不少

  • 快速判断某个数据是否在集合中
  • 对整数数据做去重
  • 对一定范围内的整数做排序
  • 求两个集合的交集和并集
  • 操作系统中标记磁盘块是否被占用
  • 内核中记录信号屏蔽字和未决信号集

位图做排序的思路也很直观,先把出现过的整数对应的位设置成 1,然后从低位到高位扫描整个位图,遇到值为 1 的位,就输出这个位对应的整数,因为扫描顺序本身就是从小到大,所以输出结果天然有序

但这种方式只适合不需要保留重复次数的整数排序,如果数据里有重复值,而你又要保留重复次数,那普通位图就不够了,这时可以考虑计数数组,或者在位图基础上做扩展

bitset 的基本认识

C++ 标准库里提供了 bitset,它可以理解成一个固定大小的位图,使用 bitset 需要包含头文件:

cpp 复制代码
#include <bitset>

bitset 的大小在编译期确定,比如 bitset<16> 表示它有 16 个比特位,这 16 位的下标范围是 0 到 15

下标 0 表示最低位,下标 15 表示最高位,这点在看输出时很容易弄混,比如设置第 0 位,打印出来时通常会出现在最右边

bitset 的定义方式

方式一:构造一个指定大小的 bitset,所有位默认都是 0

cpp 复制代码
#include <iostream>
#include <bitset>
using namespace std;

int main()
{
    bitset<16> bs1;
    cout << bs1 << endl; // 0000000000000000

    return 0;
}

方式二:通过整数初始化

cpp 复制代码
#include <iostream>
#include <bitset>
using namespace std;

int main()
{
    bitset<16> bs2(0xfa5);
    cout << bs2 << endl; // 0000111110100101

    return 0;
}

0xfa5 的二进制低 16 位会被放进 bitset 里,如果整数的二进制位数少于 bitset 的长度,高位会自动补 0

方式三:通过 0 和 1 组成的字符串初始化

cpp 复制代码
#include <iostream>
#include <bitset>
#include <string>
using namespace std;

int main()
{
    bitset<16> bs3(string("10111001"));
    cout << bs3 << endl; // 0000000010111001

    return 0;
}

字符串右边的字符会对应 bitset 的低位,所以字符串 "10111001" 初始化到 bitset<16> 里,打印时前面会补 0

bitset 常用成员函数

bitset 常用成员函数如下:

成员函数 功能
set 设置指定位或所有位
reset 清空指定位或所有位
flip 反转指定位或所有位
test 获取指定位的状态
count 获取被设置为 1 的位数
size 获取 bitset 能容纳的位数
any 判断是否至少有一个位为 1
none 判断是否所有位都是 0
all 判断是否所有位都是 1
to_string 转成由 0 和 1 组成的字符串
to_ulong 转成 unsigned long
to_ullong 转成 unsigned long long

set、reset、flip 都有两个常见用法,一种是操作指定位置,另一种是不传下标,直接操作所有位

cpp 复制代码
#include <iostream>
#include <bitset>
using namespace std;

int main()
{
    bitset<8> bs;

    // 把第 1 位和第 3 位置成 1
    bs.set(1);
    bs.set(3);
    cout << bs << endl; // 00001010

    // 清掉第 1 位
    bs.reset(1);
    cout << bs << endl; // 00001000

    // 把第 3 位从 1 翻成 0
    bs.flip(3);
    cout << bs << endl; // 00000000

    // 不传下标时,表示操作全部位
    bs.set();
    cout << bs << endl; // 11111111

    bs.reset();
    cout << bs << endl; // 00000000

    bs.flip();
    cout << bs << endl; // 11111111

    return 0;
}

test 用来判断某一位是不是 1,count 用来统计有多少位是 1

cpp 复制代码
#include <iostream>
#include <bitset>
using namespace std;

int main()
{
    bitset<8> bs(string("10110010"));

    // test 检查某一位是否为 1
    cout << bs.test(1) << endl; // 1
    cout << bs.test(2) << endl; // 0

    // count 统计 1 的个数,size 返回总位数
    cout << bs.count() << endl; // 4
    cout << bs.size() << endl;  // 8

    // any/none/all 用来做整体状态判断
    cout << bs.any() << endl;  // 1
    cout << bs.none() << endl; // 0
    cout << bs.all() << endl;  // 0

    return 0;
}

这里的 test(1) 看的是从右往左数的第 1 位,因为 bitset 的下标 0 是最低位

bitset 运算符的使用

bitset 支持不少位运算相关的运算符

运算符 功能
\[\] 访问或修改某一位
& 按位与
| 按位或
^ 按位异或
~ 按位取反
&= 按位与后赋值
|= 按位或后赋值
^= 按位异或后赋值
<< 左移
>> 右移
<<= 左移后赋值
>>= 右移后赋值
== 判断两个 bitset 是否相等
!= 判断两个 bitset 是否不相等

直接用 \[\] 可以访问某一位

cpp 复制代码
#include <iostream>
#include <bitset>
using namespace std;

int main()
{
    bitset<8> bs;

    // 下标 0 对应最低位,所以会显示在最右边
    bs[0] = 1;
    bs[3] = 1;

    cout << bs << endl;    // 00001001
    cout << bs[3] << endl; // 1

    return 0;
}

位运算可以用来做集合操作,比如一个 bitset 表示一个集合,某一位为 1 表示这个元素存在,两个 bitset 按位与,可以得到交集,两个 bitset 按位或,可以得到并集

cpp 复制代码
#include <iostream>
#include <bitset>
using namespace std;

int main()
{
    bitset<8> s1(string("00101101"));
    bitset<8> s2(string("00011110"));

    // 可以把 bitset 当成位集合来做交并差相关操作
    cout << (s1 & s2) << endl; // 00001100
    cout << (s1 | s2) << endl; // 00111111
    cout << (s1 ^ s2) << endl; // 00110011
    cout << (~s1) << endl;     // 11010010

    return 0;
}

左移和右移也比较常见

cpp 复制代码
#include <iostream>
#include <bitset>
using namespace std;

int main()
{
    bitset<8> bs(string("00001111"));

    // 移出去的位会丢掉,左边或右边空出来的位置补 0
    cout << (bs << 2) << endl; // 00111100
    cout << (bs >> 2) << endl; // 00000011

    bs <<= 1;
    cout << bs << endl; // 00011110

    return 0;
}

移出去的位会丢掉,空出来的位置补 0

bitset 适合做什么

bitset 最适合处理固定范围内的布尔状态,比如:

  • 标记某个编号是否出现过
  • 记录一组开关状态
  • 做集合的交并差相关操作
  • 保存一些标志位
  • 在数据范围固定时做快速去重

如果位数在编译期就确定,用 bitset 很方便,如果位数需要运行时才能确定,标准库的 bitset 就不太合适,这时可以自己用 vector 做动态位图,后面的模拟实现就是这个思路

bitset 模拟实现的接口

为了避免和标准库 bitset 冲突,模拟实现时放到自己的命名空间里,接口可以先按下面这一版来设计:

cpp 复制代码
namespace cl
{
    template<size_t N>
    class bitset
    {
    public:
        bitset();

        void set(size_t pos);
        void reset(size_t pos);
        void flip(size_t pos);
        bool test(size_t pos) const;

        size_t size() const;
        size_t count() const;

        bool any() const;
        bool none() const;
        bool all() const;

        void Print() const;

    private:
        vector<unsigned int> _bits;
    };
}

这里的 N 是非类型模板参数,它表示位图能表示多少个比特位,底层不是真的开 N 个 bool,而是用整数数组来压缩存储

一个 unsigned int 通常有 32 个比特位,所以 N 个比特位需要的整数个数大概是:

text 复制代码
(N + 31) / 32

有些写法会用 N / 32 + 1 来算,这样也能覆盖大多数场景,比如 N 为 40 时,需要 40 / 32 + 1,也就是 2 个整数,不过如果 N 正好是 32 的倍数,N / 32 + 1 会多开一个整数,所以这里实现时用 (N + 31) / 32,更贴近实际需要

构造函数

构造函数要做的事很简单,根据 N 算出需要多少个整数,然后全部初始化为 0

cpp 复制代码
bitset()
{
    _bits.resize((N + 31) / 32, 0);
}

如果 N 是 100,那么需要 4 个 unsigned int,因为 3 个只能提供 96 个比特位,多出来的那些不用的高位,平时不参与逻辑判断

set、reset、flip、test

要操作第 pos 位,先要算出它在第几个整数里,再算出它是这个整数里的第几个比特位,假设一个整数有 32 位:

text 复制代码
i = pos / 32
j = pos % 32

i 表示第几个整数,j 表示这个整数里的第几个比特位

设置第 pos 位为 1:

cpp 复制代码
_bits[i] |= (1u << j);

清空第 pos 位:

cpp 复制代码
_bits[i] &= ~(1u << j);

反转第 pos 位:

cpp 复制代码
_bits[i] ^= (1u << j);

测试第 pos 位:

cpp 复制代码
return (_bits[i] & (1u << j)) != 0;

写成成员函数就是:

cpp 复制代码
void set(size_t pos)
{
    assert(pos < N);

    size_t i = pos / 32;
    size_t j = pos % 32;

    _bits[i] |= (1u << j);
}

void reset(size_t pos)
{
    assert(pos < N);

    size_t i = pos / 32;
    size_t j = pos % 32;

    _bits[i] &= ~(1u << j);
}

void flip(size_t pos)
{
    assert(pos < N);

    size_t i = pos / 32;
    size_t j = pos % 32;

    _bits[i] ^= (1u << j);
}

bool test(size_t pos) const
{
    assert(pos < N);

    size_t i = pos / 32;
    size_t j = pos % 32;

    return (_bits[i] & (1u << j)) != 0;
}

这里用了 assert 做越界检查,标准库 bitset 的 test 越界会抛异常,operator\[\] 不做边界检查,我们这里是模拟实现,用 assert 更简单

size 和 count

size 返回位图能容纳的位数,也就是模板参数 N

cpp 复制代码
size_t size() const
{
    return N;
}

count 用来统计有多少位是 1,最直接的办法是从 0 到 N - 1,一个一个调用 test

cpp 复制代码
size_t count() const
{
    size_t ret = 0;

    for (size_t i = 0; i < N; ++i)
    {
        if (test(i))
        {
            ++ret;
        }
    }

    return ret;
}

这种写法简单清楚,适合学习实现,如果想做得更快,可以按整数块统计每个 unsigned int 里有多少个 1,不过模拟 bitset 时,先把逻辑写明白更重要

any、none、all

any 判断是否至少有一个位是 1,只要某个整数块不为 0,就说明有位被设置了

cpp 复制代码
bool any() const
{
    for (auto value : _bits)
    {
        if (value != 0)
        {
            return true;
        }
    }

    return false;
}

none 判断是否所有位都是 0,它刚好可以复用 any

cpp 复制代码
bool none() const
{
    return !any();
}

all 判断是否所有有效位都是 1,最简单的写法是看 count 是否等于 N

cpp 复制代码
bool all() const
{
    return count() == N;
}

注意最后一个整数里可能有一些多余位,比如 bitset<40> 底层用了两个 unsigned int,实际有 64 个比特位空间,但只有前 40 位有效,所以 all 不能直接判断每个整数是不是全 1,用 count() == N 就能避开这个问题

打印函数

打印时通常从高位往低位打印,这样结果和标准库 bitset 输出习惯一致

cpp 复制代码
void Print() const
{
    for (size_t i = N; i > 0; --i)
    {
        cout << test(i - 1);
    }

    cout << endl;
}

比如设置第 0 位和第 3 位后,打印结果是:

text 复制代码
00001001

第 0 位在最右边,这个习惯和整数二进制展示方式一致

模拟实现完整代码

下面给一份完整代码,这个版本保留前面设计的接口,又补完整了成员函数实现

cpp 复制代码
#include <iostream>
#include <vector>
#include <cassert>
using namespace std;

namespace cl
{
    template<size_t N>
    class bitset
    {
    public:
        bitset()
        {
            // N 个比特位按 32 位一个整型来存
            _bits.resize((N + 31) / 32, 0);
        }

        void set(size_t pos)
        {
            assert(pos < N);

            // i 表示落在哪个整型里,j 表示这个整型中的第几位
            size_t i = pos / 32;
            size_t j = pos % 32;

            _bits[i] |= (1u << j);
        }

        void reset(size_t pos)
        {
            assert(pos < N);

            size_t i = pos / 32;
            size_t j = pos % 32;

            // 对目标位取反掩码,再按位与清零
            _bits[i] &= ~(1u << j);
        }

        void flip(size_t pos)
        {
            assert(pos < N);

            size_t i = pos / 32;
            size_t j = pos % 32;

            // 异或 1 可以把这一位翻转
            _bits[i] ^= (1u << j);
        }

        bool test(size_t pos) const
        {
            assert(pos < N);

            size_t i = pos / 32;
            size_t j = pos % 32;

            // 只要目标位与出来不是 0,就说明这一位是 1
            return (_bits[i] & (1u << j)) != 0;
        }

        size_t size() const
        {
            return N;
        }

        size_t count() const
        {
            size_t ret = 0;

            // 这里直接按有效位逐个统计,逻辑最直观
            for (size_t i = 0; i < N; ++i)
            {
                if (test(i))
                {
                    ++ret;
                }
            }

            return ret;
        }

        bool any() const
        {
            for (auto value : _bits)
            {
                // 任意一个整型块非 0,就说明至少有一位被设置
                if (value != 0)
                {
                    return true;
                }
            }

            return false;
        }

        bool none() const
        {
            return !any();
        }

        bool all() const
        {
            // 只比较有效位,避免最后一个整型里的无效位干扰判断
            return count() == N;
        }

        void Print() const
        {
            // 从高位往低位打印,输出风格和标准库 bitset 一致
            for (size_t i = N; i > 0; --i)
            {
                cout << test(i - 1);
            }

            cout << endl;
        }

    private:
        vector<unsigned int> _bits;
    };
}

测试模拟实现

写一个简单测试,看看 set、reset、flip、test、count、any、none、all 是否正常

cpp 复制代码
int main()
{
    cl::bitset<16> bs;

    // 初始时所有位都是 0
    bs.Print(); // 0000000000000000

    // 把几个位置成 1,观察打印结果是否符合预期
    bs.set(0);
    bs.set(3);
    bs.set(7);
    bs.Print(); // 0000000010001001

    // 读接口测试
    cout << bs.test(3) << endl; // 1
    cout << bs.test(4) << endl; // 0
    cout << bs.count() << endl; // 3
    cout << bs.size() << endl;  // 16

    // 整体状态测试
    cout << bs.any() << endl;  // 1
    cout << bs.none() << endl; // 0
    cout << bs.all() << endl;  // 0

    // 翻转和清零测试
    bs.flip(3);
    bs.Print(); // 0000000010000001

    bs.reset(7);
    bs.Print(); // 0000000000000001

    return 0;
}

如果要支持 set 全部位、reset 全部位、flip 全部位,也可以继续加重载函数

cpp 复制代码
void set()
{
    for (size_t i = 0; i < N; ++i)
    {
        set(i);
    }
}

void reset()
{
    for (auto& value : _bits)
    {
        value = 0;
    }
}

void flip()
{
    for (size_t i = 0; i < N; ++i)
    {
        flip(i);
    }
}

这里 set 和 flip 选择按有效位逐个处理,因为最后一个整数里可能有无效位,如果直接把每个 unsigned int 都设置成全 1,会把那些无效位也设置掉,虽然 Print、count、all 都只看有效位时问题不大,但学习实现时最好把有效位和无效位分清楚

再看 40 亿整数问题

现在回到开头的问题,如果要判断某个无符号整数 x 是否出现过,可以准备一个足够大的位图,读取到数字 x 时,把第 x 位设置成 1,查询 x 是否存在时,检查第 x 位是不是 1

如果用标准库 bitset,理论上可以这样表达:

cpp 复制代码
bitset<4294967296> bs;

但这个写法在实际编译环境里不一定合适,原因是 bitset 的大小是编译期常量,这么大的对象也不适合随便放在栈上

真实工程里更常用动态位图,也就是用 vector 或 vector 这种方式按需开空间,标准库还有 vector 这种特殊化容器,它也会做位压缩,但 vector 的接口和普通 vector 不完全一样,用的时候要知道它不是普通 bool 数组

位图的优缺点

位图的优点很明显

  • 空间压缩效果非常好
  • 判断存在与否很快
  • 对固定整数范围非常友好
  • 可以用位运算快速做集合交并

缺点也要记住

  • 不适合直接处理范围特别离散的数据
  • 只能表示存在或不存在,不能表示出现次数
  • 如果最大值很大但实际数据很少,空间可能会浪费
  • 对非整数 key 通常需要哈希转换,可能引入冲突

所以位图不是万能结构,它适合的是那种范围明确、状态简单、数据量很大的问题

总结

bitset 可以看成 C++ 标准库提供的固定大小位图,它用每一个比特位表示一个状态,所以特别适合保存大量布尔状态

位图最大的价值是省空间,40 亿个无符号整数如果直接存,大概需要 16GB,如果只记录出现状态,用 2^32 个比特位就够,大概是 512MB

使用 bitset 时要记住几个点

  • bitset 的大小在编译期确定
  • 下标 0 表示最低位
  • set、reset、flip 分别用于置位、清位、反转
  • test 用来检查某一位状态
  • count、any、none、all 用来做整体判断
  • &、|、^、~ 可以做位集合运算

模拟实现时,核心就是三步

  • 用整数数组保存所有比特位
  • 通过 pos / 32 找到整数下标
  • 通过 pos % 32 找到整数中的比特位

把这套映射关系想清楚以后,set、reset、flip、test 这些接口就很好写了

相关推荐
我还记得那天1 小时前
C语言随机数生成机制与猜数字游戏实现
c语言·开发语言·游戏
伊灵eLing1 小时前
GoLang 语言基础
开发语言·后端·golang
两年半的个人练习生^_^1 小时前
JMM 进阶:彻底理解 synchronized 实现原理
java·开发语言
小白不白1112 小时前
Invoke的用法
开发语言·人工智能·数码相机·计算机视觉·c#
techdashen2 小时前
What is maintenance, anyway?
开发语言·后端·rust
万法若空2 小时前
C/C++基本类型表示范围
c语言·开发语言·c++
yijianace2 小时前
Python爬虫实战:BooksToScrape 多线程爬取与图片下载
开发语言·爬虫·python
凡人叶枫2 小时前
Effective C++ 条款15:在资源管理类中提供对原始资源的访问
linux·开发语言·c++·stm32·单片机
swordbob2 小时前
Spring Boot 2.0 改 CGLIB 后,接口实现是否有影响
java·开发语言·spring