7. 排序
Chinese-language Computer Science 408 note: 7. 排序. Original source available where applicable.
On this page
This note is currently available in Chinese. The original wording, formulas and diagrams are preserved to avoid an unverified technical translation.
ABSTRACT · 本节范围 本笔记逐页覆盖原 PDF 的 8 页和 7 道实际题目:题 1、2、4、5、6、7、8。原文件本身没有题 3。内容包括直接插入、链表插入、折半插入、冒泡、快速、简单选择、堆和归并排序;代码统一为 C++17。
IMPORTANT · 先统一下标 除题 1 的哨兵版本明确使用
records[1..n]外,其余顺序表代码都使用values[0..n-1]。稳定是指关键字相等的记录在排序后仍保持原相对次序。
flowchart TD
S[本节内部排序] --> I[插入类]
I --> D[直接插入]
I --> BI[折半插入]
S --> X[交换类]
X --> B[冒泡]
X --> Q[快速]
S --> C[选择类]
C --> SS[简单选择]
C --> H[堆排序]
S --> M[归并类]
M --> MS[二路归并]
1. 插入排序
题 1:直接插入排序 - 顺序表
在第 $i$ 趟开始时,records[1..i-1] 已经有序。先把 records[i] 暂存在哨兵 records[0],再从右向左移动所有严格大于它的元素,最后写入空位。只移动“大于”而不移动“等于”,因此算法稳定。
#include <algorithm>
#include <cstddef>
#include <vector>
// records[0] 是哨兵,真正数据位于 records[1..n]。
void insertionSortWithSentinel(std::vector<int>& records) {
if (records.size() <= 2) {
return;
}
const int n = static_cast<int>(records.size()) - 1;
for (int current = 2; current <= n; ++current) {
records[0] = records[current];
int previous = current - 1;
while (records[0] < records[previous]) {
records[previous + 1] = records[previous];
--previous;
}
records[previous + 1] = records[0];
}
}
最好情况为已有序,只需 $\Theta(n)$ 次比较且几乎不移动;平均和最坏时间为 $\Theta(n^2)$,辅助空间 $\Theta(1)$。
DANGER · 原稿错误 原稿把
j声明在for的局部作用域中,却在循环结束后继续使用R[j+1],标准 C++ 无法编译。当前版本把下标声明移到循环外,并保留哨兵防止向左越界。
题 1:直接插入排序 - 带头结点单链表
先让第一个数据结点独立成为有序链,再逐个摘下无序区结点,在有序区寻找插入前驱。寻找时越过所有“小于或等于”当前关键字的结点,才能让后出现的相等记录仍排在后面。
struct ListNode {
int key = 0;
ListNode* next = nullptr;
};
// head 是带头结点、无环的单链表。
void insertionSortList(ListNode* head) {
if (head == nullptr || head->next == nullptr) {
return;
}
ListNode* unsorted = head->next->next;
head->next->next = nullptr;
while (unsorted != nullptr) {
ListNode* node = unsorted;
unsorted = unsorted->next;
ListNode* previous = head;
while (previous->next != nullptr
&& previous->next->key <= node->key) {
previous = previous->next;
}
node->next = previous->next;
previous->next = node;
}
}
链表不支持随机访问,因此不能用折半查找降低定位比较次数;整体时间仍为 $O(n^2)$,只改指针而不搬移记录,辅助空间 $O(1)$。
DANGER · 原稿错误 原稿未处理空链表与只有头结点的情况,并在判断
pre->next != null之前先访问pre->next->data,可能解引用空指针;用<寻找位置还会把后出现的相等结点插到前面,破坏稳定性。
题 2:折半插入排序
有序前缀上使用二分查找,找到当前关键字的上界位置 [low, high):若相等则继续向右,因此插入到已有相等元素之后。二分只减少比较次数,后移元素仍可能达到 $\Theta(n^2)$。
void binaryInsertionSort(std::vector<int>& values) {
for (std::size_t current = 1;
current < values.size(); ++current) {
const int key = values[current];
std::size_t low = 0;
std::size_t high = current;
while (low < high) {
const std::size_t middle =
low + (high - low) / 2;
if (key < values[middle]) {
high = middle;
} else {
low = middle + 1;
}
}
for (std::size_t move = current;
move > low; --move) {
values[move] = values[move - 1];
}
values[low] = key;
}
}
关键字比较次数为 $\Theta(n\log n)$;最好总时间为 $\Theta(n\log n)$,平均和最坏时间为 $\Theta(n^2)$,辅助空间 $\Theta(1)$,算法稳定。
DANGER · 原稿错误 原稿把移动循环放进二分循环的错误层级,并使用未声明的
i、j;high = mid - 1与最终插入high + 1的边界也没有统一。当前版本直接在半开区间中求稳定上界。
2. 交换排序
题 4:冒泡排序
第 1 趟把未排序区最大元素交换到末尾;每完成一趟,右侧有序区扩大 1。只有相邻元素严格逆序时才交换,所以相等记录不会越过彼此。
void bubbleSort(std::vector<int>& values) {
for (std::size_t end = values.size(); end > 1; --end) {
bool changed = false;
for (std::size_t current = 1;
current < end; ++current) {
if (values[current] < values[current - 1]) {
std::swap(values[current], values[current - 1]);
changed = true;
}
}
if (!changed) {
return;
}
}
}
带提前结束标志时,最好时间 $\Theta(n)$,平均和最坏时间 $\Theta(n^2)$;辅助空间 $\Theta(1)$,算法稳定。
DANGER · 原稿错误 原稿把
false拼成flase,且注释把标志语义说反:changed == true表示本趟发生过交换,只有changed == false才能证明序列已有序。
题 5:快速排序
以下“挖坑法”取区间首元素为枢轴。右侧扫描跳过大于或等于枢轴的元素,左侧扫描跳过小于或等于枢轴的元素;必须包含等号,否则遇到大量重复关键字时两个指针可能都不移动。
int partition(std::vector<int>& values, int low, int high) {
const int pivot = values[low];
while (low < high) {
while (low < high && values[high] >= pivot) {
--high;
}
values[low] = values[high];
while (low < high && values[low] <= pivot) {
++low;
}
values[high] = values[low];
}
values[low] = pivot;
return low;
}
void quickSortRange(std::vector<int>& values,
int low,
int high) {
if (low >= high) {
return;
}
const int pivotPosition = partition(values, low, high);
quickSortRange(values, low, pivotPosition - 1);
quickSortRange(values, pivotPosition + 1, high);
}
void quickSort(std::vector<int>& values) {
if (!values.empty()) {
quickSortRange(values, 0,
static_cast<int>(values.size()) - 1);
}
}
划分较均衡时,最好和平均时间为 $\Theta(n\log n)$、递归栈平均 $O(\log n)$;若每次枢轴都是极值,时间和递归栈分别退化为 $\Theta(n^2)$ 与 $O(n)$。算法通常不稳定。随机选枢轴或三数取中可降低持续退化的概率,但不能改变理论最坏界。
DANGER · 原稿错误 原稿缺少分号并把实参写成
part(A[], low, high),函数边界也被花括号截断;更关键的是两侧扫描都使用严格不等号,重复值可能使循环停滞。
3. 选择排序
题 6:简单选择排序
第 $i$ 趟在无序区 [i, n) 中找最小元素,与 values[i] 交换。长距离交换可能让相等记录跨越,所以标准简单选择排序不稳定。
void selectionSort(std::vector<int>& values) {
for (std::size_t first = 0;
first + 1 < values.size(); ++first) {
std::size_t minimum = first;
for (std::size_t current = first + 1;
current < values.size(); ++current) {
if (values[current] < values[minimum]) {
minimum = current;
}
}
if (minimum != first) {
std::swap(values[first], values[minimum]);
}
}
}
最好、平均、最坏时间均为 $\Theta(n^2)$,但交换次数最多 $n-1$;辅助空间 $\Theta(1)$,算法不稳定。原稿主体思路正确,本稿只避免无意义的自交换并补全性质边界。
题 7:堆排序
0 下标数组中,结点 $i$ 的左右孩子分别为 $2i+1$、$2i+2$。升序排列使用大根堆:先在线性时间内建堆,再反复把堆顶最大值换到当前堆尾,并对缩小后的堆向下调整。
void siftDown(std::vector<int>& values, int root, int high) {
const int rootValue = values[root];
for (int child = 2 * root + 1;
child <= high;
child = 2 * root + 1) {
if (child < high
&& values[child] < values[child + 1]) {
++child;
}
if (rootValue >= values[child]) {
break;
}
values[root] = values[child];
root = child;
}
values[root] = rootValue;
}
void heapSort(std::vector<int>& values) {
const int n = static_cast<int>(values.size());
for (int root = n / 2 - 1; root >= 0; --root) {
siftDown(values, root, n - 1);
}
for (int end = n - 1; end > 0; --end) {
std::swap(values[0], values[end]);
siftDown(values, 0, end - 1);
}
}
建堆为 $\Theta(n)$,随后 $n-1$ 次调整总计 $\Theta(n\log n)$;最好、平均和最坏排序时间均为 $\Theta(n\log n)$,辅助空间 $\Theta(1)$,算法不稳定。
DANGER · 原稿错误 原稿在确认
j < high之前先访问arr[j+1],可能越界;下沉后又拿已被覆盖的arr[i]与孩子比较,而非与暂存根值比较。建堆循环误写成i++,排序阶段还混用了a与arr。
4. 归并排序
题 8:二路归并排序
递归排序左右半区后,用辅助数组保存原区间,再以 left、right 两个指针归并。相等时先取左半区记录,才能保持稳定;辅助数组只在入口申请一次,避免每层递归重复分配。
void mergeRange(std::vector<int>& values,
std::vector<int>& buffer,
int low,
int middle,
int high) {
for (int index = low; index <= high; ++index) {
buffer[index] = values[index];
}
int left = low;
int right = middle + 1;
for (int output = low; output <= high; ++output) {
if (left > middle) {
values[output] = buffer[right++];
} else if (right > high) {
values[output] = buffer[left++];
} else if (buffer[left] <= buffer[right]) {
values[output] = buffer[left++];
} else {
values[output] = buffer[right++];
}
}
}
void mergeSortRange(std::vector<int>& values,
std::vector<int>& buffer,
int low,
int high) {
if (low >= high) {
return;
}
const int middle = low + (high - low) / 2;
mergeSortRange(values, buffer, low, middle);
mergeSortRange(values, buffer, middle + 1, high);
mergeRange(values, buffer, low, middle, high);
}
void mergeSort(std::vector<int>& values) {
if (values.empty()) {
return;
}
std::vector<int> buffer(values.size());
mergeSortRange(values, buffer, 0,
static_cast<int>(values.size()) - 1);
}
最好、平均和最坏时间均为 $\Theta(n\log n)$,辅助数组占 $\Theta(n)$,递归栈占 $O(\log n)$,总体辅助空间 $\Theta(n)$;算法稳定。
DANGER · 原稿错误
new B[maxsize]不是合法的动态数组表达式;主归并循环的控制变量与条件混乱,一侧剩余时又从已被覆盖的A而非备份B复制,且辅助数组既未复用也未释放。
5. 复杂度与性质总表
| 算法 | 最好时间 | 平均时间 | 最坏时间 | 主要辅助空间 | 稳定性 |
|---|---|---|---|---|---|
| 直接插入 | $\Theta(n)$ | $\Theta(n^2)$ | $\Theta(n^2)$ | $\Theta(1)$ | 稳定 |
| 折半插入 | $\Theta(n\log n)$ | $\Theta(n^2)$ | $\Theta(n^2)$ | $\Theta(1)$ | 稳定 |
| 优化冒泡 | $\Theta(n)$ | $\Theta(n^2)$ | $\Theta(n^2)$ | $\Theta(1)$ | 稳定 |
| 快速排序 | $\Theta(n\log n)$ | $\Theta(n\log n)$ | $\Theta(n^2)$ | 平均 $O(\log n)$,最坏 $O(n)$ | 不稳定 |
| 简单选择 | $\Theta(n^2)$ | $\Theta(n^2)$ | $\Theta(n^2)$ | $\Theta(1)$ | 不稳定 |
| 堆排序 | $\Theta(n\log n)$ | $\Theta(n\log n)$ | $\Theta(n\log n)$ | $\Theta(1)$ | 不稳定 |
| 二路归并 | $\Theta(n\log n)$ | $\Theta(n\log n)$ | $\Theta(n\log n)$ | $\Theta(n)$ | 稳定 |
NOTE · 折半插入的最好情况 即使输入已有序,本实现仍会对每个有序前缀执行二分查找,因此关键字比较总数为 $\Theta(n\log n)$;它只把移动次数降到 $\Theta(n)$,不能像直接插入那样达到线性总时间。
6. 原题覆盖与纠错索引
| 原题号 | 内容 | 状态 |
|---|---|---|
| 1 | 顺序表直接插入 + 链表插入排序 | 已覆盖,补全空链与稳定性 |
| 2 | 折半插入排序 | 已覆盖,统一半开区间边界 |
| 4 | 冒泡排序 | 已覆盖,修正标志含义与拼写 |
| 5 | 快速排序 | 已覆盖,修正重复值停滞与递归边界 |
| 6 | 简单选择排序 | 已覆盖,补全性质 |
| 7 | 堆排序 | 已覆盖,修正越界、建堆方向和变量名 |
| 8 | 归并排序 | 已覆盖,修正缓冲区、循环和剩余段复制 |
NOTE · 题号说明 原 PDF 的实际编号是题 1、2、4、5、6、7、8,共 7 题;文件中没有题 3,并非本稿遗漏。
相关知识
7. 本节复盘
- 先写清数组下标体系、有序区和无序区边界,再写循环;哨兵版的
records[0]不属于数据区。 - 插入排序和冒泡排序只让严格逆序记录移动,因而可以稳定;折半插入要把新记录放到相等记录之后。
- 快排两侧扫描必须防止指针交叉,并妥善处理等于枢轴的记录;枢轴选择影响划分质量。
- 简单选择排序比较次数不随初始序列改变;堆排序建堆为 $\Theta(n)$,完整排序为 $\Theta(n\log n)$。
- 0 下标堆的左右孩子是 $2i+1$、$2i+2$;比较右孩子前必须先确认它存在。
- 归并时相等先取左表才能稳定,且剩余元素必须从未被覆盖的辅助数组复制。
- 所有模板都要验证空表、单元素、重复值、负数、已有序和逆序输入。