排序算法之快排

60 阅读4分钟

排序算法算是面试中比较高频的问题了

排序算法有很多种,比如:

  • 冒泡排序
  • 选择排序
  • 插入排序
  • 快速排序
  • ...

每种排序都有各自的优势

今天我们来讲快速排序

它的优势就是它的名字,速度快,时间复杂度大概是O(nlog2n)O(n log₂n)

实现

快速排序的实现:

  1. 将元素分割成独立的两部分,左边的元素均比右边的小
  2. 将这两部分分别再次执行第1步,然后一直分割下去,直到不可分割为止

分割

我们首先来实现第一步,怎么分割才能实现左小右大呢?

我们从中取一个元素,比这个元素小放左边,比这个元素大就放右边不就行了么:

function partition(arr) {
    var pivot = 0,
        index = pivot + 1;
    for (var i = index; i <= arr.length - 1; i++) {
        if (arr[i] < arr[pivot]) {
            swap(arr, i, index);
            index++;
        }
    }
    swap(arr, pivot, index - 1);
}
function swap(arr, i ,j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

上面简单实现了一个分割函数partition,代码比较晦涩难懂,我们举个例子:

将数组[8, 14, 3, 5, 17]传入partition

首先进行初始化:

  1. pivot:译为基准,也就是我们需要比较的元素,比它小的元素放左边,比它大的元素放右边,这里指向的是数组的第一个元素8
  2. index:慢指针,指向pivot的下一个元素,如果i遍历到的元素比pivot小,indexi的元素互换,然后index++指针右移一位指向下一个元素
  3. i:快指针,指向index,从index开始遍历到数组末尾,遇到比pivot小的,iindex的元素互换

初始化后是这样的: image.png

i遍历到3时,3比8小,互换indexi的元素,然后index++ image.png

i遍历到5时,5比8小,互换indexi的元素,然后index++ image.png

此时i已遍历完毕,执行swap(arr, pivot, index - 1),互换pivotindex-1的元素 image.png

最终变成了这样: image.png

此时比8小的元素放在左边,比8大的元素放在右边,完美实现了分割

这里用到了双指针算法里非常经典的快慢指针

递归分割

此时我们实现了分割函数,现在我们需要将分割的两部分再次分割,然后一直分割下去,直到不可分割为止

这里我们需要用递归来执行,下面是一个完整的快速排序函数quickSort

function quickSort(arr, left, right) {
    var len = arr.length,
        partitionIndex,
        left =typeof left !='number' ? 0 : left,
        right =typeof right !='number' ? len - 1 : right;
    
    if (left < right) {
        partitionIndex = partition(arr, left, right);
        quickSort(arr, left, partitionIndex-1);
        quickSort(arr, partitionIndex+1, right);
    }
    return arr;
}

function partition(arr, left, right) {
    var pivot = left,
        index = pivot + 1;
    for (var i = index; i <= right; i++) {
        if (arr[i] < arr[pivot]) {
            swap(arr, i, index);
            index++;
        }
    }
    swap(arr, pivot, index - 1);
    return index-1;
}

function swap(arr, i ,j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}
  1. 设置leftright为要分割的区间,初次为整个数组
  2. 执行partition分割整个数组
  3. 执行完后返回partitionIndex,它指向基准值(比它小的排左边,比它大的排右边)
  4. 根据partitionIndex分割为左右两个区间,然后两个区间再次递归执行quickSort,然后再次执行里面的partition再次分割(根据leftright定义区间)
  5. 这样一直递归执行quickSort,直到不可分割为止(left < right

总结

至此整个数组就排完序了

下面的动图帮助大家更好的理解: 849589-20171015230936371-1413523412.gif

时间复杂度

快速排序的时间复杂度大概是O(nlog2n)O(n log₂n),这个怎么来的呢?

首先递归执行quickSort类似于一个二叉树的遍历,时间复杂度是O(log2n)O(log₂n)

然后每次执行执行partition时,内部都会有一个for循环遍历,for循环遍历的时间复杂度是O(n)O(n)

两者内外嵌套在一起,时间复杂度就是O(nlog2n)O(n log₂n)

不稳定

快速排序的时间复杂度是O(nlog2n)O(n log₂n),但有时候会暴涨到O(n2)O(n^2)

为什么呢?

如果数组是有序的,比如[1, 2, 3, 4, 5, 6, 7, 8, 9],那么:

  1. 取到1进行分割,比1小的分到左边,比1大的分到右边,发现左边没有元素,右边是剩余的全部元素
  2. 左边没有元素就不用分割了,接着分割右边的元素
  3. 递归分割下去

之前的情况是左右都有元素,同时分割数组一半的长度,时间复杂度是O(log2n)O(log₂n)

这里是只有右边有元素,而且是整个数组长度,那这样的话遍历整个数组的时间复杂度就是O(n)O(n),加上内部的for循环时间复杂度就是O(n2)O(n^2)

所以如果数组有序的话快速排序的时间复杂度反而慢了