目录
1 十大常见的排序算法
1.1 算法的稳定性
2 冒泡排序
2.1 算法原理
2.2 算法实现
2.3 时间空间复杂度分析
2.3.1 时间复杂度分析
2.3.2 空间复杂度分析
3 快速排序
3.1 算法原理
3.1.1 排序思想
3.1.2 递归过程
3.2 示例
3.2.1 示例 1
3.2.2 示例 2
3.2.3 示例 3
3.3 算法实现
3.4 时间空间复杂度分析
3.4.1 时间复杂度分析
3.4.2 空间复杂度分析
1 十大常见的排序算法
排序算法众多,实现方式各异,其时间复杂度、空间复杂度和稳定性各不相同,如下图所示:
1.1 算法的稳定性
- 稳定排序算法:如果一个排序算法在排序前后,相等的元素之间的相对顺序保持不变,那么这个算法是稳定的。
- 不稳定排序算法:如果一个排序算法在排序前后,相等的元素之间的相对顺序可能发生改变,那么这个算法是不稳定的。
假设有一个学生记录列表,每个记录包含学生的姓名和成绩:
[("Alice", 85), ("Bob", 90), ("Charlie", 85), ("David", 90)]
按成绩升序排序:
-
稳定排序算法:
- 排序结果:[("Alice", 85), ("Charlie", 85), ("Bob", 90), ("David", 90)]
- 相同成绩的学生(85 分的 Alice 和 Charlie,90 分的 Bob 和 David)保持了原来的顺序。
-
不稳定排序算法:
- 排序结果:[("Charlie", 85), ("Alice", 85), ("David", 90), ("Bob", 90)]
- 相同成绩的学生的顺序可能发生变化。
2 冒泡排序
2.1 算法原理
冒泡排序是一种直观且易于理解的排序算法,其基本思想是通过不断地交换相邻的、顺序错误的元素,逐步将较大的元素(如果是升序排列的话)像水泡一样 “浮” 到序列的末尾。具体步骤如下:
1. 比较相邻元素:从数组的第一个元素开始,逐一比较相邻的两个元素。
2. 交换位置:如果前一个元素大于后一个元素(对于升序排序而言),则交换这两个元素的位置。如果是降序排序,则当发现前一个元素小于后一个元素时进行交换。
3. 一趟遍历完成:经过一次完整的遍历后,数组中最大的元素(升序时)或最小的元素(降序时)会被移动到数组的最后一个位置。
4. 重复遍历与交换:在排除了已排序的最后一个元素后,再次从头开始重复第 1 步和第 2 步的操作,直到整个数组中的所有元素都被正确地排序。
5. 循环遍历直至完全排序:整个过程会不断重复,每完成一次遍历就使得更多的元素处于正确的位置,直到没有更多的交换发生,即整个数组被完全排序为止。
通过上述步骤,冒泡排序能够有效地对一组数字进行排序,尽管它的效率在大规模数据集上不如更高级的排序算法,如快速排序或归并排序,但对于小规模数据集来说,冒泡排序仍然是一个很好的选择。
如下图所示:
2.2 算法实现
#include <stdio.h>
// 冒泡排序函数
void bubbleSort(int arr[], int size)
{
// 外层循环控制遍历次数(n-1)次
for (int i = 0; i < size - 1; i++)
{
// 内层循环进行相邻元素的比较和交换 (n-1-i)次
for (int j = 0; j < size - i - 1; j++)
{
// 如果前一个元素大于后一个元素,则交换
if (arr[j] > arr[j + 1])
{
// 交换 arr[j] 和 arr[j+1]
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
int main()
{
// 定义一个待排序的数组
int arr[] = {3, 1, 5, 4, 2};
int size = sizeof(arr) / sizeof(arr[0]); // 计算数组长度
// 输出原始数组
printf("原始数组: ");
for (int i = 0; i < size; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
// 调用冒泡排序函数
bubbleSort(arr, size); // 传递数组名即传递数组首元素的地址
// 输出排序后的数组
printf("排序后的数组: ");
for (int i = 0; i < size; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}
输出结果如下所示:
下面是优化后的冒泡排序:
#include <stdio.h>
// 冒泡排序函数
void bubbleSort(int arr[], int size)
{
// 外层循环控制遍历次数 (n-1)次
for (int i = 0; i < size - 1; i++)
{
// 设置标志位,用于检测本轮是否有元素交换
int swapped = 0;
// 内层循环进行相邻元素的比较和交换 (n-1-i)次
for (int j = 0; j < size - i - 1; j++)
{
// 如果前一个元素大于后一个元素,则交换
if (arr[j] > arr[j + 1])
{
// 交换 arr[j] 和 arr[j+1]
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
// 标记发生了交换
swapped = 1;
}
}
// 如果没有发生任何交换,说明数组已经有序,提前退出,避免不必要的遍历。
if (!swapped)
{
break;
}
}
}
优化点解释:
设置标志位 swapped
:
- 在每次内层循环开始时,设置一个标志位
swapped
为 0。 - 如果在内层循环中发生了元素交换,将
swapped
设置为 1。 - 内层循环结束后,检查
swapped
是否仍为 0。如果是,则说明数组已经有序,可以提前退出外层循环,避免不必要的遍历。
注意:
即使外层循环 i < size - 1 不小心写成了 i < size,内层循环 j < size - i - 1 不小心写成了 j < size - 1,最终的结果也不会错。这是因为在这些情况下,虽然会进行一些多余的比较和交换,但不会影响最终的排序结果。
外层循环
i < size
:
- 正确的外层循环条件是
i < size - 1
,这样可以确保在最后一趟遍历时,只剩下最后一个元素,而这个元素已经是有序的。- 如果写成
i < size
,外层循环会多进行一次遍历,即进行size
次遍历。- 在第
size
次遍历时,内层循环j < size - 1
会比较所有相邻的元素,但由于数组已经排序完成,不会有元素交换发生。因此,这次遍历是多余的,但不会影响最终的排序结果。内层循环
j < size - 1
:
- 正确的内层循环条件是
j < size - i - 1
,这样可以确保每次遍历只比较未排序部分的元素。- 如果写成
j < size - 1
,内层循环会比较所有相邻的元素,包括已经排序好的部分。- 由于已经排序好的部分不会再发生交换,这些多余的比较不会影响最终的排序结果。
尽管这些错误不会影响最终的排序结果,但会导致不必要的比较和交换,从而降低算法的效率。因此,建议使用正确的循环条件以提高性能。
2.3 时间空间复杂度分析
2.3.1 时间复杂度分析
-
最坏情况(Worst Case):
- 当输入数组是完全逆序的时,冒泡排序需要进行最多的比较和交换操作。
- 每次内层循环都需要进行 n−1 次比较和最多 n−1 次交换。
- 因此,总的时间复杂度为 O(n^2)。
-
最好情况(Best Case):
- 当输入数组已经是有序的时,冒泡排序只需要进行一次完整的遍历,而且不会进行任何交换。
- 使用优化后的冒泡排序(带有标志位
swapped
),可以在第一次遍历后就提前结束。 - 因此,总的时间复杂度为 O(n)。
-
平均情况(Average Case):
- 对于随机分布的数组,冒泡排序的时间复杂度通常也是 O(n^2)。
- 这是因为在大多数情况下,数组不会完全有序,也不会完全逆序,但仍然需要多次比较和交换。
2.3.2 空间复杂度分析
- 原地排序:
- 冒泡排序是一种原地排序算法,即它只需要常数级的额外存储空间。
- 主要的额外空间用于临时变量(如交换元素时使用的
temp
变量)。 - 因此,空间复杂度为 O(1)。
3 快速排序
3.1 算法原理
快速排序(Quick Sort)是由图灵奖获得者 Tony Hoare 发明的一种高效排序算法,被列为 20 世纪十大算法之一。快速排序以其卓越的性能和简洁的实现方式,在实际应用中非常广泛,尤其是在处理大规模数据时表现尤为出色。快速排序的时间复杂度为 O(n log n),通常比其他同样具有 O(n log n) 时间复杂度的排序算法更快。
3.1.1 排序思想
快速排序的核心思想是 “分治法”,即通过递归的方式将大问题分解成小问题来解决。具体步骤如下:
1. 选择基准(Pivot):从数列中挑选一个元素作为 “基准”(pivot)。选择基准的方法有多种,常见的包括选择第一个元素、最后一个元素、中间元素或随机选择一个元素。
2. 分区(Partition):重新排列数列,使得所有比基准值小的元素都摆放在基准的前面,所有比基准值大的元素都摆放在基准的后面。相同的元素可以任意放置。在这个分区操作结束之后,基准元素会处于数列的中间位置,这个位置称为基准的最终位置。
3. 递归排序:递归地对基准左侧的子数列和右侧的子数列进行快速排序。递归的最底层情况是子数列的大小为零或一,这时子数列已经自然排序好了。
3.1.2 递归过程
-
递归的最底层:当子数列的大小为零或一时,递归结束。这是因为单个元素或空数列自然是有序的。
-
递归的迭代:在每次递归调用中,快速排序都会将一个元素放到其最终位置。因此,随着递归的进行,越来越多的元素会被正确排序,最终整个数列将变得有序。
3.2 示例
3.2.1 示例 1
假设有一个数列 [3, 6, 8, 10, 1, 2, 1]
,我们选择第一个元素 3
作为基准:
1. 分区:
- 重新排列数列,使得所有比
3
小的元素在左边,所有比3
大的元素在右边。结果可能是[1, 2, 1, 3, 10, 6, 8]
。
2. 递归排序:
- 对基准左侧的子数列
[1, 2, 1]
进行快速排序。 - 对基准右侧的子数列
[10, 6, 8]
进行快速排序。
3. 递归结束:
- 当子数列的大小为零或一时,递归结束。
通过上述步骤,快速排序能够高效地对数列进行排序。尽管快速排序在最坏情况下(例如数列已经有序或完全逆序)的时间复杂度退化为 O(n^2),但在实际应用中,通过合理选择基准和优化分区操作,快速排序通常能保持其优秀的性能。
3.2.2 示例 2
3.2.3 示例 3
第 1 轮操作:
第 2 轮操作:
3.3 算法实现
#include <stdio.h>
// 子排序函数,实现快速排序的核心逻辑
void subSort(int arr[], int start, int end)
{
if (start < end)
{
// 选择基准元素,这里选择数组的第一个元素
int base = arr[start];
int low = start;
int high = end + 1;
// 分区操作
while (1)
{
// 从左向右查找大于基准的元素
while (low < end && arr[++low] <= base)
;
// 从右向左查找小于基准的元素
while (high > start && arr[--high] >= base)
;
// 如果找到了需要交换的元素
if (low < high)
{
// 交换两个位置的元素
int temp = arr[low];
arr[low] = arr[high];
arr[high] = temp;
}
else
{
// 分区结束,跳出循环
break;
}
}
// 交换基准元素和 high 位置的元素
int temp1 = arr[start];
arr[start] = arr[high];
arr[high] = temp1;
// 递归调用子排序函数,对基准左侧和右侧的子数组进行排序
subSort(arr, start, high - 1);
subSort(arr, high + 1, end);
}
}
// 快速排序主函数
void quickSort(int arr[], int size)
{
// 调用子排序函数,从数组的第一个元素到最后一个元素
subSort(arr, 0, size - 1);
}
// 打印数组
void print(int arr[], int size)
{
for (int i = 0; i < size; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
}
int main()
{
// 定义一个待排序的数组
int arr[] = {9, -16, 40, 23, -30, -49, 25, 21, 30};
int length = sizeof(arr) / sizeof(int); // 计算数组长度
// 输出排序前的数组
printf("排序前的数组: ");
print(arr, length);
// 调用快速排序函数
quickSort(arr, length);
// 输出排序后的数组
printf("排序后的数组: ");
print(arr, length);
return 0;
}
输出结果如下所示:
另一种实现方法:
#include <stdio.h>
// 函数声明
void quickSort(int arr[], int low, int high);
int partition(int arr[], int low, int high);
// 主函数
int main()
{
int arr[] = {10, 7, 8, 9, 1, 5, -13, -20, -19, -50};
int n = sizeof(arr) / sizeof(arr[0]);
printf("原始数组: \n");
for (int i = 0; i < n; i++)
{
printf("%d ", arr[i]);
}
// 调用快速排序函数
quickSort(arr, 0, n - 1);
printf("\n排序后的数组: \n");
for (int i = 0; i < n; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
// 快速排序函数
void quickSort(int arr[], int low, int high)
{
if (low < high)
{
// pi 是分区操作后的基准元素索引
int pi = partition(arr, low, high);
// 分别对基准元素左边和右边的子数组进行递归排序
quickSort(arr, low, pi - 1); // 排序左子数组
quickSort(arr, pi + 1, high); // 排序右子数组
}
}
// 分区函数
int partition(int arr[], int low, int high)
{
int pivot = arr[high]; // 选择最后一个元素作为基准
int i = (low - 1); // i 指向较小元素的索引
for (int j = low; j <= high - 1; j++)
{
// 如果当前元素小于或等于基准
if (arr[j] <= pivot)
{
i++; // 增加较小元素的索引
// 交换 arr[i] 和 arr[j]
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
// 交换 arr[i+1] 和 arr[high] (或 pivot)
int temp = arr[i + 1];
arr[i + 1] = arr[high];
arr[high] = temp;
return (i + 1); // 返回基准元素的最终位置
}
输出结果如下所示:
3.4 时间空间复杂度分析
3.4.1 时间复杂度分析
-
最坏情况(Worst Case):
- 当输入数组已经有序或完全逆序时,每次划分只能将数组分成一个元素和剩下的元素,导致递归深度为 n。
- 每次划分需要 O(n) 的时间,因此总的时间复杂度为 O(n^2)。
-
最好情况(Best Case):
- 当每次划分都能将数组均匀分成两个相等的部分时,递归深度为 log n。
- 每次划分需要 O(n) 的时间,因此总的时间复杂度为 O(n log n)。
-
平均情况(Average Case):
- 对于随机分布的数组,快速排序的平均时间复杂度也是 O(n log n)。
- 这是因为在大多数情况下,数组的划分接近均匀。
3.4.2 空间复杂度分析
-
递归栈空间:
- 快速排序是一个递归算法,递归调用的深度决定了所需的空间。
- 在最坏情况下,递归深度为 n,因此空间复杂度为 O(n)。
- 在最好和平均情况下,递归深度为 log n,因此空间复杂度为 O(log n)。
-
辅助空间:
- 快速排序是原地排序算法,除了递归栈空间外,只需要常数级的额外存储空间。
- 因此,辅助空间复杂度为 O(1)。