数据结构和算法(四):二叉树、红黑树、递归树、堆和堆排序、堆的应用

从广义上来讲:数据结构就是一组数据的存储结构 , 算法就是操作数据的方法

数据结构是为算法服务的,算法是要作用在特定的数据结构上的。

10个最常用的数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie树

10个最常用的算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法

本文总结了20个最常用的数据结构和算法,不管是应付面试还是工作需要,只要集中精力攻克这20个知识点就足够了。

数据结构和算法(一):复杂度、数组、链表、栈、队列的传送门

数据结构和算法(二):递归、排序、通用排序算法的传送门

数据结构和算法(三):二分查找、跳表、散列表、哈希算法的传送门

数据结构和算法(四):二叉树、红黑树、递归树、堆和堆排序、堆的应用的传送门

数据结构和算法(五):图、深度优先搜索和广度优先搜索、字符串匹配算法、Trie树、AC自动机的传送门

数据结构和算法(六):贪心算法、分治算法、回溯算法、动态规划、拓扑排序的传送门

第十六章 二叉树

一、什么是树?
    1. 树是由结点和边组成的,不存在任何环的一种数据结构。树是一种非线性表结构,比线性表结构要更加复杂。通过下图,我们就可以更直观的认识树。
树的定义.png
    1. 在一棵树中,由于节点之间关系、特征不同,所以对不同节点也有不同的称呼,比如下面这颗树,E节点是A节点和F节点的父节点;A是E的子节点;A和F的父节点都是E,所以A和F互为兄弟节点;E没有父节点,所以称E为根节点;G、H、I、J、K、L节点都没有子节点,所以称为叶子节点
树中的节点的称呼
    1. 关于树还有三个概念:高度(Height)、深度(Depth)、层(Level),比较容易混淆
    • 节点的高度:节点到叶子节点的最长边数

    • 节点的深度:根节点到这个节点的边数

    • 节点的层数:节点的深度 + 1

    • 树的高度:根节点的高度

树的高度、深度、层

小结:这里的高度和深度其实和日常工作生活中的一样的,高度其实就是从叶子到节点的距离,深度就是从根部到节点的距离。

二、什么是二叉树?
    1. 顾名思义,二叉树,就是每个节点最多有两个分叉的树,即每个节点最多有两个子节点,分别是左子节点和右子节点。
    1. 二叉树也有几个概念:满二叉树、完全二叉树,如下图所示:
    • 满二叉树:除了叶子节点外,其他所有节点都有2个子节点

    • 完全二叉树: 除了最后一层其它层的节点个数达到最大,并且最后一层的的叶子节点都靠左排列

满二叉树和完全二叉树
    1. 满二叉树好理解,但是完全二叉树为啥如此定义呢?为什么叫做完全呢?这就得从二叉树的存储说起了。
三、如何存储一颗二叉树?

存储二叉树有两种办法,一种是基于指针的二叉链式存储法,另一种是基于数组的顺序存储法

    1. 链式存储法,也就是像链表一样,每个节点有三个字段,一个存储数据,另外两个分别存放指向左右子节点的指针,如下图:
链式存储二叉树
    1. 顺序存储法,就是按照规律把节点存放在数组里,如下图所示,我们把A节点存放在下标为1的位置,B节点存放在下标为2的位置,C节点存放在下标为3的位置,依次类推,得出规律:节点X的下标为i,那么X的左子节点总是存放在2 * i的位置,X的右子节点总是存放在2 * i + 1的位置。(为了方便计算,总把根节点放在下标为1的位置)
顺序存储法
    1. 刚刚举的例子是一颗完全二叉树,所以仅仅浪费了下标为0的存储位置,但是,如果是非完全二叉树,就是浪费很多存储空间,如下图所示,所以你就懂了完全二叉树为何叫做完全二叉树了,就是因为这样的二叉树,能比较完全的利用数组存储空间。
