Bootstrap

数据结构与算法04二叉树|二叉排序树|AVL树

目录

一、二叉树(binary tree)

1、二叉树常见术语

2、二叉树常用的操作

2.1、初始化:与链表十分相似,先创建节点,然后构造引用/指针关系.

2.2、插入和删除操作

 3、常见二叉树类型

3.1、满二叉树

 3.2、完全二叉树(complete binary tree)

3.3、完满二叉树(full binary tree)

 3.4平衡二叉树

 4、二叉树的退化

 5、二叉树的遍历

5.1层序遍历

5.1.1代码实现

5.2前序遍历

5.3中序遍历

5.4后序遍历

6、二叉树的数组表示

6.1用数组表示完美二叉树

6.2表示任意的二叉树

6.3代码

二、二叉搜索树

 二叉搜索树的操作

1、查找节点

2、插入结点

3、删除节点

中序遍历有序

二叉搜索树的效率问题 

三、AVL树

 1、AVL(Adelson-Velskii 和Landis)树的常见术语

1.1节点高度

1.2节点平衡因子

2、AVL树旋转

2.1右旋

第一种情况,当节点child无右子节点(记为grand_child)时,

 第二种情况,当child节点有右子节点时,

2.2左旋操作

第一种情况,不平衡节点的child节点无左子节点

第二种情况,child节点有左子节点时,

 3、先左旋后右旋

4、先右旋后左旋

 5、旋转的选择

 6、AVL树的常见操作

6.1插入结点

6.2删除节点


在这里,主要介绍一下二叉树相关的知识。

一、二叉树(binary tree)

二叉树是一种非线性数据结构,代表祖先与后代之间的派生关系,主要体现了一分为二的分治思想。与链表类似,二叉树的基本单元是节点,每个节点包含值、左、右子节点引用。

class TreeNode:
    """二叉树节点类"""
    def __init__(self,val:int):
        self.val:int = val
        self.left:TreeNode|None = None
        self.right:TreeNode|None = None

每个节点都对应有两个引用,分别指向其左子节点(left-child node)右子节点(right-child node),该节点就称为这两个子节点的父节点

在二叉树中,除了叶节点以外,其他所有节点都包含子节点和非空子树。如图1所示,若节点1为父节点,则节点2和3分别对应为节点1的左子节点和右子节点,又分别以节点2和节点3为根节点构成左右子树。

图1

1、二叉树常见术语

  • 根节点(root node):位于二叉树顶层的节点,没有父节点。
  • 叶节点(leaf node):没有子节点的节点,其两个指针均指向 None 。
  • 边(edge):连接两个节点的线段,即节点引用(指针)。
  • 节点所在的层(level):从顶至底递增,根节点所在层为 1 。
  • 节点的度(degree):节点的子节点的数量。在二叉树中,度的取值范围是 0、1、2 。
  • 二叉树的高度(height):从根节点到最远叶节点所经过的边的数量。
  • 节点的深度(depth):从根节点到该节点所经过的边的数量。
  • 节点的高度(height):从距离该节点最远的叶节点到该节点所经过的边的数量。
图2

 注意,通常来讲,高度和宽度都是指从根节点开始到目标节点经过的边的个数,也有其他教材把高度和深度定义为讲过的节点的个数,那样的话,对应的深度和高度都需要加1操作.

2、二叉树常用的操作

2.1、初始化:与链表十分相似,先创建节点,然后构造引用/指针关系.
n1 = TreeNode(1)
n2 = TreeNode(2)
n3 = TreeNode(3)
n4 = TreeNode(4)
n5 = TreeNode(5)

n1.left = n2
n1.right = n3
n2.left = n4
n2.right = n5
2.2、插入和删除操作

与链表类似,在二叉树中插入与删除节点可以通过修改指针来实现,具体如图所示.

# 插入操作
p = TreeNode(0)
n1.left = p
p.left = n2

# 删除操作
n1.left = n2
图3

 3、常见二叉树类型

3.1、满二叉树

又称为完美二叉树,所有层的节点都被完全填满.叶节点的度为0,其余节点的度均为2;若树的高度为h,则节点总数为2^{h+1}-1,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象.

 3.2、完全二叉树(complete binary tree)

