【发布时间】:2022-06-11 08:11:45
【问题描述】:
我听说过一类称为简洁等级数据结构的数据结构。这些数据结构有什么作用?这里的“简洁”是什么意思?它们是如何工作的?
【问题讨论】:
标签: data-structures bit rank space-complexity succinct-data-structures
我听说过一类称为简洁等级数据结构的数据结构。这些数据结构有什么作用?这里的“简洁”是什么意思?它们是如何工作的?
【问题讨论】:
标签: data-structures bit rank space-complexity succinct-data-structures
二元排序问题如下。给定一个位数组,我们将其表示为 B。位数组 B 中有 n 个位。目标是预处理 B,以便您可以有效地回答以下形式的查询:
数组的前 i 位的总和是多少?
这称为 rank 查询,我们将其表示为 rank(i)。
例如,假设给你这个位数组:
11011100101110111100
假设我们使用 0 索引,那么 rank(5) 将是数组前五位的总和。这些位是11011,所以我们有 rank(5) = 4。类似地,你可以检查 rank(10) = 6。作为边缘情况,我们有 rank(0) = 0,因为如果你加起来没有返回 0。
直观地说,这似乎是一个很容易解决的问题。我们可以通过简单地将所有前缀和写在一个单独的数组中来预处理数组。可能看起来像这样:
通过这种方式设置,我们可以在 O(1) 时间内通过简单地在该辅助数组中查找索引 i 来计算 rank(i)。
所以...我们完成了,对吧?抱歉不行。让我们花点时间想想这个数组占用了多少总空间。它是一个由 n+1 个整数组成的数组(数组中的每个位置都有一个数组元素,在数组的最后一个元素之后再加一个),所以看起来这肯定会使用 O(n) 总空间。虽然从某种意义上说这是对的,但这个数字具有误导性。
例如,假设我们在一台 64 位机器上,其中每个整数都表示为一组 64 位。上述方法的一个简单实现可能对我们数组中的每个整数使用 64 位,这意味着我们需要为这个数组使用(大约)64n 个总位。将此与写出原始位数组 B 所需的空间量进行比较。数组 B 的长度为 n 位,因此它仅使用 n 位内存。这意味着存储所有前缀和的辅助表使用的空间是原始位数组本身的 64 倍!
我们可能会认为这不是什么大问题。当然,它比原始数组大 64 倍——但这是个问题吗?不幸的是,这确实是一个问题。许多需要像这样的位数组的应用程序,例如存储大量文本字符串或巨大的树,都使用刚开始几乎不适合内存的数据集。例如,假设您正在处理一个 1GB 长的位数组。在这种情况下,存储这个辅助数组将占用 64 × 1GB = 64GB 的内存来写出 - 大量的内存!至少在我写这篇文章的 2022 年,这比你的普通台式机要多得多。 (如果你是在 2030 年代阅读这篇文章,只需将“GB”改为“TB”,你就会有与我们 2020 年相同的本能反应。^_^)
我们在本次讨论中的目标是构建一个数据结构,使我们能够快速回答排名查询,但使用尽可能少的额外内存位。这将带我们进入一些不寻常的领域。我们将通过计算我们使用的总位数来量化我们的数据结构的空间使用情况,这意味着我们不能说存储单个整数使用 O(1) 内存。我们需要挖掘出一些聪明的技术,这些技术虽然在数据结构研究人员社区中广为人知,但在一般计算机科学或软件工程中并不常见。
我们在此旅程中的第一步将是采用上述想法 - 制作一个整数数组来存储所有可能的前缀和 - 并将 64 倍的内存膨胀减少到更合理的程度。
要了解如何执行此操作,让我们假设我们正在处理一个长度正好为 1023 位的位数组。为什么是 1023 位?这是因为那正好是 210 - 1 位。现在,假设我们要为该数组中的每个位写下前缀和。因为我们的位数组只有 210 - 1 位,所以每个前缀和将是介于 0 和 210 - 1 之间的数字,包括 0 和 210 - 1。 (为什么?因为每个位都是 0 或 1,并且在所有位都为 1 的绝对最坏情况下,总和将是 210 - 1。)如果你回想一下二进制数如何表示,这意味着我们的每个前缀和都可以表示为一个 10 位数字。使用完整的 64 位整数来写出每个前缀和是很浪费的;我们只会使用这 64 位中的 10 位,而其他 54 位将始终为零!
如果我们要看看这在内存中是如何表示的,它看起来像这样:
大多数编程语言(以及芯片)不支持使用 10 位整数数组。但是由于位运算的力量,我们可以很容易地模拟这样的事情。我们将形成一个数组,其元素是 64 位整数。然后我们将完全忽略它们是 64 位整数的事实,而只是将我们的数组视为一个长的位流。要从该数组中获取特定的 10 位整数值,我们只需要执行一些按位运算符来定位哪些 64 位整数包含我们的 10 位值,然后提取相关位并重新组合它们。这不是最直接的计算,但也没有那么可怕。下面是一些 C 代码:
/* Given an array of elements that are each bit_width bits long,
* represented as an array of 64-bit integers holding the relevant
* bits, extract the element at position index. It's assumed that
* bit_width <= 64.
*
* "Beware of bugs in [this] code; I have only proved it correct, not tried it."
*/
uint64_t extract_bits_from(uint64_t* bit_array,
unsigned bit_width,
unsigned index) {
assert(0 < bit_width && bit_width <= 64);
/* Special-case the scenario where we want a 64-bit value,
* which just means we do an array read.
*/
if (bit_width == 64) return bit_array[index];
/* Otherwise, we have 63 or fewer bits. */
/* Logical index of the first bit that we want to extract. */
unsigned first_bit = bit_width * index;
/* Physical index into the array of 64-bit integers where we
* need to look.
*/
unsigned int_index = first_bit / 64;
uint64_t first_int = bit_array[int_index];
/* Determine how many bits we're going to pull from this
* number. To do this, we'll look at the offset of the bit
* position we start at and see where that falls in our 64-
* bit value.
*/
unsigned bit_start = first_bit % 64;
/* There are two options. The first is that all the bits we
* need are in this integer. In that case, extract them and
* go home.
*/
if (bit_start + bit_width <= 64) {
/* Shift everything down by the appropriate amount,
* then mask off the higher bits.
*/
return (first_int >> bit_start) & ((1ULL << bit_width) - 1);
}
/* Otherwise, pull the lower bits from this integer and the
* higher bits from the next integer. First, we have to see
* how many bits to read.
*/
unsigned low_bit_count = 64 - bit_start;
uint64_t low_bits = (first_int >> bit_start) & ((1ULL << low_bit_count) - 1);
unsigned high_bit_count = bit_start - 64;
uint64_t next_int = bit_array[int_index + 1];
uint64_t high_bits = next_int & ((1ULL << high_bit_count) - 1);
return low_bits | (high_bits << low_bit_count);
}
在我们使用 210 - 1 位数组的特定情况下,这种方法将需要一个 10n 位的辅助数组。这远比我们开始时的 64n 位低,尽管它仍然是原始数组大小的巨大爆炸。
在继续之前,让我们花一点时间概括一下这个想法。如果我们的数组有 210 - 1 位,我们需要每个数字 10 位。类似的推理告诉我们,如果我们的数组有 215 - 1 位,则每个数字需要 15 位。向后运行此过程,您可以计算出,对于 n 位数组,我们需要 log2 (n+1) 位来写出每个前缀和。这意味着,在一般情况下,这种方法将允许我们在 O(1) 时间内回答排名查询,并且将使用 O(n log n) 总位数。
然后我们可以问:有没有办法减少我们的空间使用?幸运的是,答案是肯定的。但要实现这一目标,我们需要做出一系列巧妙的见解。
为什么我们现在的空间使用量是 O(n log n)?那是因为
为了减少我们的空间使用,我们要么需要写下更少的总数,要么写下每个数字的更少位,或者理想情况下两者兼而有之。现在可能还不清楚如何做这两个,但事实证明,一旦我们以正确的方式看待事物,这些想法中的每一个都会变得非常自然。
我们的第一步将是使用第一个想法并写下更少的数字。这是思考如何做到这一点的好方法。现在,我们在每个数字之前写下前缀总和,如下所示:
这使得回答排名查询变得容易:我们只需查看前缀和数组并读出答案。
但是,这里有一个可爱的小观察。假设我们不是在每个位之前存储前缀和,而是在每个 other 位之前存储前缀和。看起来像这样:
这似乎是一个奇怪的想法 - 我们刚刚丢弃了一半的前缀和! - 但它仍然让我们有效地查询前缀和(在时间 O(1) 中)。就是这样。如果您想在 偶数 编号的位置计算前缀和,只需读取该数组槽内的预计算值。例如,要计算 rank(6),我们会在索引 6 / 2 = 3 处查找数组条目,从零开始。那是5,这确实是正确的答案。
另一方面,如果您想在 奇数 编号的位置计算前缀和,我们将无法从一开始就读取预先计算的值。但是,我们的奇数条目紧挨着偶数条目。我们可以通过读取我们之前的偶数条目来计算前缀和,然后将出现在该位置之后的位的值相加。例如,要计算 rank(5),我们首先通过查看预先计算的数组来计算 rank(4) = 3。然后,我们将查看位数组中索引 5(零索引)处的位。它是 1,所以我们的排名查询的答案是 3 + 1 = 4。Tada!
总体而言,这种方法将我们使用的额外内存量减半(我们已经丢弃了一半的数字),并且我们的查询速度几乎和以前一样快。以前我们只需要查找单个数组条目,现在我们必须查找一个数组条目,然后查看原始位数组中的一位。
我们刚刚将内存使用量减少了一半 - 与以前相比有了巨大的改进!我们能把这个想法更进一步吗?答案是肯定的。我们将首先选择一个整数 b,我们将其称为 块大小。然后,我们将我们的位数组分成 blocks,每个有 b 位。然后,我们只会在每个块的开头写下前缀和,有效地只存储我们开始的原始前缀和数量的 1/b 分数。例如,在 b=8 的示例位向量上,这可能看起来像这样:
要了解如何计算排名查询,让我们通过 rank(30),前 30 位的总和。这里的想法是对我们之前所做的事情的概括:我们将找到最后一个数组条目,其中我们计算了一个前缀和,然后从数组本身中添加缺失的位。以下是我们的做法。
这种方法有多快?好吧,步骤 (1) 总是需要时间 O(1)。这只是一个数组查找。然而,步骤(2)取决于我们制作的块有多大。如果 b 很小,我们就不必扫描太多位。如果 b 很大,我们将不得不扫描大量的位。总的来说,我们所做的总工作最终是 O(b)。
但是我们的空间使用情况如何?好吧,如果我们要存储前缀和的完整数组,我们将使用 O(n log n) 空间:将有(大约)n 个数字,每个数字使用(大约)log2 n 位。但是现在我们只在每个块的开头存储前缀和,我们只存储这些前缀和的大约 1/b。这意味着我们现在存储了大约 n / b 个前缀和,每个前缀和仍然需要大约 log2 n 位。这使得我们的空间使用量为 O((n log n) / b)。
所以我们现在遇到了一个有趣的情况:
这里没有最佳选择 b 来平衡这些力。如果您绝对必须让排名查询快速运行,则您必须选择 b 的一小部分并支付一些额外的内存成本。另一方面,如果您可以接受较慢的查询,则可以将 b 调高,以将空间使用量降至可管理的范围内。
这就引出了一个问题:有没有一种方法可以两全其美?也就是说,我们能否在获得快速查询的同时降低空间使用率?令人惊讶的是,答案是肯定的!让我们看看我们是如何到达那里的。
之前,我们提到有两种不同的方法可以尝试减少 O(n log n) 存储空间。第一个是存储更少的数字。我们通过只写下前缀总和来做到这一点。第二个是每个数字写下更少的位。这似乎是不可能的,但当你以正确的方式看待它时,它实际上并不算太糟糕。让我们探索第二种选择。
作为对我们现在所处位置的复习,我们将数组分解为每个 b 位的块,对于某些参数 b,我们可以随意选择。然后,我们在每个块的开头写下了前缀和。事情看起来像这样:
目前,执行排名查询的成本是 O(b)。这是因为作为排名查询的一部分,我们必须扫描块的位,将它们相加,而我们这样做的方式是对这些位进行线性扫描。有没有办法加快速度?
确实存在,这是我们获得重要见解的地方。如果您考虑一下,扫描一个块的位并将它们相加与对较小的位数组执行排名查询基本相同。也就是说,我们首先尝试回答“整个数组的前 i 位 的总和是多少?”这个问题,然后我们把它变成了“总和是多少我们最终进入的块的前 i % b 位?”换句话说,我们只剩下一个较小版本的我们开始处理的相同问题!
我们可以用这些信息做什么?我们执行排名查询的第一个策略是写下每个位的前缀和。这将使我们能够非常快速地计算前缀和,而无需从原始数字中读取大量位。让我们在这里尝试重复这个想法。如果在每个块中,我们在每个位之前写下前缀和,会发生什么?可能看起来像这样:
在这里,我仅在其中一个块中显示了前缀和,但我们会在所有块中运行类似的前缀和。我只是找不到一种方法将所有这些都放入一张图片中。 :-)
这是查询现在的样子。假设我们要计算 rank(20),即前 20 位的总和。以下是我们的做法。
请注意,这整个过程是由表查找驱动的 - 不需要线性扫描!事实上,无论我们为块大小 b 做出什么选择,我们最终都会为每个查询做 O(1) 的工作:进行必要的划分、模块和表读取的成本。太棒了!
这种方法需要多少空间?这有两个组成部分。我们使用辅助存储空间的第一个地方是前缀和的顶级数组。正如我们之前看到的,如果我们每个有 b 位的块,则使用 O((n log n) / b) 位。
但是现在我们必须考虑在每个块中写下前缀和所需的空间。我们需要多少空间?我们在这里需要的主要见解是块中的前缀和使用的位数少于整个数组中的前缀和。在 n 位数组中,每个前缀和需要 O(log n) 位,因为前缀和可以是从 0 到 n 的任何值,包括 0 到 n。但是在 b 位的块中,其中 b 可能远小于 n,我们只需要使用 O(log b) 位作为前缀和,因为在 a块总和的范围可以从 0 到 b,包括 0 到 b。这将成为我们设计这些数据结构的一个主要想法:如果您有一个较小的数组,则您的前缀和需要的位数也更少!
要计算出整个数据结构中所有这些前缀和需要多少总空间,我们可以使用以下计算。在每个 b 位块中,我们将写下 b 个前缀和,每个前缀和的长度为 O(log b) 位,因此我们需要 O(b log b) 总位。由于大约有 n / b 个块(n 位被分组为每个 b 位的块),每个块内的相对索引的总空间使用量为 O(n log b)。。 p>
总的来说,这意味着我们用于这种两级方法的内存总量为 O((n log n) / b + n log b) 位。这是一个奇怪的表达,所以让我们花一点时间来解开它。
将 b 设置得太小或太大都不利于我们的整体空间使用这一事实表明,有一个最佳的 b 选择可以平衡这些项。确实有!如果我们选择 b = O(log n)(也就是说,选择 b 为 log n 的某个倍数),那么我们的整体空间使用量就会最小化。特别是,我们的空间使用情况如下:
O((n log n) / b + n log b)
= O((n log n) / log n + n log log n)
= O(n + n log log n)
= O(n log log n)
瞧!我们已将空间使用量降至 O(n log log n) 位。请记住,对于任何合理的 n 值,log log n 的数量都是 tiny。例如,假设您有一个 n = 264 位的数组。然后 log n = 64(假设我们使用以 2 为底的对数,我们就是这样)和 log log n = 6。这是对我们原来 O(n log n) 位存储空间的巨大改进!
然而,虽然 log log n 是一个很小的数字,因为我们的解决方案使用 O(n log log n) 位内存,它仍然需要比我们开始使用的原始位数组更多的内存。有没有办法进一步降低空间使用量?
当然,答案是肯定的。这样做会涉及到一些令人惊讶的数学。
(这个答案在后面的答案中继续。)
【讨论】: