堆原理分析及优先级队列实现

忘是亡心i 2024-03-27 09:32 174阅读 0赞

前言

是一颗具有特定性质的二叉树,基于堆可以实现优先级队列,在搜索插入删除操作上的时间复杂度均为O(logn),在找到最大最小元素操作上的时间复杂度均为O(1)。本篇文章将对堆的原理和性质进行分析,并提供基于JAVA语言的堆的实现,最后会再基于堆,实现一个简单优先级队列。

正文

一. 堆的定义

堆的定义如下所示。

  • 堆是一颗完全二叉树;
  • 父节点的元素如果总是大于等于子节点,称之为大根堆
  • 父节点的元素如果总是小于等于子节点,称之为小根堆

要理解堆,首先需要了解什么是完全二叉树。完全二叉树定义为:如果二叉树的深度为h,那么除第h层外,其余第1h-1层的节点数都达到最大值,第h层的所有节点都连续集中在左边。一颗完全二叉树如下所示。

在这里插入图片描述

大根堆如下所示。

在这里插入图片描述

小根堆如下所示。

在这里插入图片描述

通常,堆中元素用一个数组来存放。以上图的小根堆为例,节点旁边的数字代表当前节点在数组中的索引,可以发现其规律就是:按照层序遍历的方式遍历完全二叉树,并每遍历一个节点就将当前节点的元素添加到数组中。所以上图的小根堆以数组表示如下。

在这里插入图片描述

最后,可以根据堆中某个节点在数组中的索引来计算这个节点的左右子节点和父节点在数组中的索引。计算的JAVA代码如下所示。

  1. public class HeapTry {
  2. public int getLeft(int i) {
  3. return (i + 1) * 2 - 1;
  4. }
  5. public int getRigth(int i) {
  6. return (i + 1) * 2;
  7. }
  8. public int getParent(int i) {
  9. if (i == 0) {
  10. return 0;
  11. }
  12. return (i - 1) / 2;
  13. }
  14. // 省略其它
  15. }

二. 堆的性质

  • 大根堆的性质:父节点的元素总是大于等于子节点
  • 小根堆的性质:父节点的元素总是小于等于子节点

以小根堆为例,考虑这样一个场景:给定一个小根堆,然后将根节点的元素删除并为根节点添加一个新的元素,新的元素不满足小于等于子节点的元素,此时小根堆的性质遭到破坏。图示如下。

在这里插入图片描述

对于上述场景,如果需要恢复小根堆的性质,则以根节点作为第一个父节点,按照如下步骤执行。

  1. 将父节点与子节点的元素进行比较并得到元素最小的节点,如果元素最小的节点是父节点,那么已经恢复小根堆的性质,执行结束;如果元素最小的节点是某个子节点,执行步骤2;
  2. 将父节点与元素最小的子节点互换元素,由于互换元素后子节点有可能破坏小根堆性质,因此从子节点开始,执行步骤1,直到恢复小根堆性质。

图示如下。

在这里插入图片描述

将恢复小根堆性质的算法用JAVA代码实现如下。

  1. public class HeapTry {
  2. // 省略其它
  3. public void keepHeap(int[] array, int i, int size) {
  4. // 通过父节点计算左右子节点的数组索引
  5. int left = getLeft(i);
  6. int rigth = getRigth(i);
  7. // 最小节点的数组索引
  8. int smallest;
  9. // 比较得到父节点和子节点中的最小节点的数组索引
  10. if (left < size) {
  11. smallest = (array[i] <= array[left]) ? i : left;
  12. } else {
  13. smallest = i;
  14. }
  15. if (rigth < size) {
  16. smallest = (array[smallest] <= array[rigth]) ? smallest : rigth;
  17. }
  18. // 如果i与smallest不相等,表明父节点不满足小于等于子节点
  19. // 将父节点元素与最小子节点元素进行交换
  20. if (i != smallest) {
  21. int temp = array[i];
  22. array[i] = array[smallest];
  23. array[smallest] = temp;
  24. // 递归以防止交换后子节点不满足小根堆性质
  25. keepHeap(array, smallest, size);
  26. }
  27. }
  28. }

大根堆与小根堆类似,这里就不再举大根堆的例子了。

三. 堆的构建

如果给定一个数组,里面元素并没有按照堆的性质进行放置,此时若需要根据数组中的元素构建一个堆,则应该让所有非叶子节点保持堆的性质(叶子节点总是满足堆的性质)。已经知道,一个堆的叶子节点总是在数组的最后部分,因此通过计算最后一个叶子节点(即数组中的最后一个位置)的父节点,就可以得到最后一个非叶子节点,该非叶子节点往前的所有节点都是非叶子节点,所以从最后一个非叶子节点开始往前,让每一个非叶子节点保持堆的性质,就可以构建一个堆。图示如下。

