一、基本算法
归并排序是将数组从中间切分为两个子数组分别排序,之后将两个子数组合并从而使得整个数组排序。
快速排序虽然也是通过切分操作来进行排序,但它用不到合并操作。原因就在于,快速排序是通过一个切分元素来将数组切分为两个子数组,左子数组的元素都小于切分元素,右子数组的元素都大于切分元素,将子数组分别以此方法排序就可以将整个数组排序了。
切分的思想在于,对数组arr
,左边界l
,右边界r
进行切分时,取第一个元素a[l]
作为切分元素,i
从左往右扫描,找到第一个大于等于a[l]
的元素,j
从右往左扫描,找到第一个小于等于a[l]
的元素,交换双方,并继续上述过程。直到两指针相遇,交换a[1]
和a[j]
的位置。
这也被称为二路快速排序
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Random;
/**
* 快速排序
* 通过一个切分的元素,将数组分成两个子数组(需要遍历一次)
* 左子数组小于等于切分元素,右子数组大于等于切分元素
* 将这两个子数组排序即可将整个数组排序
*
* @Author Nino 2019/10/4
*/
public class QuickSort<E extends Comparable<E>>{
/**
* 快速排序的递归接口
* @param nums
*/
public void quickSort(E[] nums) {
//打乱数组
shuffle(nums);
quickSort(nums, 0, nums.length - 1);
}
/**
* 快速排序的递归方法
* @param nums
* @param l
* @param r
*/
private void quickSort(E[] nums, int l, int r) {
if (l >= r) {
return;
}
int j = partition(nums, l, r);
quickSort(nums, l, j - 1);
quickSort(nums, j + 1, r);
}
/**
* 随机打乱数组
* 快速排序需要每次正好对半分数组才会性能达到最优,因此需要打乱
*
* @param arr
*/
private void shuffle(E[] arr) {
List<E> list = Arrays.asList(arr);
Collections.shuffle(list);
list.toArray(arr);
}
/**
* 数组的切分过程
* 取第一个元素a[l]作为切分元素
* i从左往右扫描,找到第一个大于等于a[l]的元素
* j从右往左扫描,找到第一个小于等于a[l]的元素
* 交换双方,并继续上述过程。
* 直到两指针相遇,交换a[1]和a[j]的位置。
*
* @param arr 数组
* @param l 需要切分的左边界
* @param r 需要切分的右边界
* @return 切分元素所在的索引
*/
private int partition(E[] arr, int l, int r) {
// 少取一位数 方便++i的操作
int i = l;
// 多取一位数,方便--j的操作
int j = r + 1;
E e = arr[l];
// 循环到i, j相遇
while (true) {
// 当arr[++i]小于a[l]且i没有到达右边界,继续扫描
while (arr[++i].compareTo(e) < 0 && i < r) {
continue;
}
// 当arr[--j]大于a[l]且j没有到达右边界,继续扫描
while (arr[--j].compareTo(e) > 0 && j > l) {
continue;
}
// i,j指针相遇,中断大while
if (i >= j) {
break;
}
// 当跳过以上两个while到达这里时说明i, j的位置需要交换了
swap(arr, i, j);
}
// 由于先遍历的是i,后遍历的是j,所以交换a[l]和a[j]的位置
swap(arr, l, j);
return j;
}
/**
* 交换数组nums中i,j索引的值
* @param nums
* @param i
* @param j
*/
private void swap(E[] nums, int i, int j) {
E temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
/**
* 测试用例
* @param args
*/
public static void main(String[] args) {
Random random = new Random();
Integer[] arr = new Integer[10];
for (int i = 0; i < arr.length; i++) {
arr[i] = Integer.valueOf(random.nextInt(20));
}
System.out.println(Arrays.asList(arr).toString());
new QuickSort<Integer>().quickSort(arr);
System.out.println(Arrays.asList(arr).toString());
}
}
性能分析
快速排序的最优情况是每次都能将数组对半分,这样递归调用的次数最少。在这种情况下时间复杂度为 O ( N l o g N ) O(NlogN) O(NlogN)
最差情况下,第一次从最小的元素切分,接下来每一次都从最小的元素切分,一共需要比较 N 2 / 2 N^2/2 N2/2次。时间复杂度达到了 O ( N 2 ) O(N^2) O(N2)。
数组一旦是有序的,便很可能发生最差的情况,因此需要打乱数组进行排序。
二、算法改进
1、切换到插入排序
当数组规模较小时,使用插入排序来代替快速排序。
局部需要修改的代码如下:
/**
* 快速排序的递归方法
* @param nums
* @param l
* @param r
*/
private void quickSort(E[] nums, int l, int r) {
if (r - l <= 15) {
insertionSort(nums, l, r);
return;
}
int j = partition(nums, l, r);
quickSort(nums, l, j - 1);
quickSort(nums, j + 1, r);
}
/**
* 对数组nums的[l, r]区间做插入排序
* @param nums
* @param l
* @param r
*/
private void insertionSort(E[] nums, int l, int r) {
for (int i = l + 1; i <= r; i++) {
for (int j = i; j > l && nums[j].compareTo(nums[j - 1]) < 0; j--) {
swap(nums, j, j - 1);
}
}
}
2、三数取中
快速排序最好情况是均分数组,但由于不可能每次都能取数组的中位数作为切分元素,折中方法是取三个元素,并将大小居中的元素作为切分元素与第一个元素交换过去。
3、三路快速排序
基本算法使用的是双路快排。
对于有大量重复元素的数组,可以将数组切分为三部分,分别对应小于、等于和大于切分元素。
三向切分快速排序可以使大量重复元素的随机数组在线性时间内完成排序。
/**
* 三路快速排序 递归接口
* @param arr
*/
public void threeWayQuickSort(E[] arr) {
//打乱数组
shuffle(arr);
threeWayQuickSort(arr, 0, arr.length - 1);
}
/**
* 三路快排 递归实现
* @param arr
* @param l
* @param r
*/
private void threeWayQuickSort(E[] arr, int l, int r) {
// 区间小的数组采用插入排序
if (r - l <= 15) {
insertionSort(arr, l, r);
return;
}
// 取切分元素
E e = arr[l];
// 等于切分元素的左边界
int lt = l;
// 当前遍历的元素
int i = l + 1;
// 等于切分元素的有边界
int gt = r;
// 遍历中
while (i <= gt) {
if (arr[i].compareTo(e) < 0) {
// 这里因为把新遍历到的元素加到了前面,因此i和lt都需要++
swap(arr, i++, lt++);
} else if (arr[i].compareTo(e) > 0) {
// 这里要把遍历到的元素跟gt的数字交换,gt原先的元素并没有被检查过
// 所以遍历的指针i不能够自增,而gt应该自减
swap(arr, i, gt--);
} else {
i++;
}
}
//对[l, lt-1]区间再做三路快排
threeWayQuickSort(arr, l, lt - 1);
//对[gt+1, r]区间再做三路快排
threeWayQuickSort(arr, gt + 1, r);
}
三、基于切分思想的快速选择算法
在二路快速排序算法中,我们使用了partition()
函数来做数组的切分操作。我们将小于等于切分元素的值都放在它的左边,将大于等于切分元素的值都放在它的右边,返回了切分元素的下标j
。
说到这里有些同学应该已经发现了,切分元素的下标j
正好就表明了它是这个数组中从小到大排第j
的元素。
我们可以利用这样的性质来寻找数组中从小到大排第k个的元素
/**
* 寻找数组中从小到大排第k的元素
* @param arr
* @param k
* @return
*/
public E select(E[] arr, int k) {
int l = 0;
int r = arr.length - 1;
while (l <= r) {
int j = partition(arr, l, r);
if (j == k) {
return arr[k];
} else if (j < k) {
l = j + 1;
} else {
r = j - 1;
}
}
// 经过以上操作就算没能精准匹配j == k
// 也可以确保k的位置被排序好了
return arr[k];
}