总结快速排序与各种衍生知识点

1,573 阅读3分钟

快速排序

快速排序是最重要的排序方法之一,在操作系统和各种类库中都被广泛使用。快排其实涉及到了很多知识点,仅仅了解快排是怎么做的还远远不够。这篇总结一下跟快排相关的各种衍生知识点。

跟归并排序一样,快速排序的基本思想也是递归,他们主要区别是:归并排序是先递归,后排序,而快速排序是先排序,后递归

快速排序的核心思路是:

  1. 打乱数组
  2. 从数组中选择一个元素a[j]
    • 不大于a[j]的数放到j的左边
    • 不小于a[j]的数放到j的右边。
  3. 递归处理左右两数组 递归排序图片

核心思路和归并排序的对比

/**
 * 归并排序
 *
 * @param a  待排序数组
 * @param lo 起始位置
 * @param hi 结束位置
 */
public void sort(T[] a, int lo, int hi) {
    if (lo >= hi) {
        return;
    }
    //辅助数组
    T[] aux = (T[]) new Comparable[a.length];
    int mid = lo + (hi - lo) / 2;
    //先递归
    sort(a, lo, mid);
    sort(a, mid + 1, hi);
    //后排序
    merge(a, aux, lo, mid, hi);
}
/**
 * 快速排序
 * @param a 待排序数组
 */
public void sort(T[] a) {
    //打乱
    StdRandom.shuffle(a);
    doSort(a, 0, a.length - 1);
}

private void doSort(T[] a, int lo, int hi) {
    if (lo >= hi) {
        return;
    }
    //先排序
    int mid = partition(a, lo, hi);
    //后递归
    doSort(a, lo, mid - 1);
    doSort(a, mid + 1, hi);
}

完整代码

public void sort(T[] a) {
    //打乱
    StdRandom.shuffle(a);
    doSort(a, 0, a.length - 1);
}

private void doSort(T[] a, int lo, int hi) {
    if (lo >= hi) {
        return;
    }

    //切分
    int mid = partition(a, lo, hi);

    //切分完毕,对mid左右数组继续切分
    doSort(a, lo, mid - 1);
    doSort(a, mid + 1, hi);
}

/**
 * 快速排序的切分
 *
 * @param a  数组
 * @param lo 开始idx
 * @param hi 结束idx
 * @return 被选中的数字v
 */
private int partition(T[] a, int lo, int hi) {
    int i = lo;
    int j = hi + 1;

    T v = a[lo];
    while (true) {
        //当心v是数组中最大或者最小的元素

        //找到第一个>=v的数(v不用判断)
        while (less(a[++i], v)) {
            if (i == hi) {
                break;
            }
        }
        //找到第一个<=v的数(如果没有就停留在start)
        //这里可以去掉条件j>lo 因为当j == lo时,a[lo]不可能比自己小
        while (less(v, a[--j])) {
        }

        //说明第一个比v小的数在第一个比v大的数的左边,数组已经有序
        if (i >= j) {
            break;
        }

        //否则交换
        swap(a, i, j);
    }

    //退出循环的条件只有i>=j,此时a[j]是比v小的数
    //把v放到合适的位置,v和a[j]交换
    swap(a, lo, j);
    return j;
}

private void swap(T[] a, int i, int j) {
    T tmp = a[i];
    a[i] = a[j];
    a[j] = tmp;
}

这里注意几个点:

  1. 在查找第一个<=v的数的循环中可以不用判断边界条件,因为数组中第一个数就是v,v不可能比自己小。
  2. 我们在扫描数组时,指针会停留在小于等于大于等于v的元素上,也就是说相同元素也会停留并交换(为什么要这样做后面会讲)。所以用上面这种算法实现的快速排序是不稳定的。
  3. 打乱数组是很重要的步骤。

时间复杂度

最好情况和最坏情况

快速排序的最好情况是每次都能将数组对半分。这种情况下,快速排序所用的比较次数正好满足分治递归的CN=2CN/2+NC_N=2C_{N/2}+N公式。2CN/22C_{N/2}表示将两个子数组排序的成本,N表示用切分元素和所有元素进行比较的成本。那跟归并排序一样,这个公式的解是CNC_N~NlogNNlogN,所以时间复杂度是O(NlogN)O(NlogN)

