父索引可以在 O(log * n) 时间和 O(1) 空间中找到。
这里log * n表示迭代对数:在结果小于或等于 1 之前必须迭代应用对数函数的次数。
实际上,如果我们可以为大型查找表提供 O(n) 空间(为树中的每个节点存储父索引),这可能会更快 - 在 O(1) 时间内完成。
下面我将概述几种不需要任何额外空间的算法,并给出 O(log n) 最坏情况时间、O(log log n) 预期时间、O(log log n) 最坏情况时间和 O(log * n) 最坏情况时间。它们基于完美二叉树的后序索引的以下属性:
- 树的最左边路径上的所有索引都等于 2 i -1。
- 最左边路径上节点的每个右子节点的索引等于 2 i -2。
- 最左边路径上的任何节点及其右子树都标有在同一位置具有最高有效非零位的索引:
i
.
- 最左边路径上任何节点的左子树包含 2 i -1 个节点。(这意味着在减去 2 i -1 之后,我们将得到一个相对于其父节点以相似方式定位的节点,具有相同的深度,但更接近满足属性 #1 和 #2 的“特殊”节点)。
属性#1 和#2 给出了获取树的某些节点的父节点的简单算法:对于形式为 2 i -1 的索引,乘以2
和加1
;对于 2 i -2形式的索引,只需添加1
. 对于其他节点,我们可以重复使用属性#4来找到满足属性#1或#2的节点(通过减去几个左子树的大小),然后找到位于最左边路径上的某个父节点,然后将之前的所有减去的值。属性 #3 允许快速找到应该减去哪些子树的大小。所以我们有以下算法:
- 同时
((x+1) & x) != 0
重复((x+2) & (x+1)) != 0
步骤 2。
- 清除最重要的非零位并添加
1
。累积差额。
- 如果
((x+1) & x) == 0
,乘以2
并添加1
;否则,如果((x+2) & (x+1)) == 0
,添加1
。
- 将步骤 2 中累积的所有差异加回。
例如,12
(以二进制形式0b1100
)在步骤#2 中被转换为0b0101
,然后转换为0b0010
(或2
十进制)。累计差为10
。第 3 步添加1
和第 4 步添加回来 10
,所以结果是13
。
其他示例:(10
以二进制形式0b1010
)在步骤#2 中转换为0b0011
(或3
以十进制形式)。第 3 步将其加倍 ( 6
),然后添加1
( 7
)。第 4 步加回累积的差 ( 7
),所以结果是14
。
时间复杂度为 O(log n) - 但仅当所有基本操作都在 O(1) 时间内执行时。
为了提高时间复杂度,我们可以并行执行步骤 #2 的多次迭代。我们可以得到n/2
索引的高阶位并根据它们计算人口数。如果将结果添加到剩余的低位后,总和没有溢出到这些高位,我们可以递归地应用这种方法,复杂度为 O(log log n)。如果我们有溢出,我们可以回滚到原始算法(逐位)。请注意,所有设置的低位也应视为溢出。所以得到的复杂度是 O(log log n) expected time。
我们可以使用二进制搜索来处理溢出,而不是逐位回滚。我们可以确定要选择多少高位(小于n/2
),这样我们要么没有溢出,要么(对于索引0b00101111111111
)选择的非零高位的数量正好是1
。请注意,我们不需要多次应用此二进制搜索过程,因为当数字中的位数大于 O(log log n) 时,不会发生第二次溢出。所以产生的复杂度是 O(log log n)最坏情况时间。假设所有基本操作都在 O(1) 时间内执行。如果某些操作(人口计数,前导零计数)在 O(log log n) 时间内实现,那么我们的时间复杂度会增加到 O(log 2 log n)。
我们可以使用不同的策略,而不是将索引的位分成两个相等的集合:
- 计算索引的人口计数并将其添加到索引值。
0
从变为的最高有效位1
确定高位/低位位的分离点。
- 计算高位的总体计数,然后将结果添加到低位。
- 如果“分隔”位非零并且
((x+1) & x) != 0
和((x+2) & (x+1)) != 0
,则从步骤#1 继续。
- 如果设置了所有低位并且设置了最低有效的高位,则将此极端情况作为右孩子处理。
- 如果
((x+1) & x) != 0
和((x+2) & (x+1)) != 0
,也将其作为右孩子处理。
- 如果
((x+1) & x) == 0
,乘以2
并添加1
;否则,如果((x+2) & (x+1)) == 0
,添加1
。
如果满足步骤#3 的条件,这意味着步骤#2 的加法导致进位到“分隔”位。其他低位表示一些不能大于原始人口计数的数字。此数字中设置的位数不能大于原始值的总体计数的对数。这意味着每次迭代后设置的位数最多是前一次迭代的设置位数的对数。因此最坏情况的时间复杂度为 O(log * n)。这非常接近 O(1)。例如,对于 32 位数字,我们需要大约 2 次迭代或更少。
该算法的每一步都应该是显而易见的,除了可能的步骤#5,其正确性有待证明。请注意,仅当将总体计数添加到“分离”位但仅添加高位的总体计数不会导致此进位时,才执行此步骤。“分离”位对应于值 2 i。所有位的填充数与仅高位的填充数之间的差异最多为i
. 因此,第 5 步处理的值至少为 2 i -i。让我们对这个值应用逐位算法。2 i -i 足够大,以便i-1
设置该位。清除该位并添加1
到 bits 中的值0..i-2
。这个值至少是 2 i-1 -(i-1) 因为我们刚刚减去了 2i-1并添加1
. 或者如果我们将索引向右移动一个位置,我们又至少有 2 个i -i。如果我们重复这个过程,我们总是会在位置找到非零位i-1
。0..i-1
每一步逐渐减小以位为单位的值与 的最近幂之间的差异2
。当这种差异出现时2
,我们可以停止这种逐位算法,因为当前节点显然是一个右孩子。由于这种逐位算法总是得出相同的结果,我们可以跳过它并始终将当前节点作为右孩子处理。
这是该算法的 C++ 实现。更多细节和一些其他算法可以在ideone上找到。
uint32_t getMSBmask(const uint32_t x)
{ return 1 << getMSBindex(x); }
uint32_t notSimpleCase(const uint32_t x)
{ return ((x+1) & x) && ((x+2) & (x+1)); }
uint32_t parent(const uint32_t node)
{
uint32_t x = node;
uint32_t bit = x;
while ((x & bit) && notSimpleCase(x))
{
const uint32_t y = x + popcnt(x);
bit = getMSBmask(y & ~x);
const uint32_t mask = (bit << 1) - 1;
const uint32_t z = (x & mask) + popcnt(x & ~mask);
if (z == mask && (x & (bit << 1)))
return node + 1;
x = z;
}
if (notSimpleCase(x))
return node + 1;
else
return node + 1 + (((x+1) & x)? 0: x);
}
如果我们只需要为一个叶子节点寻找父节点,这个算法和代码可能会被简化。(爱迪生)。
uint32_t parent(const uint32_t node)
{
uint32_t x = node;
while (x > 2)
{
const uint32_t y = x + popcnt(x);
const uint32_t bit = getMSBmask(y & ~x);
const uint32_t mask = (bit << 1) - 1;
x = (x & mask) + popcnt(x & ~mask);
}
return node + 1 + (x == 1);
}