二叉堆详解实现优先级队列

二叉堆详解实现优先级队列

二叉堆(Binary Heap)没什么神秘,性质比二叉搜索树 BST 还简单。

其主要操作就两个,sink(下沉)和 swim(上浮),用以维护二叉堆的性质。其主要应用有两个,首先是一种排序方法「堆排序」,第二是一种很有用的数据结构「优先级队列」。

那么本文以实现优先级队列(Priority Queue)为例,来讲讲一下二叉堆怎么运作的。

#一、二叉堆概览

首先,二叉堆和二叉树有啥关系呢,为什么人们总是把二叉堆画成一棵二叉树?

因为,二叉堆在逻辑上其实是一种特殊的二叉树(完全二叉树),只不过存储在数组里。一般的链表二叉树,我们操作节点的指针,而在数组里,我们把数组索引作为指针:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

// 父节点的索引
int parent(int root) {
return root / 2;
}
// 左孩子的索引
int left(int root) {
return root * 2;
}
// 右孩子的索引
int right(int root) {
return root * 2 + 1;
}

画个图你立即就能理解了,比如 arr 是一个字符数组,注意数组的第一个索引 0 空着不用:

img

你看到了,因为这棵二叉树是「完全二叉树」,所以把 arr[1] 作为整棵树的根的话,每个节点的父节点和左右孩子的索引都可以通过简单的运算得到,这就是二叉堆设计的一个巧妙之处。

为了方便讲解,下面都会画的图都是二叉树结构,相信你能把树和数组对应起来。

二叉堆还分为最大堆和最小堆。最大堆的性质是:每个节点都大于等于它的两个子节点。类似的,最小堆的性质是:每个节点都小于等于它的子节点。

两种堆核心思路都是一样的,本文以最大堆为例讲解。

对于一个最大堆,根据其性质,显然堆顶,也就是 arr[1] 一定是所有元素中最大的元素。

#二、优先级队列概览

优先级队列这种数据结构有一个很有用的功能,你插入或者删除元素的时候,元素会自动排序,这底层的原理就是二叉堆的操作。

数据结构的功能无非增删查改,优先级队列有两个主要 API,分别是 insert 插入一个元素和 delMax 删除最大元素(如果底层用最小堆,那么就是 delMin,删除最小元素)。

刚才说到二叉堆本质上是一棵二叉树,而优先级队列这种数据结构底层是二叉堆,那么为啥它的名字又和「队列」扯上了关系呢?

因为优先级队列的操作和队列比较像。普通队列有个从队尾插入元素,从队头移出元素,出队顺序就是入队的时间顺序;你也可以认为优先级队列是一种特殊的队列,从队尾插入元素,从队头移出元素,只不过出队顺序是按照元素的大小顺序。

这么一看,是不是觉得这种数据结构有队列内味儿了?

下面我们实现一个简化的优先级队列,先看下代码框架:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

public class MaxPQ
<Key extends Comparable<Key>> {
// 存储元素的数组
private Key[] pq;
// 当前 Priority Queue 中的元素个数
private int size = 0;

public MaxPQ(int cap) {
// 索引 0 不用,所以多分配一个空间
pq = (Key[]) new Comparable[cap + 1];
}

/* 返回当前队列中最大元素 */
public Key max() {
return pq[1];
}

/* 插入元素 e */
public void insert(Key e) {...}

/* 删除并返回当前队列中最大元素 */
public Key delMax() {...}

/* 上浮第 x 个元素,以维护最大堆性质 */
private void swim(int x) {...}

/* 下沉第 x 个元素,以维护最大堆性质 */
private void sink(int x) {...}

/* 交换数组的两个元素 */
private void swap(int i, int j) {
Key temp = pq[i];
pq[i] = pq[j];
pq[j] = temp;
}

/* pq[i] 是否比 pq[j] 小? */
private boolean less(int i, int j) {
return pq[i].compareTo(pq[j]) < 0;
}

/* 还有 left, right, parent 三个方法 */
}

提示

这里用到 Java 的泛型,Key 可以是任何一种可比较大小的数据类型(实现了 compareTo 方法),比如 Integer 等类型。

当然,你自己练习时可以适度变通,比如说在构造函数中传入一个比较函数,这样就可以实现自定义排序逻辑了。

我这里为了简化,就实现一个最大堆 MaxPQ,即每次删除都是队列中最大的元素。

空出来的四个方法是二叉堆和优先级队列的奥妙所在,下面用图文来逐个理解。

#三、实现 swim 和 sink

为什么要有上浮 swim 和下沉 sink 的操作呢?为了维护二叉堆结构。

我们要讲的是最大堆,每个节点都比它的两个子节点大,但是在插入元素和删除元素时,难免破坏堆的性质,这就需要通过这两个操作来恢复堆的性质了。

对于最大堆,会破坏堆性质的有两种情况:

1、如果某个节点 A 比它的子节点(中的一个)小,那么 A 就不配做父节点,应该下去,下面那个更大的节点上来做父节点,这就是对 A 进行下沉

2、如果某个节点 A 比它的父节点大,那么 A 不应该做子节点,应该把父节点换下来,自己去做父节点,这就是对 A 的上浮

当然,错位的节点 A 可能要上浮(或下沉)很多次,才能到达正确的位置,恢复堆的性质。所以代码中肯定有一个 while 循环。

细心的读者也许会问,这两个操作不是互逆吗,所以上浮的操作一定能用下沉来完成,为什么我还要费劲写两个方法?

是的,操作是互逆等价的,但是最终我们的操作只会在堆底和堆顶进行(等会讲原因),显然堆底的「错位」元素需要上浮,堆顶的「错位」元素需要下沉。

上浮的代码实现:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