完全二叉树只有最底层的节点未被填满,且最底层的节点尽量靠左填充.

3.3、完满二叉树(full binary tree)

 完满二叉树除了叶节点以外,其余所有节点都有两个子节点.

 3.4平衡二叉树

平衡二叉树(balanced binary tree)中任意节点的左子树和右子树的高度之差的绝对值不超过 1 .

 4、二叉树的退化

当二叉树的每层节点都被填满时,达到完美二叉树;而当所有节点都偏向一侧时,二叉树退化为链表.

  • 完美二叉树是理想情况,可以充分发挥二叉树“分治”的优势。
  • 链表则是另一个极端,各项操作都变为线性操作,时间复杂度退化至 O(n).

 5、二叉树的遍历

5.1层序遍历

层序遍历(level-order traversal)指的是从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的顺序访问节点.层序遍历本质上是属于广度优先遍历(breadth-first traversal),也成为广度优先搜索(breadth-first search,BFS),它体现了是一圈一圈向外扩展.

5.1.1代码实现

广度优先遍历通常借助duilie来实现,队列遵循先进先出的规则,而广度优先遍历则遵循逐层推进的规则,两者背后的思想是一致的,故实现代码如下:

from collections import deque
def level_order(root:TreeNode|None)->list[int]:
    """层序遍历"""
    queue:deque[TreeNode] = deque()
    queue.append(root)
    result = []
    while queue:
        node = queue.popleft()
        result.append(node.val)
        if node.left:
            queue.append(node.left)
        if node.right:
            queue.append(node.right)
    return result # [1, 2, 3, 4, 5]
5.2前序遍历

相应地,前、中、后序遍历均属于深度优先遍历(depth-first traversal),也称为深度优先搜索(depth-first search,DFS),它体现了一种先走到尽头,再回溯继续的遍历方式.

递则是向下递推;归则是向上回溯.

result = []
def pre_order(root:TreeNode|None)->list[int]|None:
    """先序遍历:根->左->右"""
    if root is None:
        return
    result.append(root.val)
    pre_order(root.left)
    pre_order(root.right)
    return result # [1, 2, 4, 5, 3]
5.3中序遍历
result1 = []
def in_order(root:TreeNode|None)->list[int]|None:
    """中序遍历:左->中->右"""
    if root is None:
        return
    in_order(root.left)
    result1.append(root.val)
    in_order(root.right)
    return result1 # [4, 2, 5, 1, 3]
5.4后序遍历
result2 = []
def post_order(root:TreeNode|None)->list[int]|None:
    """后序遍历:左->右->根"""
    if root is None:
        return
    post_order(root.left)
    post_order(root.right)
    result2.append(root.val)
    return result2 # [4, 5, 2, 3, 1]

6、二叉树的数组表示

在链表表示下,二叉树的存储单元为节点TreeNode且节点之间通过指针相连接。

6.1用数组表示完美二叉树

给定一颗完美二叉树,将所有结点按照层序遍历的顺序存储在一个数组中,则每个节点都对应有一个唯一的数组索引,根据层序遍历的特点,可以推导出父节点和子结点之间的映射公式;若某节点的索引为i,则该节点的左子节点对应的索引为2*i + 1,右子节点对应的索引为2*i + 2。

图6.1

 如图所示,若这样存储给定任意一个节点,都能通过映射公式找到其对应的左子节点和右子节点。

6.2表示任意的二叉树

完美二叉树/完全二叉树是一个特例,在正常的二叉树中,中间层往往会有许多None,但由于层序遍历并不包含这些None,因此无法仅凭序列来推测None的数量和分布位置。这也就意味着存在多种二叉树结构都符合该层的遍历序列。

图6.2.1

为了解决此问题,可以考虑在层序遍历序列中显示的写出所有的None。如图所示,经这样处理后,层序遍历就能唯一的表示一颗二叉树了。

图6.2.2

 对于完全二叉树,None节点一定都是位于最后一层的最右边的位置,即None一定会出现在层序遍历的末尾,因此在使用数组表示完全二叉树时可以省略所有的None,这样就会非常方便。

图6.2.3

 即如图6.2.2中的树用数组可以表示为tree = [1, 2, 3, 4, None, 5, 6, 7, 8, None, None, 12, None, None, 14],图6.2.3中的树用数组就可以表示为tree = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],后面的所有NOne就可以不写。

