『算法』图解数组排序全家桶

2,397 阅读25分钟

「本文已参与低调务实优秀中国好青年前端社群的写作活动」。

碎碎念🤥

大家好,我是潘小安,一个永远在减肥路上的前端er🐷 !

数组的排序算法是一个程序员必学的知识点,因为数组的排序算法多种多样,且有很多细节。如果自己没有手动敲一遍,很容易两种情况:

  • 一看就会,一写就懵;
  • 看完懂了,下次看到类似文章还要点进去确认自己是否真的懂了;

为了不出现这两种尴尬的情况,借着此次学习算法的契机,决定把数组排序这块知识点连锅都给它端了(没有全端,剩了一点)。

本文对于一些常见的排序算法进行汇总,没有希尔排序(因为截止发稿前我还没学会掌握这个),也没有堆排序,因为我想把它放到二叉树相关文章和二叉树一起,文中出现的排序算法,有些浅尝辄止,有些会附带清晰逻辑流程图(一张一张画的),还有快速排序这种硬菜,会从入门到放弃地和大家一起探讨优化过程,希望大家能一起交流,一起学习,一起进步。

上菜 🥬

给你一个整数数组 nums,请你将该数组升序排列。

eg:[3,5,1,6,4,7,2]经过排序后顺序变成 [1,2,3,4,5,6,7]

排序算法们 🐽

本篇文章汇总了一下几种数组排序方法

冒泡排序

开始冒泡排序之前,问一个问题:

海为什么是蓝的?

因为鱼会吐泡泡。卟噜卟噜 blueblue~

冒泡排序,顾名思义就是让数组里的每个子项像大海中的泡泡一样,浮向它应该去的位置。具体的操作步骤我们可以分为:

1.比较两个数,把较大的数放在后面

2.每次比完一轮后,再从头开始比,最后得到排好序的数组

我们可以画图模拟这个过程:

image-20220515125006824

我们使用第一层 for 循环来表示需要比较的次数,第二层 for 循环来进行具体的比较,我们使用 ES6 中的解构赋值对数组中的两个项进行交换,于是可以得到如下的代码:

function bubbleSort(arr) {
    const length = arr.length
    for (let i = 0; i < length; i++) {
        for (let j = 0; j < length; j++) {
            if (arr[j] > arr[j + 1]) {
                [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
            }
        }
    }
    return arr
}

不难看出,这种写法其实是有优化空间的,因为从步骤中我们可以看出,在已经遍历了 k 次之后,数组后面的 k 个数字已经是有序的了,所以我们在后续的遍历轮次中可以不去判断已经排好序的部分。改进后的代码如下:

function bubbleSort(arr) {
    const length = arr.length
    for (let i = 0; i < length; i++) {
        // for (let j = 0; j < length; j++) {
        for (let j = 0; j < length - 1 - i; j++) {
            if (arr[j] > arr[j + 1]) {
                [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
            }
        }
    }
    return arr
}

还有什么可以优化的地方吗?我们可以设想一种情况,如果数组一开始就是已经按照从小到大排好序传进来,我们有没有一种方法可以直接让这个数组返回呢?

答案是有! 我们可以设置一个标志位,当某次循环开始,函数没有进入比较函数的逻辑的时候,我们就可以认为数组已经排好序了,这个时候我们直接返回数组就好:

function betterbubbleSort(arr) {
    const length = arr.length
    //设置一个标志位,如果标志位没有被改变,说明数组已经拍好序了
    let flag = true
    for (let i = 0; i < length; i++) {
        for (let j = 0; j < length - 1 - i; j++) {
            if (arr[j] > arr[j + 1]) {
                [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
                flag = false
            }
        }
        if (flag) {
            return arr
        }
    }
    return arr
}

冒泡算法它的复杂度较高,但是我们仍然可以在第一版的基础上对它进行优化。它也是所有排序算法中比较简单的一种,但是即便如此,也需要我们自己手动写一遍,这样才能在需要用到的时候能够 “心中有码”

选择排序

接下来轮到选择排序,选择排序复杂度也是 O(n2) 的复杂度。和冒泡排序不同的地方在于:

冒泡排序选择排序
一轮遍历中可能要交换多次使用变量保留最小值,只会在每一轮的最后一次,交换一次或者不交换(最小值已经在属于它的位置上了)

下面我们使用图解来看看选择排序的过程: 选择排序.svg 根据流程图,我们可以很容易的写出以下代码:

var sortArray=function(nums){
    const len = nums.length
    let minIndex
    for (let i = 0; i < len - 1; i++) {
        // 初始化 minIndex 为当前区间第一个元素
        minIndex = i
        for (let j = i; j < len; j++) {
            // 若 j 处的数据项比当前最小值还要小,则更新最小值索引为 j
            if (nums[j] < nums[minIndex]) {
                minIndex = j
            }
        }
        // 如果 minIndex 对应元素不是目前的待交换元素,则交换两者
        if (minIndex !== i) {
            [nums[i], nums[minIndex]] = [nums[minIndex], nums[i]]
        }
    }
    return nums
}

插入排序

第三种我们来看看插入排序,和前两种排序算法一样,插入排序的复杂度也是 O(n2)。插入排序可以把数组分为已排序区待排序两个区域,初始化的时候,数组的第一个元素进入已排序区域。后续每次遍历先保存待排序的数,后使用待排序数和已排序区的元素做比对,如果已排序项大于待排序元素,则将已排序项后移,直到找到一个比待排序想要小的数或者下标已经遍历到 0,再把该下标赋值为保存好的待排序数。我们使用图来看看插入排序的过程。

插入排序.svg 根据图中逻辑我们可以写出下面代码:

var sortArray = function(nums) {
    const len = nums.length
    // temp 用来保存当前需要插入的元素
    let temp
    for (let i = 1; i < len; i++) {
        let j = i
        temp = nums[i]
        // 判断 j 前面一个元素是否比 temp 大
        while (j > 0 && nums[j - 1] > temp) {
            // 如果是,则将 j 前面的一个元素后移一位,为 temp 让出位置
            nums[j] = nums[j - 1]
            j--
        }
        // 循环让位,最后得到的 j 就是 temp 的正确索引
        nums[j] = temp
    }
    return nums
};

至此数组排序中的三个高复杂度的算法都已经搞定了,这三种排序算法虽说复杂度较高会比较少的应用到日常开发中,但是其中的逻辑方法依旧值得我们学习。我们使用一个图表来概括一下三种复杂度为 O(n2) 的算法:

算法复杂度特点
冒泡排序O(n2)两两对比,频繁交换,一轮出一个大数放在数组末尾
选择排序O(n2)两两对比,保留最小,一轮交换一次,出一个小数放在数组开头
插入排序O(n2)分已排序待排序区,保留待排序元素,对比待排序元素,在合适的位置复制插入

接下来我们就要开始进入数组排序的快车道,请检查你的安全带,发车!


归并排序

归并排序听名字就知道,我们整个排序算法需要拆分成两个主要的步骤:归类+合并,也就是我们常说的 ”分而治之“ 的思想。简单来说,分为以下几个步骤:

  • 拆分数组,直到每个子数组只有一个元素。
  • 将子数组合并成较大数组
  • 最后合成一个排好序的大数组 那么要如何拆?如何合并?

拆分: 首先拆分我们使用数组的 slice 方法对数组进行切割,我们找到中间点,把数组拆成左右两个子数组,在切割的时候要注意左闭右开或者左闭右闭的写法要全程保持一致;其次这种连续拆分,且下一次拆分需要依赖到上一次拆分结果的情况,我们首先想到的是递归。有递归就一定有递归的 return 条件,在这里这个条件是入参的数组长度等于 1,说明已经拆成了最小长度子数组了。

合并: 合并的问题其实就是一个如何合并两个有序数组的问题。我们单独使用一个辅助函数 mergeArray 来进行合并操作。合并操作分为以下几个步骤:

  • 定义一个结果数组 res,定义两个待合并数组的下标 ij
  • 比较当前项的大小,将更小项放入 res 中,对应下表加一;
  • 某个数组遍历完毕后,将另一个的数组拼接到结果数组中;

同样的,我们使用流程图来看看归并排序的执行过程,

归并排序.svg 归类(拆分)部分:

function sortArray(arr) {
    const len = arr.length
    if (len == 1) {
        return arr
    }
    const mid = Math.floor(len / 2)
    //拆分左边数组
    let leftarr = sortArray(arr.slice(0, mid))
    //拆分右边数组
    let rightarr = sortArray(arr.slice(mid, len))
    //合并两个有序数组
    arr = mergeArr(leftarr, rightarr)
    return arr
}

合并部分:

function mergeArr(arr1, arr2) {
    let i = 0, j = 0
    //结果数组
    const res = []
    const len1 = arr1.length
    const len2 = arr2.length
    //按照大小推入结果数组
    while (i < len1 && j < len2) {
        if (arr1[i] < arr2[j]) {
            res.push(arr1[i])
            i++
        } else {
            res.push(arr2[j])
            j++
        }
    }
    //如果其中一个数组合并完了,另一个数组直接合进去就好
    if (i < len1) {
        //arr1还有剩下的
        return res.concat(arr1.slice(i))
    } else {
        //arr2还有剩下的
        return res.concat(arr2.slice(j))
    }
}

没有接触过递归的小伙伴可能会觉得代码部分有点难理解,因为会绕不清楚当前的 leftarrrightarr 到底是什么。这边给出一个小技巧,就是调试

image.png

可以在浏览器的调试工具或者其他编辑器的调试工具中打断点,查看作用域,查看调用堆栈。这样可以更好的理解整个递归拆分数组和合并数组的过程,希望对新手玩家有所帮助。

归并排序的复杂度是 O(nlog(n)),相比于前三种排序算法,复杂度大大降低。但是归并排序使用递归,创建了新数组还有下标等多个变量,需要使用的内存空间也更多,空间复杂度也就更高,是一种非常典型的用空间换时间的算法优化手段。

快速排序

快速排序是 1964 年由 东尼·霍尔 开发的一种排序算法,快速排序和归并排序一样,也是使用 “分而治之” 的思想,不同点在于快速排序是 “原地划分” ,使用 双指针 对数组遍历并交换元素,不开辟额外的存储空间。 快速排序一般分为以下几步:

  • 选择一个基准值。
  • 使用左指针右指针遍历数组,这一步我们需要遍历来划分出两个子数组,一个子数组全是小于等于基准值的数,另一个子数组全是大于等于基准值的数。
  • 对两个子数组递归执行前两个步骤,直到子数组长度为1

基础版本

我们可以选择最左边的数作为基准值,同时初始化两个指针 i 和 j,我们可以通过图示直观的理解流程:

快速排序 0.svg

我们再使用一个更直观的图来理解这个过程。

快速排序 0——0.svg

  • 整个数组其实被划分成三个区域,分别是基准值区域小于等于基准值区域大于基准值区域
  • j 指针逐渐增大的过程中,遇到比基准值大的就扩大蓝色部分的区域,遇到小于等于基准值的,就把这个值放到绿色区域,当 j 遍历到数组末尾的时候,数组就会被分成了绿蓝两部分
  • 最后再把基准值放到 i 的位置,这时候基准值的左边都是小于等于基准值,右边都是大于基准值的,换句话说,i 这个位置就是基准值在最终排序中的位置,下次切割的时候基准值就可以不参与下次遍历,用代码表示就是下次只需要递归处理 [0,i-1] [i-1,j]两个子数组。

根据这个逻辑我们可以写出下面的代码,包含一些必要的注释:

function partition(array, L, R) {
    let i = L
    let pivot = array[L]
    for (let j = L + 1; j <= R; j++) {
         //增大绿色部分长度
        if (array[j] <= pivot) {
            [array[i + 1], array[j]] = [array[j], array[i + 1]]
            i++;
        }
        //否则每次 j++都在增大蓝色部分长度
    }
    //把基准值放在中间,不参与下次遍历
    [array[L], array[i]] = [array[i], array[L]]
    swap(array, L, i)
    return i
}
function sortArray(array, L = 0, R = array.length - 1) {
    //特殊情况
    if (R - L < 1) return array
    //寻找切割点
    let pivotIndex = partition(array, L, R)
    sortArray(array, L, pivotIndex - 1)
    sortArray(array, pivotIndex + 1, R)
​
    return array
}

但是当我们这样写的时候,在实际应用中,却发现运行时间可能有时候会不尽如人意。

在这里我们需要说明一下所谓的复杂度的退化问题,快速排序之所以可以快是因为使用递归可以同时处理两边的子数组,才有了这个 O(nlog(n)) 的复杂度,但是当每次拆分的时候左右两个子数组的数量严重不相等,甚至说每次切分的时候都有一个子数组的长度为 0,那么我们其实需要遍历的次数就和选择排序每一轮需要寻找的次数一致了,也就是说会接近 O(n2)。所以快速排序的 的关键在于切分出来的子数组长度是否足够接近。依照这个理论,我们上面这个算法有什么问题呢?思考下面两种场景:

  • 参数数组中有大量重复值
  • 参数数组已经是一个有序的数组

重复值: 在上面的代码中,我们遍历遇到和基准点相同的项的时候,我们选择放进绿色区域,当大量重复值都被放进绿色区域的时候,就会出现绿色区域每次分割的时候都比蓝色区域长度大得多,于是就会造成我们说的复杂度提升问题

数组已经有序: 数组已经有序的情况,每次选第一个为基准值,就会造成一个极端情况,就是拆分过程中某边的子数组长度会一直为 0,这样复杂度一下子就直接拉满了。

那么我们要怎么解决这两个问题呢?

双路快速排序

简单的说就是让 ij 两个指针分别从右往左,从左往右来遍历数组,遇到和基准值相等的数的时候,直接忽略不管,这样就不会因为重复值影响拆分数组的大小了。

具体可分为以下几个步骤:

1.选取最左边为基准点

2.先遍历 R 指针,直到 R 指向小于基准点的数或者 L,R相遇停止

3.再遍历 L 指针,直到 L 指向大于基准点的数据或者 L,R相遇停止

4.交换 L 和 R 的值,后循环 2-3 步骤

5.L,R相遇,交换 L 指针和基准点的值,返回 L 指针作为新的切割点

下面是该过程图解:

二路排序算法 根据这个过程我们可以写出如下代码:

function sortArray(array, L = 0, R = array.length - 1) {
    if (R - L < 1) return array
    //找到切分点
    let pivotIndex = partition(array, L, R)
    //分割点的前一个节点才是左子数组的边界
    if (L < pivotIndex - 1) {
        //继续切分小于基准值的子数组
        sortArray(array, L, pivotIndex - 1)
    }
    //分割点是右子数组的边界
    if (pivotIndex + 1 < R) {
        //继续切分大于基准值的子数组
        sortArray(array, pivotIndex + 1, R)
    }
    return array
}
function partition(array, L, R) {
    //找一个基准值
    let povotindex = L
    let pivot = array[L]
    while (L < R) {
        //右指针比较移动逻辑
        while (L < R && array[R] >= pivot) {
            R--
        }
        //左指针比较移动逻辑
        while (L < R && array[L] <= pivot) {
            L++
        }
        if (L < R) {
            [array[L], array[R]] = [array[R], array[L]]
        }
    }
    //L==R的时候要把基准值换到中间
    [array[povotindex], array[L]] = [array[L], array[povotindex]]
    return L
}

花式选择基准值

那如何解决有序数组的高复杂度问题呢?操作有序数组会有较高的复杂度,是因为我们基准值选的太死板。每次第一个,没有花样。我们可以换个思路取基准值,减小出现极端情况的概率,这里我们提供两种方案:

  • 取区间的随机数,然后和 L 交换
  • L R mid 三个数中第二大的数,然后和 L 交换

三路快速排序

细心的同学可能注意到,双路快速排序虽然解决了重复元素的分边的问题,但是没有解决重复元素的重复参与排序的问题。 三路快速排序就是来解决这个问题的,如果和之前一样我们把数组看做一块区域的话,三路快速排序相当于单独划出了一块区域来存放等于基准值的数组项,如下所示:

初始化:

image-20220515121125010

遍历结束:

image-20220515121154218

我们除了使用 LR 两个指针之外,我们添加 ltgt 两个指针确定小于基准值大于基准值的边界,从而可以去确定所有等于基准值的范围。思路是这样,编码方式可以多种多样,这里提供一种参考方式:

  • 全程闭区间,所以 lr 初始化分别为 0len-1

  • lt 初始化为 l-1gt 初始化为 r+1,未遍历之前,我们假定所有待遍历部分都等于基准值,在遍历过程中逐渐缩小范围。

  • 我们让 l 和数组中的其他一个随机值互换位置,然后选择 l 作为基准值,为了更好理解我们下面的图假设随机到了 3 这个值

  • 设置一个变量 iil+1 开始遍历(因为 l 是基准值,不用判断了)

    • arr[i] < pivot的时候,交换 lt+1i 位置的值,lt 向右移动,i 向右移动;
    • arr[i] > pivot 的时候,交换 gt-1i 位置的值,gt 向左移动,i 不移动,继续循环判断;
    • arr[i] == pivot 的时候,i 向右移动
  • 当 i 和 gt 指针重合时,所有的数组项都已经遍历过了。

  • 切割递归子数组,左子数组为 [l,lt],右子数组为 [gt,r]

我们通过流程图来辅助理解一下:

image-20220515123205598

于是我们可以得到以下代码(必要的注释已经写在代码上):

var sortArray = function (nums) {
    quickSort3Ways(nums, 0, nums.length - 1)
    return nums
};
var swap = function (arr, i, j) {
    [arr[i], arr[j]] = [arr[j], arr[i]]
}
var quickSort3Ways = function (arr, l, r) {
    //取随机基准值
    swap(arr, l, Math.floor(Math.random() * (r - l + 1) + l))
    let pivot = arr[l]
    //定义等于基准值元素的左边界
    let lt = l - 1;
    //定义等于基准值元素的右边界
    let gt = r + 1;
    //定义循环变量
    let i = l + 1
    //注意这里的循环结束条件
    while (i < gt) {
        //arr[i]比基准值小,放在 l 和 lt 之间
        if (arr[i] < pivot) {
            swap(arr, i, lt + 1);
            lt++;
            i++;
        } else if (arr[i] > pivot) {
            swap(arr, gt - 1, i);
            gt--;
        } else {
            i++;
        }
    }
    //剩余数组有效才进入继续排序
    if (l < lt) {
        quickSort3Ways(arr, l, lt)
    }
    if (r > gt) {
        quickSort3Ways(arr, gt, r)
    }
}

总的来说,快速排序的关键点有两个:

  • 找一个好的基准点
  • 处理好重复值

至于如何遍历,那就是八仙过海各显神通了。

计数排序

从计数排序开始我们就进入了非比较排序部分。顾名思义,就是接下来的这几种排序方法不是通过元素之间进行比较而排序的。

第一个要介绍的就是计数排序,计数计数-计算数量。计数排序的过程大致可以分为以下几个步骤:

  • 遍历待排序数组 A,以子元素的值作为下标进行统计,放在统计数组 B
  • 遍历 B 数组,从 B 数组中还原出结果数组 C

从排序过程中我们很容易发现,计数排序的局限性:

  • 只能排序整数
  • 无法控制数组中的最大元素的值,可能导致统计数组的长度过大。

下面我们就以上面的 demo 中的例子,来看看计数排序的过程:

function countSort(A) {
    const B = []
    for (let i = 0; i < A.length; i++) {
        const j = A[i]
        B[j] >= 1 ? B[j]++ : (B[j] = 1)
    }
    const C = []
    for (let j = 0; j < B.length; j++) {
            while (B[j] > 0) {
                C.push(j)
                B[j]--
            }
    }
    return C
}
countSort([3, 5, 3, 1, 6, 4, 7, 2, 3])

由于 javascript 中对数组声明时的长度没有严格的限制,所以我们初始化数组后可以直接开始赋值,当 A 数组遍历结束后,我们得到的 B 数组为:[, 1, 1, 3, 1, 1, 1, 1 ],因为待排序数组中没有 0,所以统计数组中的下标为 0 处的值为空。

之后我们将 B 数组遍历,有值的下标进行递减还原数组的操作,最后得到结果数组 [1, 2, 3, 3, 3, 4, 5, 6, 7]

但是当遇到最大值很大且所有待排序项都接近最大值的情况,比如 [1000,1001,1010,1002,1003,1004,] 这种情况,我们的统计数组中就会出现一千多个空项,有很大的空间浪费。

所以我们可以在基础版的计数排序上做一些优化,优化后的步骤如下:

  • 找到待排序数组的最大值和最小值,创建最大值-最小值+1 长度的统计数组
  • 遍历待排序数组 A,以子元素的值作为下标进行统计,放在统计数组 B
  • 遍历 B 数组,从 B 数组中还原出结果数组 C,还原过程中每个数都要加回最小值

有兴趣的同学可以自行探索编码一下下。

桶排序

第二个非比较排序是桶排序,桶排序的过程如下:

  • 确定一个桶个数
  • 得到待排序数组的最大值和最小值,并通过最大值,最小值,桶个数来确定每个桶的大小
  • 根据当前数的大小来判断当前数应该放到第几个桶内
  • 桶内使用其他方法进行排序
  • 把每个桶内的树重新组合输出结果数组

根据过程我们可以写出下面代码:

function bucketSort(arr, num) {
    function swap(arr, i, j) {
        [arr[i], arr[j]] = [arr[j], arr[i]]
    }
    const max = Math.max(...arr)
    const min = Math.min(...arr)
    const buckets = []
    //根据数组区间确定桶大小
    const bucketsSize = Math.floor((max - min) / num) + 1
    for (let i = 0; i < arr.length; i++) {
        // ~~ 位运算取整,确定当前数分配到哪个桶中
        const index = ~~(arr[i] / bucketsSize)
        if (!buckets[index]) {
            buckets[index] = []
        }
        buckets[index].push(arr[i])
        //对该桶进行排序,这里使用的是插入排序
        let l = buckets[index].length
        while (l > 0) {
            buckets[index][l] < buckets[index][l - 1] && swap(buckets[index], l, l - 1)
            l--
        }
    }
    let wrapBuckets = []
    //从桶中取出所有元素得到结果数组
    for (let i = 0; i < buckets.length; i++) {
        buckets[i] && (wrapBuckets = wrapBuckets.concat(buckets[i]))
    }
    return wrapBuckets
}
const arr = [3, 5, 3, 1, 6, 4, 7, 2, 3]
console.log(bucketSort(arr, 2))

我们在 demo 中使用两个桶,第一次遍历把所有数分配之后,buckets 数组如下:

[ [ 1, 2, 3, 3, 3 ], [ 4, 5, 6, 7 ] ]

在分配过程中,我们使用插入排序对每次分配进来桶里的元素进行排序,元素分配完成后直接使用 cancat 合并所有的桶即可。

wrapBuckets.concat(buckets[i])

可以想到当我们的桶数量足够大,大到每个桶大小为 1的时候,我们其实就隐约看到了归并排序的影子,用足够的空间去换较低的时间复杂度。而当所有的数都放在一个桶里的时候,整体的算法复杂度又取决于你的桶内的时间复杂度,或者说当都在一个桶里的时候,桶排序就排了个寂寞,因为只是把所有数换了个地方存放而已。

基数排序

基数排序也是一种分布式排序算法,它根据数组的有效位或基数将整数分布到不同的中。我们以十进制举例,基数排序是分别按照个位,十位,百位...依次对数组进行排序从而确定最终的排序数组。以数组[456, 789, 123, 1, 32, 4, 243, 321, 42, 90, 10, 999] 来举例,我们使用图来描述一下过程。

image-20220523234725582

我们需要思考 的问题有两个:

1.如何确定一堆数要排序几次?也就是说要排到百位还是千位还是万位?

我们可以直接取最大数的最高位为界限。

2.我们如何在编码的时候做到可以高位均一致的情况下按照低位的排序结果维持数字在数组中的顺序?比如 3233两个数在个位数排序的时候 3233 的前面,在十位数排序的时候两个是相等的,那么要如何维持 32 还是在 33 前面呢?

这里就需要插入一些必要的关于算法稳定性的讨论:

算法的稳定性讨论

什么叫算法的稳定性?

如果排序前后相同元素的相对位置不变,则说该算法具有稳定性,如果排序后相同元素相对位置发生了变化,则说明该算法不具有稳定性。

算法稳定性重要吗?

如果只是单纯的数字排序,稳定性没啥重要,但是当数组元素是对象,且已经按照某个键值排好序后要求按照另一个键值排序时候,往往会要求二次排序的时候需要保持一次排序的相对顺序,也就对稳定性有了要求。

全家桶中的算法稳定性分析

算法名是否稳定
冒泡排序稳定
选择排序不稳定
插入排序稳定
归并排序稳定
快速排序不稳定
计数排序稳定
桶排序稳定
基数排序稳定

了解完算法稳定性之后,我们就有第二个小问题的答案了 - 选择一种稳定的排序算法,为单次位排序提供稳定性,这里我们选择计数排序。

在确定了循环次数和单次循环方法后,我们可以写出如下代码:

function radixSort(arr, radixBase = 10) {
    if (arr.length < 2) {
        return arr
    }
    const max = Math.max(...arr)
    let significantDigit = 1
    //确认循环次数
    while (max / significantDigit >= 1) {
        arr = countingSortForRadix(arr, radixBase, significantDigit)
        //每次循环结束根据基数改变筛选条件,从个位到十位到百位
        significantDigit *= radixBase
    }
    return arr
}
function countingSortForRadix(arr, radixBase, significantDigit) {
    let bucketsIndex;
    //计数排序 统计数组
    const buckets = new Array(radixBase).fill(0);
    //结果数组
    const res = [];
    //根据位上的值添加到统计数组中
    for (let i = 0; i < arr.length; i++) {
        bucketsIndex = Math.floor((arr[i] / significantDigit) % radixBase)
        buckets[bucketsIndex]++;
    }
    //累加统计数组,确认每个数的最终位置
    for (let i = 1; i < radixBase; i++) {
        buckets[i] += buckets[i - 1]
    }
    //还原结果数组
    for (let i = arr.length - 1; i >= 0; i--) {
        bucketsIndex = Math.floor((arr[i] / significantDigit) % radixBase)
        res[--buckets[bucketsIndex]] = arr[i]
    }
    console.log(res)
    return res
}

在《学习 JavaScript 数据结构与算法》这本书中的基数排序算法中,使用到了数组中是我最小值参与排序和确定循环轮次,但是截止发稿之前,作者还没有领悟到书中的用意,所以还是用自己理解了的这套代码作为实例代码。等作者悟到了确实加入最小值进行运算能提高性能,会进行二次编辑。

小声BB🤫

现在是北京时间 2022 年 5 月 24 日 12:50 分,在间歇性发愤图强,间歇性浑浑噩噩的状态下终于完成了这篇排序算法的相关文章。虽然有些遗憾,但是不完美也是另外一种完美(放屁!后期会补上漏了的两种算法的!)。

从上一篇发文到现在,经历了比较多的事情:

参加主持了训练营,培养了阅读早起的好习惯。

读完了《被讨厌的勇气》,了解了关于阿德勒心理学相关的概念。人要立足当下,未来由现在决定,不受过去影响。学会接纳自己信赖他人,融入共同体,为他人贡献

读完了《明朝的那些事》,了解大明王朝的起起伏伏起张居正 yyds伏伏伏伏。看到了以前课本中可能被忽略的一些老朋友(宋濂,于谦),看到了锦衣卫,东厂,内阁之间的爱恨情仇,也看到王守仁如何在程朱理学中开辟出心学的曲折历程,于是顺手就把《知行合一》加入进了阅读清单。

卸载 LOL 手游(排位五连跪)了,心态爆炸。

重新开始背单词了。

还有别的一些事情,等待中,希望是个好结果。

对了,凯老师终于要下线了,感谢凯老师的谆谆教导,希望尼卡同学早日圆梦。


🎉 🎉  觉得文章有帮助的小伙伴,点个赞,这对我很重要  🎉 🎉

🎉 🎉  对文章中的措辞表达、知识点、文章格式等方面有任何疑问或者建议,请评论告知,这个也很重要 🎉 🎉