public class MaxPQ <Key extends Comparable<Key>> {
// 为了节约篇幅,省略上文给出的代码部分...

private void swim(int x) {
// 如果浮到堆顶,就不能再上浮了
while (x > 1 && less(parent(x), x)) {
// 如果第 x 个元素比上层大
// 将 x 换上去
swap(parent(x), x);
x = parent(x);
}
}
}

画个 GIF 看一眼就明白了:

img

下沉的代码实现:

下沉比上浮略微复杂一点,因为上浮某个节点 A,只需要 A 和其父节点比较大小即可;但是下沉某个节点 A,需要 A 和其两个子节点比较大小,如果 A 不是最大的就需要调整位置,要把较大的那个子节点和 A 交换。

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

public class MaxPQ <Key extends Comparable<Key>> {
// 为了节约篇幅,省略上文给出的代码部分...

private void sink(int x) {
// 如果沉到堆底,就沉不下去了
while (left(x) <= size) {
// 先假设左边节点较大
int max = left(x);
// 如果右边节点存在,比一下大小
if (right(x) <= size && less(max, right(x)))
max = right(x);
// 结点 x 比俩孩子都大,就不必下沉了
if (less(max, x)) break;
// 否则,不符合最大堆的结构,下沉 x 结点
swap(x, max);
x = max;
}
}
}

画个 GIF 看下就明白了:

img

至此,二叉堆的主要操作就讲完了,一点都不难吧,代码加起来也就十行。明白了 sinkswim 的行为,下面就可以实现优先级队列了。

#四、实现 delMax 和 insert

这两个方法就是建立在 swimsink 上的。

insert 方法先把要插入的元素添加到堆底的最后,然后让其上浮到正确位置

img

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

public class MaxPQ <Key extends Comparable<Key>> {
// 为了节约篇幅,省略上文给出的代码部分...

public void insert(Key e) {
size++;
// 先把新元素加到最后
pq[size] = e;
// 然后让它上浮到正确的位置
swim(size);
}
}

delMax 方法先把堆顶元素 A 和堆底最后的元素 B 对调,然后删除 A,最后让 B 下沉到正确位置

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

public class MaxPQ <Key extends Comparable<Key>> {
// 为了节约篇幅,省略上文给出的代码部分...

public Key delMax() {
// 最大堆的堆顶就是最大元素
Key max = pq[1];
// 把这个最大元素换到最后,删除之
swap(1, size);
pq[size] = null;
size--;
// 让 pq[1] 下沉到正确位置
sink(1);
return max;
}
}

img

至此,一个优先级队列就实现了,插入和删除元素的时间复杂度为 O(logK)K 为当前二叉堆(优先级队列)中的元素总数。因为我们时间复杂度主要花费在 sink 或者 swim 上,而不管上浮还是下沉,最多也就树(堆)的高度,也就是 log 级别。

#五、最后总结

二叉堆就是一种完全二叉树,所以适合存储在数组中,而且二叉堆拥有一些特殊性质。

二叉堆的操作很简单,主要就是上浮和下沉,来维护堆的性质(堆有序),核心代码也就十行。

优先级队列是基于二叉堆实现的,主要操作是插入和删除。插入是先插到最后,然后上浮到正确位置;删除是调换位置后再删除,然后下沉到正确位置。核心代码也就十行。

也许这就是数据结构的威力,简单的操作就能实现巧妙的功能,真心佩服发明二叉堆算法的人!

最后,更多二叉堆/优先级队列相关的题目练习见 二叉堆(优先级队列)的经典习题open in new window

经典练习题

二叉堆的主要应用是优先级队列,而优先级队列的特色是动态排序,插入的元素可以自动维护正确的顺序。当然,后文讲的 二叉搜索树open in new window 也可以做到动态排序,优先级队列的限制是只能从队头和队尾操作元素。

一般来说,用到优先级队列的题目主要分两类,一类是合并多个有序链表这类题,另一类是寻找第 k 个最大元素这类题,我们分别来看。


先来看第一类,合并有序链表这样的题目:


#23. 合并 K 个升序链表open in new window

  • 标签:二叉堆,数据结构,链表,链表双指针

给你一个链表数组,每个链表都已经按升序排列,请你将这些链表合并成一个升序链表,返回合并后的链表。

示例 1:

输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
解释:链表数组如下:
[
1->4->5,
1->3->4,
2->6
]
将它们合并到一个有序链表中得到。
1->1->2->3->4->4->5->6

#基本思路

本文有视频版:链表双指针技巧全面汇总open in new window

21. 合并两个有序链表open in new window 的延伸,利用 优先级队列(二叉堆)open in new window 进行节点排序即可。

#解法代码

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists.length == 0) return null;
// 虚拟头结点
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
// 优先级队列,最小堆
PriorityQueue<ListNode> pq = new PriorityQueue<>(
lists.length, (a, b)->(a.val - b.val));
// 将 k 个链表的头结点加入最小堆
for (ListNode head : lists) {
if (head != null)
pq.add(head);
}

while (!pq.isEmpty()) {
// 获取最小节点,接到结果链表中
ListNode node = pq.poll();
p.next = node;
if (node.next != null) {
pq.add(node.next);
}
// p 指针不断前进
p = p.next;
}
return dummy.next;
}
}

#373. 查找和最小的 K 对数字open in new window

  • 标签:二叉堆,链表双指针

给定两个以 升序排列 的整数数组 nums1nums2, 以及一个整数 k

定义一对值 (u,v),其中第一个元素来自 nums1,第二个元素来自 nums2

请找到最小的 k 个数对 (u1,v1), (u2,v2)(uk,vk)

示例 1:

