👋 欢迎来到“C语言算法学习”系列!
快速排序(Quick Sort)是一种非常高效的排序算法,广泛用于实践中。在这篇文章中,我们将详细介绍快速排序的工作原理、C语言实现,并提供一些优化建议、常见问题的解答以及编程技巧。
🌟 快速排序简介
快速排序是分治算法的一种,它的基本思想是:
- 选择一个“分界点”元素,将数组分成两部分,使得左边的所有元素都不大于分界点,右边的所有元素都不小于分界点。
- 然后,递归地对这两部分进行排序。
分治算法的三步骤:
- 分成子问题:选择一个分界点,将数组分成两部分。
- 递归处理子问题:分别对左边和右边的子数组进行排序。
- 合并子问题:由于数组本身在排序过程中被分成了有序的部分,因此无需额外合并操作。
快速排序的基本步骤:
- 确定分界点
x
:通常选择数组的中间位置,或者随机选一个元素作为分界点。 - 调整区间:通过一轮扫描,将比
x
小的元素放到左边,比x
大的元素放到右边。 - 递归排序:对左右两部分继续递归执行上述操作。
🔍 快速排序模板的实现
1. 快速排序的核心代码
以下是一个快速排序算法模板的C语言实现:
#include <stdio.h>
#define N 100010
int n;
int q[N];
// 快速排序
void quick_sort(int q[], int l, int r) {
// 递归终止条件
if (l >= r) return;
// 选择分界点
int i = l - 1, j = r + 1, x = q[(l + r) >> 1];
// 划分区间
while (i < j) {
do i++; while (q[i] < x); // 找到比分界点大的元素
do j--; while (q[j] > x); // 找到比分界点小的元素
if (i < j) {
// 交换元素
int temp = q[i];
q[i] = q[j];
q[j] = temp;
}
}
// 递归处理子问题
quick_sort(q, l, j);
quick_sort(q, j + 1, r);
}
int main() {
// 输入数据
scanf("%d", &n);
for (int k = 0; k < n; k++) scanf("%d", &q[k]);
// 调用快速排序
quick_sort(q, 0, n - 1);
// 输出排序结果
for (int k = 0; k < n; k++) printf("%d ", q[k]);
return 0;
}
2. 代码说明
- 快速排序函数:
quick_sort(int q[], int l, int r)
:通过递归对数组进行排序。l
和r
表示当前区间的左右边界。- 在每次递归中,我们选择中间位置的元素作为分界点,将数组分成两部分,左边小于等于分界点,右边大于等于分界点。
- 然后通过递归对子数组进行排序。
- 递归终止条件:当左边界
l
不再小于右边界r
时,递归结束。
📈快速排序算法的更规范实现
之所以说更正规,是因为算法教材上一般都是这么写的
快速排序的核心在于其划分步骤,即如何将数据划分成两个子数组。提供的代码采用的是Hoare分区方案,这是一种经典而高效的划分方法。
算法组成
-
partition():划分函数
// 划分函数 int partition(int a[], int l, int r) { // 选择基准元素 int i = l, j = r + 1; int x = a[l]; // 划分区间 while (true) { while (a[++i] < x && i < r) // 从左向右找第一个大于等于 x 的元素, 注意边界 ; while (a[--j] > x && j > l) // 从右向左找第一个小于等于 x 的元素, 注意边界 ; if (i >= j) // 如果两个指针相遇,则退出循环 break; // 交换元素 int temp = a[i]; a[i] = a[j]; a[j] = temp; } // 此时 j 指向分界点元素 a[l] = a[j]; // 将分界点元素放到正确的位置 a[j] = x; // 将基准元素放到最终位置 return j; // 返回分界点位置 }
-
参数:
a[]
: 需要排序的数组。l
: 当前子数组的起始索引。r
: 当前子数组的结束索引。
-
作用:
partition()
函数的主要任务是选择一个“基准”元素(代码中选择的是子数组的第一个元素,即a[l]
),然后重新排列子数组,使得所有小于基准的元素位于基准的左边,而所有大于基准的元素位于基准的右边。函数最终返回基准元素在排序后应处的位置。
-
过程描述:
- 选择子数组中的第一个元素作为基准
x
。 - 用两个指针
i
和j
分别从左到右,以及从右到左扫描数组,找到需要交换的元素。 - 在
i
、j
未相遇时,交换两个指针当前指向的元素。 - 最终将基准元素放置到正确的位置上,达到划分的目的,并返回新的基准位置
j
。
- 选择子数组中的第一个元素作为基准
-
-
randomPartition():随机化划分
// 随机选择基准值 int randomPartition(int q[], int l, int r) { int i = l + rand() % (r - l + 1); // 随机选择一个元素作为基准元素 int temp = q[i]; // 将分界点元素与最左边元素交换 q[i] = q[l]; q[l] = temp; return partition(q, l, r); // 调用 partition 函数进行划分 }
- 作用:
- 为了优化划分过程并避免快速排序在一些特殊输入情况下退化为 O(n^2) 时间复杂度,这一函数在每次划分时随机选择一个基准元素,从而提高整体算法的平均性能。
- 过程描述:
- 在子数组内随机选择一个下标
i
,然后将a[i]
和a[l]
交换,以此来改变基准元素。 - 调用
partition()
函数执行实际的划分步骤。
- 在子数组内随机选择一个下标
-
quick_sort():快速排序递归函数
// 快速排序函数 void quick_sort(int q[], int l, int r) { if (l >= r) return; int i = randomPartition(q, l, r); // 随机选择分界点并进行划分 // 以下没有对分界点进行处理,因为分界点已经在正确的位置上了 quick_sort(q, l, i - 1); // 递归处理左子区间 quick_sort(q, i + 1, r); // 递归处理右子区间 }
-
参数:
q[]
: 待排序的数组。l
: 当前处理子数组的起始索引。r
: 当前处理子数组的结束索引。
-
作用:
- 对数组
q[]
的子数组q[l...r]
进行排序。
- 对数组
-
过程描述:
- 当子数组包含一个或多个元素(即
l < r
),进行递归排序:- 调用
randomPartition()
来确定一个基准位置i
。 - 对基准左边子数组
q[l...i-1]
进行递归排序。 - 对基准右边子数组
q[i+1...r]
进行递归排序。
- 调用
- 随着递归的逐层展开,子数组不断变小,直到每个子数组包含一个元素或为空为止。
- 当子数组包含一个或多个元素(即
-
完整代码
#include <stdio.h>
#include <stdlib.h>
#define N 100010
int n;
int q[N];
// 划分函数
int partition(int a[], int l, int r)
{
// 选择基准元素
int i = l, j = r + 1;
int x = a[l];
// 划分区间
while (true)
{
while (a[++i] < x && i < r) // 从左向右找第一个大于等于 x 的元素, 注意边界
;
while (a[--j] > x && j > l) // 从右向左找第一个小于等于 x 的元素, 注意边界
;
if (i >= j) // 如果两个指针相遇,则退出循环
break;
// 交换元素
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}
// 此时 j 指向分界点元素
a[l] = a[j]; // 将分界点元素放到正确的位置
a[j] = x; // 将基准元素放到最终位置
return j; // 返回分界点位置
}
// 随机选择基准值
int randomPartition(int q[], int l, int r)
{
int i = l + rand() % (r - l + 1); // 随机选择一个元素作为基准元素
int temp = q[i]; // 将分界点元素与最左边元素交换
q[i] = q[l];
q[l] = temp;
return partition(q, l, r); // 调用 partition 函数进行划分
}
// 快速排序函数
void quick_sort(int q[], int l, int r)
{
if (l >= r)
return;
int i = randomPartition(q, l, r); // 随机选择分界点并进行划分
// 以下没有对分界点进行处理,因为分界点已经在正确的位置上了
quick_sort(q, l, i - 1); // 递归处理左子区间
quick_sort(q, i + 1, r); // 递归处理右子区间
}
int main()
{
// 输入数据
scanf("%d", &n);
for (int k = 0; k < n; k++)
scanf("%d", &q[k]);
// 调用快速排序
quick_sort(q, 0, n - 1);
// 输出排序结果
for (int k = 0; k < n; k++)
printf("%d ", q[k]);
return 0;
}
🧠 算法分析
1. 时间复杂度
- 最佳情况:当每次划分都非常均匀(即分界点总能将数组分成两个几乎相等的部分),此时每一层递归的工作量为 O(K),总的递归层数为 O(log K),因此时间复杂度为 O(K log K)。
- 最坏情况:当每次划分都不均匀(例如数组已经是有序的),此时每次划分只减少一个元素,递归深度为 O(K),因此最坏情况下的时间复杂度为 O(K^2)。
- 平均情况:在大多数情况下,快速排序的时间复杂度为 O(K log K)。
2. 空间复杂度
快速排序的空间复杂度主要来自于递归调用栈。每次递归都在栈上保存一些状态,递归深度为 O(log K),因此空间复杂度为 O(log K)。另外,快速排序的原地排序性质意味着我们不需要额外的存储空间来保存排序后的结果。
⚡ 常见问题与优化建议
1. 如何选择分界点?
- 中间元素:通常选择数组的中间元素作为分界点,这样能在大多数情况下实现较好的时间复杂度。
- 随机选择:为了避免最坏情况的发生,我们可以随机选择分界点,从而减少最坏情况出现的概率。
- 三数取中法:通过选择数组的首、尾和中间元素中的中位数作为分界点,进一步提高算法的稳定性。
2. 最坏情况如何优化?
- 快速排序在数组已经是有序的情况下表现最差,时间复杂度会退化为 O(K^2)。为了避免这种情况,可以使用随机化快速排序(即随机选择分界点)来减少最坏情况出现的概率。
3. 递归深度太大怎么办?
- 如果数组很大,递归深度可能会很大,导致栈溢出。可以通过尾递归优化,或者使用显式的栈来模拟递归过程,避免栈溢出。
4. 如何优化空间复杂度?
- 虽然快速排序是原地排序的算法,但递归调用时仍然需要一些额外的空间。可以使用非递归版本的快速排序,手动管理递归栈,从而优化空间复杂度。
🛠️ 编程技巧与优化建议
-
避免重复排序:如果数组的子部分已经有序,可以跳过排序。可以在递归中加入判断条件,只有在需要排序时才进行递归。
-
随机化分界点:为了避免最坏情况,可以通过随机化分界点来减少数组已经有序时最坏情况的发生几率。
-
选择合适的基准元素:对于某些特殊数据,选择最小值或最大值作为基准元素可能导致效率较低,可以采用三数取中法来选择基准元素,或使用随机化基准选择。
-
改进内存管理:在实际开发中,尽量减少不必要的内存分配和释放,合理使用内存空间。
🚀 总结
快速排序是一种非常高效的排序算法,在平均情况下具有 O(K log K) 的时间复杂度,能够在大规模数据上高效排序。我们通过理解其分治思想,结合优化建议,能够在实际应用中实现高效排序。
希望本篇文章能帮助你更好地理解快速排序。如果你有任何问题或建议,欢迎在评论区留言讨论!一起进步,一起成长!
✍️ 读者互动
- 👉 你在实际开发中遇到过哪些关于快速排序的挑战?
- 👉 你有使用其他排序算法的经验吗?欢迎分享你的想法!
希望本篇文章对你学习算法能有所帮助!有任何问题和想法欢迎评论区一起交流探讨、共同进步!