在这里插入图片描述

可以看到,叶子节点总是在数组的最后部分,并且通过计算最后一个叶子节点(数组索引为5)的父节点可以得到最后一个非叶子节点(数组索引为2)。

构建堆的JAVA代码如下所示。

  1. public class HeapTry {
  2. // 省略其它
  3. public void createHeap(int[] array, int size) {
  4. // 计算最后一个非叶子节点的数组索引
  5. int lastParent = getParent(size - 1);
  6. // 从最后一个非叶子节点往前遍历每一个非叶子节点,并让每个非叶子节点保持堆性质
  7. for (int i = lastParent; i >= 0; i--) {
  8. keepHeap(array, i, size);
  9. }
  10. }
  11. }

四. 堆的排序

因为小根堆的根节点的元素总是整个堆中的最小元素,大根堆的根节点的元素总是整个堆中的最大元素,因此可以基于堆的性质实现排序算法(通常小根堆实现降序排序大根堆实现升序排序)。给定一个小根堆,且一共有n个元素,堆排序算法步骤如下。

  1. 将小根堆的根节点与堆最后一个节点互换元素,然后执行步骤2;
  2. 将前n-1个节点重新构建一个小根堆,并且令n=n-1,如果n0则排序结束,否则执行步骤1。

图解流程如下所示。

在这里插入图片描述

堆排序(小根堆)的JAVA代码如下所示。

  1. public class HeapTry {
  2. // 省略其它
  3. public void heapSort(int[] array, int size) {
  4. if (size == 0) {
  5. return;
  6. }
  7. while (size > 0) {
  8. int temp = array[0];
  9. array[0] = array[size - 1];
  10. array[size - 1] = temp;
  11. createHeap(array, size - 1);
  12. size = size - 1;
  13. }
  14. }
  15. }

五. 插入和删除

如果要基于堆实现一个优先级队列,那么堆需要能够支持插入和删除元素。首先是元素的插入,以小根堆为例,将需要插入的元素添加到堆的最后以生成最后一个叶子节点,然后将其和父节点进行比较,如果其小于父节点则和父节点互换元素,然后继续和新位置对应的父节点进行比较,直到插入的元素大于等于父节点或者插入的元素成为根节点的元素为止。图示如下。

在这里插入图片描述

然后是元素的删除,同样以小根堆为例,每次删除都应该删除堆中的最小元素,即根节点的元素,然后将堆的最后一个叶子节点的元素填充到根节点,此时填充到根节点的元素可能破坏堆的性质,因此需要使用第二小节中的算法来恢复根节点的堆的性质。图示如下。

在这里插入图片描述

堆的插入和删除的JAVA代码如下所示。

  1. public class HeapTry {
  2. // 省略其它
  3. public void insert(int[] array, int size, int i) {
  4. array[size] = i;
  5. int index = size;
  6. while (array[index] < array[getParent(index)]) {
  7. int temp = array[index];
  8. array[index] = array[getParent(index)];
  9. array[getParent(index)] = temp;
  10. index = (index - 1) / 2;
  11. }
  12. }
  13. public int pop(int[] array, int size) {
  14. if (size <= 0) {
  15. throw new IndexOutOfBoundsException();
  16. }
  17. int result = array[0];
  18. array[0] = array[size - 1];
  19. array[size - 1] = 0;
  20. keepHeap(array, 0, size - 1);
  21. return result;
  22. }
  23. }

六. 实现优先级队列

本小节实现的优先级队列基于大根堆进行实现。要基于堆实现优先级队列,首先需要设计队列中的元素。先给出队列元素的JAVA代码,然后再进行说明。

  1. public class PriObject<V> {
  2. private final V value;
  3. private final int priority;
  4. private final LocalDateTime dateTime;
  5. public PriObject(V value, int priority) {
  6. this.value = value;
  7. this.priority = priority;
  8. dateTime = LocalDateTime.now();
  9. }
  10. public V getValue() {
  11. return value;
  12. }
  13. public int getPriority() {
  14. return priority;
  15. }
  16. public LocalDateTime getDateTime() {
  17. return dateTime;
  18. }
  19. public int compareTo(PriObject<?> priObject) {
  20. if (priority > priObject.getPriority()) {
  21. return 1;
  22. } else if (priority < priObject.getPriority()) {
  23. return -1;
  24. } else {
  25. return -dateTime.compareTo(priObject.getDateTime());
  26. }
  27. }
  28. }