输入:nums1 = [1,7,11], nums2 = [2,4,6], k = 3
输出:[1,2],[1,4],[1,6]
解释:返回序列中的前 3 对数:
[1,2],[1,4],[1,6],[7,2],[7,4],[11,2],[7,6],[11,4],[11,6]

#基本思路

这道题其实是前文 单链表的六大解题套路open in new window 中讲过的 23. 合并K个升序链表open in new window 的变体。

怎么把这道题变成合并多个有序链表呢?就比如说题目输入的用例:

nums1 = [1,7,11], nums2 = [2,4,6]

组合出的所有数对儿这就可以抽象成三个有序链表:

[1, 2] -> [1, 4] -> [1, 6]
[7, 2] -> [7, 4] -> [7, 6]
[11, 2] -> [11, 4] -> [11, 6]

这三个链表中每个元素(数对之和)是递增的,所以就可以按照 23. 合并K个升序链表open in new window 的思路来合并,取出前 k 个作为答案即可。

#解法代码

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class Solution {
public List<List<Integer>> kSmallestPairs(int[] nums1, int[] nums2, int k) {
// 存储三元组 (num1[i], nums2[i], i)
// i 记录 nums2 元素的索引位置,用于生成下一个节点
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> {
// 按照数对的元素和升序排序
return (a[0] + a[1]) - (b[0] + b[1]);
});
// 按照 23 题的逻辑初始化优先级队列
for (int i = 0; i < nums1.length; i++) {
pq.offer(new int[]{nums1[i], nums2[0], 0});
}

List<List<Integer>> res = new ArrayList<>();
// 执行合并多个有序链表的逻辑
while (!pq.isEmpty() && k > 0) {
int[] cur = pq.poll();
k--;
// 链表中的下一个节点加入优先级队列
int next_index = cur[2] + 1;
if (next_index < nums2.length) {
pq.add(new int[]{cur[0], nums2[next_index], next_index});
}

List<Integer> pair = new ArrayList<>();
pair.add(cur[0]);
pair.add(cur[1]);
res.add(pair);
}
return res;
}
}

#378. 有序矩阵中第 K 小的元素open in new window

  • 标签:二叉堆,链表双指针

给你一个 n x n 矩阵 matrix,其中每行和每列元素均按升序排序,找到矩阵中第 k 小的元素。
请注意,它是 排序后 的第 k 小元素,而不是第 k不同的元素。

你必须找到一个内存复杂度优于 O(n2) 的解决方案。

示例 1:

输入:matrix = [[1,5,9],[10,11,13],[12,13,15]], k = 8
输出:13
解释:矩阵中的元素为 [1,5,9,10,11,12,13,13,15],第 8 小元素是 13

#基本思路

这道题其实是前文 单链表的六大解题套路open in new window 中讲过的 23. 合并K个升序链表open in new window 的变体。

矩阵中的每一行都是排好序的,就好比多条有序链表,你用优先级队列施展合并多条有序链表的逻辑就能找到第 k 小的元素了。

#解法代码

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class Solution {
public int kthSmallest(int[][] matrix, int k) {
// 存储二元组 (matrix[i][j], i, j)
// i, j 记录当前元素的索引位置,用于生成下一个节点
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> {
// 按照元素大小升序排序
return a[0] - b[0];
});


// 初始化优先级队列,把每一行的第一个元素装进去
for (int i = 0; i < matrix.length; i++) {
pq.offer(new int[]{matrix[i][0], i, 0});
}

int res = -1;
// 执行合并多个有序链表的逻辑,找到第 k 小的元素
while (!pq.isEmpty() && k > 0) {
int[] cur = pq.poll();
res = cur[0];
k--;
// 链表中的下一个节点加入优先级队列
int i = cur[1], j = cur[2];
if (j + 1 < matrix[i].length) {
pq.add(new int[]{matrix[i][j + 1], i, j + 1});
}
}
return res;
}
}

#313. 超级丑数open in new window

  • 标签:二叉堆,链表双指针

超级丑数 是一个正整数,并满足其所有质因数都出现在质数数组 primes 中。给你一个整数 n 和一个整数数组 primes,返回第 n超级丑数

示例 1:

输入:n = 12, primes = [2,7,13,19]
输出:32
解释:给定长度为 4 的质数数组 primes = [2,7,13,19],前 12 个超级丑数序列为:[1,2,4,7,8,13,14,16,19,26,28,32]。

#基本思路

这题是 264. 丑数 IIopen in new window 的进阶版,第 264 题是 21. 合并两个有序链表(简单)open in new window 的变体,而这道题是 23. 合并K个升序链表(困难)open in new window 的变体,我在 双指针技巧秒杀七道链表题目open in new window 中都讲过。

你一定要先做下 264 题,注意那里我们抽象出了三条链表,需要 p2, p3, p5 作为三条有序链表上的指针,同时需要 product2, product3, product5 记录指针所指节点的值,用 min 函数计算最小头结点。

这道题相当于输入了 len(primes) 条有序链表,我们不能用 min 函数计算最小头结点了,而是要用优先级队列来计算最小头结点,同时依然要维护链表指针、指针所指节点的值,我们把这些信息用一个三元组来保存。

结合第 23 题的解法逻辑,你应该能够看懂这道题的解法代码了。

