文章目录
深入理解快速排序算法
快速排序是一种高效的排序算法,由C. A. R. Hoare在1960年提出,它也是最常用的排序算法之一。快速排序的基本思想是通过一个划分操作,将待排序的数组分成左右两部分,其中一部分的所有数据都比另一部分的所有数据要小,然后再递归地对这两部分数据分别进行快速排序。
快速排序的工作原理
快速排序通常采用“分治法”策略。它的核心在于选择一个“基准值”,并围绕这个基准值重新排列序列,使得左边的元素都不大于基准值,右边的元素都不小于基准值。这个过程称为“分区”操作。完成分区后,基准值处于其最终位置,这个位置称为“分区点”。
接下来,我们将通过不同的方法实现分区操作,并探讨它们的特点。
1. Hoare 分区法
Hoare 分区方法是最早的快速排序分区算法。它的基本思路是初始化两个指针,一个从数组的开始处向右移动,另一个从数组的末尾向左移动。这两个指针分别寻找不符合分区条件的元素,并交换它们。
int PartSort1(int* a, int left, int right)
{
int midi = GetMidi(a, left, right);
swap(&a[left], &a[midi]);
int keyi = left;
while (left < right)
{
while (right > left && a[right] >= a[keyi])
{
right--;
}
while (left > right && a[left] <= a[keyi])
{
left++;
}
swap(&a[left], &a[right]);
}
swap(&a[keyi], &a[left]);
return left;
}
2. 挖坑法
挖坑法是一种直观的快速排序实现方式。在这种方法中,基准值被取出(形成一个坑),然后从数组的两端交替地向中间扫描,并填补坑位。
int PartSort2(int* a, int left, int right)
{
int midi = GetMidi(a, left, right);
swap(&a[left], &a[midi]);
int key = a[left];
int hole = left;
while (left < right)
{
while (left < right && a[right] >= key)
{
right--;
}
a[hole] = a[right];
hole = right;
while (left < right && a[left] <= key)
{
left++;
}
a[hole] = a[left];
hole = left;
}
a[hole] = key;
return left;
}
3. 前后指针法
前后指针法使用两个指针,一个指向数组的开始,称为“前指针”,另一个从前指针的下一个位置开始,称为“后指针”。后指针寻找小于基准值的元素,并与前指针的下一位置交换,直到后指针遍历完数组。
int PartSort3(int* a, int left, int right)
{
int midi = GetMidi(a, left, right);
swap(&a[left], &a[midi]);
int key = left;
int prev = left;
int cur = left+1;
while (cur <= right)
{
while (cur <= right && a[cur] >= a[key])
{
cur++;
}
if (cur > right)
{
break;
}
prev++;
swap(&a[prev], &a[cur]);
}
swap(&a[prev], &a[key]);
return prev;
}
快速排序的优化
为了提高快速排序的性能,可以通过一些优化策略来减少不平衡的划分和减少递归深度。下面是一些常见的优化方法:
三数取中法
在快速排序中,基准值的好坏直接影响到算法的性能。如果每次都能使基准值位于序列的中间,那么排序效率最高。三数取中法就是一种常用的选择基准值的策略。通常选取数组中的第一个元素、中间的元素和最后一个元素,然后将这三个元素的中值作为基准值。
int GetMidi(int* a, int left, int right) {
int mid = left + (right - left) / 2;
if (a[left] > a[right]) {
if (a[mid] > a[left]) {
return left;
} else if (a[mid] > a[right]) {
return mid;
} else {
return right;
}
} else {
if (a[mid] > a[right]) {
return right;
} else if (a[mid] > a[left]) {
return mid;
} else {
return left;
}
}
}
小区间优化
小区间优化,递归到小区间时,不再进行递归分割排序,降低递归的次数。
当区间数据个数小于规定的大小的时候,不再进行递归分割排序,开始使用插入排序。
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
int tmp = 10;
if ((end - begin + 1) > tmp)
{
int keyi = PartSort3(a, begin, end);
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
else
{
InsertSort(a + begin, end - begin + 1);
}
}
非递归实现的细节分析
在快速排序的非递归实现中,我们利用一个数据结构(通常是栈)来模拟递归调用的过程。正如代码示例中所展示的,这种方法避免了递归带来的栈溢出的风险,尤其在处理极端不平衡的数据时更显得稳健。接下来,我们将详细分析非递归快速排序的实现过程和其优势。
void QuickSortNonR(int* a, int left, int right)
{
Stack ST;
StackInit(&ST);
StackPush(&ST, left);
StackPush(&ST, right);
while (!StackEmpty(&ST))
{
right = StackTop(&ST);
StackPop(&ST);
left = StackTop(&ST);
StackPop(&ST);
int mid = PartSort1(a, left, right);
if (mid + 1 < right)
{
StackPush(&ST, mid + 1);
StackPush(&ST, right);
}
if (left < mid - 1)
{
StackPush(&ST, left);
StackPush(&ST, mid);
}
}
StackDestroy(&ST);
}
栈的初始化和使用
在非递归的快速排序中,我们首先需要初始化一个栈。这个栈用来存储接下来需要进行排序的子数组的边界索引。代码中的StackInit函数负责初始化栈结构,StackPush用于将元素压入栈中,而StackPop则用于从栈中取出元素。
主循环逻辑
主循环中,只要栈不为空,我们就从栈中弹出一对元素,这对元素代表了当前需要排序的子数组的左右边界。随后,使用之前讨论的PartSort1函数对这个子数组进行一次划分,获取到中间元素的正确位置。
划分之后的处理
划分操作完成后,我们会检查划分得到的两个子数组的大小。如果子数组非空(即存在至少一个元素),我们就将其边界索引再次压入栈中。具体来说:
- 如果左侧子数组有多于一个元素,将其左边界和中间元素的索引压入栈。
- 如果右侧子数组有多于一个元素,将中间元素的索引加一(即分区点的右侧)和右边界压入栈。
这样的处理确保了栈中始终存储着当前尚未排序完成的子数组的边界。
栈的销毁
在整个排序过程完成后,需要销毁栈以释放其占用的资源。这一步通常在所有的排序操作完成后进行,确保不会有内存泄漏。
非递归快速排序的优势
- 避免栈溢出:对于深度极大的递归调用,特别是在处理极端不平衡的数据时,递归实现可能会导致栈溢出。非递归实现通过显式使用堆上的栈结构避免了这一问题。
- 控制权管理:在非递归实现中,排序过程的控制权更加明显,开发者可以更容易地对排序过程进行调试和优化。
- 可调性:非递归实现允许更灵活地调整和优化数据结构的使用,例如选择不同类型的栈实现以优化性能。
结论
快速排序的非递归实现提供了一种避免递归限制的替代方法,尤其适用于处理大数据集或极端数据案例。通过使用栈来模拟递归调用栈的方式,我们可以保证排序的效率和稳定性,同时避免了递归可能导致的栈溢出问题。