队列元素类PriObject有三个属性,含义如下。

  • value表示元素的值;
  • priority表示元素的优先级,这里设定值越大优先级越高;
  • dateTime表示元素的生成时间,当向优先级队列添加value时,优先级队列会将value封装成一个PriObject对象,dateTime则表示这个元素的生成时间,用于两个元素priority相等时判断优先级,设定时间越早越优先。

队列元素类PriObject还提供了compareTo()方法用于两个元素的比较,比较规则为:priority越大越优先,如果priority相等则dateTime越小越优先。

下面看一下优先级队列PriorityQueue的字段。

  1. public class PriorityQueue<T> {
  2. // 堆数组初始容量为16
  3. private final int INITAIL_SIZE = 16;
  4. // 堆数组
  5. private PriObject<?>[] array = new PriObject<?>[INITAIL_SIZE];
  6. // 堆元素个数
  7. private final AtomicInteger size = new AtomicInteger(0);
  8. // 全局锁
  9. private final Lock lock = new ReentrantLock();
  10. // 省略其它
  11. }

PriorityQueue的全局锁字段用于优先级队列在offer()元素和poll()元素时线程安全。

PriorityQueue内部也使用了方法来获取左右子节点和父节点的数组索引以及保持堆性质,如下所示。

  1. public class PriorityQueue<T> {
  2. // 省略其它
  3. private int getLeft(int i) {
  4. return (i + 1) * 2 - 1;
  5. }
  6. private int getRight(int i) {
  7. return (i + 1) * 2;
  8. }
  9. private int getParent(int i) {
  10. if (i == 0) {
  11. return 0;
  12. }
  13. return (i - 1) / 2;
  14. }
  15. private void keepHeap(int i, int size) {
  16. int left = getLeft(i);
  17. int rigth = getRight(i);
  18. int max;
  19. if (left < size) {
  20. max = (array[i].compareTo(array[left]) > 0) ? i : left;
  21. } else {
  22. max = i;
  23. }
  24. if (rigth < size) {
  25. max = (array[max].compareTo(array[rigth]) > 0) ? max : rigth;
  26. }
  27. if (i != max) {
  28. PriObject<?> temp = array[i];
  29. array[i] = array[max];
  30. array[max] = temp;
  31. keepHeap(max, size);
  32. }
  33. }
  34. // 省略其它
  35. }

下面看一下PriorityQueueoffer()方法。

  1. public class PriorityQueue<T> {
  2. // 省略其它
  3. /**
  4. * 添加元素到队列,每一个元素会被封装为一个{@link PriObject}对象
  5. * 然后被添加到队列中。
  6. *
  7. * @param value 队列存储的元素
  8. * @param priority 优先级
  9. * @return 添加成功返回true,失败返回false
  10. */
  11. public boolean offer(T value, int priority) {
  12. lock.lock();
  13. try {
  14. PriObject<T> pObj = new PriObject<>(value, priority);
  15. if (size.get() == array.length) {
  16. resize();
  17. }
  18. int index = size.get();
  19. array[index] = pObj;
  20. while (array[index].compareTo(array[getParent(index)]) > 0) {
  21. PriObject<?> temp = array[index];
  22. array[index] = array[getParent(index)];
  23. array[getParent(index)] = temp;
  24. index = getParent(index);
  25. }
  26. size.incrementAndGet();
  27. } catch (Exception e) {
  28. return false;
  29. } finally {
  30. lock.unlock();
  31. }
  32. return true;
  33. }
  34. private void resize() {
  35. int oldSize = array.length;
  36. int newSize = oldSize << 1;
  37. array = Arrays.copyOf(array, newSize);
  38. }
  39. // 省略其它
  40. }

在向优先级队列添加一个元素时,如果元素个数在添加前已经等于堆数组长度,此时会触发扩容机制,并且扩容后容量为扩容前的一倍。

下面再看一下PriorityQueuepoll()方法。

  1. public class PriorityQueue<T> {
  2. // 省略其它
  3. public T poll() {
  4. T value;
  5. lock.lock();
  6. try {
  7. int currentSize = size.get();
  8. if (currentSize == 0) {
  9. return null;
  10. }
  11. value = (T) array[0].getValue();
  12. array[0] = array[currentSize - 1];
  13. array[currentSize - 1] = null;
  14. keepHeap(0, currentSize - 1);
  15. size.decrementAndGet();
  16. } catch (Exception e) {
  17. value = null;
  18. } finally {
  19. lock.unlock();
  20. }
  21. return value;
  22. }
  23. }