#解法代码

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class Solution {
public int nthSuperUglyNumber(int n, int[] primes) {
// 优先队列中装三元组 int[] {product, prime, pi}
// 其中 product 代表链表节点的值,prime 是计算下一个节点所需的质数因子,pi 代表链表上的指针
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> {
return a[0] - b[0];
});

// 把多条链表的头结点加入优先级队列
for (int i = 0; i < primes.length; i++) {
pq.offer(new int[]{ 1, primes[i], 1 });
}

// 可以理解为最终合并的有序链表(结果链表)
int[] ugly = new int[n + 1];
// 可以理解为结果链表上的指针
int p = 1;

while (p <= n) {
// 取三个链表的最小结点
int[] pair = pq.poll();
int product = pair[0];
int prime = pair[1];
int index = pair[2];

// 避免结果链表出现重复元素
if (product != ugly[p - 1]) {
// 接到结果链表上
ugly[p] = product;
p++;
}

// 生成下一个节点加入优先级队列
int[] nextPair = new int[]{ugly[index] * prime, prime, index + 1};
pq.offer(nextPair);
}
return ugly[n];
}
}

#355. 设计推特open in new window

  • 标签:数据结构,设计

设计一个简化版的推特 (Twitter),可以让用户实现发送推文,关注/取消关注其他用户,能够看见关注人(包括自己)的最近 10 条推文。

实现 Twitter 类:

  • void postTweet(int userId, int tweetId) 根据给定的 tweetIduserId 创建一条新推文。每次调用此函数都会使用一个不同的 tweetId
  • List<Integer> getNewsFeed(int userId) 检索当前用户新闻推送中最近 10 条推文的 ID。新闻推送中的每一项都必须是由用户关注的人或者是用户自己发布的推文。推文必须按照时间顺序由最近到最远排序
  • void follow(int followerId, int followeeId) ID 为 followerId 的用户开始关注 ID 为 followeeId 的用户。
  • void unfollow(int followerId, int followeeId) ID 为 followerId 的用户不再关注 ID 为 followeeId 的用户。

示例:

输入
["Twitter", "postTweet", "getNewsFeed", "follow", "postTweet", "getNewsFeed", "unfollow", "getNewsFeed"]
[[], [1, 5], [1], [1, 2], [2, 6], [1], [1, 2], [1]]
输出
[null, null, [5], null, null, [6, 5], null, [5]]

解释
Twitter twitter = new Twitter();
twitter.postTweet(1, 5); // 用户 1 发送了一条新推文 (用户 id = 1, 推文 id = 5)
twitter.getNewsFeed(1); // 用户 1 的获取推文应当返回一个列表,其中包含一个 id 为 5 的推文
twitter.follow(1, 2); // 用户 1 关注了用户 2
twitter.postTweet(2, 6); // 用户 2 发送了一个新推文 (推文 id = 6)
twitter.getNewsFeed(1); // 用户 1 的获取推文应当返回一个列表,其中包含两个推文,id 分别为 -> [6, 5]。推文 id 6 应当在推文 id 5 之前,因为它是在 5 之后发送的
twitter.unfollow(1, 2); // 用户 1 取消关注了用户 2
twitter.getNewsFeed(1); // 用户 1 获取推文应当返回一个列表,其中包含一个 id 为 5 的推文。因为用户 1 已经不再关注用户 2

#基本思路

这道题比较经典,在特定场景下让你设计算法。其难点在于 getNewsFeed 方法,要按照时间线顺序展示所有关注用户的推文,这个方法要用到我在 单链表的六大解题套路open in new window 解决 23. 合并K个升序链表open in new window 的合并多个有序链表的技巧:

你把一个用户发布的所有推文做成一条有序链表(靠近头部的推文是最近发布的),那么只要合并关注用户的推文链表,即可获得按照时间线顺序排序的信息流。

具体看代码吧,我注释比较详细。

#解法代码

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class Twitter {
// 全局时间戳
int globalTime = 0;
// 记录用户 ID 到用户示例的映射
HashMap<Integer, User> idToUser = new HashMap<>();

// Tweet 类
class Tweet {
private int id;
// 时间戳用于对信息流按照时间排序
private int timestamp;
// 指向下一条 tweet,类似单链表结构
private Tweet next;

public Tweet(int id) {
this.id = id;
// 新建一条 tweet 时记录并更新时间戳
this.timestamp = globalTime++;
}

public int getId() {
return id;
}

public int getTimestamp() {
return timestamp;
}

public Tweet getNext() {
return next;
}

public void setNext(Tweet next) {
this.next = next;
}
}

// 用户类
class User {
// 记录该用户的 id 以及发布的 tweet
private int id;
private Tweet tweetHead;
// 记录该用户的关注者
private HashSet<User> followedUserSet;

public User(int id) {
this.id = id;
this.tweetHead = null;
this.followedUserSet = new HashSet<>();
}

public int getId() {
return id;
}

public Tweet getTweetHead() {
return tweetHead;
}

public HashSet<User> getFollowedUserSet() {
return followedUserSet;
}

public boolean equals(User other) {
return this.id == other.id;
}

// 关注其他人
public void follow(User other) {
followedUserSet.add(other);
}

// 取关其他人
public void unfollow(User other) {
followedUserSet.remove(other);
}

// 发布一条 tweet
public void post(Tweet tweet) {
// 把新发布的 tweet 作为链表头节点
tweet.setNext(tweetHead);
tweetHead = tweet;
}
}

public void postTweet(int userId, int tweetId) {
// 如果这个用户还不存在,新建用户
if (!idToUser.containsKey(userId)) {
idToUser.put(userId, new User(userId));
}
User user = idToUser.get(userId);
user.post(new Tweet(tweetId));
}

public List<Integer> getNewsFeed(int userId) {
List<Integer> res = new LinkedList<>();
if (!idToUser.containsKey(userId)) {
return res;
}
// 获取该用户关注的用户列表
User user = idToUser.get(userId);
Set<User> followedUserSet = user.getFollowedUserSet();
// 每个用户的 tweet 是一条按时间排序的链表
// 现在执行合并多条有序链表的逻辑,找出时间线中的最近 10 条动态
PriorityQueue<Tweet> pq = new PriorityQueue<>((a, b) -> {
// 按照每条 tweet 的发布时间降序排序(最近发布的排在事件流前面)
return b.timestamp - a.timestamp;
});
// 该用户自己的 tweet 也在时间线内
if (user.getTweetHead() != null) {
pq.offer(user.getTweetHead());
}
for (User other : followedUserSet) {
if (other.getTweetHead() != null) {
pq.offer(other.tweetHead);
}
}
// 合并多条有序链表
int count = 0;
while (!pq.isEmpty() && count < 10) {
Tweet tweet = pq.poll();
res.add(tweet.getId());
if (tweet.getNext() != null) {
pq.offer(tweet.getNext());
}
count++;
}
return res;
}

public void follow(int followerId, int followeeId) {
// 如果用户还不存在,则新建用户
if (!idToUser.containsKey(followerId)) {
idToUser.put(followerId, new User(followerId));
}
if (!idToUser.containsKey(followeeId)) {
idToUser.put(followeeId, new User(followeeId));
}

User follower = idToUser.get(followerId);
User followee = idToUser.get(followeeId);
// 关注者关注被关注者
follower.follow(followee);
}

public void unfollow(int followerId, int followeeId) {
if (!idToUser.containsKey(followerId) || !idToUser.containsKey(followeeId)) {
return;
}
User follower = idToUser.get(followerId);
User followee = idToUser.get(followeeId);
// 关注者取关被关注者
follower.unfollow(followee);
}
}

