目录
🕒 1. 快速排序
💡 算法思想:快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
🕘 1.1 Hoare版本(左右指针法)
具体思路是:
- 选定一个基准值(pivot / key),最好选定最左边或者最右边;
- 确定两个指针
left
和right
分别从左边和右边向中间遍历数组; - 如果选最右边为基准值,那么left指针先走,如果遇到大于基准值的数就停下来。这样能保证相遇位置比基准值大;
- 然后右边的指针再走,遇到小于基准值的数就停下来;
- 交换left和right指针对应位置的值;
- 重复以上步骤,直到
left = right
,最后将基准值与left(right)位置的值交换,这便完成一趟排序。
这样基准值左边的所有数都比它小,而它右边的数都比它大,从而它所在的位置就是排序后的正确位置。之后再递归排以基准值为界限的左右两个区间中的数,当区间中没有元素时,排序完成。
// 快速排序hoare版本
int PartSort(int* a, int left, int right)
{
int key = right;// 选定基准值
while (left < right)
{
// 选右边为基准值,则左指针先走,从左往右找到第一个比基准值大的元素
while (left < right && a[left] <= a[key])
{
left++;
}
// 右指针再走,从右往左找到第一个比基准值小的元素
while (left < right && a[right] >= a[key])
{
right--;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[key]);
return left;
}
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return; // 如果左边界大于等于右边界,表示已经排序完成。
}
int keyi = PartSort(a, left, right); // 对数组进行一次分割操作,获取基准值的最终位置
QuickSort(a, left, keyi - 1); // 对基准值左边的子数组进行递归排序
QuickSort(a, keyi + 1, right); // 对基准值右边的子数组进行递归排序
}
🕘 1.2 挖坑法
💡 算法思想:挖坑法与上面Hoare版本思想基本一致,都是利用递归(即分治的算法思想)去实现排序。但挖坑法的不同之处在于,当left
和right
这两个下标在遍历过程中找到满足条件的元素时(即元素值大于或小于基准值key),它们会立即将这些元素放置到合适的位置,这个过程可以描述为边查找边替换。相比之下,Hoare版本则是先记录下符合条件的元素下标,在遍历完成后再进行位置的交换。
具体思路是:
- 先将选定的基准值(最左边)直接取出,然后留下一个坑;
- 当右指针遇到小于基准值的数时,直接将该值放入坑中,而右指针指向的位置形成新的坑位;
- 然后左指针遇到大于基准值的数时,将该值放入坑中,左指针指向的位置形成坑位;
- 重复该步骤,直到左右指针相等。最后将基准值放入坑位之中。
注意,在操作过程中,我们并不是真正地创建一个新的坑位,而是在逻辑上将其视为坑位。实际上,坑位仍然占据着原来的元素,我们只是在操作时暂时将原元素存储起来,然后用新元素直接覆盖它。在left
和right
最终相遇的位置,就会形成一个坑位,恰好可以放置key。
// 快速排序挖坑法
int PartSort(int* a, int left, int right)
{
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 hole;
}
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return; // 如果左边界大于等于右边界,表示已经排序完成。
}
int keyi = PartSort(a, left, right); // 对数组进行一次分割操作,获取基准值的最终位置
QuickSort(a, left, keyi - 1); // 对基准值左边的子数组进行递归排序
QuickSort(a, keyi + 1, right); // 对基准值右边的子数组进行递归排序
}
🕘 1.3 前后指针法
💡 算法思想:有两个指针:cur
、prev
(cur
的位置在prev
的下一个位置),期间通过cur
去遍历数组,将每个元素与key
值比较大小,若发现有元素小于key
,就让prev++
(即走到下一个位置),再与cur
此时对应的元素进行交换,通过递归的方式重复上述的步骤,即可实现完全排序。
简而言之:
prev
和key
初始位置之间的区间,是在维护一个所有元素都小于key
的区域,目的是使key
左侧的序列中的所有值都小于key
。prev++
到cur
之间的区间,则是在维护一个所有元素都大于key
值的区域,以确保key
右侧的序列中的所有值都大于key
。cur
不断寻找小于key
的元素并与prev++
后的位置交换,是为了将序列后方小于key
的元素移动到key
前方,保证key
左侧子序列的值都小于key
,右侧子序列的值都大于key
。
具体思路是:
- 选定基准值,定义prev和cur指针(
cur = prev + 1
); - cur先走,遇到小于基准值的数停下,然后将prev向后移动一个位置;
- 将prev对应值与cur对应值交换;
- 重复上面的步骤,直到cur走出数组范围;
- 最后将基准值与prev对应位置交换;
- 递归排序以基准值为界限的左右区间。
// 快速排序前后指针法
int PartSort(int* a, int left, int right)
{
// 选择基准值为数组的第一个元素(left)
int keyi = left; // 基准值索引
int prev = left; // 前指针初始化为基准值的位置
int cur = left + 1; // 后指针初始化为基准值后一个位置
// 遍历数组,将小于基准值的元素交换到前面
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]); // 如果当前元素小于基准值,交换到前面
}
cur++;
}
Swap(&a[prev], &a[keyi]); // 将基准值交换到最终位置
// 选择基准值为数组的最后一个元素(right)
/*
int keyi = right; // 基准值索引
int prev = left - 1; // 前指针初始化为左边界的前一个位置
int cur = prev + 1; // 后指针初始化为左边界
// 遍历数组,将小于基准值的元素交换到前面
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[cur], &a[prev]); // 如果当前元素小于基准值,交换到前面
}
cur++;
}
Swap(&a[keyi], &a[++prev]); // 将基准值交换到最终位置
*/
return prev;
}
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return; // 如果左边界大于等于右边界,表示已经排序完成。
}
int keyi = PartSort(a, left, right); // 对数组进行一次分割操作,获取基准值的最终位置
QuickSort(a, left, keyi - 1); // 对基准值左边的子数组进行递归排序
QuickSort(a, keyi + 1, right); // 对基准值右边的子数组进行递归排序
}
🕘 1.4 快速排序的优化
快速排序是一种高效的排序算法,但在某些情况下存在缺陷。例如,若基准值选为最小值,则会引发不必要的递归。此外,对于大量有序或近似有序的数据排序时,快速排序效率较为低下,甚至可能导致程序崩溃,因为过多的递归调用可能会造成栈溢出。为了提高效率并避免这些问题,可以采用以下两种优化策略:
- 三数取中法选基准值
- 递归到小的子区间时,可以考虑使用插入排序
🕤 1.4.1 三数取中
💡 算法思想:即在选择基准值时,不是简单地选择数组的第一个元素或者最后一个元素,而是从当前子数组的第一个元素、中间元素和最后一个元素中选择中间大小的元素作为基准值。这种方法的优势在于:
- 降低最坏情况的发生概率: 如果每次选取的基准值都是当前子数组中的中间值,那么快速排序在大多数情况下会有较好的性能表现,因为这样可以避免极端情况下分割不均匀的问题。
- 减少递归深度: 选取较为中间的元素作为基准值,可以更均匀地划分数组,避免出现极端的递归深度,从而减少排序的时间复杂度。
// 三数取中法
int MidIndex(int* a, int left, int right)
{
int mid = (left + right) / 2; // 计算中间元素的索引
// 若要防止整数溢出可以使用以下方式计算mid
// 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 right;
}
else
{
return mid;
}
}
else
{
if (a[mid] > a[left])
{
return left;
}
else if (a[mid] < a[right])
{
return right;
}
else
{
return mid;
}
}
}
// 快速排序前后指针法优化
int PartSort(int* a, int left, int right)
{
int mid = MidIndex(a, left, right);
// 将基准位置调整至最左边
Swap(&a[mid], &a[left]);
// 选择基准值为left
int keyi = left;
int prev = left;
int cur = left + 1;
......
}
🕤 1.4.2 小区间优化排序
💡 算法思想:优化排序的时间复杂度,减少不必要的调用递归次数
具体来说,就是在完成一次快速排序递归后,将数据分为左右两个子序列。如果这两个子序列的数据量都较小,就可以直接采用更适合小数据集的排序方法,如插入排序,而不必再进行快速排序的递归操作。这样不仅提高了算法的效率,也优化了递归层次,特别是在数据量较小的情况下更为明显。
// 快速排序小区间优化
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return;
}
// 小区间优化,减少递归次数
if (right - left + 1 < 10)
{
InsertSort(a + left, right -left + 1);
}
else
{
int keyi = PartSort(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
🕘 1.5 快速排序的非递归实现
💡 算法思想:快速排序的非递归实现依赖于栈来存储待排序的子区间。这种方法不仅避免了递归可能导致的栈溢出问题,而且其核心思想与递归实现是相似的。
具体思路是:
- 将数组左右下标入栈;
- 若栈不为空,两次取出栈顶元素,分别为闭区间的左右界限;
- 将区间中的元素按照上述任意一种方法得到基准值的位置;
- 再以基准值为界限,若基准值左右区间中有元素,则将区间入栈;
- 重复上述步骤直到栈为空。
void QuickSortNonR(int* a, int left, int right)
{
// 创建栈
Stack st;
StackInit(&st);
// 原始数组区间入栈
StackPush(&st, right); // 先将右边界入栈
StackPush(&st, left); // 再将左边界入栈
// 将栈中区间排序
while (!StackEmpty(&st))
{
// 弹出栈顶的左右边界
left = StackTop(&st);
StackPop(&st);
right = StackTop(&st);
StackPop(&st);
// 对当前区间进行划分,得到基准值的位置
int mid = PartSort(a, left, right);
// 将基准值两侧的子区间入栈,准备下一轮排序
if (right > mid + 1)
{
StackPush(&st, right); // 右边界入栈
StackPush(&st, mid + 1); // 右侧子区间的左边界入栈
}
if (left < mid - 1)
{
StackPush(&st, mid - 1); // 左侧子区间的右边界入栈
StackPush(&st, left); // 左边界入栈
}
}
// 销毁栈
StackDestroy(&st);
}
快速排序的特性总结:
- 快速排序整体的综合性能和使用场景都是比较好的,所以才叫快速排序。
- 时间复杂度:最好:O(N*logN) ; 最坏:O(N2)
- 空间复杂度:O(logN) ~ O(N)
- 稳定性:不稳定
❗ 转载请注明出处
作者:HinsCoder
博客链接:🔎 作者博客主页