写在前面
本人最近准备复试,在刷leetcode过程中遇到了类似于荷兰国旗的问题,于是特意回看了一下快速排序算法,也加深了对其的理解。作为一种feedback的方式,记录一篇笔记总结一下对快排的认识。
目录
- 概述
- 基本算法
- 算法改进
- 例题
概述
快速排序是一种基于分治思想的排序算法。它之所以流行的原因有二:一是原地排序,且长度为N的数组排序所需时间与NlgN成正比;二是其内循环比多数排序算法都小。之所以仅有快速排序被称为“快速”,是因为多数排序算法的基本操作次数执行次数的多项式最高次项为X✖️nlog2n(X为系数),快速排序的X最小。这使得快速排序在同级别算法中最好,因此称为快速排序。另外,快速排序的平均时间复杂度为O(nlog2n),空间复杂度O(log2n)。
基本算法
想要理解快速排序,重中之重在于理解切分(paititon)过程。而要理解切分过程,学会使用循环不变量设计扫描指针是绕不开的关卡。文章后面会谈到此概念在快速排序中的应用。
先将传入的数组划分为两个子数组,将两部分独立排序,之后递归调用处理整个数组。
void quickSort(int nums[], int left, int right){
if(left >= right) //递归结束条件
return;
//切分,返回切分元素存放位置
int i = partition(nums, left, right);
//递归调用排序左半部分[left...i-1]
quickSort(nums, left, i - 1);
//递归调用排序右半部分[i+1...right]
quickSort(nums, i + 1, right);
}
接下来到了方法的关键——切分。该过程数组满足下面三个条件:
- 对于某个i,nums[i]已经排序完毕
- 左半部分[left…i-1]所有元素均不大于nums[i]
- 右半部分[i+1…right]所有元素均不小于nums[i]
过程如下:一般选取nums[left]作为切分元素(或称枢轴),即它是那个chosen one!之后,从数组左端开始扫描直到遇见大于它的元素,再从数组右端开始扫描直到遇见小于它的元素。这两个元素显然并未排序完毕,所以将其两个交换。如此循环,直到左右扫描指针相遇彼此,则此时我们会发现左半部分[left…i-1]所有元素均不大于nums[i]且右半部分[i+1…right]所有元素均不小于nums[i]。即,此时该相遇位置就是切分元素nums[left]的最终位置。
简易切分示意图如下:
切分(paititon)算法实现过程如下:
int partition(int nums[], int left, int right){
int temp = nums[left]; //切分元素(或称枢轴),一般选取输入数组左端值
int lt = left, rt = right + 1; //左右扫描指针,此处指针的设计会影响后面"检查大小"与"扫描"这两操作的先后顺序
//注意:[left...lt) , [rt...right]划分的两数组初始为空
// 许多常见排序算法的扫描指针的设计都依据循环不变量这一概念
while(1){
while(nums[++lt] < temp) //先扫描后检查大小,向左扫描直到遇见大于temp的值
if(lt == right)
break;
while(temp < nums[--rt]) //先扫描后检查大小,向右扫描直到遇见小于temp的值
if(rt == left)
break;
if(lt >= rt) //当左右扫描指针相遇标志着扫描结束,切分元素位置已找出
break;
swap(nums[lt], nums[rt]); //交换元素
}
swap(nums[left], nums[rt]); //将切分元素放置在左右指针相遇位置,返回位置索引
return rt;
}
其中,要注意左右扫描指针的设计,这里涉及到循环不变量的概念,这个概念尤其会应用在后续算法改进中的指针设计。事实上,许多常见的排序算法都要用到循环不变量,平时要多总结,便于深入理解排序算法过程。
为了加深对循环不变量的理解,在给出另一种扫描指针的设计。
void quickSortSecond(int nums[], int left, int right){ //从左向右,由小到大排序
int temp; //枢轴
int i = left, j = right; //左指针i,右指针j [left...i) , (j...right]两数组起始为空
//注意区间开闭情况,这会使后续操作顺序变为先"检查大小"后"扫描"
if(left < right){ //递归终止条件
temp = nums[left]; //枢轴选取数组的左边界
while(i < j){
while(j > i && nums[j] >= temp) //j向左扫描,遇到比temp值大的值就继续向左扫描
j--;
if(i < j) //当向左扫描遇到的值小于temp时,将该值赋值到左侧索引i位置
nums[i++] = nums[j]; //并且i前进到下一个索引位置,等待扫描开始
while(i < j && nums[i] < temp) //i向右扫描,遇到比temp值小的值就继续向右扫描
i++;
if(i < j) //当向左扫描遇到的值大于temp时,将该值复制到右侧索引j位置
nums[j--] = nums[i]; //并且j前进到下一个索引位置,等待扫描开始
}
nums[i] = temp; //i,j最终相遇位置即为枢轴存放位置
//下一趟排序开始
quickSortSecond(nums, left, i - 1); //左
quickSortSecond(nums, i + 1, right); //右
}
}
此时,区间变为[left…i)和(j…right],切分过程变为先检查大小后扫描。
算法改进
如果排序代码多次执行或者用于大型数组,那么上面的基本算法可能不再适用。改进方法有多种,具体理论可以去阅读算法(第四版),经典中的经典!接下来介绍一种改进策略,也是由荷兰国旗问题引出的三路快排算法。
思路如下:将数组切分成三部分,即分别小于、等于和大于切分元素的子数组(对应荷兰国旗的三种颜色)。设计出三个指针,即lt,rt,mt。并且一般将数组左端元素作为切分元素temp。而后,从左到右扫描数组,使得
[left…lt) < temp (gt…right] > temp [lt…mt) == temp [mt…gt]等待排序
说明:循环不变量设计核心:代码执行过程中始终保持区间全覆盖且其中无重复。(注:可以自己画一个数轴实操一下)
针对这一核心,解释上面的设计思路:
- lt为小于temp和等于temp的分界点。为了不重合,一个设计成开区间,另一个就必须是闭区间。即,[left…lt) < temp和[lt…mt) == temp
- mt为扫描指针,一般设计成开区间。即,[lt…mt) == temp
准确来说[left…mt)中的元素代表已遍历。 - gt为等待排序区间和大于temp区间分界点。同样为了不重合,一个设计成开区间,另一个就必须是闭区间。即,[mt…gt]等待排序和(gt…right] > temp。
另外注意,循环不变量设计思路不唯一,掌握核心即可随意设计。
接下来,为了将思路落实,我们就要考虑一个问题,如何初始化变量使得以上思路成立?因为这关系到后续算法的两大问题:
- 扫描过程中,“加减”和“交换”这两个操作的先后顺序
- 递归何时结束
为落实这三个问题,我们要说明一下切分过程。当指针mt扫描到某处时,遇到的情况无非三种:大于temp,小于temp,等于temp。所以针对这三种情况,我们有三步处理:
- nums[mt] == temp 加减:mt自增1;
- nums[mt] > temp 交换:nums[mt]与nums[rt]交换;加减:rt自减1;
- nums[mt] < temp 交换:nums[mt]与nums[lt]交换;加减:lt自增1,mt自增1;
其实我们已经注意到“加减”和“交换”两个操作的顺序要根据指针变量的初始化而决定。至于递归何时结束?如果你理解了基本算法思路和循环不变量设计思路,不难发现当指针mt与指针rt相遇时,遍历结束。附上一个三路快排的切分示意图:
现在,让我们回到如何初始化指针变量的问题上来。在基本算法部分我们已经注意到,初始数组一定为空。也就是说,要设计lt,rt指针使得三部分数组在一开始为空。而后,“加减”和“交换”两步操作就看你的初始值如何定义了。以下是算法过程:(注意:此过程不唯一,要根据你设计的循环不变量而定)
void quickThirdSort(int arr[], int left, int right){
int temp = arr[left];
int lt = left, rt = right, mt = left + 1; //[left...lt)<temp, [lt...mt]==temp, (rt...right]>temp
if(left < right) { //判定合法性
while(mt <= rt){ //递归终止条件
if(arr[mt] == temp)
mt++;
else if(arr[mt] > temp)
swap(arr[mt], arr[rt--]); //先交换再加减
else //arr[mt] < temp
swap(arr[mt++],arr[lt++]); //先交换再加减
}
//现在arr[left...lt) < temp = arr[lt...rt] < arr(rt...right]
//递归开始
quickThirdSort(arr, left, lt - 1);
quickThirdSort(arr, rt + 1, right);
}
else{
return;
}
}
例题
leetcode#75.颜色分类
题目描述:给定一个包含0、1和2,一共 n 个元素的数组,对它们进行原地排序,使得相同值元素相邻,并按照0、1、2顺序排列。
实例:
输入: [2,0,2,1,1,0] 输出: [0,0,1,1,2,2]
思路:
- 确定数组元素仅包含0、1、2三种值,由此可以采用三路快排方法求解问题;
- 取1作为切分元素,并设置zero,two两个边界指针,i为扫描指针。此处初始值的设计直接影响到循环边界(i <= two或i == two)以及“加减”和”交换“的操作顺序。其中一种循环自变量定义会在代码中给出。
- 在循环过程中扫描到某处,简述判断过程:
若等于1,非常简单,自增i就好; 若等于2,显然它属于“2”数组,这时要把它与指针two前(two—)到元素交换,让它归到“2”数组去。同时,我们也要自减two(注意:“加减”和”交换“的操作顺序由初始化变量决定!)这时我们会疑惑交换过来的数不一定为1呀!别着急,一步一步来。 现在对交换过来的元素进行判断。 若为1,前面已经告诉你啦! 若为0,稍微有点复杂。将zero前(zero++)的元素和该值交换,当然,zero自增1将这个元素收入到“0”数组。此外,我们发现zero前的元素本来就是1(因为zero是“0”数组和“1”数组的分界点),所以交换过来的元素理应是“1”数组的值,所以i自增。注意:这一步需要自增两个指针,而且顺序还是由循环自变量设计决定!
- 当i指针与two指针相遇时,循环结束。只需遍历一遍。无需递归
附上自制过程图:
算法:
void sortColors(vector<int>& nums) {
//时间复杂度O(n)
//空间复杂度(1)
//循环自变量定义:
//[0...zero] = 0
//[zero...i) = 1
//[i..two)待排序
//[two...n-1] = 2
int zero = -1; //nums[0...zero] == 0 初始数组为空
int two = nums.size(); //nums[two..n-1] == 2 初始数组为空
for (int i = 0; i < two; ) {
if(nums[i] == 1){
i++;
}
else if(nums[i] == 2){
swap(nums[i], nums[--two]);
}
else{ //nums[i] == 0
assert(nums[i] == 0);
swap(nums[++zero], nums[i++]);
// swap(nums[++zero], nums[i++]);
}
}
}
对本题有什么疑惑,可以去英文版leetcode的discuss区看看大佬们的解答,可能会启发你。
欢迎同志们指正和讨论,共同进步!
本人blog:http://breadhunter.gitee.io