再来看第二类,寻找第 k 个最大元素这类题:


#215. 数组中的第 K 个最大元素open in new window

  • 标签:二叉堆,快速选择算法,数组

给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。

请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。

示例 1:

输入:[3,2,1,5,6,4] 和 k = 2
输出:5

示例 2:

输入:[3,2,3,1,2,4,5,5,6] 和 k = 4
输出:4

#基本思路

二叉堆的解法比较简单,实际写算法题的时候,推荐大家写这种解法。

可以把小顶堆 pq 理解成一个筛子,较大的元素会沉淀下去,较小的元素会浮上来;当堆大小超过 k 的时候,我们就删掉堆顶的元素,因为这些元素比较小,而我们想要的是前 k 个最大元素嘛。

nums 中的所有元素都过了一遍之后,筛子里面留下的就是最大的 k 个元素,而堆顶元素是堆中最小的元素,也就是「第 k 个最大的元素」。

二叉堆插入和删除的时间复杂度和堆中的元素个数有关,在这里我们堆的大小不会超过 k,所以插入和删除元素的复杂度是 O(logK),再套一层 for 循环,总的时间复杂度就是 O(NlogK)

当然,这道题可以有效率更高的解法叫「快速选择算法」,只需要 O(N) 的时间复杂度。

快速选择算法不用借助二叉堆结构,而是稍微改造了快速排序的算法思路,有兴趣的读者可以看详细题解。

#解法代码

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class Solution {
public int findKthLargest(int[] nums, int k) {
// 小顶堆,堆顶是最小元素
PriorityQueue<Integer> pq = new PriorityQueue<>();
for (int e : nums) {
// 每个元素都要过一遍二叉堆
pq.offer(e);
// 堆中元素多于 k 个时,删除堆顶元素
if (pq.size() > k) {
pq.poll();
}
}
// pq 中剩下的是 nums 中 k 个最大元素,
// 堆顶是最小的那个,即第 k 个最大元素
return pq.peek();
}
}

#451. 根据字符出现频率排序open in new window

  • 标签:二叉堆,排序

给定一个字符串 s,根据字符出现的 频率 对其进行 降序排序。一个字符出现的 频率 是它出现在字符串中的次数,返回已排序的字符串。如果有多个答案,返回其中任何一个。

示例 1:

输入:s = "tree"
输出:"eert"
解释:'e' 出现两次,'r' 和 't' 都只出现一次。
因此 'e' 必须出现在 'r' 和 't' 之前。此外,"eetr" 也是一个有效的答案。

#基本思路

做这道题肯定需要计算每个字符出现的频率,然后你可以用很多种其他方法把字符按照频率排序。我这里用 优先级队列open in new window 来实现排序的效果,详细看代码。

#解法代码

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class Solution {
public String frequencySort(String s) {
char[] chars = s.toCharArray();
// s 中的字符 -> 该字符出现的频率
HashMap<Character, Integer> charToFreq = new HashMap<>();
for (char ch : chars) {
charToFreq.put(ch, charToFreq.getOrDefault(ch, 0) + 1);
}

PriorityQueue<Map.Entry<Character, Integer>>
pq = new PriorityQueue<>((entry1, entry2) -> {
// 队列按照键值对中的值(字符出现频率)从大到小排序
return entry2.getValue().compareTo(entry1.getValue());
});

// 按照字符频率排序
for (Map.Entry<Character, Integer> entry : charToFreq.entrySet()) {
pq.offer(entry);
}

StringBuilder sb = new StringBuilder();
while (!pq.isEmpty()) {
// 把频率最高的字符排在前面
Map.Entry<Character, Integer> entry = pq.poll();
String part = String.valueOf(entry.getKey()).repeat(entry.getValue());
sb.append(part);
}

return sb.toString();
}
}

#703. 数据流中的第 K 大元素open in new window

  • 标签:二叉堆,数据结构

设计一个找到数据流中第 k 大元素的类(class)。注意是排序后的第 k 大元素,不是第 k 个不同的元素。

请实现 KthLargest 类:

1、KthLargest(int k, int[] nums) 使用整数 k 和整数流 nums 初始化对象。

2、int add(int val)val 插入数据流 nums 后,返回当前数据流中第 k 大的元素。

