排序方式大总结(含详细代码和应用场景)

430 阅读7分钟

排序总结

下面的排序算法中会多次用到交换函数,这里先给出其定义:

// 交换数组中 i 和 j 位置上的值
var swap = function(arr, i, j){
    // 注意:调用此函数时,需要保证 i≠j,否则会出错
    arr[i] = arr[i] ^ arr[j]
    arr[j] = arr[i] ^ arr[j]
    arr[i] = arr[i] ^ arr[j]
}

选择排序

基本思想:每一趟,从待排序序列中选择最小的元素,放到已排序序列的末尾。一共需要 n-1 趟。

var selectSort = function (arr) {
    const n = arr.length
    for (let i = 0; i < n - 1; i++){
        let minIndex = i // 记录最小元素的索引
        // 从 arr[i+1...n-1] 中找到最小元素的索引并更新
        for (let j = i + 1; j < n; j++){
            if (arr[j] < arr[minIndex]) {
                minIndex = j
            }
        }
        // 交换数组中 minIndex 和 i 位置上的值
        if (minIndex !== i) {
            swap(arr, minIndex, i)
        }
    }
}

冒泡排序

基本思想:每一趟冒泡,从后往前(或从前往后)两两比较相邻元素的值,若为逆序,则交换它们。一趟冒泡结束后,会将最小的元素交换到待排序序列的第一个位置(或将最大的元素交换到待排序序列的最后一个位置)。一共需要 n-1 趟。

var bubbleSort = function (arr) {
    const n = arr.length
    for (let i = 0; i < n - 1; i++){
        let flag = false // 记录本趟冒泡是否发生交换
        for (let j = n - 1; j >= i + 1; j--){
            if (arr[j] < arr[j-1]) {
                flag = true
                swap(arr, j, j - 1)
            }
        }
        // 本趟冒泡没有发生交换,说明数组已经有序,排序结束
        if(!flag) return
    }
}

插入排序

基本思想:每次将一个待排序的元素插入前面已排好序的子序列,直到全部元素插入完成。

var insertSort = function (arr) {
    const n = arr.length
    // 外层循环的每一次执行,需要将 arr[0...i] 中的元素变成有序的
    for (let i = 1; i < n; i++){
        for (let j = i; j > 0 && arr[j] < arr[j-1]; j--){ // 如果当前元素不小于前一个元素,说明 arr[0...i] 已经有序,直接进入下一次外层循环
            swap(arr, j, j - 1)
        }
    }
}

归并排序(2路)

基本思想:整体就是一个简单递归。将原数组拆分成左右两个子数组,将这两个子数组排好序,然后合并成一个更大的有序数组。

// 对 arr 中的两个有序子数组 arr[l...mid] 和 arr[mid+1...r] 进行排序
var merge = function(arr, l, mid, r){
    const ans = []
    let i = l, j = mid + 1
    while(i <= mid && j <= r){
        ans.push(arr[i] <= arr[j] ? arr[i++] : arr[j++])
    }
    while(i <= mid){
        ans.push(arr[i++])
    }
    while(j <= r){
        ans.push(arr[j++])
    }
    for(let k = l; k <= r; k++){
        arr[k] = ans.shift()
    }
}
// 对 arr[l...r] 进行归并排序
var process = function(arr, l, r){
    if(l === r) return
    const mid = l + ((r - l) >> 1) // 找到中间位置
    process(arr, l, mid) // 对左边进行排序
    process(arr, mid + 1, r) // 对右边进行排序
    merge(arr, l, mid, r) // 将左右两个有序的数组合并成一个更大的有序数组
}
var mergeSort = function(arr){
    if(arr.length === 0){
        return
    }
    process(arr, 0, arr.length - 1)
}

归并排序的应用

数组中的逆序对

题:在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。

解:设数组当前元素为 x, 其左边有 y 个数比它大,那么对于 x,就产生 y 个逆序对。统计数组中所有元素的逆序对个数,加起来就是整个数组的逆序对总数。可以利用归并排序的合并阶段计算出数组中每个元素的逆序对个数(即左边有多少个元素比当前元素大)。设在合并阶段,左子数组当前元素为 a,右子数组当前元素为 b。那么:

  1. a <= b时,将 a 添加到结果数组,左子数组当前元素后移,不产生逆序对
  2. a > b时,将 b 添加到结果数组,右子数组当前元素后移,对于 b 来说,a 至左子数组末尾元素都比它大,产生若干逆序对

注意:当 a === b 时,应该将 a 添加到结果数组,这样,才能完整的统计右子数组中每个元素的左边有多少个元素比它大

举个例子:统计数组[7, 3, 2, 6, 0, 1, 5, 4]的逆序对总数,其归并排序的过程如图:

