大家好,我是挨打的阿木木,爱好算法的前端摸鱼老。最近会频繁给大家分享我刷算法题过程中的思路和心得。如果你也是想提高逼格的摸鱼老,欢迎关注我,一起学习。
题目
692. 前K个高频单词
给一非空的单词列表,返回前 k 个出现次数最多的单词。
返回的答案应该按单词出现频率由高到低排序。如果不同的单词有相同出现频率,按字母顺序排序。
示例 1:
输入: ["i", "love", "leetcode", "i", "love", "coding"], k = 2
输出: ["i", "love"]
解析: "i" 和 "love" 为出现次数最多的两个单词,均为2次。
注意,按字母顺序 "i" 在 "love" 之前。
示例 2:
输入: ["the", "day", "is", "sunny", "the", "the", "the", "sunny", "is", "is"], k = 4
输出: ["the", "is", "sunny", "day"]
解析: "the", "is", "sunny" 和 "day" 是出现次数最多的四个单词,
出现次数依次为 4, 3, 2 和 1 次。
注意:
- 假定 k 总为有效值, 1 ≤ k ≤ 集合元素数。
- 输入的单词均由小写字母组成。
扩展练习:
- 尝试以 O(n log k) 时间复杂度和 O(n) 空间复杂度解决。
思路
- 用
map对象,经过一轮遍历,统计好所有元素出现的次数; - 把记录下来的次数统计成数组
countList, 每个元素需要用到两个属性,一个是出现的次数,一个是当前的字符; - 对数组进行排序 -- 先比出现的次数再比挨个字母的顺序;
- 返回前面K个元素,只取字符的值。
实现
/**
* @param {string[]} words
* @param {number} k
* @return {string[]}
*/
var topKFrequent = function(words, k) {
let map = new Map();
const n = words.length;
// 记录每个单词出现的次数
for (let i = 0; i < n; i++) {
map.set(words[i], (map.get(words[i]) || 0) + 1);
}
// 把记录下来的次数统计成数组
let countList = [];
for (let [ key, value ] of map) {
countList.push({ key, value });
}
// 对数组进行排序 -- 先比出现的次数再比挨个字母的顺序
countList.sort((a, b) => b.value - a.value || compareString(a.key, b.key));
// 返回前面K个元素
return countList.slice(0, k).map(v => v.key);
};
// 对比两个字符串的大小,从小排到大
function compareString(a, b) {
// 当有一个字符为空的时候不用比较了
if (!a || !b) {
return a ? 1 : -1;
}
// 当前字符相等的时候比较下一个字符
return a[0].charCodeAt() - b[0].charCodeAt() || compareString(a.slice(1), b.slice(1));
}
结果
优化
思路:
- 首先我们可以对排序方式做个筛选,不用排完整个数组,只排出前面
k个元素; - 其次我们每次直接操作当前的元素,不需要
这道题目在进行排序的时候,我犹豫了要用哪种排序方式,直接用sort对所有元素进行排序是代码最简洁的,但却不是性能最高的,因为我们只需要拿到前面K个元素,没必要把后面的元素也进行排序。正常来讲如果整个数组全部进行排列是快速排序比较快,但是如果k的值偏小的时候,堆排序的性能会更高,话不多说我们直接上实践代码。
堆排序代码
/**
* @param {string[]} words
* @param {number} k
* @return {string[]}
*/
var topKFrequent = function(words, k) {
let map = new Map();
const n = words.length;
// 记录每个单词出现的次数
for (let i = 0; i < n; i++) {
map.set(words[i], (map.get(words[i]) || 0) + 1);
}
// 把记录下来的次数统计成数组
let countList = [];
for (let [ key, value ] of map) {
countList.push({ key, value });
}
return heapSort(countList, k);
};
// 对比两个对象的大小
function compareObject(a, b) {
return (a.value > b.value) || (a.value === b.value && (compareString(b.key, a.key) > 0));
}
// 对比两个字符串的大小,从小排到大
function compareString(a, b) {
// 当有一个字符为空的时候不用比较了
if (!a || !b) {
return a ? 1 : -1;
}
// 当前字符相等的时候比较下一个字符
return a[0].charCodeAt() - b[0].charCodeAt() || compareString(a.slice(1), b.slice(1));
}
// 堆排序
function heapSort(arr, k) {
buildMaxHeap(arr);
let result = [ arr[0].key ];
const n = arr.length - 1;
// 把当前的值放到最后面,然后把最后面的值作为头节点
// 然后调整堆的顺序即可
for (let i = n; i > n - k + 1; i--) {
swap(arr, 0, i);
heapify(arr, i - 1);
result.push(arr[0].key);
}
return result;
}
// 交换节点的值
function swap(arr, i, j) {
const temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
// 建立最大堆,初始化数据的时候操作
function buildMaxHeap(arr) {
const n = arr.length - 1;
// 最后一个数的父节点是(n - 1) / 2 | 0;
const cur = (n - 1) / 2 | 0;
// 从倒数第二层开始排,最后一层不用调整;
for (let i = cur; i >= 0; i--) {
heapify(arr, n, i);
}
}
// 调整堆顶元素所在的位置, 如果比下层元素小就往下交换位置
function heapify(arr, len, i = 0) {
let left = i * 2 + 1;
let right = i * 2 + 2;
let maxIndex = i;
// 左节点是最大值的话
if (left <= len && compareObject(arr[left], arr[maxIndex])) {
maxIndex = left;
}
// 右节点是最大值的话
if (right <= len && compareObject(arr[right], arr[maxIndex])) {
maxIndex = right;
}
// 如果当前节点不是最大值,那么当前节点的值要往下传递,让下面值大的冒泡上来
if (maxIndex !== i) {
swap(arr, i, maxIndex);
heapify(arr, len, maxIndex);
}
}
堆排序结果
调整思路
写到这里的时候我有点怀疑人生,讲道理空间下降了,你时间得给我提高点吧,结果并没有,做了波反向优化。不过没关系,日常翻车习惯了就好哈哈。我又回过头看代码,发现其实做了很多反复的操作,于是我又想着能不能像之前做监控二叉树时候一样,反过来冒泡,而且我们只需要维护一个长度为K的堆,超出的元素我们直接剔除即可, 就不用一开始初始化堆的时候把整个数组排一遍。
- 我们一开始初始化堆的时候,不建立最大堆了,建立一个最小堆;
- 然后如果堆的数量超过
k的时候,我们把最小堆中的第一个元素,也是最小的元素删除,然后替换成当前的值,同时对整个堆做一个排序调整,如果当前的元素不是堆中最小的元素,把最小元素换上来; - 这里我参考了一下味精王同学之前的一篇文章,我发现leetcode内置了优先队列这个数组,于是出于简化代码方面考虑,我选择用优先队列来实现。
这个库我们也可以引入项目中使用: github.com/datastructu… ;实现原理可以参照我们上边堆排序的代码。
最终代码
/**
* @param {string[]} words
* @param {number} k
* @return {string[]}
*/
var topKFrequent = function(words, k) {
let map = new Map();
const n = words.length;
// 记录每个单词出现的次数
for (let i = 0; i < n; i++) {
map.set(words[i], (map.get(words[i]) || 0) + 1);
}
// 把记录下来的次数统计成数组
let countList = [];
for (let [ key, value ] of map) {
countList.push({ key, value });
}
const heap = new PriorityQueue({
compare: (a, b) => {
// 如果出现的次数不相等,直接比较
if (a.value - b.value) return a.value - b.value;
// 相等的话就比较字符
return compareString(a.key, b.key);
}
});
for (let i = 0; i < countList.length; i++) {
// 溢出的节点删除掉,只保留k个元素
if (heap.size() > k) {
heap.dequeue();
}
heap.enqueue(countList[i]);
}
let result = [];
// 维护好数量为K的最小堆,一个一个弹出来即可
while (!heap.isEmpty) {
result.unshift(heap.dequeue().element.key);
}
return result;
};
// 对比两个字符串的大小,从小排到大
function compareString(a, b) {
// 当有一个字符为空的时候不用比较了
if (!a || !b) {
return a ? -1 : 1;
}
// 当前字符相等的时候比较下一个字符
return b[0].charCodeAt() - a[0].charCodeAt() || compareString(a.slice(1), b.slice(1));
}
很遗憾这部分代码无法直接在leetcode运行,我测试了一下优先队列的MinPriorityQueue方法是可用的,但是PriorityQueue却用不了,有懂的老哥也可以评论区告诉我一声,大家就先看个思路吧!
看懂了的小伙伴可以点个关注、咱们下道题目见。如无意外以后文章都会以这种形式,有好的建议欢迎评论区留言。