快速排序之种种:参考必须简化,评判必须单纯

Posted Debroon

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了快速排序之种种:参考必须简化,评判必须单纯相关的知识,希望对你有一定的参考价值。

 


原理

排序

在评判“最好”之前,我们还是要加一些限制条件,比如是一般情况下最好,还是恶劣的情况下最好。

快速排序算法相当于设置标准,把客户分为超级客户、VIP客户、一般客户,进行不同的服务,创造更高的价值。对员工的能力进行分级,重赏头部员工更利于公司发展。

首先,对于一大堆无序的数字,从中随机挑选一个,比如是 53,这个被随机选上的数字被称为枢值(枢纽的枢),接下来,将所有要排序的数字分成两部分,第一部分是大于等于枢值 53 的,第二部分是小于枢值 53 的。

在第一步完成后,一大堆无序的数字就变得稍微有序一点了。

第二步,从上面得到的两堆数字,分别采用第一步的方法各自再找一个枢值。

对于第一堆,由于所有的数字都比 53 大,至少也等于 53,因此,第二次随机挑选的枢值肯定是一个大于 53 的数字,比如 79;类似地,对于第二堆,由于所有的数字都小于 53,因此第二次随机挑选的枢值肯定小于它,比如4。

接下来,再把两堆数字各自分成大于等于相应枢值的数字序列,以及小于枢值的数字序列。这样做下来,原来的一大堆数就变成了四小堆,它们分别是小于 4 的数字,介于 4 到 53 之间的,介于 53 到 79 之间的,以及大于或等于 79 的。

再接下来,用同样的方法,四堆变八堆,八堆变十六堆,很快所有的数字就排好序了。

······

计算机中快速排序的算法也在强调少做事情,将一大堆数字分成几小堆,再从中不断挑选枢值做对比。

社会上为什么要将人分为三六九等而不仅仅是一律平等?

一个社会的管理,要想效率高,最简单的办法就是对每一个人作一些区分,而效率最低的办法就是刻意追求所有人一律平等,不作区分。

如重点学校入学都有门槛,是为了控制学生大体相近的成绩水平,方便老师教学,也保持学校整体不差的成绩。

  • 当一个学校的学生水平都比较接近,老师教起来就容易,因此按照成绩对学生作一个初步的划分是有道理的,特别是在资源不足的情况下。

  • 如果一个学校的学生从100分的到0分的都有,那么老师教起来就困难了,如果想达到前面同样的效果,就必须多投入资源。

从效率上讲,层级就如同快速排序事先划定的枢值,有了三六九等,这样才有效率可言。

人与人之间为什么要各种比较,就是为了要让社会以最快的速度识别,发现你。

从这个角度讲,贴标签也并不是什么坏事。有能力的,让人贴上优秀的标签,而不是糟糕的标签。
 


partition

选定一个参考点,使得左边元素都小于它,右边元素都大于它,这个过程就叫 partition


整个快速排序的核心区别,就是 partition 这个函数的区别。

p = partition(arr, left, right);
// p 是 参考点的下标

大致框架:

void QuickSort(int arr[], int l, int r) {
	if( l >= r ) return;
	int p = partition(arr, l, r);  // p 是 参考点的下标
	QuickSort(arr, l, p-1);        // 小于 p
	QuickSort(arr, p+1, r);        // 大于 p
}

关键是 partition 这个函数怎么实现?

简单起见,我们把第一个元素当作参考点,而后参考点左侧都小于它,右侧都大于它。

不使用额外空间,我们要让数组分段:

  • 橙色区间:arr[l+1, j] < v
  • 紫色区间:arr[j+1, i-1] > v

如果新元素 e > 参考点 v,放入 紫色区间段 > v,i ++,看下一个元素。


如果新元素 e < 参考点 v,放入 橙色区间段 < v,swap(e,arr[j+1]),i++,j++,看下一个。


遍历完成后:


此时,再 swap(arr[l], arr[j]):


初始状态(注意索引 i、j 的位置):

  • j 的作用是,维护橙色区间:arr[l+1, j] < v
  • i 的作用是,维护紫色区间:arr[j+1, i-1] > v

代码实现:

void QuickSort(int arr[], int l, int r) {
	if( l >= r ) return;
	p = partition(arr, l, r);  // p 是 参考点的下标
	QuickSort(arr, l, p-1);    // 小于 p
	QuickSort(arr, p+1, r);    // 大于 p
}

int partition(int arr[], int l, int r) {
	int j = l;
	for( int i=l+1; i<=r; i++)
		if( arr[i] == arr[j] )
			j++, swap(arr, i, j);
	swap(arr, l, j);           // 把最左侧参考点放到中间
	return j;
}

 


优化

插入排序优化

使用插入排序优化的原理:对于小规模的数组,使用插入排序更快。

在快速排序的过程中,在最底部就是很多个小数组 — 把零个、一个元素的区间直接返回变成对小到一定程度的区间,转而使用插入排序。

