前言
学习本章之前建议先学习二叉树、插入排序。堆排序、栈和队列,这里提供一篇介绍插入排序的文章进行参考
【C/C++】插入排序(详细注释)
快速排序的思想
任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
快速排序的特性
1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(logN)
4. 稳定性:不稳定
快速排序的实现(升序)
最简单的未经过优化的hoare法快速排序
// 未优化过的快排
void QuickSort_0(int* a, int left, int right) // 传参要求左右都是闭区间
{
if (left >= right) // 如果数组的最左边的下标等于最右边的下标,
return; // 则代表数组中只剩下一个数,不需要再递归划分
int keyi = left; // 默认最左边为标志位
int end = right; // 备份最右边的值的下标,因为后面直接使用函数形参进行操作
while (left < right) // 这个循环结束的时候是能保证left和right相遇的位置一定比标志位小的
{
// 从右边开始找,找到第一个比标志位小的数
while (a[right] >= a[keyi] && left < right) // 注意找的过程也要保证左指针小于右指针
{
right--;
}
// 从左边开始找,找到第一个比标志位大的数
while (a[left] < a[keyi] && left < right) // 注意找的过程也要保证左指针小于右指针
{
left++;
}
Swap(&a[left], &a[right]); // 经过上面的查找之后把找到的两个数进行交换
}
Swap(&a[keyi], &a[left]); // 把left和right相遇的位置的值和标志位的值进行交换
// 交换完之后达成标志位左边的值都比标志位小,右边的值都比标志位大
// 对标志位的左右区间进行递归,重复上方的操作
QuickSort_0(a, keyi, left - 1);
QuickSort_0(a, left + 1, end);
}
排序要做的就只是不断地把大的值丢到右边,小的值丢到左边
这个排序的执行过程非常像二叉树的前序遍历过程。
现在的这套快排效率还是比较低的,原因如下
1、现在每次的标志位都是取最左边的数,这个数有可能是该次排序的数组中的最小(大)值。这样的话右(左)指针找小(大)的话就不得不每次
都跑遍整个数组,这会导致时间复杂度直线上升。
2、就像二叉树一样,递归得越深,每层要走的节点数就越多。而且如果数组内的数据比较少,为了把这些数据做好排序要进行
递归排序的话又会很亏,比如说数组内有只有5个数据,就要递归6次,跑7次上面的主代码。
3、如果数组内的数据太多,递归层数就会很多,这就有可能会导致栈溢出。
为解决上述的种种问题,我们提出下面的解决方案
问题一:三数取中
引发第一个问题的主要原因是因为取到了数组中的最大或者最小的元素导致一边的指针每次都要跑很长一段距离导致时间复杂度大大提高。解决这个问题的方法一般有两个,一个是随机选一个数据作为标志位(keyi)的值,还有一个方法就是本次要介绍的三数取中的方法。
这个方法就是拿数组中的第一个数据和中间的一个数据和数组最后的一个数据进行大小比较,然后选这三个数中大小排中间的数据作为标志位的值(keyi),这也就保证了不会取到数组中最大或者最大的值作为标志位(keyi)的值。
实现如下:
int GetMidi(int* a, int left, int right) // 三数取中函数
{
int midi = (left + right) / 2; // 算出中间值的下标
if (a[left] < a[midi]) // 找到不是最大也不是最小的那个值
{
if (a[midi] < a[right])
return midi;
else if (a[left] < a[right])
return right;
else
return left;
}
else // a[left] > a[midi]
{
if (a[midi] > a[right])
return midi;
else if (a[left] < a[right])
return left;
else
return right;
}
}
void QuickSort(int* a, int left, int right)
{
if (left >= right) // 如果数组的最左边的下标等于最右边的下标,
return; // 则代表数组中只剩下一个数,不需要再递归划分
int midi = GetMidi(a, left, right); // 三数取中
Swap(&a[left], &a[midi]); // 选好标志位的值之后把值放回数组中的第一个位置方便后续的操作
int key = left;
int _left = left;
int _right = right;
while (left < right)
{
while (a[right] >= a[key] && left < right)
{
right--;
}
while (a[left] < a[key] && left < right)
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[key], &a[left]);
QuickSort(a, _left, left - 1);
QuickSort(a, left + 1, _right);
}
问题二:小区间优化
快排虽然很快,但是如果数组内的数据比较少的话,进行的操作和数据量比起来很不值当,比较少的数却要递归较多次。如果整个递归深度很深的话这个缺点更加明显,越到下面分出来的小数组就越多,进行的吃亏操作就越多。
他的递归抽象地看就像是上面三角形一样,越到下面,小数组占整个程序的比例就越高,这些小数组如果选择用其他方法来进行排序的话就能使程序加快很多,使用的空间也能有所下降。
那么下一个问题是,我们应该使用哪种排序方法进行小区间优化呢?
冒泡?选择?插入?希尔?堆排?还是别的什么?
可能有很多人觉得堆排比较快,所以用堆排会好一点,实际上堆排在排序之前还要对数组进行建堆,在这么小的数据量下这么操作有点过火了。
那是希尔吗?也不是,希尔是对插入的优化,他的预排序是让数组基本有序,对数据量比较大的数组效果才比较明显,更何况快排在前面的递归就已经让数组一定有序了,所以没必要用希尔排序。
冒泡和选择的效率又很低,所以答案很明显了,就是使用插入排序。并且实际上官方提供的快排函数也是这么设计的。
那么我们直接以上面的代码为基础来进行优化一下。
void InsertSort(int *a, int n) // a指针为需要进行排序的数组的指针,n为需要排序的数组的数据个数
{
for (int i = 0; i < n - 1; ++i) // 把每个数都和前面的序列进行比较、插入,这里i不能到达n-1是为了防止越界
{
int end = i;
int tmp = a[end + 1]; // 备份本次循环准备要进行插入的值
while (end >= 0)
{
if (a[end] > tmp) // 如果该位置的值大于tmp则把该位置的值往后移动
{
a[end + 1] = a[end];
end--; // 让即将进行插入的值与前面的序列以此进行比较
}
else // 如果没满足上面if中的条件则代表找到了要进行插入的位置
{
break; // 结束循环准备进行值的插入
}
}
a[end + 1] = tmp; // 把值进行插入
}
}
// 以上为插入函数的代码
void QuickSort(int* a, int left, int right)
{
if (right - left + 1 <= 10) // 小区间优化,库里面用的是<=16,但我还是想写10,写多少其实都可以,合适就行
{
InsertSort(a, right - left + 1); // 这里是插入函数的调用
return; // 经过插入排序之后,这段区间已经有序,所以直接返回
}
// 由于有小区间优化,所以不会递归到left == right的情况
// 所以便不需要上面那样的结束递归条件判断了。
int midi = GetMidi(a, left, right); // 三数取中
Swap(&a[left], &a[midi]); // 选好标志位的值之后把值放回数组中的第一个位置方便后续的操作
int keyi = left; // keyi指向的位置和_left是一样的,
int _left = left; // 但他们的含义实际上是不一样的,不过后面写哪个都一样
int _right = right;
while (left < right)
{
while (a[right] >= a[keyi] && left < right)
{
right--;
}
while (a[left] < a[keyi] && left < right)
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
QuickSort(a, _left, left - 1);
QuickSort(a, left + 1, _right);
}
为了节省空间以及突出重点,所以这里没有再写三数取中函数的实现代码,可以到更上面的代码块查看,相同的注释就不在这里写了。
问题三:自省排序
我们都知道,递归是在栈上进行的,如果递归得太深的话就会导致栈溢出。
实际上经过上面的两个优化之后已经大大降低了栈溢出的可能性了,但还是抵不住运气极端差的时候,三数取中不停地取次小或次大的数,导致递归的时候两侧划分得极不平均,还是会导致栈溢出。
还有一种情况就是,数组内的含大量重复数据,这也会导致快排的时间复杂度变得很高。解决这个情况可以考虑使用三路划分,不过自省也可以解决这种情况,所以这里就只介绍自省了。
先上代码吧!
// 这里比之前的参数多了一个deep和DefindDeep
// deep的含义是当前的递归深度
// DefindDeep的含义是我们希望深度到多少的时候就开始自省排序,一般选择2*logN比较合适,
void ItrospectionQuickSort(int* a, int left, int right, int deep, int DefindDeep)
{
deep++; // 每次递归使得深度增加
if (right - left + 1 <= 10) // 小区间优化
{
InsertSort(a, right - left + 1);
return;
}
if (deep > DefindDeep) // 如果当前深度比预先定好的深度还深的话,使用堆排进行自省排序
{
HeapSort(a, right - left + 1); // 堆排序的调用,传参要求左右都是闭区间
}
int midi = GetMidi(a, left, right); // 三数取中
Swap(&a[left], &a[midi]);
int keyi = left;
int _left = left;
int _right = right;
while (left < right)
{
while (a[right] >= a[keyi] && left < right)
{
right--;
}
while (a[left] < a[keyi] && left < right)
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
ItrospectionQuickSort(a, _left, left - 1, deep, DefindDeep);
ItrospectionQuickSort(a, left + 1, _right, deep, DefindDeep);
}
至于DefindDeep要如何得到,可以参考下面的这段代码。并且建议在排序排序函数外计算好logN的值之后再作为参数传入,如果在排序函数内进行的话会导致每一次排序都要计算一次logN,大大降低程序的效率。
int logN(int n)
{
int log = 0;
for (int i = 1; i < n; i *= 2)
log++;
return log == 0 ? 1 : log;
}
关于堆排序的代码,这里不再详细讲述,要讲的话篇幅不小,毕竟本文的主题是快排。
这里我们还可以发现一个的问题就是,如果让用户去使用这个排序函数,用户还要自己传deep的值和自己算一出的DefindDeep值并传入。这就让这条函数的使用变得非常麻烦,不人性化。
于是我们可以对这个排序函数再进行一层封装,使之调用格式变得和上面的一样。
在这里我们引出一个“子函数”概念,在原函数名称的前面加个‘_’符号代表这是这个函数的子函数
// 为了简洁和上面相同的注释大部分不再展示
// 子函数
int _ItrospectionQuickSort(int* a, int left, int right, int deep, int DefindDeep)
{
deep++; // 每次递归使得深度增加
if (deep > DefindDeep) // 如果当前深度比预先定好的深度还深的话,使用堆排进行自省排序
{
HeapSort(a, right - left + 1); // 堆排序的调用,传参要求左右都是闭区间
}
int keyi = left;
while (left < right)
{
while (a[right] >= a[keyi] && left < right)
{
right--;
}
while (a[left] < a[keyi] && left < right)
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
return left; // 返回左右指针相遇的位置
}
// 我们便可以把函数封装成这个样子
void ItrospectionQuickSort(int* a, int left, int right)
{
if (right - left + 1 <= 10) // 小区间优化
{
InsertSort(a, right - left + 1);
return;
}
int midi = GetMidi(a, left, right); // 三数取中
Swap(&a[left], &a[midi]);
// 调用子函数完成单趟排序,获得排序好之后标志位的下标
int keyi = _ItrospectionQuickSort(a, left, right, 0, logN(right - left + 1));
ItrospectionQuickSort(a, left, keyi - 1); // 左递归
ItrospectionQuickSort(a, keyi + 1, right); // 右递归
}
这种写法提升了可读性的同时增加了可维护性。如果要使用其他思想的快排的话只需要更改调用的子函数即可,非常的方便。
不同的快排思路(挖坑法、单向指针法)
在把快排的优化说得差不多之后,我们来讲讲快排的不同实现方法,上面使用的是最早出现的hoare法快排,因为这个算法是hoare创造的。但是后来有的人觉得这个方法理解起来比较困难。
比如在找大小的时候为什么一定要右指针先动?左指针先动行不行?
为什么能保证左右指针相遇的位置的值一定是比标志位小的值?
这些问题我会在文章的最后进行解答,我们先来看看后来的人是如何对hoare的方法进行理解上的优化的。
挖坑法
所谓挖坑法,就是把标志位的值挖出来,然后再进行左右找大小。
这里没有对挖坑法的快排进行任何优化,希望你能学完上面的内容之后自己试着动手优化看看
// 这里没有对挖坑法的快排进行任何优化
// 希望你能学完上面的内容之后自己试着动手优化看看
void PotholeQuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
int _left = left;
int _right = right;
int key = arr[_left]; // 保存标志位的值,相当于把这个值挖出来并在数组上留下一个“坑”
while (_left < _right)
{
while (arr[_right] >= key && _left < _right)
_right--; //从右边开始找,找到第一个比标志位小的数,
arr[_left] = arr[_right]; // 并放到i的位置(标志位的左边),此时_right指向的位置就变成了“坑”
while (arr[_left] <= key && _left < _right)
_left++; //从左边开始找,找到第一个比标志位大的数,
arr[_right] = arr[_left]; // 并放到j的位置(标志位的右边),此时_left指向的位置就变成了“坑”
}
arr[_left] = key; // 把之前保存好的标志位的值放到左右指针相遇的位置,相当于把挖的坑填上
// 此时_left左边值都比_left指向的值小,_left右边的值都比_left指向的值大
PotholeQuickSort(arr, left, _left - 1); //左递归
PotholeQuickSort(arr, _left + 1, right); //右递归
}
我们可以看到在挖坑法当中也是让右指针先走,但由于挖掉的是数组当中最右边的值,于是乎让右指针先走的这套逻辑就很自然,不需要深究为什么不能让左指针先走。
如果没能理解挖坑法的话可以试着自己根据代码逻辑画画图来看看,学会画图也是很重要的一项技能。
单向指针法
所谓单向指针法,即是从原本的左右指针对着走变成了两个指针都从同一侧向另一侧走,也就是前后指针。这么做的好处是使代码逻辑变得更好理解了,并且也不需要考虑左右指针哪个先走的问题,效率上没有什么变化。
int _PartQuickSort(int* a, int left, int right)
{
int keyi = left; // 默认最左边的值为key值
int prev = left; // 后指针初始化指向数值第一个值
int cur = prev + 1; // 前指针初始化指向后指针的下一个值
// 前指针走完整个数值则完成单趟排序
while (cur <= right)
{
// 如果前指针指向的值小于key值则让前后指针的值交换
// 完成的就是把小的值放到左边,大的值放到右边的目的
if (a[cur] <= a[keyi] && ++prev != cur) // 注意&&前的条件如果没达成就不会执行后面的表达式的
{
Swap(&a[cur], &a[prev]);
}
cur++; // 每轮循环cur指针都往后走
}
// 循环结束,后指针指向的位置就是key值应该处在的位置
Swap(&a[keyi], &a[prev]);
return prev;
}
void PartQuickSort(int* a, int left, int right)
{
// 小区间优化
if (right - left + 1 <= 10)
{
InsertSort(a, right - left + 1);
return;
}
// 三数取中
int midi = GetMidi(a, left, right);
Swap(&a[left], &a[midi]);
int keyi = _PartQuickSort(a, left, right); // 调用单向指针排序的单向排序逻辑,并返回key值的位置
PartQuickSort(a, left, keyi - 1); // 左递归
PartQuickSort(a, keyi + 1, right); // 右递归
}
可以看到这里也使用了上面提到过的子函数封装的思想,这样代码是不是就变得很易读且逻辑分明了呢。
递归改非递归
我们知道递归是需要消耗栈上空间的,数据量过大的时候就会容易导致栈溢出。这时候就需要把递归代码改成非递归(迭代)的才行。这里快排的改法就需要使用到栈或队列了,至于用栈还是队列都是可以的,用栈就是模拟深度优先,用队列就是广度优先。
我们这里演示用栈来写,这也是正常递归的顺序。
注:为了节省篇幅,这里借用了C++中STL库的stack,用自己实现的栈也是一样的。
// 定义一个代表排序区间的结构体,模拟每次递归的递归区间
// 如果不想定义这么一个结构体也可以,只需要入栈和出栈的时候分两次出入左右区间即可
typedef struct Range
{
int _left;
int _right;
}Range;
void QuickSortNonR(int* a, int left, int right)
{
stack<Range> st; // 实例化一个用来存区间的栈
st.push({ left, right }); // 把函数调用时传入的区间进行入栈
while (!st.empty())
{
Range tmp = st.top(); // 取出栈顶区间方便后续使用
st.pop(); // 相当于栈顶区间排完了,进行出栈
// 调用单趟排序逻辑并返回key值的位置
// 这里又是展现了一次功能拆分实现的爽点了呢,只需要这么一调用就完成了单趟目的
int keyi = _PartQuickSort(a, tmp._left, tmp._right);
// 以下便是模拟函数递归的逻辑
if(tmp._left < keyi - 1) // 如果左区间剩余的数据个数大于一个
st.push({ tmp._left, keyi - 1 }); // 则把左区间入栈
if(keyi + 1 < tmp._right) // 如果右区间剩余的数据个数大于一个
st.push({ keyi + 1, tmp._right }); // 则把右区间入栈
}
// 当没有新区间入栈,栈中区间也全部排好之后,递归结束
// 此时就像是函数递归里的归,不过迭代法不需要归也排好序了
}
关于霍尔(hoare)法中的一些细节问题
为什么一定要右指针先走?
因为霍尔(hoare)法需要右指针先走以保证左右指针相遇位置的值能跟最左侧的值(key值)进行交换,也就是在排升序的时候比key值小的值。
那么为什么让右指针先走就能保证相遇位置就能跟最左侧的值(key值)进行交换?
以排升序为例,在第一轮左右指针找大小的时候,右指针先找到比key值小的值并停留在该值处,然后左指针再找比key值大的数,双方找完之后就会让这两个值的位置进行交换,此时左指针指向的值比key值小。交换完之后右指针继续往左找,假设此时左指针右侧的值和右指针左侧的值都比key值大,那么右指针会直接和左指针相遇,经过前面的左右值交换,左指针指向的值一定比key值小,那么左右制作相遇的位置就是比key值小的了。
那么如果在第一次右指针向左找小的时候就没找到比key值小的值呢?
这种情况下右指针便会直接与左指针相遇,触发交换函数的时候是自己与自己进行交换,不会有任何影响。由此可见右指针先行的话不会有问题。
那如果左指针先走的话会发生什么问题呢?
还是以升序排序为例,在第一轮左右指针找大小的时候,左指针先找到比key值大的值并停留在该值处,然后右指针再找比key值小的数,双方找完之后就会让这两个值的位置进行交换,此时右指针指向的值比key值大。交换完之后左指针继续往左找,假设此时左指针右侧的值和右指针左侧的值都比key值大,那么左指针会直接和右指针相遇,经过前面的左右值交换,右指针指向的值一定比key值大,此时两个指针相遇位置的值与key值进行交换的话就会导致交换后有一个比key值大的数在key值的左边,这就不符合逻辑了。