对不起,经理!当年我写的不是真正的“快速排序"🤣

1,540 阅读4分钟

故事起源

故事,要从两年前讲起,当时我还在上一家公司,做的是数据大屏项目,存在着一个接口请求返回上万量级的数据,当时前端界面上可以选择不同的维度来对数据进行排序展示。我当时第一时间选择了快速排序,大致代码如下:

function quickSort(nums) {
    if (nums.length <= 1) return nums
    const pivotIndex = Math.floor(nums.length / 2);
    const pivot = nums.splice(pivotIndex, 1)[0];
    const left = [];
    const right = [];
    for (let i = 0; i < nums.length; i++) {
        if (nums[i] < pivot) {
            left.push(nums[i]);
        } else {
            right.push(nums[i]);
        }
    }
    return quickSort(left).concat([pivot], quickSort(right));
};

const nums = [11, 2, 31, 78, 8, 4, 3, 6, 82, 9, 12];
const res = quickSort(nums);
console.log(res)

当我汇报这个操作需要计算 N 秒时(忘记多少秒了 😅 😅 😅 ),经理问我,你用的是快速排序吗?我肯定的回答是的 😂 😂 😂 然而,时至今日,每当我回想起这份代码曾被我运用到项目里时,我仍为自己的愚蠢及无知所感到羞愧 🤣 🤣 🤣,因为这段代码的槽点实在是有些多,比如:

  • 返回的数据是重新clone一份的,原存储空间不能被重复利用
  • 每次(也许)调用quickSort时都会创建两个额外的存储区(clone的原因)
  • 递归调用次数太多~
  • 稳定性不佳
  • ......

这份代码是我从阮一峰的这篇讲快速排序的文章里复制过来的,我说这些,并不是批判说这份代码,这篇文章,抑或是针对阮一峰怎么样,我自己也是从这里学习的,这篇文章以及代码都是很好懂的,可以用来学习一下思想。 我们下文的主旨就是针对这份代码的槽点进行优化。

一、快速排序思想

在开始优化之前,我们先过来一下快速排序的思想。

  • 首先,我们需要选取一个基准值
  • 通过两个指针,一个在数组左边,一个在数组右边。根据指针的值与基准值进行比较,这一过程称为双端比较。
  • 将小于基准值的值放到基准值的左边,将大于等于基准值的值放到基准值的右边
  • 重复上述操作,最后形成一个有序的数组

这么说,好像有一点点的抽象,看了以后代码也不会写嘛~。没关系,下面我将以图文的形式带上实现思想来加深说明。

  1. 我们的第一步就是选取基准值
  2. 从右向左与基准值进行比较,逻辑如下👇

image.png

  1. 再从左向右进行比较,逻辑如下👇

image.png

  1. 一直到两个指针相遇为止,接着以基准点的左右两边,分别调用quickSort,如下图中左边就是 [0] 这一段了,而右边就是 [2-9],这一段了。

image.png

完整的PPT带有详细步骤及动画效果,如若需要,可联系我~

二、重写快速排序

理解了快速排序思想,那么首先我们针对前面两个槽点进行优化——即不修改排序前后的数据存储区。 为避免文章显得过于冗余,这一部分的动画效果我就不放上来了,我直接上代码好啦。

function quickSortV1(nums: number[], left: number, right: number) {
    if (left >= right) return // 终结条件
    let x = left, y = right, base = nums[left]
    while (x < y) {
        // y 指向的值 >= base 时,y 继续缩小范围向左移
        while (x < y && nums[y] >= base) y--;

        // 如果到这里来了,说明上面的 while 判断失败,也就意味着以下两种情况
        // 1. y 等于了 x,说明从右向左找,都没有比 base 小的
        // 2. x < y, 但在 y 这个位置找到了比 base 小的
        if (x < y) nums[x++] = nums[y];

        // x 指向的值 < base 时,x 缩小范围向右移
        while (x < y && nums[x] < base) x++;

        // 到这儿,说明上面的 while 判断失败,意味着以下两种情况
        // 1. x 等于了 y 指针,说明从左向右找,也没有比 base 大的
        // 2. x < y, 但在 x 这个位置找到了比 base 大或相等的
        if (x < y) nums[y--] = nums[x];
    }
    // 跳出循环时,将 base 替换到 相遇点
    nums[x] = base;
    // 继续分段排序
    quickSortV1(nums, left, x - 1) // 第二、三个参数分别是左边界 和 相遇点的前一个下标
    quickSortV1(nums, x + 1, right) // 同理,分别是相遇点的后一个下标 和 右边界
}

const list = [1, 3, 2, 8, 4, 0, 7, 9];

quickSortV1(list, 0, list.length - 1)

比起我们第一份代码中,每次调用时两个额外创建的存储区,随着递归的次数越多,这个开销越大。这份代码利用数组的下标特性,将这个开销给完美的省了下来。

尽管已经有了不小的优化,但根据我上面列的问题点,仍有很大的进步空间,接下来,我们将从C++的STL中的快速排序学习如何优化剩下的这些问题。主要有以下几个方向:

  • 单边递归法(左递归法)
  • 三点取中法
  • 特殊数据,停止快排
  • 使用插入排序收尾

三、单边递归法

单边递归法就是基准点的左半边使用循环做排序,而右半边则使用递归调用去排序。

