树形结构及其算法
1. 树的概念
2. 二叉树与基本实现
3. 二叉查找树
4. 堆及堆排序
5. 平衡二叉树(AVL)
6. 红黑树
7. B 树和 B+ 树
1. 树的概念
树(Tree)是由多个节点(Node)的集合组成,每个节点又有多个与其关联的子节点(Child Node)。子节点就是直接处于节点之下的节点,而父节点(Parent Node)则位于节点直接关联的上方。树的根(Root)指的是一个没有父节点的单独的节点。
所有的树都呈现了一些共有的性质:
- 只有一个根节点;
- 除了根节点,所有节点都有且仅有一个父节点;
- 无环。将任意一个节点作为起始节点,都不存在任何回到该起始节点的路径(正是前两个性质保证了无环的成立)。
树形结构是一种日常生活应用相当广泛的非线性结构。树状算法在程序中的建立与应用大多使用链表来处理,因为链表的指针用来处理树相当方便,只需改变指针即可。当然,也可以使用数组这样的连续内存来表示二叉树,两者各有利弊。
图示:
树的术语
- 根(Root):树中最顶端的节点,根没有父节点。
- 子节点(Child):节点所拥有子树的根节点称为该节点的子节点。
- 父节点(Parent):如果节点拥有子节点,则该节点为子节点的父节点。
- 兄弟节点(Sibling):与节点拥有相同父节点的节点。
- 后代节点(Descendant):节点向下路径上可达的节点。
- 叶节点(Leaf):没有子节点的节点。
- 内节点(Internal Node):至少有一个子节点的节点。
- 度(Degree):节点拥有子树的数量。
- 边(Edge):两个节点中间的链接。
- 路径(Path):从节点到子孙节点过程中的边和节点所组成的序列。
- 层级(Level):根为 Level 0 层,根的子节点为 Level 1 层,以此类推。
- 高度(Height)/ 深度(Depth):树中层的数量。比如只有 Level 0、Level 1、Level 2,则高度为 3。
树的种类
- 无序树:树中任意节点的子节点之间没有顺序关系,这种树称为无序树,也称为自由树。
- 有序树:树中任意节点的子节点之间有顺序关系,这种树称为有序树。
- 二叉树:每个节点最多含有两个子树的树称为二叉树。
- 霍夫曼树(用于信息编码):带权路径最短的二叉树称为哈夫曼树或最优二叉树。
- B 树:一种对读写操作进行优化的自平衡的二叉查找树,能够保持数据有序,拥有多余两个子树。
树的存储与表示
由于对节点的个数无法掌握,常见树的存储表示都转换成二叉树进行处理,即子节点个数最多为 2。
- 顺序存储:将数据结构存储在固定的数组中,虽然在遍历速度上有一定的优势,但因需要事先声明的所占空间大,是非主流二叉树。
- 链式存储:使用链表来表示二叉树的好处是对于节点的增加与删除相当容易;缺点是很难找到父节点,除非在每一节点多增加一个父字段。
顺序存储
链式存储
常见的一些树的应用场景
- xml,html 等,那么编写这些东西的解析器的时候,不可避免用到树。
- 路由协议就是使用了树的算法。
- Mysql 数据库索引。
- 文件系统的目录结构。
- 很多经典的 AI 算法其实都是树搜索,如机器学习中的决策树(decision tree)就是树结构。
2. 二叉树与基本实现
二叉树的基本概念
二叉树是每个节点最多有两个子树的树结构。通常子树被称作“左子树”(left subtree)和“右子树”(right subtree)。
二叉树的性质(特性)
- 性质1:在二叉树的第 i 层上至多有 2i-1 个节点(i>0)
- 性质2:深度为 k 的二叉树至多有 2k-1 个节点(k>0)
- 性质3:对于任意一棵二叉树,如果其叶节点数为 N0,而度数为 2 的节点总数为 N2,则 N0 = N2+1
- 性质4:具有 n 个节点的完全二叉树的深度必为 log2(n+1)
- 性质5:对完全二叉树,若从上至下、从左至右编号,则编号为 i 的节点,其左子节点编号必为 2i,其右子节点编号必为 2i+1;其父节点的编号必为 i/2(i=1 时为根,除外)
完全二叉树和满二叉树
-
完全二叉树:除了最后一层,其它层的节点数都达到了最大值,且叶节点从左往右紧密排列。
-
满二叉树:除了最后一层,其它层的节点都有两个子节点。
代码实现:用队列实现普通二叉树
1 class Node: 2 """树的节点类""" 3 def __init__(self, item): 4 self.item = item 5 self.lchild = None 6 self.rchild = None 7 8 9 class BinaryTree: 10 """二叉树类""" 11 12 def __init__(self, root=None): 13 """初始化根节点""" 14 self.root = root 15 16 def add(self, item): 17 """为树添加节点""" 18 node = Node(item) 19 # 如果树是空的,则为根节点赋值 20 if self.root is None: 21 self.root = node 22 else: 23 queue = [] 24 queue.append(self.root) 25 # 对已有节点进行层次遍历 26 while queue: 27 # 弹出队列的第一个元素(先进先出) 28 cur_node = queue.pop(0) 29 if cur_node.lchild is None: 30 cur_node.lchild = node 31 return 32 elif cur_node.rchild is None: 33 cur_node.rchild = node 34 return 35 # 如果左右子树都不为空,加入队列继续判断 36 else: 37 queue.append(cur_node.lchild) 38 queue.append(cur_node.rchild)
3. 二叉查找树
普通二叉树的问题
如果要访问二叉树中的某一个节点,通常需要逐个遍历二叉树中的节点来定位,它不像数组那样能对指定的元素进行直接的访问。
所以普通二叉树的查找效率是线性的 O(n),在最坏的情况下需要查找树中所有的节点。也就是说,随着二叉树节点数量增加时,查找任一节点的步骤数量也将相应地增加。
那么,如果一个二叉树的查找时间是线性的,那相比数组来说到底哪里有优势呢?毕竟数组的查找时间是常量 O(1)。的确是这样,通常来说普通的二叉树确实不能提供比数组更好的查找性能。然而,如果我们按照一定的规则来组织排列二叉树中的元素时,就可以很大程度地改善查找效率。
二叉查找树的定义
二叉查找树(BST:Binary Search Tree)是二叉树中最常用的一种类型,也叫二叉搜索树。顾名思义,二叉查找树是为了实现快速查找而生的。它不仅支持快速查找一个数据,还支持快速插入、删除一个数据。它是怎么做到这些的呢?
这些都依赖于二叉查找树的特殊结构。二叉查找树要求,在树中的任意一个节点,其值要大于其左子树中每个节点的值,且要小于其右子树所有节点的值。
二叉查找树有以下性质:
- 可以是空集合,若不是空集合,则节点上一定要有数据值;
- 每一个节点的值需大于其左子树(left subtree)下的所有节点的值;
- 每一个节点的值需小于其右子树(left subtree)下的所有节点的值;
- 树的每个节点值都不相同。
二叉查找树改善了二叉树节点查找的效率,是一种很好的排序应用模式,因为在建立二叉树的同时,数据就经过初步的比较判断,并按照二叉树的建立规则来存放数据,非常高效。因此,二叉查找树也叫作二叉排序树。
- 数组的搜索比较方便,可以直接用下标,但删除或者插入某些元素就比较麻烦;链表与之相反,删除和插入元素很快,但查找很慢。
- 相对数组和链表而言,二叉排序树是一种比较有用的折衷方案,既有链表的好处,也有数组的好处,在处理大批量的动态的数据时较为有效。
二叉查找树的时间复杂度分析
不管操作是插入、删除还是查找,二叉查找树的时间复杂度其实都跟树的高度成正比,也就是 O(height)。
- 所以对于一棵完全二叉查找树,它的时间复杂度为 O(logn)。
- 如果退化成下图左边这种类型,它的时间复杂度为 O(n)。
1)创建二叉查找树
例 1:用数组实现二叉查找树
使用有序的一维数组来表示二叉树,首先可将此二叉树假想成一棵满二叉树,而且第 k 层具有 2k-1 个节点,按序存放在一维数组中。
首先来看看使用一维数组建立二叉查找树的表示方法以及数组索引值的设置:
索引值 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
内容值 | A | B | C | D |
(PS:索引从 1 开始是为了计算更方便)
由此可以看出此一维数组中的索引值有以下关系:
- 左子节点的索引值是父节点索引值乘 2。
- 右子节点的索引值是父节点索引值乘 2 加 1。
代码实现
1 # 把数组的数据值放入二叉查找树数组 2 def bstree(bts_list, data): 3 for i in range(1, len(data)): 4 # 根节点索引值设置为1而不是0,是为了能够直接计算子节点的索引值 5 index = 1 6 # 默认空节点的数据值为0 7 # 从根节点开始遍历,直到找到空节点把数据放入 8 while bts_list[index] != 0: 9 # 如果数据值大于当前节点值,则往右子树比较 10 if data[i] > bts_list[index]: 11 index = index * 2 + 1 12 # 如果数据值小于当前节点值,则往左子树比较 13 else: 14 index *= 2 15 # 找到空节点,放入数据值 16 bts_list[index] = data[i] 17 18 19 # 原始数据(第一个元素为无效数据,有效数据实际从索引值1开始) 20 data = [0, 6, 3, 5, 4, 7, 8, 9, 2] 21 # 初始化二叉树数组 22 bts_list = [0] * 16 23 24 print("原始数组内容:\n%s" % data) 25 bstree(bts_list, data) 26 print("二叉树数组内容:\n%s" % bts_list[1:])
执行结果:
原始数组内容: [0, 6, 3, 5, 4, 7, 8, 9, 2] 二叉树数组内容: [6, 3, 7, 2, 5, 0, 8, 0, 0, 4, 0, 0, 0, 0, 9]
例 2:用链表实现二叉查找树
1 class Node: 2 """树的节点类""" 3 def __init__(self, data): 4 self.data = data 5 self.left = None 6 self.right = None 7 8 9 class BSTree: 10 """二叉查找树类""" 11 12 def __init__(self, root=None): 13 """初始化根节点""" 14 self.root = root 15 16 def add(self, data): 17 """添加节点数据""" 18 if self.root is None: 19 self.root = Node(data) 20 return 21 cur_node = self.root 22 while cur_node is not None: 23 # backup 作用:记录 cur_node 的父节点 24 backup = cur_node 25 # 若新加的值小于当前节点的值,则往左子树遍历 26 if cur_node.data > data: 27 cur_node = cur_node.left 28 # 若新加的值大于当前节点的值,则往右子树遍历 29 else: 30 cur_node = cur_node.right 31 # 当cur_node为空节点时,将新加的值与父节点backup的值比较 32 if backup.data > data: 33 backup.left = Node(data) 34 else: 35 backup.right = Node(data) 36 37 def travel_data(self): 38 """打印节点值""" 39 print("根节点值:") 40 print(self.root.data) 41 print("各左节点值:") 42 left_node = self.root.left 43 while left_node is not None: 44 print(left_node.data) 45 left_node = left_node.left 46 print("各右节点值:") 47 right_node = self.root.right 48 while right_node is not None: 49 print(right_node.data) 50 right_node = right_node.right 51 52 53 # 初始化原始数据 54 data = [5, 6, 24, 8, 12, 3, 17, 1, 9] 55 bst = BSTree() 56 for i in data: 57 bst.add(i) 58 bst.travel_data()
执行结果:
根节点值: 5 各左节点值: 3 1 各右节点值: 6 24
2)查找节点
二叉查找树在建立的过程中,是根据左子树 < 树根 < 右子树的原则建立的,因此只需从树根出发比较节点值,如果比树根大就往右,否则往左而下,直到相等就找到了要查找的值,如果比到 None,无法再前进就代表查找不到此值。
通过 BST 查找节点,理想情况下我们需要检查的节点数可以减半。如下图中的 BST 树,包含了 15 个节点。从根节点开始执行查找算法,第一次比较决定我们是移向左子树还是右子树。对于任意一种情况,一旦执行这一步,我们需要访问的节点数就减少了一半,从 15 降到了 7。同样,下一步访问的节点也减少了一半,从 7 降到了 3,以此类推。
根据这一特点,查找算法的时间复杂度应该是 O(logn)。
实际上,对于 BST 查找算法来说,其十分依赖于树中节点的拓扑结构,也就是节点间的布局关系。下图描绘了一个节点插入顺序为 20, 50, 90, 150, 175, 200 的 BST 树。这些节点是按照递升顺序被插入的,结果就是这棵树没有广度(Breadth)可言。也就是说,它的拓扑结构其实就是将节点排布在一条线上,而不是以扇形结构散开,所以查找时间也为 O(n)。
当 BST 树中的节点以扇形结构散开时,对它的插入、删除和查找操作最优的情况下可以达到线性的运行时间 O(logn)。因为当在 BST 中查找一个节点时,每一步比较操作后都会将节点的数量减少一半。尽管如此,如果拓扑结构像上图中的样子时,运行时间就会退减到线性时间 O(n)。因为每一步比较操作后还是需要逐个比较其余的节点。也就是说,在这种情况下,在 BST 中查找节点与在链表中查找就基本类似了。
因此,BST 算法查找时间依赖于树的拓扑结构。最佳情况是 O(logn),最坏情况是 O(n)。
代码实现
建立一个二叉查找树,并输入要查找的值。如果找到该值,则显示查找的次数。
1 class Node: 2 """树的节点类""" 3 def __init__(self, data): 4 self.data = data 5 self.left = None 6 self.right = None 7 8 9 class BSTree: 10 """二叉查找树""" 11 12 def __init__(self, root=None): 13 """初始化根节点""" 14 self.root = root 15 16 def add(self, data): 17 """添加节点数据""" 18 if self.root is None: 19 self.root = Node(data) 20 return 21 cur_node = self.root 22 while cur_node is not None: 23 # backup 作用:记录 cur_node 的父节点 24 backup = cur_node 25 # 若新加的值小于当前节点的值,则往左子树遍历 26 if cur_node.data > data: 27 cur_node = cur_node.left 28 # 若新加的值大于当前节点的值,则往右子树遍历 29 else: 30 cur_node = cur_node.right 31 # 当cur_node为空节点时,将新加的值与父节点backup的值比较 32 if backup.data > data: 33 backup.left = Node(data) 34 else: 35 backup.right = Node(data) 36 37 def search(self, data): 38 """查找节点""" 39 # 记录遍历节点的次数 40 search_time = 1 41 cur_node = self.root 42 while True: 43 if cur_node is None: 44 return None 45 if cur_node.data == data: 46 print("共查找了【%d】次" % search_time) 47 return cur_node.data 48 elif cur_node.data > data: 49 cur_node = cur_node.left 50 else: 51 cur_node = cur_node.right 52 search_time += 1 53 54 55 # 初始化原始数据 56 data = [7, 1, 4, 2, 8, 13, 12, 11, 15, 9, 5] 57 print("原始数据:%s" % data) 58 bst = BSTree() 59 for i in data: 60 bst.add(i) 61 search_data = bst.search(8) 62 if search_data: 63 print("您要找的值【%d】找到了" % search_data)
执行结果:
原始数据: [7, 1, 4, 2, 8, 13, 12, 11, 15, 9, 5] 共查找了【2】次 您要找的值【8】找到了
3)插入节点
二叉树节点插入的情况和查找相似,重点是插入后仍要保持二叉查找树的特性。BST 的插入算法的复杂度与查找算法的复杂度是一样的:最佳情况是 O(logn),最坏情况是 O(n),因为它们对节点的查找定位策略是相同的。
代码实现
如果插入的节点已经在二叉树中,就没有插入的必要了。如果插入的值不在二叉树中,就出现查找失败的情况,表示找到了要插入的位置。
1 class Node: 2 """树的节点类""" 3 def __init__(self, data): 4 self.data = data 5 self.left = None 6 self.right = None 7 8 9 class BSTree: 10 """二叉查找树""" 11 12 def __init__(self, root=None): 13 """初始化根节点""" 14 self.root = root 15 16 def add(self, data): 17 """添加节点数据""" 18 if self.root is None: 19 self.root = Node(data) 20 return 21 cur_node = self.root 22 while cur_node is not None: 23 # backup 作用:记录 cur_node 的父节点 24 backup = cur_node 25 # 若新加的值小于当前节点的值,则往左子树遍历 26 if cur_node.data > data: 27 cur_node = cur_node.left 28 # 若新加的值大于当前节点的值,则往右子树遍历 29 else: 30 cur_node = cur_node.right 31 # 当cur_node为空节点时,将新加的值与父节点backup的值比较 32 if backup.data > data: 33 backup.left = Node(data) 34 else: 35 backup.right = Node(data) 36 37 def search(self, data): 38 """查找节点""" 39 # 记录遍历节点的次数 40 search_time = 1 41 cur_node = self.root 42 while True: 43 if cur_node is None: 44 return None 45 if cur_node.data == data: 46 print("共查找了【%d】次" % search_time) 47 return cur_node.data 48 elif cur_node.data > data: 49 cur_node = cur_node.left 50 else: 51 cur_node = cur_node.right 52 search_time += 1 53 54 def inorder(self, node): 55 """中序遍历""" 56 if node is not None: 57 self.inorder(node.left) 58 print("%d" % node.data, end=" ") 59 self.inorder(node.right) 60 61 62 # 初始化原始数据 63 data = [7, 1, 4, 2, 8, 13, 12, 11, 15, 9, 5] 64 print("原始数据:%s" % data) 65 66 # 初始化二叉查找树 67 bst = BSTree() 68 for i in data: 69 bst.add(i) 70 71 # 需要添加的数据值 72 new_data = 21 73 # 先使用查找算法校验插入的数据是否已存在 74 search_data = bst.search(new_data) 75 if search_data: 76 print("您要找的值【%d】找到了,无需再插入!" % search_data) 77 # 若不存在,则进行添加并使用中序遍历打印所有节点值 78 else: 79 bst.add(new_data) 80 bst.inorder(bst.root)
执行结果:
原始数据:[7, 1, 4, 2, 8, 13, 12, 11, 15, 9, 5] 1 2 4 5 7 8 9 11 12 13 15 21
4)删除节点
从 BST 中删除节点比插入节点难度更大。因为删除一个非叶子节点,就必须选择其他节点来填补因删除节点所造成的树的断裂。如果不选择节点来填补这个断裂,那么就违背了 BST 的性质要求。
删除节点算法的第一步是定位要被删除的节点,这可以使用前面介绍的查找算法,因此运行时间为 O(logn)。接着应该选择合适的节点来代替删除节点的位置,它共有三种情况需要考虑:
- 情况 1:如果被删除的节点没有右孩子,那么就选择它的左孩子来代替原来的节点。二叉查找树的性质保证了被删除节点的左子树必然符合二叉查找树的性质,因此左子树的值都小于被删除节点的父节点的值。(如果被删除的节点没有左孩子,同理。)
- 情况 2:如果被删除节点的右孩子没有左孩子,那么这个右孩子被用来替换被删除节点。因为被删除节点的右孩子都大于被删除节点左子树的所有节点,同时也大于被删除节点的父节点。因此,用右孩子来替换被删除节点,符合二叉查找树的性质。
- 情况 3:如果被删除节点的右孩子有左孩子,就需要用被删除节点右孩子的左子树中的最下面的节点来替换它,就是说,我们用被删除节点的右子树中最小值的节点来替换。我们知道,在 BST 中,最小值的节点总是在最左边,最大值的节点总是在最右边。所以替换被删除节点右子树中最小的一个节点,就保证了该节点一定大于被删除节点左子树的所有节点;同时,也保证它替代了被删除节点的位置后,它的右子树的所有节点值都大于它。因此这种选择策略符合二叉查找树的性质。
和查找、插入算法类似,删除算法的运行时间也与 BST 的拓扑结构有关,最佳情况是 O(logn),而最坏情况是 O(n)。
5)重复数据的处理
前面我们讲的二叉查找树的操作,针对的都是不存在键值相同的情况。那如果存储的两个对象键值相同,这种情况该怎么处理呢?有两种解决方法。
- 第一种方法比较容易:二叉查找树中每一个节点不单只存储一个数据,为此我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。
- 第二种方法比较不好理解,不过更加优雅:每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。
当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。
对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。
6)遍历节点
二叉树的遍历(Binary Tree Traversal)是树的一种重要的运算。最简单的说法就是“访问树中所有的节点各一次”,并且在遍历后,将树中的数据转化为线性关系。
二叉树有四种常用的遍历方式:
- 层序遍历:从根节点开始,从上到下、从左到右地依次遍历每个节点(常用队列实现):
- 根节点入队列。
- 根节点出队,同时将根节点的左子树和右子树入队。
- 结点出队,同时将该节点的左子树和右子树入队。
- 重复步骤 3 直到队列为空。
- 前序遍历(Perorder traversal):“中左右”的遍历顺序,也就是先从根节点遍历,再往左方移动,当无法继续时,继续向右方移动,接着再重复此步骤:
- 遍历(或访问)树根。
- 遍历左子树。
- 遍历右子树。
- 中序遍历(Inorder traversal):“左中右”的遍历顺序,也就是从树的左侧逐步向下方移动,直到无法移动,再访问此节点,并向右移动一节点。如果无法再往右移动时,可以返回父节点,并重复左、中、右的步骤:
- 遍历左子树。
- 遍历(或访问)树根。
- 遍历右子树。
- 后序遍历(Postorder traversal):“左右中”的遍历顺序,就是先遍历左子树,再遍历右子树,最后遍历(或访问)根根,反复执行此步骤:
- 遍历左子树。
- 遍历右子树。
- 遍历树根。
示例:普通二叉树的遍历
代码实现:二叉查找树的遍历
二叉查找树意味着二叉树中的数据是排好序的,顺序为左节点 < 根节点 < 右节点,这表明二叉查找树的中序遍历结果是有序的。
1 class Node: 2 """树的节点类""" 3 def __init__(self, data): 4 self.data = data 5 self.left = None 6 self.right = None 7 8 9 class BSTree: 10 """二叉查找树""" 11 12 def __init__(self, root=None): 13 """初始化根节点""" 14 self.root = root 15 16 def add(self, data): 17 """添加节点数据""" 18 if self.root is None: 19 self.root = Node(data) 20 return 21 cur_node = self.root 22 while cur_node is not None: 23 # backup 作用:记录 cur_node 的父节点 24 backup = cur_node 25 # 若新加的值小于当前节点的值,则往左子树遍历 26 if cur_node.data > data: 27 cur_node = cur_node.left 28 # 若新加的值大于当前节点的值,则往右子树遍历 29 else: 30 cur_node = cur_node.right 31 # 当cur_node为空节点时,将新加的值与父节点backup的值比较 32 if backup.data > data: 33 backup.left = Node(data) 34 else: 35 backup.right = Node(data) 36 37 def breadth_order(self): 38 """队列实现层序遍历""" 39 if self.root is None: 40 return 41 queue = [self.root] 42 while queue: 43 cur_node = queue.pop(0) 44 print(cur_node.data, end=" ") 45 if cur_node.left is not None: 46 queue.append(cur_node.left) 47 if cur_node.right is not None: 48 queue.append(cur_node.right) 49 50 def preorder(self, node): 51 """递归实现前序遍历""" 52 if node is None: 53 return 54 print(node.data, end=" ") 55 self.preorder(node.left) 56 self.preorder(node.right) 57 58 def inorder(self, node): 59 """递归实现中序遍历""" 60 if node is None: 61 return 62 self.inorder(node.left) 63 print(node.data, end=" ") 64 self.inorder(node.right) 65 66 def postorder(self, node): 67 """递归实现后序遍历""" 68 if node is None: 69 return 70 self.postorder(node.left) 71 self.postorder(node.right) 72 print(node.data, end=" ") 73 74 75 # 初始化原始数据 76 data = [7, 1, 4, 2, 8, 13, 12, 11, 15, 9, 5] 77 print("原始数据:%s" % data) 78 # 创建二叉查找树 79 bst = BSTree() 80 for i in data: 81 bst.add(i) 82 83 print("层序遍历:", end="") 84 bst.breadth_order() 85 print() 86 print("先序遍历:", end="") 87 bst.preorder(bst.root) 88 print() 89 print("中序遍历:", end="") 90 bst.inorder(bst.root) 91 print() 92 print("后序遍历:", end="") 93 bst.postorder(bst.root)
运行结果:
原始数据:[7, 1, 4, 2, 8, 13, 12, 11, 15, 9, 5]
层序遍历:7 1 8 4 13 2 5 12 15 11 9
先序遍历:7 1 4 2 5 8 13 12 11 9 15
中序遍历:1 2 4 5 7 8 9 11 12 13 15
后序遍历:2 5 4 1 9 11 12 15 13 8 7
思考:(先中后序)哪两种遍历方式能够唯一的确定一颗树?
- 先序:a b c d e f g h
- 中序:b d c e a f h g
- 后序:d e c b h g f a
答:先序与中序、中序与后续。
4. 堆及堆排序
什么是堆?
堆(Heap)是一种特殊的二叉树——堆积树(Heap Tree)的数组对象。堆的具体实现一般不通过指针域,而是通过构建一个一维数组与二叉树的父子节点进行对应,因此堆总是一个完全二叉树。
对于任意一个父节点的序号 n 来说(这里的 n 从 0 开始算),它的子节点的序号一定是 2n+1 和 2n+2;若 n 从 1 算,则它的子节点的序号是 2n 和 2n+1。因此可以直接用数组来表示一个堆。
堆具有以下性质:
- 堆是一个完全二叉树。
- 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。
- 若根节点最大,则堆叫做“最大堆”或“大根堆”;若根节点最小,则堆叫做“最小堆”或“小根堆”。
如下图所示,第 1 个和第 2 个是大顶堆,第 3 个是小顶堆,第 4 个不是堆。从图中可以看出来,对于同一组数据,我们可以构建多种不同形态的堆。
堆化
在使用堆排序前,必须先了解如何将二叉树转换成堆积树。往堆中插入或者删除一个元素后,我们需要继续满足堆的两个特性,即需要进行调整,让其重新满足堆的特性,这个过程叫做堆化(heapify)。
堆化实际上有两种,从下往上和从上往下。
堆中插入元素
如下图所示,往最大堆中插入一个新元素 22。
我们可以让新插入的节点与父节点对比大小。如果不满足子节点小于等于父节点的大小关系,我们就互换两个节点。一直重复这个过程,直到父子节点之间满足刚说的那种大小关系,这就是从下往上的堆化方法。
删除堆顶元素
假设我们构造的是大顶堆,堆顶元素就是最大的元素。当我们删除堆顶元素之后,就需要把第二大的元素放到堆顶,那第二大元素肯定会出现在左右子节点中。然后我们再迭代地删除第二大节点,以此类推,直到叶子节点被删除。
不过这种方法有点问题,就是最后堆化出来的堆并不满足完全二叉树的特性。
堆排序
1)建堆
进行排序前,首先要进行建堆,即把数组原地建成一个堆。所谓“原地”就是不借助另一个数组,就在原数组上操作。建堆的过程,有两种思路。
第一种是借助我们前面讲的,在堆中插入一个元素的思路。尽管数组中包含 n 个数据,但是我们可以假设,起初堆中只包含一个数据,就是下标为 1 的数据。然后,我们调用前面讲的插入操作,将下标从 2 到 n 的数据依次插入到堆中。这样我们就将包含 n 个数据的数组,组织成了堆。
第二种实现思路跟第一种截然相反,也是下文图解和代码实现的思路。第一种建堆思路的处理过程是按数组顺序从前往后处理数据,并且每个数据插入堆中时,都是从下往上堆化。而第二种实现思路,是从后往前处理数组数据,且每个数据是从上往下堆化的。
2)堆排序
建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为 n 的位置。
这个过程有点类似上面讲的“删除堆顶元素”的操作,当堆顶元素移除之后,我们把下标为 n 的元素放到堆顶,然后再通过堆化的方法,将剩下的 n-1 个元素重新构建成堆。堆化完成之后,我们再取堆顶的元素,放到下标是 n-1 的位置,一直重复这个过程,直到最后堆中只剩下标为 1 的一个元素,排序工作就完成了。
代码实现堆排序
1 # 堆排序 2 def heap_sort(data): 3 # 对原始数据建堆 4 for i in range(len(data)//2, 0, -1): 5 add_heap(data, i, len(data)-1) # i表示最后一个带有子节点的节点索引 6 print("原始数据建堆结果:%s" % data) 7 8 # 堆排序 9 for i in range(len(data)-2, 0, -1): 10 # 头尾节点交换:每次将最大值放到数组末尾 11 data[1], data[i+1] = data[i+1], data[1] 12 # 对剩下的节点进行重新建堆 13 add_heap(data, 1, i) # i表示堆顶节点索引 14 print("处理过程为:%s" % data) 15 16 # 建堆 17 def add_heap(data, i, size): 18 # data:需要排序的数组 19 # i:需要堆化的节点索引位(从上往下堆化) 20 # size:需要建堆的数组长度 21 22 # 记录当前树根的值 23 tmp = data[i] 24 # j 为子节点的索引(左子树优先) 25 j = i * 2 26 # 遍历比较后代节点 27 while j <= size: 28 # 除了最后一个节点,前面的节点都要与右边节点进行比较 29 if j < size: 30 # 比较当前的左右节点,谁大取谁 31 if data[j] < data[j+1]: 32 j += 1 33 # 如果树根值较大,结束比较过程 34 if tmp >= data[j]: 35 break 36 # 若树根较小,则将子节点的值赋给树根,并从当前节点的左子节点开始继续比较 37 else: 38 data[j//2] = data[j] 39 j = 2*j 40 # 将临时存储的树根的值赋给最后遍历到的子节点 41 data[j//2] = tmp 42 43 44 if __name__ == "__main__": 45 data = [0, 5, 6, 4, 8, 3, 2, 7, 1] 46 print("原始数组为:%s" % data) 47 # 进行堆排序 48 heap_sort(data) 49 print("排序结果为:%s" % data) 50
执行结果:
原始数组为:[0, 5, 6, 4, 8, 3, 2, 7, 1] 原始数据建堆结果:[0, 8, 6, 7, 5, 3, 2, 4, 1] 处理过程为:[0, 7, 6, 4, 5, 3, 2, 1, 8] 处理过程为:[0, 6, 5, 4, 1, 3, 2, 7, 8] 处理过程为:[0, 5, 3, 4, 1, 2, 6, 7, 8] 处理过程为:[0, 4, 3, 2, 1, 5, 6, 7, 8] 处理过程为:[0, 3, 1, 2, 4, 5, 6, 7, 8] 处理过程为:[0, 2, 1, 3, 4, 5, 6, 7, 8] 处理过程为:[0, 1, 2, 3, 4, 5, 6, 7, 8] 排序结果为:[0, 1, 2, 3, 4, 5, 6, 7, 8]
堆排序的性能分析
堆排序是选择排序的改进版
选择排序的原理不难理解,每次从未排序部份找出最小值,插入已排序部份的后端,其时间主要花费于在整个未排序部份寻找最小值。如果能让搜寻最小值的方式加快,那么选择排序的效率也就可以加快。
而堆排序法其实在重建堆的过程中,就是一个选择的行为,(最小堆)每次将最小值选至树根,而选择的路径并不是所有的元素,而是由树根至树叶的路径,因而可以加快选择的过程,所以称之为改良的选择排序法。
- 建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以堆排序整体的时间复杂度是 O(nlogn)。
- 整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。
- 堆排序不是稳定的排序算法,因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。
在实际开发中,为什么快速排序要比堆排序性能好?
- 堆排序数据访问的方式没有快速排序友好。对于快速排序来说,数据是顺序访问的;而对于堆排序来说,数据是跳着访问的。
- 对于同样的数据,在排序过程中,堆排序的数据交换次数要多于快速排序。
堆的应用
优先级队列
优先队列的概念和堆的特性十分契合,往优先队列中插入一个元素就相当于往堆中插入一个元素;从优先队列中取出优先级最高的元素,就相当于取出堆顶元素。
问题
假设我们有 100 个小文件,每个文件中存储的都是有序的字符串。我们希望将这些 100 个小文件合并成一个有序的大文件。
方案
整体思路有点像归并排序中的合并函数。我们从这 100 个文件中,各取第一个字符串,放入数组中,然后比较大小,把最小的那个字符串放入合并后的大文件中,并从数组中删除。时间复杂度为 O(100n)。
优化
- 每次将从小文件中取出来的字符串放入到大小为 100 的小顶堆中,那堆顶的元素,也就是优先级队列队首的元素,即最小的字符串。
- 之后将这个字符串放入到大文件中,并将其从堆中删除。
- 然后再从对应小文件中取出下一个字符串,放入到堆中。
- 循环这个过程,就可以将 100 个小文件中的数据依次放入到大文件中。时间复杂度为 O(log100n)。
高性能定时器
问题
假设我们有一个定时器,定时器中维护了很多定时任务,每个任务都设定了一个要触发执行的时间点。定时器每过一个很小的单位时间(比如 1 秒),就扫描一遍任务,看是否有任务到达设定的执行时间。如果到达了,就拿出来执行。
但是,这样每过 1 秒就扫描一遍任务列表的做法比较低效,主要原因有两点:第一,任务的约定执行时间离当前时间可能还有很久,这样前面很多次扫描其实都是徒劳的;第二,每次都要扫描整个任务列表,如果任务列表很大的话,势必会比较耗时。
方案
我们按照任务设定的执行时间,将这些任务存储在优先级队列中,队列首部(也就是小顶堆的堆顶)存储的是最先执行的任务。这样,定时器就不需要每隔 1 秒就扫描一遍任务列表了,每次拿队首任务的执行时间点,与当前时间点相减,直接得到下一个任务的执行时间间隔。
求 Top K
问题
求 Top K 的问题可以分为两类:
- 一类是针对静态数据集合,也就是说数据集合事先确定,不会再变。
- 另一类是针对动态数据集合,也就是说数据集合事先并不确定,有数据会动态地加入到集合中。
方案
- 针对静态数据:如何在一个包含 n 个数据的数组中,查找前 K 大数据呢?我们可以维护一个大小为 K 的最小堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。这样等数组中的数据都遍历完之后,堆中的数据就是前 K 大数据了。遍历数组需要 O(n) 的时间复杂度,一次堆化操作需要 O(logK) 的时间复杂度,所以最坏情况下,n 个元素都入堆一次,时间复杂度就是 O(nlogK)。
- 针对动态数据:每当有数据要插入时先与堆顶元素比较,若大于堆顶元素则删除堆顶元素并且将这个数据插入堆中;如果小于堆顶元素小则不做处理。这样无论何时需要查询当前 K 大元素,都能立即返回。
求动态数据集合中的 n% 分位数
以求 50% 分位数,即中位数为例,假设当前总数据的个数是 n,方案如下:
-
维护一个大顶堆存储前半部分数据,一个小顶堆存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。
-
偶数情况下 n/2 个存大顶堆,n/2 存小顶堆;奇数情况下 n/2+1 个存大顶堆,n/2 个存小顶堆。
-
如果新加入数据小于等于大顶堆堆顶元素,那么将其加入大顶堆;否则加入小顶堆。
-
数据加入后可能出现两边数据与约定数量不符的情况,此时可以将数据互相移动,将数量满足约定。
若是求 n% 分位数,则在大顶堆中保存 n * 99% 个数据,小顶堆中保存 n * 1% 个数据。
5. 平衡二叉树(AVL)
二叉查找树存在的问题
二叉查找树是最常用的一种二叉树,它支持快速插入、删除、查找操作,各个操作的时间复杂度跟树的高度成正比,最佳时间复杂度是 O(logn)。
不过,由于二叉排序树本身为有序,当插入一个有序程度十分高的序列时,生成的二叉排序树会持续在某个方向的字数上插入数据,导致最终的二叉排序树会退化为链表,从而使得二叉树的查询和插入效率恶化。时间复杂度会退化到 O(n)。
因此一般的二叉查找树不适用于数据经常变动(加入或删除)的情况。而是比较适合不会变动的数据,例如编程语言中的“保留字”等。
为了能够尽量降低查找所需要的时间,快速找到所要的键值,或者很快地知道当前的树中没有我们要的键值,必须让树的高度越小越好。要解决这个时间复杂度退化的问题,我们需要设计一种平衡二叉查找树。
平衡二叉树的概念
平衡二叉树(Balanced Binary Tree)又称 AVL 树(由 Adelse-Velskil 和 Landis 两个人发明),本身也是一棵二叉查找树,其产生是为了解决二叉排序树在插入时发生线性排列的现象。
平衡二叉树的严格定义是这样的:
- 满足二叉查找树的性质,左子树所有值小于父节点,右子树所有值大于等于父节点。
- 作为一棵平衡二叉树,它需要满足任意一个节点的左右子树的高度相差不能大于 1。
在平衡二叉树中,每次在插入数据和删除数据后,必要时就会对二叉树做一些高度的调整(左旋和右旋),来让二叉查找树的高度随时维持平衡,将查找、插入、删除操作的时间复杂度保证在 O(logn) 范围内。通常只有从那些插入点到根节点的路径上的节点的平衡性可能被改变,因为只有这些节点的子树可能变化。
平衡二叉树适用于动态数据,这就完成了哈希表不便完成的工作——动态性。所以:
- 如果输入集合确定,所需要的就是查询,则可以考虑使用哈希表。
- 如果输入集合不确定,则考虑使用平衡二叉树/红黑树,保证达到最大效率。
平衡二叉树主要优点集中在快速查找,频繁旋转会使插入和删除牺牲掉 O(logn) 左右的时间,不过相对二叉查找树来说,时间上稳定了很多。
平衡二叉树的高度调整方式
高度调整的关键是找出“不平衡点”,主要的四种调整方式有 LL(左旋)、RR(右旋)、LR(先左旋再右旋)、RL(先右旋再左旋)。这里介绍下简单的单旋转操作,左旋和右旋。LR 和 RL 本质上只是 LL 和 RR 的组合。
(平衡因子 = 左子树高度 - 右子树高度)
左旋的操作可以用一句话简单表示:将当前节点 S 的左孩子旋转为当前结点父节点 E 的右孩子,同时将父节点 E 旋转为当前节点 S 的左孩子。可用动画表示:
6. 红黑树
平衡二叉树(AVL)为了追求高度平衡,需要通过平衡处理使得左右子树的高度差必须小于等于 1。高度平衡带来的好处是能够提供更高的搜索效率,其最坏的查找时间复杂度都是 O(logn)。但是由于需要维持这份高度平衡,所付出的代价就是当对树种结点进行插入和删除时,需要经过多次旋转实现复衡。这导致 AVL 的插入和删除效率并不高。
为了解决这样的问题,能不能找一种结构能够兼顾搜索和插入、删除的效率呢?这时候红黑树便申请出战了。
红黑树具有五个特性:
-
每个节点要么是红的,要么是黑的。
-
根节点是黑的。
-
每个叶子节点(叶子节点即树尾端 NIL 指针或 NULL 节点)都是黑的。
-
如果一个节点是红的,那么它的两个子节点都是黑的。
-
对于任意节点而言,其到叶子节点树尾端 NIL 指针的每条路径都包含相同数目的黑节点。
红黑树通过将节点进行红黑着色,使得原本高度平衡的树结构被稍微打乱,平衡程度降低。红黑树不追求完全平衡,只要求达到部分平衡。这是一种折中的方案,大大提高了节点删除和插入的效率。C++ 中的 STL 就常用到红黑树作为底层的数据结构。
红黑树 VS 平衡二叉树
7. B 树和 B+ 树
数据库索引大量使用到了 B 树和 B+ 树,索引一般需要解决下面两个问题:
- 根据某个值查找数据,比如 select * from user where id=1234;
- 根据区间值来查找某些数据,比如 select * from user where id > 1234 and id < 2345。
尝试用数据结构解决这个问题:
- 散列表的查询性能很好,时间复杂度是 O(1)。但是,散列表不能支持按照区间快速查找数据。
- 平衡二叉查找树尽管查询的性能也很高,时间复杂度是 O(logn),而且对树进行中序遍历时我们还可以得到一个从小到大有序的数据序列,但这仍然不足以支持按照区间快速查找数据。
- 跳表是在链表之上加上多层索引构成的。它支持快速地插入、查找、删除数据,对应的时间复杂度是 O(logn)。并且跳表也支持按照区间快速地查找数据。
这样看来,跳表是可以解决这个问题。实际上,数据库索引所用到的数据结构跟跳表非常相似,叫作 B+ 树。不过,它是通过二叉查找树演化过来的,而非跳表。
1)B+ 树
B+ 树的思想
为了让二叉查找树支持按照区间来查找数据,我们可以对它进行这样的改造:树中的节点并不存储数据本身,而是只是作为索引。除此之外,我们把每个叶子节点串在一条链表上,链表中的数据是从小到大有序的。经过改造之后的二叉树,就像图中这样,看起来是不是很像跳表呢?但是,当我们要为几千万、上亿的数据构建索引时,即使将索引存储在内存中,内存访问的速度非常快,查询的效率非常高,但同时,占用的内存也会非常大,所以借助时间换空间的思路,把索引存储在硬盘中,而非内存中。
我们知道,硬盘是一个非常慢速的存储设备。通常内存的访问速度是纳秒级别的,而磁盘访问的速度是毫秒级别的。这种将索引存储在硬盘中的方案,尽管减少了内存消耗,但是在数据查找的过程中,需要读取磁盘中的索引,因此数据查询效率就相应降低很多。
综上,索引查找的效率取决于磁盘 I/O 的次数;而磁盘 I/O 的次数取决于树的高度;那如何降低树的高度呢?如果我们把索引构建成 m 叉树,高度是不是比二叉树要小呢?
不管是内存中的数据,还是磁盘中的数据,操作系统都是按页(一页大小通常是 4KB,这个值可以通过 getconfig PAGE_SIZE 命令查看)来读取的,一次会读一页的数据。如果要读取的数据量超过一页的大小,就会触发多次 I/O 操作。所以,我们在选择 m 大小的时候,要尽量让每个节点的大小等于一个页的大小。这样,读取一个节点只需要一次磁盘 I/O 操作。
B+ 树的性质
- m 叉树只存储索引,并不真正存储数据,这个有点儿类似跳表。
- 通过链表将叶子节点串联在一起,这样可以方便按区间查找。
- 一般情况,根节点会被存储在内存中,其他节点存储在磁盘中。
- 根节点中子节点的个数不能超过 m。
- 除根节点外每个节点中子节点的个数不能超过 m,也不能小于 m/2(向上取整,包括 m/2)。
- 除叶子节点外,每个节点的关键字个数 = 子节点个数 - 1。
2)B 树
定义
B-tree 全称 Balance-tree(平衡多路查找树),平衡的意思是左边和右边分布均匀。多路的意思是相对于二叉树而言的,二叉树就是二路查找树,查找时只有两条路,而 B-tree 有多条路,即父节点有多个子节点。
而 B 树实际上是低级版的 B+ 树,或者说 B+ 树是 B 树的改进版。B 树跟 B+ 树的不同点主要集中在这几个地方:
- B+ 树中的节点不存储数据,只存储索引;而 B 树中的节点存储数据。
- B 树中的叶子节点并不需要链表来串联。
- 也就是说,B 树只是一个每个节点的子节点个数不能小于 m/2 的 m 叉树。
m 阶 B 树的性质
- 阶数表示了一个节点最多有多少个子节点,一般用字母 m 表示阶数;
- 根节点中子节点的个数不能超过 m;
- 除根节点外每个节点中子节点的个数不能超过 m,也不能小于 m/2(向上取整,包括 m/2);
- 除叶子节点外,每个节点的关键字个数 = 子节点个数 - 1。