void QuickSort(int arr[], int l, int r) {
	if( r - l <= 15 ) {             // 只有 15 个元素以内时
		InsertionSort(arr, l, r);   // 使用插入排序
		return;                     // 一定要 return
	}
	
	p = partition(arr, l, r);       // p 是 参考点的下标
	QuickSort(arr, l, p-1);         // 小于 p
	QuickSort(arr, p+1, r);         // 大于 p
}

int partition(int arr[], int l, int r) {
	int j = l;
	for( int i=l+1; i<=r; i++)
		if( arr[i] == arr[j] )
			j++, swap(arr, i, j);
	swap(arr, l, j);                // 把最左侧参考点放到中间
	return j;
}

 


随机快速排序

第一版快速排序的问题,在完全有序的数组情况下,快速排序复杂度变成 O ( n 2 ) O(n^{2}) O(n2)

  • 1、2、3、4、5、6、7、8、9、···

因为第一版快速排序,我们选择的参考点是第一个元素,那后面的元素都比参考点大,那 partition 过程:

每次 partition 过程都是:

  • 左边为空
  • 右边为少一个元素的数组

在完全有序的数组情况下,快速排序复杂度变成 O ( n 2 ) O(n^{2}) O(n2)

如何解决呢?我们不能固定以最左边的元素为参考点,我们取最中间的值。

也不可行,只要你选取标定点的方式是固定的,我们就能找到一组极端的测试用例,使得快速排序算法 100% 退化成 O ( n 2 ) O(n^2) O(n2) 的算法。

所以,我们应该随机选择一个参考点,打破有序数组的局面。如:

随机选择了一个参考点 6,和第一个元素交换位置。

  • 最左边元素索引: l l l

  • 最右边元素索引: r r r

  • 目标:生成 [ l ,   r ] [l,~r] [l, r] 之间的随机值

// 生成 low ~ high 之间的随机数公式 : rand()%(high-low+1)+low
int p = rand() % (r - l + 1) + l;

完整代码:

void QuickSort(int arr[], int l, int r) {
	if( r - l <= 15 ) {                // 只有 15 个元素以内时
		InsertionSort(arr, l, r);      // 使用插入排序
		return;                        // 一定要 return
	}
	
	p = partition(arr, l, r);          // p 是 参考点的下标
	QuickSort(arr, l, p-1);            // 小于 p
	QuickSort(arr, p+1, r);            // 大于 p
}

int partition(int arr[], int l, int r) {
	int p = rand() % (r - l + 1) + l;       // 生成 low ~ high 之间的随机数
	swap(arr[l], arr[p]);                   // 选用随机参考点

	int j = l;
	for( int i=l+1; i<=r; i++)
		if( arr[i] == arr[j] )
			j++, swap(arr[i], arr[j]);
	swap(arr[l], arr[j]);                   // 把最左侧参考点放到中间
	return j;
}

随机选择,避免了固定选择的退化情况。
 


双路快速排序

其实随机快速排序也有问题,当所有元素都相同时,那 partition 过程(如果等于放左边):

  • 左边为少一个元素的数组
  • 右边为空

为了解决这个问题,我们需要调整 partition 过程:


之前俩个区间我们是放在一起的,现在我们分开放。

partition 过程,俩个哨兵 i、j,i++

  • 如果新元素 e <= v,i ++,i 继续往后走。

  • 如果新元素 e >= v,i 不动,j--

  • 如果新元素 e >= v,j ++,j 继续往前走。
  • 如果新元素 e <= v,j 不动。

现在的情况:

swap(arr[i], arr[j]) 即可,而后 i++,j–,继续下一轮循环。


为了解决之前数组元素全部相同的问题,我们的交换条件都是附加了 =,无论是 >=,还是 <=


哪怕遇到全部相同的元素,partition 过程也会划分到不同的俩侧,而不是单侧。

把划分数组的区间放在俩端,而不是一端,这种方法叫双路快速排序。

void QuickSort(int arr[], int l, int r) {
	if( r - l <= 15 ) {                      // 只有 15 个元素以内时
		InsertionSort(arr, l, r);            // 使用插入排序
		return;                              // 一定要 return
	}
	
	int p = partition(arr, l, r);            // p 是 参考点的下标
	QuickSort(arr, l, p-1);                  // 小于 p
	QuickSort(arr, p+1, r);                  // 大于 p
}

int partition(int arr[], int l, int r) {
	int p = rand() % (r - l + 1) + l;        // 生成 low ~ high 之间的随机数
	swap(arr[l], arr[p]);                    // 选用随机参考点

	int i = l+1, j = r;
	while( true ){                           // 不能改成 i<j,arr=[0, 2, 3, 1]通不过
		while( i <= j && arr[i] < arr[l] )   // i值找到大于 p 的位置
			i ++;                            // 右移 i
		while( j >= i && arr[j] > arr[l] )   // j值找到小于 p 的位置
			j --;                            // 左移 j
		if( i >= j )                         // 如果 i>=j 退出
			break;                           // 退出
		swap(arr[i], arr[j]);                // 不然,交换
		i ++, j --;                          // 继续循环
	}
	swap(arr[l], arr[j]);                    // 把最左侧参考点放到中间
	return j;
}

