【JavaScript】从topK问题学习「堆排序&快速排序」算法

498 阅读3分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情

输出内容才能更好的理解输入的知识,leetcode题目链接215. 数组中的第K个最大元素

前言🎀

网易一面的算法题,当时没学过也没刷过算法 场面一度十分尴尬。后来才知道大厂都要考算法🙊
稀里糊涂的使用了 插入排序,但并不是最优解 面试官也不认可 所以凉了~

impicture_20220729_202141.png 悔恨之余去LeetCode刷了100多条提交,当时以为完全理解了,但二刷时还是有些思路不清

温故而知新,借着这次更文活动的机会 仔细整理一下解题思路,也希望能帮到更多的同学
如果觉得有收获还望大家点个赞🌹

题目描述📖

给定整数数组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

提示:
· 1 <= k <= nums.length <= 10510^5
· 104-10^4 <= nums[i] <= 10410^4

解题思路🌲

求无序数组第k大的值
首先排除使用语言内置的排序方法,然后很容易想到先排序数组再返回倒数第k个元素的值的方法

但其实并不需要排序全部元素,因为我们只需确定倒数第k个元素的正确性(即求 在顺序数组中倒数第k个元素是多少),所以只需要局部排序即可

例如冒泡排序会逐个的排序,在k指向数组比较靠前的元素时并不符合我们局部排序的需求 所以可以直接排除

从多种排序算法中选出符合特性并且效率较高的 快速排序堆排序
而选择快速排序和堆排序的具体原因就需要从它们实现排序的内部原理来理解了

快速排序

快排速度快,而且效率高, 是处理大数据最快的排序算法之一。

概念:快速排序使用 分治法partition 把一个串(list)分为 较大和较小的两个子串(sub-list),然后递归的排序两个子串,直到子串长度为0或1为止

分治法partition: 以一个目标为基准 对数组使用左右双指针排序,将数组分为比基准小的子数组、基准值、比基准大的子数组

image.png 每次快排的partition操作都能找到基准值正确的位置

partition

function partition(arr, left, right) {
  // 取中间项为基准
  let pivot = arr[~~((left + right) / 2)];

  // 开始调整
  while (left < right) {
    // 左指针到大于等于基准值的元素为止
    while (arr[left] < pivot) {
      left++
    }
    // 右指针到小于等于基准值的元素为止
    while (arr[right] > pivot) {
      right--
    }
    // 存在重复数据时 继续递增left,防止死循环
    if(left !== right && arr[left] === arr[right]) {
        left++
        continue
    }
    // 交换左右指针的元素
    if(left < right) [arr[left], arr[right]] = [arr[right], arr[left]]
  }
  return left
}

堆排序

堆排序是一种原地,非稳定,时间复杂度nlogn的排序算法,借助了数据结构堆
整体过程可以总结为:上浮下沉

堆是一颗完全二叉树,堆中每一个节点的值必须大于或等于(小于或者等于)其左右子树的值(大顶堆,小顶堆)
大顶堆:每个父节点要大于或者等于其左右子树节点
小顶堆:每个父节点要小于或者等于其左右子树节点

JS可以使用数组实现堆,无需存储左右指针,节省空间

image.png

某元素坐标为n  
第n个元素 左子节点 坐标为 2 * n + 1  
第n个元素 右子节点 坐标为 2 * n + 2  
第n个元素 父节点 坐标为 (n - 1) / 2  
最后一个非叶子节点为 ~~(arr.length / 2) - 1

堆排序过程(升序):构建一个大顶堆,取栈顶的数字(即剩下数值中的最大值)。重复以上操作使其满足堆定义,直到取完堆中的数字最终得到一个升序序列

构建大顶堆

function buildMaxHeap(arr) {
    const size = arr.length
    // 从最后一个非叶子节点 自底向上的构建堆
    for (let i = ~~(size / 2) - 1; i >= 0; i--) {
        maxHeapify(arr, i, size)
    }
}

