[前端]_一起刷leetcode 692. 前K个高频单词

139 阅读6分钟

大家好,我是挨打的阿木木,爱好算法的前端摸鱼老。最近会频繁给大家分享我刷算法题过程中的思路和心得。如果你也是想提高逼格的摸鱼老,欢迎关注我,一起学习。

题目

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, 21 次。

 

注意:

  1. 假定 k 总为有效值, 1 ≤ k ≤ 集合元素数。
  2. 输入的单词均由小写字母组成。

 

扩展练习:

  1. 尝试以 O(n log k) 时间复杂度和 O(n) 空间复杂度解决。

思路

  1. map对象,经过一轮遍历,统计好所有元素出现的次数;
  2. 把记录下来的次数统计成数组countList, 每个元素需要用到两个属性,一个是出现的次数,一个是当前的字符;
  3. 对数组进行排序 -- 先比出现的次数再比挨个字母的顺序;
  4. 返回前面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));
}

结果

image.png

优化

思路:

  1. 首先我们可以对排序方式做个筛选,不用排完整个数组,只排出前面k个元素;
  2. 其次我们每次直接操作当前的元素,不需要

这道题目在进行排序的时候,我犹豫了要用哪种排序方式,直接用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);
    }
}

堆排序结果

image.png

调整思路

写到这里的时候我有点怀疑人生,讲道理空间下降了,你时间得给我提高点吧,结果并没有,做了波反向优化。不过没关系,日常翻车习惯了就好哈哈。我又回过头看代码,发现其实做了很多反复的操作,于是我又想着能不能像之前做监控二叉树时候一样,反过来冒泡,而且我们只需要维护一个长度为K的堆,超出的元素我们直接剔除即可, 就不用一开始初始化堆的时候把整个数组排一遍。

  1. 我们一开始初始化堆的时候,不建立最大堆了,建立一个最小堆;
  2. 然后如果堆的数量超过k的时候,我们把最小堆中的第一个元素,也是最小的元素删除,然后替换成当前的值,同时对整个堆做一个排序调整,如果当前的元素不是堆中最小的元素,把最小元素换上来;
  3. 这里我参考了一下味精王同学之前的一篇文章,我发现leetcode内置了优先队列这个数组,于是出于简化代码方面考虑,我选择用优先队列来实现。

image.png

这个库我们也可以引入项目中使用: 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却用不了,有懂的老哥也可以评论区告诉我一声,大家就先看个思路吧!

看懂了的小伙伴可以点个关注、咱们下道题目见。如无意外以后文章都会以这种形式,有好的建议欢迎评论区留言。