数据结构与算法--树的理论和应用

目录

二叉查找树

红黑树

递归树

堆排序

堆的应用

参考


 

树的高度,深度,层数的定义下面用图来说明这三个概念的区别
数据结构与算法--树的理论和应用_第1张图片

下面用图来说明这三个概念的区别

数据结构与算法--树的理论和应用_第2张图片

高度,这个概念跟生活中的楼层一样,从下往上数如第10层,12层起点都是地面
深度,是从上往下度量的,比如水中鱼的深度,是从水平面开始度量读
层数,跟深度类似,但计数起点是1

二叉树
满二叉树,叶子节点全都在最底层,出了叶子节点之外,每个节点都有左右两个子节点
完全二叉树,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,除了最后一层其他层的节点个数都要达到最大
数据结构与算法--树的理论和应用_第3张图片

完全二叉树这种特殊的定义,在用数组存储二叉树时候可以避免浪费,数组的节点都用上了没有空间浪费

数据结构与算法--树的理论和应用_第4张图片

二叉树的遍历
前序遍历,对于树种的任意节点,先打印这个节点,再打印它的左子树,最后打印它的右子树
中序遍历,对于树种的任意节点来说,先打印它的左子树,然后打印它本身,最后打印它的右子树
后序遍历,对于树种任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身

数据结构与算法--树的理论和应用_第5张图片

三种遍历的代码实现

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.如果有两个子节点,需要找到这个节点右子树中的最小节点,把它替换到要删除的节点上,再删除这个最小节点,也可以加个删除标记

数据结构与算法--树的理论和应用_第6张图片

支持重复数据的二叉查找树
插入时,如果碰到一个节点的值与要插入的值相同,就将要插入的数据放到这个节点的右子树
查找时,遇到相同节点,继续在右子树种查找,直到遇到叶子节点

数据结构与算法--树的理论和应用_第7张图片

删除时,先查找每个要删除的节点,再按之前的删除方式,以此删除

数据结构与算法--树的理论和应用_第8张图片

完全二叉树的的高度小于等于logn

有了散列表为什么还要用二叉树

  • 散列表中断数据是无序存储的,如果要输出有序数据,需要先排序,二叉树的中序遍历O(n)时间就可以输出了
  • 散列表扩容时很耗时,遇到散列冲突性能不稳定,平衡二叉树的性能非常稳定
  • 尽管散列表的查找操作时间复杂度是常量级,但因哈希冲突这个常量不一定比logn小,实际操作不一定比O(logn)块,加上函数的耗时,也不一定就比平衡二叉树效率高
  • 散列表的构造比二叉树要复杂,需要考虑散列函数的设计,冲突解决,扩容缩容,平衡二叉树只要考虑平衡这一个问题
  • 为避免过多的散列冲突,散列表的装载因子不能太大,特别是基于开放寻址法解决冲突时
     

 

红黑树

平衡二叉树

  • AVL树,查找效率高,但删除/增加维护成本高
  • 红黑树,查找/删除/增加的时间复杂度为0(logn)
  • Treap,
  • Splay Tree,这两个大部分情况下操作效率都很高,但极端情况下时间复杂度会降低

红黑树的定义

  • 根节点是黑色的
  • 每个叶子节点都是黑色的空节点(NIL),即叶子节点不存储数据
  • 任何相邻的节点都不能同时为红色,即红色节点是被黑色节点隔开的
  • 每个节点,从该节点达到其可达叶子节点的所有路径,都包含相同数目的黑色节点

增加,删除操作,可能会破坏第三,第四点
平衡调整操作,就是把第三,第四点恢复过来
红黑树的左旋和右旋操作

数据结构与算法--树的理论和应用_第9张图片


插入操作
红黑树规定,插入的节点必须是红色的,插入的新节点都在叶子节点上,这里有两个特殊情况

  • 如果插入节点的父节点是黑色的,那什么都不用做,它仍满足红黑树的定义
  • 如果插入节点是根节点,直接改变它的颜色,变成黑色就可以了

其他情况都会违背红黑树的定义,就需要用左旋,右旋来调整,并改变颜色
红黑树的平衡调整是一个迭代的过程,把正在处理的节点叫做 关注节点,关注节点会随着不停迭代处理,
而不断变化,最开始的关注节点就是新插入的节点
新节点插入后,如果红黑树的平衡被打破,一般会有三种情况,只需要根据每种情况的特点,不断调整,
就可以让红黑树继续符合规定,也就是继续保持平衡

CASE1,如果关注节点是a,它的叔叔节点d是红色,就执行下面操作

  • 将关注节点a的父节点b,叔叔节点d的颜色都设置成黑色
  • 将关注节点a的祖父节点c的颜色设置成红色
  • 关注节点变成a的祖父节点c
  • 跳到CASE2或者CASE3

