1. 堆和优先队列
堆就是用数组实现的二叉树,所有它没有使用父指针或者子指针。堆根据“堆属性”来排序,“堆属性”决定了树中节点的位置。
堆的常用方法:
- 构建优先队列
- 支持堆排序
- 快速找出一个集合中的最小值(或者最大值)
堆分为两种:最大堆和最小堆,两者的差别在于节点的排序方式。
在最大堆中,父节点的值比每一个子节点的值都要大。在最小堆中,父节点的值比每一个子节点的值都要小。这就是所谓的“堆属性”,并且这个属性对堆中的每一个节点都成立。
这里有一篇介绍的非常好的文章:
https://www.jianshu.com/p/6b526aa481b1
我们使用完全二叉树表示堆,就会变得特别方便,叫做二叉堆。位置k的节点,父节点位置是k/2,子节点位置是2k和2k+1。利用在数组中无需指针即可沿树上下移动的特点,可以保证对数复杂度的性能。我们的目的是要维持堆堆有序,那么最大(最小)的节点就是根节点。
在有序化的过程中会遇到下面两种情况:
- 从下至上的堆有序化(上浮swim):如果堆的有序状态因为一个子节点大于父节点而打破,首先交换它和它的父节点,交换后,这个节点比它的两个子节点都大,甚至仍可能比现在大父节点还大。我们可以一遍遍的使用上浮恢复堆有序。Java代码如下:
private void swim(int k) {
while (k > 1 && less(k/2, k)) {
exch(k, k/2);
k = k/2;
}
}
- 从上至下的堆有序化(下沉sink):如果堆的有序状态因为一个节点比他们两个或者其中之一更小而打破,那么我们需要和它的两个子节点的较大者交换来恢复堆。交换可能在子节点处继续打破堆有序,那么需要不断使用相同的方式将其恢复。
private void sink(int k) {
while (2*k <= N) {
int j = 2*k;
if (j < N && less(j, j+1)) j++;
if (!less(k, j)) break;
exch(k, j);
k = j;
}
}
许多情况下,程序需要处理有序的元素,但是不一定要求他们全部有序,或者不一定要求一次就全部将他们排序。在这种情况下,一个合适的数据结构应该支持两种操作:删除最大元素和插入元素。这种数据类型叫做优先队列。
有了swim和sink,我们就能够定义插入元素和删除最大元素这两个操作了。
- 插入元素:将新元素驾到元素末尾,并让这个新元素上浮(swim)到合适的位置。
- 删除最大元素:将数组顶端删去最大的元素,并将最后一个元素放到顶端,减小堆的大小,并让这个元素下沉(sink)到合适位置。
这就能保证在插入元素和删除最大元素这两个操作的时候,操作用时和队列大小成对数关系。
(此部分内容来自《算法》, https://algs4.cs.princeton.edu/24pq/)
2.堆排序
-
创建一个堆 H[0……n-1];
-
把堆首(最大值)和堆尾互换;
-
把堆的尺寸缩小 1,并调用 下沉操作,目的是把新的数组顶端数据调整到相应位置;
-
重复步骤 2,直到堆的尺寸为 1。
堆排序可以分为两个阶段,第一是堆的构造,我们将原始数组重新排进一个堆中;当然,可以从左到右扫描数组,使用swim操作,就像连续向优先队列插入元素一样,也叫自上向下的方式,时间复杂度为O(n*log2n)。但是最高效的办法是从右到左,使用sink操作(自下向上)。自下而上的建堆方法时间复杂度只有O(n)。
第二阶段,将堆顶最大元素删除,与最后一个数交换。然后将新堆顶下沉。
public static void sort(Comparable[] a)
{
int N = a.length;
for (int k=N/2; k>=1; k--)
sink(a, k, N);
while (N > 1)
{
exch(a, 1, N--);
sink(a, 1, N);
}
}
3.前K个高频元素
给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素
LeetCode374
https://leetcode.cn/problems/top-k-frequent-elements/
- 快速排序法
在快速排序中,每一轮排序都会将序列一分为二,左子区间的数都小于基准数,右子区间的数都大于基准数,而快速排序用来解决TopK问题,也是基于此的。N个数经过一轮快速排序后,如果基准数的位置被换到了i,那么区间[0,N-1]就被分为了[0,i-1]和[i+1,N-1],这也就是说,此时有N-1-i个数比基准数大,i个数比基准数小,假设N-1-i=X那么就会有以下几种情况:
①X=K。这种情况说明比基准数大的有K个,其他的都比基准数小,那么就说明这K个比基准数大的数就是TopK了;
②X<K。这种情况说明比基准数大的数不到K个,但是这X肯定是属于TopK中的TopX,而剩下的K-X就在[0,i]之间,此时就应当在[0,i]中找到Top(K-X),这就转换为了TopK的子问题,可以选择用递归解决;
③X>K。这种情况说明比基准数大的数超过了K个,那么就说明TopK必定位于[i+1,N-1]中,此时就应当继续在[i+1,N-1]找TopK,这样又成了TopK的一个子问题,也可以选择用递归解决。
- 堆排序法
出自LeetCode上面的题解:
https://leetcode-cn.com/problems/top-k-frequent-elements/solution/leetcode-di-347-hao-wen-ti-qian-k-ge-gao-pin-yuan-/
题目最终需要返回的是前 kk 个频率最大的元素,可以想到借助堆这种数据结构,对于 kk 频率之后的元素不用再去处理,进一步优化时间复杂度。
具体操作为:
- 借助 哈希表 来建立数字和其出现次数的映射,遍历一遍数组统计元素的频率
- 维护一个元素数目为 kk 的最小堆
- 每次都将新的元素与堆顶元素(堆中频率最小的元素)进行比较
- 如果新的元素的频率比堆顶端的元素大,则弹出堆顶端的元素,将新的元素添加进堆中
- 最终,堆中的 kk 个元素即为前 kk 个高频元素
- 桶排序法
首先依旧使用哈希表统计频率,统计完成后,创建一个数组,将频率作为数组下标,对于出现频率不同的数字集合,存入对应的数组下标即可。
O(n)的复杂度,python写起来比较简单。
C++写也可以,需要用unsorted_map实现哈希表,用list容器实现桶。
class Solution:
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
times = {}
for i in nums:
if i in times:
times[i] += 1
else:
times[i] = 1
bottom = [[] for _ in range(len(nums)+1)]
for i in times.keys():
time = times[i]
bottom[time].append(i)
res = []
for i in bottom[::-1]:
res.extend(i)
return res[0:k]
4.数组中第K个最大元素
给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。
https://leetcode.cn/problems/kth-largest-element-in-an-array/
- 大顶堆,需要建堆和出堆两个操作,建堆时间复杂度为O(n),前k个元素出堆的时间复杂度为klog(n),代码如下:
class Solution:
def findKthLargest(self, nums: List[int], k: int) -> int:
def sink(i, nums):
n = len(nums)
while 2*i<=n:
child = 2*i
if child<n and nums[child-1]<nums[child]:
child += 1
if nums[i-1]>nums[child-1]:
break
nums[i-1], nums[child-1] = nums[child-1], nums[i-1]
i = child
return nums
n = len(nums)
# 建堆
for i in range(math.ceil(n/2), 0, -1):
nums = sink(i, nums)
# 最大元素出堆
last = n-1
for i in range(k):
top = nums[0]
nums[0], nums[last] = nums[last], nums[0]
nums = sink(1, nums[:last])
last -= 1
return top
- 快速选择
改进快速排序算法来解决这个问题:在分解的过程当中,我们会对子数组进行划分,如果划分得到的 qq 正好就是我们需要的下标,就直接返回 a[q]a[q];否则,如果 qq 比目标下标小,就递归右子区间,否则递归左子区间。这样就可以把原来递归两个区间变成只递归一个区间,提高了时间效率。这就是「快速选择」算法。
class Solution:
def findKthLargest(self, nums: List[int], k: int) -> int:
# 快速选择算法
def partition(nums, l, r):
base = nums[l]
base_index = l
while l<r:
while l<r and nums[r]>base:
r-=1
nums[l] = nums[r]
while l<r and nums[l]<=base:
l+=1
nums[r] = nums[l]
nums[l] = base
return l
n = len(nums)
l = 0
r = n-1
k2 = n-k
while True:
index = partition(nums, l, r)
if index==k2:
return nums[index]
elif k2>index:
l = index+1
partition(nums, l, r)
else:
r = index-1
partition(nums, l, r)
- 最小堆
优先队列的思路: 由于找第 K 大元素,其实就是整个数组排序以后后半部分最小的那个元素。因此,我们可以维护一个有 K 个元素的最小堆;
如果当前堆不满,直接添加;
堆满的时候,如果新读到的数小于等于堆顶,肯定不是我们要找的元素,只有新遍历到的数大于堆顶的时候,才将堆顶拿出,然后放入新读到的数,进而让堆自己去调整内部结构。