绘制可展现的树
比尔.米尔
当我需要为某个项目绘制一些树时,我认为绘制整齐树木会有一个经典而简单的算法。我发现的更有趣得多:树布局不仅是一个NP完全问题1,但树绘图算法背后有一个漫长而有趣的历史。我将使用树绘图算法的历史来逐一介绍核心概念,使用它们来构建一个完整的O(n)算法,以绘制一颗迷人的树。
这里有什么问题?
图1
给定一棵树T,我们要做的就是绘制它,这样观众就会发现它很有吸引力。本文中介绍的每种算法的目标都是为树的每个节点分配一个(x,y)坐标,以便在算法运行后将其绘制到屏幕上或打印出来。
为了存储树绘图算法的结果,我们将创建一个DrawTree数据结构来镜像我们正在绘制的树; 我们唯一假定的是每个树节点都可以迭代其子节点。清单1中可以找到DrawTree的基本实现。
class DrawTree(object):
def __init __(self,tree,depth = 0):
self.x = -1
self.y = depth
self.tree = tree
self.children = [DrawTree(t,depth + 1)for t in tree]
随着我们方法的复杂性增加,DrawTree的复杂性也会增加。现在,它只将-1指定给每个节点的x坐标,将节点的深度指定给其y坐标,并存储对当前树的根的引用。然后通过递归地为每个节点创建一个DrawTree来建立该节点的子节点列表。通过这种方式,我们构建了一个DrawTree,它会封装要绘制的树并将绘图特定的信息添加到每个节点。
随着我们在本文中贯彻执行更好的算法,我们将利用我们每个人的经验帮助我们产生有助于我们构建下一个的原则。虽然生成一个“有吸引力”的树图是品味的问题,但这些原则将有助于指导我们改进程序的输出。
一开始,有Knuth
我们将要制作的特定类型的绘图是根部位于顶部,其子位于其下的位置等等。这种类型的图表,以及由此产生的这类问题,主要归功于Donald Knuth 2,我们将从中提出我们的前两个原则:
原则1:树的边不应该相互交叉。
原则2:相同深度的所有节点应绘制在同一水平线上。这有助于明确树的结构。
图2
Knuth算法具有简单和快速的优点,但它只适用于二叉树,它可以产生一些相当畸形的图形。它是一个简单的序树的遍历,与被用作x变量,则在每个节点增加一个全局计数器。清单2中的代码演示了这种技术。
i = 0
def knuth_layout(tree, depth):
if tree.left_child:
knuth_layout(tree.left_child, depth+1)
tree.x = i
tree.y = depth
i += 1
if tree.right_child:
knuth_layout(tree.right_child, depth+1)
从图2可以看出,该算法生成的树满足原则1,但不是特别有吸引力。你也可以看到Knuth图将会非常快速地扩展,因为即使树可能显着变窄,它们也不会重用x坐标。为了避免这种浪费空间,我们将介绍第三个原则:
原则3:树木应尽可能狭窄。
简要的复习
在我们继续研究一些更高级的算法之前,停止并同意我们将在本文中使用的术语可能是一个好主意。首先,我们将在描述数据节点之间的关系时使用家族树的隐喻。一个节点可以有下面的孩子,左边或右边的兄弟姐妹和上面的父亲。
我们已经讨论过树遍历了,我们也将讨论前序遍历和后序遍历。很久以前,您可能在“数据结构”测试中看到了这三个术语,但除非您一直在玩树最近,他们可能变得有点朦胧。
遍历类型简单地决定了我们在给定节点上执行处理时需要做什么。中序遍历,如上面的Knuth算法,只适用于二叉树,并意味着我们处理左孩子,然后再处理当前节点,最后右子。后序遍历意味着我们处理当前节点,那么它的所有孩子,后序遍历简直是相反的。
最后,您可能已经在之前看到过大O符号的概念,以表示算法运行时间的大小顺序。在这篇文章中,我们将快速放松地使用它作为一个简单的工具来区分可接受的运行时间和不可接受的运行时间。如果已经在它的主回路的算法频繁遍历其中一个节点的所有孩子,我们要调用它O(n^2)
,或二次。除此之外,我们将称之为O(n)或线性。如果您想了解更多详细信息,本文结尾部分引用的论文更多地介绍了这些算法的运行时特性。
从底部起
图3
Charles Wetherell和Alfred Shannon 3于1979年,Knuth在提出树布局问题8年后,引入了一整套创新技术。首先,他们展示了如何生成满足前三项原则的最小宽度树。简单地维护每一行上的下一个可用插槽,以后序遍历树,为该插槽分配一个节点,并增加插槽计数器,如清单3所示。
nexts = [0] * maximum_depth_of_tree
def minimum_ws(tree, depth=0):
tree.x = nexts[depth]
tree.y = depth
nexts[depth] += 1
for c in tree.children:
minimum_ws(tree, c)
尽管它符合我们所有的原则,但也许你会同意产出是丑陋的。即使是像图3那样的简单例子,也很难快速确定节点之间的关系,整个结构似乎都是一起松散的。现在是我们介绍另一个有助于改善Knuth树和最小宽度树的原则的时候了:
原则4:父母应该集中在孩子身上。
图4
到目前为止,我们已经能够用非常简单的算法来绘制树,因为我们并不需要考虑本地情境; 我们依靠全局计数器来避免节点彼此重叠。为了满足父母应该以孩子为中心的原则,我们需要考虑每个节点的本地情境,因此需要一些新的策略。
Wetherell和Shannon介绍的第一个策略是从底部开始构建树,然后对树进行后序遍历,而不是像清单2那样从顶部开始,或者像清单3那样从中间开始。一旦你看到这样树,居中父母是一个简单的操作:简单地把它的孩子的x坐标分成两半。
但是,我们必须记住,在构建时要留意树的左侧。图4显示了树的右侧已经被推出到右侧以容纳左侧的情况。为了改善这种分离,Wetherell和Shannon维护了列表2中引入的下一个可用点的阵列,但是如果将父项居中会导致树的右侧与左侧重叠,则仅使用下一个可用点。
Mods和Rockers
在我们开始查看更多代码之前,让我们仔细看看我们自下而上构建树的后果。如果它是一片叶子,我们会给每个节点下一个可用的x坐标,如果它是一个分支,则将它放在子节点上方。但是,如果将分支居中会导致分支与树的另一部分发生冲突,我们需要将分支移到正确的位置以避免冲突。
当我们将分支移到右边时,我们必须移动它的所有子项,否则我们将失去我们一直努力维护的中心父节点。很容易想出一个简单的函数来将分支及其子树右移一些空间:
def move_right(branch, n):
branch.x += n
for c in branch.children:
move_right(c, n)
它有效,但提出了一个问题。如果我们使用这个函数向右移动一个子树,我们将在递归内部(放置节点)进行递归(移动树),这意味着我们将有一个低效率的算法,它可能会在时间O (N ^ 2)。
为了解决这个问题,我们会给每个节点一个额外的成员mod
。当我们到达需要用n
空格向右移动的分支时,我们将添加n
到其x
坐标和其mod
值,并且愉快地继续放置算法。因为我们正在从下往上移动,所以我们不必担心我们的树木的底部会发生冲突(我们已经表明它们不是),我们将等到稍后将它们移动到右边。
一旦第一次树遍历发生,我们运行第二次树遍历将分支移动到右侧,需要将其移到右侧。由于我们将访问每个节点一次并仅对其执行算术运算,因此我们可以肯定,这个遍历将是O(n),就像第一个一样,并且它们一起也将是O(n)。
清单5中的代码演示了父节点的居中和使用mod值来提高代码的效率。
from collections import defaultdict
class DrawTree(object):
def __init__(self, tree, depth=0):
self.x = -1
self.y = depth
self.tree = tree
self.children = [DrawTree(t, depth+1) for t in tree]
self.mod = 0
def layout(tree):
setup(tree)
addmods(tree)
return tree
def setup(tree, depth=0, nexts=None, offset=None):
if nexts is None: nexts = defaultdict(lambda: 0)
if offset is None: offset = defaultdict(lambda: 0)
for c in tree.children:
setup(c, depth+1, nexts, offset)
tree.y = depth
if not len(tree.children):
place = nexts[depth]
tree.x = place
elif len(tree.children) == 1:
place = tree.children[0].x - 1
else:
s = (tree.children[0].x + tree.children[1].x)
place = s / 2
offset[depth] = max(offset[depth], nexts[depth]-place)
if len(tree.children):
tree.x = place + offset[depth]
nexts[depth] += 2
tree.mod = offset[depth]
def addmods(tree, modsum=0):
tree.x = tree.x + modsum
modsum += tree.offset
for t in tree.children:
addmods(t, modsum)
树作为块
尽管在很多情况下它确实产生了很好的结果,但清单5可以生成一些破损的树,比如图5中的树(可悲的是,已经在时间的流逝中消失了)。解释Wetherell-Shannon算法产生的树的另一个困难在于,当放置在树中的不同点处时,相同的树结构可以被不同地绘制。为了避免这种情况,我们会从Edward Reingold和John Tilford的论文中偷取原理4:
原则5:无论树怎样都应该绘制成同一棵子树。
尽管这可能会扩大我们的图纸,但这一原则将有助于使它们传达更多信息。这也有助于简化树的自底向上遍历,因为它的一个后果是,一旦我们找出了子树的x坐标,我们只需要将它作为一个单元向左或向右移动即可。
这是清单6中实现的算法概述:
•对树进行后序遍历
•如果节点是叶子,则给它一个0的x坐标
•否则,将其右边的子树尽可能靠近左边而不发生冲突
•使用与先前的算法在O(n)时间内移动树
•将节点放在其子节点的中间位置
•执行树的第二步,将
累加的mod值添加到x坐标
这个算法很简单,但要执行它,我们需要引入一些复杂性。
轮廓
图6
树的轮廓是树的一侧的最大或最小坐标的列表。在图6中,有一棵左树和一棵右树,每个节点的x坐标重叠。如果我们沿着左边的树的左边追踪每个层的最小x坐标,我们就得到[1,1,0],我们称之为树的左边轮廓。如果我们沿着右边走,从每一层取最右边的x坐标,我们得到[1,1,2],这是树的右边轮廓。
为了找到右边树的左边轮廓,我们再次取每层的最左边节点的x坐标,给我们[1,0,1]。这一次,轮廓有一个有趣的特性,并非所有节点都以父子关系连接; 第二层的0不是第三层的1的父层。
如果我们按照清单6加入这两棵树,我们可以找到左树的右轮廓和右树的左轮廓。然后我们可以很容易地找到我们需要的最小量,将右边的树推向右边,这样它就不会与左边的树重叠。清单7给出了一个简单的方法。
from operator import lt, gt
def push_right(left, right):
wl = contour(left, lt)
wr = contour(right, gt)
return max(x-y for x,y in zip(wl, wr)) + 1
def contour(tree, comp, level=0, cont=None):
if not cont:
cont = [tree.x]
elif len(cont) < level+1:
cont.append(tree.x)
elif comp(cont[level], tree.x):
cont[level] = tree.x
for child in tree.children:
contour(child, comp, level+1, cont)
return cont
如果我们在图6的树上运行清单7中的程序push_right()
,我们将得到[1,1,2]作为左树的右轮廓,[1,0,1]作为右树的左轮廓。然后我们比较这些列表以找到它们之间的最大空间,并为填充添加一个空格。在图6的情况下,将右侧树向右推2个空格将防止它与左侧树重叠。
新建线程
使用清单7中的代码,我们找到了正确的值以表明我们构建正确的树,但为此我们必须扫描两个子树中的每个节点,以获得所需的轮廓。由于它很可能是O(n ^ 2)操作,因此Reingold和Tilford引入了一个混淆称为线程的概念,这根本不像用于并行执行的线程。
图7
线程是一种通过在轮廓上的节点之间创建链接(如果其中一个不是另一个的子节点)来减少扫描其轮廓的子树所花费的时间的方法。在图7中,虚线表示线程,而实线表示父子关系。
我们还可以利用这样一个事实,即如果一棵树比另一棵树深,我们只需要下降到更短的树。任何比这更深的东西都不会影响两棵树之间必要的分离,因为它们之间不会有任何冲突。
使用线程并且只需要遍历我们需要的深度,我们就可以得到树的轮廓,并使用清单8中的过程以线性时间设置线程。
def nextright(tree):
if tree.thread: return tree.thread
if tree.children: return tree.children[-1]
else: return None
def nextleft(tree):
if tree.thread: return tree.thread
if tree.children: return tree.children[0]
else: return None
def contour(left, right, max_offset=0, left_outer=None, right_outer=None):
if not left_outer:
left_outer = left
if not right_outer:
right_outer = right
if left.x - right.x > max_offset:
max_offset = left.x - right.x
lo = nextleft(left)
li = nextright(left)
ri = nextleft(right)
ro = nextright(right)
if li and ri:
return contour(li, ri, max_offset, lo, ro)
return max_offset
很容易看到,该过程仅访问正在扫描的子树的每个级别上的两个节点。这篇论文有一个很好的证据表明这是在线性时间内发生的; 如果你有兴趣,我建议你阅读它。
把它放在一起
清单8给出的轮廓过程整洁快速,但它不适用于我们之前讨论的mod技术,因为节点的实际x值是节点的x值加上从本身到根的路径上所有修改符的总和。为了处理这种情况,我们需要给轮廓算法增加一些复杂度。
我们需要做的第一件事是保留两个额外的变量,即左子树上的修饰符的总和和右子树上的修饰符的总和。这些和是计算轮廓上每个节点的实际位置所必需的,这样我们可以检查它是否与相反一侧的节点发生冲突。参见清单9。
def contour(left, right, max_offset=None, loffset=0, roffset=0, left_outer=None, right_outer=None):
delta = left.x + loffset - (right.x + roffset)
if not max_offset or delta > max_offset:
max_offset = delta
if not left_outer:
left_outer = left
if not right_outer:
right_outer = right
lo = nextleft(left_outer)
li = nextright(left)
ri = nextleft(right)
ro = nextright(right_outer)
if li and ri:
loffset += left.mod
roffset += right.mod
return contour(li, ri, max_offset,
loffset, roffset, lo, ro)
return (li, ri, max_offset, loffset, roffset, left_outer, right_outer)
我们需要做的另一件事是在退出时返回函数的当前状态,以便我们可以在线程节点上设置适当的偏移量。掌握这些信息后,我们准备查看使用清单8中的代码的函数,将两棵树尽可能紧密地放在一起:
def fix_subtrees(left, right):
li, ri, diff, loffset, roffset, lo, ro
= contour(left, right)
diff += 1
diff += (right.x + diff + left.x) % 2
right.mod = diff
right.x += diff
if right.children:
roffset += diff
if ri and not li:
lo.thread = ri
lo.mod = roffset - loffset
elif li and not ri:
ro.thread = li
ro.mod = loffset - roffset
return (left.x + right.x) / 2
在我们运行轮廓过程之后,我们将左右树之间的最大差异加1,以使它们不会相互冲突,如果它们之间的中点是奇数,则再添加1。这让我们保留了一个便利的测试属性 - 所有节点都具有整数x坐标,而且不会降低精度。
然后我们将右边的树移动到右边。请记住,我们都将diff添加到x坐标并将其保存到mod值的原因是mod值仅适用于当前节点下面的节点。如果右子树有多个节点,我们将diff添加到roffset中,因为右节点的所有子节点都将移动到右边。
如果树的左侧比右侧更深,反之亦然,我们需要设置一个线程。我们只需检查一侧的节点指针是否比另一侧的节点指针前进得更远,如果已经存在,则将线程从较浅的树的外部设置到较深的树的外部。
为了正确处理我们之前谈到的mod值,我们需要在线程节点上设置一个特殊的mod值。由于我们已经更新了右侧偏移值以反映右侧树的向右移动,因此我们需要在此处执行的操作是将线程节点的mod值设置为更深树的偏移量与其自身之间的差值。
现在我们已经有了代码来查找树的轮廓并尽可能地将两棵树放在一起,我们可以轻松实现上述算法。我提供其余的代码而没有评论:
def layout(tree):
return addmods(setup(dt))
def addmods(tree, mod=0):
tree.x += mod
for c in tree.children:
addmods(c, mod+tree.mod)
return tree
def setup(tree, depth=0):
if len(tree.children) == 0:
tree.x = 0
tree.y = depth
return tree
if len(tree.children) == 1:
tree.x = setup(tree.children[0], depth+1).x
return tree
left = setup(tree.children[0], depth+1)
right = setup(tree.children[1], depth+1)
tree.x = fix_subtrees(left, right)
return tree
对N叉树的扩展
现在我们终于得到了一个绘制二叉树的算法,它满足了我们的原则,在一般情况下看起来很好,并且在线性时间内运行,所以考虑如何将它扩展到具有任意数量子级的树上是很自然的。如果你跟着我走了这么远,你可能认为我们应该采用我们刚刚定义的美妙算法,并将其应用于节点的所有子节点。
先前算法在n元树上工作的扩展可能如下所示:
- 进行树的后序遍历
- 如果节点是叶子,则给它一个0的x坐标
- 否则,对于其每个孩子,尽可能将孩子尽可能靠近其左兄弟姐妹
- 将父节点放在其最左边和最右边的孩子之间
该算法工作,速度快,但存在一个简单的问题。它将节点的所有子树放置在尽可能远的地方。如果右边的一个节点与左边的一个节点发生冲突,那么它们之间的树将全部填充到右边,如图7所示。让我们采用树图的最后一个原则来解决这个问题:
原则6:父节点的子节点应均匀分布。
图8
为了对称地绘制一个n元树,并且很快,我们将需要迄今为止开发的所有技巧加上一些新的技巧。感谢Christoph Buchheim等人5最近发表的一篇论文,我们已经掌握了所有的工具,并且仍然能够以线性时间绘制我们的树。
要修改上面的算法以符合原则6,我们需要一种方法来隔离两棵相互冲突的大树之间的树。最简单的方法是,每当两棵树发生冲突时,将可用空间除以树的数量,然后移动每棵树使其与其兄弟姐妹分开。例如,在图7中,右边和左边的大树之间有一段距离n,它们之间有三棵树。如果我们简单地将中间的第一棵树n/3与左边的树分开,下一个n/3远离那棵树,等等,我们就会有一棵满足原则6的树。
到目前为止,我们已经看到了这篇文章中的一个简单的算法,但我们发现它并不合适,而这一次也不例外。如果我们必须改变每两棵相互冲突的树之间的所有树,那么我们冒着在我们的算法中引入O(n ^ 2)操作的风险。
对于这个问题的解决方法类似于我们前面介绍的移位问题的修复方法mod。每次发生冲突时,我们都不需要将中间的每个子树都移动到中间,我们将保存中间需要移动树的值,然后在放置节点的所有子节点后应用这些移位。
为了找出我们想要移动中间节点的正确值,我们需要能够找到冲突的两个节点之间的树数。当我们只有两棵树时,显然发生的任何冲突都是在左边和右边的树之间。当可能有多少树时,找出哪棵树导致冲突成为一个挑战。
为了迎接这个挑战,我们将引入一个default_ancestor变量,并将另一个成员添加到我们称之为的树形数据结构中ancestor。祖先节点或者指向它自己或者指向它所属的树的根。当我们需要找到一个节点属于哪棵树时,我们将使用祖先成员(如果已设置),但是会回落到指向的树上default_ancestor。
当我们放置节点的第一个子树时,我们只需将default_ancestor设置为指向该子树,并假定由下一个树造成的任何冲突都与第一个树相冲突。在我们放置第二个子树之后,我们区分两种情况。如果第二个子树的深度小于第一个子树的深度,我们遍历它的右边界,将祖先成员设置为等于第二棵树的根。否则,第二棵树比第一棵树大,这意味着与下一棵树的任何冲突都与第二棵树放置在一起,因此我们只需将default_ancestor设置为指向它即可。
所以,不用多说,如Buchheim提出的用于布置富有吸引力的树的O(n)算法的python实现在清单12中。
class DrawTree(object):
def __init__(self, tree, parent=None, depth=0, number=1):
self.x = -1.
self.y = depth
self.tree = tree
self.children = [DrawTree(c, self, depth+1, i+1)
for i, c
in enumerate(tree.children)]
self.parent = parent
self.thread = None
self.offset = 0
self.ancestor = self
self.change = self.shift = 0
self._lmost_sibling = None
#this is the number of the node in its group of siblings 1..n
self.number = number
def left_brother(self):
n = None
if self.parent:
for node in self.parent.children:
if node == self: return n
else: n = node
return n
def get_lmost_sibling(self):
if not self._lmost_sibling and self.parent and self !=
self.parent.children[0]:
self._lmost_sibling = self.parent.children[0]
return self._lmost_sibling
leftmost_sibling = property(get_lmost_sibling)
def buchheim(tree):
dt = firstwalk(tree)
second_walk(dt)
return dt
def firstwalk(v, distance=1.):
if len(v.children) == 0:
if v.leftmost_sibling:
v.x = v.left_brother().x + distance
else:
v.x = 0.
else:
default_ancestor = v.children[0]
for w in v.children:
firstwalk(w)
default_ancestor = apportion(w, default_ancestor,
distance)
execute_shifts(v)
midpoint = (v.children[0].x + v.children[-1].x) / 2
ell = v.children[0]
arr = v.children[-1]
w = v.left_brother()
if w:
v.x = w.x + distance
v.mod = v.x - midpoint
else:
v.x = midpoint
return v
def apportion(v, default_ancestor, distance):
w = v.left_brother()
if w is not None:
#in buchheim notation:
#i == inner; o == outer; r == right; l == left;
vir = vor = v
vil = w
vol = v.leftmost_sibling
sir = sor = v.mod
sil = vil.mod
sol = vol.mod
while vil.right() and vir.left():
vil = vil.right()
vir = vir.left()
vol = vol.left()
vor = vor.right()
vor.ancestor = v
shift = (vil.x + sil) - (vir.x + sir) + distance
if shift > 0:
a = ancestor(vil, v, default_ancestor)
move_subtree(a, v, shift)
sir = sir + shift
sor = sor + shift
sil += vil.mod
sir += vir.mod
sol += vol.mod
sor += vor.mod
if vil.right() and not vor.right():
vor.thread = vil.right()
vor.mod += sil - sor
else:
if vir.left() and not vol.left():
vol.thread = vir.left()
vol.mod += sir - sol
default_ancestor = v
return default_ancestor
def move_subtree(wl, wr, shift):
subtrees = wr.number - wl.number
wr.change -= shift / subtrees
wr.shift += shift
wl.change += shift / subtrees
wr.x += shift
wr.mod += shift
def execute_shifts(v):
shift = change = 0
for w in v.children[::-1]:
w.x += shift
w.mod += shift
change += w.change
shift += w.shift + change
def ancestor(vil, v, default_ancestor):
if vil.ancestor in v.parent.children:
return vil.ancestor
else:
return default_ancestor
def second_walk(v, m=0, depth=0):
v.x += m
v.y = depth
for w in v.children:
second_walk(w, m + v.mod, depth+1, min)
结论
我在本文中略过了一些内容,仅仅是因为我认为尝试并向呈现的最终算法呈现合乎逻辑的进展比使用纯代码重载文章更重要。如果您想了解更多详细信息,或者查看我在各种代码清单中使用的树形数据结构,可以访问http://github.com/llimllib/pymag-trees/下载每种算法的源代码,一些基本测试以及用于生成本文图形的代码。
参考
1 K. Marriott, NP-Completeness of Minimal Width Unordered Tree Layout, Journal of Graph Algorithms and Applications, vol. 8, no. 3, pp. 295-312 (2004). http://www.emis.de/journals/JGAA/accepted/2004/MarriottStuckey2004.8.3.pdf
2 D. E. Knuth, Optimum binary search trees, Acta Informatica 1 (1971)
3 C. Wetherell, A. Shannon, Tidy Drawings of Trees, IEEE Transactions on Software Engineering. Volume 5, Issue 5
4 E. M. Reingold, J. S Tilford, Tidier Drawings of Trees, IEEE Transactions on Software Engineering. Volume 7, Issue 2
5 C. Buchheim, M. J Unger, and S. Leipert. Improving Walker's algorithm to run in linear time. In Proc. Graph Drawing (GD), 2002. http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.16.8757