非完全二叉树-用顺序存储法
四、如何遍历一颗二叉树?
    1. 遍历一棵树,有经典三种方法:前序遍历、中序遍历、后序遍历,这里的序指的是父节点的遍历顺序,前序就是先遍历父节点,中序就是中间遍历父节点,后续就是最后遍历父节点。如下图所示:
    • 前序遍历:对树中的任意节点来说,先打印这个节点,然后打印它的左子树,最后打印它的右子树。

    • 中序遍历:对树中的任意节点来说,先打印它的左子树,然后打印这个节点,最后打印它的右子树。

    • 后序遍历:对树中的任意节点来说,先打印它的左子树,然后打印它的右子树,最后打印它本身。

前序、中序、后序
    1. 二叉树的前序、中序、后序遍历,其实是一个递归的过程,写递归代码的关键在于,写出递推公示;写递推公式的的关键在于,如果要解决问题A,就假设问题B和C已经解决了,然后在看如何用B和C去解决问题A,最后在解决问题B、C。
前序遍历的递推公式:
preOrder(r) = print r->preOrder(r->left)->preOrder(r->right)

中序遍历的递推公式:
inOrder(r) = inOrder(r->left)->print r->inOrder(r->right)

后序遍历的递推公式:
postOrder(r) = postOrder(r->left)->postOrder(r->right)->print r

    1. 二叉树遍历的时间复杂度是多少呢?从前序、中序、后序的遍历图中,我们可以看出,每个节点最多会被访问2次,也就是如果有n个节点,遍历一次最多需要访问2n个节点,所以时间复杂度为O(n)
五、什么是二叉查找树?
    1. 二叉查找树,是一种特殊的二叉树,支持动态数据集合的快速插入、删除、查找操作。
    1. 为了支持这些特性,二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树中每个节点的值,都要大于这个节点的值。如下图所示:
二叉查找树
    1. 二叉查找树的查找操作,想要查找一个数,先取根节点,如果根节点等于我们要查找的的数据,就返回;如果要查找的比根节点小,就在左子树中递归查找;如果要查找的数据比根节点大,就在右子树中递归查找。
    1. 二叉查找树的插入操作,想要插入一个数,如果要插入的数据比节点的数据大,且节点的右子节点为空,则插入;如果右子节点不为空,那么就递归遍历右子树,寻找合适的插入位置;如果要插入的数据比节点的数据小,也同理。
    1. 二叉查找树的删除操作就比较复杂了,分为三种情况:
    • 如果要删除的节点没有子节点,只需要将其父节点指向子节点的指针置为null即可。

    • 如果要删除的节点只有一个子节点,只需要讲其父节点指向的子节点的指针换成其子节点的指针即可。

    • 如果要删除的节点有两个子节点,就比较复杂了。我们需要找到这个节点的右子树中最小的节点,然后替换要删除的节点。因为这个节点的右子树都是大于此节点的,所以需要找出右子树最小的,代替删除的节点。

    1. 二叉查找树除了支持上面几个操作外,还有一个非常重要的特性就是中序遍历二叉查找树,中序遍历就可以输出有序的数据队列,相当于完成了排序操作,时间复杂度为O(n),非常高效,所以二叉查找树也叫做二叉排序树。
    1. 二叉查找树插入、删除、查找的时间复杂度。实际上,二叉查找树的形态各异,不同的形态时间复杂度也完全不一样,比如下图中,同样的数据,这三种二叉查找树的执行效率就不相同。
    三种不同形态的二叉查找树
    • 第一种的二叉查找树已经退化为了链表,所以查找的时间复杂度变成了O(n)

    • 第三种是完全二叉查找树,查找操作类似于二分查找,所以时间复杂度为O(logn)

    1. 从上面可以看出,为了保证时间复杂度为O(logn),我们必须保证二叉查找树是平衡的,而且需要无论如何删除、插入数据,都能保持任意结点的左右子树都比较平衡,这样才能保证插入、删除、查找的时间复杂度稳定为O(logn),我们把这种可以保持平衡的二叉查找树叫做平衡二叉查找树
六、二叉查找树与散列表的对比

