1 内存管理相关
1 .1 float 6.9 f 的内存计算方法
二进制小数的计算:
(1)小数的二进制算法和整数的大致相反,就是不断的拿小数部分乘以2取积的整数部分,然后正序排列。比如求0.9的二进制:
0.9*2=1.8 取 1
0.8*2=1.6 取 1
0.6*2=1.2 取 1
0.2*2=0.4 取 0
0.4*2=0.8 取 0
0.8*2=1.6 取 1
二进制小数也是无限循环的:0.11100110011...,6.9的二进制表示就是110.1110011001100...。这里就看出来了,6.9转换成二进制,小数部分是无限循环的。
(2)实数转二进制float类型的方法:
A. 分别将实数的整数和小数转换为二进制
B. 左移或者右移小数点到第一个有效数字之后
C. 从小数点后第一位开始数出23位填充到尾数部分
D. 把小数点移动的位数,左移为正,右移为负,加上偏移量127,将所得的和转换为二进制填充到指数部分
E. 根据实数的正负来填充符号位,0为正,1为负
(3)float 类型结构
float类型总共4个字节——32位:
- 符号位
其中最左边的为符号位,0为正,1为负。 - 指数
接下来的E是指数,一共8位,也用二进制来表示。 - 尾数
最后的F是小数部分,尾数正是由这23位的小数部分+1位组成的。
(4)将小数点左移(或右移)到第一个有效数字之后。说的通俗些,就是把小数点移到第一个1之后。这样的话,对于上面的110.1110011001100...我们就需要把小数点左移2位,得到1.101110011001100...
(5)把得到的1.101110011001100..这个数,从小数点后第一位开始,数出23个来,填充到上面float内存结构的尾数部分
(6)指数就是刚才我们把小数点移动的位数,左移为正,右移为负,再按照上面所说的偏移量算法,我们填充的指数应该是2+127=129,转换成8位二进制就是10000001。
(7)最后,根据这个数的正负来填充符号位。我们这里是正数,所以填0
6.9f 的内存表示:
0 10000001 10111001100110011001100
2 算法和常用数据结构
算法:由基本运算及规定的运算顺序所构成的完整的解题步骤。具有有穷性,确定性,输入,输出,可行性 5个重要特征。
算法的设计目标:正确性,可读性,健壮性,高效率及低存储量需求。
2.1 算法复杂度
2.1.1 时间复杂度
将算法中基本操作的执行次数作为算法时间复杂度的度量。一般依照使得基本操作执行次数最多的输入来计算时间复杂度,即最坏的情况作为算法时间复杂度的度量。
算法的时间复杂度取决于问题的规模和待处理数据的初态。
根据输入数据的特点,时间复杂度具有「最差」、「平均」、「最佳」三种情况,分别使用 O , \Θ , Ω 三种符号表示。以下借助一个查找算法的示例题目帮助理解。
输入长度为 N 的整数数组 nums
,判断此数组中是否有数字 7 ,若有则返回 true
,否则返回 false 。
bool findSeven(vector<int>& nums) {
for (int num : nums) {
if (num == 7)
return true;
}
return false;
}
最佳情况 Ω(1) : nums = [7, a, b, c, ...] ,即当数组首个数字为 7 时,无论 nums 有多少元素,线性查找的循环次数都为 1 次;
最差情况 O(N) : nums = [a, b, c, ...] 且 nums 中所有数字都不为 7 ,此时线性查找会遍历整个数组,循环 N 次;
平均情况 Θ : 需要考虑输入数据的分布情况,计算所有数据情况下的平均时间复杂度;例如本题目,需要考虑数组长度、数组元素的取值范围等;
计算时间复杂度的步骤:
(1)确定算法中基本操作以及问题的规模;
(2)根据基本操作执行情况计算出规模n的函数f(n), 并确定时间复杂度为 T(n) = O(f(n)) 中增长最快的项。
2.1.2 空间复杂度
空间复杂度涉及的空间类型有:
输入空间: 存储输入数据所需的空间大小;
暂存空间: 算法运行过程中,存储所有中间变量和对象等数据所需的空间大小;
输出空间: 算法运行返回时,存储输出数据所需的空间大小;
通常情况下,空间复杂度指在输入数据大小为 NN 时,算法运行所使用的「暂存空间」+「输出空间」的总体大小。
通常情况下,空间复杂度统计算法在 “最差情况” 下使用的空间大小,以体现算法运行所需预留的空间量,使用符号 O 表示。
常数 O(1):
普通常量、变量、对象、元素数量与输入数据大小 N 无关的集合,皆使用常数大小的空间。
void algorithm(int N) {
int num = 0;
int nums[10000];
Node* node = new Node(0);
unordered_map<int, string> dic;
dic.emplace(0, "0");
}
2.2 数据结构
数据结构是为实现对计算机数据有效使用的各种数据组织形式,服务于各类计算机操作。不同的数据结构具有各自对应的适用场景,旨在降低各种算法计算的时间与空间复杂度,达到最佳的任务执行效率。
如下图所示,常见的数据结构可分为「线性数据结构」与「非线性数据结构」,具体为:「数组」、「链表」、「栈」、「队列」、「树」、「图」、「散列表」、「堆」。
(1)数组
数组是将相同类型的元素存储于连续内存空间的数据结构,其长度不可变。
(2)链表
链表以节点为单位,每个元素都是一个独立对象,在内存空间的存储是非连续的。链表的节点对象具有两个成员变量:「值 val
」,「后继节点引用 next
」 。
struct ListNode {
int val; // 节点值
ListNode *next; // 后继节点引用
ListNode(int x) : val(x), next(NULL) {}
};
// 实例化节点
ListNode *n1 = new ListNode(4); // 节点 head
ListNode *n2 = new ListNode(5);
ListNode *n3 = new ListNode(1);
// 构建引用指向
n1->next = n2;
n2->next = n3;
链表分为单链表和双链表。
单链表只能由开始结点走到终端结点,而不能由终端结点反向走到开始结点。
双链表:
//定义链表
typedef struct Node{
int data;//存放数据
Node* pre; //指向上一个节点
Node* next;//指向下一个节点
Node(): data(0),pre(NULL),next(NULL){};
Node(int x, Node* node): data(x),pre(node),next(NULL){};
Node(int x, Node* node, Node* node2): data(x),pre(node),next(node2){};
}*ListNode;
- 这里采用初始化列表的方式初始化Node,不仅仅是因为这样写简洁方便,而是这种方式在有些时候是比在函数体里面初始化更加的高效。
- 使用typedef的方式在下方定义的*ListNode是一个结构体类型而不是一个变量。
template<typename T>
void CreateList(ListNode &link_list, T arr, int len){
Node* p = link_list;
for(int i=0; i<len; i++){
p->next = new Node(arr[i], p); //每一步都设置前驱节点
p = p->next;
}
}
void ShowList(const ListNode &link_list){
Node* p = link_list->next;
if(NULL == p) return;
while(NULL != p->next){
cout << p->data << "\t";
p = p->next;
}
cout<<p->data << endl;
}
int main(void){
ListNode link_list = new Node();
vector<int> a = {1,2,3,4,5};
CreateList(link_list, a, a.size());
Node *p = link_list->next->next;
cout<<p->pre->data<<endl;
ShowList(link_list);
system("pause");
return 0;
}
(3)栈
栈是一种具有 「先入后出」 特点的抽象数据结构,可使用数组或链表实现。
stack<int> stk;
stk.push(1); // 元素 1 入栈
stk.push(2); // 元素 2 入栈
stk.pop(); // 出栈 -> 元素 2
stk.pop(); // 出栈 -> 元素 1
(4)队列
队列是一种具有 「先入先出」 特点的抽象数据结构,可使用链表实现
que.push(1); // 元素 1 入队
que.push(2); // 元素 2 入队
que.pop(); // 出队 -> 元素 1
que.pop(); // 出队 -> 元素 2
(5)树
树是一种非线性数据结构,根据子节点数量可分为 「二叉树」 和 「多叉树」,最顶层的节点称为「根节点 root」。以二叉树为例,每个节点包含三个成员变量:「值 val」、「左子节点 left」、「右子节点 right」 。
struct TreeNode {
int val; // 节点值
TreeNode *left; // 左子节点
TreeNode *right; // 右子节点
TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};
如下图所示,建立此二叉树需要实例化每个节点,并构建各节点的引用指向。
// 初始化节点
TreeNode *n1 = new TreeNode(3); // 根节点 root
TreeNode *n2 = new TreeNode(4);
TreeNode *n3 = new TreeNode(5);
TreeNode *n4 = new TreeNode(1);
TreeNode *n5 = new TreeNode(2);
// 构建引用指向
n1->left = n2;
n1->right = n3;
n2->left = n4;
n2->right = n5;
(6)图
邻接矩阵的大小只与节点数量有关,即 N^2,其中 N 为节点数量。因此,当边数量明显少于节点数量时,使用邻接矩阵存储图会造成较大的内存浪费。
因此,邻接表 适合存储稀疏图(顶点较多、边较少); 邻接矩阵 适合存储稠密图(顶点较少、边较多)。
(7)散列表
散列表是一种非线性数据结构,通过利用 Hash 函数将指定的「键 key
」映射至对应的「值 value
」,以实现高效的元素查找。
(8)堆
堆是一种基于「完全二叉树」的数据结构,可使用数组实现。以堆为原理的排序算法称为「堆排序」,基于堆实现的数据结构为「优先队列」。堆分为「大顶堆」和「小顶堆」,大(小)顶堆:任意节点的值不大于(小于)其父节点的值。
完全二叉树定义: 设二叉树深度为 kk,若二叉树除第 k 层外的其它各层(第 1 至 k−1 层)的节点达到最大个数,且处于第 k 层的节点都连续集中在最左边,则称此二叉树为完全二叉树。
2.2.1 树
树状图是一种数据结构,它是由 n(n>=1)个有限结点组成一个具有层次关系的集合。把它叫做“树”是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
每个结点有零个或多个子结点;没有父结点的结点称为根结点;每一个非根结点有且只有一个父结点;除了根结点外,每个子结点可以分为多个不相交的子树。
结点:结点不仅包含数据元素,而且包含指向子树的分支。
结点的度:结点拥有的子树个数或分支个数,称为结点的度。如根结点的度为2。
树的度:树中各结点度的最大值。
叶子结点(终端结点):结点的度为0的的结点。
孩子结点:结点的子树的根。
树的高度(树的深度):树中结点的最大层次,如树共有3层,所以高度为3。
2.2.2 二叉树
二叉树是一个每个结点最多只能有两个分支的树,左边的分支称之为左子树,右边的分支称之为右子树。
分类:
(1)完全二叉树
完全二叉树 ——若设二叉树的高度为 h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数,第 h 层有叶子节点,并且叶子结点都是从左到右依次排布(第h 层所有的结点都连续集中在最左边),这就是完全二叉树(堆就是完全二叉树)。
特征:
深度为k的完全二叉树,至少有2^(k-1)个节点,至多有2^k-1个节点。
树高h=log2n + 1
满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树。
(2)满二叉树
一棵树深度为k,2^k-1个节点的树是满二叉树,即除了叶结点外每一个结点都有左右子节点且叶子结点都处在最底层的二叉树。
特征:
所有内部节点都有两个子节点,最底一层是叶子节点。
如果一颗树深度为h,最大层数为k,且深度与最大层数相同,即k=h;
第i层的结点数是:2^(i-1)
总结点数是:2^k-1 (2的k次方减一)
总节点数一定是奇数。
树高:h=log2(n+1)
(3)平衡二叉树
平衡二叉树的提出就是为了保证树不至于出现二叉查找树的极端一条腿长现象,尽量保证两条腿平衡。因此它的定义:
它是一颗空树或左右两个子树的高度差的绝对值不超过 1,并且左右两个子树都是一棵平衡二叉树
判断平衡二叉树的 2 个条件:
- 1. 是「二叉排序树」
- 2. 任何一个节点的左子树或者右子树都是「平衡二叉树」(左右高度差小于等于 1)
如下符合1和2条件:
不符合的情况,如有节点子树高度差大于 1 违法第 2 条件
平衡因子指的是,平衡二叉树在保持平衡的时候,是通过平衡因子来判断的,
节点的平衡因子 = 该节点的左子树的高度 - 该节点右子树的高度。
只有当值等于-1(右子树的高度大),0(左右子树的高度相等),1(左子树的高度大)的时候,能够代表该子树是平衡的除此之外,就认为该节点已经失衡了,需要旋转来维持平衡,如下图的平衡因子分布情况:
2 种「旋转」方式:
- 左旋
- 旧根节点为新根节点的左子树
- 新根节点的左子树(如果存在)为旧根节点的右子树
2. 右旋:
- 旧根节点为新根节点的右子树
- 新根节点的右子树(如果存在)为旧根节点的左子树
(4)二叉搜索树
又称二叉查找树、二叉排序树(Binary Sort Tree)。它是一颗空树或是满足下列性质的二叉树:(右边>=根>=左边)
1)若左子树不空,则左子树上所有节点的值均小于或等于它的根节点的值;
2)若右子树不空,则右子树上所有节点的值均大于或等于它的根节点的值;
3)左、右子树也分别为二叉排序树。
特征:
优点:查找速度快,二叉查找树比普通树查找更快
缺点:出现平衡问题
二叉搜索树在经过多次插入与删除后,有可能导致如下右图的结构:
(5)红黑树
每个节点都带有颜色属性(颜色为红色或黑色)的自平衡二叉查找树,满足下列性质:
1)节点是红色或黑色;
2)根节点是黑色;
3)所有叶子节点都是黑色;
4)每个红色节点必须有两个黑色的子节点。(从每个叶子到根的所有路径上不能有两个
连续为红色的结点);
5)从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。 (没有度为
1的结点)。
以上规则可以保证左右子树结点数差距不超过两倍
2.2.3 二叉树的遍历方式
1)先序遍历(根左右)
2)中序遍历(左根右)
3)后序遍历(左右根)
2.2.4 图
图:图由节点(node)和边(edge)组成。
解决最短路径问题的算法被称为广度优先搜索。。广度优先搜索是一种用于图的查找算法,可帮助
回答两类问题。
第一类问题:从节点A出发,有前往节点B的路径吗?
第二类问题:从节点A出发,前往节点B的哪条路径最短?
2.3 动态规划
动态规划特点
「分治」是算法中的一种基本思想,其通过将原问题分解为子问题,不断递归地将子问题分解为更小的子问题,并通过组合子问题的解来得到原问题的解。
类似于分治算法,「动态规划」也通过组合子问题的解得到原问题的解。不同的是,适合用动态规划解决的问题具有「重叠子问题」和「最优子结构」两大特性。
重叠子问题
动态规划的子问题是有重叠的,即各个子问题中包含重复的更小子问题。若使用暴力法穷举,求解这些相同子问题会产生大量的重复计算,效率低下。
动态规划在第一次求解某子问题时,会将子问题的解保存;后续遇到重叠子问题时,则直接通过查表获取解,保证每个独立子问题只被计算一次,从而降低算法的时间复杂度。
最优子结构
如果一个问题的最优解可以由其子问题的最优解组合构成,并且这些子问题可以独立求解,那么称此问题具有最优子结构。
动态规划从基础问题的解开始,不断迭代组合、选择子问题的最优解,最终得到原问题最优解。
2.4 排序算法
排序算法用作实现列表的排序,列表元素可以是整数,也可以是浮点数、字符串等其他数据类型。
某些算法需要在排序算法的基础上使用(即在排序数组上运行),例如:
二分查找: 根据数组已排序的特性,才能每轮确定排除两部分中的哪一部分;
对于包含n个元素的列表,用二分查找最多需要log 2 n步,而简单查找最多需要n步
双指针: 例如合并两个排序链表,根据已排序特性,才能通过双指针移动在线性时间内将其合并为一个排序链表。
排序算法的分类:
排序算法主要可根据 稳定性 、就地性 、自适应性 分类。理想的排序算法具有以下特性:
具有稳定性,即相等元素的相对位置不变化;
具有就地性,即不使用额外的辅助空间;
具有自适应性,即时间复杂度受元素分布影响;
(1)稳定性
根据 相等元素 在数组中的 相对顺序 是否被改变,排序算法可分为「稳定排序」和「非稳定排序」两类。
「稳定排序」在完成排序后,不改变 相等元素在数组中的相对顺序。例如:冒泡排序、插入排序、归并排序、基数排序、桶排序。
「非稳定排序」在完成排序后,相等素在数组中的相对位置 可能被改变。例如:选择排序、快速排序、堆排序。
(2)就地性:
根据排序过程中 是否使用额外内存(辅助数组),排序算法可分为「原地排序」和「异地排序」两类。一般地,由于不使用外部内存,原地排序相比非原地排序的执行效率更高。
「原地排序」不使用额外辅助数组,例如:冒泡排序、插入排序、选择排序、快速排序、堆排序。
「非原地排序」使用额外辅助数组,例如:归并排序、基数排序、桶排序
(3)自适应性:
根据算法 时间复杂度 是否 受待排序数组的元素分布影响 ,排序算法可分为「自适应排序」和「非自适应排序」两类。
「自适应排序」的时间复杂度受元素分布影响;例如:冒泡排序、插入排序、快速排序、桶排序。
「非自适应排序」的时间复杂度恒定;例如:选择排序、归并排序、堆排序、基数排序。
(4)比较类:
比较类排序基于元素之间的 比较算子(小于、相等、大于)来决定元素的相对顺序;相对的,非比较排序则不基于比较算子实现。
「比较类排序」基于元素之间的比较完成排序,例如:冒泡排序、插入排序、选择排序、快速排序、归并排序、堆排序。
「非比较类排序」不基于元素之间的比较完成排序,例如:基数排序、桶排序。
2.4.1 冒泡排序
冒泡排序是最基础的排序算法,由于其直观性,经常作为首个介绍的排序算法。其原理为:
内循环: 使用相邻双指针 j , j + 1 从左至右遍历,依次比较相邻元素大小,若左元素大于右元素则将它们交换;遍历完成时,最大元素会被交换至数组最右边 。
外循环: 不断重复「内循环」,每轮将当前最大元素交换至 剩余未排序数组最右边 ,直至所有元素都被交换至正确位置时结束。
首轮「内循环」后,数组最大元素已被交换至数组最右边;接下来,只需要完成数组剩余 N - 1N−1 个元素的排序即可(设数组元素数量为 NN )。同理,对剩余 N - 1N−1 个元素执行「内循环」,可将第二大元素交换至剩余数组最右端,以此类推。冒泡排序的「外循环」共 N - 1N−1 轮,每轮「内循环」都将当前最大元素交换至数组最右边,从而完成对整个数组的排序。
复杂度分析:
时间复杂度 O(N^2) : 其中 N 为输入数组的元素数量;「外循环」共 N−1 轮,使用 O(N) 时间;每轮「内循环」分别遍历 N−1 , N−2 , ⋯ , 2 , 1 次,平均 N/2 次,使用 O(N/2) = O(N)时间;因此,总体时间复杂度为 O(N^2)
空间复杂度 O(1) : 只需原地交换元素,使用常数大小的额外空间。
算法特性:
时间复杂度为 O(N^2),因为其是通过不断 交换元素 实现排序(交换 2 个元素需要 3 次赋值操作),因此速度较慢;
原地: 指针变量仅使用常数大小额外空间,空间复杂度为 O(1) ;
稳定: 元素值相同时不交换,因此不会改变相同元素的相对位置;
自适应: 通过增加一个标志位 flag ,若某轮内循环未执行任何交换操作时,说明已经完成排序,因此直接返回。此优化使冒泡排序的最优时间复杂度达到 O(N)(当输入数组已排序时)
void bubbleSort(vector<int> &nums) {
int N = nums.size();
for (int i = 0; i < N - 1; i++) { // 外循环
for (int j = 0; j < N - i - 1; j++) { // 内循环
if (nums[j] > nums[j + 1]) {
// 交换 nums[j], nums[j + 1]
swap(nums[j], nums[j + 1]);
}
}
}
}
完整示例:
#include <iostream>
using namespace std;
const int INIT = 10;
class Bubble {
public:
void bubble(int arr[],int n) {
for (int i = 0; i < n-1; i++)
{
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
//左大右小,则交换,保持始终右边最大
int tmp = arr[j];//tmp暂存大值
arr[j] = arr[j+1];
arr[j+1] = tmp;
}
}
}
}
};
int main()
{
Bubble b1;
int n = 0;
int arr[INIT];
cout << "Input n=";
cin >> n;
for (int i = 0; i < n; i++)
//依次输入元素
cin >> arr[i];
b1.bubble(arr,n);
for (int j= 0; j < n; j++)
cout << arr[j] <<" ";
return 0;
}
2.4.2 快速排序
快速排序算法有两个核心点,分别为 哨兵划分 和 递归 。
哨兵划分:
以数组某个元素(一般选取首元素)为 基准数 ,将所有小于基准数的元素移动至其左边,大于基准数的元素移动至其右边。
递归:
对 左子数组 和 右子数组 分别递归执行 哨兵划分,直至子数组长度为 1 时终止递归,即可完成对整个数组的排序。
流程:
先从数列中取出一个元素作为基准元素。一基准元素为标准,将问题分解为两个子序列,使小于或者等于基准元素的子序列在左侧,使大于基准元素的子序列在右侧。
对两个子序列进行快速排序(递归快速排序)。
将排好的两个子序列合并在一起,得到原问题的解。
①:取第一个元素。(通常选取第一个元素)
②:取最后一个元素
③:取中间位置的元素
④:取第一个、最后一个、中间位置元素三者之中位数
⑤:取第一个和最后一个之间位置的随机数 k (low<=k<=hight)
假设当前的待排序的序列为 R[low,hight] , 其中 low<=hight。同时选取首元素为基准元素。
步骤一:选取首元素的第一个元素作为基准元素 pivot=R[low] ,i=low ,j=hight。
步骤二:从右向左扫描,找到小于等于 pivot 的数,如果找到,R[i] 和 R[j] 交换 ,i++。
步骤三:从左向右扫描,找到大于 pivot 的数,如果找到,R[i] 和 R[j] 交换,j--。
步骤四:重复 步骤二~步骤三,直到 j 与 i 的指针重合 返回位置 mid=i ,该位置的数正好是 pivot 元素。
(1)初始化。i=low ,j=hight,pivot=R[low]=30。如下图所示:
(2)向左走,从数组的右边位置向左找,一直找到小于等于 pivot 的数,找到R[j]=12,R[i]与R[j]交换,i++。如下图所示:
(3)向右走,从数组的左边位置向右找,一直找到比 pivot 大的数,找到 R[i]=58 ,R[i] 与 R[j] 交换 ,j--。
(4)向左走,从数组的右边位置向左找,一直找到小于等于 pivot 的数,找到R[j]=18,R[i]与R[j]交换,i++。如下图所示:
(5)向右走,从数组的左边位置向右找,一直找到比 pivot 大的数,这是 i=j,第一轮排序结束,返回 i 的位置,mid=i 。
2.4.3 归并排序
归并排序体现了 “分而治之” 的算法思想,具体为:
「分」: 不断将数组从 中点位置 划分开,将原数组的排序问题转化为子数组的排序问题;
「治」: 划分到子数组长度为 1 时,开始向上合并,不断将 左右两个较短排序数组 合并为 一个较长排序数组,直至合并至原数组时完成排序;
void mergeSort(vector<int>& nums, int l, int r) {
// 终止条件
if (l >= r) return;
// 递归划分
int m = (l + r) / 2;
mergeSort(nums, l, m);
mergeSort(nums, m + 1, r);
// 合并阶段
int tmp[r - l + 1]; // 暂存需合并区间元素
for (int k = l; k <= r; k++)
tmp[k - l] = nums[k];
int i = 0, j = m - l + 1; // 两指针分别指向左/右子数组的首个元素
for (int k = l; k <= r; k++) { // 遍历合并左/右子数组
if (i == m - l + 1)
nums[k] = tmp[j++];
else if (j == r - l + 1 || tmp[i] <= tmp[j])
nums[k] = tmp[i++];
else {
nums[k] = tmp[j++];
}
}
}
// 调用
vector<int> nums = { 4, 1, 3, 2, 5, 1 };
mergeSort(nums, 0, nums.size() - 1);
参考文献:
【1】作者:Krahets
链接:https://leetcode.cn/leetbook/read/illustration-of-algorithm/r8ytog/
来源:力扣(LeetCode)
【2】二叉树详解(C++)_c++二叉树_霸道小明的博客-CSDN博客
【3】https://www.cnblogs.com/sench/p/7786718.html
【4】什么是平衡二叉树-腾讯云开发者社区-腾讯云
【5】平衡二叉树 通俗易懂 - 知乎