最后编写测试程序来验证实现的优先级队列的功能。测试代码如下所示。

  1. public class PriorityQueueTest {
  2. private static final String EVENT_1 = "事件1";
  3. private static final String EVENT_2 = "事件2";
  4. private static final String EVENT_3 = "事件3";
  5. @Test
  6. public void 给定不同优先级事件_当调用offer方法_可以根据优先级正确获取元素() {
  7. Event event1 = new Event(EVENT_1);
  8. Event event2 = new Event(EVENT_2);
  9. Event event3 = new Event(EVENT_3);
  10. PriorityQueue<Event> priorityQueue = new PriorityQueue<>();
  11. priorityQueue.offer(event1, 10);
  12. priorityQueue.offer(event2, 20);
  13. priorityQueue.offer(event3, 5);
  14. assertThat(priorityQueue.poll().getEvent(), is(EVENT_2));
  15. assertThat(priorityQueue.poll().getEvent(), is(EVENT_1));
  16. assertThat(priorityQueue.poll().getEvent(), is(EVENT_3));
  17. }
  18. @Test
  19. public void 给定相同优先级事件_当调用offer方法_可以根据添加时间正确获取元素() {
  20. Event event1 = new Event(EVENT_1);
  21. Event event2 = new Event(EVENT_2);
  22. Event event3 = new Event(EVENT_3);
  23. PriorityQueue<Event> priorityQueue = new PriorityQueue<>();
  24. // 添加事件1,并睡眠10毫秒
  25. priorityQueue.offer(event1, 10);
  26. sleep10Millisecond();
  27. // 添加事件2,并睡眠10毫秒
  28. priorityQueue.offer(event2, 10);
  29. sleep10Millisecond();
  30. // 添加事件3
  31. priorityQueue.offer(event3, 10);
  32. assertThat(priorityQueue.poll().getEvent(), is(EVENT_1));
  33. assertThat(priorityQueue.poll().getEvent(), is(EVENT_2));
  34. assertThat(priorityQueue.poll().getEvent(), is(EVENT_3));
  35. }
  36. @Test
  37. public void 给定17个不同优先级的事件_当调用offer方法_触发扩容() {
  38. String eventString = "事件";
  39. PriorityQueue<Event> priorityQueue = new PriorityQueue<>();
  40. for (int i = 0; i < 17; i++) {
  41. Event event = new Event(eventString + i);
  42. priorityQueue.offer(event, i);
  43. }
  44. for (int i = 16; i >= 0; i--) {
  45. assertThat(priorityQueue.poll().getEvent(), is(eventString + i));
  46. }
  47. }
  48. @Test
  49. public void 给定17个相同优先级的事件_当调用offer方法_触发扩容() {
  50. String eventString = "事件";
  51. PriorityQueue<Event> priorityQueue = new PriorityQueue<>();
  52. for (int i = 0; i < 17; i++) {
  53. Event event = new Event(eventString + i);
  54. priorityQueue.offer(event, 10);
  55. sleep10Millisecond();
  56. }
  57. for (int i = 0; i < 17; i++) {
  58. assertThat(priorityQueue.poll().getEvent(), is(eventString + i));
  59. }
  60. }
  61. private void sleep10Millisecond() {
  62. try {
  63. Thread.sleep(10);
  64. } catch (InterruptedException e) {
  65. System.out.println(e.getMessage());
  66. }
  67. }
  68. private static class Event {
  69. private final String event;
  70. public Event(String event) {
  71. this.event = event;
  72. }
  73. public String getEvent() {
  74. return event;
  75. }
  76. }
  77. }

测试了四个场景,分别是:priority不同,priority相同,触发扩容时priority不同和触发扩容时priority相同这四种场景下的优先级队列的功能实现。执行结果如下。

在这里插入图片描述

总结

是一颗完全二叉树,并且根据父节点总是大于等于子节点或者父节点总是小于等于子节点可将堆划分为大根堆小根堆。堆中元素可以由一个数组来表示,并且可以根据某个节点在数组中的索引计算得到该节点的左右子节点和父节点在数组中的索引。基于堆可以实现排序算法,通常,大根堆实现升序排序小根堆实现降序排序。基于还可以实现优先级队列,并且优先级队列的元素存储在一个堆数组中,堆数组在容量满时会进行扩容,因此可以将优先级队列看作是一个无界队列

发表评论

表情:
评论列表 (有 0 条评论,174人围观)

还没有评论,来说两句吧...

相关阅读

    相关 Java优先级队列-

    本文章主要讲解了基二叉树之后的知识点堆,讲解了什么是堆,以及堆的应用-优先级队列的使用,用堆来解决TopK问题,作者在最后为各位读者准备了习题以及讲解答案,希望对大家有帮...

    相关 优先级队列

    优先级队列 概念 > 我们知道,队列是一种先进先出的数据结构,但有些情况下,操作的数据可能带有优先级,一般出队列时,可能需要优先级高的元素先出队列,该中场景下,使用