示例:

输入:
["KthLargest", "add", "add", "add", "add", "add"]
[[3, [4, 5, 8, 2]], [3], [5], [10], [9], [4]]
输出:
[null, 4, 5, 5, 8, 8]

解释:
KthLargest kthLargest = new KthLargest(3, [4, 5, 8, 2]);
kthLargest.add(3); // return 4
kthLargest.add(5); // return 5
kthLargest.add(10); // return 5
kthLargest.add(9); // return 8
kthLargest.add(4); // return 8

#基本思路

这题考察优先级队列的使用,可以先做下这道类似的题目 215. 数组中的第 K 个最大元素open in new window

优先级队列的实现原理详见 图文详解二叉堆,实现优先级队列open in new window

#解法代码

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class KthLargest {

private int k;
// 默认是小顶堆
private PriorityQueue<Integer> pq = new PriorityQueue<>();

public KthLargest(int k, int[] nums) {
// 将 nums 装入小顶堆,保留下前 k 大的元素
for (int e : nums) {
pq.offer(e);
if (pq.size() > k) {
pq.poll();
}
}
this.k = k;
}

public int add(int val) {
// 维护小顶堆只保留前 k 大的元素
pq.offer(val);
if (pq.size() > k) {
pq.poll();
}
// 堆顶就是第 k 大元素(即倒数第 k 小的元素)
return pq.peek();
}
}

#347. 前 K 个高频元素open in new window

  • 标签:二叉堆,哈希表,快速选择

给你一个整数数组 nums 和一个整数 k,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。

示例 1:

输入:nums = [1,1,1,2,2,3], k = 2
输出:[1,2]

#基本思路

首先,肯定要用一个 valToFreq 哈希表open in new window 把每个元素出现的频率计算出来。

然后,这道题就变成了 215. 数组中的第 K 个最大元素open in new window,只不过第 215 题让你求数组中元素值 e 排在第 k 大的那个元素,这道题让你求数组中元素值 valToFreq[e] 排在前 k 个的元素。

我在 快速排序详解及运用open in new window 中讲过第 215 题,可以用 优先级队列open in new window 或者快速选择算法解决这道题。这里稍微改一下优先级队列的比较函数,或者改一下快速选择算法中的逻辑即可。

这里我再加一种解法,用计数排序的方式找到前 k 个高频元素,见代码。

#解法代码

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

// 用优先级队列解决这道题
class Solution {
public int[] topKFrequent(int[] nums, int k) {
// nums 中的元素 -> 该元素出现的频率
HashMap<Integer, Integer> valToFreq = new HashMap<>();
for (int v : nums) {
valToFreq.put(v, valToFreq.getOrDefault(v, 0) + 1);
}

PriorityQueue<Map.Entry<Integer, Integer>>
pq = new PriorityQueue<>((entry1, entry2) -> {
// 队列按照键值对中的值(元素出现频率)从小到大排序
return entry1.getValue().compareTo(entry2.getValue());
});

for (Map.Entry<Integer, Integer> entry : valToFreq.entrySet()) {
pq.offer(entry);
if (pq.size() > k) {
// 弹出最小元素,维护队列内是 k 个频率最大的元素
pq.poll();
}
}

int[] res = new int[k];
for (int i = k - 1; i >= 0; i--) {
// res 数组中存储前 k 个最大元素
res[i] = pq.poll().getKey();
}

return res;
}
}

// 用计数排序的方法解决这道题
class Solution2 {
public int[] topKFrequent(int[] nums, int k) {
// nums 中的元素 -> 该元素出现的频率
HashMap<Integer, Integer> valToFreq = new HashMap<>();
for (int v : nums) {
valToFreq.put(v, valToFreq.getOrDefault(v, 0) + 1);
}

// 频率 -> 这个频率有哪些元素
ArrayList<Integer>[] freqToVals = new ArrayList[nums.length + 1];
for (int val : valToFreq.keySet()) {
int freq = valToFreq.get(val);
if (freqToVals[freq] == null) {
freqToVals[freq] = new ArrayList<>();
}
freqToVals[freq].add(val);
}

int[] res = new int[k];
int p = 0;
// freqToVals 从后往前存储着出现最多的元素
for (int i = freqToVals.length - 1; i > 0; i--) {
ArrayList<Integer> valList = freqToVals[i];
if (valList == null) continue;
for (int j = 0; j < valList.size(); j++) {
// 将出现次数最多的 k 个元素装入 res
res[p] = valList.get(j);
p++;
if (p == k) {
return res;
}
}
}

return null;
}
}

#692. 前K个高频单词open in new window

  • 标签:二叉堆,哈希表

给定一个单词列表 words 和一个整数 k,返回前 k 个出现次数最多的单词。返回的答案应该按单词出现频率由高到低排序,如果不同的单词有相同出现频率,按字典顺序 排序。

示例 1:

输入:words = ["i", "love", "leetcode", "i", "love", "coding"], k = 2
输出:["i", "love"]
解析:"i" 和 "love" 为出现次数最多的两个单词,均为 2 次。
注意,按字母顺序 "i" 在 "love" 之前。

#基本思路

这道题可以认为是 347. 前 K 个高频元素open in new window 的进阶版。整体思路还哈希表计数,然后用 优先级队列open in new window 维护出现频率最高的 k 个单词。

只是我们需要注意题目要求,在 PriorityQueue 的比较器中正确处理频率相同的单词的字典序。

