在日常的开发工作中,我们经常需要处理各种数据排序的任务。虽然现代编程语言的标准库已经提供了高度优化的排序函数,但作为一名追求卓越的工程师,理解底层的排序算法依然至关重要。这不仅有助于我们应对面试中的技术挑战,更能让我们在面对特定场景时(如嵌入式系统或小规模数据集)做出最佳的性能选择。
今天,我们将深入探讨两种最基础且广泛使用的排序算法:插入排序 和 选择排序。很多初学者容易混淆这两者,因为它们在代码结构上看起来有些相似,且时间复杂度在表面上也都是 $O(n^2)$。但实际上,它们的工作机制、适用场景以及性能特征有着本质的区别。让我们一起来揭开它们的面纱。
核心概览:两者究竟有何不同?
简单来说,这两种算法的核心区别在于它们如何构建有序序列以及如何处理未排序的元素:
- 选择排序 的核心思想是“挑选”。它就像是一个严格的筛选过程,每次都在剩余的乱序元素中找到最小(或最大)的那个,然后直接放到已排序部分的末尾。它的关注点始终在未排序部分。
- 插入排序 的核心思想是“插入”。它的工作方式类似于我们整理扑克牌:拿到一张牌,将其插入到手中已排好序的牌的合适位置。它的关注点在于已排序部分,通过移动元素来腾出空间。
在深入代码之前,让我们先通过一个直观的对比表格来看看它们的主要特性差异。
插入排序
:—
将未排序元素插入到已排序部分的正确位置。
稳定 (相等元素的相对顺序不变)。
较多 (最坏 $O(n^2)$ 次赋值)。
$O(n)$ (当数组已经有序时)。
$O(n^2)$ (当数组逆序时)。
小规模数据、基本有序的数据。
—
深入理解插入排序
#### 算法原理
插入排序的逻辑非常符合人类直觉。假设我们左手拿着一张张牌,右手去抓牌。每次抓到一张新牌(未排序元素),我们都会用眼睛扫描左手已有的牌(已排序部分),找到这张新牌应该插入的位置,然后把比它大的牌往后挪一格,最后把新牌插进去。
具体步骤如下:
- 划分:将数组分为两部分。初始时,我们认为第一个元素已经是“已排序”的,剩下的都是“未排序”的。
- 选取:从“未排序”部分取出第一个元素(我们称之为
key)。 - 比较与移动:将 INLINECODE1d6b0735 与“已排序”部分的元素从右向左依次比较。如果 INLINECODE65d834c9 小于当前元素,就将当前元素向右移动一位。
- 插入:重复上述过程,直到找到
key的正确位置,将其插入。 - 重复:对未排序部分的每个元素重复步骤 2-4,直到整个数组有序。
#### 代码实现与解析
让我们用 C++ 来实现这一逻辑。为了帮助你更好地理解,我在代码中添加了详细的中文注释。
// C++ program for insertion sort
#include
using namespace std;
// 插入排序函数
// 参数 arr: 待排序的数组
// 参数 n: 数组的长度
void insertionSort(int arr[], int n)
{
int i, key, j;
// 从第二个元素开始遍历,因为我们将第一个元素视为默认已排序
for (i = 1; i = 0 确保不越界
// 条件:arr[j] > key 确保我们只移动比 key 大的元素
while (j >= 0 && arr[j] > key)
{
// 将比 key 大的元素向右移动一位,腾出空间
arr[j + 1] = arr[j];
// j 向左移动,继续比较前一个元素
j = j - 1;
}
// 3. 循环结束后,j + 1 就是 key 的正确位置,将其插入
arr[j + 1] = key;
}
}
// 辅助函数:打印数组内容
void printArray(int arr[], int n)
{
for (int i = 0; i < n; i++)
cout << arr[i] << " ";
cout << endl;
}
// 主函数
int main()
{
// 测试数据
int arr[] = { 12, 11, 13, 5, 6 };
int N = sizeof(arr) / sizeof(arr[0]);
cout << "原始数组: ";
printArray(arr, N);
// 调用插入排序
insertionSort(arr, N);
cout << "排序后数组: ";
printArray(arr, N);
return 0;
}
#### 插入排序的实战分析
- 最佳情况:如果输入数组本身就是升序的,内层循环的条件
arr[j] > key永远不满足。这意味着我们不需要做任何移动操作。算法只需遍历一次数组,时间复杂度为 $O(n)$。这使得插入排序在处理几乎有序的数据时非常高效。 - 最坏情况:如果输入数组是降序的,每次拿到的
key都比前面所有的数小,内层循环需要执行最多次的移动。此时时间复杂度为 $O(n^2)$。 - 稳定性:因为只有当 INLINECODEf32e9fd3 时才移动,等于 INLINECODE5c25eba5 的元素不会跨越
key,所以它是稳定的。
#### 实际应用场景
在实际开发中,插入排序的一个著名应用是 TimSort(Java 和 Python 中 sort() 方法的默认实现之一)。TimSort 将归并排序和插入排序结合起来,利用插入排序在处理小规模或局部有序数据上的优势,极大地提升了整体性能。
—
深入理解选择排序
#### 算法原理
与插入排序不同,选择排序的逻辑更加“简单粗暴”。它的目标非常明确:每次只找最小的。
具体步骤如下:
- 初始化:将整个数组视为未排序。假设当前下标 INLINECODEcd76af95 为最小值下标 INLINECODE5abac998。
- 扫描:在剩余的未排序部分(即 INLINECODE7c60db7d 到 INLINECODEdc5a8183)中,寻找真正的最小值。
- 更新:如果在扫描过程中发现比 INLINECODE0bc9703f 更小的元素,就更新 INLINECODE3f204896。
- 交换:一轮扫描结束后,将找到的最小元素 INLINECODEd2a151b8 与当前位置 INLINECODE7f2e094c 的元素交换。
- 重复:移动
i,重复上述过程,直到数组末尾。
#### 代码实现与解析
下面是选择排序的完整实现。请注意观察它的交换次数与插入排序的区别。
// C++ program for selection sort
#include
using namespace std;
// 交换两个元素的辅助函数
void swap(int *xp, int *yp)
{
int temp = *xp;
*xp = *yp;
*yp = temp;
}
// 选择排序函数
void selectionSort(int arr[], int n)
{
int i, j, min_idx;
// 遍历数组边界,最后一个元素自然会排好,所以 i < n-1
for (i = 0; i < n - 1; i++)
{
// 1. 假设当前下标 i 是最小值的下标
min_idx = i;
// 2. 在未排序部分 (i+1 到 n-1) 寻找真正的最小值
for (j = i + 1; j < n; j++)
{
// 如果发现比当前记录的最小值还小的元素
if (arr[j] < arr[min_idx])
{
// 更新最小值下标
min_idx = j;
}
}
// 3. 如果最小值下标变了,则交换位置
// 注意:这里我们每一轮只做一次交换
if (min_idx != i)
{
swap(&arr[min_idx], &arr[i]);
}
}
}
// 辅助函数:打印数组
void printArray(int arr[], int n)
{
for (int i = 0; i < n; i++)
cout << arr[i] << " ";
cout << endl;
}
// 主函数
int main()
{
int arr[] = { 64, 25, 12, 22, 11 };
int n = sizeof(arr) / sizeof(arr[0]);
cout << "原始数组: ";
printArray(arr, n);
selectionSort(arr, n);
cout << "排序后数组: ";
printArray(arr, n);
return 0;
}
#### 选择排序的实战分析
- 交换次数:这是选择排序最大的亮点。无论数组多么乱,每一轮它最多只进行一次交换。这对于那些写入操作(Write Operation)成本极高的场景(例如 EEPROM 内存)非常有用。
- 时间复杂度:这一点很糟糕。无论数组是已经排好序的,还是完全逆序的,它都必须老老实实地执行两层循环,找出每一个位置的最小值。因此,它的最好、最坏和平均复杂度都是 $O(n^2)$。
- 不稳定性:选择排序通常是不稳定的。考虑数组 INLINECODE35c7f253。第一轮,我们找到最小值 2,将它与第一个 5 交换。数组变成 INLINECODE465d0341。虽然数值没变,但原本在第一个位置的 5 跑到了第二个 5 的后面,相对顺序改变了。
—
性能大比拼:谁更胜一筹?
为了更直观地感受差异,让我们编写一段综合测试代码,对比两者在不同数据规模下的表现。
#include
#include // 用于计时
#include // 用于 std::reverse
using namespace std;
using namespace std::chrono;
// ... (此处省略 insertionSort 和 selectionSort 的实现代码,同上) ...
// 辅助函数:复制数组以进行公平测试
void copyArray(int source[], int dest[], int n) {
for(int i=0; i<n; i++) dest[i] = source[i];
}
int main() {
const int SIZE = 5000; // 数据规模
int* arr_original = new int[SIZE];
int* arr_test = new int[SIZE];
// 生成随机数据
for(int i=0; i<SIZE; i++) arr_original[i] = rand() % 10000;
cout << "正在测试数据规模: " << SIZE << endl;
// 测试选择排序
copyArray(arr_original, arr_test, SIZE);
auto start = high_resolution_clock::now();
selectionSort(arr_test, SIZE);
auto stop = high_resolution_clock::now();
auto duration_sel = duration_cast(stop - start);
cout << "选择排序耗时: " << duration_sel.count() << " 毫秒" << endl;
// 测试插入排序
copyArray(arr_original, arr_test, SIZE);
start = high_resolution_clock::now();
insertionSort(arr_test, SIZE);
stop = high_resolution_clock::now();
auto duration_ins = duration_cast(stop - start);
cout << "插入排序耗时: " << duration_ins.count() << " 毫秒" << endl;
delete[] arr_original;
delete[] arr_test;
return 0;
}
如果你运行这段代码,你会发现对于完全随机的大数据量,插入排序通常会比选择排序快一些(尽管都是 $O(n^2)$,但常数因子不同)。特别是当我们将数组设置为“基本有序”时,插入排序的速度将快得惊人,几乎是瞬间完成,而选择排序依然慢吞吞的。
关键要点总结
- 选用的依据:
* 当你的数据量小(例如 $n < 50$)或者数据基本有序时,插入排序是绝对的王者。它的常数因子小,且最佳情况为 $O(n)$。
* 当你非常在意写入操作(交换)的次数,或者内存写入受限时,选择排序可能更合适,因为它每轮最多只交换一次数据。
- 稳定性很重要:
* 如果你需要保持相同键值对象的原始相对顺序(例如按“成绩”排序后,同分的依然按“学号”排序),请务必选择插入排序或其他稳定的排序算法。
- 避免在大型项目中手动实现:
* 在生产环境中处理大规模数据时,请优先使用标准库提供的 INLINECODEfe877ca7 (C++) 或 INLINECODEc1b16300 (Java),它们通常是快速排序或归并排序的复杂变体,性能远优于这两种 $O(n^2)$ 的基础算法。
希望这篇文章能帮助你彻底搞懂插入排序和选择排序的区别!建议你自己动手敲一敲上面的代码,感受一下算法运行的节奏。如果你有任何疑问,欢迎随时交流。