更新
自从我发布这篇文章以来已经有很多年了,但是:
我已经知道这比在
整数,但速度一样快吗?
如果您使用bitset 的方式确实比位摆弄更清晰、更干净,例如一次检查一位而不是使用位掩码,那么您将不可避免地失去按位计算的所有好处操作提供,例如能够检查是否一次针对掩码设置了 64 位,或者使用 FFS 指令快速确定在 64 位中设置了哪一位。
我不确定bitset 是否会以所有可能的方式使用(例如:按位使用operator&),但如果你使用它就像一个固定大小的布尔值数组这几乎是我经常看到人们使用它的方式,那么您通常会失去上述所有这些好处。不幸的是,我们无法获得使用operator[] 一次访问一个位的那种水平的表达能力,并让优化器找出所有按位操作以及 FFS 和 FFZ 等为我们进行的操作,至少自上次以来没有我检查的时间(否则bitset 将是我最喜欢的结构之一)。
现在,如果您打算将bitset<N> bits 与类似uint64_t bits[N/64] 互换使用,就像使用按位运算以相同方式访问两者一样,它可能是相当的(自从这篇古老的帖子以来就没有检查过)。但是,您首先会失去使用bitset 的许多好处。
for_each方法
我想,在过去我提出了一个for_each 方法来迭代vector<bool>、deque 和bitset 之类的东西时,我遇到了一些误解。这种方法的要点是在调用函子时利用容器的内部知识更有效地迭代元素,就像一些关联容器提供自己的 find 方法而不是使用 std::find 来做得更好而不是线性时间搜索。
例如,如果您对这些容器有内部知识,则可以通过在 64 个连续索引被占用时使用 64 位掩码一次检查 64 个元素来遍历 vector<bool> 或 bitset 的所有设置位,如果不是这种情况,同样使用 FFS 指令。
但迭代器设计必须在operator++ 中执行这种类型的标量逻辑,这将不可避免地不得不做一些更昂贵的事情,这只是在这些特殊情况下设计迭代器的性质。 bitset 完全缺乏迭代器,这通常使人们想要使用它来避免处理按位逻辑,以使用 operator[] 在顺序循环中单独检查每个位,只想找出设置了哪些位。这也远没有for_each 方法实现的效率那么高。
双/嵌套迭代器
上面提出的for_each 容器特定方法的另一种替代方法是使用双/嵌套迭代器:即指向不同类型迭代器的子范围的外部迭代器。客户端代码示例:
for (auto outer_it = bitset.nbegin(); outer_it != bitset.nend(); ++outer_it)
{
for (auto inner_it = outer_it->first; inner_it != outer_it->last; ++inner_it)
// do something with *inner_it (bit index)
}
虽然不符合标准容器中现在可用的平面类型迭代器设计,但这可以进行一些非常有趣的优化。举个例子,想象一下这样的情况:
bitset<64> bits = 0x1fbf; // 0b1111110111111;
在这种情况下,外部迭代器只需进行几次按位迭代 ((FFZ/or/complement),即可推断出要处理的第一个位范围是位 [0, 6),此时我们可以通过内部/嵌套迭代器非常便宜地迭代该子范围(它只会增加一个整数,使++inner_it 仅相当于++int)。然后,当我们增加外部迭代器时,它可以非常快速地再次使用一些按位指令确定下一个范围将是 [7, 13)。在我们遍历那个子范围之后,我们就完成了。再举一个例子:
bitset<16> bits = 0xffff;
在这种情况下,第一个和最后一个子范围将是 [0, 16),并且 bitset 可以通过单个按位指令确定,此时我们可以遍历所有设置位,然后我们就完成了。
这种类型的嵌套迭代器设计可以很好地映射到 vector<bool>、deque 和 bitset 以及人们可能创建的其他数据结构,例如展开列表。
我这么说的方式不仅仅只是简单的推测,因为我有一组类似于 deque 的数据结构,它们实际上与 vector 的顺序迭代相当(随机的仍然明显慢-access,特别是如果我们只是存储一堆原语并进行琐碎的处理)。然而,为了在顺序迭代中达到与vector 相当的时间,我不得不使用这些类型的技术(for_each 方法和双/嵌套迭代器)来减少每次迭代中进行的处理和分支的数量。否则我无法与时代抗衡,否则仅使用平面迭代器设计和/或operator[]。而且我当然并不比标准库的实现者聪明,但我想出了一个类似deque 的容器,它可以更快地顺序迭代,这强烈地向我表明这是迭代器的标准接口设计的问题在优化器无法优化的这些特殊情况下会带来一些开销。
旧答案
我是那些会给你类似性能答案的人之一,但我会尽量给你一些比"just because"更深入的东西。这是我通过实际的分析和时间来发现的,而不仅仅是不信任和偏执。
bitset 和vector<bool> 的最大问题之一是,如果您想像使用布尔数组一样使用它们,它们的界面设计“太方便”了。优化器非常擅长消除您建立的所有结构,以提供安全性、降低维护成本、减少更改的侵入性等。他们在选择指令和分配最少数量的寄存器方面做得特别好,以使此类代码运行速度与不太安全、不太容易维护/更改的替代方案。
以效率为代价使 bitset 接口“过于方便”的部分是随机访问 operator[] 以及 vector<bool> 的迭代器设计。当您在索引n 访问其中一个时,代码必须首先确定第 n 位属于哪个字节,然后是该位的子索引。第一阶段通常涉及对左值的除法/右移以及模/位运算,这比您尝试执行的实际位运算成本更高。
vector<bool> 的迭代器设计面临着类似的尴尬困境,它要么必须每迭代 8 次以上就分支到不同的代码,要么支付上述那种索引成本。如果前者完成,它会使迭代之间的逻辑不对称,并且迭代器设计往往会在那些罕见的情况下受到性能影响。举例来说,如果 vector 有自己的 for_each 方法,则可以通过仅将位与 vector<bool> 的 64 位掩码掩码来一次遍历 64 个元素的范围,如果所有位被设置而不单独检查每个位。它甚至可以使用FFS 一次性计算出范围。迭代器设计往往不可避免地必须以标量方式进行或存储更多状态,每次迭代都必须进行冗余检查。
对于随机访问,优化器似乎无法优化掉这种索引开销,以确定在不需要时访问哪个字节和相对位(可能有点过于依赖运行时),并且您往往会看到显着的性能提升使用更多的手动代码处理位,并具有关于它正在处理的字节/字/双字/四字的高级知识。这有点不公平的比较,但std::bitset 的困难在于,在代码提前知道要访问哪个字节的情况下,没有办法进行公平的比较,而且通常情况下,你往往有这个信息提前。这是随机访问情况下的苹果与橙子的比较,但您通常只需要橙子。
如果接口设计涉及bitset,其中operator[] 返回一个代理,则可能不会出现这种情况,需要使用双索引访问模式。例如,在这种情况下,您可以通过使用模板参数写入 bitset[0][6] = true; bitset[0][7] = true; 来访问位 8,以指示代理的大小(例如 64 位)。一个好的优化器可能能够采用这样的设计,并通过将其转换为:bitset |= 0x60;
另一个可能有帮助的设计是如果bitsets 提供了for_each_bit 类型的方法,将位代理传递给您提供的函子。这实际上可能可以与手动方法相媲美。
std::deque 也有类似的接口问题。对于顺序访问,它的性能不应该比std::vector 慢得多。然而不幸的是,我们使用operator[] 顺序访问它,该operator[] 专为随机访问或通过迭代器而设计,并且双端队列的内部代表根本不能非常有效地映射到基于迭代器的设计。如果 deque 提供了自己的 for_each 类型的方法,那么它可能会开始更接近 std::vector's 顺序访问性能。这些是一些罕见的情况,其中 Sequence 接口设计带有一些优化器通常无法消除的效率开销。通常,优秀的优化器可以在生产构建中使便利性免于运行时成本,但不幸的是,并非在所有情况下都如此。
对不起!
也很抱歉,回想起来,除了bitset 之外,我还谈到了vector<bool> 和deque。这是因为我们有一个代码库,其中使用这三个,特别是迭代它们或使用它们进行随机访问,通常是热点。
苹果变橘子
正如旧答案中所强调的,将bitset 的直接用法与具有低级按位逻辑的原始类型进行比较是在将苹果与橙子进行比较。这不像bitset 的实现效率很低。如果您确实需要使用随机访问模式访问一堆位,由于某种原因或其他原因,一次只需要检查和设置一个位,那么它可能是理想的实现此目的。但我的观点是,我遇到的几乎所有用例都不需要这样做,当不需要时,涉及按位运算的老派方法往往效率更高。