#解法代码

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class Solution {
public List<String> topKFrequent(String[] words, int k) {
// 字符串 -> 该字符串出现的频率
HashMap<String, Integer> wordToFreq = new HashMap<>();
for (String word : words) {
wordToFreq.put(word, wordToFreq.getOrDefault(word, 0) + 1);
}

PriorityQueue<Map.Entry<String, Integer>> pq = new PriorityQueue<>(
(entry1, entry2) -> {
if (entry1.getValue().equals(entry2.getValue())) {
// 如果出现频率相同,按照字符串字典序排序
return entry2.getKey().compareTo(entry1.getKey());
}
// 队列按照字符串出现频率从小到大排序
return entry1.getValue().compareTo(entry2.getValue());
});

// 按照字符串频率升序排序
for (Map.Entry<String, Integer> entry : wordToFreq.entrySet()) {
pq.offer(entry);
if (pq.size() > k) {
// 维护出现频率最多的 k 个单词
pq.poll();
}
}

// 把出现次数最多的 k 个字符串返回
LinkedList<String> res = new LinkedList<>();
while (!pq.isEmpty()) {
res.addFirst(pq.poll().getKey());
}
return res;
}
}

最后,再看一看优先级队列的其他运用吧:


#1845. 座位预约管理系统open in new window

  • 标签:二叉堆,数据结构,设计

请你设计一个管理 n 个座位预约的系统,座位编号从 1n

请你实现 SeatManager 类:

  • SeatManager(int n) 初始化一个 SeatManager 对象,它管理从 1n 编号的 n 个座位。所有座位初始都是可预约的。
  • int reserve() 返回可以预约座位的最小编号,此座位变为不可预约。
  • void unreserve(int seatNumber) 将给定编号 seatNumber 对应的座位变成可以预约。

示例 1:

输入:
["SeatManager", "reserve", "reserve", "unreserve", "reserve", "reserve", "reserve", "reserve", "unreserve"]
[[5], [], [], [2], [], [], [], [], [5]]
输出:
[null, 1, 2, null, 2, 3, 4, 5, null]

解释:
SeatManager seatManager = new SeatManager(5); // 初始化 SeatManager,有 5 个座位。
seatManager.reserve(); // 所有座位都可以预约,所以返回最小编号的座位,也就是 1。
seatManager.reserve(); // 可以预约的座位为 [2,3,4,5],返回最小编号的座位,也就是 2。
seatManager.unreserve(2); // 将座位 2 变为可以预约,现在可预约的座位为 [2,3,4,5]。
seatManager.reserve(); // 可以预约的座位为 [2,3,4,5],返回最小编号的座位,也就是 2。
seatManager.reserve(); // 可以预约的座位为 [3,4,5],返回最小编号的座位,也就是 3。
seatManager.reserve(); // 可以预约的座位为 [4,5],返回最小编号的座位,也就是 4。
seatManager.reserve(); // 唯一可以预约的是座位 5,所以返回 5。
seatManager.unreserve(5); // 将座位 5 变为可以预约,现在可预约的座位为 [5]。

#基本思路

这题是 379. 电话目录管理系统open in new window 的进阶版,那一道题返回的空闲号码可以随意,而这道题要求返回最小的座位编号。

其实很思路是一样的,只是这里需要用到能够按照元素大小自动排序的数据结构 优先级队列(二叉堆)open in new window,直接看代码吧。

#解法代码

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class SeatManager {
// 利用优先级队列自动排序,队头的元素就是最小的
PriorityQueue<Integer> pq = new PriorityQueue<>();

public SeatManager(int n) {
// 初始化所有空闲座位
for (int i = 1; i <= n; i++) {
pq.offer(i);
}
}

public int reserve() {
// 拿出队头元素(最小)
return pq.poll();
}

public void unreserve(int i) {
pq.offer(i);
}
}

#295. 数据流的中位数open in new window

  • 标签:二叉堆,数学

中位数是有序列表中间的数。如果列表长度是偶数,中位数则是中间两个数的平均值。

设计一个支持以下两种操作的数据结构:

1、void addNum(int num) 从数据流中添加一个整数到数据结构中。

2、double findMedian() 返回目前所有元素的中位数。

示例:

addNum(1)
addNum(2)
findMedian() -> 1.5
addNum(3)
findMedian() -> 2

#基本思路

本题的核心思路是使用两个优先级队列。

img

小的倒三角就是个大顶堆,梯形就是个小顶堆,中位数可以通过它们的堆顶元素算出来:

img

#解法代码

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class MedianFinder {
private PriorityQueue<Integer> large;
private PriorityQueue<Integer> small;

public MedianFinder() {
// 小顶堆
large = new PriorityQueue<>();
// 大顶堆
small = new PriorityQueue<>((a, b) -> {
return b - a;
});
}

public double findMedian() {

// 如果元素不一样多,多的那个堆的堆顶元素就是中位数
if (large.size() < small.size()) {
return small.peek();
} else if (large.size() > small.size()) {
return large.peek();
}
// 如果元素一样多,两个堆堆顶元素的平均数是中位数
return (large.peek() + small.peek()) / 2.0;

}

public void addNum(int num) {
if (small.size() >= large.size()) {
small.offer(num);
large.offer(small.poll());
} else {
large.offer(num);
small.offer(large.poll());
}
}
}

#870. 优势洗牌open in new window

  • 标签:数组,数组双指针

给定两个大小相等的数组 ABA 相对于 B优势可以用满足 A[i] > B[i] 的索引 i 的数目来描述。

请你返回 A任意排列,使其相对于 B 的优势最大化。

示例 1:

输入:A = [2,7,11,15], B = [1,10,4,11]
输出:[2,11,7,15]

#基本思路

这题就像田忌赛马的情景,nums1 就是田忌的马,nums2 就是齐王的马,数组中的元素就是马的战斗力,你就是谋士孙膑,请你帮田忌安排赛马顺序,使胜场最多。