我们知道,散列表的插入、删除、查找的时间复杂度都是O(1),非常高效,而二叉查找树只有在比较平衡的情况下,才能做到O(logn),那么我们为什么要用二叉查找树?

    1. 散列表的数据时无序的,想要有序必须先排序,而二叉查找树,只需要中序遍历即可,可以在O(n)内输出有序数据序列。
    1. 散列表扩容耗时很多,而且遇到散列冲突时,性能不稳定,尽管二叉查找树的性能也不稳定,但是我们最常用的平衡二叉查找树性能非常稳定,时间复杂度可以稳定在O(logn)内。
    1. 散列表的构造比二叉树要复杂,要考虑散列函数的设计、冲突解决办法、扩容、缩荣等,而二叉查找树只需要考虑平衡性这一个问题,且解决办法比较成熟。
    1. 所以综合这几点,平衡二叉查找树在某些方面还是有优势的,实际开发中,要结合具体情况来选择。

第十七章 红黑树

一、平衡二叉查找树
    1. 上一节,我们讲了,二叉查找树想要做到稳定的O(logn)时间复杂度,就必须尽量保持平衡,所以就引出来平衡二叉查找树,平衡二叉查找树的严格定义是这样的:二叉树中任意节点的左右子树的高度相差不能大于1。从定义来看,完全二叉数和满二叉树都是平衡二叉树,非完全二叉树也可能是平衡二叉树。
    1. 最先被发明的平衡二叉查找树是AVL树,它严格符合平衡二叉查找树的定义,任何节点的左右子树的高度相差都不超过1,是一种高度平衡的二叉查找树。AVL是如何保证频繁的插入、删除的过程中保持平衡的呢?请看这幅漫画,生动形象的讲解左旋、右旋。 在插入过程中,会出现四种破坏AVL树特性的情况,可以采取以下方法处理:
    • 左-左型:做右旋。
    • 右-右型:做左旋转。
    • 左-右型:先做左旋,后做右旋。
    • 右-左型:先做右旋,再做左旋。
    1. AVL查找效率非常高,但是有利就有弊,AVL为了维持这种高度的平衡,每次插入和删除时都要做调整,比较耗时,对于频繁插入、删除的数据集合,使用AVL树代价就比较高了。所以,我们就引入了红黑树,红黑树也是一种平衡二叉树,它只做到了近似平衡,并不是严格的平衡,维护平衡的成本要比AVL低。
二、红黑树
    1. 红黑树并不是严格的平衡二叉树,它要求从根到叶子的最长路径不多于最短路径的两倍长,为了满足这个特性,红黑树设置了五大规则:
    • 规则1:节点是红色或者黑色

    • 规则2:根节点是黑色

    • 规则3:每个叶子的节点都是黑色的空节点

    • 规则4:每个红色节点的两个子节点都是黑色的

    • 规则5:从任意节点到其叶子节点的每条路径都包含相同个数的黑色节点

    • 必须满足这些规则,才能保证红黑树的自平衡,使根到叶子的最长路径不超过最短路径的2倍。

    1. 红黑树的高度近似logn,是近似平衡的二叉树,插入、删除、查找的时间复杂度都是O(logn),性能非常稳定,在实际工作中,凡是动态插入、删除、查找数据的场景都可以用它。
三、红黑树的平衡调整
    1. 对红黑树频繁进行插入、删除操作,可能会破坏五大规则,我们需要进行平衡调整,使其重新满足五大规则,平衡调整有两种方式:变色和旋转,旋转又分为左旋和右旋
    1. 变色,为了重新符合红黑树的五大规则,尝试将黑色节点变成红色节点或者将红色节点变成黑色节点。
    1. 左旋转,为了重新符合红黑树的五大规则,尝试对节点X左旋转,如下图,将Y变成新的父节点,X变成Y的左子节点,b变成X的右子节点。
左旋转
    1. 右旋转,为了重新符合红黑树的五大规则,尝试对节点X进行右旋转,如下图,让Y顶替自己的位置,X作为右子节点,C作为X的左子节点。
右旋转
四、红黑树的插入和删除

红黑树的插入和删除比较复杂,建议大家看这篇文章,理解即可。理解了原理之后,我们就可以把红黑树的平衡调整过程,当做魔方复原的过程,按照步骤一步一步来即可。

第十八章 递归树