归并排序过程图示

对于元素 4 来说,统计其逆序对个数的过程为:①合并[5][4]时产生 1 个逆序对②合并[0, 1][4, 5]时不产生逆序对③合并[2, 3, 6, 7][0, 1, 4, 5]时产生 2 个逆序对。共计 3 个逆序对,即元素 4 的左边有 3 个元素比它大。统计其它元素的逆序对过程以此类推......

备注:

  • 当前算法会将原数组按升序排列
  • 对于数组中每个元素,计算其逆序对的过程都是分批的、不重复的、也不遗漏的
let ans // 逆序对的个数
// 合并两个有序子数组 arr[l...mid]、arr[mid+1...r],并统计逆序对的个数
var mergeAndCount = function(arr, l, mid, r){
    const tempArr = []
    let i = l, j = mid + 1
    while(i <= mid && j <= r){
        if(arr[i] <= arr[j]){
            // 不产生逆序对
            tempArr.push(arr[i++])
        }else{
            // arr[i...mid] 与 arr[j] 构成共 mid-i+1 逆序对
            ans += (mid - i + 1)
            tempArr.push(arr[j++])
        }
    }
    while(i <= mid){
        // 左子数组中有剩余元素,但是由于对逆序对的统计是针对于右子数组中的元素的,所以这里不产生逆序对
        tempArr.push(arr[i++])
    }
    while(j <= r){
        // 右子数组中有剩余元素,但这些剩余元素都大于左子数组中的所有元素,所以也不产生逆序对
        tempArr.push(arr[j++])
    }
    // 将临时数组中的元素拷贝到原数组中
    for(let k = l; k <= r; k++){
        arr[k] = tempArr.shift()
    }
}
// 对 arr[l...r] 进行归并排序
var mergeSort = function(arr, l, r){
    if(l === r) return
    const mid = l + ((r - l) >> 1) // 找到中间位置
    mergeSort(arr, l, mid) // 对左边进行排序
    mergeSort(arr, mid + 1, r) // 对右边进行排序
    mergeAndCount(arr, l, mid, r) // 合并左右两个有序子数组,并统计逆序对的个数
}
var reversePairs = function(nums){
    ans = 0
    if(nums.length === 0){
        return 0
    }
    mergeSort(nums, 0, nums.length - 1)
    return ans
}

快速排序

基本思想:在待排序数组arr[l...r]中任取一个元素作为枢轴,记为 pivot 。通过一趟排序(或一次划分)将数组分割成独立的三部分:arr[l...m]arr[m+1...n-1]arr[n...r]。使得arr[l...m]中的所有元素小于 pivot,arr[m+1...n-1]中的所有元素等于 pivot,arr[n...r]中的所有元素大于 pivot。然后分别递归的对arr[l...m]arr[n...r]重复上述过程。

划分的步骤为:定义一个小于枢轴元素的区域右边界 lArea 和 一个大于枢轴元素的区域左边界 rArea,遍历arr[l...r]

  1. 如果当前元素小于枢轴元素,则交换当前元素和 lArea 的下一个元素,并将 lArea 和当前元素右移
  2. 如果当前元素大于枢轴元素,则交换当前元素和 rArea 的上一个元素,并将 rArea 左移
  3. 如果当前元素等于枢轴元素,则当前元素右移
// 对 arr[l...r] 进行一次划分,并返回小于枢轴的区域右边界和大于枢轴的区域左边界
var partition = function(arr, l, r, pivot){
    let lArea = l - 1 // 小于 pivot 的区域右边界
    let rArea = r + 1 // 大于 pivot 的区域左边界
    while(l < rArea){
        if(arr[l] > pivot){
            l !== rArea - 1 && swap(arr, l, rArea - 1)
            rArea--
        }else if(arr[l] < pivot){
            lArea + 1 !== l && swap(arr, lArea + 1, l)
            lArea++
            l++
        }else{
            l++
        }
    }
    return [lArea, rArea]
}
// 对 arr[l...r] 进行快速排序
var process = function(arr, l, r){
    if(l >= r) return
    const pivot = arr[Math.round(Math.random() * (r - l) + l)] // 任取一个元素作为枢轴
    const areas = partition(arr, l, r, pivot)
    process(arr, l, areas[0])
    process(arr, areas[1], r)
}
var quickSort = function(arr){
    if(arr.length === 0){
        return
    }
    process(arr, 0, arr.length - 1)
}

快速排序的应用

颜色分类

题:给定一个包含红色、白色和蓝色、共 n 个元素的数组 nums ,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。必须在不使用库内置的 sort 函数的情况下解决这个问题。进阶:你能想出一个仅使用常数空间的一趟扫描算法吗?