数据结构与算法--树的理论和应用_第10张图片

CASE2,如果关注节点是a,它的叔叔节点d是黑色,关注节点a是其父节点b的右子节点,就执行下面操作

  • 关注节点变成a的父节点b
  • 围绕新的关注节点b左旋
  • 跳到CASE3

数据结构与算法--树的理论和应用_第11张图片

CASE3,如果关注节点是a,它的叔叔节点d是黑色,关注节点a是其父节点b的左子节点,就执行下面操作

  • 围绕关注节点a的祖父节点c右旋
  • 将关注节点a的父节点b,兄弟节点c颜色互换
  • 调整结束

数据结构与算法--树的理论和应用_第12张图片

 

删除操作

红黑树的删除操作就要复杂很多,删除分为两步
第一步是针对删除节点初步调整,初步调整至保证整个红黑树满足最后一条定义
每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点
第二步是针对关注节点进行二次调整,让它满足红黑树的第三条定义,不存在两个相邻的红色节点

1.针对删除节点初步调整
红黑树的定义中,只包含红色节点和黑色节点,经过初步调整后,为了保住满足红黑树定义的最后一条,有些节点会被
标记成两种颜色,红-黑 或者 黑-黑,
CASE1,如果要删除的节点是a,它只有一个子节点b,执行下面操作

  • 删除节点a,并且把节点b替换到节点a的位置,这一部分操作跟普通的二叉树的删除操作一样
  • 节点a只能是黑色,街边b也只能是红色,其他情况均不符合红黑树的定义,所以把节点b改成黑色
  • 调整结束,不需要进行二次调整

数据结构与算法--树的理论和应用_第13张图片

CASE2,如果要删除的节点a有两个非空子节点,并且它的后继节点就是节点a的右子节点c,进行下面操作

  • 如果节点a的后继节点就是右子节点c,那右子节点c肯定没有左子树,把节点a删除,并且将节点c替换到节点a的位置
  • 把节点c的颜色设置为跟节点a相同的颜色
  • 如果节点c是黑色,为了不违反红黑树最后一条定义,将c的右子节点d多加一个黑色,d就变成了红-黑 或者 黑-黑
  • 此时关注节点变成节点d,第二步调整操作就会针对关注节点来做

数据结构与算法--树的理论和应用_第14张图片

CASE3,如果要删除的节点是a,它有两个非空子节点,并且节点a的后继节点不是右子节点,执行下面操作

  • 找到后继节点d,将其删除,删除后继节点d的过程类似CASE1
  • 将a节点他喜欢成后继节点d
  • 把节点d的颜色设置成跟节点a相同的颜色
  • 如果节点d是黑色,为了不违反红黑树的最后一条定义,给节点d的右子节点c多加一个黑色,此时节点c是红-黑 或者 黑-黑
  • 这时候,关注节点变成了节点c,第二步的调整操作就会针对关注节点来做

数据结构与算法--树的理论和应用_第15张图片

2.针对关注节点进行二次 调整
经过初步调整之后,关注节点变成了 红-黑 或者 黑-黑,针对这个关注节点
再分为四种情况来进行二次调整,二次调整是为了让红黑树中不存在相邻的红色节点
CASE1,如果关注节点是a,它的兄弟节点c是红色,则执行下面操作

  • 围绕关注节点a的父节点b左旋
  • 关注节点a的父节点b和祖父节点c交换颜色
  • 关注节点不变
  • 继续从四种情况中选择合适的规则来调整

数据结构与算法--树的理论和应用_第16张图片

CASE2,如果关注节点是a,它的兄弟节点c是黑色的,并且节点c的左右子节点d,e都是黑色的,则执行下面操作

  • 将关注节点a的兄弟节点c颜色变成红色
  • 从关注节点a中去掉一个黑色,这时候节点a就是单纯的红色或者黑色了
  • 给关注节点的a的父节点b添加一个黑色,这时候节点b就变成了红-黑 或者 黑-黑
  • 关注节点从a变成其父节点b
  • 继续从四种情况中选择符合的规则来调整

数据结构与算法--树的理论和应用_第17张图片

CASE3,如果关注节点是a,它的兄弟节点c是黑色,c的左子节点d是红色,c的右子节点e是黑色

  • 围绕关注节点a的兄弟节点c右旋
  • 节点c和节点d交换颜色
  • 关注节点不变
  • 跳转到CASE4,继续调整

数据结构与算法--树的理论和应用_第18张图片

