本文主要介绍了三版快速排序,第三版是最终版本,也是优化后性能最佳的版本。但是第二版是目前最流行的,也是我们写代码的时候的首选版本。
前言基础:
1、快排的时间复杂度为O(nlogn)。是原地排序算法,空间复杂度为O(logn),是不稳定排序算法。
2、之所以快是因为不断的划分,类似于归并排序法,但是归并是每次取一半,快排是不一定取一半,可能左边多右边少,也可能右边多左边少。
3、对于未经过任何优化过的快速排序,数组越有序排序的效率越低,因为当数组完全有序会生成一个深度为元素个数的栈,很有可能超出系统栈的深度。可以通过随机数选取来优化这样的问题。
4、快速排序算法是一种随机算法。随机分配每个区间
5、快排每次排序结束之后。target元素所在的位置就是其最终所在的位置,所以根据这个特性求解第几大第几小问题。
6、快排的递归结束条件是左边界大于等于右边界元素的索引。l>=r
快速排序的大致思想:先进行一次快排分为左右的空间,然后递归左右两个子空间,直到左边界>=右边界。 >肯定跳出,=是表示左右边界相等即只有一个元素,所以也没有必要排序。
核心在于Partition函数,为了让每次选择的那个元素在应该呆在的位置。这个元素的下标就是其左边的元素都是小于它的,右面的都是大于它的
一、第一版快速排序 (单路快速排序)
大致思想:如上图所示,
1、每次选取一个特定的元素v,操作指针j和i。
2、i是扫描的元素,如果i指向的元素<标志元素V,则让指针j++并且交换i和j对应元素的值,结束的条件是i大于当前元素的最右边界限
3、当i>最右边元素的值时,交换下标j和L对应的元素,使其保证arr[l,j-1]是小于标志点元素V的,arr[j+1,r]是大于标志元素V的。最终返回j点的索引。
4、之后进行递归分别对j左边和j右边的元素分别进行快排,从而达到递归的效果。
public class QuickSort {
public QuickSort() {
}
//单路快速排序,无法实现对数据全为一样的数组进行快排
public static <E extends Comparable<E>> void sort(E arr[]) {
Random random = new Random();
sort(arr, 0, arr.length - 1, random);
}
private static <E extends Comparable<E>> void sort(E arr[], int l, int r, Random random) {
if (l >= r) return;
int flagIndex = partition(arr, l, r, random); //flagIndex就是左边的都小于它,右边的都大于它
sort(arr, l, flagIndex - 1, random);//此处传参为index-1因为不对标志元素进行排序
sort(arr, flagIndex + 1, r, random);
}
//l+1~j是小于的区间 j~i-1是大于的,j是一个分界点i~r是还未处理的元素
private static <E extends Comparable<E>> int partition(E[] arr, int l, int r, Random random) {
// 避免当数组为完全有序时,调用系统栈次数为元素的个数,使其系统栈溢出
int index = random.nextInt(r - l + 1) + l;
swap(arr, l, index);
int j = l;//j指得就是大于和小于的分界点
E flag = arr[l];
for (int i = l + 1; i <= r; i++) {
if (arr[i].compareTo(flag) < 0) {
j++;
swap(arr, j, i);
}
}
swap(arr, l, j);
return j;
}
}
二)第二版快速排序(二路快速排序)
第二版快速排序主要是优化第一版本对数组元素完全相同或重复元素较多时的效率下降问题,双路排序将与标志元素相同的元素均匀分布在大于和小于数据的两端。
如上图所示:
1、起初橙色和紫色是空区间,i从L+1开始,j从r+1开始,
2、直到i找到>=v的元素,j找到<=v的元素停止。或者i>j,j<i
3、交换i和j下标对应的元素
4、一直遍历直到 i>=j时退出循环。
5、最后在arr[l,j-1]是<=flag的,arr[j+1,r]是>=目标元素的。
6、每次跳出内层while循环的条件都是左边元素>=falg,右边元素<=flag。
7、内层while循环的条件中i是可以等于j的,因为最终运行完之后一定是i>j此时才能让j于l元素进行位置的交换。并且循环体内的while循环二条件无等号
8、每次循环结束之后,j所指的元素都是小于flag最大下标的那个元素。
//二路快速排序,实现可以对完全相同数组的排序,应对所有问题,但是对有大量重复数据的性能不如三路快排
public static <E extends Comparable<E>> void sort2Ways(E arr[]) {
Random random = new Random();
sort2Ways(arr, 0, arr.length - 1, random);
}
private static <E extends Comparable<E>> void sort2Ways(E arr[], int l, int r, Random random) {
if (l >= r) return;
int flagIndex = partition2Ways(arr, l, r, random);
sort2Ways(arr, l, flagIndex - 1, random);//此处传参为index-1因为不对标志元素进行排序
sort2Ways(arr, flagIndex + 1, r, random);
}
private static <E extends Comparable<E>> int partition2Ways(E[] arr, int l, int r, Random random) {
// 避免当数组为完全有序时,调用系统栈次数为元素的个数,使其系统栈溢出
int index = random.nextInt(r - l + 1) + l;
swap(arr, l, index);
//就变在了这个while循环,i指的是要开始遍历的元素,j是大于区间的第一个元素
int i = l + 1, j = r; //j默认指向最后一个元素
//尽管是两层,但是是O(n)级别的
//l+1~i-1是小于等于的元素 i~j-1是要扫描的元素,j~r是大于等于的元素
while (true) {
//寻找比目标元素大于等于的元素,用于放左边
while (i <= j && arr[i].compareTo(arr[l]) < 0)
i++;
//寻找比目标元素小于等于的元素,用于放右边
while (j >= i && arr[j].compareTo(arr[l]) > 0)
j--;
if (i >= j) break; //退出的条件
//无论有没有最终都是将这两个元素交换一下,并且将等于flag的元素进行一次交换
//性能低下体现在这里,当两个元素都等于flag的时候还是要进行一次交换
//假设有三个等于flag的元素,,它就会把另外两个加入到大于和小于的区间里,大于和小于的区间元素越多,最后递归的深度就会越深,此时的性能就会越差
swap(arr, i, j);
i++;
j--;
}
swap(arr, l, j);
return j;
}
三)第三版快速排序(三路快速排序)
三路排序与二路排序的唯一区别在于三路排序每次递归的过程中不会将每次与flag的元素再次进行递归,进一步最大的优化了效率。是快速排序的最终版本。
1、可以应对完全有序的数组。
2、可以处理元素全部相同的数组。
3、没有开辟额外的内存空间。
4、当数据完全有序时,时间复杂度为O(n)只需要遍历一遍数组即可。
应用于只有三种情况的数组,然后进行只扫描一遍且不开辟额外的空间即完成排序。
1、在每次排序之后arr[l,lt-1]是<flag的元素,arr[gt,r]是>flag元素的。
2、每次对(l,lt-1)和(gt,r)进行递归操作,而对于(lt,gt)的这一段==flag的不再进行递归操作。
//三路快速排序,解决数据中包含大量重复数据。相当于对二路的一种优化,主要优化等于flag的部分
/*
* 三路快排的核心用法就是解决有特定类型情况的排序问题,如LeetCode的75题
* 优化的核心就是防止相等的元素进入大于或小于的区间
* */
public static <E extends Comparable<E>> void sort3Ways(E arr[]) {
Random random = new Random();
sort3Ways(arr, 0, arr.length - 1, random);
}
private static <E extends Comparable<E>> void sort3Ways(E arr[], int l, int r, Random random) {
if (l >= r) return;
int index = random.nextInt(r - l + 1) + l;
swap(arr, l, index);
//[l+1,lt]是全部<flag的,[gt,r]是全部大于flag的,[lt+1,i-1]是全部等于的,[i,gt]是要处理的元素
int lt = l, i = l + 1, gt = r + 1; //初始化的目的是让那两个空间为空
E flag = arr[l];
while (i < gt) { //循环结束的条件
if (arr[i].compareTo(flag) < 0) {
lt++;
swap(arr, i, lt);
i++;
} else if (arr[i].compareTo(flag) > 0) {
gt--;
swap(arr, i, gt);
//i++此处没有i++因为右面的元素都是未知元素,而左面的都是已经判断好了是小于flag的,所以只需要直接放到区间里直接判断即可
} else {//arr[i]==flag
i++;
}
}
//将lt放到等于的那个区间里,此时[l,lt-1]小于的区间[lt,gt-1]等于的区间[gt,r]大于的区间
swap(arr, l, lt);
//核心改变处,只处理应该递归的那部分元素,而不是将全部元素都进去递归
sort3Ways(arr, l, lt - 1, random);
sort3Ways(arr, gt, r, random);
}
整个算法的优化过程:
1、单路排序法,无法解决完全相同的元素-->引入了随机数
2、单路无法应对完全相同或数组有序度较高问题以及所有元素都一样的数据----->>双路排序算法,将等于的平均分配在了两边
3、双路排序在完全相同的数据中,有重复递归现象--->三路排序算法
快速排序应用:
一、Select K问题。
1、寻找数组中第K小的元素。(k是从0开始的)
因为每次快排中,确定目标元素所在的位置就是其最终所在的位置。由于基础快排是从小到大的,所以只需要保证每次快排返回的索引值==要的第几小的K即可。
public static int Min_num(Integer arr[],int k){
Random random=new Random();
return selectK(arr,0,arr.length-1,k,random);
}
public static int selectK(Integer arr[], int l,int r,int k,Random random) {
int index = partition(arr, 0, arr.length - 1, random);
if (index==k)
return arr[index];
if (index<k)
return selectK(arr,index+1,r,k,random);
return selectK(arr,l,index-1,k,random);
}
private static int partition(Integer[] arr, int l, int r, Random random) {
int index = random.nextInt(r - l + 1) + l;
swap(arr, l, index);
int i=l+1,j=r;
while (true) {
while (i <= j &&arr[i]<arr[l])
i++;
while(j>=i&&arr[j]>arr[l])
j--;
if (i>=j)
break;
swap(arr,j,i);
i++;
j--;
}
swap(arr,l,j);
return j;
}
private static void swap(Integer[] arr, int l, int index) {
int tmp = arr[l];
arr[l] = arr[index];
arr[index] = tmp;
}
2、寻找第K大的元素:LeetCode:215
与上面的寻找第K小的实际性质相同,只是改变一下K即可完成修改
class Solution {
public int findKthLargest(int[] arr, int k) {
Random random=new Random();
k=arr.length-k;
return selectK(arr,0,arr.length-1,k,random);
}
public int selectK(int arr[], int l,int r,int k,Random random) {
int index = partition(arr, l, r, random);
if (index==k)
return arr[index];
if (index<k)
return selectK(arr,index+1,r,k,random);
return selectK(arr,l,index-1,k,random);
}
private int partition(int[] arr, int l, int r, Random random) {
int index = random.nextInt(r - l + 1) + l;
swap(arr, l, index);
int i=l+1,j=r;
while (true) {
while (i <= j &&arr[i]<arr[l])
i++;
while(j>=i&&arr[j]>arr[l])
j--;
if (i>=j)
break;
swap(arr,j,i);
i++;
j--;
}
swap(arr,l,j);
return j;
}
private void swap(int[] arr, int l, int index) {
int tmp = arr[l];
arr[l] = arr[index];
arr[index] = tmp;
}
}
3、寻找最小的前K个元素。剑指 Offer40
这题我们首先可能会想到先对整个数组进行快排,然后拷贝前K个元素即可,但是这样实现的缺点就是我们将K 后面的元素也进行了排序,造成了性能的下降,于是我们还可以调用之前写的selectK方法,当遇到满足条件的直接返回了,这样少进行了很多次的排序。
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if (k==0)
return new int[0];
Random random=new Random();
selectK(arr,0,arr.length-1,k-1,random);
//第一个参数是拷贝的数组,第二个参数是要拷贝的长度。
return Arrays.copyOf(arr,k);
}
public int selectK(int arr[], int l,int r,int k,Random random) {
int index = partition(arr, l, r,k, random);
if (index==k)
return arr[index];
if (index<k)
return selectK(arr,index+1,r,k,random);
return selectK(arr,l,index-1,k,random);
}
private int partition(int[] arr, int l, int r, int k, Random random) {
int index = random.nextInt(r - l + 1) + l;
swap(arr, l, index);
int i=l+1,j=r;
while (true) {
while (i <= j &&arr[i]<arr[l])
i++;
while(j>=i&&arr[j]>arr[l])
j--;
if (i>=j)
break;
swap(arr,j,i);
i++;
j--;
}
swap(arr,l,j);
return j;
}
private void swap(int[] arr, int l, int index) {
int tmp = arr[l];
arr[l] = arr[index];
arr[index] = tmp;
}
}
总结:
SelectK算法的优点就是可以减少排序的次序,无论是寻找前K个还是寻找第K个,算法最终都会减少遍历的次数,只要K的条件满足了就不会在对数组进行排序,进而提升效率。
对于下面的数组,寻找前K个最小的元素:
返回的结果以及算法之后的结果为:
二)颜色分类问题:leetCode75
1、仅一次扫描既对数组进行排好序。(可以多维护指针的方式对元素类型少的数组进行一次扫描进行排序)
2、没有使用额外的辅助空间。
class Solution {
public void sortColors(int[] arr) {
if (arr.length==0)
return;
//0~l是小的,l+1到gt是等于1的,gt到r是大于的
int gt=arr.length;
int lt=-1;
int i=0;
while (i<gt){
if (arr[i]<1){
lt++;
swap(arr,i,lt);
i++;
}else if (arr[i]>1){
gt--;
swap(arr,i,gt);
}else {
i++;
}
}
}
private void swap(int arr[],int i,int j){
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}