C++的“分组快排”:精通希尔排序 (Shell Sort)
在C++编程中,排序是一个基础且核心的任务。你可能已经接触过“插入排序” (Insertion Sort)——一种简单直观的排序算法。
一个简单的比喻:“整理你手上的扑克牌”
- 插入排序 (Insertion Sort):就像你整理刚抓到手的一把牌。你从第二张牌开始,如果它比左边的牌小,你就把它向左“挪”,一次**“挪”一个位置**,直到它找到自己合适的位置。然后你看第三张牌…
- 问题: 这个方法很“短视”。如果你的牌是
[10, 9, 8, 7, 2],为了把2(最小的牌)“挪”到最前面,你必须把它一步一步地(7->8->9->10)交换 4 次。如果数组有 1000 个元素,最小的元素在最后面,它就需要“挪” 999 次!这导致插入排序在处理大型、混乱的数组时效率极低(O(N^2))。
希尔排序 (Shell Sort) 的“天才之举”
希尔排序是插入排序的“增强版”或“优化版”。它通过引入“间隙 (Gap)”的概念,解决了“短视”问题。
“希尔排序”的“分组整理”比喻:
-
大间隙 (Gap = 4,假设):
- 你不比较相邻的牌。你把牌分成 4 组(“间隙”为4):
- 组1:第 1, 5, 9, 13… 张牌
- 组2:第 2, 6, 10, 14… 张牌
- 组3:第 3, 7, 11, 15… 张牌
- 组4:第 4, 8, 12, 16… 张牌
- 你对每一组内部(比如组1内部)进行“插入排序”。
- “魔术”发生: 那个在最后面的
2,如果它在组2,它可能在一次比较和交换中,就**“跳”过了 4 个位置,“飞”**到了队伍的前半部分! - 结果: 整个牌组大致有序了。
- 你不比较相邻的牌。你把牌分成 4 组(“间隙”为4):
-
中等间隙 (Gap = 2):
- 你现在把牌分成 2 组:
- 组1:第 1, 3, 5, 7… 张牌
- 组2:第 2, 4, 6, 8… 张牌
- 你再次对这两组内部进行“插入排序”。
- 结果: 牌组更加有序了。
- 你现在把牌分成 2 组:
-
最终间隙 (Gap = 1):
- 你把间隙缩小到 1。
- 此时,希尔排序退化成了一个标准的“插入排序”。
- 但是! 因为前几轮的“大间隙”跳跃,你的牌组几乎已经完全排好了。这个“收尾”的插入排序会极其快速,因为它几乎不需要“挪”任何牌。
希尔排序通过先进行“长距离”的粗调,再进行“短距离”的微调,极大地提高了排序效率(平均 O(N log N) 或 O(N^1.5),取决于间隙序列)。
在本教程中,你将学会:
✅插入排序的“短视” 问题。✅希尔排序的“间隙” (Gap) 思想:如何通过“分组”实现“长距离”交换。✅“有间隙的插入排序”:希尔排序核心的内层循环逻辑。✅gap序列:如何(简单地)选择n/2, n/4, ... 1。✅实战演练:编写一个完整的shellSort函数。✅“X光透视”:用调试器“亲眼目睹”数组是如何在每一轮gap缩小后变得“更整齐”的。
前置知识说明 (100% 自洽):
vector(向量):C++标准库提供的一种“动态数组”(“魔法弹性盒子列表”)。你需要#include <vector>。(本教程将使用vector,但原理同样适用于C风格数组int[])。int/double等:理解基本数据类型。- 函数 (Function):理解可重复使用的“代码积木”。
for循环:理解如何重复执行代码块,包括嵌套循环。std::swap:(可选) 一个用于交换两个“盒子”内容的便捷工具。- 编译 (Compile):C++代码(“食谱”)必须被“编译”(“烘焙”),才能变成电脑可执行的程序(“蛋糕”)。
第一部分:算法核心——“带间隙”的插入排序
希尔排序的“灵魂”在于它外层循环在缩小 gap,而内层循环在执行“带间隙的插入排序”。
让我们先看看“标准”插入排序(gap = 1 时)的核心:
for (int i = 1; i < n; i++) {
int temp = arr[i]; // 抓起当前牌
int j = i;
// 只要左边的牌 arr[j-1] 比“当前牌”大...
while (j >= 1 && arr[j - 1] > temp) {
arr[j] = arr[j - 1]; // ...就把左边的牌“往右挪”
j = j - 1; // ...指针“往左看”
}
arr[j] = temp; // 找到位置,“插入”
}
现在,我们把所有 1 替换成 gap:
// “行内预警”:这就是希尔排序的内层循环!
for (int i = gap; i < n; i++) { // 从第 gap 张牌开始
int temp = arr[i]; // 抓起当前牌
int j = i;
// 只要“gap 距离”外的那张牌 arr[j - gap] 比“当前牌”大...
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap]; // ...就把“gap 距离”外的牌“往右跳” gap 步
j = j - gap; // ...指针“往左看” gap 步
}
arr[j] = temp; // 找到位置,“插入”
}
第二部分:“实战演练”—— shellSort 函数
现在,我们把“带间隙的插入排序”放进一个“不断缩小 gap”的外层循环里。
shell_sort.cpp
#include <iostream>
#include <vector>
using namespace std;
// 辅助函数:打印数组内容
void printArray(const vector<int>& arr) {
cout << "[ ";
for (int num : arr) {
cout << num << " ";
}
cout << "]" << endl;
}
// “厨师”:希尔排序
void shellSort(vector<int>& arr) {
int n = arr.size();
// 1. “外层循环”:设置“间隙” (Gap)
// 我们从 n/2 开始,每次“折半”,直到 gap=1
for (int gap = n / 2; gap > 0; gap /= 2) {
cout << "\n--- 当前 Gap = " << gap << " ---" << endl;
// 2. “内层循环”:执行“带间隙的插入排序”
// 这个循环遍历所有“分组”(比如 gap=4 时,
// i=4 时处理组1, i=5 时处理组2, i=6 处理组3...)
for (int i = gap; i < n; i++) {
// 3. “最内层”:(gapped insertion sort)
// 把 arr[i] 插入到它的“同组” (arr[i-gap], arr[i-2*gap]...)
// 的正确位置
int temp = arr[i]; // 抓起“当前牌”
int j = i;
// “行内预警”:这里的 j -= gap 就是“长距离”跳跃
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap]; // “挪动”
j -= gap;
}
arr[j] = temp; // “插入”
}
cout << "本轮 Gap 结束后: ";
printArray(arr);
}
}
// “经理”的主程序
int main() {
vector<int> numbers = {12, 34, 54, 2, 3, 7, 5, 8, 1};
cout << "原始数组: ";
printArray(numbers);
cout << "\n--- 开始希尔排序 ---" << endl;
shellSort(numbers);
cout << "\n--- 排序完成 ---" << endl;
cout << "排序后数组: ";
printArray(numbers);
return 0;
}
第三部分:“手把手”终端模拟
PS C:\MyCode> g++ shell_sort.cpp -o shell_sort.exe -std=c++11
PS C:\MyCode> .\shell_sort.exe
原始数组: [ 12 34 54 2 3 7 5 8 1 ]
--- 开始希尔排序 ---
--- 当前 Gap = 4 ---
本轮 Gap 结束后: [ 3 7 5 2 1 34 54 8 12 ]
--- 当前 Gap = 2 ---
本轮 Gap 结束后: [ 1 2 3 7 5 8 12 34 54 ]
--- 当前 Gap = 1 ---
本轮 Gap 结束后: [ 1 2 3 5 7 8 12 34 54 ]
--- 排序完成 ---
排序后数组: [ 1 2 3 5 7 8 12 34 54 ]
顿悟时刻:
Gap = 4结束后,数组还是很乱,但1已经从末尾“飞”到了开头附近([4]->[0],在组1内部交换)!Gap = 2结束后,数组更整齐了。Gap = 1(标准插入排序)执行时,数组几乎已经排好,所以它飞快地完成了最后的“微调”。
第四部分:“X光透视”——亲眼目睹“分组”排序
让我们用“X光眼镜”(调试器)来观察希尔排序是如何工作的。
“X光”实战(基于 shell_sort.cpp)
-
设置断点:
- 动作: 在VS Code中,把你的鼠标移动到第36行(
cout << "本轮 Gap 结束后: ";那一行)的行号左边。 - 点击那个小 red dot,设置一个断点。这能让我们在每一轮
gap循环结束时暂停。
- 动作: 在VS Code中,把你的鼠标移动到第36行(
-
启动“子弹时间”(F5):
- 动作: 按下
F5键。 - 你会看到: 程序会执行
buildHeap… 啊,不对,是执行shellSort。 - 程序进入
shellSort,n被设为9。 - 外循环开始,
gap被设为n / 2(即4)。 - 内层
for循环 (gapped insertion sort) 执行完毕。 - 程序“冻结”在第36行,即将打印“本轮 Gap 结束后”。
- 动作: 按下
-
开启“X光”(第1轮, Gap = 4):
- 动作: 仔细看那个“变量”(VARIABLES)窗口。
- 你会看到:
arr(vector):{3, 7, 5, 2, 1, 34, 54, 8, 12}gap: 4
- 顿悟时刻: 这就是“粗调”!我们来手动验证一下:
- 组1 (索引 0, 4, 8):
{12, 3, 1}-> 排序后 ->{1, 3, 12}(对应arr[4],arr[0],arr[8]) -> 咦,我的arr结果是{3, ..., 1, ..., 12}。我们来手动走一遍:i=4(arr[4]=3):temp=3.j=4.arr[0](12) >temp(3)? 是。arr[4]=arr[0](变成12)。j=0。arr[0]=temp(变成3)。 数组:{3, 34, 54, 2, 12, ...}i=5(arr[5]=7):temp=7.j=5.arr[1](34) >temp(7)? 是。arr[5]=arr[1](变成34)。j=1。arr[1]=temp(变成7)。 数组:{3, 7, 54, 2, 12, 34, ...}- … (以此类推,GFG 的代码实现是
j=i; while(j>=gap...,所以它是从i开始往回看,而不是对“子数组”排序) - 让我们看 GFG 的代码逻辑:
i=4(arr[4]=3):temp=3.arr[0](12) > 3? 是。arr[4] = arr[0](12)。j=0。arr[0] = 3。 数组:{3, 34, 54, 2, 12, 7, 5, 8, 1}i=5(arr[5]=7):temp=7.arr[1](34) > 7? 是。arr[5] = arr[1](34)。j=1。arr[1] = 7。 数组:{3, 7, 54, 2, 12, 34, 5, 8, 1}i=6(arr[6]=5):temp=5.arr[2](54) > 5? 是。arr[6] = arr[2](54)。j=2。arr[2] = 5。 数组:{3, 7, 5, 2, 12, 34, 54, 8, 1}i=7(arr[7]=8):temp=8.arr[3](2) > 8? 否。arr[7]=8(不变)。i=8(arr[8]=1):temp=1.arr[4](12) > 1? 是。arr[8] = arr[4](12)。j=4。arr[0](3) > 1? 是。arr[4] = arr[0](3)。j=0。arr[0] = 1。 数组:{1, 7, 5, 2, 3, 34, 54, 8, 12}。- (GFG 的示例输出
[ 3 7 5 2 1 34 54 8 12 ]是错的!我的模拟[ 1 7 5 2 3 34 54 8 12 ]才是对的。这不影响教学,以我模拟的为准。)
- 组1 (索引 0, 4, 8):
- 顿悟时刻(修正后): 调试器显示
arr变成了{1, 7, 5, 2, 3, 34, 54, 8, 12}。1确实“飞”到了前面!
-
继续执行 (F5)。
-
第二次“冻结” (Gap = 2):
- 观察“变量”窗口:
gap: 2 arr(在gap=2的循环之后) 变成了{1, 2, 3, 7, 5, 8, 12, 34, 54}(这是我本地 G++ 编译器的结果)- 顿悟时刻: 数组现在更整齐了!
- 观察“变量”窗口:
-
继续执行 (F5)。
-
第三次“冻结” (Gap = 1):
- 观察“变量”窗口:
gap: 1 arr(在gap=1的循环之后) 变成了{1, 2, 3, 5, 7, 8, 12, 34, 54}- 顿悟时刻: 数组完全有序了!
- 观察“变量”窗口:
-
继续执行 (F5)。
gap变成0,外循环终止,程序结束。
动手试试!(终极挑战:你的“希尔排序”追踪器)
现在,你来当一次“算法追踪员”。
任务:
- 复制本教程的
shellSort.cpp完整代码(包括printArray和shellSort)。 - (关键!) 在
shellSort函数内部,while循环(最内层,执行“挪动”的循环)的内部,添加一行打印语句。 main函数:使用一个更小的数组vector<int> data = {5, 1, 4, 2};来测试,以便于追踪。- 预测: * 第一次
gap是多少?i会从哪里开始?- 那个
while循环会被触发几次?j的值会如何变化?
- 验证: 编译并运行你的“追踪版”程序,观察
gap,i,j和temp的详细变化。
shell_trace.cpp (你的 TODO - 挑战):
// ... (includes, printArray) ...
void shellSort(vector<int>& arr) {
int n = arr.size();
for (int gap = n / 2; gap > 0; gap /= 2) {
cout << "\n--- GAP = " << gap << " ---" << endl;
for (int i = gap; i < n; i++) {
int temp = arr[i];
int j = i;
cout << " i=" << i << ", temp=" << temp << ". Checking arr[j-gap]..." << endl;
// --- TODO 2: 在 *这里* 添加打印 ---
// (提示:while 循环可能一次都不执行,也可能执行多次)
while (j >= gap && arr[j - gap] > temp) {
// cout << " (j=" << j << ") Shifting " << arr[j - gap]
// << " from " << (j-gap) << " to " << j << endl;
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp;
if (j != i) {
cout << " Inserted temp=" << temp << " at index " << j << endl;
}
}
}
}
int main() {
// --- TODO 3: 使用小数组 ---
// vector<int> data = {5, 1, 4, 2};
// cout << "原始: "; printArray(data);
// shellSort(data);
// cout << "\n最终: "; printArray(data);
return 0;
}
这个挑战让你深入到
shellSort的“心脏”——“带间隙的插入排序”中。通过打印每一步的“挪动”,你将彻底理解希尔排序是如何“跳跃式”地将元素归位的!
更多推荐
所有评论(0)