赞
踩
快速排序是霍尔(Hoare)于1962年提出的一种二叉树结构的交换排序方法,其基本思路为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
[!Tips] 学习快速排序的建议:
- 画图理解单趟排序,画递归展开图
- 注意体会数据逐渐有序的过程(每经过一趟排序,该趟排序的基准值的位置是它最终的位置)
快速排序将待排序序列逐步划分为较小的子序列,并对每个子序列再进行单趟排序。被嵌套调用的一系列单趟排序构成了完成的快速排序算法。
在快速排序的单趟排序中,我们通过选择一个基准元素(代码中用Key表示,其下标用KeyIndex表示),并根据其值将序列进行划分。
我们提供霍尔法、挖坑法、前后指针法,3种实现方法。
霍尔(Hoare)是最初发现快速排序的人,它使用的单趟排序算法被称为霍尔法。
选择基准元素,通常选择序列的第一个元素作为基准元素。
利用两个下标left
和right
分别指向待排数组的最左侧和最右侧,right
指针找比key
基准值小的数,left
找比key
基准值大的数。
right
先向左移动,直到找到第一个小于基准元素的值。
left
向右移动,直到找到第一个大于基准元素的值。
找到之后交换这两个下标对应的数值,即交换a[left]
和a[right]
。
重复步骤 3-5,直到 left
与 right
相遇。
最后,交换基准元素 a[KeyIndex]
和 a[left]
,此时,基准元素所在位置就是它最终的位置。
函数返回 left
(left
标记了子序列的分界点)。
// Hoare法单趟排序 int PartSort(int* a, int left, int right) { // 选择基准元素的索引为 left int KeyIndex = left; while (left < right) { // right 从右侧开始,找到第一个小于基准元素的值 while (left < right && a[right] >= a[KeyIndex]) { --right; } // left 从左侧开始,找到第一个大于基准元素的值 while (left < right && a[left] <= a[KeyIndex]) { ++left; } // 交换 left 和 right 的元素 Swap(&a[left], &a[right]); } // 将基准元素放置在最终的位置 Swap(&a[KeyIndex], &a[left]); return left; }
[!Question] 为什么能够保证相遇位置比
key
小?
因为right
先走,使得结局为以下两种:
right
停下之后,left
向右靠近并与right
相遇,由于right
位置定在比key
小的值上,所以最终left
和right
都在比key
小的位置处。left
停下之后,right
与left
进行交换,交换后left
指向的值比key
小,此时right
遇到left
的位置一定比key
小
挖坑法是由 Tony Hoare(托尼·霍尔)在1960年提出的。他作为著名的计算机科学家,也是快速排序算法的发明者之一。
选择第一个元素作为基准元素,然后把基准元素用Key
存放起来。
HoleIndex
是坑的位置,初始位置为基准元素的位置。left
和right
分别指向待排数组的最左侧和最右侧,right
找比key
基准值小的数,left
找比key
基准值大的数。
right
从右侧开始,寻找第一个小于基准元素的值。
将该值移动到当前的坑中,该值原来位置成为新的坑位,因此更新 HoleIndex
为右侧坑的位置。
left
从左侧开始,寻找第一个大于基准元素的值。
将该值填充到右侧的坑中,该值原来位置成为新的坑位,因此更新 HoleIndex
为左侧坑的位置。
重复步骤 3-6,直到左侧指针 left
和右侧指针 right
相遇。
将基准元素放置到最后一个坑中,即填充到最终的位置。
返回坑的位置 HoleIndex
作为划分子序列的分界点。
// 挖坑法单趟排序 int PartSort2(int* a, int left, int right) { // 选择基准元素为左侧第一个元素 int Key = a[left]; int HoleIndex = left; // 坑的下标,初始为基准元素位置 while (left < right) { // 从右侧开始,找到第一个小于基准元素的值 while (left < right && a[right] >= Key) { --right; } // 将右侧小于基准元素的值填充到左侧的坑中 a[HoleIndex] = a[right]; HoleIndex = right; // 从左侧开始,找到第一个大于基准元素的值 while (left < right && a[left] <= Key) { ++left; } // 将左侧大于基准元素的值填充到右侧的坑中 a[HoleIndex] = a[left]; HoleIndex = left; } // 将基准元素放置到最后一个坑中,即填充到最终的位置 a[HoleIndex] = Key; return HoleIndex; }
相比于霍尔法和挖坑法,前后指针法只进行元素的比较和少量交换操作,没有显式地创建临时变量来记录基准值。这样能够简化代码实现,并且在实际运行中减少了一些内存和计算开销。
基准值 key
为最左边元素,prev
指向基准值, cur
指向基准值下一个位置。
从 cur
开始遍历数组,直到找到第一个小于等于基准值的元素,则先将prev指针向后移动一位,再将cur交换到prev指针指向的位置。
继续遍历数组,直到 cur
超过 right
为止。
最后,将基准值交换到 prev
指向的位置,此时基准值左侧的元素均小于等于基准值,右侧的元素均大于基准值。
返回 prev
(prev
标记了子序列的分界点)。
// 前后指针法 int PartSort3(int* arr, int left, int right) { int key = left; // 将最左边的元素作为基准值 int prev = left; // 后指针(动得慢) int cur = left + 1; // 前指针(动得快) while (cur <= right) { // 当cur遍历到的元素小于等于基准值时,先将prev指针向后移动一位,再将cur交换到prev指针指向的位置 if (arr[cur] <= arr[key] && ++prev != cur) { Swap(&arr[cur], &arr[prev]); } cur++; } // 最后将基准值交换到prev指针指向的位置,以完成单趟排序 Swap(&arr[key], &arr[prev]); return prev; // 返回基准值的位置,用于后续的划分 }
以一个数为基准,将数组分为两个子序列,左子序列放比基准数小的,右子序列放比基准数大的数,然后再将左右两段子序列以同样方式分割,数组有序。
![[霍尔法排序.png]]
// 快速排序的递归实现 void QuickSort(int* a, int begin, int end) { //两种情况下直接返回:1. 区间只有一个值 2. 区间不存在 if (begin >= end) { return; } // 使用单趟排序函数 PartSort 对序列进行划分,并获取基准元素的位置 int KeyIndex = PartSort(a, begin, end); // 对基准元素左侧的子序列排序 QuickSort(a, begin, KeyIndex - 1); // 对基准元素右侧的子序列排序 QuickSort(a, KeyIndex + 1, end); }
快速排序的非递归实现是使用栈存储每一次分割后的数组下标区间,以数组下标区间传入单趟排序,以实现对递归思想的模拟。
[!attention] 注意:
栈的特点是先进后出。
所以,获取区间以先右后左顺序时,就要以先左再右压栈。同样,如果要求左子列先出栈,右子列后出栈,那么必须先让右子列入栈,再让左子列入栈。
//栈的接口函数 typedef int STDataType; typedef struct Stack { STDataType* a; int top;//标记栈顶 int capacity; }ST; void STInit(ST* pst); //初始化栈 void STDestroy(ST* pst); //销毁栈 void STPush(ST* pst, STDataType x); //压栈 void STPop(ST* pst); //出栈 STDataType STTop(ST* pst); //取出栈顶元素 bool STEmpty(ST* pst); //栈判空 int STSize(ST* pst); //栈大小
// 非递归快速排序实现 void QuickSortNonR(int* a, int begin, int end) { ST st; // 定义一个栈,用于模拟递归过程 STInit(&st); // 初始化栈 // 将初始的 begin 和 end 压入栈,相当于递归的入口 STPush(&st, end); STPush(&st, begin); while (!STEmpty(&st)) { // 从栈中取出当前处理的 begin 和 end int left = STTop(&st); STPop(&st); int right = STTop(&st); STPop(&st); // 调用 PartSort 函数对序列进行划分,并得到基准元素的位置 int KeyIndex = PartSort(a, left, right); // 判断是否需要对右侧子序列进行排序,并将其入栈 if (KeyIndex + 1 < right) { STPush(&st, right); STPush(&st, KeyIndex + 1); } // 判断是否需要对左侧子序列进行排序,并将其入栈 if (left < KeyIndex - 1) { STPush(&st, KeyIndex - 1); STPush(&st, left); } } // 栈中所有任务处理完毕,排序完成 STDestroy(&st); }
[!Attention] 注意
快速排序无论采用哪种单趟排序策略,无论采用递归还是非递归它的时间复杂度是:O(N*logN)
它的空间复杂度是:O(logN) ——logN也是递归层数,即创建的栈帧的数量
优化快速排序的基准数选择对算法的性能和稳定性有很大的影响。其中,三值取中法是一种常用的优化策略,它通过选取子序列中某三个元素的中位数作为基准数(一般选择首、中、尾三数),从而尽可能防止选择的基准数为待排数组的最大、最小值,而导致最坏情况出现。
[!Example] 最坏情况发生在以下两种情景下:
数组已经有序(升序或降序):当输入的数组是有序的时候,如果每次选择第一个或最后一个元素作为基准数,快速排序每次只能将数组划分为一个元素和剩余的元素,导致递归深度为 n(数组长度),时间复杂度为 O(n^2)。
数组具有相同元素:当输入的数组中所有元素都相同(例如,数组元素全部为2),无论选择哪个元素作为基准数,每次划分都只能将数组分为一个元素和一坨剩余的元素,导致递归深度为 n(数组长度),时间复杂度为 O(n^2)。
在这些最坏情况下,快速排序的性能大大降低,它退化成了一个效率较低的排序算法。为了避免最坏情况的发生,我们在选择基准数时更加谨慎,确保快速排序的平均性能得到保证。
// 三值取中法优化的快速排序递归实现 void QuickSortOptimized(int* a, int begin, int end) { if (begin >= end) { return; } // 三值取中法选取基准数 int mid = begin + (end - begin) / 2; if (a[begin] > a[mid]) { Swap(&a[begin], &a[mid]); } if (a[mid] > a[end]) { Swap(&a[mid], &a[end]); } if (a[begin] > a[mid]) { Swap(&a[begin], &a[mid]); } // 将基准数交换到子序列的第二个位置 Swap(&a[mid], &a[begin + 1]); // 使用霍尔法进行划分 int KeyIndex = PartSort(a, begin, end); // 对左右子序列递归进行排序 QuickSortOptimized(a, begin, KeyIndex - 1); QuickSortOptimized(a, KeyIndex + 1, end); }
为了解决递归到较深处时,调用大量栈帧来实现短小的排序的“小题大做”问题,可以采用小区间优化的方法,即当待排序的子序列长度小于某个阈值时,不再使用递归方式进行排序,而是采用其他排序算法(如插入排序)对该子序列进行排序。通过直接对较短的子序列使用简单而高效的排序算法,避免了递归调用带来的开销,从而提高了排序的效率。
问题1:待排数组较短时,此时再使用递归方式对短数组进行快速排序为什么会导致效率下降?(小区间优化的必要性)
问题2:为什么我们选择了“插入排序”进行“小区间优化”?
为了实现小区间优化,我们选择了插入排序作为替代算法。插入排序算法的特点是对几乎有序的序列具有较好的性能。在较短的子序列中,由于经过快速排序的初步划分,可能已经接近有序或完全有序。这种情况下,插入排序能够利用序列的局部有序性,通过较少的比较和交换操作来完成排序。
相比于其他排序算法(如冒泡排序或选择排序),插入排序在局部有序性较好的情况下,具有更好的性能。它的时间复杂度为 O(n^2),但在较短的子序列中,由于交换的次数较少,实际的运行时间较快。因此,选择插入排序作为小区间优化的替代算法,能够有效地提高快速排序在短数组上的性能,进一步优化算法的效率。
[!Attention] 需要注意的是,当序列长度小于某个较小的固定值(如10或20)时,可以考虑应用小区间优化。
//插入排序 void InsertSort(int* a, int n) { for (int i = 0; i < n-1; ++i) { // [0, end] 有序,插入tmp依旧有序 int end = i; int tmp = a[i+1]; while (end >= 0) { if (a[end] > tmp) { a[end + 1] = a[end]; --end; } else { break; } } a[end + 1] = tmp; } }
// 三数取中法优化的快速排序递归实现(带小区间优化) void QuickSortOptimizedWithInsertion(int* a, int begin, int end) { // 小区间优化:当待排序子序列的长度小于等于10时,使用插入排序 if (end - begin + 1 <= 10) { InsertSort(a + begin, end - begin + 1); return; } // 三数取中法选取基准数 int mid = begin + (end - begin) / 2; if (a[begin] > a[mid]) { Swap(&a[begin], &a[mid]); } if (a[mid] > a[end]) { Swap(&a[mid], &a[end]); } if (a[begin] > a[mid]) { Swap(&a[begin], &a[mid]); } // 将基准数交换到子序列的第二个位置 Swap(&a[mid], &a[begin + 1]); // 使用霍尔法进行划分 int KeyIndex = PartSort(a, begin, end); // 对左右子序列递归进行排序 QuickSortOptimizedWithInsertion(a, begin, KeyIndex - 1); QuickSortOptimizedWithInsertion(a, KeyIndex + 1, end); }
#include <stdio.h> #include <string.h> #include <stdlib.h> #include "Stack.h" void PrintArray(int* a, int n) { for (int i = 0; i < n; ++i) { printf("%d ", a[i]); } printf("\n"); } void Swap(int* a, int* b) { int tmp = *b; *b = *a; *a = tmp; } // Hoare法单趟排序 int PartSort(int* a, int left, int right) { // 选择基准元素的索引为 left int KeyIndex = left; while (left < right) { // right 从右侧开始,找到第一个小于基准元素的值 while (left < right && a[right] >= a[KeyIndex]) { --right; } // left 从左侧开始,找到第一个大于基准元素的值 while (left < right && a[left] <= a[KeyIndex]) { ++left; } // 交换 left 和 right 的元素 Swap(&a[left], &a[right]); } // 将基准元素放置在最终的位置 Swap(&a[KeyIndex], &a[left]); return left; } // 挖坑法单趟排序 int PartSort2(int* a, int left, int right) { // 选择基准元素为左侧第一个元素 int Key = a[left]; int HoleIndex = left; // 坑的下标,初始为基准元素位置 while (left < right) { // 从右侧开始,找到第一个小于基准元素的值 while (left < right && a[right] >= Key) { --right; } // 将右侧小于基准元素的值填充到左侧的坑中 a[HoleIndex] = a[right]; HoleIndex = right; // 从左侧开始,找到第一个大于基准元素的值 while (left < right && a[left] <= Key) { ++left; } // 将左侧大于基准元素的值填充到右侧的坑中 a[HoleIndex] = a[left]; HoleIndex = left; } // 将基准元素放置到最后一个坑中,即填充到最终的位置 a[HoleIndex] = Key; return HoleIndex; } // 前后指针法 int PartSort3(int* arr, int left, int right) { int key = left; // 将最左边的元素作为基准值 int prev = left; // 后指针(动得慢) int cur = left + 1; // 前指针(动得快) while (cur <= right) { // 当cur遍历到的元素小于等于基准值时,先将prev指针向后移动一位,再将cur交换到prev指针指向的位置 if (arr[cur] <= arr[key] && ++prev != cur) { Swap(&arr[cur], &arr[prev]); } cur++; } // 最后将基准值交换到prev指针指向的位置,以完成单趟排序 Swap(&arr[key], &arr[prev]); return prev; // 返回基准值的位置,用于后续的划分 } // 快速排序的递归实现 void QuickSort(int* a, int begin, int end) { //两种情况下直接返回:1. 区间只有一个值 2. 区间不存在 if (begin >= end) { return; } // 使用单趟排序函数 PartSort 对序列进行划分,并获取基准元素的位置 int KeyIndex = PartSort(a, begin, end); // 对基准元素左侧的子序列排序 QuickSort(a, begin, KeyIndex - 1); // 对基准元素右侧的子序列排序 QuickSort(a, KeyIndex + 1, end); } // 非递归快速排序实现 void QuickSortNonR(int* a, int begin, int end) { ST st; // 定义一个栈,用于模拟递归过程 STInit(&st); // 初始化栈 // 将初始的 begin 和 end 压入栈,相当于递归的入口 STPush(&st, end); STPush(&st, begin); while (!STEmpty(&st)) { // 从栈中取出当前处理的 begin 和 end int left = STTop(&st); STPop(&st); int right = STTop(&st); STPop(&st); // 调用 PartSort 函数对序列进行划分,并得到基准元素的位置 int KeyIndex = PartSort(a, left, right); // 判断是否需要对右侧子序列进行排序,并将其入栈 if (KeyIndex + 1 < right) { STPush(&st, right); STPush(&st, KeyIndex + 1); } // 判断是否需要对左侧子序列进行排序,并将其入栈 if (left < KeyIndex - 1) { STPush(&st, KeyIndex - 1); STPush(&st, left); } } // 栈中所有任务处理完毕,排序完成 STDestroy(&st); } // 三值取中法优化的快速排序递归实现 void QuickSortOptimized(int* a, int begin, int end) { if (begin >= end) { return; } // 三值取中法选取基准数 int mid = begin + (end - begin) / 2; if (a[begin] > a[mid]) { Swap(&a[begin], &a[mid]); } if (a[mid] > a[end]) { Swap(&a[mid], &a[end]); } if (a[begin] > a[mid]) { Swap(&a[begin], &a[mid]); } // 将基准数交换到子序列的第二个位置 Swap(&a[mid], &a[begin + 1]); // 使用霍尔法进行划分 int KeyIndex = PartSort(a, begin, end); // 对左右子序列递归进行排序 QuickSortOptimized(a, begin, KeyIndex - 1); QuickSortOptimized(a, KeyIndex + 1, end); } //插入排序 void InsertSort(int* a, int n) { for (int i = 0; i < n - 1; ++i) { // [0, end] 有序,插入tmp依旧有序 int end = i; int tmp = a[i + 1]; while (end >= 0) { if (a[end] > tmp) { a[end + 1] = a[end]; --end; } else { break; } } a[end + 1] = tmp; } } // 三数取中法优化的快速排序递归实现(带小区间优化) void QuickSortOptimizedWithInsertion(int* a, int begin, int end) { // 小区间优化:当待排序子序列的长度小于等于10时,使用插入排序 if (end - begin + 1 <= 10) { InsertSort(a + begin, end - begin + 1); return; } // 三数取中法选取基准数 int mid = begin + (end - begin) / 2; if (a[begin] > a[mid]) { Swap(&a[begin], &a[mid]); } if (a[mid] > a[end]) { Swap(&a[mid], &a[end]); } if (a[begin] > a[mid]) { Swap(&a[begin], &a[mid]); } // 将基准数交换到子序列的第二个位置 Swap(&a[mid], &a[begin + 1]); // 使用霍尔法进行划分 int KeyIndex = PartSort(a, begin, end); // 对左右子序列递归进行排序 QuickSortOptimizedWithInsertion(a, begin, KeyIndex - 1); QuickSortOptimizedWithInsertion(a, KeyIndex + 1, end); } int main() { int arr[8] = { 55,74,32,67,12,23,49,87 }; printf("Before:\n"); PrintArray(arr, (sizeof(arr) / sizeof(int))); printf("After:\n"); QuickSortOptimizedWithInsertion(arr, 0, (sizeof(arr) / sizeof(int))-1); PrintArray(arr, (sizeof(arr) / sizeof(int))); return 0; }
void STInit(ST* pst) { assert(pst); pst->a = NULL; pst->top = 0; //top是栈顶的下一个位置的下标 pst->capacity = 0; } void STDestroy(ST* pst) { assert(pst); free(pst->a); pst->a = NULL; pst->capacity = pst->top = 0; } void STPush(ST* pst, STDataType x) { if (pst->top == pst->capacity) { int newCapacity = pst->capacity == 0 ? 4 : pst->capacity*2; STDataType* tmp = realloc(pst->a, newCapacity*sizeof(STDataType)); if (tmp == NULL) { perror("realloc fail"); return; } pst->a = tmp; pst->capacity = newCapacity; } pst->a[pst->top] = x; pst->top++; } void STPop(ST* pst) { assert(pst); assert(!STEmpty(pst)); pst->top--; } STDataType STTop(ST* pst) { assert(pst); assert(!STEmpty(pst)); return pst->a[pst->top - 1]; } int STSize(ST* pst) { assert(pst); return pst->top; } bool STEmpty(ST* pst) { assert(pst); if (pst->top == 0) { return true; } else { return false; } }
本文参考了:
- 书籍:
《大话数据结构》- 程杰- 博客:
- CSDN博主「手眼通天王水水」的原创文章http://t.csdn.cn/2ZpHx
遵循CC 4.0 BY-SA版权协议
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。