必学算法之快速排序及其改进方案
算法名称:快速排序
要求掌握程度:熟练掌握
代码链接:quicksort.cc
前言
快速排序作为经典排序算法之一,基本上是面试必问题,手写快排和改进快排也是老生长谈的问题,故此,笔者笨鸟先飞,在面试官问题到来之前先掌握,面试的时候就可以跟面试官吹NewBee了(bushi)。
废话少说,直接发车!
基础快速排序算法<也称朴素快速排序>
算法介绍
快速排序是一种基于分治思想的高效排序算法,将序列分成两个较大和较小的子序列,然后递归的排序两个子序列。
基本算法流程如下:
- 挑选基准值(pivot):从数组中挑选出一个元素<一般是最左元素或者最右元素>,称为“基准”或者“哨兵”。
- 根据基准分割数组:将数组分割成两个子序列,所有比基准值小的元素放在基准值的左边,所有比基准值大的元素放在基准值的右边。
- 递归排序子序列:递归调用过程1、2,将小于基准值元素的子序列和大于基准值元素的子序列排序。
为了方便大家理解,下面画出一个简单的示意图:
重复进行上面的操作,直到左右两边都是有序的序列,整个排序过程就完成了。在分治的思想下,选择最左元素为基准点,序列每次都被拆分成两个子序列,直到不可拆分为止。
时间复杂度
快速排序是在冒泡排序的基础上改进而来的,冒泡排序每次只能交换相邻的两个元素,而快速排序是跳跃式的交换,交换的距离很大,因此总的比较和交换次数少了很多,速度也快了不少。
但是快速排序在最坏情况下的时间复杂度和冒泡排序一样,是 O(n2)
,实际上每次比较都需要交换,但是这种情况并不常见。我们可以思考一下如果每次比较都需要交换,那么数列的平均时间复杂度是 O(nlogn)
。
空间复杂度
快速排序只是使用数组原本的空间进行排序,所以所占用的空间应该是常量级的,但是由于每次划分之后是递归调用,所以递归调用在运行的过程中会消耗一定的空间,在一般情况下的空间复杂度为 O(logn)
,在最差的情况下,若每次只完成了一个元素,那么空间复杂度为 O(n)
。所以我们一般认为快速排序的空间复杂度为 O(logn)
。
算法稳定性
快速排序是一个不稳定的算法,在经过排序之后,可能会对相同值的元素的相对位置造成改变。
实现代码
// 快速排序基础版本
void quicksort_0(vector<int> &arr, int left, int right)
{
if (left >= right)
return;
// 1. 选择pivot, 这里选最左边
int pivot = arr[left];
// 2. 划分子序列
int i = left;
int j = right;
while (i < j)
{
while (i < j && arr[j] > pivot)
--j;
while (i < j && arr[i] <= pivot)
++i;
if (i < j)
swap(arr[i], arr[j]);
}
// 3. 基准复位
swap(arr[left], arr[i]);
// 4. 递归划分子序列
quicksort_0(arr, left, i - 1);
quicksort_0(arr, i + 1, right);
}
优化快速排序算法
1. 随机基准法
基本的快速排序选取第一个或者最后一个元素作为基准。这样在数组已经有序的情况下,每次划分将得到最坏的结果,时间复杂度为O(n^2)
。一种比较常见的优化方法是随机化算法,即随机选取一个元素作为基准。这种情况下虽然最坏情况仍然是O(n^2)
,但最坏情况不再依赖于输入数据,而是由于随机函数取值不佳。实际上,随机化快速排序得到理论最坏情况的可能性仅为1/(2n)
。所以随机化快速排序可以对于绝大多数输入数据达到O(nlogn)
的期望时间复杂度。
引入原因:在待排序列是部分有序时,固定选取枢轴使快排效率底下,要缓解这种情况,就引入了随机选取枢轴。<划重点,这是可以吹的!!!>
代码
// 快速排序随机基准改进
void quicksort_1(vector<int> &arr, int left, int right)
{
if (left >= right)
return;
// 1. 选择pivot, 这里随机选择
srand(time(NULL));
int idx = rand() % (right - left) + left;
swap(arr[left], arr[idx]);
int pivot = arr[left];
// 2. 划分子序列
int i = left;
int j = right;
while (i < j)
{
while (i < j && arr[j] > pivot)
--j;
while (i < j && arr[i] <= pivot)
++i;
if (i < j)
swap(arr[i], arr[j]);
}
// 3. 基准复位
swap(arr[left], arr[i]);
// 4. 递归划分子序列
quicksort_1(arr, left, i - 1);
quicksort_1(arr, i + 1, right);
}
2. 三数取中法,选取基准元
取序列中第一个数,最后一个数,第(N/2)个数即中间数三个数的中位数作为基准值。举个例子,对于int a[] = { 2,5,4,9,3,6,8,7,1,0};,‘2’、‘3’、‘0’,
分别是第一个数,第(N/2)
个是数以及最后一个数,三个数中3最大,0最小,2在中间,所以取2为基准值。
引入原因:虽然随机选取枢轴能够减少出现不好分割的几率,但是还是最坏情况下还是O(n^2),要缓解这种情况,就引入了三数取中选取枢轴。最佳的划分是将待排序的序列分成等长的子序列,最佳的状态我们可以使用序列的中间的值,也就是第N/2个数。可是,这很难算出来,并且会明显减慢快速排序的速度。这样的中值的估计可以通过随机选取三个元素并用它们的中值作为枢纽元而得到。事实上,随机性并没有多大的帮助,因此一般的做法是使用左端、右端和中心位置上的三个元素的中值作为枢纽元。显然使用三数中值分割法能够有效应对预排序输入的不好情形,并且减少快排的比较次数。
代码
// 快速排序随机三元取中改进
void quicksort_2(vector<int> &arr, int left, int right)
{
if (left >= right)
return;
// 1. 选择pivot, 这里采用三元取中方法选取
int mid = left + (right - left) / 2; // 数组中间元素的下标
if (arr[left] > arr[right]) // 保证左端较小
swap(arr[left], arr[right]);
if (arr[mid] > arr[right]) // 保证中间较小
swap(arr[mid], arr[right]);
if (arr[mid] > arr[left]) // 保证左端最小
swap(arr[left], arr[mid]);
// 此时arr[left]已经为整个序列左中右三个关键字的中间值
int pivot = arr[left];
// 2. 划分子序列
int i = left;
int j = right;
while (i < j)
{
while (i < j && arr[j] > pivot)
--j;
while (i < j && arr[i] <= pivot)
++i;
if (i < j)
swap(arr[i], arr[j]);
}
// 3. 基准复位
swap(arr[left], arr[i]);
// 4. 递归划分子序列
quicksort_2(arr, left, i - 1);
quicksort_2(arr, i + 1, right);
}
3.插入排序优化
当待排序序列的长度分割到一定大小后,使用插入排序替换快速排序,这是因为,对于很小和部分有序的数组,快排不如插排好。当待排序序列的长度分割到一定大小后,继续分割的效率比插入排序要差,此时可以使用插排而不是快排。
对于一定长度的选取:
待排序序列长度N = 10,虽然在5~20之间任一截止范围都有可能产生类似的结果,这种做法也避免了一些有害的退化情形。
摘自《数据结构与算法分析》Mark Allen Weiness 著
代码
在三数取中法的基础上使用插入排序优化算法。
// 快速排序随机三元取中+插入排序改进
void quicksort_3(vector<int> &arr, int left, int right)
{
// 插入排序优化
if (right - left + 1 < 10)
{
for (int i = left; i <= right; i++)
{
int key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j] > key)
{
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
return;
}
// 1. 选择pivot, 这里采用三元取中方法选取
int mid = left + (right - left) / 2; // 数组中间元素的下标
if (arr[left] > arr[right]) // 保证左端较小
swap(arr[left], arr[right]);
if (arr[mid] > arr[right]) // 保证中间较小
swap(arr[mid], arr[right]);
if (arr[mid] > arr[left]) // 保证左端最小
swap(arr[left], arr[mid]);
// 此时arr[left]已经为整个序列左中右三个关键字的中间值
int pivot = arr[left];
// 2. 划分子序列
int i = left;
int j = right;
while (i < j)
{
while (i < j && arr[j] > pivot)
--j;
while (i < j && arr[i] <= pivot)
++i;
if (i < j)
swap(arr[i], arr[j]);
}
// 3. 基准复位
swap(arr[left], arr[i]);
// 4. 递归划分子序列
quicksort_3(arr, left, i - 1);
quicksort_3(arr, i + 1, right);
}
4.双路快排
基准选的好,对于有序不相同的序列,能起到一定的优化作用,但是如果序列中存在大量重复元素,快速排序的性能依旧会退化到O(n^2)
,这是由于左右两个子序列不平衡造成的,为了解决这个问题,我们可以人为让==pivot
的数平均分在左右子序列中,使得左右子序列数量大致相同,从而稳定排序算法的时间复杂度到O(nlogn)
。
具体分析请参考:快速排序优化之——双路快速排序(C++)
代码
// 快速排序随机三元取中+插入排序+双路快排改进
void quicksort_4(vector<int> &arr, int left, int right)
{
// 插入排序优化
if (right - left + 1 < 10)
{
for (int i = left; i <= right; i++)
{
int key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j] > key)
{
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
return;
}
// 1. 选择pivot, 这里采用三元取中方法选取
int mid = left + (right - left) / 2; // 数组中间元素的下标
if (arr[left] > arr[right]) // 保证左端较小
swap(arr[left], arr[right]);
if (arr[mid] > arr[right]) // 保证中间较小
swap(arr[mid], arr[right]);
if (arr[mid] > arr[left]) // 保证左端最小
swap(arr[left], arr[mid]);
// 此时arr[left]已经为整个序列左中右三个关键字的中间值
int pivot = arr[left];
// 2. 划分子序列
int i = left + 1;
int j = right;
while (i <= j)
{
while (i <= j && arr[j] > pivot)
--j;
while (i <= j && arr[i] < pivot)
++i;
if (i <= j)
{
swap(arr[i], arr[j]);
--j;
++i;
}
}
// 3. 基准复位
// Note: 这里要注意交换的位置,如果是从右往左搜索,最终停留位置在 i, swap(arr[left], arr[i]);
// 如果是从左往右搜索,最终停留位置在 j, swap(arr[left], arr[j]);
// 否则会陷入死循环
swap(arr[left], arr[i]);
// 4. 递归划分子序列
quicksort_4(arr, left, i - 1);
quicksort_4(arr, i + 1, right);
}
5.三路快排
二路快排对于相同元素较多的时候,性能依旧会退化,为了更好地解决这个问题,三路快排被提出。
三路快排的核心思想在于,将数组分为三部分:
-
小于中轴值部分
[left,l-1]
-
等于中轴值部分
[l,r]
-
大于中轴值部分
[r+1,right]
i
指针从左往右遍历
-
如果
arr[i]
小于pivot
的值,就交换arr[l]
和arr[i]
,i
和l
同时向右移动 -
如果
arr[i]
等于pivot
的值,i
往右移动; -
如果
arr[i]
大于pivot
的值,就交换arr[r]
和arr[i]
,r
向左移动,i
不动
因为i跟r交换后,还需要再比较交换后的数,所以i不能动
这样遍历结束时
l
在第一个等于中轴值位置上r
在最后一个等于中轴值位置上i
在一个等于中轴值位置的后一个,即i = r + 1
代码
// 快速排序随机三元取中+插入排序+三路快排改进
void quicksort_5(vector<int> &arr, int left, int right)
{
// 插入排序优化
if (right - left + 1 < 10)
{
for (int i = left; i <= right; i++)
{
int key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j] > key)
{
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
return;
}
// 1. 选择pivot, 这里采用三元取中方法选取
int mid = left + (right - left) / 2; // 数组中间元素的下标
if (arr[left] > arr[right]) // 保证左端较小
swap(arr[left], arr[right]);
if (arr[mid] > arr[right]) // 保证中间较小
swap(arr[mid], arr[right]);
if (arr[mid] > arr[left]) // 保证左端最小
swap(arr[left], arr[mid]);
// 此时arr[left]已经为整个序列左中右三个关键字的中间值
int pivot = arr[left];
// 2. 划分子序列
int l = left, r = right;
int i = left + 1;
while (i <= r)
{
if (arr[i] < pivot) // 跟==v的第一个交换, 维持l为==v的入口位置
swap(arr[l++], arr[i++]);
else if (arr[i] == pivot)
++i;
else if (arr[i] > pivot) // 维持r为==v的出口位置
swap(arr[i], arr[r--]);
}
// 3. 递归划分子序列
quicksort_5(arr, left, l - 1);
quicksort_5(arr, r + 1, right);
}
总结
快速排序算法是一种不稳定的分而治之的排序算法,时间复杂度为O(nlogn)
,空间复杂度为O(logn)
。虽然有各种改进方法,但是即使是三路快排,时间复杂度依旧无法向堆排序那样稳定在O(logn)
的时间复杂度,因此,我们选择排序算法的时候应该综合考虑。