数据结构与算法学习③
1、树
前面学习的:数组,链表,栈,队列,散列表,集合等均属于线性数据结构。而树及后面要学习的一些数据结构属于非线性数据结构!
1.1、定义
树在维基百科中的定义为:树(英语:Tree)是一种无向图(undirected graph),其中任意两个顶点间存在唯一一条路径。或者说,只要没有回路的连通图就是树。在计算机科学中,树(英语:tree)是一种抽象数据类型(ADT)或是实现这种抽象数据类型的数据结构,用来模拟具有树状结构性质的数据集合。它是由n(n>0)个有限节点组成一个具有层次关系的集合
这个定义不是特别好懂,我们借助图形来理解就会非常的清晰
以上这些都是树,下面我们再看几个不是树的情况
那树具备什么样的特点呢?
每个节点都只有有限个子节点或无子节点;
没有父节点的节点称为根节点;
每一个非根节点有且只有一个父节点;
除了根节点外,每个子节点可以分为多个不相交的子树;
树里面没有环路(cycle)
比如在下方这副图中:
其中:节点B是节点C D E的父节点,C D E就是B的子节点,C D E之间称为兄弟节点,我们把没有父节点的A节点叫做根节点,我们把没有子节点的节点称为叶子节点如:F G H I K L均是叶子节点。
从节点B开始也是一个树,我们把它叫做根节点A的子树,子树也是一棵树,同样满足树的定义,其他同理可得。
1.2、相关概念
理解了树的定义之后我们来学习几个跟树相关的概念:高度(Heigh),深度(Depth),层(Level),我们依次来看这几个概念:
节点的高度:节点到叶子节点的最长路径(边数),所有叶子节点的高度为0。
节点的深度:根节点到这个节点所经历的边的个数,根的深度为0。二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。
节点的层数:节点的深度+1
树的高度:根节点的高度我们用一幅图来继续说明如下:
2、二叉树
树这种数据结构形式结构是多种多样的,但是在实际企业开发中用的最多的还是二叉树
2.1、二叉树概述
2.1.1、定义及特点
二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。
并且,同理可得,二叉树每个节点的左子树和右子树也分别满足二叉树的定义
有了二叉树的概念定义之后,我们同理可得:三叉树,四叉树,五叉树,…,N叉树。
二叉树符合递归的特征
二叉树的存储特点
很多其他高级数据结构都是基于二叉树,他们的操作特点各有不同,但从存储上来说底层无外乎就是两种:数组存储,链式存储。基于链式存储的树的节点可定义如下:
public class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(){};
TreeNode(int val){this.val=val;};
TreeNode(int val,TreeNode left,TreeNode right){
this.val=val;
this.left=left;
this.right=right;
}
}
用图表示为
依次同理,基于链式存储的N叉树的节点可定义为如下:
public class TreeNode {
int val;
List<TreeNode>children
TreeNode(){};
}
2.1.2、分类
在二叉树中,有几种特殊的情况,分别叫做:满二叉树,完全二叉树
1、满二叉树
叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫作满二叉树
树的高度为 log2n
2、完全二叉树
1:叶子节点都在最底下两层,
2:最后一层的叶子节点都靠左排列(某个节点只有一个叶子节点的情况下),
3:并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫作完全二叉树。
树的高度log2n
满二叉树我们特别容易理解,完全二叉树我们可能就不是特别能够分清楚,下面分析一下看下方图中哪些是完全二叉树?
问题:为什么偏偏把最后一层的叶子节点靠左排列的叫完全二叉树?如果靠右排列就不能叫完全二叉树了吗?原因是什么?
这个主要是基于完全二叉树的存储方式,前面说到想要存储一棵二叉树,我们有两种方法,一种是基于指针或者引用的二叉链式存储法,一种是基于数组的顺序存储法。
而对于完全二叉树我们更倾向采用基于数组的顺序存储方式。具体做法如下:
把根节点存储在下标 i = 1 的位置,那左子节点存储在下标 2 * i = 2 的位置,右子节点存储在 2 * i + 1 =3 的位置。
以此类推,B 节点的左子节点存储在 2i = 2 * 2 = 4 的位置,右子节点存储在 2 * i + 1 = 2 * 2 + 1 = 5 的位置。如下图所示:
从存储结果来看,用数组存储完全二叉树能够有效利用存储空间,我们只是浪费了下标为0的一个存储位置。
当然我们也可以从数组下标为0的位置开始存储完全二叉树的根节点,这样对于任意一个节点存储在下标为k的位置,下标 2 * k +1 的位置存储它的左子节点,下标 2 * k +2 的位置存储它的右子节点,但是为了便于计算我们还是选择从下标为1的位置开始存储。
但如果不是完全二叉树采用数组来进行顺序存储的话,如下图,浪费的存储空间就比较多了
总之:
如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。这也是为什么完全二叉树要求最后一层的子节点都靠左并且除了最后一层,其他层的节点个数都要达到最大的原因。
重要:二叉树的性质:
性质1:在二叉树的第i层上至多有2^(i-1)个结点(i≥1)。
性质2:深度为k的二叉树最多有2^k-1个结点(k≥1)。
性质3:一棵二叉树的度为0结点数为n0,度为2的结点数为n2,则n0 = n2 + 1。
节点的度:节点的子节点个数(指出去的指针数),所以二叉树节点的度分三种0,1,2,
假设一个二叉树有n个节点:
度为0(叶子)的节点个数是n0
度为1的节点个数是n1
度为2的节点个数是n2
则有如下公式成立:n0 = n2 + 1
如果该二叉树是完全二叉树,则有n1=0或者n1=1,因此
当n1=0时:n0 = (n +1) / 2 ,此时n为奇数
当n1=1时,n0= n/2,此时n为偶数
性质4:具有n个结点的完全二叉树的深度为floor(log2 n) + 1 。
2.2、二叉树遍历
前面讲述了二叉树的存储结构,接下来学习二叉树的遍历方式,经典的三种遍历方式:前序遍历,中序遍历,后序遍历
数据结构和算法动态可视化:https://visualgo.net/zh/
前序遍历:对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
对于图中第一棵树前序遍历结果:3467589
中序遍历:对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。对于图中第一棵树中序遍历结果:6473859
后序遍历:对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。对于图中第一棵树后序遍历结果:6748953
二叉树遍历的时间复杂度是多少呢?
通过我们分析的二叉树的遍历流程我们可以发现,遍历二叉树的时间复杂度跟二叉树节点的个数n成正比,因此,二叉树遍历的时间复杂度是O(n)。
2.3、实战题目
144. 二叉树的前序遍历
https://leetcode-cn.com/problems/binary-tree-preorder-traversal/
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer>res=new ArrayList();
recur(root,res);
return res;
}
public void recur(TreeNode node,List<Integer>res){
if(node==null){
return;
}
res.add(node.val);
recur(node.left,res);
recur(node.right,res);
}
时间复杂度是O(n),
空间复杂度:O(height),其中height 表示二叉树的高度。递归函数需要栈空间,而栈空间取决于递归的深度,因此空间复杂度等价于二叉树的高度。
94.二叉树的中序遍历
https://leetcode-cn.com/problems/binary-tree-inorder-traversal/
先左再右
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer>res=new ArrayList();
recur(root,res);
return res;
}
public void recur(TreeNode root,List<Integer> res){
if(root==null){
return;
}
recur(root.left,res);
res.add(root.val);
recur(root.right,res);
}
145. 二叉树的后序遍历
https://leetcode-cn.com/problems/binary-tree-postorder-traversal/
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer>res=new ArrayList();
recur(root,res);
return res;
}
public void recur(TreeNode root,List<Integer> res){
if(root==null){
return;
}
recur(root.left,res);
recur(root.right,res);
res.add(root.val);
}
589. N叉树的前序遍历
https://leetcode-cn.com/problems/n-ary-tree-preorder-traversal/
N叉树前序:先根,然后依次子节点 递归算法实现起来很简单
public List<Integer> preorder(Node root) {
List<Integer>res=new ArrayList();
recur(root,res);
return res;
}
public void recur(Node node,List<Integer>res){
if(node==null){
return;
}
res.add(node.val);
List<Node> children=node.children;
if(children!=null&&children.size()>0){
for(Node n:children){
recur(n,res);
}
}
}
590.N叉树的后序遍历
N叉树后续:子节点(依次)然后根节点,
使用递归算法代码非常简单跟前序遍历几乎一样。
https://leetcode-cn.com/problems/n-ary-tree-postorder-traversal/
public List<Integer> postorder(Node root) {
List<Integer>res=new ArrayList();
recur(root,res);
return res;
}
public void recur(Node root,List<Integer>res){
if(root==null){
return;
}
List<Node> children=root.children;
for(Node o:children){
recur(o,res);
}
res.add(root.val);
}
102. 二叉树的层序遍历
class Solution {
List<List<Integer>>list=new ArrayList<>();
public List<List<Integer>> levelOrder(TreeNode root) {
if(root==null)return new ArrayList<>();
dfs(root,0);
return list;
}
public void dfs(TreeNode root,int level){
if(root==null)return;
if(list.size()-1<level)list.add(new ArrayList<>());
List<Integer>res = list.get(level);
res.add(root.val);
dfs(root.left,level+1);
dfs(root.right,level+1);
}
}
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
if(root==null)return new ArrayList<>();
List<List<Integer>> result=new ArrayList<>();
Queue<TreeNode>queue=new LinkedList<>();
queue.add(root);
while(!queue.isEmpty()){
//获取一层的size
int size=queue.size();
List<Integer> list=new ArrayList<>();
for(int i=0;i<size;i++){
//吐出来后,有左右元素的入栈,是同一层的。
TreeNode node=queue.poll();
list.add(node.val);
if(node.left!=null)queue.add(node.left);
if(node.right!=null)queue.add(node.right);
}
result.add(list);
}
return result;
}
}
101. 对称二叉树
https://leetcode-cn.com/problems/symmetric-tree/
public boolean isSymmetric(TreeNode root) {
if(root==null){
return true;
}
return recur(root.left,root.right);
}
public boolean recur(TreeNode left,TreeNode right){
if(left==null&&right==null){
return true;
}
if(left==null&&right!=null){
return false;
}
if(left!=null&&right==null){
return false;
}
if(left.val!=right.val){
return false;
}
return recur(left.left,right.right)&&recur(left.right,right.left);
}
时间复杂度是O(n),因为要遍历n个节点
空间复杂度取决与递归的深度(跟递归使用的系统栈空间有关),在这里也就是跟该二叉树的高度有关,最坏情况下当二叉树退化成链表时就是O(n)
104. 二叉树的最大深度
public int maxDepth(TreeNode root) {
if(root==null)return 0;
Queue<TreeNode>queue=new LinkedList<>();
queue.add(root);
int dept=0;
while(!queue.isEmpty()){
int size=queue.size();
for(int i=0;i<size;i++){
TreeNode node=queue.poll();
if(node.left!=null)queue.add(node.left);
if(node.right!=null)queue.add(node.right);
}
dept++;
}
return dept;
}
class Solution {
public int maxDepth(TreeNode root) {
if(root==null)return 0;
return Math.max(maxDepth(root.left),maxDepth(root.right))+1;
}
}
226.翻转二叉树
public TreeNode invertTree(TreeNode root) {
recur(root);
return root;
}
public void recur(TreeNode root){
if(root==null){
return;
};
TreeNode temp=root.right;
root.right=root.left;
root.left=temp;
recur(root.left);
recur(root.right);
}
剑指 Offer 27. 二叉树的镜像
https://leetcode-cn.com/problems/er-cha-shu-de-jing-xiang-lcof/
public TreeNode mirrorTree(TreeNode root) {
recur(root);
return root;
}
public void recur(TreeNode root){
if(root==null){
return;
}
TreeNode temp =root.left;
root.left=root.right;
root.right=temp;
recur(root.left);
recur(root.right);
}
106. 从中序与后序遍历序列构造二叉树
https://leetcode-cn.com/problems/construct-binary-tree-from-inorder-and-postorder-traversal/
题解
https://leetcode-cn.com/problems/construct-binary-tree-from-inorder-and-postorder-traversal/solution/tu-jie-gou-zao-er-cha-shu-wei-wan-dai-xu-by-user72/
Map<Integer,Integer>map=new HashMap();
int[]post;
public TreeNode buildTree(int[] inorder, int[] postorder) {
//缓存中序遍历结果的值和对应的数组下标
for(int i=0;i<inorder.length;i++){
map.put(inorder[i],i);
}
post=postorder;
TreeNode root=recurBuild(0,inorder.length-1,0,postorder.length-1);
return root;
}
/*
is和ie分别是某子树中序遍历结果的数组下标开始和结束
ps,pe分别是某子树后序遍历结果的数组下标开始和结束,并且pe位置的值就是这棵子树的根节点
*/
public TreeNode recurBuild(int is,int ie,int ps,int pe){
if(is>ie||ps>pe){
return null;
}
//先找到子树的根节点
TreeNode root=new TreeNode(post[pe]);
//从中序遍历结果中拿到根节点的下标
int ri=map.get(post[pe]);
//分别递归的去构造该根节点的左子树和右子树
root.left=recurBuild(is,ri-1,ps,ps+ri-is-1);
root.right=recurBuild(ri+1,ie,ps+ri-is,pe-1);
return root;
}
105. 从前序与中序遍历序列构造二叉树
class Solution {
HashMap<Integer,Integer>map=new HashMap<>();
int[]post;
public TreeNode buildTree(int[] preorder, int[] inorder) {
for(int i=0;i<inorder.length;i++){
map.put(inorder[i],i);
}
post=preorder;
TreeNode node=buildTree(0,preorder.length-1,0,inorder.length-1);
return node;
}
public TreeNode buildTree(int pl,int pr,int il,int ir){
if(pl>pr||il>ir)return null;
int val=post[pl];
TreeNode root=new TreeNode(val);
int index=map.get(val);
/*
前序遍历 根 左 子 树 右 子 树
pl pl+1 index-1-lf+pl+1=index-lf+pl index-lf+pl+1 pr
中序遍历 左 子 树 根 右 子 树
lf index-1 index index+1 lr
*/
root.left=buildTree(pl+1,index-il+pl,il,index-1);
root.right=buildTree(index-il+pl+1,pr,index+1,ir);
return root;
}
}
2.4 扩展题目
小米2020秋招
已知一棵二叉树前序遍历和中序遍历分别为ABDEGCFH和DBGEACHF,则该二叉树的后序遍历为
A: GEDHFBCA
B: DGEBHFCA
C: ABCDEFGH
D: ACBFEDHG
题解:
1:记住一个特点,二叉树的前序遍历结果中第一个一定是根节点,后续遍历结果中根节点一定在最后
2:二叉树的遍历过程其实是一个递归过程,整个二叉树,它的子树,子子树等的遍历形式一样,所以一定要记住四个字:同理可得!
答案为B
小米2020秋招
一棵有15个节点的完全二叉树和一棵同样有15个节点的普通二叉树,叶子节点的个数最多会差多少个?
A: 3
B: 5
C: 7
D: 9
题解:完全二叉树,除最后一层外,其他层节点数都达到了最大,而15个节点刚好是4层,树的高度是3,叶子节点为8个,要使两棵树叶子节点相差最多,那有15个节点的普通二叉树只能是退化成一个链表,只有一个叶子节点,所以相差最多为7。
小米2020秋招
现有一个包含m个节点的三叉树,即每个节点都有3个指向孩子节点的指针,请问:在这3m个指针中有()个空指针
A: 2m
B: 2m+1
C: 2m-1
D: 3m
题解:对一个一棵三叉树,每个节点都有三个指针,所以m个节点的三叉树总共有3m个指针,但是对于每个节点来说都只需要一个指针指向自己,除了根节点以外,所以m个节点的三叉树需要耗费m-1个指针,因此有 3m-(m-1)= 2m +1 个指针是空指针(总共需要的指针减去需要耗费的指针就是空指针了)
快手2020秋招
如果一棵二叉树的中序遍历是 BAC,那么它的先序遍历不可能是
A: ABC
B: CBA
C: ACB
D: BAC
题解:该题可根据中序和前序遍历特点逐个推导答案为C
京东2020秋招
一颗二叉树的叶子节点有5个,出度为1的结点有3个,该二叉树的结点总个数是?
A: 11
B: 12
C: 13
D: 14
题解:根据二叉树的性质分析答案为12
n1=3 n0=5 n0=n2+1
n1=3 n2=5 n2=4
3+5+4=12
京东2020秋招
完全二叉树699个节点,则叶子节点有多少个?
A: 256
B: 350
C: 352
D: 512
题解:根据上一题题解可直接得出答案为350
n0=(n+1)/2
小米2020秋招
一个二叉树的先序遍历结果和中序遍历结果相同,则其所有非叶子节点必须满足的条件是?
A: 只有左子树
B: 只有右子树
C: 节点的度为1
D: 节点的度为2
题解:还是回归到先序遍历和中序遍历的特点答案选B
先序是左中右,中序是中左右,要一样去左留中右即可
二叉搜索树
1、定义及特点
二叉搜索树又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型。我们来看其定义:
二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值
详细可以分为以下4点:
- 若任意节点的左子树不空,则左子树上所有节点的值均小于它的根节点的值;
- 若任意节点的右子树不空,则右子树上所有节点的值均大于它的根节点的值;
- 任意节点的左、右子树也分别为二叉查找树;
- 没有键值相等的节点。
另外:对于二叉查找树而言,它的中序遍历结果是一个递增的序列!
二叉搜索树有何特点?
我们从二叉搜索树这个名称就能够体会到该树的一个特点,能够支持快速查找,除此之外,二叉查找树支持动态数据的快速插入,删除,查找操作。
这是它的一个操作特点,也正是我们要来学习它的原因。之前也学习过散列表这种数据结构,它也支持这几个操作,并且散列表实现这几个操作更加的高效,时间复杂度是O(1),那既然有了如此高效的散列表为什么还要来学习二叉查找树,是不是有某些情况我们必须使用它?带着这几个问题我们依次展开二叉搜索树这几个特点的相关学习。
2、二叉搜索树的实现
想要深刻的了解二叉搜索树的相关特征,我们先来动手实现一个二叉搜索树。
public class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(){};
TreeNode(int val){this.val=val;};
TreeNode(int val,TreeNode left,TreeNode right){
this.val=val;
this.left=left;
this.right=right;
}
}
定义整棵树的根几点root
private TreeNoderoot;//根节点
重写toString方法,输出该二叉搜索树的中序遍历结果
public String toString(){
StringBuilder sb=new StringBuilder();
inOrder(root,sb);
return sb.toString();
}
private void inOrder(TreeNode node,StringBuilder sb){
if(node==null){
return;
}
inOrder(node.left,sb);
sb.append(node.val).append("->");
inOrder(node.right,sb);
}
2.1、插入操作
二叉搜索树的插入操作要先从根节点开始依次比较要插入的数据和节点的数据的大小并以此来判断是将数据插入其左子树还是右子树。
如果要插入的数据比节点数值大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。
同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。
https://visualgo.net/zh/bst
//添加
public boolean add (int val) {
// 判断树是否为空
if (root==null) {
root = new TreeNode(val);
return true;
}
//从根节点开始进行比较
TreeNode curr = root;
while (curr !=null) {
if (curr.val < val) {
if (curr.right == null) {
curr.right = new TreeNode(val);
return true;
}
curr = curr.right;
}else if (curr.val > val) {
if (curr.left ==null) {
curr.left = new TreeNode(val);
return true;
}
curr = curr.left;
}else {
//要么更新节点的值,要么不操作
//..........
return false;
}
}
return false;
}
2.2、查找操作
我们从根节点开始,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。如图
public TreeNode get(int val){
TreeNode curr=root;
while (curr!=null){
if (val>curr.val){
curr=curr.right;
}else if (val<curr.val){
curr=curr.left;
}else {
return curr;
}
}
return null;
}
思考:有人可能会有疑问,这里每个节点中除了存储值之外就只有两个指针left和right了,我都已经知道值了,还根据值查找节点TreeNode有什么作用呢?
实际工程应用中TreeNode中可以根据需要存储很多数据,这里的值val就相当于一个ID,节点中存储ID和对应的业务数据,查找的时候根据ID查找业务数据。
或者类比HashMap,它的节点中就存储了key,hash值,Value;相关操作都是根据key和hash值来操作的,获取的是存储在节点中存储的Value
2.3、删除操作
二叉搜索树的插入和查询相对来说比较简单易懂,但是删除操作相对复杂,总结下来有三种情况:
1:要删除的节点是叶子节点即没有子节点,我们只需将父节点中指向该节点的指针置为null即可,这是最简单的一种形式。比如删除图中的节点10
2:要删除的节点只有一个子节点(只有左子节点或者只有右子节点),我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。比如删除图中的节点38
3:要删除的节点有两个子节点,这是最复杂的一种情况,我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,我们可以应用上面两条规则来删除这个最小节点。比如删除图中的节点25
按照规则删除之后的树结构为:
编写删除方法remove
//删除
public void remove(int val) {
//定义被删除的节点及它的父节点
TreeNode del = root;
TreeNode del_p = null;
while (del!=null) {
if (val > del.val) {
del_p = del;
del = del.right;
}else if (val < del.val) {
del_p = del;
del = del.left;
}else {
//找到了
break;
}
}
if (del == null) {
return;
}
//根据不同情况来进行操作
//如果被删除的节点有两个子节点
if (del.left !=null && del.right !=null) {
//用被删除节点的右子树上的最小的节点来替换该删除的节点,
//真正要被删除的节点其实是它右子树上最小的这个节点
//要去找到del它右子树上的最小的节点
TreeNode min = del.right;
TreeNode min_p = null;
while (min.left !=null) {
min_p = min;
min = min.left;
}
//现在证明已经找到右子树上最小的节点及其父节点了
//用最小的节点替换要被删除的节点
del.val = min.val;
//真正要被删除的是这个最小的节点
del = min;
del_p = min_p;
}
//只剩两种情况了,要被删除的节点要么没有子节点,要么只有一个子节点
//我们要找到被删除节点的子节点
TreeNode del_child = null;
if (del.left !=null) {
del_child = del.left;
}else if (del.right !=null) {
del_child = del.right;
}
//del,del_p,del_child
//执行删除,del_p指向del的这个指针指向del_child
if (del_p.left == del) {
del_p.left = del_child;
}else if (del_p.right == del) {
del_p.right = del_child;
}
del.left = null;
del.right = null;
}
2.4、查找最值
对于查找二叉查找树的最小值,我们只需要从根节点开始依次查找其左子节点直到最后的叶子节点,最后的叶子节点就是其最小值,同理查找最大值只需要从根节点开始依次查找其右子节点直到最后的叶子节点即为最大值。
//获取BST中的最大值节点
public TreeNode getMax(){
//根节点右子树中的最后侧叶子节点
if (root == null) {
return null;
}
TreeNode max = root;
while (max.right != null) {
max = max.right;
}
return max;
}
//获取BST中的最小值节点
public TreeNode getMin(){
//根节点左子树中的最左侧叶子节点
if (root == null) {
return null;
}
TreeNode min = root;
while (min.left != null) {
min = min.left;
}
return min;
}
测试类
public static void main(String[] args) {
BinarySearchTree bst = new BinarySearchTree();
bst.add(10);
bst.add(2);
bst.add(4);
bst.add(6);
bst.add(10);
bst.add(15);
bst.add(16);
bst.add(17);
bst.add(18);
bst.add(1);
bst.add(3);
bst.add(5);
bst.add(9);
bst.add(10);
bst.add(11);
bst.add(12);
bst.add(13);
bst.add(4);
bst.add(7);
System.out.println(bst);
System.out.println(bst.get(10));
bst.remove(10);
System.out.println(bst);
bst.remove(18);
System.out.println(bst);
bst.remove(12);
System.out.println(bst);
bst.remove(1);
System.out.println(bst);
bst.add(10);
System.out.println(bst);
}
2.5、时间复杂度分析
我们分析一下二叉查找树的查找,插入,删除的相关操作的时间复杂度。
实际上由于二叉查找树的形态各异,时间复杂度也不尽相同,我画了几棵树我们来看一下插入,查找,删除的时间复杂度
对于图中第一种情况属于最坏的情况,二叉查找树已经退化成了链表,左右子树极度不平衡,此时查找的时间复杂度肯定是O(n)。
对于图中第二种或者第三种情况是属于一个比较理想的情况,我们代码的实现逻辑以及图中所示表明插入,查找,删除的时间复杂度其实和树的高度成正比,那也就是说时间复杂度为O(height)
那如何求一棵完全二叉树的高度?即求一棵包含n个节点的完全二叉树的高度?
对于一棵满二叉树而言:树的高度就等于最大层数减一,为了方便计算,我们转换成层来表示。从上图中可以看出,包含 n 个节点的完全二叉树中,第一层包含 1 个节点,第二层包含 2 个节点,第三层包含 4 个节点,依次类推,下面一层节点个数是上一层的 2 倍,第 K 层包含的节点个数就是2^(k-1)。
但是对于完全二叉树来说,最后一层的节点个数有点儿不遵守上面的规律了。它包含的节点个数在 1 个到2^(k-1)个之间(我们假设最大层数是 k)。如果我们把每一层的节点个数加起来就是总的节点个数 n。也就是说,如果节点的个数是 n,那么 n 满足这样一个关系:
1+2+4+8+...+2^(k-2)+1=<n<=1+2+4+8+...+2^(k-2)+2^(k-1)
通过我们的分析我们发现一棵极度不平衡的二叉查找树,它的查找性能和单链表一样。我们需要构建一种不管怎么删除、插入数据,在任何时候,都能保持任意节点左右子树都比较平衡的二叉查找树,这种特殊的二叉查找树也可以叫做平衡二叉查找树。平衡二叉查找树的高度接近logn,所以插入、删除、查找操作的时间复杂度也比较稳定,是 O(logn)。
2.6、对比散列表
之前我们学习过散列表的插入、删除、查找操作的时间复杂度可以做到常量级的 O(1),非常高效。
而二叉搜索树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是 O(logn),相对散列表,好像并没有什么优势,那我们为什么还要用二叉查找树呢?原因有如下几点:
1:散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。
2:散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能也不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn)。
3:尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比 logn 小,所以实际的查找速度可能不一定比 O(logn) 快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
4:散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。
5:为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。综合这几点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。我们在实际的开发过程中,需要结合具体的需求来选择。
3、实战题目
96. 不同的二叉搜索树
https://leetcode-cn.com/problems/unique-binary-search-trees/
1:按照 BST 的定义,如果整数 1 到 n 中的整数 k 作为根节点值,则 1 ~ k-1 会去构建左子树,k+1 ~ n 会去构建右子树。1+2+4+8+…+2(k-2)+1=<n<=1+2+4+8+…+2(k-2)+2^(k-1)1
2:以 k 为根节点的 BST 种类数 = 左子树 BST 种类数 * 右子树 BST 种类数。3:最后问题变成:计算不同的 k 之下,上面等式右边的种类数的累加结果。解题思路:递归初始代码:
numTrees(int n) {
if(n<=1){
return 1;
}
int sum=0;
for(int i=1;i<=n;i++){
sum+=numTrees(i-1)*numTrees(n-i);
}
return sum;
}
时间太慢,为什么?有很多重复计算,如何解决?
记忆化递归
public int numTrees(int n) {
return recur(n,new HashMap());
}
public int recur (int n,HashMap<Integer,Integer>map){
if(n<=1){
map.put(n,1);
return 1;
}
if(map.containsKey(n)){
return map.get(n);
}
int sum=0;
for(int i=1;i<=n;i++){
sum+=recur(i-1,map)*recur(n-i,map);
}
map.put(n,sum);
return sum;
}
95. 不同的二叉搜索树 II
https://leetcode-cn.com/problems/unique-binary-search-trees-ii/
public List<TreeNode> generateTrees(int n) {
if(n<1){
return new ArrayList();
}
return recurGen(1,n);
}
//根据数据区间生成该区间内所有的BST
public List<TreeNode>recurGen(int start,int end){
List<TreeNode>tree=new ArrayList();
if(start>end){
tree.add(null);
return tree;
}
//在[start,end]区间内,依次以i为根点,[start,i-1]为左子树区间,[i+1,end]为右子树区间构造BST
for(int i=start;i<=end;i++){
List<TreeNode>lefts=recurGen(start,i-1);
List<TreeNode>rights=recurGen(i+1,end);
//从左子树集合和右子树集合中分别选一种组合,集合当前节点i构造BST
for(TreeNode left:lefts){
for(TreeNode right:rights){
TreeNode root=new TreeNode(i);
root.left=left;
root.right=right;
//组合好后BST添加到最终集合
tree.add(root);
}
}
}
return tree;
}
108. 将有序数组转换为二叉搜索树
https://leetcode-cn.com/problems/convert-sorted-array-to-binary-search-tree/
public TreeNode sortedArrayToBST(int[] nums) {
return recur(nums,0,nums.length-1);
}
public TreeNode recur(int[] nums,int start,int end){
if(start>end){
return null;
}
//中间节点作为根节点
int mid=(end-start)/2+start;
TreeNode root=new TreeNode(nums[mid]);
root.left=recur(nums,start,mid-1);
root.right=recur(nums,mid+1,end);
return root;
}
144. 二叉树的前序遍历
https://leetcode-cn.com/problems/binary-tree-preorder-traversal/submissions/
public List<Integer> preorderTraversal(TreeNode root) {
if(root==null){
return new ArrayList();
}
Stack<TreeNode>stack=new Stack();
stack.push(root);
List<Integer>res=new ArrayList();
while(!stack.isEmpty()){
//先把中序的root拿出去
TreeNode pop=stack.pop();
res.add(pop.val);
//中序是中左右,因为是栈,先进后出,反一下
if(pop.right!=null){
stack.push(pop.right);
}
if(pop.left!=null){
stack.push(pop.left);
}
}
return res;
}
1、堆的概述
1.1、定义及特点
堆(Heap):是一种可以迅速找到数据集合中最大值或者最小值的数据结构。
堆又可以分为两种:
大顶堆(大根堆):可以迅速找到最大值的堆叫大顶堆
小顶堆(小根堆):可以迅速找到最小值的堆叫小顶堆
https://en.wikipedia.org/wiki/Heap_(data_structure)(维基百科:堆)
1.2、堆的实现形式及复杂度
堆本身是一个抽象的数据结构,它的实现可以有很多种,比如常见的:二叉堆,斐波拉契堆等等!其中面试最为常见的是二叉堆(相对容易实现)但却不是最好的。
对于堆而言,常见的几个操作如下(以大顶堆为例)
常见的实现及对应的复杂度如下图:
2、二叉堆
2.1、二叉堆的定义及特性
二叉堆是通过完全二叉树来实现的(注意不是二叉搜索),它具备以下特性:
- 堆是一个完全二叉树;
- 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值
如下图就是一个大顶堆
要点:完全二叉树如何存储?
对于完全二叉树除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列,当然想到完全二叉树我们立马能够想到我们之前所讲到的完全二叉树可以用数组来进行存储
2.2、二叉堆的实现要点
2.2.1、插入操作
1:新元素一律插入到堆的尾部,
2:从尾部开始依次向上调整堆的结构直到满足堆的定义(一直到根节点结束),我们叫做:heapifyUp
操作过程如下图
2.2.2、删除堆顶操作
1:将堆尾元素替换到顶部,(即堆顶元素被替代删除掉)
2:从顶部开始依次向下调整堆的结构直到满足堆的定义(一直到堆尾即可),我们叫做heapifyDown
删除堆顶元素操作的时间复杂度是O(log n)
面试题目:2020-华为秋招
{0, 2, 1, 4, 3, 9, 5, 8, 6, 7} 是以数组形式存储的小顶堆,删除堆顶元素0后的结果是()
A:{2, 1, 4, 3, 8, 9, 5, 8, 6, 7}
B:{1, 2, 5, 4, 3, 9, 8, 6, 7}
C:{2, 3, 1, 4, 7, 9, 5, 8, 6}
D:{1, 2, 5, 4, 3, 9, 7, 8, 6}
D
2.2.3、获取最值操作
堆的特性就是可以迅速获取一堆元素中的最大值或者最小值,因此对于堆这种数据结构,我们返回其根节点即可,就是最大值或者最小值。时间复杂度是O(1)
总结
二叉堆是堆的一种常见且简单的实现,但并非最优的实现。在工程应用中有用基于二叉堆实现的,也有基于其他更高效的数据结构来实现的,不同的语言实现的不太一样!
3、堆的应用
3.1、优先级队列
队列我们都知道,满足先进先出(FIFO)的特点,但是对于优先级队列出队列的顺序不是按照进入的顺序来的,而是按照优先级来的,优先级高的先出队列,优先级低的后出队列。
如何来实现一个优先级队列呢?
实现的方法有很多但是我们最常用的就是用堆来实现,用堆实现一个优先级队列是最直接和高效的。并且在不同语言中就直接提供了具体的实现,比如java语言中的PriorityQueue,Python中也有第三方的库实现好了。
Java PriorityQueue:基于二叉小顶堆实现,堆顶元素是基于自然排序或者Comparator排序的最小元素。但是是非线程安全的。因此java中也提供了线程安全的优先级队列:PriorityBlockingQueue
对于优先级队列的使用场景有很多,这里例举一个
问题:比如现在有1T的数据需要排序,请问你应该如何做?
首先我们可以用100个文件来存储这1T数据,那在每条数据产生的时候对数据进行哈希求值然后对100取模决定将该条数据存储到哪个文件中,这样可以将1T数据分配到100个文件中,接下来我们分别对每个文件中的数据进行排序,这个排序可以采用各种排序算法来解决,对每个小文件排好序之后剩余的就是将这100个文件合并成一个有序的大文件,在这个合并的过程中我们就可以使用到优先级队列。
具体做法是:
1:从100个文件中各取第一条数据,依次存入实现设定好的小顶堆中,这样堆顶元素就是优先级队列队首的元素,也就是最小的数据,我们将堆顶元素取出存储到最终合并好的大文件中,然后删除堆顶元素。
2:如果刚刚删除的堆顶元素来自xx,则从该文件中取出下一个数据存入到优先级队列中,然后再取堆顶元素存储到合并后的大文件中,删除堆顶元素,然后循环这个过程,就可以将100个文件中的数据依次的存储到最终的大文件中。
3.2、Top K问题
刚刚讲到利用堆实现一个优先级队列,此外堆还有另外一个非常重要的应用场景,那就是经典的求Top K问
题。
对于Top K问题的求解我们大致可以分为两类情况:
情况1:针对一组事先已经确定好的静态数据
比如:从包含n个数据的数组中,查找前K大数据,我们可以维护一个大小为K的小顶堆,顺序遍历数组向堆中添加元素,会出现以下两种情况:
1:如果堆中元素不足k个,则一直向堆中添加数据
2:如果堆中元素已够k个,则判断新加入元素和堆顶元素的大小,如果新加入元素比堆顶元素小则不做处理继续遍历数组,如果数组元素比堆顶元素大,则将堆顶元素删除,将数组元素加入到堆中这样等数组遍历完后,堆中的数据就是前K大数据了。
注意:如果是要找前K小的数据应该怎么做?
维护一个大小为k的大顶堆,同理,顺序遍历数据集依次向堆中添加元素,也会出现以下两种情况:
1:堆中元素不足k个,则一直向堆中添加数据
2:堆中元素已够k个,则判断新加入元素是否比堆顶元素小,如果比堆顶元素小则删除堆顶元素并加入新元素,如果并堆顶元素大则不做操作继续下一个数据。
类似于:
遍历数组需要 O(n) 的时间复杂度,一次堆化操作需要 O(log K) 的时间复杂度,所以最坏情况下,n个元素都入堆一次,时间复杂度就是 O(n * logK)
情况2:针对一组动态数据,可能随时有数据的加入和移除
这种情况其实就是实时 Top K。
操作思路是跟情况1一致,只不过所有操作都是实时的,有数据流时就操作。
注意:TOP K问题的最高效解法是利用快排思想来解决!这个后续课程会讨论。
另外其实TOP K问题,求前k大,也可以用大顶堆来实现,但是我们需要将所有数据先添加入堆中,然后再依次从堆中删除堆顶元素,直到删除k个为止,这样也能实现,只不过时间复杂度是O(n * log n),相对于前面讲的用小顶堆实现复杂度要略高。并且要实现实时 Top K不太好实现!
求前k小问题也同理可以用小顶堆来实现!
3.3、99%响应时间
比如在我们的工作中可能会遇到这样一个业务需求:如何实时统计业务接口的99%响应时间?
我们先来解释一个概念:99百分位数
99百分位数的概念可以类比中位数,中位数的概念就是将数据从小到大排列,处于中间位置,就叫中位数,这
个数据会大于等于前面50%的数据。如果将一组数据从小到大排列,这个 99 百分位数就是大于前面 99% 数据的那
个数据。 如果你还是不太理解,我再举个例子。假设有 100 个数据,分别是 1,2,3,……,100,那 99 百分位
数就是 99,因为小于等于 99 的数占总个数的 99%。
弄懂了这个概念之后我们再来看 99% 响应时间。如果有 100 个接口访问请求,每个接口请求的响应时间都不同,比如 55 毫秒、100 毫秒、23 毫秒等,我们把这 100 个接口的响应时间按照从小到大排列,排在第 99 的那个数据就是 99% 响应时间,也叫 99 百分位响应时间。
我们总结一下,如果有 n 个数据,将数据从小到大排列之后,99 百分位数大约就是第 n99% 个数 据,同类,80 百分位数大约就是第 n80% 个数据。
那如何求解99%响应时间?
我们维护两个堆,一个大顶堆,一个小顶堆。假设当前总数据的个数是 n,大顶堆中保存 n * 99% 个数据,小顶堆中保存 n*1% 个数据。大顶堆堆顶的数据就是我们要找的 99% 响应时间。每次插入一个数据的时候,我们要判断这个数据跟大顶堆和小顶堆堆顶数据的大小关系,然后决定插入到哪个堆中。如果这个新插入的数据比大顶堆的堆顶数据小,那就插入大顶堆;如果这个新插入的数据比小顶堆的堆顶数据大,那就插入小顶堆。但是,为了保持大顶堆中的数据占 99%,小顶堆中的数据占 1%,在每次新插入数据之后,我们都要重新计算,这个时候大顶堆和小顶堆中的数据个数,是否还符合 99:1 这个比例。如果不符合,我们就将一个堆中的数据移动到另一个堆。移动的方式就是取其中一个堆的堆顶数据存入另一个堆。通过这样的方法,每次插入数据,可能会涉及几个数据的堆化操作,所以时间复杂度是 O(logn)。每次求 99%响应时间的时候,直接返回大顶堆中的堆顶数据即可,时间复杂度是 O(1)。
4、面试实战
https://leetcode-cn.com/problems/zui-xiao-de-kge-shu-lcof/
解法1:排序,然后找前k个,时间复杂度是O(n * log n)
解法2:使用优先级队列(二叉小顶堆),小顶堆求前k小,需要先将所有数据添加到堆,然后依次取出堆顶元素,取k个
public int[] getLeastNumbers(int[] arr, int k) {
//优先级队列基于二叉小顶堆
PriorityQueue<Integer> queue=new PriorityQueue();
for(int i=0;i<arr.length;i++){
queue.offer(arr[i]);
}
int[]res=new int[k];
for(int i=0;i<k;i++){
res[i]=queue.poll();
}
return res;
}
时间复杂度:O(n * log n)
空间复杂度:O(n)
解法3:求前k小,用大顶堆实现可以达到O(n * log k)的复杂度
public int[] getLeastNumbers(int[] arr, int k) {
if(arr==null||arr.length==0||k==0){
return new int[]{};
}
//小顶堆变成大顶堆
PriorityQueue<Integer> queue=new PriorityQueue<>(k,(v1,v2)->Integer.compare(v2,v1));
for(int i=0;i<arr.length;i++){
//所有元素加入堆
if(queue.size()<k){
queue.offer(arr[i]);
}else if(queue.peek()>arr[i]){
queue.poll();
queue.offer(arr[i]);
}
}
//一次从对顶取元素,保证了数据的顺序性
int[]res=new int[k];
int i=0;
for(int e:queue){
res[i++]=e;
}
return res;
}
Comparator是一个函数式接口(有@FunctionalInterface注解),说明可以使用Lambda表达式完成比较操作,并且其中T指的是你要比较的类型
- 在这个接口中必须要实现的方法是int compare(T o1, T o2);
- 排序规则:传递的参数是第一个是o1,第二个是o2,比较的时候也是用o1- o2进行比较,那么就是
升序;如果比较的时候是用反过来o2-o1进行比较,那么就是降序
215. 数组中的第K个最大元素
https://leetcode-cn.com/problems/kth-largest-element-in-an-array/
public int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer>queue=new PriorityQueue<>(k,(v1,v2)->Integer.compare(v2,v1));
for(int i=0;i<nums.length;i++){
queue.offer(nums[i]);
}
for(int i=0;i<k-1;i++){
queue.poll();
}
return queue.poll();
}
239. 滑动窗口最大值
https://leetcode-cn.com/problems/sliding-window-maximum/
public int[] maxSlidingWindow(int[] nums, int k) {
if(nums.length==0 || k==0){
return new int[]{};
}
int n=nums.length;
int[]res=new int[n-k+1];
int max=0;
//大顶堆
Queue<Integer> queue = new PriorityQueue<>((v1,v2)-> v2-v1);
for(int i=0;i<n;i++){
int start=i-k;
//如果满了,删除最前面一个
if(start>=0){
queue.remove(nums[start]);
}
queue.offer(nums[i]);
//大顶堆,出来就是最大元素
if(queue.size()==k){
res[max++]=queue.peek();
}
}
return res;
}
注意:最终提交时未AC,超时,原因是:虽然offer是O(log k)的,但是remove操作是O(k)的,所以遍历完整个数组,时间复杂度是O(n * k)的
最终解法之前用单调队列完成过!
347. 前 K 个高频元素
https://leetcode-cn.com/problems/top-k-frequent-elements/
hash+priorityQueue
1:借助 哈希表 来建立数字和其出现次数的映射,遍历一遍数组统计元素的频率
2:维护一个元素数目为 k 的最小堆
3:每次都将新的元素与堆顶元素(堆中频率最小的元素)进行比较
4:如果新的元素的频率比堆顶端的元素大,则弹出堆顶端的元素,将新的元素添加进堆中
最终,堆中的 kk 个元素即为前 kk 个高频元素
public int[] topKFrequent(int[] nums, int k) {
HashMap<Integer,Integer> map=new HashMap();
for(int num:nums){
if(map.containsKey(num)){
map.put(num,map.get(num)+1);
}else{
map.put(num,1);
}
}
PriorityQueue<Integer> queue=new PriorityQueue<>(k,(v1,v2)->map.get(v1)-map.get(v2));
for(Integer key:map.keySet()){
if(queue.size()<k){
queue.offer(key);
}else if(map.get(key)>map.get(queue.peek())){
queue.poll();
queue.offer(key);
}
}
int[] res=new int[k];
int i=0;
while(!queue.isEmpty()){
res[i++]=queue.poll();
}
return res;
}
264. 丑数 II
https://leetcode-cn.com/problems/ugly-number-ii/
public int nthUglyNumber(int n) {
//定义数组和第一个数字
int[] nums = new int[n];
nums[0] = 1;
int i2 = 0, i3 = 0, i5 = 0;
int temp = 1;
//1非常关键,开始没有需要这个值使用起来,拿到最小值,一个一个加
for (int i = 1; i < n; i++) {
temp = Math.min(Math.min(nums[i2] * 2, nums[i3] * 3), nums[i5] * 5);
nums[i] = temp;
//去重的关键
if (temp == nums[i2] * 2) {
i2++;
}
if (temp == nums[i3] * 3) {
i3++;
}
if (temp == nums[i5] * 5) {
i5++;
}
}
return nums[n - 1];
}