
本文共 13167 字,大约阅读时间需要 43 分钟。
目录
一、基本概念
1.1 树的特征:
我们学过像栈和队列这样的线性数据结构,对递归也有一定的了解,我们说有线性数据结构,那就有非线性数据结构,因此先让我们来看看最简单和常见的非线性数据结构——树(Tree)。树在计算机科学的各个领域中被广泛应用,包括操作系统,图形学,数据库系统和计算机网络。树结构和自然界的树有许多相似的地方,也有根、枝和叶,它们的不同之处在于计算机中的树结构根在顶部而叶子则在底部。
那么树有什么特征呢?
-
第一个特征是:树是分层的,这里分层的意思是树的顶层部分更加宽泛,而底层部分更加精细具体。
下面是一个例子,最上层是“界”,它下面的一层(上层的子层)是“门”,然后是“纲”等等。但是,无论我们细分到多少层,这里面包含的生命体仍是动物。 -
第二个特征是:一个节点(node)的所有子节点(children)和另一个节点的子节点是完全独立的。
比如“猫属”有两个子节点“家生”和“野生”,“蝇属”中也有一个“家生”, 但它和“猫属”中的“家生”是完全不同而且相互独立的。这意味着我们可以在不影响“猫属” 的子节点的情况下更改“蝇属”的子节点。 -
第三个特征是:它的每个叶节点(leaf)都是不同的。
对每一种动物,我们都可以从根节点(root)开始沿着一条特定的路径找到它对应的叶节点,并把它和其他动物区分开, 例如对于家猫,我们可以沿着 动物界 → 脊索动物门 → 哺乳动物纲 → 食肉动物目 → 猫科 → 猫属 → 家猫 找到它。
1.2 树结构的相关术语和定义:




树的定义一:
![]()
树的定义二:(递归定义)
![]()
二、树的实现
2.1 方法一:嵌套列表法