解:很明显,我们只需要将枢轴元素设为 1,然后进行一次快速排序中的『划分』,即可满足题目要求。下面是代码实现:

var sortColors = function(nums) {
    const pivot = 1
    let lArea = -1, rArea = nums.length
    let i = 0
    while(i < rArea){
        if(nums[i] > pivot){
            i !== rArea - 1 && swap(nums, i, rArea - 1)
            rArea--
        }else if(nums[i] < pivot){
            i !== lArea + 1 && swap(nums, i, lArea + 1)
            lArea++
            i++
        }else{
            i++
        }
    }
};

堆排序

基本思想:将arr[0...n-1]建成初始大根堆,由于大根堆本身的特点,堆顶元素就是最大值,所以将堆顶元素与堆底元素交换。此时已不满足大根堆的性质,需要将arr[0...n-2]重新从堆顶元素向下调整成一个新的大根堆,然后将堆顶元素与堆底元素交换。继续将arr[0...n-3]调整成一个新的大根堆,交换堆顶元素与堆底元素。...... 重复上述过程,即可完成排序。

// 建立大根堆
var buildMaxHeap = function(arr){
    // 找到最后一个分支节点(就是末尾节点的父节点)
    const branch = Math.floor((arr.length - 2) / 2)
    // 对 branch 及其之前的节点进行调整(branch 之后的节点都是叶子节点,已经是一个大根堆,无需调整)
    for(let i = branch; i >= 0; i--){
        heapAdjust(arr, i, arr.length)
    }
}
// 将以任意元素为根的子树调整成一个大根堆
var heapAdjust = function(arr, index, heapSize){
    // 找到左孩子节点
    let left = index * 2 + 1
    // 只要还存在孩子节点,就需要判断是否进行调整
    while(left < heapSize){
        // 找到左右孩子节点中较大的那个,下标赋给 largest
        let largest = ((left + 1) < heapSize && arr[left+1] > arr[left]) ? left + 1 : left
        // 父节点和较大的孩子节点之间,谁的值大,就把谁的下标赋给 largest
        largest = arr[index] < arr[largest] ? largest : index
        if(largest === index){
            // 孩子节点的值均不大于父节点的值,无需调整
            break
        }else{
            // 存在孩子节点的值大于父节点的值,需要调整
            swap(arr, index, largest)
            // 更新父节点的下标和左孩子节点的下标
            index = largest
            left = index * 2 + 1
        }
    }
}
var heapSort = function(arr){
    let n = arr.length
    buildMaxHeap(arr) // 建立大根堆
    for(let i = n; i > 1; i--){
        heapAdjust(arr, 0, n) // 将剩余元素调整成一个新的大根堆
        swap(arr, 0, --n) // 交换堆顶元素和堆底元素
    }
}

堆排序的应用

给几乎有序的数组排序

题:已知一个几乎有序的数组,几乎有序是指,如果把数组排好顺序的话,每个元素移动的距离可以不超出k,并且k相对于数组来说比较小。请选择一个合适的排序算法针对这个数组进行排序。

解:由于数组当前元素的位置相对于排好序之后的位置距离不超过 k,所以可以肯定的是arr[0...k]的最小值一定是arr[0],因为arr[k+1]及之后的元素到arr[0]的距离都大于 k。以此类推:可以知道,arr[1...k+1]的最小值一定是arr[1]arr[2...k+2]的最小值一定是arr[2]......

算法的具体步骤如下:

  1. arr[0...k]添加到小根堆,然后弹出堆顶元素,作为arr[0]的值
  2. 下标从 k+1 开始,遍历原数组,每次都将当前元素添加到小根堆,然后弹出堆顶元素添加到原数组中
  3. 遍历完成后,将小根堆中的剩余元素全部依次弹出,添加到原数组中