一、什么是递归树?
    1. 递归的思想就是将大问题分解成小问题,在将小问题分解成小小问题,就这么一直分解下去,直到分解的足够小,不用继续递归分解为止。
    1. 我们把一层一层分解的过程画成图,它其实就是一颗树,我们把它叫做递归树。这里画了一颗斐波那契数列的递归树,节点里的数字表示数据的规模,一个节点的求解可以分解成左右子节点的求解。
斐波那契数列的递归树
    1. 某些场景下,用递归树可以很快的估算出某个算法的时间复杂度
二、用递归树如何求解时间复杂度?
    1. 归并算法还记得吧,我们就借助归并排序来看看,如何用递归树,分析递归代码的时间复杂度。
    1. 归并排序每次都会将数据一分为二,我们把归并排序画成递归树,如下图:
归并排序递归树
    1. 通过上图可以看出,每一层的数据规模都是n,每一层归并消耗的时间跟数据规模有关系,所以我们每一层耗费的时间都可以记做n;归并排序的递归是满二叉树,满二叉树的高度大约是logn,所以归并排序的时间复杂度就是O(nlogn)。
三、用递归树分析快速排序的时间复杂度
    1. 我们在讲快排的时候讲过,递归算法的时间复杂度的计算方法,当时是这么做的,当分区点每次都恰好将数据等分时,T(n) = 2T(n/2) + n = 2^k * T(n/2^k) + k * n,分解公式在下方,第k次时分解到只剩1个数据,算出k = log2n,将k带入公式,得出T(n) = nlog2n + Cn,用大O表示法的话,T(n) = O(nlogn),但是这只是最好情况的时间复杂度,一旦分区不均匀,计算时间复杂度就会很复杂了,所以我们尝试使用递归树来计算分区不均匀时的快排的时间复杂度。
T(n) = 2*T(n/2) + n
     = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
     = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
     = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
     ......
     = 2^k * T(n/2^k) + k * n
     ......

    1. 我们假设每次分区后,分区的比例为1:9,递推公式就可以写为T(n) = T(n/10) + T(9n/10) + n,把递归分解过程画成递归树,就是下面这个样子:
快速排序的递归树
    1. 每一层分区操作所遍历的数据个数之和都是n,我们只需要算出递归树的高度h,那么整个快排的过程中遍历的数据个数就是n * h,也就是时间复杂度是O(n * h)
    1. 因为每次分区并不是均匀的一分为二,所以快排的递归树不是满二叉树,从根节点到叶子节点的,最短的路径每次乘以1/10,最长的路径每次都乘以9/10,通过计算,可以得出,从根节点到叶子节点最短路径为log10n,最长路径为log10/9n
    1. 所以遍历数据个数的总和就介于nlog10n到nlog10/9n之间,根据大O表示法,就可以得出时间复杂度是O(nlogn)
    1. 分区比例并不随数据规模的变化而变化,是一个常量,所以当分区比例为1:99、1:999、1:9999时,快排的时间复杂度仍然是O(nlogn),也就可以说快排的平均时间复杂度为O(nlogn)

第十九章 堆和堆排序

一、什么是堆?
    1. 堆是一种特殊的树,只要满足以下两个条件,就可以称这棵树为
    • 堆是一颗完全二叉树(完全二叉树要求,除了最后一层,其他节点个数都是满的,最后一层的节点都靠左排列)

    • 堆中的每一个节点都必须大于等于(或者小于等于)其子树中每个节点的值。

    1. 每个节点的值都大于等于其子树每个节点的值的堆,我们称之为大顶堆,每个节点的值都小于等于其子树每个节点的值的堆,我们称之为小顶堆。如下图:
      大顶堆、小顶堆、不是堆
    1. 从上图可以看出,对于同一组数据,我们可以构建多种不同形态的堆。
二、如何存储堆?
    1. 堆是一颗完全二叉树,比较适合用数组存放,因为用数组存放不需要存储左右子节点的指针,非常节省空间,通过下标就可以找到一个节点的左右子节点和父节点。下图就是一个数组存放堆的例子:


      用数组存储堆
    1. 从图中可以看出,下标i的节点的左子节点的下标是i*2,右子节点的下标为i*2+1,父节点的下标为i/2,这是根节点存放在下标1的位置时的规律。
