目录
1)和归并的一个优化相似:小集合(r-l<=15)上不用再递归而直接插入
一、原理
1.总体原理
1)从待排序区间选一个数,作为基准值pivot
2)Partition(和merge是归并的核心操作一样,它是快排的核心操作):遍历整个待排序区间,将比基准值小的放到基准值左边,大于等于的放到基准值右边。
3)分治思想:对左右两个小区间按同样方式处理,直到小区间长度为1,代表已经有序,或者小区间长度为0,代表没有数据。
2.Partition分区的步骤图片分析:
v:基准值(待比较值);e:当前在遍历的元素;i:当前正在扫描的元素的下标;l:基准值对应下标(分区点);j:小于基准值的最后一个元素。
1)若arr[i]>=v:只需要把紫色部分拉长,即i++即可,就把e放进了i>=v的区间,i++就遍历下一个e元素了。
2)若arr[i]<v:即e是属于橙色部分的,只需要将当前扫描的小于v的这个e,与>=v的第一个元素(即紫色部分的第一个元素(下标j+1))做交换,即swap(i,j+1);,交换后j++让橙色部分扩充把这个元素e囊括,i++继续向后移动遍历下一个元素。
3)当整个区间扫描完毕,整个区间就被我们分割成以下情况:左侧元素全<v,右侧元素全>v,i也走到了数组末尾。
4)此时j就落在了最后一个小于v的索引,此时只需要交换swap(l,j);交换后arr[j]这个元素就变成v了(就落在了最终的位置),arr[j]之前的都是小于v的元素,arr[j]之后的都是>=v的元素,分区完成。
5)继续在小于v和大于v的子区间上重复上述过程(递归),直到整个集合有序。
二、基本代码实现
注:这里快速排序的分区思想是基于《算法导论》的分区思想
1.递归写法
public static void quickSort(int[] arr){
quickSortInternal(arr,0,arr.length-1);
}
/**
* 在arr[l...r]上进行快速排序
* @param arr
* @param l
* @param r
*/
private static void quickSortInternal(int[] arr, int l, int r) {
if(l>=r){
return;//只有一个元素时有序,不用再进行
}
//先获取分区点
//所谓分区点,就是经过分区函数后,某个元素落在了最终的位置
//分区点左侧全是小于该元素的区间,分区点右侧都是>+该元素的区间
int p=partition(arr,l,r);
//递归重复在分区点的左区间和右区间上重复上述流程
quickSortInternal(arr,l,p-1);
quickSortInternal(arr,p+1,r);
}
/**
* 在arr[l...r]上的分区函数,返回分区点的索引
* @param arr
* @param l
* @param r
* @return
*/
private static int partition(int[] arr, int l, int r) {
//基准值
int v=arr[l];
//arr[l+1...j]<v
//arr[j+1...i)>=v
//i表示当前正在扫描的元素
//最开始时上面两个区间没有元素,是空区间,赋值时注意
int j=l;
for (int i = l+1; i <= r; i++) {
if(arr[i]<v){
swap(arr,j+1,i);
j++;
//i++在循环条件里写了
}//else arr[i]>v不需要交换只用i++
}
//走完循环交换基准值v和最后一个小于v的元素(j索引处)交换
swap(arr,l,j);
return j;//基准值对应索引
}
结果:
2.带输入数组理解partition过程
3.优化:
1)和归并的一个优化相似:小集合(r-l<=15)上不用再递归而直接插入
if(r-l<=15){
insertionSort(arr,l,r);
return;
}
// if(l>=r){
// return;//只有一个元素时有序,不用再进行
// }
2)基准值选择的优化(对于退化的修改)
1.默认选择边上元素(左或右)
关于基准值的选择,我们现在是默认选择第一个元素作为分区点,但是在近乎有序的集合上默认使用第一个数作为分区点,快排会退化为O(N^2)的时间复杂度(可能造成栈溢出),他甚至完全不如插入排序。
为何栈溢出:递归过程中实际上是在遍历数组,O(N^2)的算法,递归函数调用次数也就变成了O(N^2)次,一般来说,JVM栈的深度(栈调用的次数)大概在1w次左右。当JVM调用函数的次数超过了能允许的最大深度就栈溢出了。
如何退化:极端情况,数组完全有序[1、2、3、4、5、6、7、8],先按1分区,分区后1左面没有数组,右边是剩下的全部数组,再拆[2...8],分区后2前面无数组,2右边是一个数组(只有>=v右元素)。分区后的左右区间严重不平衡,二叉树退化为单支树,即树结构退化为了链表(logN->n)
对于这种退化的修改:(不再从最左侧开始选基准值)用下面的随机选择或取中优化
2.随机选择⭐
随机在[l...r]区间选一个数,交换l与randomIndex位置,此时l对应的值是随机值而非第一个元素,将这个数作为基准值开始从左侧遍历。
private static final ThreadLocalRandom random=ThreadLocalRandom.current();
private static int partition(int[] arr, int l, int r) {
//随机在当前的数组中选一个数
int randomIndex=random.nextInt(l,r);
swap(arr,randomIndex,l);
//基准值
int v=arr[l];//此时arr[l]不一定是数组第一个元素,而是[l...r]间任意索引对应值
3.几数取中(一般三数取中)
如三个数arr[l],arr[mid],arr[right]大小是中间的值为基准值。随机选择和取中都能避免在递归过程中,在接近有序的数组上快速排序的分区严重不平衡的问题。
3.快排的退化
1.接近有序的数组上快排退化(上面优化中提到了)
2.在大量重复元素数组上快排退化:
极端情况,当100w个元素全都是一个值,分区后,<v的元素没有,全都在>=v的集合中,二叉树又退化成了单支树。这种退化的解决思路:二路/三路快排
4.快排非递归写法
/**
* 借助栈/队列来实现非递归分治的快排
*/
public static void quickSortNonRecursion(int[] arr){
Deque<Integer> stack=new ArrayDeque<>();
//栈中保存当前集合的开始位置和终止位置
int l=0;
int r=arr.length-1;
//先入后出
stack.push(r);
stack.push(l);
while(!stack.isEmpty()){
//栈不为空,说明子区间还未处理完毕
int left=stack.pop();//left是区间左端点
int right=stack.pop();
if(left>=right){
//区间只有一个元素,不需要再分区了
continue;
}
int p=partition(arr,left,right);
//依次将右区间的开始和结束位置压入栈中//先右后左压,取出来是先左后右
stack.push(right);
stack.push(p+1);
//再将左侧区间开始和结束位置入栈
stack.push(p-1);
stack.push(left);
}
}
/**
* 在arr[l...r]上的分区函数,返回分区点的索引
* @param arr
* @param l
* @param r
* @return
*/
private static int partition(int[] arr, int l, int r) {
//随机在当前的数组中选一个数
int randomIndex=random.nextInt(l,r);
swap(arr,randomIndex,l);
//基准值
int v=arr[l];//此时arr[l]不一定是数组第一个元素,而是[l...r]间任意索引对应值
//arr[l+1...j]<v
//arr[j+1...i)>=v
//i表示当前正在扫描的元素
//最开始时上面两个区间没有元素,是空区间,赋值时注意
int j=l;
for (int i = l+1; i <= r; i++) {
if(arr[i]<v){
swap(arr,j+1,i);
j++;
//i++在循环条件里写了
}//else arr[i]>v不需要交换只用i++
}
//走完循环交换基准值v和最后一个小于v的元素(j索引处)交换
swap(arr,l,j);
return j;//基准值对应索引
}
三、二路快排
上述优化后的基本快排仍存在不足
当待排序的集合中出现大量重复元素时,就会导致某个分区的元素过多(相等的元素全在一个区间中),造成递归过程中递归树严重不平衡,快排退化为O(N^2)时间复杂度。【见快排的退化第2点】
1.图片分析
二路快排:将相等的元素均分到左右两个子区间
1)使用两个变量i和j,从前向后扫描,碰到第一个arr[i]>=v的元素停止,从后向前扫描,碰到第一个<=v的元素停止,把这两个元素交换(swap(arr,i,j)),这样就可以把相等元素平均到左右两个子区间。
2)i++或j--继续移动i,j,此时,橙色区间元素<=v,紫色区间元素>=v,i,j继续向后遍历判断
3)i>=j时整个集合就扫描结束了,注意,j最后会落在最后一个<=v的元素上
4)交换l和j对应的元素即可,交换后返回j处索引,继续在橙色与紫色部分递归即可。
2.带入数组元素分析分区过程
3.代码实现
public static void quickSort2(int[] arr){
quickSortInterna2(arr,0,arr.length-1);
}
private static void quickSortInterna2(int[] arr, int l, int r) {
if(r-l<=15){
insertionSort(arr, l, r);
return;
}
int p=partition2(arr,l,r);
quickSortInterna2(arr,l,p-1);
quickSortInterna2(arr,p+1,r);
}
/**
* 在arr[l...r]上进行双路快排的分区处理
* @param arr
* @param l
* @param r
* @return
*/
private static int partition2(int[] arr, int l, int r) {
int randomIndex=random.nextInt(l,r);
swap(arr,randomIndex,l);
int v=arr[l];
//arr[l+1..i)<=v
int i=l+1;
//arr(j...r]>=v
int j=r;
while(true){
//i从前向后扫描,碰到第一个>=v的元素停止
while(i<=j&&arr[i]<v){
i++;
}
//j从后向前扫描,碰到第一个<=v的元素停止
while(j>=i&&arr[j]>v){
j--;
}
if(i>=j){
//循环遍历交换完毕
break;
}
swap(arr,i,j);
i++;
j--;
}
//整个集合扫描完毕,j落在最后一个<=v的元素上
swap(arr,l,j);
return j;
}
结果:(100w个元素在0-100之间取值(即含大量重复元素时排序的结果观察))
四、挖坑法快排
注意,挖坑法也没有解决大量重复元素的情况(重复元素过多退化仍有可能栈溢出)
1.思路
1)选取分区点p=arr[l]。挖坑法必须先从后向前扫描(先从right开始),再从前向后扫描。从前向后的话由于右边j索引元素未存,直接j对应元素就丢了,而从后向前第一次覆盖的arr[i]还是在left处,即i索引的值是被保存在v的,值不会丢。
2)j先从后向前扫描,碰到第一个<v的元素停止,然后直接arr[i]=arr[j]
3)i再从前向后扫描,碰到第一个>j的元素停止,然后直接arr[j]=arr[i]
4)循环结束(挖坑结束)后只需要将分区值v给array[i]即可
2.代码实现
public static void quickSortOfDigPit(int[] arr){
quickSortOfDigPitInternal(arr,0,arr.length-1);
}
// 在arr[l...r]上进行快速排序
private static void quickSortOfDigPitInternal(int[] arr, int l, int r) {
if(r-l<=15){
insertionSort(arr,l,r);//插入排序
return;
}
//分区点
int p=partitionDigPit(arr,l,r);
//分区点左侧继续挖坑排序
quickSortOfDigPitInternal(arr,l,p-1);
//分区点右侧继续挖坑排序
quickSortOfDigPitInternal(arr,p+1,r);
}
/**
* 在arr[l...r]上挖坑法进行排序的分区处理
* @param arr
* @param l
* @param r
* @return
*/
private static int partitionDigPit(int[] arr, int l, int r) {
int randomIndex=random.nextInt(l,r);
swap(arr,randomIndex,l);
int i=l;
int j=r;
int v=arr[l];//也相当于左侧元素(分区点元素)被暂存了
while(true){
//必须先进行右侧向前扫描
//j对应元素<v,循环暂停
while(j>i&&arr[j]>=v){
j--;
}
//直接把j处对应值给i
arr[i]=arr[j];
//i向后遍历,直到对应元素>v
while(i<j&&arr[i]<=v){
i++;
}
//i处对应元素直接覆盖j处对应元素
arr[j]=arr[i];
if(i>=j){
//i>=j时,挖坑结束
break;
}
}
//此时挖坑结束,开始填坑,即把v值给arr[i]即可
arr[i]=v;
return i;
}
五、三路快排(了解)
1.思路
在一次分区函数的操作中,将所有相等的元素都放在最终位置,只需要在小于v和大于v的子区间上进行快排,所有相等的元素就不再处理了。
2.图片分析
1)当前扫描元素e<v:当arr[i]<v,换到橙色部分(和等于的第一个换),swap(arr,i,lt+1),lt++,i++继续处理下一个元素。
2)当前扫描元素e==v:直接i++处理下一个元素即可
3)当前扫描元素e>v:当arr[i]>v,换到紫色部分,swap(arr,i,gt-1),但是此时i不需++【因为此时换过来的gt-1对应的元素是没处理过的,你不知道它是大于小于或等于v。上面i++是因为交换的元素都是已经处理的<或=的元素】,只需要gt--即可,然后继续判断i对应处e的大小。
4)经过全集合全部处理结束后,i和gt就重合了,此时v只需要和lt一交换(lt指向最后一个<v的元素)即可此时左侧就全是<v的,右侧就全是>=v的。
最终只需在所有<v(arr[l...lt-1])和所有>v(arr[gt...r])的区间再递归进行快速排序,中间所有与v相等的元素全都在本轮分区中到达了最终位置。
3.代码实现
public static void quickSort3(int[] arr){
quickSortInternal3(arr,0,arr.length-1);
}
private static void quickSortInternal3(int[] arr, int l, int r) {
if(r-l<=15){
insertionSort(arr,l,r);
return;
}
int randomIndex=random.nextInt(l,r);
swap(arr, l, randomIndex);
int v=arr[l];//分区元素
//变量的取值要满足区间定义,最开始的时候,所有区间取值得是空的
//arr[l+1...lt]<v
//lt指向最后一个<v的元素
//lt是向后++的
int lt=l;
//arr[lt+1,i)==v
int i=lt+1;
//arr[gt,r]>v
//gt是第一个>v的元素(gt是左移--的)
int gt=r+1;
//i从前向后扫描直到与gt重合时,所有元素就处理完毕了
while(i<gt){
if(arr[i]<v){
swap(arr,i,lt+1);
i++;
lt++;
}else if(arr[i]>v){
swap(arr,i,gt-1);
gt--;
//此处i不++后移,因为换过来的gt-1对应元素没有处理,不知大小
}else{
i++;
}
}
//循环结束,lt落在最后一个<v的索引处,交换lt与l索引位置的值,让v到lt索引处
swap(arr,l,lt);
//此时arr[l...lt-1]<v,在这个区间继续快排
quickSortInternal3(arr,l,lt-1);
//arr[gt...r]>v,在这个区间继续快排
quickSortInternal3(arr,gt,r);
}
private static void insertionSort(int[] arr, int l, int r) {
for (int i = l+1; i <=r ; i++) {
for (int j = i; j >l&&arr[j]<arr[j-1]; j--) {
swap(arr,j,j-1);
}
}
}