在完美二叉树中获取顶点的父节点
Posted
技术标签:
【中文标题】在完美二叉树中获取顶点的父节点【英文标题】:Getting parent of a vertex in a perfect binary tree 【发布时间】:2013-12-05 16:27:44 【问题描述】:我有一个perfect binary tree,它列举了订购后的方式。这种树的一个例子是
15
7 14
3 6 10 13
1 2 4 5 8 9 11 12
我知道树的大小。我正在寻找一个公式或一个简单的算法,它将一个数字作为输入(我感兴趣的顶点的 ID)并返回一个数字 - 父节点的 ID。从顶部遍历树并在O(log n)
中获取结果非常容易。有更快的解决方案吗?我对叶子最感兴趣,所以如果有针对特殊情况的解决方案,也可以带上它。
【问题讨论】:
你的树是二叉搜索树还是只是二叉树?给出的示例没有搜索属性,但是普通的二叉树在 O(log n) 中找不到元素,那么它是什么? @DAnstahr 你甚至不能在示例树上的 O(logn) 中做到这一点,因为它不是 BST @Danstahr 我认为你的数据结构是最大堆,对于最大堆来说,找到元素或其父元素是 O(N)。 请忘记里面存储的数据。它与问题无关。只看后序枚举。特定的顺序可能会为此带来一个简单的数学公式。 @Danstahr 现在我得到了你真正想要的东西,抱歉混淆了,是的,在 O(logn) 中计算的方法很简单,而且很可能必须有一种使用数学在 O(1) 中计算的方法 【参考方案1】:可以在 O(log* n) 时间和 O(1) 空间中找到父索引。
这里log* n的意思是iterated logarithm:在结果小于等于1之前必须迭代应用对数函数的次数。 p>
实际上,如果我们能够为大型查找表(存储树中每个节点的父索引)提供 O(n) 空间,那么这可能会更快 - 在 O(1) 时间内完成。
下面我将概述几种不需要任何额外空间的算法,并给出 O(log n) 最坏情况时间、O(log log n) 预期时间、O(log log n) 最坏情况时间和O(log* n) 最坏情况时间。它们基于完美二叉树的后序索引的以下属性:
-
树最左边路径上的所有索引都等于 2i-1。
最左边路径上节点的每个右子节点的索引都等于 2i-2。
最左边路径上的任何节点及其右子树都标有相同位置上具有最高有效非零位的索引:
i
。
最左边路径上任何节点的左子树包含 2i-1 个节点。 (这意味着在减去 2i-1 之后,我们将得到一个相对于其父节点以相似方式定位的节点,具有相同的深度,但更接近满足属性 #1 的“特殊”节点和#2)。
属性 #1 和 #2 给出了简单的算法来获取树的 一些 节点的父节点:对于 2i-1 形式的索引,乘以 @ 987654326@加1
;对于 2i-2 形式的索引,只需添加 1
。对于 other 节点,我们可以重复使用属性#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
)选择的非零高位的数量正好是@ 987654358@。请注意,我们不需要多次应用此二进制搜索过程,因为当数字中的位数大于 O(log log n) 时,不会发生第二次溢出。所以得到的复杂度是 O(log log n) worst case time。假设所有基本操作都在 O(1) 时间内执行。如果某些操作(人口计数,前导零计数)在 O(log log n) 时间内实现,那么我们的时间复杂度会增加到 O(log2 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,其正确性有待证明。请注意,仅当将总体计数添加到“分离”位但仅添加高位的总体计数不会导致此进位时,才执行此步骤。 “分隔”位对应于值 2i。所有位的填充数与仅高位的填充数之间的差异最多为i
。因此,第 5 步处理的值至少为 2i-i。让我们对这个值应用逐位算法。 2i-i 足够大,以至于设置了位 i-1
。清除该位并将1
添加到位0..i-2
中的值。该值至少为 2i-1-(i-1),因为我们只是减去了 2i-1 并添加了1
。或者如果我们将索引向右移动一个位置,我们又至少有 2i-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);
如果我们只需要为一个叶子节点寻找父节点,这个算法和代码可能会被简化。 (Ideone)。
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);
【讨论】:
感谢您的巨大努力。赏金是你的。 @Evgeny Kluev 你能详细说明一下while循环背后的直觉吗? @TLJ:我认为答案中已经解释了一切。至于while循环,它的主体实现了算法的第1&2步,第3&4步给出了终止循环的条件。 @EvgenyKluev 算法效果很好!谢谢你,但我还不明白。例如在一种情况下,为什么我们乘以 2 并加 1 并将它们添加到 diff 并解析为父节点。我会试着读一遍。 这是一个非常复杂的解决方案 :) 我在电子表格中布置了一个基本树...docs.google.com/spreadsheets/d/… 并查看了位模式。只需找到第一个 0 位,并设置它,然后清除它上面的位... (JS) function upTree( N ) var n; for( n = 0; n 【参考方案2】:function getParent(node, size)
var rank = size, index = size;
while (rank > 0)
var leftIndex = index - (rank + 1)/2;
var rightIndex = index - 1;
if (node == leftIndex || node == rightIndex)
return index;
index = (node < leftIndex ? leftIndex : rightIndex);
rank = (rank - 1)/2;
它从根开始,决定进入哪个分支,并重复直到找到节点。排名是同级最左边节点的索引:1, 3, 7, 15, ..., k^2 - k + 1
。
输入参数为:
node
– 您想要其父节点的节点的索引。 (基于 1)
size
– 根节点的索引; 15
在您的示例中。
示例:
>>> r = []; for (var i = 1; i <= 15; i++) r.push(parent(i,15)); r;
[3, 3, 7, 6, 6, 7, 15, 10, 10, 14, 13, 13, 14, 15, undefined]
【讨论】:
这个解决方案确实有效,但它是我在 OP 中提到的 log(n)。【参考方案3】:让我们看看你的树:
15
7 14
3 6 10 13
1 2 4 5 8 9 11 12
将标签 n 重写为 15-n。然后我们得到:
0
8 1
12 9 5 2
14 13 11 10 7 6 4 3
也可以写成
0
+8 +1
+4 +1 +4 +1
+2 +1 +2 +1 +2 +1 +2 +1
嗯,有一个适合你的模式。所以在这个标签方案中,左孩子比他们的父母多2^(i+1)
,其中i
是孩子的身高,而右孩子比他们的父母多1
。但是,我们如何才能计算出孩子的身高,以及它是左孩子还是右孩子?
不幸的是,如果不计算出节点的整个路径(这意味着对数时间),我无法找到任何方法来获取此信息。但是,您可以直接从节点的标签中推断出节点的路径(此处演示了高度为 3 的树):
假设我们有一个标签n
如果n == 0
,就是根。
否则:
如果n - 8 >= 0
,它在根的左子树中。设置n = n-8
。
如果n - 8 < 0
,它在根的右子树中。设置n = n-1
。
如果现在是n == 0
,它就是上一步中发现的任何子树的根。
否则:
如果n - 4 >= 0
,它位于上一步中发现的任何子树的左子树中。设置n = n-4
如果n - 4 < 0
,它位于上一步中发现的任何子树的右子树中。设置n = n-1
。
等等,直到您开始测试n-1 >= 0
。
您可以使用位算术和-1
s 来完成所有这些操作,并且在现实世界中它会非常快(在万亿节点树中计算它只需要比 10 节点树长约 12 倍(忽略内存问题))但它仍然是对数时间。
无论如何,一旦您知道标签的高度以及它是左子还是右子,您就可以使用我之前提到的关系轻松计算父标签。
【讨论】:
【参考方案4】:如果允许您查询某个节点的子节点的 ID,您可以做一些有用的事情。
小例1:如果x = size
,就是根。
简单案例 2:如果 x
是叶子(查询子 ID 以找出答案),请尝试 x + 1
。如果x + 1
不是叶子(另一个查询子ID),x
是x + 1
的右子。如果x + 1
是一片叶子,x
是x + 2
的左孩子。
对于内部节点:x
的孩子是x - 1
(右孩子)和x - (1 << height(x) - 1)
(左孩子,右孩子是完美二叉树,所以它有 2h- 1 个节点)。因此,使用x
和left_child(x)
之间的差异,可以确定x
的高度:height(x) =ctz(x - left_child(x))
,但它实际上是所需的子树的大小,所以无论如何你都会采用1 << height
,所以ctz
可以删除。
所以x
的父级是x + 1
(iff right_child(x + 1) == x
) 或x
的父级是x + (x - left_child(x)) * 2
(否则)。
这不如只对 ID 进行数学运算,但假设您可以在恒定时间内请求节点的子节点,这是一个恒定时间算法。
【讨论】:
关于节点的子节点:我认为这是不可能的。假设向我抛出了一个数组,而我唯一知道的是该数组代表了一个完美的后序二叉树(例如,array[15]
是 OP 中给出的示例的根)。我有一种感觉,这两个问题(得到父母和得到孩子)可能几乎是等价的。
@Danstahr 是的,它们几乎是等价的。如果您有一个节点及其父节点,您也可以计算两个子节点。如果你只有那个数组,它当然不会工作。真可惜,我以为我在这里有一个好把戏:)【参考方案5】:
import math
def answer(h,q=[]):
ans=[]
for x in q:
if(True):
curHeight=h;
num=int(math.pow(2,h)-1)
if(x==num):
ans.append(-1)
else:
numBE=int(math.pow(2,curHeight)-2)
numL=int(num-int(numBE/2)-1)
numR=int(num-1)
flag=0
while(x!=numR and x!=numL and flag<10):
flag=flag+1
if(x>numL):
num=numR
else:
num=numL
curHeight=curHeight-1
numBE=int(math.pow(2,curHeight)-2)
numL=num-(numBE/2)-1
numR=num-1
ans.append(num)
return ans
【讨论】:
【参考方案6】:很抱歉没有回答,但我认为不可能在少于O(log n)
的时间内完成,即使允许恒定时间算术和按位逻辑运算。
节点索引中的每个位都可能对从节点到根的遍历中的几乎每个左/右/停止决策产生影响,包括第一个。此外,检查树的每一级节点的索引,它们是非周期性的(不仅是 2 的幂)。这意味着(我认为)恒定数量的算术操作不足以识别级别,或者节点是左孩子还是右孩子。
不过,这是一个有趣的问题,我很想被证明是错误的。我刚刚搜索了我的 Hacker's Delight 副本——我对他们使用的一些奇特的数字基础寄予厚望,但没有什么很合适。
【讨论】:
***.com/questions/20405346/… uhmm...是的,我猜位搜索是 O(log n)...(没关系,我猜)以上是关于在完美二叉树中获取顶点的父节点的主要内容,如果未能解决你的问题,请参考以下文章