"""预备知识:你会发现,我们在插入时,传入的列表并不需要返回,因为列表list和字典dict是可变的类型, 就和c++中的地址传递或者引用是一个道理,也就是说函数里面的改变,会把实参也 改变了"""def BinaryTree(r): """ 作用:创建二叉树 :param r: :return: """ return [r, [], []]def insertLeft(root, newBranch): """ 插入左子树 :param root: :param newBranch: :return: """ # 将左子树取出来 t = root.pop(1) # 如果左子树不为空,则把新插入的newBranch作为这个树的根节点,原来左子树的根节点变为它的左子树 if len(t) > 1: root.insert(1, [newBranch, t, []]) # 如果左子树为空,则把新插入的newBranch作为这个树的根节点即可 else: root.insert(1, [newBranch, [], []]) # return root # 不用返回,因为列表和字典是可变数据类型def insertRight(root, newBranch): """ 插入右子树 :param root: :param newBranch: :return: """ # 将右子树取出来 t = root.pop(2) # 如果右子树不为空,则新节点作为右子树的根,原来的右子树的根节点变为新节点的右子树 if len(t) > 1: root.insert(2, [newBranch, [], t]) # 如果右子树为空,则插入右子树作为根节点 else: root.insert(2, [newBranch, [], []]) # return rootdef getRootVal(root): """ 取根节点的值 :param root: :return: """ return root[0]def setRootVal(root, newVal): """ 设置根节点的值 :param root: :param newVal: :return: """ root[0] = newValdef getLeftChild(root): """ 取左子树的值 :param root: :return: """ return root[1]def getRightChild(root): """ 取右子树的值 :param root: :return: """ return root[2]# 下面是测试代码if __name__ == "__main__": r = BinaryTree(3) # 创建一个根节点的数据项为3的二叉树 insertLeft(r, 4) # 插入左子树 insertRight(r, 5) # 插入右子树 insertRight(r, 6) # 插入右子树 l = getLeftChild(r) # 取左子树 print(r) print(l) setRootVal(l, 9) # 设置左子树根的值 print(r) insertLeft(l, 7) print(r)
2.2 方法二:节点链接法
我们第二种实现树的方式使用节点和引用。在这种情况下,我们将定义具有根值,以及左子树和右子树属性的类。由于这种实现方式与面向对象的编程方式联系更紧密,因此继续使用这种实现方式介绍本章的其余部分。
使用节点和引用,我们认为该树的结构可能会类似于下图所示:
下面是实现的代码:
重要的是要记住:这种方式通过是left和right属性引用其他BinaryTree类实现的。例如,当我们插入一个新的左子节点到树中时,我们创建了另一个BinaryTree的实例,并修改了根节点的self.leftChild使之指向新的树。
class BinaryTree: def __init__(self, rootObj): self.key = rootObj # 当前节点的值 self.leftChild = None # 左子树 self.rightChild = None # 右子树 def insertLeft(self, newNode): """ 作用:插入左子树 :param newNode: :return: """ # 如果左子树为空,则直接插入 if self.leftChild == None: self.leftChild = BinaryTree(newNode) # 左子树不为空,让插入的节点为当前左子树的根节点 else: temp = BinaryTree(newNode) # 定义新节点 temp.leftChild = self.leftChild # 当前左子树成为新节点的左子树 self.leftChild = temp # 新节点成为左子树 def insertRight(self, newNode): """ 作用:插入右子树 :param newNode: :return: """ # 如果右子树为空,则直接插入 if self.rightChild == None: self.rightChild = BinaryTree(self.rightChild) # 右子树不为空,让插入的节点为当前右子树的根节点 else: temp = BinaryTree(newNode) # 定义新节点 temp.rightChild = self.rightChild # 当前右子树成为新节点的右子树 self.rightChild = temp # 新节点成为右子树 def getRootVal(self): """ 作用:取节点的值 :return: """ return self.key def setRootVal(self, value): """ 作用:设置节点的值 :param value: :return: """ self.key = value def getLeftChild(self): """ 作用:取左子树 :return: """ return self.leftChild def getRightChild(self): """ 作用:取右子树 :return: """ return self.rightChild# 以下是测试代码if __name__ == "__main__": r = BinaryTree('a') print(r.getRootVal()) print(r.getLeftChild()) r.insertLeft('b') print(r.getLeftChild()) print(r.getLeftChild().getRootVal()) r.insertRight('c') print(r.getRightChild()) print(r.getRightChild().getRootVal()) r.getRightChild().setRootVal('hello') print(r.getRightChild().getRootVal())
下面再解释一下上述代码:
(1)构造函数需要得到一些类型的对象存储在根中。就像你可以在列表中储存。任何一种你喜欢的类型,树的根对象可以指向任何一种类型。 (2)为了添加左子节点,我们将创建一个新的二叉树对象,并设置根的left属性指向这个新对象。我们必须考虑两种情况进行插入。第一种情况的特征是,没有现有左子节点。当没有左子节点时,简单地将新节点添加到树中即可。第二种情况的特征是,当前存在左子节点,在第二种情况下,我们插入一个节点并将已存在的子节点降级。 (3)添加右子节点和左子节点同理,不再赘述。
最后的测试代码是检查它的结构。让我们生成一个简单的树(如下图所示),以节点a为根节点,并添加节点b和c作为子节点。注意,根节点的左右子节点本身就是BinaryTree类的不同实例。 正如我们在树的原始递归定义中所说的,这使我们能够把一个二叉树的任何子节点当成二叉树本身。
![]()
三、树的应用
3.1 表达式解析:
在实现了树(Tree)数据结构之后,现在我们来看一个例子,这将告诉我们怎么样利用树(Tree)去解决一些实际的问题。解析树可以用来呈现例如句子或者数学达式等真实世界中的结构。
上图显示了一个简单句子的层次结构。将一个句子表征为一个树(Tree)的结构,能使我们通过利用子树来处理句子中的每个独立成分。 下面我们通过树来实现表达式解析。
(1)实现思路:




(2)实现流程:


(3)规则:
(4)实现代码:
下图展示了实现的整个过程:
- 1、思路:
- 2、代码:
def buildParseTree(fpexe): fplist = fpexe.split() # 将字符串分割常列表 pStack = Stack() # 定义的栈,也可以使用列表来代替 eTree = BinaryTree('') pStack.push(eTree) # 入栈下降 currentTree = eTree for i in fplist: # 对单词列表进行扫描,一共4中情况 # 第一种情况为:( if i == '(': currentTree.insertLeft('') # 创建当前节点的左子树 pStack.push(currentTree) # 入栈下降 currentTree = currentTree.getLeftChild() # 将当前节点的左子节点设置为新的当前节点 # 第二种情况为:数字 elif i not in ['+', '-', '*', '/', ')']: currentTree.setRootVal(int(i)) # 设置当前节点的值 parent = pStack.pop() # 出栈上升 currentTree = parent # 第三种情况为:运算符 elif i in ['+', '-', '*', '/']: currentTree.setRootVal(i) # 将当前节点的值设置为此运算符 currentTree.insertRight('') # 插入右子节点 pStack.push(currentTree) # 入栈下降 currentTree = currentTree.getRightChild() # 将新插入的右子节点设置为当前节点 # 第四种情况为:) elif i == ')': currentTree = pStack.pop() # 出栈上升 # 出错 else: raise ValueError return eTree # 将整个树返回
这样我们就把创建表达式解析树的代码写好了。下面来看在表达式解析树的基础上如何对表达式求值。


- 表达式求值的代码:
import operatordef evaluate(parseTree): """ 作用:表达式解析树的递归求值 :param parseTree: :return: """ opers = { '+':operator.add, '-':operator.sub, '*':operator.mul, '/':operator.truediv} # 减小规模 leftC = parseTree.getLeftChild() rightC = parseTree.getRightChild() # 如果左右子树不为空 if leftC and rightC: fn = opers[parseTree.getRootVal()] # fn是operator的对象,这个对象有两个操作数进行加减乘除运算 return fn(evaluate(leftC), evaluate(rightC)) # 左右子树的值传入计算并返回 # 基本结束条件:没有左右子树 else: return parseTree.getRootVal()
四、树的遍历
4.1基本概念:

4.2 代码:
我们也可以让preorder作为二叉树类中的内置方法,这部分代码如下图所示。注意这一代码从外部移到内部所产生的变化。概括的说,我们只是将“tree”换成了“self”。但是我们也要修改代码基本情况。内置的方法在递归调用preorder之前必须检查左右子节点是否存在。
五、优先队列和二叉堆
5.1 基本概念:

为了降低确定优先队列中元素的优先级的时间复杂度,通常的方法是使用二叉堆来实现:




前面说了一大堆,其实就是在说二叉堆是怎么样的,如何使用二叉堆实现优先队列。下面看看二叉堆具体实现的操作是怎么样的呢?
5.2 二叉堆的python实现:
(1)BinaryHeap():创建一个空二叉堆
(2)insert():将新key加入到堆中


insert函数的代码如下:
![]()
(3)delMin():返回堆的最小项,并删除
既然已经定义了insert(key)
方法,我们来看看delMin()
方法。堆次序要求根节点是树中最小的数据项,因此很容易找到最小项。比较困难的是移走根节点的数据项后如何恢复堆结构和堆次序。我们可以分两步走。首先,用最后一个节点来代替根节点。移走最后一个节点维持了堆结构的性质。如此简单的替换,还是可能破坏堆次序。这就要用到第二步:将新节点“下沉”来恢复堆次序。