function maxHeapify(arr, index, heapSize) {
    // 获取父节点左右子节点的索引 
    let iLeft = 2 * index + 1
    let iRight = 2 * index + 2
    // 记录元素值最大的节点索引
    let iMax = index

    if (iLeft < heapSize &&  arr[iLeft] > arr[iMax]) iMax = iLeft
    if (iRight < heapSize &&  arr[iRight] > arr[iMax]) iMax = iRight
    if (iMax !== index) {
        // 最大节点与父节点交换
        [arr[index], arr[iMax]] = [arr[iMax], arr[index]]
        // 重新调整树结构
        maxHeapify(arr, iMax, heapSize)
    }
}

题解✏️

无论是 快速排序 还是 堆排序partition 还是 buildMaxHeap,它们每次都能高效的获取到数组中某元素值的正确位置。 然后或是递归或是循环的排列完数组里的每一个元素

而topK问题的求解并不需要全排列,所以需要我们理解两种排序内部的原理并根据具体情况使用,这也是该问题是中等题却不是简单题的原因

结合快速排序

快速排序每次都可以确认基准值的正确位置 i

当 i !== n - k(倒数第k个元素索引)时,可以根据i和k得出下次递归排序应该发生在哪个区间
当 i === n - k 时,既是正确答案

let mid = (left + right) / 2 >> 0 其实是想模拟选出一个中间的值,因为当基准值为最大值或最小值的时候快排的时间复杂度=冒泡排序的时间复杂O(n^2),但其实在乱序数组中选哪个都一样

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var findKthLargest = function(nums, k) {
    const len = nums.length
    return quickSort(nums, 0, len - 1, len - k)
};

function quickSort(arr, left, right, target) {
    if (left === right) return arr[left]
    const index = partition(arr, left, right)
    if (index === target) {
        return arr[index]
    } else if (index > target) {
        return quickSort(arr, left, index - 1, target)
    } else {
        return quickSort(arr, index + 1, right, target)
    }
}

function partition(arr, left, right) {
    let mid = (left + right) / 2 >> 0
    let pvoit = arr[mid]

    while (left < right) {
        while (arr[left] < pvoit) {
            left++
        }
        while (arr[right] > pvoit) {
            right--
        }
        if (left !== right && arr[left] === arr[right]) {
            left++
            continue
        }
        if (left < right) [arr[left], arr[right]] = [arr[right], arr[left]]
    }
    return left
}

提交代码,通过😊 topk1.png

结合堆排序

整理堆排序的流程:

  1. 将无序数组构建成一个堆,根据升序降序需求选择大顶堆
  2. 将堆顶元素与末尾元素交换,将最大元素「沉」到数组末端
  3. 重新调整结构,使其满足堆定义,然后继续交换堆顶与当前末尾元素,反复执行调整、交换步骤,直到整个序列有序

总结下来是一套 建堆 调整 删除 的操作,每次调整和删除都能获得当前堆中最大的值,只要进行k - 1次操作后即可获得对应的元素

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var findKthLargest = function(nums, k) {
    let heapSize = nums.length
    buildMaxHeap(nums)
    for (let i = 0; i < k; i++) {
        --heapSize;
        [nums[0], nums[heapSize]] = [nums[heapSize], nums[0]]
        maxHeapify(nums, 0, heapSize)
    }
    return nums[heapSize]
};

function buildMaxHeap(arr) {
    const size = arr.length
    for (let i = ~~(size / 2) - 1; i >= 0; i--) {
        maxHeapify(arr, i, size)
    }
}

function maxHeapify(arr, index, heapSize) {
    let iLeft = 2 * index + 1
    let iRight = 2 * index + 2
    let iMax = index

    if (iLeft < heapSize &&  arr[iLeft] > arr[iMax]) iMax = iLeft
    if (iRight < heapSize &&  arr[iRight] > arr[iMax]) iMax = iRight
    if (iMax !== index) {
        [arr[index], arr[iMax]] = [arr[iMax], arr[index]]
        maxHeapify(arr, iMax, heapSize)
    }
}

提交代码,通过 完结撒花🥳 topk2.png

结语🎉

也可以在leetcode看官方的题解 可惜没JS版的 ,不要光看不做题哦,后续会持续更新算法相关的知识
写作不易,如果觉得有收获欢迎大家点个赞谢谢🌹

才疏学浅,如果文章有什么问题欢迎大家指教