6.3代码
class ArrayBinaryTree:
    """数组表示下的二叉树类"""
    def __init__(self,arr:list[int|None]):
        self._tree = list(arr)

    def size(self):
        return len(self._tree)

    def search(self,i:int)-> int|None:
        """查找指定索引对应的二叉树结点的值"""
        if i < 0 or i >= self.size():
            return None
        return self._tree[i]

    def left(self,i:int)->int|None:
        """返回对应的节点i的左子节点"""
        return 2 * i + 1

    def right(self,i:int)->int|None:
        """返回对应节点i的右子节点"""
        return 2 * i + 2

    def parent(self,i:int)->int|None:
        """返回对应节点为i的父节点的索引"""
        return i // 2 # 稍微有点疑问

    def level_order(self)->list[int]:
        """层序遍历"""
        self.res = []
        for i in range(self.size()):
            if self.search(i) is not None:
                self.res.append(self.search(i))
        return self.res

    def dfs(self,i:int,order:str):
        """深度优先遍历"""
        if self.search(i) is None:
            return
        if order == "pre":
            self.res.append(self.search(i))
        self.dfs(self.left(i),order)

        if order == "in":
            self.res.append(self.search(i))
        self.dfs(self.right(i),order)

        if order == "post":
            self.res.append(self.search(i))

    def pre_order(self)->list[int]:
        """前序遍历"""
        self.res = []
        self.dfs(0,order="pre")
        return self.res

    def in_order(self)->list[int]:
        self.res = []
        self.dfs(0,order="in")
        return self.res

    def post_order(self)->list[int]:
        self.res = []
        self.dfs(0,order="post")
        return self.res

if __name__ == "__main__":
    arr = [1,2,3,4,None,6,None]
    bt = ArrayBinaryTree(arr)
    a = bt.pre_order()
    b = bt.in_order()
    c = bt.post_order()
    print(f"前序遍历为{a}\n中序遍历为{b}\n后序遍历为{c}")
    # 前序遍历为[1, 2, 4, 3, 6]
    # 中序遍历为[4, 2, 1, 6, 3]
    # 后序遍历为[4, 2, 6, 3, 1]

二、二叉搜索树

二叉搜索树满足以下两个条件,

  • 1、对于根节点而言,左子树中所有节点的值<根节点的值<右子树中所有结点的值。
  • 2、任意的左右子树均为二叉排序树,同样满足条件1。

 二叉搜索树的操作

将二叉搜索树封装为一个类BinarySearchTree,并且声明一个成员变量root,指向树的根节点。

​
class TreeNode:
    """二叉树结点类"""
    def __init__(self,val:int):
        self.left:TreeNode|None = None
        self.right:TreeNode|None = None
        self.val = val
class BinarySearchTree:
    """二叉搜索树"""
    def __init__(self):
        # 初始化空树
        self._root = None

​
1、查找节点

与二分查找算法的工作原理相一致,都是每轮排除一般的情况,循环次数最多为二叉树的高度。

    def search(self,num:int)->TreeNode|None:
        """查找方法
        设置一个变量cur用来记录当前遍历到的每个节点,
        从二叉排序树的根节点出发循环比较当前节点值与查找的目标节点num之间的大小关系
        1、若num比当前节点cur.val小,则遍历左子树
        2、若num比当前节点cur.val大,则遍历右子树
        3、若num == cur,val,则说明找到目标节点,跳出循环并返回该节点。
        """
        cur = self._root
        while cur is not None:
            if num < cur.val:
                cur = cur.left
            elif num > cur.val:
                cur = cur.right
            else:
                break
        return cur
2、插入结点

    给定一个待插入的元素num,插入流程应该是按照先找到待插入位置(遍历至None的位置),然后将num元素封装为节点node,将该节点置于None的位置即可插入成功。

