本笔记记录王争专栏数据结构与算法之美的学习记录,以便自己复习回顾,代码部分均已经过验证,可直接使用
文章目录
1. 二叉树基础
之前的都是线性表结构,栈、队列等。现在说说非线性表结构,树。相比下,复杂的多。
1. 树
类似我们现实的树,这里的每个元素我们叫“节点”,用来连线相邻节点之间的关系,叫“父子关系”。
此外,父节点、子节点、兄弟节点、根节点、叶子节点。根节点就是没有父节点的节点;叶子节点就是没有子节点的节点。
此外,还有三个比较相似的概念:高度Height、深度Depth、层level
- 节点的高度=节点到叶子节点的最长路径(边数)
- 节点的深度=根节点到这个节点所经历的边的个数
- 节点的层数=节点的深度+1
- 树的高度=根节点的高度
2. 二叉树 Binary Tree
二叉树:每个节点最多有两个“叉”,也就是两个子节点,左子节点和右子节点。有的节点可以只有左子节点或右子节点。
如果除了叶子结点,每个节点都有左右两个子节点,这种叫满二叉树。
如果最后一层的叶子节点都靠左排列,除了最后一层,其它层的节点个数都达到最大,叫完全二叉树。
3. 如何存储一个二叉树
一种是基于指针或引用的二叉链式存储法,一种是基于数组的顺序存储法。
比较简单、直观的链式存储法。每个节点都有三个字段,其中一个存储数据,另外两个指向左右子节点的指针,只要拎住根节点,可以通过左右子节点的指针,把整棵树都串起来。比较常用。大部分二叉树都是通过这种结构实现。
基于数组的顺序存储法。把根节点存储在下标i=1
的位置,左子节点存储在下标2*i=2
的位置,右子节点存储在2*i+1=3
的位置。以此类推,B节点的左子节点存储在2*i=2*2=4
的位置,右子节点存储在2*i+1=2*2+1=5
的位置
如果节点x存储在数组中下标为i
的位置,下标为2*i
的位置存储的就是左子节点,下标为2*i+1
存储的就是右子节点。反过来,下标为i/2
的位置存储的就是它的父节点。通过这种方式,只需要知道根节点存储的位置(一般情况下,为了方便计算子节点,根节点会存储在下标为1的位置),这样可以通过下标计算,把整棵树串起来。
如果完全二叉树,仅仅“浪费”一个下标为0的存储位置; 如果非完全二叉树,会浪费较多的数组存储空间。
因此,完全二叉树,用数组存储最节省内存,不需要存储额外的左右子节点的指针,这也是为什么完全二叉树要求最后一层的子节点都靠左。
堆其实就是一种完全二叉树,最常用的存储方式是数组。
4. 二叉树的遍历
如何将所有节点都遍历打印出来?经典的方法有三种,前序遍历、中序遍历和后序遍历。前中后序,指的是节点和它的左右子树节点遍历打印的先后顺序。
- 前序遍历,指对于树的任意节点,先打印这个节点,然后再打印左子树,最后打印右子树
- 中序遍历,指对于树的任意节点,先打印左子树,再打印它本身,最后打印右子树
- 后序遍历,指对于树的任意节点,先打印左子树,再打印右子树,最后打印节点本身
实际上,二叉树的前、中、后序遍历就是一个递归过程。如前序遍历,就是先打印根节点,再递归打印左子树,最后递归打印右子树。
代码实现
public class Node {
private int data;
private Node leftNode;
private Node rightNode;
public Node(int data,Node leftNode,Node rightNode){
this.data = data;
this.leftNode = leftNode;
this.rightNode = rightNode;
}
// getter/setter...
public class BinaryTree {
// 二叉树的前中后序排序
// 注意:必须逆序建立,先建立子节点,再往上建立,因为非叶子节点会使用到下面的节点,而初始化是按顺序初始化的
public Node init(){
Node J = new Node(8,null,null);
Node H = new Node(4,null,null);
Node G = new Node(2,null,null);
Node F = new Node(7,null,J);
Node E = new Node(5,H,null);
Node D = new Node(1,null,G);
Node C = new Node(9,F,null);
Node B = new Node(3,D,null);
Node A = new Node(6,B,C);
return A; // 返回根节点
}
public void printNode(Node node){
System.out.println(node.getData());
}
// 前序遍历
public void preOrder(Node root){
printNode(root);
// 左子节点
if(root.getLeftNode()!=null){
preOrder(root.getLeftNode());
}
// 右子节点
if(root.getRightNode()!=null){
preOrder(root.getRightNode());
}
}
// 中序遍历
public void inOrder(Node root){
// 左子节点
if(root.getLeftNode()!=null){
preOrder(root.getLeftNode());
}
printNode(root);
// 右子节点
if(root.getRightNode()!=null){
preOrder(root.getRightNode());
}
}
// 后序遍历
public void postOrder(Node root){
// 左子节点
if(root.getLeftNode()!=null){
preOrder(root.getLeftNode());
}
// 右子节点
if(root.getRightNode()!=null){
preOrder(root.getRightNode());
}
printNode(root);
}
public static void main(String[] args) {
BinaryTree tree = new BinaryTree();
Node root = tree.init();
System.out.println("前序遍历");
tree.preOrder(root);
System.out.println("=============");
System.out.println("中序遍历");
tree.inOrder(root);
System.out.println("=============");
System.out.println("后序遍历");
tree.postOrder(root);
System.out.println("=============");
}
}
二叉树遍历的时间复杂度是多少?
从前中后序的顺序图,可以看出,每个节点最多被访问两次,遍历操作的时间复杂度,跟节点的个数N成正比,也就是O(n)
2. 二叉查找树 Binary Search Tree
1. 概念
是二叉树中最常用的一种类型,也叫二叉搜索树,为了实现快速查找而生的。不仅仅支持快速查找一个数据,还支持快速插入、删除一个数据。如何做到的?依赖二叉查找树的特殊结构。
二叉查找树要求,在树的任意一个节点,其左子树的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。
2. 操作
1. 查找操作
先取根节点,如果等于我们要查找的数据,就返回。如果要查找的数据比根节点的值小,就在左子树中递归查找;如果要查找的数据比根节点的值大,就在右子树递归查找
代码实现
public class BinarySearchTree {
public static class Node{
private int data;
private Node left;
private Node right;
public Node(int data){
this.data = data;
}
}
private Node tree;
public Node find(int data){
Node p = tree;
while(p!=null){
if(data<p.data) p=p.left;
else if(data >p.data) p=p.right;
else return p;
}
return null;
}
}
2. 插入操作
插入过程类似查找操作。新插入的数据一般都在叶子节点,只需要从根节点开始,依次比较要插入的数据和节点的大小关系。
如果要插入的数据比节点的数据大,并且节点的右子树为空,将新数据直接插到右子节点的位置;如果不为空,递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且节点的左子树为空,将新数据插入到左子节点的位置;如果不为空,再递归遍历左子树,查找插入位置。
public void insert(int data){
if(tree==null){
tree = new Node(data);
return;
}
Node p = tree;
while(p!=null){
if(data>p.data){
if (p.right==null) {
p.right = new Node(data);
return;
}
p=p.right;
}else{
if(p.left==null){
p.left = new Node(data);
return;
}
p = p.left;
}
}
}
3. 删除操作
删除操作较为复杂,分为三种情况
- 如果要删除的节点没有子节点,只需要将父节点中,指向要删除节点的指针置为null,如下图的节点55
- 如果要删除的节点只有一个子节点,只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点,如下图的节点13
- 如果要删除的节点有两个子节点,要找到这个节点的右子树的最小节点,把他替换到要删除的节点上。然后删除这个最小节点。因为最小节点肯定没有左子节点,然后应用上面两点来删除这个最小节点。如下图的节点18
public void delete(int data){
Node p = tree; // p指向要删除的节点,初始化指向根节点
Node pp = null; // pp记录的是p的父节点
while(p!=null && p.data!=data){
pp = p;
if(data>p.data){
p = p.right;
}else{
p = p.left;
}
}
if(p==null) return;//未找到
// 要删除的节点有两个子节点
if(p.left!=null && p.right!=null){
// 查找由子树的最小节点
Node minP = p.right;
Node minPP = p;//minPP表示minP的父节点
while(minP.left!=null){
minPP = minP;
minP = minP.left;
}
p.data = minP.data;//将minP的数据替换到p中
p = minP; // 下边就变成删除minP
pp = minPP;
}
// 删除节点是叶子节点或者只有一个子节点
Node child; // p的子节点
if(p.left != null){
child = p.left;
}else if(p.right !=null){
child = p.right;
}else{
child = null;
}
if(pp==null){ // 删除的是根节点
tree = p.left;
}else if(pp.left==p){
pp.left = child;
}else{
pp.right=child;
}
}
其实,删除的话,还可以先标记,但不真正删除,浪费了内存空间,但删除操作简化。
4. 其他操作
除了插入、删除和查找外,还支持快速的查找最大节点和最小节点、前驱节点和后继节点。
// 查找最小节点
public Node findMin(){
if(tree==null) return null;
Node p = tree;
while(p.left!=null){
p = p.left;
}
return p;
}
// 查找最大节点
public Node findMax(){
if(tree==null) return null;
Node p = tree;
while(p.right!=null){
p=p.right;
}
return p;
}
二叉树除了支持上面的操作外,还有一个重要特性,中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度为O(n),非常高效。因此,也叫二叉排序树。
3. 支持重复数据的二叉查找树
实际软件开发中,二叉查找树中存储的是对象。我们利用对象的某个字段作为键值key来构建二叉查找树。我们把对象的其他字段叫卫星数据。
如果存储的两个对象key相同,如何处理?两种解决办法
- 二叉查找树的每个节点不仅会存储一个数据,我们可以通过链表和支持动态扩容的数组等结构,将值相同的数据存储在同一个节点
- 每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们将这个要插入的数据放到这个节点的右子树。也就是说,把这个新插入的数据当做大于这个节点的值来处理。
当要查找数据的时候,遇到值相同的节点,并不停止查找操作,而是继续在右子树查找,直到遇到叶子节点停止。这样把key等于要查找值的所有节点都找出来。
对于删除操作,也需要先找到每个要删除的节点,然后按照之前的删除操作的方法,依次删除。
4. 二叉查找树的时间复杂度分析
二叉查找树的形态各式各样,对于同一种数据,构造三种二叉查找树,其查找、插入、删除操作的效率都不一样。第一种二叉树,已经退化为链表,查询的时间复杂度为O(n)
其实,不管插入、删除还是查找,时间复杂度跟树的高度成正比,也就是O(height)。如何求一棵树包含n个节点的完全二叉树的高度呢?
树的高度等于最大层数减一,转换为层来表示。包含n个节点的完全二叉树,第一层包含1个节点,第二层包含2个节点,第三层包含4个节点,以此类推,下面一层个数是上一层的2倍,第K层包含的节点个数是2^(k-1)
对于完全二叉树来说,最后一层的节点个数其实是在1~2^(L-1)个之间(假设最大层数是L)。如果把每一层的节点个数加起来就是总的节点个数n,也就是说如果节点的个数是n,那么n满足关系
n >= 1+2+4+8+...+2^(L-2)+1
n <= 1+2+4+8+...+2^(L-2)+2^(l-1)
L的范围是[log₂(n+1),log₂n+1],完全二叉树的层数小于等于log₂n+1,也就是说,高度小于等于log₂n
显然,我们需要的是,在任何时候,都能保持任意节点左右子树都比较平衡的二叉查找树,平衡二叉查找树。平衡二叉查找树的高度接近logn,所以,插入、删除、查找时间复杂度比较稳定,为O(logn)
如何求二叉树的高度?递归法,根节点高度=max(左子树高度,右子树高度)+1
5. 相比散列表,二叉查找树的优势
- 散列表的数据无序存储,如果要输出有序的数据,需要先排序。而二叉查找树,只需要中序遍历,就可以在O(n)的时间复杂度内,输出有序的数据序列
- 散列表扩容耗时,且遇到hash冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但工程中常用的平衡二叉查找树性能稳定,时间复杂度O(logn)
- 尽管散列表的查找等操作的时间复杂度是常量级,但因为hash冲突,常量不一定比logn小,实际查找速度不一定比O(logn)快,加上hash函数的耗时,效率不一定高
- 散列表的构造复杂,需要考虑很多东西,如hash函数的设计、hash冲突解决方法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性,解决方案也成熟、固定。
二者并不冲突,根据具体的需求选择使用哪一个。
3. 红黑树
为什么工程中都喜欢用红黑树,而不是其他平衡二叉树?
1. 什么是“平衡二叉查找树”
严格定义:二叉树中任意一个节点的左右子树的高度相差不能大于1。
发明平衡二叉树的初衷是,解决普通二叉查找树在频繁插入、删除等动态更新的情况下,出现时间复杂度退化的问题。
平衡的意思,其实就是让整棵树看起来比较“对称”,比较“平衡”,不出现左子树很高、右子树很矮的情况。相应的插入、删除、查找等操作效率高一些。
2. 如何定义一棵“红黑树”
一般提到平衡二叉查找树,听到的基本都是红黑树,默认平衡二叉查找树就是红黑树。
英文Red-Black Tree,简称RB树,红黑树如何定义的?
红黑树的节点,一类被标记为黑色,一类被标记为红色,此外,一棵红黑树还需满足:
- 根节点是黑色的
- 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据
- 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
- 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点
第二点“叶子结点都是黑色的空节点”,主要是为了简化红黑树的代码实现而设置的,暂时将黑色的、空的叶子节点都省略掉
图例:
3. 为什么说红黑树“近似平衡”
近似平衡也就是性能不会退化的太严重
一棵极其平衡的二叉树(满二叉树或完全二叉树)的高度大约是log₂n,如果要证明红黑树近似平衡,只需要分析,红黑树的高度是否比较稳定的趋近于log₂n
推导过程:
首先,如果将红色节点从红黑树去掉,单纯包含黑色节点的红黑树的高度是多少?
红色节点删除之后,有些节点就没有父节点,直接拿节点的祖父节点作为父节点,之前的二叉树变成四叉树。
红黑树的定义中有一条:从任意节点到可达的叶子节点的每个路径包含相同数目的黑色节点。我们从四叉树中取出某些节点,放到叶子节点位置,四叉树就变成完全二叉树。所以,仅包含黑色节点的四叉树的高度,比包含相同节点个数的完全二叉树的高度要小。
完全二叉树的高度近似log₂n,这里的四叉“黑树”的高度低于完全二叉树,因此,去掉红色节点的“黑树”的高度不会超过log₂n
把红色节点再加回去,高度变多少呢?
从上图中可看出,红色节点不相邻,也就是,有一个红色节点就至少要有一个黑色节点,将它跟其他红色节点隔开。红黑树中包含最多黑色节点的路径不会超过log₂n,所以加入红色节点后,最长路径不超过2log₂n,也就是说,红黑树的高度近似2log₂n
红黑树的高度只比高度平衡的AVL树的高度log₂n大了一倍,在性能上下降的不多。
4. 为什么用红黑树
AVL树是一种高度平衡的二叉树,查找的效率非常高,但为了维持高度的平衡,付出更多代价,每次插入、删除都要进行调整,比较复杂耗时。对于有频繁的插入、删除操作的数据集合,代价较高。
红黑树近似平衡,维护成本较低,插入、删除、查找等操作性能比较稳定。工业级的应用,更倾向于使用它。
红黑树是为了解决普通二叉查找树在数据更新的过程中,复杂度退化而产生的,高度近似log₂n,近似平衡,插入、删除、查找操作时间复杂度都是O(logn)
红黑树的实现较为复杂,自己写代码难度较高,倾向于使用跳表替代它。
4. 红黑树的实现
TODO