三、堆的插入和删除
    1. 我们对堆做删除或插入操作后,可能会破坏堆的两大特性,我们就需要进行调整,使其重新满足堆的两大特性,这个过程,我们称之为堆化(heapify),堆化有两种:从下往上堆化和从上往下堆化。
    1. 往堆中插入一个元素,我们可以用从下往上堆化,就是从下开始顺着节点路径往上走,进行比较,然后交换。例如:往堆中插入元素22,如下图:
      往堆中插入元素22
//自定义一个堆,并且插入数据
public class Heap{
    private var array:Array<Int>     //存放堆的数组
    private var maxCount: Int        //堆数组可以存储的最大个数
    private var realCount: Int = 0   //堆数组实际存储的数据个数
    
    //初始化一个堆,给定堆的容量
    public init(capacity: Int){
        array = Array(repeating: 0, count: capacity)
        maxCount = capacity
        realCount = 0
    }
    
    public func insert(data: Int){
        if realCount >= maxCount { return } //堆满了
        realCount = realCount + 1
        var i = realCount
        array[i] = data    
        // i/2代表i的父节点
        while(i / 2 > 0 && array[i] > array[i / 2]){    //当节点比其父节点小时,进行交换,这就是从下而上堆化的过程
            array.swapAt(i, i/2)
            i = i / 2
        }
    }
}

var testHeap = Heap(capacity: 10)
testHeap.insert(data: 5)
    1. 删除堆顶元素,我们可以采用从上往下堆化,为了避免删除后,堆是不完全二叉树,我们将堆顶元素删除后,要将最后一个元素放到堆顶,然后在进行堆化。例如:我们想删除堆顶元素33,就需要删除33后,将最后一个元素放在堆顶,然后进行堆化,过程如下:
      删除堆顶元素33的堆化过程
    //删除堆顶元素
    public func removeMax(){
        if realCount == 0 { return }    //如果堆中没有元素
        array[1] = array[realCount]
        realCount = realCount - 1
        heapify(array: array, realCount: realCount, i: 1)
    }
    
    private func heapify(array:Array<Int>, realCount: Int, i: Int){
        var headArray = array
        var i = i
        while true {
            if 2*i < realCount && headArray[i] < headArray[2*i]{
                //如果节点小于其左子节点
                headArray.swapAt(i, 2*i)
                i = 2*i
            }else if 2*i+1 < realCount && headArray[i] < headArray[2*i + 1]{
                //如果节点小于其右子节点
                headArray.swapAt(i, 2*i+1)
                i = 2*i + 1
            }else{
                break
            }
        }
    }
    1. 一个包含n个节点的完全二叉树,树的高度不会超过log2n,堆化的过程是沿着节点路径走的,最多不会超过树的高度,所以堆化的时间复杂度是跟树的高度成正比的,也就是O(logn);插入和删除堆顶元素主要逻辑就是堆化,所以插入和删除堆顶元素的时间复杂度就是O(logn)
四、如何用堆实现堆排序?
    1. 我们学过很多排序算法,有时间复杂度为O(n2)的冒泡排序、插入排序、选择排序,也有时间复杂度为O(nlogn)的归并排序、快速排序。借用堆这种数据结构实现的排序,我们称之为堆排序,堆排序的时间复杂度非常稳定,是O(nlogn),而且还是原地排序算法。
    1. 堆排序的过程可以分解成两个大步骤:建堆和排序
    1. 首先,进行建堆,就是在不借助其他数组的前提下,将数组原地建成一个堆,思路是这样,从第一个非叶子节点开始,不断往上依次堆化,如下图,建堆的时间复杂度是O(n)
      将原始数据建堆
    1. 然后,进行排序,建堆后数据就按照大顶堆的特性排布了,数组中的第一个元素就是堆顶,也是最大的元素,接下来的排序我们这样处理:将堆顶元素与最后一个元素交换,然后除了最后一个元素以外的n-1个元素进行堆化,然后再将堆顶元素与倒数第二个元素交换,然后将除了最后两个元素以外的n-2个元素堆化,一直重复这个过程,知道堆中只剩下下标为1的元素,排序就完成了。我们举个例子说明一下,如下图所示
      建堆之后,排序的过程.png
    1. 总结一下
    • 堆排序的过程只需要个别临时存储空间,所以是原地排序算法;

    • 堆排序包括两个步骤:建堆和排序,建堆时间复杂度是O(n),排序时间复杂度是O(nlogn),所以堆排序整体的时间复杂度是O(nlogn);

    • 由于堆排序过程中存在堆顶元素和最后一个节点互换的操作,有可能改变原始相对顺序,所以堆排序不是稳定的排序算法

