比尔.米尔node
当我须要为某个项目绘制一些树时,我认为绘制整齐树木会有一个经典而简单的算法。我发现的更有趣得多:树布局不只是一个NP彻底问题1,但树绘图算法背后有一个漫长而有趣的历史。我将使用树绘图算法的历史来逐一介绍核心概念,使用它们来构建一个完整的O(n)算法,以绘制一颗迷人的树。python
这里有什么问题?git
图1github
给定一棵树T,咱们要作的就是绘制它,这样观众就会发现它颇有吸引力。本文中介绍的每种算法的目标都是为树的每一个节点分配一个(x,y)坐标,以便在算法运行后将其绘制到屏幕上或打印出来。算法
为了存储树绘图算法的结果,咱们将建立一个DrawTree数据结构来镜像咱们正在绘制的树; 咱们惟一假定的是每一个树节点均可以迭代其子节点。清单1中能够找到DrawTree的基本实现。数据结构
class DrawTree(object):app
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中引入的下一个可用点的阵列,可是若是将父项居中会致使树的右侧与左侧重叠,则仅使用下一个可用点。
在咱们开始查看更多代码以前,让咱们仔细看看咱们自下而上构建树的后果。若是它是一片叶子,咱们会给每一个节点下一个可用的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元树上工做的扩展可能以下所示:
该算法工做,速度快,但存在一个简单的问题。它将节点的全部子树放置在尽量远的地方。若是右边的一个节点与左边的一个节点发生冲突,那么它们之间的树将所有填充到右边,如图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