注意,由于二叉搜索树的定义,故二叉搜索树中不允许存在相同值即重复的节点,故在插入之前应该先循环遍历判断该元素是否已在二叉搜索树中,使用技巧pre是为了寻找到None节点的父节点,这样便可直接使用pre节点操作。

 def insert(self,num:int):
        """插入节点"""
        if self._root is None:
            self._root = TreeNode(num)
            return
        cur,pre = self._root,None
        while cur is not None:
            # 找到重复的节点直接返回
            if num == cur.val:
                return
            pre = cur
            if num < cur.val:
                cur = cur.left
            elif num > cur.val:
                cur = cur.right
        # 插入结点
        node = TreeNode(num)
        if num < pre.val:
            pre.left = node
        else:
            pre.right = node
3、删除节点

     与插入结点一样的是,删除某节点之后还需要继续保持二叉排序树的基本定义(left<root<right),但删除结点与插入结点不同的是,删除结点可以分为三种情况,下面分别对这三种情况进行讨论,

第一种,待删除的节点的度为0,也就是说该节点为叶子节点,则可以直接删除,因为即使删除叶子节点也不会破坏到二叉排序树的基本定义,

第二种,若带删除的节点的度为1,那么直接将叶子节点替换为该节点即可,原因就是 待删除节点为2,以元素2为根节点的树为以4为根节点的左子树,所以4对应的所有左子树的结点的值均小于4,故直接将3替换为4的左子节点即可删除元素2成功。

第三种, 也是最为复杂的一种,若待删除的节点的度为2,那么无法直接删除它,由于删除之后仍需要保持二叉排序树原本的性质,故需要用新的节点替换掉待删除结点,那么我们既可以用左子树的最大节点或右子树的最小节点来替换。

    def remove(self,num:int):
        """删除节点"""
        # 若树为空则直接结束
        if self._root is None:
            return
        cur,pre = self._root,None
        # 1、先找到待删除的节点
        while cur is not None:
            if num == cur.val:
                break
            pre = cur
            if num < cur.val:
                cur = cur.left
            else:
                cur = cur.right
        if cur is None:
            return
        # 2、判断待删除节点的分支个数
        if cur.left is None or cur.right is None:
            child = cur.left or cur.right

            # 删除节点
            if cur != self._root:
                if pre.left == cur:
                    pre.left = child
                else:
                    pre.right = child
            else:
                self._root = child
        else:
            # 这段是通过找当前节点的右子树中最小的元素代码
            tmp:TreeNode = cur.right
            while tmp.left is not None:
                tmp = tmp.left
            self.remove(tmp.val)
            cur.val = tmp.val
中序遍历有序

     二叉树的中序遍历遵循左->根->右的遍历顺序,而二叉搜索树又满足左子节点<根节点<右子节点的值的关系,这也就意味着在对二叉搜索树进行中序遍历时总会优先遍历到下一个最小的节点,故二叉搜索树的中序遍历是升序的。且使用二叉搜索树获取有序的数据仅需要O(n)时间,无需额外排序操作,非常高效。

二叉搜索树的效率问题 

     给定一组数据,我们考虑使用数组或二叉搜索树存储。观察表 7-2 ,二叉搜索树的各项操作的时间复杂度都是对数阶,具有稳定且高效的性能。只有在高频添加、低频查找删除数据的场景下,数组比二叉搜索树的效率更高。在理想情况下,二叉搜索树是“平衡”的,这样就可以在 log⁡ n轮循环内查找任意节点。然而,如果我们在二叉搜索树中不断地插入和删除节点,可能导致二叉树退化为图 7-23 所示的链表,这时各种操作的时间复杂度也会退化为 O(n) 。

   

三、AVL树

下面先简单介绍一下,为什么要引入AVL树,

第一方面,在二叉搜索树章节中提到过,在多次插入和删除操作之后,二叉搜索树可能退化为链表。在这种情况下,所有操作的时间复杂度将从O(logn)劣化为O(n)。

 第二方面,在满二叉树中连续插入两个节点之后,可能会导致树严重向左倾斜,那么查找的时间复杂度也会随之恶化。

故在引入AVL之后,确保在持续增加和删除结点之后,AVL不会退化,从而使得各种操作的时间复杂度都会保持在O(log n)级别。

 1、AVL(Adelson-Velskii 和Landis)树的常见术语

    AVL树既是二叉搜索树,又是平衡二叉树,同时满足这两类二叉树的所有性质,因此是一种平衡二叉搜索树(balanced binary search tree)。   一颗AVL树是其每一个节点的左子树和右子树的高度最多差1的二叉查找树/排序树。