最优策略是将齐王和田忌的马按照战斗力排序,然后按照战斗力排名一一对比:

如果田忌的马能赢,那就比赛,如果赢不了,那就换个垫底的来送人头,保存实力。

具体分析见详细题解。

#解法代码

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class Solution {
public int[] advantageCount(int[] nums1, int[] nums2) {
int n = nums1.length;
// 给 nums2 降序排序
PriorityQueue<int[]> maxpq = new PriorityQueue<>(
(int[] pair1, int[] pair2) -> {
return pair2[1] - pair1[1];
}
);
for (int i = 0; i < n; i++) {
maxpq.offer(new int[]{i, nums2[i]});
}
// 给 nums1 升序排序
Arrays.sort(nums1);

// nums1[left] 是最小值,nums1[right] 是最大值
int left = 0, right = n - 1;
int[] res = new int[n];

while (!maxpq.isEmpty()) {
int[] pair = maxpq.poll();
// maxval 是 nums2 中的最大值,i 是对应索引
int i = pair[0], maxval = pair[1];
if (maxval < nums1[right]) {
// 如果 nums1[right] 能胜过 maxval,那就自己上
res[i] = nums1[right];
right--;
} else {
// 否则用最小值混一下,养精蓄锐
res[i] = nums1[left];
left++;
}
}
return res;
}
}

#1834. 单线程 CPUopen in new window

  • 标签:二叉堆,排序

给你一个二维数组 tasks,用于表示 n 项从 0n - 1 编号的任务。其中 tasks[i] = [enqueueTime_i, processingTime_i] 意味着第 i 项任务将会于 enqueueTime_i 时进入任务队列,需要 processingTime_i 的时长完成执行。

现有一个单线程 CPU,同一时间只能执行 最多一项 任务,该 CPU 将会按照下述方式运行:

  • 如果 CPU 空闲,且任务队列中没有需要执行的任务,则 CPU 保持空闲状态。
  • 如果 CPU 空闲,但任务队列中有需要执行的任务,则 CPU 将会选择执行时间最短的任务开始执行。如果多个任务具有同样的最短执行时间,则选择下标最小的任务开始执行。
  • 一旦某项任务开始执行,CPU 在 执行完整个任务 前都不会停止。
  • CPU 可以在完成一项任务后,立即开始执行一项新任务。

返回 CPU 处理任务的顺序。

示例 1:

输入:tasks = [[1,2],[2,4],[3,2],[4,1]]
输出:[0,2,3,1]
解释:事件按下述流程运行:
- time = 1,任务 0 进入任务队列,可执行任务项 = {0}
- 同样在 time = 1,空闲状态的 CPU 开始执行任务 0,可执行任务项 = {}
- time = 2,任务 1 进入任务队列,可执行任务项 = {1}
- time = 3,任务 2 进入任务队列,可执行任务项 = {1, 2}
- 同样在 time = 3,CPU 完成任务 0 并开始执行队列中用时最短的任务 2,可执行任务项 = {1}
- time = 4,任务 3 进入任务队列,可执行任务项 = {1, 3}
- time = 5,CPU 完成任务 2 并开始执行队列中用时最短的任务 3,可执行任务项 = {1}
- time = 6,CPU 完成任务 3 并开始执行任务 1,可执行任务项 = {}
- time = 10,CPU 完成任务 1 并进入空闲状态

#基本思路

这题的难度不算大,就是有些复杂,难点在于你要同时控制三个变量(开始时间、处理时间、索引)的有序性,而且这三个变量还有优先级

首先应该考虑开始时间,因为只要到了开始时间,任务才进入可执行状态;

其次应该考虑任务的处理时间,在所有可以执行的任务中优先选择处理时间最短的;

如果存在处理时间相同的任务,那么优先选择索引最小的。

所以这道题的思路是:

先根据任务「开始时间」排序,维护一个时间线变量 now 来判断哪些任务到了可执行状态,然后借助一个优先级队列 pq 对「处理时间」和「索引」进行动态排序

利用优先级队列动态排序是有必要的,因为每完成一个任务,时间线 now 就要更新,进而产生新的可执行任务。

#解法代码

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class Solution {
public int[] getOrder(int[][] tasks) {
int n = tasks.length;
// 把原始索引也添加上,方便后面排序用
ArrayList<int[]> triples = new ArrayList<>();
for (int i = 0; i < tasks.length; i++) {
triples.add(new int[]{tasks[i][0], tasks[i][1], i});
}
// 数组先按照任务的开始时间排序
triples.sort((a, b) -> {
return a[0] - b[0];
});

// 按照任务的处理时间排序,如果处理时间相同,按照原始索引排序
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> {
if (a[1] != b[1]) {
// 比较处理时间
return a[1] - b[1];
}
// 比较原始索引
return a[2] - b[2];
});

ArrayList<Integer> res = new ArrayList<>();
// 记录完成任务的时间线
int now = 0;
int i = 0;
while (res.size() < n) {
if (!pq.isEmpty()) {
// 完成队列中的一个任务
int[] triple = pq.poll();
res.add(triple[2]);
// 每完成一个任务,就要推进时间线
now += triple[1];
} else if (i < n && triples.get(i)[0] > now) {
// 队列为空可能因为还没到开始时间,
// 直接把时间线推进到最近任务的开始时间
now = triples.get(i)[0];
}

// 由于时间线的推进,会产生可以开始执行的任务
for (; i < n && triples.get(i)[0] <= now; i++) {
pq.offer(triples.get(i));
}
}

// Java 语言特性,将 List 转化成 int[] 格式
int[] arr = new int[n];
for (int j = 0; j < n; j++) {
arr[j] = res.get(j);
}
return arr;
}
}