(4)buildHeap():从无序表生成“堆”
上图所示是
buildHeap(list)
方法将初始树[ 9, 6, 5, 2, 3]
中的节点移动到正确的位置时所做的交换操作。尽管我们从树中间开始,然后回溯到根节点,但percDown方法保证了最大子节点总是“下沉”。因为堆是完全树,任何经过中间点的节点都是叶节点,因此没有子节点。注意,当i=1时,我们从根节点开始下沉,这就需要大量的交换操作。可以看到,上图最右边的两颗树,首先9从根节点的位置上移走,移到下一层级之后,percDown进一步检查它此时的子节点,保证它下降到不能下降为止,即下降到正确的位置。这就导致了第二次交换:9和3的交换。由于9已经移到了树的最底层,便无法进一步交换了。比较一下上图所示的一系列交换的列表表示和树表示是很有用的。![]()
上面之所以从中间开始,其实已经解释的很清楚了,因为二叉堆是一棵完全树,中间节点后面的节点都是叶子节点。因此,后面的节点再不需要检查了,因为没有孩子。
(5)完整代码:
class BinHeap: def __init__(self): self.heapList = [0] # 0号不用,先占位 self.currentSize = 0 # insert函数的实现 def percUp(self, i): """ 作用:使新插入的节点上浮 :param i: :return: """ while i//2 > 0: # 如果小于,则交换 if self.heapList[i] < self.heapList[i//2]: tmp = self.heapList[i] self.heapList[i] = self.heapList[i//2] self.heapList[i//2] = tmp # 否则退出循环 else: break i = i//2 # 沿路径上升 def insert(self, k): """ 作用:插入新的节点 :param k: :return: """ self.heapList.append(k) # 添加到末尾 self.currentSize += 1 # 二叉堆的大小加1 self.percUp(self.currentSize) # 新节点上浮 # delMin实现 def percDown(self, i): """ 作用:新顶下沉 :param i: :return: """ while i*2 <= self.currentSize: mc = self.minChild(i) # 寻找唯一子节点 if self.heapList[i] > self.heapList[mc]: self.heapList[i], self.heapList[mc] = self.heapList[mc], self.heapList[i] i = mc def minChild(self, i): """ 作用:寻找唯一子节点 :param i: :return: """ if i * 2 + 1 > self.currentSize: # 说明没有右节点,只有左节点,返回左节点即可 return i*2 else: if self.heapList[i*2] < self.heapList[i*2+1]: return i*2 else: return i*2+1 def delMin(self): """ 作用:返回堆的最小项,并删除 :return: """ retval = self.heapList[1] # 移走堆顶 self.heapList[1] = self.heapList[self.currentSize] # 用最后一个代替 self.currentSize -= 1 # 将大小减一 self.heapList.pop() # 删除最后一个元素 self.percDown(1) # 新顶下沉 return retval # 从无序表建立"堆" def buildHeap(self, alist): """ 作用:从无序表建立"堆" :param alist: :return: """ i = len(alist) // 2 # 从中间开始 self.currentSize = len(alist) self.heapList[0] = [0] + alist[:] print(len(self.heapList), i) while i > 0: print(self.heapList, i) self.percDown(i) i -= 1 print(self.heapList, i)
六、二叉查找树及其操作
6.1 基本概念:
在之前,我们已经介绍了在一个集合中,得到键值对的两种不同的方法。这两种我们讨论的ADT MAP(映射)的实现方式是列表的二分查找和散列表。接下来,我们学习一下二叉搜索树作为从键指向值的另一种方式,在这种情形中我们对数据在树中的实际位置不感兴趣,但是我们对用二叉树结构来提供更有效率的搜索感兴趣。
在我们看这种实现方式之前,让我们回顾一下ADT MAP提供的接口。我们会发现,这种接口和Python的字典非常相似。
复习一下ADT MAP(映射)的基本操作:
6.2 二叉查找树(BST)的性质:
一个二叉查找树,如果左子树中键值Key都小于父节点,而右子树中键值Key都大于父节点,我们将这种树称为BST查找树。如前面所述,当我们实现Map方法时,BST方法将引导我们实现这一点。下图显示了二叉搜索树的这一特性,显示的键没有任何关联的值。 注意:这种属性适用于每个父节点和子节点。所有左子树的键值是小于根的键值的,所有的键值在右子树均大于根。