1.1节点高度

    由于AVL树的相关操作需要获取节点高度,因此我们为节点类添加height变量,需要获取它的值,并且要有更新AVL树高度的函数。

“节点的高度”指的是从该节点到距离它最远的叶子节点的边的数量。需要特殊指明的是,叶节点的高度为0,而空节点的高度为-1。

1.2节点平衡因子

    节点的平衡因子(balance factor)被定义为左子树的高度减去右子树的高度,同时规定空节点的平衡因子为0,若平衡因子为f,则有-1=<f<=1,获取平衡因子的功能封装为函数后,如下,

2、AVL树旋转

    AVL树的特点就在于其旋转操作,它能够在不影响二叉树的中序遍历的前提下,使得平衡节点重新恢复平衡。换句话说,旋转操作既能保持二叉搜索树的性质,也能使得树重新变为平衡二叉树。

若节点的平衡因子的绝对值|f|大于1,则称该节点为失衡节点。根据节点失衡情况的不同,旋转操作可以分为四种:右旋、左旋、先右旋后左旋、先左旋后右旋。

在这里,给出AVL树的节点类,获取/更新树的高度,平衡因子的方法。

class TreeNode:
    def __init__(self,val:int):
        self.val = val
        self.height:int = 0
        self.left:TreeNode|None = None
        self.right:TreeNode|None = None

def height(self,node:TreeNode|None)->int:
    """获取节点的高度"""
    if node is not None:
        return node.height
    return -1

def update_height(self,node:TreeNode|None):
    """更新节点高度"""
    # 结点的高度等于最高的子树高度加1
    node.height = max([self.height(node.left),self.height(node.right)]) + 1

def balance_factor(self,node:TreeNode|None)->int:
    if node is None:
        return 0
    # balance factor = 左子树高度 - 右子树高度
    return self.height(node.left) - self.height(node.right)
2.1右旋

如下图中所示,可以确定的是它是一棵二叉排序树,但并不能满足AVL的另一个条件,计算出各个节点的平衡因子之后,可以发现,只有值为3和4的节点不能满足平衡条件,从底向上看,二叉排序树中的首个失衡节点为3,因为值为4的节点虽然也为失衡节点,但是,它的不平衡归根结底也是由它的子树所决定的。所以,我们关注以节点3为根节点的子树,将该节点记为node,其左子节点记为child,子树右旋后达到平衡,并仍保持二叉排序树性质。

第一种情况,当节点child无右子节点时3为node节点,1为child节点,
右旋前
右旋后

 第二种情况,当child节点有右子节点时记为grand_child,

这种情况下,如图所示,只有值为5的节点失衡,这也仍然满足一颗二叉排序树的性质,故这种情况下该如何调整呢?

整体也是需要先右旋,然后把grand_child节点设置为原本node节点的左子节点即可。因为本来child子树就是node节点的左子树,其上面对应的所有节点肯定都是小于node节点对应的元素的值,所以在右旋把node节点所连接的节点转化为右子树之后,grand_child一定能成为node的左子节点。

总结一下,,向右旋转是一种形象化的说法,实际上也是需要修改节点的指针来实现,代码如下: 

def right_rotate(self,node:TreeNode|None)->TreeNode|None:
    """右旋操作"""
    child = node.left
    grand_child = child.right
    # 以child为原点,将node向右旋转
    child.right = node
    node.left = grand_child
    # 更新节点高度
    self.update_height(node)
    self.update_height(child)
    # 返回旋转后子树的根节点
    return child
2.2左旋操作
第一种情况,不平衡节点的child节点无左子节点

如图所示,从底向上看,首个发生不平衡的节点是4,进而导致节点1也不平衡,满足二叉排序树的性质。由于原本就是二叉排序树,故位于根节点1右子树上的节点值肯定是递增的,故只需要把4节点左旋,让节点5成为以1为根节点的右子树的根节点即可,这样既是二叉排序树,又保持住了平衡。

第二种情况,child节点有左子节点时,

 如图所示,满足二叉排序树但不平衡的二叉树的失衡节点为值为1的节点。当child节点有左子节点时(记为grand_child),只需要在左旋中添加一步,将grand_child作为node的右子节点即可,和右旋,child节点有右子树是一个道理的。