// 创建一个小根堆类
class MinHeap{
    constructor(){
        this.heap = []
    }
    // 向堆中添加一个元素
    add(x){
        this.heap.push(x)
        let index = this.heap.length - 1 // 当前节点的下标
        let parentIndex = Math.trunc((index - 1) / 2) // 父节点的下标
        while(parentIndex >= 0 && this.heap[parentIndex] > this.heap[index]){ // 存在父节点且父节点的值大于当前节点的值,就需要进行调整
            // 交换当前节点和父节点的值
            const temp = this.heap[index]
            this.heap[index] = this.heap[parentIndex]
            this.heap[parentIndex] = temp
            // 更新当前节点和父节点的下标
            index = parentIndex
            parentIndex = Math.trunc((index - 1) / 2)
        }
    }
    // 弹出堆顶元素
    poll(){
        const res =  this.heap.shift()
        // 交换堆顶元素与堆底元素
        this.heap.length !== 0 && this.heap.unshift(this.heap.pop())
        let index = 0 // 从堆顶元素向下进行堆调整
        let left = index * 2 + 1 // 左孩子的下标
        // 只要还存在孩子节点,就需要判断是否进行调整
        while(left < this.heap.length){
            // 找到左右孩子节点中较小的那个,下标赋给 smallest
            let smallest = (left + 1 < this.heap.length && this.heap[left+1] < this.heap[left]) ? left + 1 : left
            // 父节点和较小的孩子节点之间,谁的值小,就把谁的下标赋给 smallest
            smallest = this.heap[smallest] < this.heap[index] ? smallest : index
            if(smallest === index){
                // 孩子节点的值均不小于父节点的值,无需调整
                break
            }else{
                // 存在孩子节点的值小于父节点的值,需要调整
                const temp = this.heap[index]
                this.heap[index] = this.heap[smallest]
                this.heap[smallest] = temp
                index = smallest
                left = index * 2 + 1
            }
        }
        return res
    }
    // 判断堆是否为空
    isEmpty(){
        return this.heap.length === 0
    }
}
var kSort = function(arr, k){
    const heap = new MinHeap()
    // 将 arr[o...k] 添加到小根堆中
    let index = 0
    while(index <= k){
        heap.add(arr[index++])
    }
    // 弹出堆顶元素 x(x是 arr[0...k] 的最小值),由于每个元素当前位置相对于排好序之后的位置,距离不超过 k,所以 x 一定是 arr[0]
    arr[0] = heap.poll()
    // arr[1...k+1] 的最小值一定是 arr[1],arr[2,k+2] 的最小值一定是 arr[2],以此类推...
    while(index < arr.length){
        heap.add(arr[index])
        arr[index-k] = heap.poll()
        index++
    }
    // 将小根堆中的剩余元素全部依次弹出,然后添加到原数组中
    while(!heap.isEmpty()){
        arr[index-k] = heap.poll()
        index++
    }
}

基数排序(桶排序)

基本思想:基数排序是一种很特别的排序方法,它不基于比较和移动进行排序,而基于元素各位的大小进行排序。其思想是将待排序元素按照位数切割成个、十、百、千等位,然后从最低位开始依次排序,直到最高位排序完成。每一次排序就是一次『分配和收集』。分配的过程是:取出当前位数的值并将元素放入该值所对应的桶中。收集的过程是:取出每个桶中的元素并按照先后顺序放回原数组中。

举个例子:对数组[3, 1, 18, 11, 28, 45, 23, 50, 30]进行基数排序的过程如图:

基数排序过程图示
// 获取数组中最大元素的十进制位数
var maxBits = function(arr){
    let ans = 0
    let max = arr[0]
    for(let i = 1; i < arr.length; i++){
        if(arr[i] > max) max = arr[i]
    }
    while(max !== 0){
        ans++
        max = Math.floor(max / 10)
    }
    return ans
}
// 获取 x 第 d 位的数字(从右往左依次是第一位、第二位...)
var getDigit = function(x, d){
    return Math.floor(x / Math.pow(10, d - 1)) % 10
}
// 对 arr[l...r] 进行基数排序
var process = function(arr, l, r, digit){
    // 基数排序的过程需要进行 digit 次的『分配和收集』
    for(let d = 1; d <= digit; d++){
        // 准备一个长度为 10 的数组,其中 counts[i] 表示数组中某位小于等于 i 的元素有多少个(d=1 表示个位,d=2 表示十位,以此类推)
        const counts = new Array(10).fill(0)
        // 初始化 counts 数组
        for(let i = l; i <= r; i++){
            const j = getDigit(arr[i], d)
            counts[j]++
        }
        for(let i = 1; i < 10; i++){
            counts[i] += counts[i-1]
        }
        // 准备一个长度等于原数组的临时数组,用于放置一次『分配和收集』之后的结果
        const temp = new Array(r - l + 1)
        // 对原数组从后往前遍历,根据 counts 数组中的值,将元素放到合适的位置(此步骤结束后,就相当于对原数组进行了一次『分配和收集』)
        for(let i = r; i >= l; i--){
            const j = getDigit(arr[i], d)
            temp[counts[j] - 1] = arr[i]
            counts[j]--
        }
        // 将临时数组中的值拷贝到原数组中,一次『分配和收集』结束
        for(let i = l, j = 0; i <= r; i++, j++){
            arr[i] = temp[j]
        }
    }
}
var radixSort = function(arr){
    if(arr.length === 0) return
    process(arr, 0, arr.length - 1, maxBits(arr))
}