目录
树
二叉查找树
红黑树
递归树
堆
堆排序
堆的应用
参考
下面用图来说明这三个概念的区别
高度,这个概念跟生活中的楼层一样,从下往上数如第10层,12层起点都是地面
深度,是从上往下度量的,比如水中鱼的深度,是从水平面开始度量读
层数,跟深度类似,但计数起点是1
二叉树
满二叉树,叶子节点全都在最底层,出了叶子节点之外,每个节点都有左右两个子节点
完全二叉树,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,除了最后一层其他层的节点个数都要达到最大
完全二叉树这种特殊的定义,在用数组存储二叉树时候可以避免浪费,数组的节点都用上了没有空间浪费
二叉树的遍历
前序遍历,对于树种的任意节点,先打印这个节点,再打印它的左子树,最后打印它的右子树
中序遍历,对于树种的任意节点来说,先打印它的左子树,然后打印它本身,最后打印它的右子树
后序遍历,对于树种任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身
三种遍历的代码实现
void preOrder(Node* root) {
if (root == null) return;
print root // 此处为伪代码,表示打印 root 节点
preOrder(root->left);
preOrder(root->right);
}
void inOrder(Node* root) {
if (root == null) return;
inOrder(root->left);
print root // 此处为伪代码,表示打印 root 节点
inOrder(root->right);
}
void postOrder(Node* root) {
if (root == null) return;
postOrder(root->left);
postOrder(root->right);
print root // 此处为伪代码,表示打印 root 节点
}
遍历二叉树,每个节点最多会被访问两次
所以二叉树遍历的时间复杂度是O(n)
一组数据,1,3,5,6,9,10 可以构建出多少种不同的二叉树
这是卡特兰数,是C[n,2n]/(n+1)种形式,c是组合数,节点的不同又是一种全排列
一共是n! * C[n,2n]/(n+1) 个二叉树
在树种任意一个节点,其左字数中的每个节点的值,都要小于这个节点的值,而右字数节点的值都大于这个节点的值
查找,更新,插入都很容,删除比较复杂有三种情况
1.如果要删除的节点没有子节点,更新父节点指向null即可
2.要删除的节点只有一个子节点(只有左子节点或右子节点),只要更新父节点指向要删除的节点的子节点即可
3.如果有两个子节点,需要找到这个节点右子树中的最小节点,把它替换到要删除的节点上,再删除这个最小节点,也可以加个删除标记
支持重复数据的二叉查找树
插入时,如果碰到一个节点的值与要插入的值相同,就将要插入的数据放到这个节点的右子树
查找时,遇到相同节点,继续在右子树种查找,直到遇到叶子节点
删除时,先查找每个要删除的节点,再按之前的删除方式,以此删除
完全二叉树的的高度小于等于logn
有了散列表为什么还要用二叉树
平衡二叉树
红黑树的定义
增加,删除操作,可能会破坏第三,第四点
平衡调整操作,就是把第三,第四点恢复过来
红黑树的左旋和右旋操作
插入操作
红黑树规定,插入的节点必须是红色的,插入的新节点都在叶子节点上,这里有两个特殊情况
其他情况都会违背红黑树的定义,就需要用左旋,右旋来调整,并改变颜色
红黑树的平衡调整是一个迭代的过程,把正在处理的节点叫做 关注节点,关注节点会随着不停迭代处理,
而不断变化,最开始的关注节点就是新插入的节点
新节点插入后,如果红黑树的平衡被打破,一般会有三种情况,只需要根据每种情况的特点,不断调整,
就可以让红黑树继续符合规定,也就是继续保持平衡
CASE1,如果关注节点是a,它的叔叔节点d是红色,就执行下面操作
CASE2,如果关注节点是a,它的叔叔节点d是黑色,关注节点a是其父节点b的右子节点,就执行下面操作
CASE3,如果关注节点是a,它的叔叔节点d是黑色,关注节点a是其父节点b的左子节点,就执行下面操作
删除操作
红黑树的删除操作就要复杂很多,删除分为两步
第一步是针对删除节点初步调整,初步调整至保证整个红黑树满足最后一条定义
每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点
第二步是针对关注节点进行二次调整,让它满足红黑树的第三条定义,不存在两个相邻的红色节点
1.针对删除节点初步调整
红黑树的定义中,只包含红色节点和黑色节点,经过初步调整后,为了保住满足红黑树定义的最后一条,有些节点会被
标记成两种颜色,红-黑 或者 黑-黑,
CASE1,如果要删除的节点是a,它只有一个子节点b,执行下面操作
CASE2,如果要删除的节点a有两个非空子节点,并且它的后继节点就是节点a的右子节点c,进行下面操作
CASE3,如果要删除的节点是a,它有两个非空子节点,并且节点a的后继节点不是右子节点,执行下面操作
2.针对关注节点进行二次 调整
经过初步调整之后,关注节点变成了 红-黑 或者 黑-黑,针对这个关注节点
再分为四种情况来进行二次调整,二次调整是为了让红黑树中不存在相邻的红色节点
CASE1,如果关注节点是a,它的兄弟节点c是红色,则执行下面操作
CASE2,如果关注节点是a,它的兄弟节点c是黑色的,并且节点c的左右子节点d,e都是黑色的,则执行下面操作
CASE3,如果关注节点是a,它的兄弟节点c是黑色,c的左子节点d是红色,c的右子节点e是黑色
CASE4,如果关注节点a的兄弟节点c是黑色的,并且c的右子节点是红色的
借助递归树来分析递归算法的时间复杂度
递归的思想是,将大问题分解为小问题来求解,再将小问题分解为小小问题
把这个一层一层分解的过程画成图,就是一棵树,这棵树就是递归树
下面是归并排序的递归树,每一次是O(n),一共logn层,所以复杂度是O(n*logn)
快速排序分析
最好的情况下是区间划分后,每次都是一分为二,但这很难
假设分区后,两个分区的大小比列是1:k,当k=9时,用递推公式就是 T(n)=T(n/10)+T(9n/10)+n
用递归树表示如下
每次分区都要遍历待分区的所有数据,每一层区分操作所遍历的数据个数之和就是n,树的高度是h,复杂度是O(h*n)
快速排序的结束条件是分区大小为1,从根节点n到叶子节点1,递归树种最长路径每次要乘以9/10,最短要乘以1/10
遍历数据的个数综合就介于 nlog10n和nlog10/9 n之间,根据大O表示法,可以计算成O(n*logn)
如果k=99,或者999,这个推到仍然是成立的,从概率论角度来说,快速排序平均时间复杂度是O(n*logn)
斐波那契数列的时间复杂度
代码如下
if f(int n) {
if(n==1) return 1;
if(n==2) return 2;
return f(n-1)+f(n-2);
}
递归树如下
f(n)分解为f(n-1)和f(n-2),每次数据规模都是-1或者-2,叶子节点的规模是1或者2
从根节点到叶子节点,每条路径长度不一样,如果每次都-1那最长路径是n,如果每次-2,那最长路径是n/2
每次分解之后合并操作只需要一次加法运算,消耗时间记做1,从上往下第一层总时间消耗是1,第二层是2,第三层是2^2
第k层是2^(k-1),整个算法的总时间消耗是每一层时间消耗之和
这个算法的时间复杂度介于O(2^n)和O(2^(n/2))之间,所以时间复杂度是指数级的
全排列的时间复杂度
比如把1,2,3 这三个数字做全排列,结果如下
123
132
213
231
312
321
它的递归树如下
第一层有n次交换操作,第二层是n*(n-1),第三层是n*(n-1)*(n-2)
最后一个数,n*(n-1)*(n-2)*...*2*1 等于n!
前面的n-1个数都小雨最后一个数,所以综合肯定小于n*n!
所以全排列的递归算法时间复杂度大于O(n!),小于O(n*n!),这个时间复杂度非常高
思考
1个细胞的生命周期是3小时,1小时分裂一次,求n小时候,容器内有多少细胞?
堆的特点
堆化包括两种
从下往上的堆化
从上往下的堆化
从下往上堆化的代码实现
public class Heap {
private int[] a; // 数组,从下标 1 开始存储数据
private int n; // 堆可以存储的最大数据个数
private int count; // 堆中已经存储的数据个数
public Heap(int capacity) {
a = new int[capacity + 1];
n = capacity;
count = 0;
}
public void insert(int data) {
if (count >= n) return; // 堆满了
++count;
a[count] = data;
int i = count;
while (i/2 > 0 && a[i] > a[i/2]) { // 自下往上堆化
swap(a, i, i/2); // swap() 函数作用:交换下标为 i 和 i/2 的两个元素
i = i/2;
}
}
}
删除顶点元素后的堆化方式
从上往下的堆化代码实现
public void removeMax() {
if (count == 0) return -1; // 堆中没有数据
a[1] = a[count];
--count;
heapify(a, count, 1);
}
private void heapify(int[] a, int n, int i) { // 自上往下堆化
while (true) {
int maxPos = i;
if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
if (maxPos == i) break;
swap(a, i, maxPos);
i = maxPos;
}
}
1.建堆大根堆
实现方式可以每次插入一个元素(从下往上堆化)
从上往下堆化
2.排序
每次输出堆顶元素
再从将最后一个元素移到堆顶,并做堆化操作
排序过程
堆排序的代码实现
// n 表示数据的个数,数组 a 中的数据从下标 1 到 n 的位置。
public static void sort(int[] a, int n) {
buildHeap(a, n);
int k = n;
while (k > 1) {
swap(a, 1, k);
--k;
heapify(a, k, 1);
}
}
private static void buildHeap(int[] a, int n) {
for (int i = n/2; i >= 1; --i) {
heapify(a, n, i);
}
}
private static void heapify(int[] a, int n, int i) {
while (true) {
int maxPos = i;
if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
if (maxPos == i) break;
swap(a, i, maxPos);
i = maxPos;
}
}
堆排序的时间复杂度分析
建堆的时间复杂度为O(n),排序为O(n*logn)
堆排序 vs 快速排序
1.堆排序数据访问方式没有快速排序友好,对CPU缓存不友好
2.对同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序
优先级队列
1.合并小文件
100个有序文件合并到一个大文件中
以此从100个文件中拿出第一个元素,组成小根堆
每次删除堆顶元素,再插入一个新元素
重复上述步骤,直到100个文件都遍历完
2.高性能定时器
将定时任务存储在小根堆中,比较当前时间和堆顶元素的时间差
sleep相应的时间,再取堆顶元素
重复上述步骤,直到堆内元素取完
求Top K
对n个元素先取前K个,建立小根堆
之后遍历 k+1 到 n个元素,如果当前元素比堆顶元素大,删除堆顶元素,并插入新元素
如果比堆顶元素小则不做处理
如果是又有新元素要添加,也是一样的步骤
当要求top k时候,直接输出k个元素数组即可
求中位数
中位数,也就是在中间位置的元素
如果数据个数是奇数,则中间元素是 n/2+1
如果数据个数是偶数,则中间位置元素是 第n/2和第n/2+1
维护两个堆,一个大根堆,一个小根堆
大根堆存储前半部分数据,小根堆存储后半部分数据
并且小根堆中的数据都大于 大根堆中的数据
于是只要获取大根堆的堆顶元素,小根堆的堆顶元素,即可求出中位数
假设大根堆元素超过一半了,则删除堆顶元素,插入到小根堆中
这样时刻保持两边平衡,就可以用O(1)时间求出中位数
99%响应时间
一堆数据中前99%的元素,就是99%响应时间
n个数据,将数据从小到大排列之后,99百分位就是第 n*99%个数据
同理,80百分位就是第n*80%个数据
建立大根堆和小根堆,大根堆中的数据占99%,小根堆中的数据占1%
每次新插入元素后,如果大根堆中的数据超过了99%,则要删除插入到小根堆中,保持两边平衡
10个搜索关键词日志文件中,获取Top10最热门关键词
遍历10亿个搜索关键词并做hash,key是关键词,value是出现的次数
之后建立大小为10的小顶堆,遍历散列表,依次和堆顶元素比较,如果大于则删除堆顶元素并插入
考虑到内存有限制
可以对10亿个搜索关键词通过hash分片到10个文件中,比如关键词对10取模就可以了
对每个文件建立Top10的堆,再把这10个堆放在一起,取出100个关键词中出现最多的10个关键词即可
清晰理解红黑树的演变
从2-3树到红黑树
红黑树的演示