可以观察到,右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的。基于对称性,我们只需将右旋的实现代码中的所有的 left 替换为 right ,将所有的 right 替换为 left ,即可得到左旋的实现代码:

def left_rotate(self,node:TreeNode|None)->TreeNode|None:
    """左旋操作"""
    child = node.right
    grand_child = child.left
    # 以child为原点,将node向左旋转
    child.left = node
    node.right = grand_child
    # 更新节点高度
    self.update_height(node)
    self.update_height(child)
    # 返回旋转后子树的根节点
    return child
 3、先左旋后右旋

像这种情况,仅旋转一次(左旋或右旋)是不够的,都无法使得子树平衡,需要先对child执行左转,再对node执行右转即可平衡。

4、先右旋后左旋

像这种情况,则是需要先对child右旋,再对node执行左旋即可保持平衡。

 5、旋转的选择

 下面,我们通过判断失衡节点的平衡因子balance factor以及较高一侧子节点的平衡因子的正负号,来确定失衡节点究竟是属于下面图中的哪种情况。

def rotate(self,node:TreeNode|None)->TreeNode|None:
    """执行旋转操作,使得子树重新平衡"""
    balance_factor = self.balance_factor(node)
    # 左偏树
    if balance_factor > 1:
        if self.balance_factor(node.left) >= 0:
            # 右旋
            return self.right_rotate(node)
        else:
            # 先左旋后右旋
            node.left = self.left_rotate(node.left)
            return self.right_rotate(node)
    # 右偏树
    elif balance_factor < -1:
        if self.balance_factor(node.right) <= 0:
            # 左旋
            return self.left_rotate(node)
        else:
            # 先右旋后左旋
            node.right = self.right_rotate(node.right)
            return self.left_rotate(node)
    # 否则的话,平衡因子为1,则为平衡树,无需旋转
    return node
 6、AVL树的常见操作
6.1插入结点

AVL树的节点插入操作与二叉排序/搜索树在主体上类似,但区别是,按照二叉排序树插入结点之后从该节点到根节点的路径上可能会出现一些列失衡的节点,因此,我们需要从这个节点开始,自底向上执行旋转操作,使得所有失衡节点都能够恢复平衡,

def insert(self, val):
    """插入节点"""
    self._root = self.insert_helper(self._root, val)

def insert_helper(self, node: TreeNode | None, val: int) -> TreeNode:
    """递归插入节点(辅助方法)"""
    if node is None:
        return TreeNode(val)
    # 1. 查找插入位置并插入节点
    if val < node.val:
        node.left = self.insert_helper(node.left, val)
    elif val > node.val:
        node.right = self.insert_helper(node.right, val)
    else:
        # 重复节点不插入,直接返回
        return node
    # 更新节点高度
    self.update_height(node)
    # 2. 执行旋转操作,使该子树重新恢复平衡
    return self.rotate(node)
6.2删除节点

同样地,删除节点也是基于二叉排序树的基础上,需要从底至顶执行旋转操作,使得左右失衡的节点恢复平衡。

def remove(self, val: int):
    """删除节点"""
    self._root = self.remove_helper(self._root, val)

def remove_helper(self, node: TreeNode | None, val: int) -> TreeNode | None:
    """递归删除节点(辅助方法)"""
    if node is None:
        return None
    # 1. 查找节点并删除
    if val < node.val:
        node.left = self.remove_helper(node.left, val)
    elif val > node.val:
        node.right = self.remove_helper(node.right, val)
    else:
        if node.left is None or node.right is None:
            child = node.left or node.right
            # 子节点数量 = 0 ,直接删除 node 并返回
            if child is None:
                return None
            # 子节点数量 = 1 ,直接删除 node
            else:
                node = child
        else:
            # 子节点数量 = 2 ,则将中序遍历的下个节点删除,并用该节点替换当前节点
            temp = node.right
            while temp.left is not None:
                temp = temp.left
            node.right = self.remove_helper(node.right, temp.val)
            node.val = temp.val
    # 更新节点高度
    self.update_height(node)
    # 2. 执行旋转操作,使该子树重新恢复平衡
    return self.rotate(node)

;