有三种方法可实现快排:
单循环
方法一,单循环,会将所有与基准数相等元素扔到同一侧,造成递归树倾斜,由于递归开销很大,《算法4》也没有讲这个实现方法,故没有主动实现。现贴别人代码作为参考
void quickSort(int[] nums,int lo,int hi){
if(lo >= hi) return;
int mid = partition(nums,lo,hi);
quickSort(nums,lo,mid - 1);
quickSort(nums,mid + 1,hi);
}
int partition(int[] nums,int lo,int hi){
// 基准值
int pivot = nums[lo];
int lt = lo;
// 循环不变量:
// all in [lo + 1, lt] < pivot
// all in [lt + 1, i) >= pivot
for (int i = lo + 1; i <= hi; i++) {
if (nums[i] < pivot) {
lt++;
exch(nums, i, lt);
}
}
exch(nums, lo, lt);
return lt;
}
【图片】
双指针
方法2,双指针,用两个指针遍历序列,分别锁定序列中小于基准数的值
和大于基准数的值
,进行两两交换,直到两个指针相遇后,再将基准数交换到右指针处,这种方法使得与基准数相同的元素等概率的分布在序列两侧,使得递归树基本平衡。
void quickSort(int[] nums,int lo,int hi){
if(lo >= hi) return;
int mid = partition(nums,lo,hi);
//nums[mid]已经被排定了,只需要继续排序nums[lo,mid-1]和
//nums[mid+1,hi]的元素
quickSort(nums,lo,mid - 1);
quickSort(nums,mid + 1,hi);
}
int partition(int[] nums,int lo,int hi){
int lt = lo + 1, hi = gt;
// 基准值
int pivot = nums[lo];
int lt = lo;
// 循环不变量:
// all in [lo , lt) <= pivot
// all in (gt , hi] >= pivot
while(true){
while(lt < hi && nums[lt] < pivot)
lt++;
while(gt > lo && nums[gt] > pivot)
gt--;
if(lt >= gt)
break;
exch(nums,lt,gt);
lt++;
gt--;
}
exch(nums,lo,gt);
return gt;
}
【图片】
三向切分
方法3,三向切分,考虑了与基准数相等的元素,将它们挤在在数组的中部,使得左右待排序的子序列长度减少,减少了递归的深度。
而且另一个好处是它不用再写额外的切分函数。
Note:
- [lo…lt]内值小于pivot
- [lt…i]内值等于pivot
- [i…gt] 是尚未切分的部分
- [gt…hi]内值大于pivot
- 如果数组内没有重复值,最后遍历完毕应该会有lt == gt
private static void quickSort2(int[] nums, int lo, int hi) {
if(hi - lo >= MININUM_LEN){
insertionSort(nums,lo,hi);
return;
}
if(lo >= hi) return;
int ran = new Random().nextInt(hi - lo + 1) + lo;
exch(nums,ran,lo);
int lt = lo,gt = hi;
int i = lt + 1,pivot = nums[lo];
while(i <= gt){
if(nums[i] > pivot)
exch(nums,i,gt--);
else if(nums[i] < pivot)
exch(nums,i++,lt++);
else
i++;
}
quickSort2(nums,lo,lt-1);
quickSort2(nums,gt+1,hi);
}
【图片】
优化
快速排序最好的切分情况是,每次数组都能被对半分。时间复杂度约为 O ( N l o g 2 N ) O( Nlog_2N ) O(Nlog2N)
(个人认为是切分数组时的遍历占N,遍历递归树占了** l o g 2 N log_2N log2N**,相乘就是 N l o g 2 N Nlog_2N Nlog2N)
只从数组首部固定选取基准数在时间上很不稳定,如果每次选到的都是序列中的最值,那么每次切分后的左右序列都会有较大的长度差,导致更多的递归,最极端的情况就是去排序已经反向排好序的数组(待验证),这会使得快排沦为冒泡排序,时间复杂度恶化为O(n²)。因此固定基准数是不可取的,需要优化。
【这里需要用图片比较最坏情况和最坏情况的区别,比较直观】
随机基准数
一种方法是,从序列中随机获取一位数作为基准数,这样能够减少选到最差基准数的概率,但是最坏情况的时间消耗不变,仍旧是O(n²)
private int partition(int[] nums,int lo,int hi){
int randomIndex = new Random().nextInt(hi - lo) + lo;
exch(nums,randomIndex,lo);
int pivot = nums[lo];
[快排代码]
}
三数取中
另外的一种方法是,假定最优基准数在数组的N/2处,将其交换到左端作为pivot,这样也能降低取到最差基准数的概率(至少不是最差的那个),但在leetcode上我并没看到它比随机好到哪里去。。。
private static int partition(int[] nums,int lo,int hi){
// 三数取中法选基准数
int mid = lo + (hi - lo) / 2;
// 对调左右端
if(nums[lo] > nums[hi])
exch(nums,hi,lo);
// 确保左端比中间大
if(nums[lo] < nums[mid])
exch(nums,mid,hi);
// 再确保中间比右端小
if(nums[mid] > nums[hi])
exch(nums,mid,hi);
int pivot = nums[lo];
[快排代码]
}
分析
**时间复杂度:**最好情况是每次切分都能恰好将数组对半分(即找到排定数组中的中间值),此时复杂度为 O ( l o g N ) O(logN) O(logN), 最坏情况是每次切分都只能切到数组的边界端(即找到排定数组的最值),此时每次递归都只会将搜索范围-1,快排沦为冒泡排序,复杂度为 O ( N 2 ) O(N^2) O(N2)
**空间复杂度:**属于原地算法,故为 O ( 1 ) O(1) O(1)
稳定性: 三种实现方法都会改变相等元素的相对次序,故不稳定
初始次序:
移动次数有关:已排好序的数组不需要移动
比较次数无关:不管实现方式是单指针,双指针还是三指针,均要比较N次
时间复杂度有关:时间复杂度最好情况为 O ( l o g N ) O(logN) O(logN) 最坏情况为 O ( N 2 ) O(N^2) O(N2) ,故初始序列有关
排序趟数有关:切分函数的递归深度取决于快排的实现方法和切分数的值
与归并排序的异同
快速排序和归并排序都采用了分治的方法,不同的是快排的处理(切分)是在分解数组前处理的,而归并是在分离之后(合并排序), 此外快排没有合并排序这一环节。