接下来,举个例子,看看BST是如何实现的:
6.3 BST的实现:
使用节点和链接结构实现BST:
接下来,我们分别看这两类是如何实现的:
- (1)BST类:
下面是插入节点的put方法实现:
![]()
下图形象的展示了上述代码的步骤:
为了更好的操作,实现魔法方法,进行索引赋值:
![]()
下面是get方法,该方法的作用是:通过节点的key找到节点的value:
![]()
同时,可以使用for循环来枚举树中的所有key,因此需要实现__iter__魔法方法:
![]()
![]()
下面是二叉查找树删除节点的方法:delete
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
- (2)TreeNode类:

6.4 算法分析:

6.5 代码:
七、AVL树的定义和性能
7.1 基本概念:
在上一节中我们学习了建立一个二叉查找树。我们知道,当树变得不平衡时 get 和 put操作会使二叉搜索树的性能降低到 O(n) 。在这一节中我们将看到一种特殊的二叉查找树,它可以自动进行调整,以确保树时时保持平衡。这种树被称为 AVL 树,由发明他的人:G.M.Adelson-Velskii 和 E.M.Landis 而命名。
AVL 树实现ADT Map(映射或字典)的抽象数据类型,就像一个普通的二叉搜索树,唯一不同的是这棵树的工作方式。为实现 AVL 树我们需要在树中的每个节点加入一个平衡因子(balance factor)以跟踪其变化情况。我们通过比较每个节点的左右子树的高度完成比较。更正式的定义是,一个节点的平衡因子定义为左子树的高度和右子树的高度之差。下图是一个例子:
利用以上的平衡因子的定义,如果平衡因子小于零,我们称子树“左重” (left-heavy) 。 如果平衡因子大于零,那么子树是“右重” (right-heavy) 。 如果平衡因子等于零,树是完美的平衡。为实现 AVL 树的目的,并获得具有平衡的树,我们将定义如果平衡因子是-1,0 或 1,那么这个树是平衡的。一旦树中的节点的平衡因子超出了这个范围, 我们需要将树恢复平衡。下图是一个不平衡的“右重”树的例子,其中每个节点都标注了平衡因子。
![]()
7.2 性能:
在我们继续进行之前,让我们看看引入这个新的平衡因素的结果。我们的需要是, 确保树上总是有一个平衡因子- 1,0,或 1,可以使对键的操作得到更好的大 O 性能。首先,我们来思考有这个平衡条件后,最坏情况下的树发生了什么变化。有两个可能的考虑,左重树和右重树。如果我们考虑树的高度为 0,1,2 和 3,下图举出了在新规则下可能的出现的最不平衡的左重树的例子。
看一下树上的节点的总数,我们就会发现一棵高度为 0 的树有 1 个节点, 一个高度为 1 的树有 1 + 1 = 2 个节点,一个高度为 2 的树有 1 + 1 + 2 = 4,一棵高度为 3 的树有 1 + 2 + 4 =7 个节点。更普遍的模式,我们看到高度为h 的树的节点数 Nh是:
可能你很熟悉这个公式, 因为它和斐波那契序列非常相似。 我们可以利用这个公式通过树中的节点的数目推导出一个 AVL 树的高度。在我们的印象中,斐波那契数列和斐波那契数定义为:
![]()
![]()
这个推导告诉我们,在任何时候我们的 AVL 树的高度等于树中节点数以 2 为底的对数的常数 (等于1.44) 倍。 这对搜索我们的AVL树来说是好消息因为它限制搜索复杂度到O(logN) 。
7.3 AVL树的实现:
既然,我们已经证明,保持一个 AVL 树的平衡将是一个很大的性能提升,让我们看看我们如何增加向树中插入一个新的键值的算法。因为所有的新键是作为叶节点插入树的, 我们知道一个新叶的平衡因子为零, 所以我们对刚刚插入的节点没有新的要求。 但是一旦有新叶插入我们必须更新其父节点的平衡因子。新的叶节点如何影响父节点的平衡因子取决于该叶节点是左节点还是右节点。
如果新节点是右节点,父节点的平衡因子将减少一。 如果新节点是左节点,父节点的平衡因子将增加一。 这种关系可以递归地应用于新的节点的前两个节点,并且有可能影响每一个前面的节点一直到树的根。 由于这是一个递归过程, 我们可以考察两个更新平衡因子的基本条件:
![]()
![]()
![]()
_put
的大部分工作正是由这个新的 updateBalance 方法完成的。这实现了我们刚才描述的递归过程。 这个再平衡方法首先检查当前节点是否十分不平衡, 以至于需要重新平衡 (上图标出来了) 。 如果当前节点需要再平衡, 那么只需要对当前节点进行再平衡, 而不需要进一步更新父节点。如果当前节点不需要再平衡, 那么父节点的平衡因子需要调整。 如果父节点的平衡因子非零,那么算法通过父节点递归调用 updateBalance 方法继续往上传递到树的根。 当对一棵树进行再平衡是必要的,我们该怎么做呢?有效的再平衡是使 AVL 树很好地工作而不牺牲性能的关键。 为了让一个 AVL 树恢复平衡, 我们会在树上执行一个或多个 “旋转”(rotation)。 为了了解什么是旋转,让我们看一个很简单的例子。思考一下 下图 的左半部分树。这棵树是不平衡的,平衡因子为- 2。为了让这棵树平衡,我们将绕根节点A 的子树节点进行左旋转。执行一个左旋转我们需要做到以下几点: 1.使右节点(B)成为子树的根。 2.移动旧根(A)到新根的左节点。 3.如果新根 (B) 原来有左节点, 那么让原来 B 的左节点成为新根左节点 (A) 的右节点。注:由于新根(B)是 A 的右节点,在这种情况下移动后的 A 的右节点一定是空的。这使得我们不用多想就可以直接给移动后的 A 添加右节点。 虽然这个程序概念上相当简单, 但是代码的细节有点棘手, 因为为了维持二叉搜索树的所有性质,必须以绝对正确的顺序把节点移来移去。此外,我们需要确保正确地更新了所有的parent 指针。 让我们通过观察一个稍微复杂的树来说明右旋转。
上图的左边展现了一棵“左重”的树,根的平衡因子为 2。执行一个正确的右旋转,我们需要做以下几点: 1.使左节点(C)成为子树的根。 2 移动旧根(E)到新根的右节点。 3.如果新根(C)原来有右节点(D) ,那么让 D 成为新根右节点(E)的左节点。注:由于新根(C)是 E 的左节点,在这种情况下移动后的 E 的左节点一定是空的。这使得我们不用多想就可以直接给移动后的 E 添加左节点。 接下来让我们看看代码:
最后两行需要一些解释。在这两行我们更新旧根和新根的平衡因子。因为所有其他的动作是移动整个子树, 被移动的子树内的节点的平衡因子不受旋转的影响。 但我们如何在没有完全重新计算新的子树的高度的情况下更新平衡因子?下面的推导将让你明白, 这些代码都是正确的。
![]()
现在你可能会认为我们已经做完了。但实际上我们还有一种情况没有考虑:
![]()
实现这些规则的代码可以从我们“再平衡” (rebalance)的方法中找到,如下面代码所示。上面的第一条规则从第二行 if 语句中开始实现。第二条规则是由第 8 行 elif 语句开始实现的。
![]()
7.4 小结:
发表评论
最新留言
关于作者