五、实际开发中,为什么快速排序比堆排序性能好?
    1. 堆排序数据访问方式没有快速排序友好,快排的数据时顺序访问的,而堆排序的数据时跳着访问的,所以堆排序对CPU缓存不友好。
    1. 对于同样的数据,堆排序的交换次数要比快速排序多。因为快速的交换次数是逆序度,而堆排序的建堆过程会打乱顺序,可能会更加无序,导致逆序度提高,所以堆排序交换次数更多一点。

第二十章 堆的应用

堆这种数据结构有很多非常重要的应用,例如:优先级队列、求Top K、求中位数

一、优先级队列
    1. 优先级队列,顾名思义,它首先应该是一个队列,但是它不想前边讲的队列一样,是先进先出的,而是根据优先级来,优先级高的最先出队。
    1. 优先级队列的应用-将多个有序小文件合并成有序大文件,过程是这样的,分别从各个小文件中取一个字符串构建一个小顶堆,堆顶就是最小的字符串,将堆顶写入大文件后,将堆顶删除,然后继续从小文件中取出下一个字符串放入堆中,重新堆化成小顶堆,不断循环这个过程,直到把各个小文件取完。
    1. 优先级队列的应用-高性能定时器,用优先级队列我们就不需要每隔1s去扫描任务列表了,而是从优先级队列的队首(也就是小顶堆的堆顶)取出队首任务的执行时间,与当前时间做差值,得到时间间隔T,让定时器T秒后再来执行任务,执行完之后,在重新计算新的队首任务与当前时间的的差值,再次设定定时器。
二、利用堆求Top K
  • 如何在一个包含n个数据的数组中,查找前K大数据呢?我们可以维护一个大小为K的小顶堆,然后顺序遍历数组,如果数据比堆顶大,则删除堆顶,将数据插入到堆顶,然后堆化;如果数据比堆顶小,则不作处理;遍历完成后,就可以得到前K大的数据了。
三、利用堆求中位数
    1. 对于n个数据的集合来说,以前的做法都是先排序,然后才能取到中位数,如果数据一直在动态变化,我们就需要不断的排序,成本就会很高了,而借助堆这种数据结构,我们就不用排序,就可以取到中位数。
    1. 我们需要维护两个堆,一个大顶堆,一个小顶堆,大顶堆存储前半部分数据,小顶堆存储后半部分数据,且小顶堆中的数据都大于大顶堆的数据,这样存储的话,大顶堆的堆顶就是我们要找的中位数了。例如:如下图所示,1、2、3、4、5、6、7这组数据的中位数就是4。


      用堆求中位数
    1. 当新插入一个数据时,就可能会破坏我们前边的约定,我们可以这样处理:新插入的数据大于大顶堆的堆顶,就把新数据插入到小顶堆,否则就插入到大顶堆;总数据量为奇数时,大顶堆应该比小顶堆多一个,总数据量为偶数时,大顶堆应该和小顶堆的数据相同;按照这个规律,我们就把数据多的堆的堆顶移动至数据少的堆中。如下图:


      两个堆移动元素,以保持约定.png
    1. 利用一个大顶堆、一个小顶堆,我们就可以实现在动态的数据集合中快速求得中位数,这样做,在插入数据的时候需要涉及堆化,所以插入数据的时间复杂度变成了O(logn),但是中位数就是大顶堆的堆顶,所以查找中位数的时间复杂度就优化成了O(1)。(虽然降低了插入的效率,但是提高了查找中位数的效率

推荐阅读更多精彩内容