前面我们介绍了二分搜索树,以及通过二分搜索树实现的集合和映射这两个更加高层次的数据结构。
树这种数据结构在计算机领域有重要作用,之所以重要,是因为树可以产生很多扩展,面对不同的问题稍微改变或限制树这种数据结构的性质,从而产生不同的数据结构,高效解决不同的问题。
下面的四章中,我们会为大家展示四种不同树的例子: 堆 线段树 字典树 并查集。通过这些不同的树的学习,希望大家可以体会到数据结构的灵活之处,以及设计数据结构时的思考。学到四种不同树形结构,也对数据结构有更深认识。
优先队列
什么是优先队列?
普通队列: 先进先出;后进后出
优先队列:出队顺序和入队顺序无关;优先级高者早出队和优先级相关 (医院,患者优先级)
操作系统中任务的调度,动态选择优先级最高的任务执行。如果我们的任务不是动态,那么我们只需要一个排序算法就好了。
处理旧任务的同时也会有新任务不断的加入,因此此处的关键词(动态)
不能一开始确定有多少个任务,而是动态的不停有新元素入队,也有旧元素出队,因此我们要使用优先队列。
游戏中的AI(LOL中的小兵)
同时面对好几个敌人时,使用优先队列,优先打优先级最高的(距离最近,血最残?)。新的敌人在靠近,动态的。
优先队列的接口(本质依然是个队列)
Interface Queue<E>void enqueue(E)E dequeue()E getFront()int getSize()boolean isEmpty()
对于我们的优先队列来说,实现这些接口的时候,具体这些接口实现出来的功能会有区别。最大的区别在于出队和队首元素是谁这两个操作。此时出队元素应该是优先级最高的元素,队首的元素也是优先级最高的元素,而不是最早进入的那个。
可以使用不同的底层实现:
普通线性结构: 有一项操作"出队"是O(n)级别的,对于n个元素来说,它的时间复杂度就是n^2级别的,相对来说就是一个很慢的了。无论是之前在集合映射中使用链表实现,或者是队列中使用动态数组,因为O(n)的操作而很慢。
顺序线性结构: 维持顺序需要O(n) 出队就只需要O(1)了
它们都有一方面的劣势,可以自己使用动态数组或链表进行实现作为练习,与堆进行比较。
堆的入队操作和出队操作都可以做到O(logn)级别。
而且与我们前面的二分搜索树不同,不是平均在O(logn),而是最差都是O(logn),使得堆相当的高效。
堆的基础表示
堆作为优先队列的底层实现,计算机科学领域,通常见到O(logn)都与树有关,不一定是显式的构造了一棵树;比如排序中归并排序,快速排序都是nlogn级别的,在排序的过程中没有使用树这种数据结构,但是递归的过程其实形成了一棵递归树。
堆本身也是一棵树,二叉堆(Binary Heap)
二叉堆是一棵完全二叉树。
满二叉树概念: 除了叶子节点,所有的节点的左右孩子都不为空,就是一棵满二叉树,如下图。
所有的节点都即拥有左孩子,又拥有右孩子,就是一棵满二叉树。
如上图是一棵完全二叉树,特点: 不一定是一个满二叉树,但它不满的那部分一定在右下侧。满二叉树中有多少层对应有多少节点变成了一个固定的事情。那我们要加入的节点并不是对应一整层,那么上一层满了,下一层从左到右依次排开。
最下面一层肯定都是叶子节点,上面一层即使有叶子节点,也一定分布在右侧。完全二叉树:把元素顺序按层排列成树的形状
二叉堆的性质
堆中某个节点的值总是不大于其父节点的值。(所有节点的值都大于等于它的孩子节点的值)
这样得到的是最大堆,相应的我们也可以定义出最小堆。某种程度上,最大堆和最小堆是可以统一的,因为什么是大,什么是小是我们可以定义的。
只保证自己的父亲节点比自己大,但是节点的层次和节点的大小之间是没有必然联系的。比如,第二层的13,层数比第三层的任何一个元素都小,但是值却并不小于。
因为完全二叉树,按顺序码放。
我们就可以用过这样的一个数组来表示这个完全二叉树。数组存储节点i的左右孩子的规律。
parent(i) = i/2left child(i) = 2*i;right child(i) = 2*i+1;
可以使用数学归纳法来证明上面的关系对于完全二叉树是成立的。一般数组存储二叉堆由1开始,公式如上。如果从0开始,会有一个偏移。
parent(i) = (i-1)/2left child(i) = 2*i+1;right child(i) = 2*i+2;
可以通过该图验证公式。
完成堆的基础实现
package cn.mtianyan.heap;import cn.mtianyan.array.Array;public class MaxHeap<E extends Comparable<E>> { private Array<E> data; /** * 带参构造函数,传入容量,调用带容量的数组构造 * * @param capacity */ public MaxHeap(int capacity) { data = new Array<>(capacity); } /** * 默认无参构造函数 */ public MaxHeap() { data = new Array<>(); } /** * 返回堆中元素个数 * * @return */ public int size() { return data.getSize(); } /** * 返回堆是否为空 * * @return */ public boolean isEmpty() { return data.isEmpty(); } /** * 计算传入的index的父亲节点index值 * * @param index * @return */ private int parent(int index) { if (index == 0) throw new IllegalArgumentException("index 0 doesn't have parent."); return (index - 1) / 2; } /** * 计算传入的index的左孩子index值 * * @param index * @return */ private int left(int index) { return (2 * index + 1); } /** * 计算传入的index的右孩子节点index值 * * @param index * @return */ private int right(int index) { return (2 * index + 2); } }
向堆中添加元素和Sift Up
从用户的角度是添加元素,从堆内部实现角度涉及堆中一个非常常见的操作 Sift Up.
堆中元素上浮的一个过程。
加入元素是非常简单的,直接添加进数组即可,但是可以看到,此时的树结构就不满足最大堆的要求: 父亲最大,16的儿子比16还大了。因此52要执行上浮操作。一个好好的堆怎么就出问题了呢?唯一的原因只会出现在52这个节点的新增,因此只需要将52不断的与它的父亲节点作比较,如果父亲小,就跟父亲互换,一直上浮互换,直到52不比自己的父亲大。
这里52 和 16 和 41 进行了两次互换操作。
编码向堆中添加元素
/** * 交换传入的索引两个位置的元素值 * * @param i * @param j */ public void swap(int i, int j) { if (i < 0 || i >= size || j < 0 || j >= size) throw new IllegalArgumentException("Index is illegal."); E temp = data[i]; data[i] = data[j]; data[j] = temp; }
数组中添加交换两元素位置的方法。
/** * 堆中添加元素方法。 * * @param e */ public void add(E e) { data.addLast(e); siftUp(data.getSize() - 1); } /** * index 为i位置元素上浮。 * * @param i */ private void siftUp(int i) { // 当上浮元素大于父亲,继续上浮。并且不能上浮到0之上 // 直到i 等于 0 或 比 父亲节点小了。 while (i > 0 && data.get(i).compareTo(data.get(parent(i))) > 0) { // 数组Array中添加方法swap data.swap(i, parent(i)); i = parent(i); // 这句话让i来到新的位置,使得循环可以查看新的位置是否还要大。 } }
取出堆中最大元素和Sift Down
最大堆只能取最大的那个元素,数组为0的位置,拿出去之后。将两棵子树合并的操作比较麻烦。
我们可以将最后一个元素填充到堆顶,然后不断的下沉这个元素。每次都与两个孩子进行比较,选择两个孩子中最大的那个元素,如果两个孩子中最大的那个元素比它自己还要大的话,那么它自己就和两个孩子中最大的那个交换位置。16的新位置有可能依然要下沉,再次与孩子比较。
编程实现
/** * 看堆中的最大元素 * * @return */ public E findMax() { if (data.getSize() == 0) throw new IllegalArgumentException("Can not findMax when heap is empty."); return data.get(0); } /** * 取出堆中最大元素 * * @return */ public E extractMax() { E ret = findMax(); data.swap(0, data.getSize() - 1); // 0位置元素和最后一个元素互换。 data.removeLast(); // 删除此时的最后一个元素(最大值) siftDown(0); // 对于0处进行siftDown操作 return ret; } /** * k位置元素下移 * * @param k */ private void siftDown(int k) { // k节点已经是叶子节点没有孩子了肯定不用下沉了。k的左孩子索引=size时已经越界了,肯定没有值了。 while (left(k) < data.getSize()) { int j = left(k); // 在此轮循环中,data[k]和data[j]交换位置 // 右孩子有可能不存在,条件: 有右孩子,右孩子的值大于左孩子的值。 if (j + 1 < data.getSize() && data.get(j + 1).compareTo(data.get(j)) > 0) j++; // 此时j因为++了,存储的是右孩子的索引了。 // data[j] 是 leftChild 和 rightChild 中的最大值 if (data.get(k).compareTo(data.get(j)) >= 0) break; // 大于时没有违反堆的性质,结束。 data.swap(k, j); k = j; } }
package cn.mtianyan;import cn.mtianyan.heap.MaxHeap;import java.util.Random;public class Main { public static void main(String[] args) { int n = 1000000; MaxHeap<Integer> maxHeap = new MaxHeap<>(); Random random = new Random(); for(int i = 0 ; i < n ; i ++) maxHeap.add(random.nextInt(Integer.MAX_VALUE)); int[] arr = new int[n]; for(int i = 0 ; i < n ; i ++) arr[i] = maxHeap.extractMax(); for(int i = 1 ; i < n ; i ++) if(arr[i-1] < arr[i]) throw new IllegalArgumentException("Error"); System.out.println("Test MaxHeap completed."); } }
运行结果:
这里就是堆排序,对于100万数字进行了排序,速度还是很快的。真正的堆排序有优化空间,现在是把数据扔进去,再一个一个取出来,但是利用堆组织数据的思想,完全可以做到原地排序。
add和extractMax时间复杂度都是O(logn) 还是二叉树的高度级别O(h) 但是因为堆是一课完全二叉树,它永远不会退化成一个链表。
Heapify操作和replace操作
replace:取出最大元素后,放入一个新元素
实现: 可以先extractMax,再add,两次O(logn)的操作
实现: 可以直接将堆顶元素替换以后Sift Down,一次O(logn)的操作
/** * 取出堆中的最大元素,并且替换成元素e * @param e * @return */ public E replace(E e){ E ret = findMax(); data.set(0, e); siftDown(0); return ret; }
heapify: 将任意数组整理成堆的形状
/** * 将任意数组整理成堆的形状 * * @param arr */ public MaxHeap(E[] arr) { data = new Array<>(arr); for (int i = parent(arr.length - 1); i >= 0; i--) siftDown(i); }
Heapify是倒着从最后一个元素的父亲节点开始倒着遍历进行siftdown操作。
Heapify的算法复杂度
将n个元素逐个插入到一个空堆中,算法复杂度是O(nlogn)
heapify的过程,算法复杂度为O(n)
package cn.mtianyan.heap;import java.util.Random;public class CompareHeapifyHeap { private static double testHeap(Integer[] testData, boolean isHeapify){ long startTime = System.nanoTime(); MaxHeap<Integer> maxHeap; if(isHeapify) maxHeap = new MaxHeap<>(testData); else{ maxHeap = new MaxHeap<>(); for(int num: testData) maxHeap.add(num); } int[] arr = new int[testData.length]; for(int i = 0 ; i < testData.length ; i ++) arr[i] = maxHeap.extractMax(); for(int i = 1 ; i < testData.length ; i ++) if(arr[i-1] < arr[i]) throw new IllegalArgumentException("Error"); System.out.println("Test MaxHeap completed."); long endTime = System.nanoTime(); return (endTime - startTime) / 1e9; } public static void main(String[] args) { int n = 10000000; Random random = new Random(); Integer[] testData = new Integer[n]; for(int i = 0 ; i < n ; i ++) testData[i] = random.nextInt(Integer.MAX_VALUE); double time1 = testHeap(testData, false); System.out.println("Without heapify: " + time1 + " s"); double time2 = testHeap(testData, true); System.out.println("With heapify: " + time2 + " s"); } }
100 万级别的数据
1000 万级别的数据
可以看出100万时heapify操作的更快一点,但是到了1000万不加heap操作更快。
基于堆的优先队列
package cn.mtianyan.queue;import cn.mtianyan.heap.MaxHeap;public class PriorityQueue<E extends Comparable<E>> implements Queue<E> { private MaxHeap<E> maxHeap; public PriorityQueue(){ maxHeap = new MaxHeap<>(); } @Override public int getSize(){ return maxHeap.size(); } @Override public boolean isEmpty(){ return maxHeap.isEmpty(); } @Override public E getFront(){ return maxHeap.findMax(); } @Override public void enqueue(E e){ maxHeap.add(e); } @Override public E dequeue(){ return maxHeap.extractMax(); } }
作者:天涯明月笙
链接:https://www.jianshu.com/p/b6a9c148cfd2