第六章 树
6.1 本章目标
- 理解树这种数据结构与用法
- 用树实现映射
- 用列表实现树
- 用类和引用实现树
- 将树实现为递归结构
- 用堆实现优先级队列
6.2 示例
举例:生物分类树、文件系统、网页。
- 树的第一个属性是层次性,即树是按层级构建的,越笼统就越靠近顶部,越具体就越靠近底部。
- 树的第二个属性是:一个节点的所有子节点都与另一个子节点无关。
- 叶子节点都是独一无二的。
6.3 术语及定义
6.3.1 树的相关术语
-
节点:
节点包括自己的名字:键key;
节点也可以带有附加信息:有效载荷payload。 -
边:
两个节点通过一条边相连,表示他们之间存在关系。
除了根节点外,其他每个节点都仅有一条入边,出边则可能有多条。 -
根节点:
-
路径:
-
子节点:
-
父节点
-
兄弟节点:同一父节点的节点
-
子树:一个父节点及其所有后代的节点和边构成一棵子树
-
叶子节点:
-
层数:节点n的层数是从根节点到n的唯一路径长度
-
高度:树的高度是其中节点层数的最大值
6.3.2 定义
这里给出两种定义:一种涉及节点和边,另一种涉及递归。
定义一:树由节点及连接节点的边构成。树有以下属性:
- 有一个根节点;
- 除了根节点外,其他每个节点都与其唯一的父节点相连;
- 从根节点到其他每个节点都有且仅有一条路径;
- 如果每个节点最多有两个子节点,我们就称这样的树为二叉树。
定义二:一棵树要么为空,要么由一个根节点和零棵或多棵子树构成,子树本身也是一棵树。每棵子树的根节点通过一条边连到父树的根节点。
6.4 实现
根据6.3节给出的定义,可以使用以下函数创建并操作二叉树。
- BinaryTree()创建一个二叉树。
- getLeftChild()返回当前节点的左子节点所对应的二叉树。
- getRightChild()返回当前节点的右子节点所对应的二叉树。
- setRootValue(val)在当前节点中存储参数val中的对象。
- getRootValue()返回当前节点存储的对象。
- insertLeft(val)新建一棵二叉树,并将其作为当前节点的左子节点。
- insertRight(val)新建一棵二叉树,并将其作为当前节点的右子节点。
实现树的关键在于选择一个好的内部技巧。两种方式:“列表之列表”、“节点与引用”。
6.4.1 列表之列表
在“列表之列表”的树中,我们将根节点的值作为列表的第一个元素;第二个元素是代表左子树的列表;第三个元素是代表右子树的列表。
myTree = ['a', # 根节点
['b', # 左子树根节点
['d', [], []],
['e', [], []]],
['c',
['f', [], []],
[]]
]
接下来定义一些便于将列表作为树使用的函数。注意,我们不是要定义二叉树类,而实创建可用于标准列表的函数。
- 列表函数 BinaryTree
def BinaryTree(r):
return [r, [], []]
- 插入左子树
def insertLeft(root, newBranch):
t = root.pop(1)
if len(t) > 1:
root.insert(1, [newBranch, t, []])
else:
root.insert(1, [newBranch, [], []])
return root
- 插入右子树
def insertRight(root, newBranch):
t = root.pop(2)
if len(t) > 1:
root.insert(2, [newBranch, [], t])
else:
root.insert(2, [newBranch, [], []])
return root
- 树的访问函数
def getRootVal(root):
return root[0]
def setRootVal(root, newval):
root[0] = newval
def getLeftChild(root):
return root[1]
def getRightChild(root):
return root[2]
6.4.2 节点与引用
首先定义一个简单的类。如下:
“节点与引用”表示法的要点是:属性leftChild和rightChild会指向BinaryTree类的其他实例。
举例来说,在向树中插入新的左子树时,我们会创建另一个BinaryTree实例,并将根节点的self.leftChild改为指向新树。
class BinaryTree:
def __init__(self, rootObj):
self.key = rootObj
self.leftChild = None # 左子节点也是树
self.rightChild = None # 右子节点也是树
下面看看基于根节点构件树所需要的函数。
- 插入左子节点:
新建一个二叉树对象,将根节点的left属性指向新对象。
def insertLeft(self, newNode):
if self.leftChild == None:
self.leftChild = newNode
else:
t = BinaryTree(newNode)
t.leftChild = self.leftChild
self.leftChild = t
- 插入右子节点:
def insertRight(self, newNode):
if self.rightChild == None:
self.rightChild = newNode
else:
t = BinaryTree(newNode)
t.rightChild = self.rightChild
self.rightChild = t
- 二叉树的访问函数
def getLeftChild(self):
return self.leftChild
def getRightChild(self):
return self.rightChild
def setRootValue(self, obj):
self.key = obj
def getRootValue(self):
return self.key
6.5 二叉树的应用
6.5.1 解析树
简单句子的解析树、数学表达式的解析树。
数学表达式的解析树,重点如下:
- 如何根据完全括号表达式构建解析树;
- 如何计算解析树中的表达式;
- 如何将解析树还原成最初的数学表达式。
构建解析树第一步是将表达式字符串拆分成标记列表。需要考虑4种标记:左括号、右括号、运算符和操作数。
左括号:新表达式的起点,应该创建一棵对应该表达式的新树。
右括号:表达式的终点。
运算符:每个运算符都有左右两个子节点。
操作数:既是叶子节点,也是运算符的子节点。
有了上述信息,便可以定义以下4条规则:
- 如果当前标记是(:就为当前节点添加一个左子节点,并下沉至该子节点;
- 如果当前标记在列表[’+’,’-’,’/’,’*’]中,就将当前节点的值设为当前标记对应的运算符;为当前节点添加一个右子节点,并下沉至该子节点;
- 如果当前标记是数字,就将当前节点的值设为这个数并返回至父节点;
- 如果当前标记是),就跳到当前节点的父节点。
代码如下:
6.5.2 树的遍历
树的遍历总共有以下三种方式:
-
前序遍历:先访问根节点,然后递归地前序遍历左子树,最后递归地遍历右子树。
前序遍历通过外部函数实现:def preorder(tree): if tree: print(tree.getRootVal()) preorder(tree.getLeftChild()) preorder(tree.getRightChild())
我们也可以将preorder实现为BinaryTree类的一个方法。
def preorder(self): print(self.key) if self.LeftChild: self.LeftChild.preorder() if self.RightChild: self.RightChild.preorder()
总结:以上两种实现方式哪一种更好呢?其实将preorder实现为外部函数可能是更好的选择。原因在于,很少会仅执行遍历操作,在大多数情况下,还要通过基本的遍历模式实现别的目标。
-
中序遍历:先递归地中序遍历左子树,然后访问根节点,最后递归地中序遍历右子树。
中序遍历函数实现:def inorder(tree): if tree: inorder(tree.getLeftChild()) print(tree.getRootVal()) inorder(tree.getRightChild())
通过中序遍历解析树,可以还原不带括号的表达式。接下来修改中序遍历算法,以得到完全括号表达式。唯一需要修改的是:在递归调用左子树前打印一个左括号,在递归调用右子树后打印一个右括号。下面是修改后的函数:
def printexp(tree): sVal = "" if tree: sVal = '(' + printexp(tree.getLeftChild()) sVal = sVal + printexp(tree.getRootVal()) sVal = sVal + printexp(tree.getRightChild() + ')') return sVal
-
后序遍历:先递归地后序遍历左子树,然后递归地后序遍历右子树,最后访问根节点。
后序遍历函数实现:def postorder(tree): if tree: postorder(tree.getLeftChild()) postorder(tree.getRightChild()) print(tree.getRootVal())
3个遍历函数的区别仅在于print语句与递归调用语句的相对位置。
之前已经见识过后序遍历的一个常见用途:遍历解析树。回顾代码,我们所做的就是先计算左子树,然后计算右子树,最后通过根节点运算符的函数调用将两个结果结合起来。假设二叉树只存储一个表达式的数据,下面重写计算函数,使之更接近于上述后序遍历函数。
重写计算二叉解析树的递归函数:def postordereval(tree): opers = {'+':operator.add, '-':operator.sub, '*':operator.mul, '/':operator.truediv} if tree: res1 = postordereval(tree.getLeftChild()) res2 = postordereval(tree.getRightChild()) if res1 and res2: return opers[tree.getRootVal()](res1, res2) else: return tree.getRootVal()
6.6 利用二叉堆实现优先级队列
队列:先进先出。队列有一个重要的变体,叫做优先级队列。
优先级队列:也是从头部移除元素,不过元素的逻辑顺序是由优先级决定的。优先级最高的元素在最前,优先级最低的元素在最后。因此,当一个元素入队时,它可能直接被移到优先级队列的头部。
实现优先级队列的经典方法是使用叫做二叉堆的数据结构。二叉堆的入队操作和出队操作均可达到O(logn)。
二叉堆画起来很像一棵树,但实现时只用一个列表作为内部表示。二叉堆有两个常见的变体:最小堆(最小的元素一直在队首)与最大堆(最大的元素一直在队首)。
6.6.1 二叉堆的操作
我们将实现以下基本的二叉堆方法。
- BinaryHeap():新建一个空的二叉堆。
- insert(k):往堆中加入一个新元素。
- findMin():返回最小的元素,元素留在堆中。
- delMin():返回最小的元素,并将该元素从堆中移除。
- isEmpty():在堆中为空时返回True,否则返回False。
- size():返回堆中元素的个数。
- buildHeap(list):根据一个列表创建堆。
6.6.2 二叉堆的实现
-
结构属性
利用树的对数性质来表示二叉堆。为了保证对数性能,必须维持树的平衡。(平衡的二叉树是指:其根节点的左右子树含有数量大致相等的节点。)
我们通过创建一棵完全二叉树来维持树的平衡。
在完全二叉树中,除了最底层,其他每一层的节点都是满的。在最底层,我们从左往右填充节点。
完全二叉树可以用一个列表来表示它,而不需要用“列表之列表”和“节点和引用”表示法。
完全二叉树的几个性质:1.由于树是完全的,因此对于在列表中处于位置p的节点来说,它的左子节点正好处于位置2p;同理,右子节点处于位置2p+1。2.若要找出树中任意点的父节点,只需用python的整除除法即可:给定列表中位置n处的节点,其父节点的位置就是n/2。 -
堆的有序性
存储堆元素依赖于堆的有序性。
堆的有序性:对于堆中任意元素x及其父元素p,p都不大于x。也就是说父节点的值小于其子节点。 -
堆操作
- init():二叉堆的构造方法。
用一个列表就能表示整个二叉堆。所以构造方法要做的就是初始化这个列表与属性currentSize(用来记录堆的大小)
def __init__(self):
# 为了后续方法可以使用整除方法
self.heapList = [0]
self.currentSize = 0
- insert():添加新的元素。
元素加入列表最简单的方法就是将元素直接添加到列表的末尾。这样能保证完全二叉树的性质。但是可能会破坏堆的有序性,那么需要添加一个方法通过比较新元素与其父元素来重新获得堆的结构性质。如果新的元素小于其父元素,就将其交换位置。
def insert(self, k):
self.heapList.append(k)
self.currentSize += 1
self.perUp(self, self.currentSize)
def perUp(self, i):
while i // 2 > 0:
if self.heapList[i] < self.heapList[i//2]:
temp = self.heapList[i//2]
self.heapList[i//2] = self.heapList[i]
self.heapList[i] = temp
i = i // 2
- delMin():返回最小的元素,并将元素从堆中删除
由于堆的有序性,最小的元素就是根节点。唯一需要注意的就是在删除最小堆元素后,需要考虑堆的有序性。
def delMin(self):
retval = self.heapList[1]
self.heapList[1] = self.heapList[-1]
self.currentSize -= 1
self.heapList.pop()
self.perDown(1)
return retval
def perDown(self, i):
while (i * 2) <= self.currentSize:
mc = self.minChild(i)
if self.heapList[i] > self.heapList[mc]:
temp = self.heapList[mc]
self.heapList[i] = self.heapList[mc]
self.heapList[mc] = temp
i = mc
def minChild(self, i):
if i*2 == self.currentSize:
return i*2
else:
if self.heapList[i*2] < self.heapList[i*2+1]:
return i*2
else:
return i*2+1
- buildHeap():根据列表构建堆
def buildHeap(self, alist):
i = len(alist) // 2 # 从一半开始,叶节点不用下沉
self.currentSize = len(alist)
self.heapList = [0] + alist[:]
while (i>0):
self.percDown(i)
i = i -1
6.7 二叉搜索树
之前已经讨论过两种映射抽象数据类型的两种实现,分别是列表二分搜索和散列表。二叉搜索树是另一种实现。
6.7.1 搜索树的操作
在实现搜索树之前,我们复习一下映射抽象数据类型结构的接口。
这个接口类似于字典。
- Map():新建一个空的映射。
- put(key, value):往映射中添加一个新的键值对。如果键存在,就用新的值替换旧值。
- get(key):返回key对应的值。如果key不存在,则返回False。
- del:通过del map[key]这样的语句从映射中删除键值对。
- len():返回映射中存储的键值对的数目。
- in:通过key in map这样的语句,在键存在时返回True,否则返回False。
6.7.2 搜索树的实现
二叉搜索树的性质-二叉搜索性:小于父节点的键都在左子树中,大于父节点的键都在右子树中。
我们将采用“节点与引用”表示法来实现二叉搜索树。
由于必须创建并处理一棵空的二叉搜索树,因此我们将使用两个类。一个类叫做BinarySearchTree,另一个叫做TreeNode。BinarySearchTree类有一个引用,指向作为二叉搜索树根节点的TreeNode类。
- BinarySearch类:
大多数情况下,这个类的方法只是检查树是否为空,如果树中有节点,请求就被发往该类的私有方法,这个方法以根节点作为参数。当树为空,或者想要删除根节点的键时,需要采取特殊措施。
class BinarySearchTree:
def __init__(self):
self.root = None
self.size = 0
def length(self):
return self.size
def __len__(self):
return self.size
def __iter__(self):
return self.root.__iter__()
- TreeNode类:
内部包括很多辅助函数,可以看到,很多辅助函数有助于根据子节点的位置(左还是右)以及自己的子节点类型来给节点归类。
class TreeNode:
def __init__(self, key, val, left=None, \
right=None, parent=None):
self.key = key
self.payload = val
self.leftChild = left
self.rightChild = right
self.parent = parent
def hasLeftChild(self):
return self.leftChild
def hasRightChild(self):
return self.rightChild
def isLeftChild(self):
return self.parent and \
self.parent.leftChild == self
def isRightChild(self):
return self.parent and \
self.parent.rightChild == self
def isRoot(self):
return not self.parent
def isLeaf(self):
return not (self.leftChild or self.rightChild)
def hasAnyChildren(self):
return self.rightChild or self.leftChild
def hasBothChildren(self):
return self.rightChild and self.rightChild
def replaceNodeData(self, key, value, lc, rc):
self.key = key
self.payload = value
self.leftChild = lc
self.rightChild = rc
if self.hasLeftChild():
self.leftChild.parent = self
if self.hasRightChild():
self.rightChild.parent = self
TreeNode类一个小结:
- TreeNode类与之前的BinaryTree类有一个很大的区别:将每个节点的父节点作为它的一个属性。
- TreeNode类的实现中,使用了python的可选参数。这样可以方便的创建一个已经有parent和child的TreeNode,直接将父节点和子节点作为参数传入即可。其他情况只通过键值对就可以创建一个独立的TreeNode。
有了BinarySearchTree和TreeNode,下面就开始介绍构建二叉搜索树的其他方法。
-
put():为二叉搜索树添加新的节点。
put是BinarySearchTree类的一个方法。首先先检查树是否有根节点,如果没有根节点,就创建一个TreeNode,并将其作为树的根节点;如果已经有根节点,就调用私有的递归辅助函数_put,并根据以下算法在树中搜索。- 从根节点开始搜索二叉树,比较新键与当前节点的键。如果新键更小,搜索左子树;如果新键更大,搜索右子树。
- 当没有可供搜索的左(右)节点时,就说明找到了新键的插入位置。
- 向树中插入一个节点,做法是创建一个TreeNode对象,并将其插入到前一步发现的位置上。
def put(self, key, val): if self.root: self._put(key, val, self.root) else: self.root = TreeNode(key, val) self.size = self.size + 1 def _put(self, key, val, currentNode): if key < currentNode.key: if currentNode.hasLeftChild(): self._put(key, val, currentNode.leftChild) else: currentNode.leftChild = TreeNode(key, val,\ parent = currentNode) else: if currentNode.hasRightChild(): self._put(key, val, currentNode.rightChild) else: currentNode.leftChild = TreeNode(key, val, \ parent = currentNode)
上述代码留个坑,不能处理重复的键,之后修改。
定义put方法之后,就可以方便的通过让__setitem__方法调用put方法来重载[]运算符。def __setitem__(self, k, v): '''可以直接通过[]= 来赋值''' self.put(k, v)
-
get():为给定的键取值。
递归地搜索二叉树,直到访问到叶子节点或者找到匹配的键。后一种情况下,返回节点中存储的值。def get(self, key): if self.root: res = self._get(key, self.root) if res: return res.payload else: return None else: return None def _get(self, key, currentNode): if not currentNode: return None elif key = currentNode.key: return currentNode elif key < currentNode.key: return _get(key, currentNode.leftChild) else: return _get(key, currentNode.rightChild) def __getitem__(self, key): '''可以直接通过[]得到payload''' return self.get(key) def __contains__(self, key): '''可以用in 检查树中是否有某个键''' if self._get(key, self.root): return True else: return False
-
del():删除一个键。
- 在树中搜索并找到要删除的节点。如果树中只有一个节点,则意味着要移除的是根节点,不过仍要确保根节点的键就是要删除的键。无论哪种情况,如果找不到要删除的键,delete方法都会抛出一个异常。
def delete(self, key): if self.size > 1: nodeToRemove = self._get(key, self.root) if nodeToRemove: self.remove(nodeToRemove) self.size = self.size-1 else: raise KeyError('Error, key not in tree') elif self.size == 1 and self.root.key == key: self.root = None self.size = self.size - 1 else: raise KeyError('Error, key not in tree') def __delitem__(self, key): self.delete(key)
- 如果找到待删除键对应的节点,就必须考虑3种情况.
-
待删除节点没有子节点。
这种情况最简单:if currentNode.isleaf(): if currentNode == currentNode.parent.leftChild: currentNode.parent.leftChild = None else: currentNode.parent.rightChild = None
-
待删除节点只有一个子节点。
这种情况需考虑:1.待删除节点是左子节点、右子节点还是根节点;2.待删除节点的子节点是左子节点还是右子节点。else: if currentNode.hasLeftChild(): if currentNode.isLeftChild(): currentNode.parent.leftChild = currentNode.leftChild currentNode.leftChild.parrent = currentNode.parerent elif currentNode.isRightChild(): currentNode.parent.rightChild = currentNode.leftChild currentNode.leftChild.parrent = currentNode.parerent else: currentNode.replaceNodeData(currentNode.leftChild.key, currentNode.leftChild.payload, currentNode.leftChild.leftChild, currentNode.leftChild.rightChild) else: if currentNode.isLeftChild(): currentNode.parent.leftChild = currentNode.rightChild currentNode.rightChild.parrent = currentNode.parerent elif currentNode.isRightChild(): currentNode.parent.rightChild = currentNode.rightChild currentNode.rightChild.parrent = currentNode.parerent else: currentNode.replaceNodeData(currentNode.rightChild.key, currentNode.rightChild.payload, currentNode.rightChild.leftChild, currentNode.rightChild.rightChild)
-
待删除节点有两个子节点。
这种情况最难处理。有两个子节点,就不能简单的用它的一个子节点取代它来解决问题。但是可以通过搜索树,找到可以替换待删除节点的节点。该节点能够保持二叉搜索树的关系——这里就引入了后继节点的概念。
后继节点:树中具有次大键的节点(我理解的是比待删除的节点稍大一点的节点)。
寻找后继节点的方法:findSuccessor()。它是TreeNode类的一个方法。在查找后继节点时,要考虑以下3种情况:- 如果节点有右子节点,那么后继节点就是右子树中最小的节点。
- 如果节点没有右子节点,并且其本身是父节点的左子节点,那么后继节点就是父节点。
- 如果节点是父节点的右子节点,并且其本身没有右子节点,那么后继节点就是除其本身外父节点的后继节点。
在试图从一棵二叉搜索树中删除节点时,上述第一个条件是唯一重要的。但是,findSuccessor方法还有其他用途。
findMin方法用来查找子树中最小的键。在任意二叉搜索树中,最小的键就是最左边的子节点。findMin方法只需沿着子树中每个节点的leftChild引用走,直到遇到一个没有左子节点的节点。
寻找后继节点代码如下:
def findSuccessor(self): succ = None if self.hasRightChild(): succ = self.rightChild.findMin() else: if self.isLeftChild(): succ = self.parent else: self.parent.rightChild = None succ = self.parent.findSuccessor() self.parent.rightChild = self return succ def findMin(self): current = self while current.hasLeftChild(): current = current.leftChild return current
后继节点的子节点必定不会多于一个(而且一定是右子节点),所以我们能够按照已经实现的两种删除方法来移除它。移除后继节点后,只需直接将它放到树中待删除节点的位置上即可。
我们通过辅助函数findSuccessor和findMin来寻找后继节点,并用spliceOut方法来移除它。spliceOut方法可以直接访问待拼接的节点,并进行正确的修改。递归调用delete会浪费时间重复搜索键的节点。
spliceOut方法代码如下:
def spliceOut(self): if self.isLeaf(): if self.isLeftChild(): self.leftChild = None else: self.rightChild = None elif self.hasAnyChildren(): if self.hasLeftChild(): if self.isLeftChild(): self.parent.leftChild = self.leftChild else: self.parent.rightChild = self.leftChild self.leftChild.parent = self.parent else: if self.isLeftChild(): self.parent.leftChild = self.rightChild else: self.parent.rightChild = self.rightChild self.rightChild.parent = self.parent
有了以上方法,待删除节点有两个节点的情况就可以实现了。以下是待删除节点有两个节点的代码。
# 待删除节点有两个节点 elif currentNode.hasBothChildren(): succ = currentNode.findSuccessor() succ.spliceOut() currentNode.key = succ.key currentNode.payload = succ.payload
-
- 在树中搜索并找到要删除的节点。如果树中只有一个节点,则意味着要移除的是根节点,不过仍要确保根节点的键就是要删除的键。无论哪种情况,如果找不到要删除的键,delete方法都会抛出一个异常。
-
iter():顺序遍历树中的键。
按顺序遍历二叉树——使用中序遍历算法。
迭代器每次调用只返回一个节点。这里我们用到创建迭代器的一个很强大的函数:yield。yield的具体讲解见:yield具体讲解二叉搜索树迭代器的代码如下所示。
因为__iter__重载了循环的for x in操作,所以它是递归的。
由于在TreeNode实例上进行递归,因此__iter__方法定义在了TreeNode类中。def __iter__(self): if self: if self.hasLeftChild(): for elem in self.leftChild: yield elem yield self.key if self.hasRightChild(): for elem in self.rightChild: yield elem