这里多说一句,在做递归时,我们 k0 是正确的,那么就假设 ki 是正确的,由此推导出这个递归它的结果就是正确的,至于它到底是怎么展开执行的,不要去关心它,将其当成另外一个函数来调用即可,切记不要把递归展开来看,这很重要。

我们仍以一个例子来演示单边递归法 。

看不明白没关系,图与代码结合一起看,很容易懂的。

  1. 首先选取下标left的值5作为基准点base
  2. y(下标7)点开始与其进行向左比较
  3. 遇到nums[y] >= basey向左移,一直比较到y为下标5,其值0base
  4. 那么nums[x] = nums[y],并且x向右移动一位,此时nums为:[0, 3, 2, 8, 4, 0, 7, 9]
  5. 接着从x(下标1)点开始与其进行向右比较
  6. 基本思路与上相反。(就是上面的快速排序思想这一部分的内容)
  7. 最终到了x、y相遇,则将相遇点设置为基准值的值(如下图排序后的结果)

image.png

  1. 接下来将右边部分进行递归调用,重复上述步骤(这些步骤就不放图了)
  2. 递归调用完成后,回到原调用栈内以后,将right设置为相遇点的前一个下标,让循环继续,重复上面的步骤

代码如下👇

// 使用了单边递归法实现的快速排序, 同时保证了无监督
function quickSortV2(nums: number[], left: number, right: number) {
    while (left < right) { // 无监督
        let x = left, y = right, base = nums[left]
        while (x < y) {
            while (x < y && nums[y] >= base) y--;
            if (x < y) nums[x++] = nums[y];
            while (x < y && nums[x] < base) x++;
            if (x < y) nums[y--] = nums[x]
        }
        nums[x] = base
        quickSortV2(nums, x + 1, right) // 使右边递归
        right = x - 1 // 这里的 x 就是相遇点,重新赋值 right,让左边继续循环
    }
}

const arr = [5, 3, 2, 8, 4, 0, 7, 9];
quickSortV2(arr, 0, arr.length - 1)

这种方式使得我们减少了递归的调用,对于算法的性能也有一定的提升,并且还不用写函数的退出条件,也就是无监督写法。

四、三点取中法

上面的优化方式中,都是使用 下标0 的值作为基准值,当我们的数据整体是一个较为有序的数组时,如:[0, 1, 2, 3, 4, 5, 9, 8],那么我们就要 7 次基准值(从0选到9),很明显这种算法的稳定性太差,那么我们就需要重新寻找一下基准值了。 三点取中法的基准值的选取就是以左右两边值和中间的值之间选取中间大小的值作为基准值,这将使得我们的算法的稳定性更加,复杂度尽量更优。

无代码,需配合实现,后面一次性给出

五、结合插入排序来模拟STL的快排

再写废话就有点多了,直接上代码吧

function quickSortV3(nums: number[], left: number, right: number) {
    __quickSortV3(nums, left, right) // 先调用快速排序
    __finalInsertSort(nums, left, right) // 再调用插入排序
}

const threshold = 16 // STL 中就是 16
function __quickSortV3(nums: number[], left: number, right: number) {
    while (right - left > threshold) { // 数量达到了才使用快排
        let x = left, y = right
        let base = getMedian(nums[left], nums[(left + right) >> 1], nums[right])
        while (x <= y) {
            while (nums[x] < base) x++
            while (nums[y] > base) y--
            if (x <= y) {
                [nums[x], nums[y]] = [nums[y], nums[x]]
                x++, y--
            }
        } 
        __quickSortV3(nums, x, right)
        right = y
    }
}

// 插入排序
function __finalInsertSort(nums: number[], left: number, right: number) {
    let index = left
    for (let i = left + 1; i < right; i++) {
        if (nums[i] < nums[index]) index = i // 将 index 置为最小值的位置
    }

    while (index > left) { // 从最小位置往左交换,一直将最小值放到下标 0 上
        [nums[index], nums[index - 1]] = [nums[index - 1], nums[index]]
        index--
    }
    
    // 之所以 + 2,是因为下标 0 已经保证是最小的了,下标 0 1 没有可比性
    for (let i = left + 2; i <= right; i++) {
        let j = i
        while (nums[j] < nums[j - 1]) { // 如果当前位置的值比前一个下标位置的值要小,则交换到排序的位置为止
            [nums[j], nums[j - 1]] = [nums[j - 1], nums[j]]
            j--
        }
    }
}

// 获取三个值中的中间大小的值
function getMedian(a: number, b: number, c: number) {
    if (a > b) swap(a, b)
    if (a > c) swap(a, c)
    if (b > c) swap(b, c)
    return b
}

function swap(a: number, b: number) {
    [a, b] = [b, a]
}

六、优化总结

  1. 利用数组的指针避免创建额外的存储
  2. 使用单边递归法减少复杂度,形成无监督写法
  3. 配合三点取中法使得算法的稳定性更佳,复杂度尽量的低
  4. 在数据量较少时停止快速排序,最后与插入排序组合形成 C++ 中的 STL 的 sort 的快速排序模拟版

尽管快速排序很快,然而实际上,这种大数据量的排序场景,快速排序也并不是最佳解法,希望点个赞👍+关注🌟,我将加更大数据量排序利器——归并排序!