那最坏的情况是什么? 如果数组已经有序,那每次扫描结束时,i会停留在lo+1,j从结尾一直扫描到lo。下一次切分时,就以a[lo]为切分元素,左边子数组为空。每一次切分后左子数组都为空。那比较次数就是

N+(N-1)+(N-2)+...+2+1 = (N+1)N/2 ~ N2N^2

这个算法的时间复杂度是O(N2)O(N^2),明显是平方级别的。同样,如果输入数组是倒序的,那每次切分后右子数组为空,也是平方级别。所以说,在排序之前打乱数组很重要,能有效预防这种情况。

重复元素

为什么扫描时遇到相等元素要停止?

我们假设partition函数中遇到相同元素不停留,当输入中含有大量相同元素的时候,算法的表现会怎样呢?首先把算法改为:

//遇到相同元素不停止
while (lessAndEquals(a[++i], v)) {
  if (i == hi) {
      break;
  }
}
while (lessAndEquals(v, a[j])) {
//这里边界条件不能去掉,因为v本身已不能够作为哨兵
  if (j == lo) {
      break;
  }
}

这里注意下,之前v本身可以作为哨兵,j最多只会停留在lo,但现在v不能作为哨兵了,所以j可能会越界。

假设一种极端情况,数组为:

a = [1, 1, 1, 1, 1, 1, 1, 1]
v = a[0] = 1
初始:i = 1, j = 6
第一次切分扫描后:i = 7, j = -1

跳出while循环后,执行swap(a, lo, j),也就是把v和自己交换。于是a[lo]为切分元素,左子数组为空,右子数组为a[lo+1]~a[hi],每一次切分后都是这样,这不就上面说的最差情况一样了吗。时间复杂度可以达到平方级。

那遇到相同元素停止的情况下是怎么样的呢?

a = [1, 1, 1, 1, 1, 1, 1, 1]
v = a[0] = 1
初始:i = 1, j = 6
第一次切分扫描后:i = 4, j = 3

这样就能够从中间切分数组。虽然说可能会把相同元素进行不必要的交换,但是为了出现避免O(N2)O(N^2)的情况,还是值得的。

快速选择

给出一个长度为N的数组,找到数组中从小到大排序的第k(为了方便,k从0开始)个元素。

  1. 第一种方法,把数组排序,然后扫描一遍出结果。这种方法时间复杂度是O(NlogN)的。
  2. 第二种方法,采用快排的思想: 随机选择一个元素a[j],对数组切分,<=a[j]的数放到左边,>=a[j]的数放到右边。如果a[j]左边的元素个数>k,就对左子数组递归查询,否则就对右子数组递归查询,直到a[j]左边的元素个数=k,那么a[j]就是第k个元素。这就是快速选择时间复杂度是线性的,不过证明太麻烦了,有兴趣可以自己搜。 快速选择
/**
 * 快速选择
 * 这里假设输入合法,即第k个元素一定存在
 *
 * @param nums 目标数组
 * @param k    从小到大第k个元素
 * @return
 */
public int select(int[] nums, int k) {
    k = k - 1;
    shuffle(nums);
    int lo = 0, hi = nums.length - 1;
    while (hi > lo) {
        int j = partition(nums, lo, hi);
        if (j < k) {
            lo = j + 1;
        } else if (j > k) {
            hi = j - 1;
        } else {
            return nums[k];
        }
    }

    //当lo == hi时,假设输入一定合法,那lo即k
    return nums[k];
}

private int partition(int[] a, int lo, int hi) {
    int i = lo;
    int j = hi + 1;
    int v = a[lo];
    while (true) {
        //找到 >= v的
        while (a[++i] < v) {
			if(i == hi){
				break;
            }
        }
        //找到 <= v的
        while (a[--j] > v) {
        }

        if (i >= j) {
            break;
        }
        swap(a, i, j);
    }
    swap(a, lo, j);

    return j;
}

leetcode相关题目

洗牌算法

快速选择