通常一个算法的复杂度,我们是分析最坏复杂度,但引入随机后,我们更多的是看数学期望 O ( n l o g n ) O(nlogn) O(nlogn)
 


三路快速排序

双路快速排序,面对全部相同元素时,解决了一边为空,一边所有元素的情况,避免时间复杂度退化。

但双路快速排序,面对全部相同元素时,虽然把参考点放到中间,但左右俩边都是相同元素。

根据算法逻辑,会对所有元素继续各种操作,其实这种情况是可以跳过的,避免重复操作,三路快速排序就解决这个问题。


按图所示,对于等于 v 的部分,不用管了,只需要递归的对小于 v、大于 v 的部分即可。


我们用索引分别指代划分好各部分,再看算法逻辑。

  • 如果新元素 e == v,i++
  • 如果新元素 e < v,swap(arr[lt+1], arr[i]),it++,i++
  • 如果新元素 e > v,swap(arr[gt-1], arr[i]),gt–,i++

循环结束时( i == gt ):


我们再 swap(arr[v], arr[lt]) 即可:


接着:

  • QuickSort(arr, l, lt-1)
  • QuickSort(arr, gt, r)

中间部份,就不重复操作了。

现在面对数组全部相同元素,那么时间复杂度是 O ( n ) O(n) O(n),只是运行了一次 partition 过程。

void partition(int arr[], int l, int r) {
	if( l >= r ) return;
	int p = rand() % (r - l + 1) + l;        // 生成 low ~ high 之间的随机数
	swap(arr[l], arr[p]);                    // 选用随机参考点

	 /* 定义清楚循环不变量, 才能写清楚边界:
      	arr[l+1, lt] < v
      	arr[lt+1, i-1] == v
      	arr[gt, r] > v
	 */
	int lt = l, i = l+1, gt = r+1;
	while( i < gt ) {
		if( arr[i] < arr[l] )
	   		lt ++, swap(arr[i], arr[lt]), i++;
  		else if( arr[i] > arr[l] )
   	   		gt --, swap(arr[i], arr[gt]);
  		else
   	   		i ++;  
 	}
 
 	swap(arr[l], arr[lt]);                   // 把最左侧参考点放到中间
	partition(arr, l, lt-1);
	partition(arr, gt, r);
}

最快的快速排序,三路快速排序。
 


游戏

 


75. Sort Colors

题目链接:https://leetcode-cn.com/problems/sort-colors/

我们可以使用 三路快速排序 的 partition 思想解决这个问题。

因为,整个数组中只有 0、1、2 三种元素。

如果我们把 1 当做参考点,0 就是小于参考点的部分,2 就是大于参考点的部分。

只需要运行一遍三路快速排序的 partition 就得解了。

​循环不变量的定义:

  • nums[0…zero] == 0
  • nums[zero + 1, i - 1] == 1
  • nums[two, n - 1] == 2

变量 zero 相当于 lt,two 相当于 gt。

class Solution {
public:
   void sortColors(vector<int>& nums) {
       // 循环不变量:nums[0...zero] == 0, nums[zero + 1, i] == 1, nums[two, n - 1] == 2
       int zero = -1, i = 0, two = nums.size();
       while(i < two) {
           if(nums[i] == 0) {
               zero ++;
               swap(nums, zero, i);
               i ++;
           } else if (nums[i] == 2) {
               two --;
               swap(nums, i, two);
           } else                            // nums[i] == 0
               i ++;
       }
   }

   void swap(vector<int>& nums, int i, int j) {
       int t = nums[i];
       nums[i]= nums[j];
       nums[j] = t;
   }
};

 


Select K

题目:给出一个无序数组,找出数组的第 K 小的元素。

思路一:直接快排,时间复杂度 O ( n l o n g n ) O(nlongn) O(nlongn)

思路二:partition,时间复杂度 O ( n ) O(n) O(n)

因为 partition 一次后,就已经排好序了。


参考点 p 已经就位(虽然左右俩边还未排好序),是数组的第 p 小元素。

  • 如果 p == k,找到了
  • 如果 p < k,到左边找,右边部分不需要管了
  • 如果 p > k,到右边找,左边部分不需要管了
	
	// 封装 partition
    int partition(vector<int>& arr, int l, int r) {
        // 生成 [l, r] 之间的随机索引
        int p = rand() % (r - l + 1) + l;
        swap(arr[l], arr[p]);

        // arr[l+1...i-1] <= v; arr[j+1...r] >= v
        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[i

以上是关于快速排序之种种:参考必须简化,评判必须单纯的主要内容,如果未能解决你的问题,请参考以下文章

iOS书写高质量代码之耦合的处理

vs2003:快速片段工具

iOS书写高质量代码之耦合的处理 干货!

数据结构和算法之排序二:快速排序

算法之快速排序(递归实现)

学编程必须了解的排序算法——快速排序