本篇博客主要介绍一下快速排序的相关算法。
快速排序
我们都知道快速排序在所有的排序算法中效率是很高的。时间复杂度为 O ( l o g 2 N ) O(log_2N) O(log2N),空间复杂度为 O ( l o g 2 N ) O(log_2N) O(log2N)。
快速排序的基本思路如下:
- 从待排序区间选择一个数,作为基准值(pivot);
- Partition:遍历整个待排序区间,将比基准值小的(可以包含相等的)放到基准值的左边,将比基准值大的(可以包含相等的)放到基准值的右边;
- 采用分治思想,对左右两个小区间按照同样的方式处理,直到小区间中元素的数量小于2个。
我们来看一下整体代码框架:
public class QuickSort {
public static void quickSort(int[] arr) {
qSort(arr, 0, arr.length - 1);
}
private static void qSort(int[] arr, int left, int right) {
// 区间中元素小于2个,已经有序
if (left >= right) {
return;
}
// 对区间进行整理,将区间中的元素以一个基准值分为左右两个部分
// 左边的部分比基准值小,右边的部分比基准值大;并返回基准值的下标
int pivotIndex = partition(arr, left, right);
// 对左区间进行整理
qSort(arr, left, pivotIndex - 1);
// 对右区间进行整理
qSort(arr, pivotIndex + 1, right);
}
// 对区间进行整理,并返回基准值所在下标
private static int partition(int[] arr, int left, int right) {
}
// 交换
private static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
区间的整理,即partition函数有三种写法,下面,我们分别来看一下。
hoare法
private static int partition(int[] arr, int left, int right) {
// 取最左边的元素作为基准值
int i = left, j = right, pivot = arr[left];
while (i < j) {
// 先从后往前找到比基准值小的值
while (i < j && arr[j] >= pivot) {
--j;
}
// 再从前往后找到比基准值大的值
while (i < j && arr[i] <= pivot) {
++i;
}
swap(arr, i, j);
}
swap(arr, i, left);
return i;
}
上述代码,我们有一个疑问,我们如何知道最外层的while循环结束后,i下标的元素一定比left下标的元素小,因为我们交换完之后,需要保证区间满足i下标左边的元素都比基准值小,右边的元素都比基准值大。
这里,我们看一下代码,while结束有两种情况:
- 在第7行不满足条件
i < j
而退出的循环,因为++i
导致循环退出,由于前面–j的操作已经结束,所以j下标的元素一定比基准值小,而++i
退出循环时,i和j是相等的,所以这种情况可以保证i下标的元素一定比left下标的元素值要小; - 在第5行不满足条件
i<j
而退出的循环,因为--j
导致循环退出,前面已经完成了一次i下标元素和j下标元素的交换,所以此时i下标元素的值一定比基准值小,所以因为--j
退出循环,i和j是相等的,所以这种情况可以保证j下标的元素一定比left下标的元素值要小。
但是,如果我们将++i和–j这两个代码块的顺序调换一下,这个代码就是错的,因为无法保证上述问题。所以,hoare有两种写法:
- 如果我们取最左边的值作为基准值,就要先从右向左找,再从左向右找;
- 如果我们取最右边的值作为基准值,就要先从左向右找,再从右向左找。
另一种写法如下:
private static int partition(int[] arr, int left, int right) {
int i = left, j = right, pivot = arr[right];
while (i < j) {
while (i < j && arr[i] <= pivot) {
++i;
}
while (i < j && arr[j] >= pivot) {
--j;
}
swap(arr, i, j);
}
swap(arr, right, i);
return i;
}
挖坑法
private static int partition(int[] arr, int left, int right) {
int i = left, j = right, pivot = arr[left];
while (i < j) {
while (i < j && arr[j] >= pivot) {
--j;
}
arr[i] = arr[j];
while (i < j && arr[i] <= pivot) {
++i;
}
arr[j] = arr[i];
}
arr[i] = pivot;
return i;
}
同样的,挖坑法也需要注意:
- 如果选取最左边的元素作为基准值,则需要先从右向左找,再从左向右找;
- 如果选取最右边的元素作为基准值,则需要先从左向右找,再从右向左找。
下面是另一种写法:
private static int partition(int[] arr, int left, int right) {
int i = left, j = right, pivot = arr[right];
while (i < j) {
while (i < j && arr[i] <= pivot) {
++i;
}
arr[j] = arr[i];
while (i < j && arr[j] >= pivot) {
--j;
}
arr[i] = arr[j];
}
arr[i] = pivot;
return i;
}
前后指针法
该种方法出自《算法导论》,我们先来看一下代码:
private static int partition(int[] arr, int left, int right) {
int d = left - 1;
int pivot = arr[right];
for (int i = left; i < right; ++i) {
if (arr[i] < pivot) {
++d;
swap(arr, i, d);
}
}
swap(arr, d + 1, right);
return d + 1;
}
快速排序的非递归实现
import java.util.Arrays;
import java.util.Stack;
public class QuickSort {
public static void main(String[] args) {
int[] arr = {
1, 3, 5, 7, 9,
2, 4, 6, 8, 0
};
quickSort(arr);
System.out.println(Arrays.toString(arr));
}
public static void quickSort(int[] arr) {
Stack<Integer> stack = new Stack<>();
stack.push(arr.length - 1);
stack.push(0);
while (!stack.isEmpty()) {
int left = stack.pop();
int right = stack.pop();
if (left >= right) {
continue;
}
int pivotIndex = partition(arr, left, right);
stack.push(pivotIndex - 1);
stack.push(left);
stack.push(right);
stack.push(pivotIndex + 1);
}
}
private static int partition(int[] arr, int left, int right) {
int i = left, j = right, pivot = arr[left];
while (i < j) {
while (i < j && arr[j] >= pivot) {
--j;
}
arr[i] = arr[j];
while (i < j && arr[i] <= pivot) {
++i;
}
arr[j] = arr[i];
}
arr[i] = pivot;
return i;
}
}
Python一行代码实现快速排序
我们来看一个高逼格代码,Python一行完成快速排序:
quick_sort = lambda array: array if len(array) <= 1 else quick_sort([item for item in array[1:] if item <= array[0]]) + [array[0]] + quick_sort([item for item in array[1:] if item > array[0]])
print(quick_sort([2,5,9,3,7,1,5]))
运行结果如下:
快速排序的缺陷和优化方法
上述版本的快速排序方法具有三个缺点:
- 逆序,表现不佳,无法达到分治的效果;
- 基准值选的不好,也无法达到分治的效果;
- 元素数量非常多,会导致递归深度过深,有可能栈就溢出了。
优化办法:
- 优化基准值的选取方式,三元素取中(第一个元素、最后一个元素、中间元素);
- 如果递归深度达到一定层次之后,就不再继续递归,而是对当前待排序区间进行其他排序,如:堆排序;
- 如果当前待排序区间已经比较小了,直接使用插入排序。
C++中的std::sort就是这么进行优化的。
Java中的Collections.sort也是基于快速排序,但是它会根据数据的各种特点,会做出各种各样的优化,还会在有些特定情况下变成TimSort(改进版的归并排序)。