CASE4,如果关注节点a的兄弟节点c是黑色的,并且c的右子节点是红色的

  • 围绕关注节点a的父节点b左旋
  • 将关注节点a的兄弟节点c的颜色,跟关注节点a的父节点b设置成相同的颜色
  • 将关注节点a的父节点b的颜色设置为黑色
  • 从关注节点a中去掉一个黑色,节点a就变成了单纯的红色或者黑色
  • 将关注节点a的叔叔节点e设置为黑色
  • 调整结束

数据结构与算法--树的理论和应用_第19张图片

 

 

递归树

借助递归树来分析递归算法的时间复杂度
递归的思想是,将大问题分解为小问题来求解,再将小问题分解为小小问题
把这个一层一层分解的过程画成图,就是一棵树,这棵树就是递归树
下面是归并排序的递归树,每一次是O(n),一共logn层,所以复杂度是O(n*logn)

数据结构与算法--树的理论和应用_第20张图片

快速排序分析
最好的情况下是区间划分后,每次都是一分为二,但这很难
假设分区后,两个分区的大小比列是1:k,当k=9时,用递推公式就是 T(n)=T(n/10)+T(9n/10)+n
用递归树表示如下
数据结构与算法--树的理论和应用_第21张图片

每次分区都要遍历待分区的所有数据,每一层区分操作所遍历的数据个数之和就是n,树的高度是h,复杂度是O(h*n)
快速排序的结束条件是分区大小为1,从根节点n到叶子节点1,递归树种最长路径每次要乘以9/10,最短要乘以1/10

数据结构与算法--树的理论和应用_第22张图片

遍历数据的个数综合就介于 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);
}

递归树如下

数据结构与算法--树的理论和应用_第23张图片

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

它的递归树如下

数据结构与算法--树的理论和应用_第24张图片

第一层有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小时候,容器内有多少细胞?

 

 

堆的特点

  1. 堆是一个完全二叉树
  2. 堆中的每个节点的值都必须大于等于(或小于等于)其子树中每个节点的值

数据结构与算法--树的理论和应用_第25张图片

堆化包括两种
从下往上的堆化
从上往下的堆化

数据结构与算法--树的理论和应用_第26张图片

从下往上堆化的代码实现

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;
    }
  }
 }

删除顶点元素后的堆化方式

数据结构与算法--树的理论和应用_第27张图片

从上往下的堆化代码实现

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.排序
  每次输出堆顶元素
  再从将最后一个元素移到堆顶,并做堆化操作

数据结构与算法--树的理论和应用_第28张图片

排序过程

数据结构与算法--树的理论和应用_第29张图片

堆排序的代码实现

// 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;
  }
}

堆排序的时间复杂度分析

数据结构与算法--树的理论和应用_第30张图片

建堆的时间复杂度为O(n),排序为O(n*logn)

堆排序 vs 快速排序
1.堆排序数据访问方式没有快速排序友好,对CPU缓存不友好
2.对同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序

 

堆的应用

优先级队列
1.合并小文件
100个有序文件合并到一个大文件中
以此从100个文件中拿出第一个元素,组成小根堆
每次删除堆顶元素,再插入一个新元素
重复上述步骤,直到100个文件都遍历完

2.高性能定时器
将定时任务存储在小根堆中,比较当前时间和堆顶元素的时间差
sleep相应的时间,再取堆顶元素
重复上述步骤,直到堆内元素取完

数据结构与算法--树的理论和应用_第31张图片

求Top K
对n个元素先取前K个,建立小根堆
之后遍历 k+1 到 n个元素,如果当前元素比堆顶元素大,删除堆顶元素,并插入新元素
如果比堆顶元素小则不做处理
如果是又有新元素要添加,也是一样的步骤
当要求top k时候,直接输出k个元素数组即可

求中位数
中位数,也就是在中间位置的元素
如果数据个数是奇数,则中间元素是 n/2+1
如果数据个数是偶数,则中间位置元素是 第n/2和第n/2+1

数据结构与算法--树的理论和应用_第32张图片

维护两个堆,一个大根堆,一个小根堆
大根堆存储前半部分数据,小根堆存储后半部分数据
并且小根堆中的数据都大于 大根堆中的数据
于是只要获取大根堆的堆顶元素,小根堆的堆顶元素,即可求出中位数

数据结构与算法--树的理论和应用_第33张图片

假设大根堆元素超过一半了,则删除堆顶元素,插入到小根堆中
这样时刻保持两边平衡,就可以用O(1)时间求出中位数


99%响应时间
一堆数据中前99%的元素,就是99%响应时间

数据结构与算法--树的理论和应用_第34张图片

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树到红黑树

红黑树的演示

数据结构与算法--树的理论和应用_第35张图片

 

 

 

你可能感兴趣的:(算法)