快速排序及其优化策略
1.算法思想
快速排序采用的是分治思想,即在一个无序的序列中选取一个任意的基准元素pivot,利用pivot将待排序的序列分成两部分,前面部分元素均小于或等于基准元素,后面部分均大于或等于基准元素,然后采用递归的方法分别对前后两部分重复上述操作,直到将无序序列排列成有序序列。快速排序是不稳定的排序算法。
2.算法实现
2.1 确定基准元素
选择num[left]、num[(left + right) / 2]、num[right]、或在数组中随机取数
2.2 调整区间
通过调整数组元素使得左侧子数组中元素的值都小于或等于基准元素,右侧子数组中元素的值都大于等于基准元素
2.3 递归调整左右区间
如何调整区间是快速排序算法的重中之重,这里采用最经典的双指针算法。
取区间中某元素为pivot,定义两个变量i和j,i从最左边向右走,找到比pivot大的元素停下;j从区间的最右边向左走,找到比pivot小的元素停下。如果i此时还在j左侧,则交换i和j所指向的元素。重复上述过程,直到i与j相遇。
在边界问题的处理上,这里采取了一种模糊处理的方法。
通常情况下,我们会选择将基准元素移至中间,将数组分成左右两个区间。但是,当数组中有重复的元素时,这种处理方法可能会导致两个区间不平衡,使得快速排序算法的性能下降。
为了避免这种情况,我们不把基准元素移至中间,而是将其分散开。这意味着在左侧区间中也可能有基准元素,右侧区间中也可能有基准元素。
这样一来,每个区间都有更多的机会包含重复的元素,从而使得快速排序算法在处理重复元素时更加高效。这种模糊处理的方法并不影响后续的递归处理。
3.代码实现
void quickSort(int num[], int left, int right)
{
if (left >= right) return; // 左右指针相遇,直接返回
int i = left - 1, j = right + 1, pivot = num[left]; // 左右指针分别指向边界两侧
while (i < j)
{
while (num[++i] < pivot); // 左指针查找大于或等于pivot的元素
while (num[--j] > pivot); // 右指针查找小于或等于pivot的元素
if (i < j) swap(num[i], num[j]);
}
quickSort(num, left, j); // 递归处理
quickSort(num, j + 1, right);
}
4.时空复杂度分析
4.1 最好情况
每次区间调整都使数组成为左右长度相等的两半
时间复杂度:O(nlogn)
空间复杂度:O(logn)
4.2 最坏情况
每次区间调整都发生在数组边上
时间复杂度:O(n2)
空间复杂度:O(n)
4.2 平均情况
时间复杂度:O(nlogn)
空间复杂度:O(logn)
5.优化策略
5.1 随机选取基准元素
快速排序达最坏情况的一种原因:假设每次选的基准元素是第1个元素,但是数组有序,那么基准元素pivot恰好是当前子数组中最小的元素。
为了解决这个问题,我们可以在当前数组中取随机数作为基准元素。
降低最坏情况发生的概率,但无法杜绝。实际应用中,采用该策略遇到最坏情况的概率极低,该策略可获得很好的性能。
std::srand(std::time(0)); // 使用当前时间作为随机种子
pivot = num[left + std::rand() % (right - left + 1)];//取left到right之间的随机数
此策略可以降低最坏情况发生的概率,但无法杜绝。
5.2 三数取中法(Median of Three)
选取num[left]、num[(left + right) / 2]和num[right]的中位数作为基准元素。
此策略保证选出的基准元素不是子数组的最小元素,也不是最大元素,分划肯定不会分到最边上。
此策略可以降低最坏情况发生的概率,但无法杜绝。
swap(num[(left + right) / 2], num[left + 1]);
if (num[left + 1] > num[right]) swap(num[left + 1], num[right]);
if (num[left] > num[right]) swap(num[left], num[right]);
if (num[left + 1] > num[left]) swap(num[left + 1], num[left]);
return num[right];
上述算法排序后使得num[left + 1] ≤ num[left] ≤ num[right]
5.3 尾递归优化
将尾递归转为循环,减小递归深度,可以降低空间复杂度,先处理短区间,最坏空间复杂度(递归深度)可降为O(logn)
较短区间,进行递归处理;较长区间,进行循环处理
每次递归处理的子数组长度至少缩减一半
void quickSort(int num[], int left, int right)
{
while (left < right)
{
// 区间划分部分,略去
// 此处j为右指针
if (j - left < right - j) {
quickSort(num, left, j - 1);
left = j + 1;
} else {
quickSort(num, j + 1, right);
right = j - 1;
}
}
}
5.4 利用栈消除所有递归
利用栈来消除递归的过程,将需要处理的区间压栈,并在循环中不断弹栈处理,直到栈为空。
具体实现如下:
- 设定一个阈值threshold,当区间长度小于threshold时,不处理该区间,待最后对整个数组做一次插入排序。
- 定义一个栈,用来存储需要处理的区间。
- 循环处理,直到栈为空:
a. 区间划分
b. 计算左右区间的长度,如果一个区间小于等于阈值threshold且另一个区间大于等于阈值threshold,则处理大于等于阈值threshold的区间;如果左区间和右区间都大于等于阈值threshold,则先处理短区间,将长区间压栈;如果两个区间都小于阈值threshold,则跳过这个区间,弹栈获得要处理的下一个区间,继续下一次循环。 - 循环结束后,对整个数组进行一次插入排序。
int threshold; //区间长度阈值
stack<pair<int, int>> st; //栈,元素为区间左右边界
while (left < right)
{
// 区间划分部分,略去
// 此处j为右指针
int leftLength = j - left, rightLength = right - j;
if (leftLength < threshold && rightLength >= threshold) left = j + 1; // 处理大于阈值threshold的区间
else if (leftLength >= threshold && rightLength < threshold) right = j - 1; // 处理大于阈值threshold的区间
else if (leftLength >= threshold && rightLength >= threshold) { // 先处理短区间,将长区间压栈
if (leftLength > rightLength) {
st.push(make_pair(left, j - 1));
left = j + 1;
} else {
st.push(make_pair(j + 1, right));
right = j - 1;
}
} else if (leftLength < threshold && rightLength < threshold) { // 跳过当前区间,弹栈获得要处理的下一个区间
if (!st.empty()) {
left = st.top().first;
right = st.top().second;
st.pop();
}
else
break;
}
}
InsertSort(R, left, right); //循环结束后,对整个数组进行一次插入排序
这种策略避免了递归调用时的系统栈空间开销,从而降低了空间复杂度。在标准的递归实现中,每次递归调用都会在系统栈上创建一个新的帧,存储函数的局部变量和返回地址。这会导致递归深度增加时系统栈空间的大量使用。当数据集过大时,这种策略可以显著减少空间的使用。
5.5 检测递归深度,转为堆排序
检测快速排序的递归深度,在递归函数内部设置一个计数器,每次递归时计数器加1,当递归深度达到O(logn) 层时,强行停止递归,转而对当前处理的子数组进行堆排序。
这种策略使最坏情况时间复杂度降为O(nlogn)
5.6 三路分划(3-Way-Partition)
当重复元素很多时,传统快速排序效率较低。因为我们可能一直在移动相同的元素,导致数组根本没有变化。
在区间调整时,三路分划将当前数组调整为三部分:小于基准元素pivot的元素在左边,等于pivot的元素在中间,大于pivot的元素在右边。
进一步递归时,仅对小于pivot的左半部分子数组和大于pivot的右半部分子数组进行递归排序。
这种策略在数组中有很多相同元素时具有良好的性能。