1、排序算法
排序算法(sorting algorithm)用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用,因为有序数据通常能够被更高效地查找、分析和处理。
1.1 评价维度
运行效率:我们期望排序算法的时间复杂度尽量低,且总体操作数量较少(时间复杂度中的常数项变小)。对于大数据量的情况,运行效率显得尤为重要。
就地性:顾名思义,原地排序通过在原数组上直接操作实现排序,无须借助额外的辅助数组,从而节省内存。通常情况下,原地排序的数据搬运操作较少,运行速度也更快。
稳定性:稳定排序在完成排序后,相等元素在数组中的相对顺序不发生改变。
稳定排序是多级排序场景的必要条件。假设我们有一个存储学生信息的表格,第 1 列和第 2 列分别是姓名和年龄。在这种情况下,非稳定排序可能导致输入数据的有序性丧失:
//输入数据是按照姓名排序好的
// (name, age)
('A', 19)
('B', 18)
('C', 21)
('D', 19)
('E', 23)
//假设使用非稳定排序算法按年龄排序列表,
//结果中 ('D', 19) 和 ('A', 19) 的相对位置改变,
//输入数据按姓名排序的性质丢失
('B', 18)
('D', 19)
('A', 19)
('C', 21)
('E', 23)
自适应性:自适应排序的时间复杂度会受输入数据的影响,即最佳时间复杂度、最差时间复杂度、平均时间复杂度并不完全相等。
自适应性需要根据具体情况来评估。如果最差时间复杂度差于平均时间复杂度,说明排序算法在某些数据下性能可能劣化,因此被视为负面属性;而如果最佳时间复杂度优于平均时间复杂度,则被视为正面属性。
是否基于比较:基于比较的排序依赖比较运算符来判断元素的相对顺序,从而排序整个数组,理论最优时间复杂度为 O(nlogn) 。而非比较排序不使用比较运算符,时间复杂度可达 O(n),但其通用性相对较差。
1.2 理想排序算法
运行快、原地、稳定、正向自适应、通用性好。显然,迄今为止尚未发现兼具以上所有特性的排序算法。因此,在选择排序算法时,需要根据具体的数据特点和问题需求来决定。接下来,我们将共同学习各种排序算法,并基于上述评价维度对各个排序算法的优缺点进行分析。
2、排序算法类别
2.1 选择排序
选择排序(selection sort)的工作原理非常简单:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。时间复杂度为 O(n²),空间复杂度为O(1)。
设数组的长度为 n,选择排序的算法流程如下所示:
1)初始状态下,所有元素未排序,即未排序(索引)区间为[0,n-1] 。
2)选取区间 [0,n-1] 中的最小元素,将其与索引 0处的元素交换。完成后,数组前 1 个元素已排序。
3)选取区间[1,n-1] 中的最小元素,将其与索引 1处的元素交换。完成后,数组前 2 个元素已排序。
4)以此类推。经过 n-1轮选择与交换后,数组前 n-1个元素已排序。
5)仅剩的一个元素必定是最大元素,无须排序,因此数组排序完成。
/* 选择排序 */
void selectionSort(vector<int> &nums) {
int n = nums.size();
// 外循环:未排序区间为 [i, n-1]
for (int i = 0; i < n - 1; i++) {
// 内循环:找到未排序区间内的最小元素
int k = i;
for (int j = i + 1; j < n; j++) {
if (nums[j] < nums[k])
k = j; // 记录最小元素的索引
}
// 将该最小元素与未排序区间的首个元素交换
swap(nums[i], nums[k]);
}
}
2.2 冒泡排序
冒泡排序(bubble sort)通过连续地比较与交换相邻元素实现排序。这个过程就像气泡从底部升到顶部一样,因此得名冒泡排序。时间复杂度为 O(n²),但当输入数组完全有序时,可达到最佳时间复杂度 O(n),空间复杂度为O(1)。
设数组的长度为 n,冒泡排序的算法流程如下所示:
1)首先,对 n 个元素执行“冒泡”,将数组的最大元素交换至正确位置 。
2)接下来,对剩余 n-1个元素执行“冒泡”,将第二大元素交换至正确位置。
3)以此类推,经过 n-1轮“冒泡”后,前 n-1 大的元素都被交换至正确位置。
4)仅剩的一个元素必定是最小元素,无须排序,因此数组排序完成。
/* 冒泡排序(标志优化)*/
void bubbleSortWithFlag(vector<int> &nums) {
// 外循环:未排序区间为 [0, i]
for (int i = nums.size() - 1; i > 0; i--) {
bool flag = false; // 初始化标志位
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换 nums[j] 与 nums[j + 1]
// 这里使用了 std::swap() 函数
swap(nums[j], nums[j + 1]);
flag = true; // 记录交换元素
}
}
if (!flag)
break; // 此轮“冒泡”未交换任何元素,直接跳出
}
}
2.3 插入排序
插入排序(insertion sort)是一种简单的排序算法,它的工作原理与手动整理一副牌的过程非常相似。具体来说,我们在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置。时间复杂度为 O(n²),但当输入数组完全有序时,可达到最佳时间复杂度 O(n),空间复杂度为O(1)。
实际上,许多编程语言(例如 Java)的内置排序函数采用了插入排序,大致思路为:对于长数组,采用基于分治策略的排序算法,例如快速排序;对于短数组,直接使用插入排序。
设数组的长度为 n,插入排序的算法流程如下所示:
1)初始状态下,数组的第 1 个元素已完成排序。
2)选取数组的第 2 个元素作为 base ,将其插入到正确位置后,数组的前 2 个元素已排序。
3)选取第 3 个元素作为 base ,将其插入到正确位置后,数组的前 3 个元素已排序。
4)以此类推,在最后一轮中,选取最后一个元素作为 base ,将其插入到正确位置后,所有元素均已排序。
/* 插入排序 */
void insertionSort