开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第30天,点击查看活动详情
快速选择和堆
1、快速选择
用于求解 TopK 问题,也就是第 K 个元素的问题。
可以使用快速排序的 partition() 进行实现。需要先打乱数组,否则最坏情况下时间复杂度为 O(N2)。
2、堆
用于求解 TopK 问题,也就是 K 个最小元素的问题。可以维护一个大小为 K 的最小堆,最小堆中的元素就是最小元素。最小堆需要使用大顶堆来实现,大顶堆表示堆顶元素是堆中最大元素。这是因为我们要得到 k 个最小的元素,因此当遍历到一个新的元素时,需要知道这个新元素是否比堆中最大的元素更小,更小的话就把堆中最大元素去除,并将新元素添加到堆中。所以我们需要很容易得到最大元素并移除最大元素,大顶堆就能很好满足这个要求。
堆也可以用于求解 Kth Element 问题,得到了大小为 k 的最小堆之后,因为使用了大顶堆来实现,因此堆顶元素就是第 k 大的元素。
快速选择也可以求解 TopK Elements 问题,因为找到 Kth Element 之后,再遍历一次数组,所有小于等于 Kth Element 的元素都是 TopK Elements。
可以看到,快速选择和堆排序都可以求解 Kth Element 和 TopK Elements 问题。
1. 数组的第K大数
- Kth Largest Element in an Array / 数组中的第K个最大元素 (Medium)
Input: [3,2,1,5,6,4] and k = 2
Output: 5
题目描述:找到从小到大排序后倒数第 k 个元素。
题解:
方法1:堆,时间复杂度 O(NlogK),空间复杂度 O(K)。
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
priority_queue<int, vector<int>, greater<int>> heap; // 小顶堆
for(auto num : nums) {
heap.push(num);
if(heap.size() > k) heap.pop(); // 维护堆的大小为k
}
return heap.top();
}
};
方法2:快速选择,时间复杂度 O(N),空间复杂度 O(1)
此题要找到第k大数,即从小到大排序后倒数第 k 个数,可转换为求正数第len - k + 1个数
使用partition将数组划分为<=pivot和>=pivot两个部分,若pivot所处位置之前有k个数则舍弃后面的数,若pivot所处位置之前不到k个数则舍弃前面的数。下面的实现中while循环后,j 即为pivot所在位置,注意此 j 是相对整个数组而不是相对某个区间,因而包括pivot在内的左侧部分元素为[0, j],长度为 j+1,若k<=j+1则第k个数在[l, j]区间内,否则在[j + 1, r]区间内
经过快速选择后,原数组中第k个数即为所求,其下标为[k - 1]
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
k = nums.size() - k + 1; // 第k大的,转换为第k小的
quick_select(nums, 0, nums.size() - 1, k);
return nums[k - 1]; // 第k小的元素在原数组中的小标为k-1
}
void quick_select(vector<int>& q,int l,int r,int k)
{
if(l >= r) return;
int x = q[l + r >> 1], i = l - 1, j = r + 1;
while(i < j) {
while(q[++i] < x);
while(q[--j] > x);
if(i < j) swap(q[i], q[j]);
}
// 0~j有j+1个数,因而k与j+1进行比较
if(k <= j + 1) quick_select(q, l, j, k); // 第k个数在左侧
else quick_select(q, j + 1, r, k); // 第k个数在右侧
}
};
2. 有序矩阵的第K小元素
Leetcode 378. 有序矩阵中第K小的元素 (Medium)
题解:
方法1:使用大顶堆
class Solution {
public:
int kthSmallest(vector<vector<int>>& matrix, int k) {
priority_queue<int> heap;
for(int i = 0; i < matrix.size(); i++) {
for(int j = 0; j < matrix[0].size(); j++) {
heap.push(matrix[i][j]);
if(heap.size() > k) heap.pop();
}
}
return heap.top();
}
};
方法2:二分查找✏️
1.找出二维矩阵中最小的数left,最大的数right,那么第k小的数必定在leftright之间 2.mid=(left+right) / 2;在二维矩阵中寻找小于等于mid的元素个数count 3.若这个count小于k,表明第k小的数在右半部分且不包含mid,即left=mid+1, right=right,又保证了第k小的数在leftright之间 4.若这个count大于k,表明第k小的数在左半部分且可能包含mid,即left=left, right=mid,又保证了第k小的数在leftright之间 5.因为每次循环中都保证了第k小的数在leftright之间,当left==right时,第k小的数即被找出,等于right
注意:这里的left mid right是数值,不是索引位置。
class Solution {
public:
int kthSmallest(vector<vector<int>>& matrix, int k) {
int l = matrix[0][0], r = matrix.back().back();
while (l < r) { // 每次循环保证第k小的数在start~end之间,当start==end,第k小的数就是start
int mid = l + (r - l) / 2;
int cnt = search_less_equal(matrix, mid); // 找二维矩阵中<=mid的元素总个数
if (cnt >= k) r = mid; // 第k小元素在左半部分,可能包含mid
else l = mid + 1; // 第k小元素在右半部分,不包含mid
}
return l;
}
int search_less_equal(vector<vector<int>>& matrix, int target) {
int n = matrix.size(), i = n - 1, j = 0, res = 0;
// 以列为单位找,找到最后一个<=mid的数
while (i >= 0 && j < n) {
if (matrix[i][j] <= target) {
res += i + 1; // 第j列有i+1个元素<=mid
++j;
} else {
--i;
}
}
return res;
}
};
桶排序
1. 出现频率最高的K个元素✏️
- Top K Frequent Elements (Medium)
Given [1,1,1,2,2,3] and k = 2, return [1,2].
方法1:桶排序,时间复杂度O(n),空间复杂度O(n)✏️
设置若干个桶,每个桶存储出现频率相同的数。桶的下标表示数出现的频率,即第 i 个桶中存储的数出现的频率为 i。
把数都放到桶之后,从后向前遍历桶,最先得到的 k 个数就是出现频率最多的的 k 个数。
public List<Integer> topKFrequent(int[] nums, int k) {
Map<Integer, Integer> frequencyForNum = new HashMap<>();
for (int num : nums) {
frequencyForNum.put(num, frequencyForNum.getOrDefault(num, 0) + 1);
}
List<Integer>[] buckets = new ArrayList[nums.length + 1];
for (int key : frequencyForNum.keySet()) {
int frequency = frequencyForNum.get(key);
if (buckets[frequency] == null) {
buckets[frequency] = new ArrayList<>();
}
buckets[frequency].add(key);
}
List<Integer> topK = new ArrayList<>();
for (int i = buckets.length - 1; i >= 0 && topK.size() < k; i--) {
if (buckets[i] == null) {
continue;
}
if (buckets[i].size() <= (k - topK.size())) {
topK.addAll(buckets[i]);
} else {
topK.addAll(buckets[i].subList(0, k - topK.size()));
}
}
return topK;
}
方法2:哈希表+堆,时间复杂度 O(nlogk),空间复杂度O(n)
- 借助 哈希表 来建立数字和其出现次数的映射,遍历一遍数组统计元素的频率
- 维护一个元素数目为 k 的最小堆
- 每次都将新的元素与堆顶元素(堆中频率最小的元素)进行比较
- 如果新的元素的频率比堆顶端的元素大,则弹出堆顶端的元素,将新的元素添加进堆中
- 最终,堆中的 k 个元素即为前 k 个高频元素
首先,遍历一遍数组统计元素的频率,这一系列操作的时间复杂度是 O(n);接着,遍历用于存储元素频率的 map,如果元素的频率大于最小堆中顶部的元素,则将顶部的元素删除并将该元素加入堆中,这里维护堆的数目是 k,所以这一系列操作的时间复杂度是 O(nlogk) 的;因此,总的时间复杂度是 O(nlogk)。
最坏情况下(每个元素都不同),map 需要存储 n 个键值对,优先队列需要存储 k 个元素,因此,空间复杂度是 O(n)。
class Solution {
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> ump;
priority_queue<pair<int, int>> pq; // 大顶堆
for(int num : nums) ump[num]++; // 建立数字及出现次数的映射,键为数字值为频率
for(auto it : ump) pq.push({it.second, it.first}); // 按 频率-数字 的顺序放入堆中,按频率排序
vector<int> res;
for(int i = 0; i < k; i++) { // 取出堆顶的前k个数即为出现频率前k高的数
res.push_back(pq.top().second); pq.pop(); // 取出时first为频率,second为数字
}
return res;
}
};
2. 按照字符出现次数对字符串排序
- Sort Characters By Frequency (Medium)
Input:
"tree"
Output:
"eert"
Explanation:
'e' appears twice while 'r' and 't' both appear once.
So 'e' must appear before both 'r' and 't'. Therefore "eetr" is also a valid answer.
public String frequencySort(String s) {
Map<Character, Integer> frequencyForNum = new HashMap<>();
for (char c : s.toCharArray())
frequencyForNum.put(c, frequencyForNum.getOrDefault(c, 0) + 1);
List<Character>[] frequencyBucket = new ArrayList[s.length() + 1];
for (char c : frequencyForNum.keySet()) {
int f = frequencyForNum.get(c);
if (frequencyBucket[f] == null) {
frequencyBucket[f] = new ArrayList<>();
}
frequencyBucket[f].add(c);
}
StringBuilder str = new StringBuilder();
for (int i = frequencyBucket.length - 1; i >= 0; i--) {
if (frequencyBucket[i] == null) {
continue;
}
for (char c : frequencyBucket[i]) {
for (int j = 0; j < i; j++) {
str.append(c);
}
}
}
return str.toString();
}
荷兰国旗问题
荷兰国旗包含三种颜色:红、白、蓝。
有三种颜色的球,算法的目标是将这三种球按颜色顺序正确地排列。它其实是三向切分快速排序的一种变种,在三向切分快速排序中,每次切分都将数组分成三个区间:小于切分元素、等于切分元素、大于切分元素,而该算法是将数组分成三个区间:等于红色、等于白色、等于蓝色。
1. 按颜色进行排序
- Sort Colors (Medium)
Input: [2,0,2,1,1,0]
Output: [0,0,1,1,2,2]
题目描述:只有 0/1/2 三种颜色。
题解:使用双指针,分别指向红色区域的末尾和蓝色区域的开头。
- 当前指针指向0则红色区域末尾指针右移,将0与红色区域末尾指针指向的数字交换,即红色区域扩大一位,然后当前指针向后移;
- 当前指针指向2则蓝色区域开头指针左移,将2与蓝色区域开头指针指向的元素交换,但蓝色指针指向的数字大小不确定,因而需要再次判断大小,当前指针保持不动;
- 若当前指针指向1则不作交换,直接指向下一个元素。
- 循环结束的条件就是当前指针必须在蓝色区域开头指针的左侧。
写法1:while循环,需要使用一个指针指向当前位置。从右侧交换过来一个数,则还需判断交换过来这个数的大小,因而交换后curr指针不动。
class Solution {
public:
void sortColors(vector<int>& nums) {
int l = -1, r = nums.size(), curr = 0;
while(curr < r) {
if(nums[curr] < 1) swap(nums[++l], nums[curr++]);
else if(nums[curr] > 1) swap(nums[--r], nums[curr]);
else curr++;
}
}
};
写法2:for循环逐个枚举各个位置,若从右侧交换过来一个数。则还需判断交换过来这个数的大小,因而交换后需要 i--,然后当前循环结束 i 会加1,从而 i 保持不变。
class Solution {
public:
void sortColors(vector<int>& nums) {
int l = -1, r = nums.size();
for(int i = 0; i < r; i++) {
if(nums[i] < 1) swap(nums[++l], nums[i]);
else if(nums[i] > 1) swap(nums[--r], nums[i--]);
}
}
};