Lecture 19: 基本排序算法 (Basic Sorting Algorithms)

目录 · ← l18 · l20 →

Lecture 19: 基本排序算法 (Basic Sorting Algorithms)

概述

本讲解决的核心问题是:给定一个数组与一个序关系 (ordering),如何高效地把它重排成有序序列。 我们依次分析插入排序 (insertion sort)选择排序 (selection sort)冒泡排序 (bubble sort)归并排序 (merge sort)快速排序 (quicksort) 的机制、代价与适应场景;随后说明任何基于比较的排序都不可能快过 Ω(n log n),而整数键可用计数排序/基数排序突破这一下界。 最后引入泛型排序 (generic sorting)qsort(base, nmemb, size, compar)函数指针 (function pointer) 把”比较规则”从算法中剥离出来,使同一份排序代码能处理 intdouble、结构体与字符串——这正是 Lecture 12 回调思想最著名的应用。

核心概念与底层机制图解

  • 排序问题的形式化与四个评价维度:输入是数组与比较函数,输出是满足序关系的一个排列。
    • 直观解释:像给一叠考卷按分数排队,规则可以自己定(升序、降序、先按年龄再按姓名)。
    • 底层机制图解:评价排序算法看四件事:时间(最坏/平均/最好)、空间(是否原地 in-place)、稳定性 (stability)(相等元素是否保持原次序)、适应性 (adaptivity)(近乎有序时是否更快)。稳定排序是”多关键字排序”的前提(先按次关键字排,再按主关键字稳定排)。
    • 作用域与存储期:所有排序都原地修改调用者数组;辅助数组(归并的 aux)由算法自己 malloc/free,属于 allocated storage duration。
  • 插入排序 (insertion sort):把数组看成”左侧已排序 + 右侧待插入”,每次取出一个元素向左找到位置插入——像整理手中的扑克牌,每抓到一张新牌就插进已排好的牌中。
    • 底层机制图解:第 i 轮把 a[i] 暂存为 key,从 i-1 向左扫描:只要 a[j] > key 就右移一格(a[j+1] = a[j]),遇到 a[j] <= key 即停,把 key 写入空位。关键性质:内层循环会在第一个不大于 key 的元素处立即 break。因此当数组已经有序时,每轮只做一次比较,总代价 O(n);一般而言代价为 O(n + 逆序对数量)——这就是”插入排序在近乎有序的数据上极快”的根本原因。课程的 isort.c 正是这一算法对任意元素类型的泛型版本。
    • 作用域与存储期:只用 key 一个 automatic 变量,空间 O(1);把元素”右移”而不是交换,使元素移动次数等于逆序对数量,对”移动代价高的元素”更划算。
  • 选择排序 (selection sort):第 i 轮在 a[i..n-1] 中选出最小值并与 a[i] 交换——像每次从剩下的考卷里挑出分数最低的那张放到队尾。
    • 底层机制图解:比较次数恒为 n(n-1)/2(实测 n = 7 时为 21),与输入顺序完全无关;交换最多 n-1 次,是三种 O(n²) 算法中写内存最少的,但不是稳定排序。
    • 作用域与存储期:空间 O(1),只用一个 min 下标变量;因为交换次数少,在”写操作昂贵”的存储介质上有历史价值。
  • 冒泡排序 (bubble sort) 与提前退出 (early exit):相邻两两比较,大的元素像气泡一样浮到末尾——像一排人比身高,相邻两人矮的往前站,一轮下来最高的被推到队尾。
    • 底层机制图解:第 i 轮把第 i 大的元素放到 a[n-1-i];用 swapped 记录本轮是否发生交换,整轮无交换即有序列退出。加了提前退出后,有序输入只需 n-1 次比较(实测 cmp = 6,与插入排序相同);退化输入仍是 O(n²),且每次交换要 3 次赋值,实践中几乎总劣于插入排序。
    • 作用域与存储期:空间 O(1)swapped 是 automatic 变量,生命周期仅限本轮循环。
  • 归并排序 (merge sort):把数组一分为二、各自递归排序、再把两个有序段合并 (merge)
    • 直观解释:两叠已经排好的牌,只要反复比较两叠的顶牌、取走较小的那张,就能合并成一叠有序牌。
    • 底层机制图解:递推式 T(n) = 2T(n/2) + O(n),展开得 O(n log n)(每层合并总代价 O(n),共 log n 层)。必须额外一块 O(n) 的辅助数组(本讲 sorts.c 中的 aux),因为合并无法在原地安全完成;它是稳定的(相等元素优先取左段),且对链表特别友好(链表合并无需辅助数组)。比较次数几乎与输入无关(实测 cmp = 14)。
    • 作用域与存储期:递归深度与栈开销均为 O(log n);辅助数组由 merge_sort 分配、返回前 free
  • 快速排序 (quicksort)、Lomuto 与 Hoare 分区:选一个基准 (pivot),把数组分成”小于等于 pivot”与”大于 pivot”两段,再递归排序两段——像按身高把队伍分成矮的一队和高的一队,再各自排队。
    • 底层机制图解Lomuto 分区a[hi] 作 pivot,i 指向”小于等于 pivot 区域的末尾”:jlo 扫到 hi-1,凡 a[j] <= pivoti++ 并与 a[i] 交换;最后把 pivot 换到 i+1 并返回该下标。Hoare 分区取中间元素作 pivot,ij 两端相向而行,遇到”左边不小于 pivot、右边不大于 pivot”就交换,直到相遇并返回 j;递归区间是 [lo, j][j+1, hi]不是 j-1,否则死循环)。Lomuto 代码简单;Hoare 交换更少(实测随机输入:Lomuto cmp=11, swap=9,Hoare cmp=32, swap=5),实践中更常用。
    • 作用域与存储期:原地排序,空间只有递归栈 O(log n)(平均),最坏 O(n)
  • 最坏情况与基准选择 (worst case and pivot choice):当每次划分都极度不平衡时,快速排序退化为 O(n²)
    • 直观解释:如果每次挑中的”基准”都恰好是最大或最小的那个,队伍就永远只被分成”一个元素 + 其余全部”。
    • 底层机制图解:对已经有序的输入使用”取末尾元素作 pivot”的 Lomuto,每轮刚好分出 0 个和 n-1 个元素,T(n) = T(n-1) + O(n) 给出 O(n²)。实测有序输入 {1..7}:Lomuto 需要 cmp = 21 = n(n-1)/2swap = 27,而 Hoare(中间 pivot)cmp = 26swap = 0n 越大差距越是 n log n 之别。三种常用对策:随机化 pivot、取”首/中/尾中位数”、小数组改用插入排序(cutoff)。
    • 作用域与存储期:全部元素相等时 Hoare 仍把数组分成两半,避免 Lomuto 退化。
  • 五种排序的对比 (comparison table)
算法最好平均最坏额外空间稳定适应性n=7 实测比较/交换
插入排序 insertionO(n)O(n²)O(n²)O(1)(逆序对相关)15 / 17
选择排序 selectionO(n²)O(n²)O(n²)O(1),写最少21 / 5
冒泡排序 bubbleO(n)O(n²)O(n²)O(1)有(提前退出)20 / 11
归并排序 mergeO(n log n)O(n log n)O(n log n)O(n)14 / 20
快速排序 quicksortO(n log n)O(n log n)O(n²)O(log n)11 / 9(Lomuto)
  • 比较排序的下界 Ω(n log n):任何只通过”比较两个元素”获取信息的排序算法,最坏情况至少需要 Ω(n log n) 次比较。
    • 直观解释n 个元素共有 n! 种排列,算法必须能把它们全部区分开;每次比较只能得到”是/否”两种回答,因此至少需要 log2(n!) 个问题。
    • 底层机制图解:把算法执行过程画成一棵决策树 (decision tree):内部结点是比较、叶子是输出排列。n 个元素有 n! 种可能排列,所以叶子数 ≥ n!,高度 ≥ log2(n!)。用 Stirling 近似 log2(n!) ≈ n log2 n - 1.44n,因此下界是 Ω(n log n)。具体数字:n = 10^6log2(n!) ≈ 1.9 × 10^7,而冒泡排序的 n(n-1)/2 ≈ 5 × 10^11 次比较——相差四万倍。
    • 作用域与存储期:这条下界只约束基于比较的算法;不比较元素值(而是直接利用键的整数值)的算法不受它限制。
  • 非比较排序 (non-comparison sorts):计数排序与基数排序利用”键是小范围整数”这一额外信息——把分数为 k 的考卷直接丢进第 k 号箱子,最后从 0 号箱子依次取走,不用互相比较。
    • 底层机制图解计数排序 (counting sort) 用大小为 k(键的取值范围)的计数数组,时间 O(n + k)、空间 O(n + k),且稳定(关键是从后往前回写);基数排序 (radix sort)d 位数字做 d 轮稳定排序,时间 O(d(n + k))。代价是它们只适用于整数键k 很大时空间爆炸。
    • 作用域与存储期:计数/输出数组都是 allocated storage duration,函数返回前必须 free
  • 泛型排序与函数指针 (generic sorting and function pointers):把”如何比较”外置为一个回调函数,排序算法只依赖这个回调。
    • 直观解释:像一台”通用分拣机”:机器只负责搬动与比较,至于”A 是否应该排在 B 前面”由你插入的规则卡片决定。
    • 底层机制图解void qsort(void* base, size_t nmemb, size_t size, int (*compar)(const void*, const void*));base 是首地址、nmemb 是元素个数、size 是每元素的字节数,三者合起来让 qsort 能做通用指针算术:第 i 个元素地址是 (char*)base + i * size(必须先转 char*,因为 void* 不能做算术)。compar 必须返回三路结果(负/零/正)。这就是 Lecture 12 的回调:qsort 里写着 (*compar)(p1, p2),具体调用谁要到运行时才知道(汇编层是一次 JSRR)。
    • 作用域与存储期:比较函数通常是文件作用域的 static 函数,具有 static storage duration,其地址(函数指针值)在整个程序运行期间不变。

代码示例与底层机制分析

示例 1:五种排序算法在同一输入上的比较与交换次数

代码 (C)/tmp/ece220_l19/sorts.c(节选算法本体;完整文件含计数与打印,已编译运行):

static void insertion_sort(int32_t a[], int32_t n)
{
    int32_t i, j, key;
    for (i = 1; n > i; i++) {
        key = a[i];
        for (j = i - 1; 0 <= j; j--) {
            cmp_count++;
            if (a[j] <= key) {
                break;                  /* 已有序时立刻退出,这就是适应性 */
            }
            a[j + 1] = a[j];            /* 右移一格,而不是交换 */
            swap_count++;
        }
        a[j + 1] = key;
        swap_count++;
    }
}

static void selection_sort(int32_t a[], int32_t n)
{
    int32_t i, j, min;
    for (i = 0; n - 1 > i; i++) {
        min = i;
        for (j = i + 1; n > j; j++) {
            cmp_count++;
            if (a[j] < a[min]) {
                min = j;
            }
        }
        if (min != i) {
            swap(&a[i], &a[min]);
        }
    }
}

static void bubble_sort(int32_t a[], int32_t n)
{
    int32_t i, j, swapped;
    for (i = 0; n - 1 > i; i++) {
        swapped = 0;
        for (j = 0; n - 1 - i > j; j++) {
            cmp_count++;
            if (a[j] > a[j + 1]) {
                swap(&a[j], &a[j + 1]);
                swapped = 1;
            }
        }
        if (!swapped) {
            break;                      /* 提前退出:本轮无交换 => 已有序 */
        }
    }
}

static void merge(int32_t a[], int32_t lo, int32_t mid, int32_t hi, int32_t aux[])
{
    int32_t i = lo, j = mid + 1, k;
    for (k = lo; hi >= k; k++) {
        aux[k] = a[k];                  /* 先把整段拷进辅助数组 */
    }
    for (k = lo; hi >= k; k++) {
        if (i > mid) {
            a[k] = aux[j++];            /* 左半用尽 */
        } else if (j > hi) {
            a[k] = aux[i++];            /* 右半用尽 */
        } else {
            cmp_count++;
            if (aux[j] < aux[i]) {      /* 严格小于才取右边 => 稳定 */
                a[k] = aux[j++];
            } else {
                a[k] = aux[i++];
            }
        }
        swap_count++;
    }
}

static int32_t partition_lomuto(int32_t a[], int32_t lo, int32_t hi)
{
    int32_t pivot = a[hi], i = lo - 1, j;
    for (j = lo; hi > j; j++) {
        cmp_count++;
        if (a[j] <= pivot) {
            i++;
            swap(&a[i], &a[j]);
        }
    }
    swap(&a[i + 1], &a[hi]);            /* pivot 归位 */
    return i + 1;
}

static int32_t partition_hoare(int32_t a[], int32_t lo, int32_t hi)
{
    int32_t pivot = a[lo + (hi - lo) / 2], i = lo - 1, j = hi + 1;
    for (;;) {
        do {
            i++;
            cmp_count++;
        } while (a[i] < pivot);
        do {
            j--;
            cmp_count++;
        } while (a[j] > pivot);
        if (i >= j) {
            return j;                   /* 返回 j,递归 [lo,j] 与 [j+1,hi] */
        }
        swap(&a[i], &a[j]);
    }
}

验证到的真实输出(输入 {38, 27, 43, 3, 9, 82, 10}gcc -g -std=c99 -Wall -Werror sorts.c -o sorts && ./sorts):

insertion sort            :   3   9  10  27  38  43  82   cmp=15 swap=17
selection sort            :   3   9  10  27  38  43  82   cmp=21 swap= 5
bubble sort               :   3   9  10  27  38  43  82   cmp=20 swap=11
merge sort                :   3   9  10  27  38  43  82   cmp=14 swap=20
quicksort (Lomuto)        :   3   9  10  27  38  43  82   cmp=11 swap= 9
quicksort (Hoare)         :   3   9  10  27  38  43  82   cmp=32 swap= 5

sorted input {3,9,10,27,38,43,82}:
  insertion               :   3   9  10  27  38  43  82   cmp= 6 swap= 6
  bubble                  :   3   9  10  27  38  43  82   cmp= 6 swap= 0
  selection               :   3   9  10  27  38  43  82   cmp=21 swap= 0
  quicksort Lomuto        :   3   9  10  27  38  43  82   cmp=21 swap=27

nearly sorted {3,9,10,27,38,43,82} with 82 moved to front:
  insertion               :   3   9  10  27  38  43  82   cmp=11 swap=12
  bubble                  :   3   9  10  27  38  43  82   cmp=11 swap= 6
  selection               :   3   9  10  27  38  43  82   cmp=21 swap= 6

worst case for quicksort, sorted input, n = 7:
  Lomuto, last pivot      :   1   2   3   4   5   6   7   cmp=21 swap=27
  Hoare, middle pivot     :   1   2   3   4   5   6   7   cmp=26 swap= 0

【代码做什么?】

  1. 六种实现各自对同一乱序数组排序,输出全部为 3 9 10 27 38 43 82;右侧是比较次数与交换/移动次数。
  2. 随机输入下:插入 15/17,选择 21/5(比较恒为 n(n-1)/2),冒泡 20/11,归并 14/20,Lomuto 快排 11/9(比较最少),Hoare 快排 32/5(交换最少)。
  3. 有序输入下:插入排序只需 6 次比较(每轮一次 break),冒泡排序也只需 6 次(第一轮无交换即退出),而选择排序仍然是 21——它完全不具备适应性
  4. 近乎有序输入(82 移到最前,只有 6 个逆序对)下插入排序 cmp = 11,少于随机输入的 15;有序输入下 Lomuto 快排的比较次数涨到 21 = n(n-1)/2,正是最坏情况的开端,而 Hoare 版(中间 pivot)swap = 0,没有退化成”1 和 n-1”的灾难。

【底层机制透视】 插入排序的内层循环 break 让它具有输入敏感性:运行时间是 O(n + I)I 为逆序对个数。选择排序的内层循环没有 break,无论输入如何都跑满 n(n-1)/2 次,因此它是”输入无关“的——这在需要可预测延迟的实时系统里反而是优点。 归并排序的 swap_count 高(20)是因为每一次写回都算一次移动,它的比较与移动次数都是 O(n log n) 量级且常数稳定,这也是它在”外部排序”与”链表排序”中不可替代的原因。 partition_hoaredo { i++; cmp_count++; } while (a[i] < pivot); 依赖”pivot 一定在区间内”这一事实,因此不会越界;而 partition_lomuto 依赖 a[hi] 本身是 pivot,所以 j 只扫到 hi-1。 两个 partition 都通过 swap 修改变量:由于 int32_t 大小固定,这里直接用了一个 int32_t 临时变量;泛型版本则必须用 memcpy(见示例 4)。

【内存布局图解】(插入排序第 i 轮,i = 4key = a[4] = 9

   a[0] a[1] a[2] a[3] a[4] a[5] a[6]      i = 4,key = a[4] = 9
  +----+----+----+----+----+----+----+
  |  3 | 27 | 38 | 43 |  9 | 82 | 10 |   排序前:已排序区 a[0..3],key 暂存于变量
  |  3 | 27 |  9 | 38 | 43 | 82 | 10 |   ① 43、38、27 依次右移(9 比它们都小)
  |  3 |  9 | 27 | 38 | 43 | 82 | 10 |   ② a[0]=3 <= 9,break;key 写入空位
  +----+----+----+----+----+----+----+
     ^ 已排序区扩展为 a[0..4];本轮移动 I_i 个元素,总计 I = 逆序对数量

【与汇编的对应】a 基址由 LEA 取得,元素偏移按字计算)

; ---- 插入排序内层:while (j >= 0 && a[j] > key) { a[j+1] = a[j]; j--; } ----
; R1 = key, R2 = j, R3 = 数组基址, R4 = &a[j]
INS_IN  ADD   R2,R2,#0        ; j >= 0 ?
        BRn   INS_PLACE
        ADD   R4,R3,R2        ; R4 = &a[j]
        LDR   R0,R4,#0        ; R0 = a[j]
        NOT   R0,R0
        ADD   R0,R0,#1
        ADD   R0,R0,R1        ; R0 = key - a[j]
        BRzp  INS_PLACE       ; key >= a[j] ? 停(适应性:有序时立即退出)
        LDR   R0,R4,#0
        STR   R0,R4,#1        ; a[j+1] = a[j]
        ADD   R2,R2,#-1       ; j--
        BRnzp INS_IN
INS_PLACE
        ADD   R2,R2,#1
        ADD   R4,R3,R2
        STR   R1,R4,#0        ; a[j+1] = key

示例 2:Lomuto 分区的逐步过程

代码 (C)/tmp/ece220_l19/partition_trace.c(核心函数,带 verbose 开关打印每一轮):

static int32_t partition_lomuto(int32_t a[], int32_t lo, int32_t hi, int32_t verbose)
{
    int32_t pivot = a[hi];
    int32_t i = lo - 1;
    int32_t j;

    for (j = lo; hi > j; j++) {
        comparisons++;
        if (a[j] <= pivot) {
            i++;
            if (i != j) {
                swap(&a[i], &a[j]);
            }
        }
    }
    swap(&a[i + 1], &a[hi]);
    return i + 1;
}

验证到的真实输出(输入 {7, 2, 1, 6, 8, 5, 3, 4}):

Lomuto trace:
  partition(lo=0, hi=7), pivot = a[7] = 4
    j=0: a[0]= 7 >   4, keep      ->  7  2  1  6  8  5  3  4
    j=1: a[1]= 2 <=  4, swap i=0  ->  2  7  1  6  8  5  3  4
    j=2: a[2]= 1 <=  4, swap i=1  ->  2  1  7  6  8  5  3  4
    j=3: a[3]= 6 >   4, keep      ->  2  1  7  6  8  5  3  4
    j=4: a[4]= 8 >   4, keep      ->  2  1  7  6  8  5  3  4
    j=5: a[5]= 5 >   4, keep      ->  2  1  7  6  8  5  3  4
    j=6: a[6]= 3 <=  4, swap i=2  ->  2  1  3  6  8  5  7  4
    place pivot at index 3
  after partition:  2  1  3  4  8  5  7  6   (pivot 4 fixed at index 3)
  partition(lo=0, hi=2), pivot = 3:  ->  2  1  3 | 4  8  5  7  6   (pivot 3 fixed at index 2)
  partition(lo=0, hi=1), pivot = 1:  ->  1  2  3 | 4  8  5  7  6   (pivot 1 fixed at index 0)
  partition(lo=4, hi=7), pivot = 6:  ->  1  2  3  4  5  6  7  8   (pivot 6 fixed at index 5)
  partition(lo=6, hi=7), pivot = 8:  ->  1  2  3  4  5  6  7  8   (pivot 8 fixed at index 7)
sorted                :  1  2  3  4  5  6  7  8
comparisons = 14, swaps = 9

【代码做什么?】

  1. 第一次分区取 a[7] = 4 作 pivot,i-1 开始;j 从 0 扫到 6,凡 a[j] <= 4 就把 i 前移并把该元素换到”小值区”末尾。
  2. 扫描结束时数组为 2 1 3 6 8 5 7 4i = 2;把 pivot 与 a[i+1] 交换得 2 1 3 4 8 5 7 6pivot 4 从此固定在下标 3
  3. 递归处理左段 [0,2](pivot 3)与右段 [4,7](pivot 6),各自重复上述过程;每完成一次分区至少有一个元素归位,最终得到 1 2 3 4 5 6 7 8,全程 comparisons = 14, swaps = 9(关闭打印后重跑计数完全相同)。

【底层机制透视】 i 的语义是”小于等于 pivot 区域的最后一个下标”,循环不变式为 a[lo..i] <= pivota[i+1..j-1] > pivota[j..hi-1] 未检查、a[hi] == pivot——理解它就理解了 Lomuto 为什么正确。 分区是原地的,只用三个自动变量;每次分区结束时 pivot 落在最终位置,因此快速排序不需要”合并”步骤,这是它与归并排序最根本的结构差异(quicksort 先分后不管,mergesort 先递归后合并)。从 trace 看每层比较次数为 7、2、1、3、1,总计 14 次,远小于 n(n-1)/2 = 28——因为选到的 pivot 接近中位数。

【内存布局图解】(第一次分区结束时)

 下标:  0    1    2    3    4    5    6    7
      +----+----+----+----+----+----+----+----+
      |  2 |  1 |  3 |  4 |  8 |  5 |  7 |  6 |
      +----+----+----+----+----+----+----+----+
        <-- <= pivot -->  ^   <-- 未处理,> pivot -->
                          pivot 4 已归位(下标 3,永不再移动)

 调用树: [0..7] p=4 -> 分割点 3
          /                    \
    [0..2] p=3 -> 分割点 2   [4..7] p=6 -> 分割点 5
     /      \                  /      \
  [0..1]  空区间           空区间     [6..7] p=8 -> 分割点 7
   p=1 -> 分割点 0

【与汇编的对应】(Lomuto 内层循环;a 基址在 R3,hi 在 R5+5)

; ---- for (j = lo; j < hi; j++) if (a[j] <= pivot) { i++; swap(a[i], a[j]); } ----
; R1 = pivot, R2 = j, R3 = 数组基址, R4 = i
LOM_LOOP
        LDR   R0,R5,#5        ; R0 = hi(hi 是本帧参数)
        NOT   R0,R0
        ADD   R0,R0,#1
        ADD   R0,R0,R2        ; R0 = j - hi
        BRzp  LOM_END         ; j >= hi,结束本轮分区
        ADD   R0,R3,R2
        LDR   R0,R0,#0        ; R0 = a[j]
        NOT   R0,R0
        ADD   R0,R0,#1
        ADD   R0,R0,R1        ; R0 = pivot - a[j]
        BRn   LOM_NEXT        ; a[j] > pivot,跳过
        ADD   R4,R4,#1        ; i++;随后用三次 LDR/STR 交换 a[i] 与 a[j]
LOM_NEXT
        ADD   R2,R2,#1        ; j++
        BRnzp LOM_LOOP
LOM_END
        ; 最后 swap(a[i+1], a[hi]) 让 pivot 归位,并返回 i+1 作为分割点

示例 3:qsort 与三路比较函数

代码 (C)/tmp/ece220_l19/qsort_demo.c(节选):

typedef struct player_t {
    char name[16];
    int32_t age;
    int32_t games;
} player_t;

/* 三路比较:负数表示 a < b,0 表示等价,正数表示 a > b */
static int cmp_int(const void* p1, const void* p2)
{
    const int32_t* a = p1;
    const int32_t* b = p2;

    if (*a < *b) {
        return -1;
    }
    if (*a > *b) {
        return 1;
    }
    return 0;
}

static int cmp_age(const void* p1, const void* p2)
{
    const player_t* a = p1;
    const player_t* b = p2;

    if (a->age < b->age) {
        return -1;
    }
    if (a->age > b->age) {
        return 1;
    }
    return 0;
}

static int cmp_age_then_name(const void* p1, const void* p2)   /* 多关键字 */
{
    const player_t* a = p1;
    const player_t* b = p2;
    int by_age = cmp_age(p1, p2);

    if (0 != by_age) {
        return by_age;
    }
    return strcmp(a->name, b->name);
}

/* 降序只需把两个操作数对调:cmp_games_desc 与 cmp_age 结构相同,
   只是把 a->age/b->age 换成 b->games/a->games */

/* qsort 传给比较函数的是"指向数组元素的指针",元素本身是 char*,所以要再解引用一次 */
static int cmp_cstring(const void* p1, const void* p2)
{
    const char* const* s1 = p1;
    const char* const* s2 = p2;

    return strcmp(*s1, *s2);
}

验证到的真实输出

integers before: 42 -7 42 0 13 -7 100 5
integers after : -7 -7 0 5 13 42 42 100

strings before: pear Apple fig apple banana Fig
strings after : Apple Fig apple banana fig pear

sorted by age:            Ed(19) Bo(25) Al(25) Di(25) Cy(31)   <- 25 岁三人次序未定义

sorted by age, then by name:
   Ed       age=19 games=55
   Al       age=25 games=12
   Bo       age=25 games=40
   Di       age=25 games=90
   Cy       age=31 games= 7

sorted by name:           Al Bo Cy Di Ed(按姓名字符串次序)

sorted by games, descending:
   Di       age=25 games=90
   Ed       age=19 games=55
   Bo       age=25 games=40
   Al       age=25 games=12
   Cy       age=31 games= 7

【代码做什么?】

  1. qsort(nums, 8, sizeof(nums[0]), cmp_int) 把含重复值的整数数组排成 -7 -7 0 5 13 42 42 100,注意两个 42 的相对次序未定义(qsort 不保证稳定)。
  2. 字符串数组用 cmp_cstring 排成 Apple Fig apple banana fig pear——这是ASCII 次序(大写字母 AZ 是 65–90,小写是 97–122),因此 AppleFig 排在小写单词之前,与”字典序(忽略大小写)”不同。
  3. 同一组 player_t 数据用四个不同的比较函数各排一次:按年龄、按年龄再按姓名、按姓名、按场数降序;排序代码一次都没改,改的只是比较函数。”按年龄”与”按年龄再按姓名”的 25 岁组次序不同(Bo, Al, Di vs Al, Bo, Di),这是多关键字比较起作用的证据。

【底层机制透视】 qsort 只认识 void* 与字节大小,因此它内部的元素地址计算必然是 (char*)base + i * size;把 void* 转成 char* 是必须的,因为 void* 上的指针算术在 C 标准中没有定义。int32_tsize = 4player_tsize = 24(16 字节 name + 4 age + 4 games),qsort 用同一段 memcpy 搬运逻辑处理二者。 比较函数的参数是指向元素的指针,不是元素本身。对 int32_t nums[],元素是 int32_t,所以参数是 int32_t*;对 char* words[],元素是 char*,所以参数是 char**——这就是 cmp_cstring 必须写两层解引用 strcmp(*s1, *s2) 的原因,也是学生最常犯的错误。 比较函数必须给出三路结果:写成 return *a - *b; 会在接近 INT32_MIN/MAX有符号整数溢出(UB),正确写法是显式 <> 分支;返回值的大小无关紧要,只有符号有意义。在机器层,qsort 里的 (*compar)(p1, p2) 是一次间接调用(LC-3 的 JSRR),使同一段排序代码获得”多态”能力——与 Lecture 12 的 I/O 通道函数指针表、示例 4 的 is_smaller 完全同源。

【内存布局图解】player_t team[5]qsort 眼中的它)

 team(栈上 5 × 24 = 120 字节)
 +----------------+----------------+----------------+
 | name[0..15]    | age (4 字节)   | games (4 字节) |   每个元素 24 字节
 +----------------+----------------+----------------+
 ^
 第 0 个元素 = cmp_age 的 p1:qsort 传的是"指向这 24 字节的指针",
 函数内转成 player_t* 后按偏移访问 age;调用链 qsort -> (*compar) -> cmp_age

【与汇编的对应】(*compar)(p1, p2) 的一次间接调用)

; ---- qsort 内部:p1 在 R5+4,p2 在 R5+5,compar 在 R5+6 ----
        LDR   R0,R5,#6        ; R0 = 函数指针(被调用函数的第一条指令地址)
        ADD   R6,R6,#-1
        LDR   R1,R5,#5
        STR   R1,R6,#0        ; 参数从右向左压栈:先 p2
        ADD   R6,R6,#-1
        LDR   R1,R5,#4
        STR   R1,R6,#0        ; 再 p1
        JSRR  R0              ; *** 间接调用:目标地址来自变量(多态)***
        LDR   R1,R6,#0        ; R1 = 比较结果
        ADD   R6,R6,#3        ; 弹出返回值与两个参数
        ADD   R1,R1,#0
        BRnz  Q_NO_SWAP       ; compar <= 0 已就绪,不交换

示例 4:课程的泛型插入排序 isort.c

代码 (C) — 课程公开源码 Ccode/isort.c(核心函数;为符合 ECE 220 编码规范,原文件的制表符缩进已规范化为 4 空格,代码逻辑逐字未改):

static int32_t
isort (void* base, int32_t n_elts, size_t size,
       int32_t (*is_smaller) (void* t1, void* t2))
{
    char*   array = base;   /* array pointer (used for pointer arithmetic) */
    void*   current;        /* current element being placed into sorted subarray */
    int32_t sorted;         /* outer loop index; number of elements sorted */
    int32_t index;          /* inner loop index for placing current element */

    if (NULL == (current = malloc (size))) {
        return 0;
    }
    for (sorted = 2; n_elts >= sorted; sorted++) {
        memcpy (current, array + (sorted - 1) * size, size);
        for (index = sorted - 1; 0 < index; index--) {
            if ((*is_smaller) (current, array + (index - 1) * size)) {
                memcpy (array + index * size,
                        array + (index - 1) * size, size);
            } else {
                break;
            }
        }
        memcpy (array + index * size, current, size);
    }
    free (current);
    return 1;
}

验证到的真实输出gcc -g -std=c99 -Wall -Werror isort.c -o isort && ./isort):

integer sort -> 1: -10 22 30 50 73 99 104
double sort -> 1: -222.0000 -17.0000 3.1415 5.0000 33.0000 39.0000 60.0000 109.0000
string sort -> 1: ASCII Be Please alphabetical in instead. list not of order order. sort sure this to use words

【代码做什么?】

  1. 启动时 malloc(size) 申请恰好一个元素大小的临时空间 current——这是泛型排序无法把元素放进 int32_t 变量时的通用解法;分配失败返回 0。
  2. 外层 for (sorted = 2; n_elts >= sorted; sorted++) 把已排序区从长度 1 逐步扩展到 n
  3. 每轮用 memcpyarray[(sorted-1)*size] 拷进 current,然后内层从右向左比较:若 current 更小就把左边的元素整体右移 size 字节。
  4. 一旦 is_smaller 返回假(current 不再更小)就 break,把 current 拷回空位;这正是插入排序的适应性来源。
  5. main 用同一个 isort 分别排序 int32_tdoublechar* 三种数组,打印结果与返回值 1(成功)。

【底层机制透视】 char* array = base; 是泛型指针算术的关键字void* 不能做算术,转成 char*array + index * size 就精确表示”第 index 个元素的起始字节地址”。若误写成 int32_t*,则 + index * size 会再乘 4 倍,导致越界写。 元素搬运用 memcpy(dest, src, size) 而不是赋值:编译期不知道类型,只能按字节复制。memcpy 要求源与目标不重叠——本例右移时目标比源高恰好 size,属于相邻不重叠,安全;若整段搬移可能重叠则必须用 memmoveis_smaller 的签名是 int32_t (*)(void*, void*)非零表示第一个更小——注意它与 qsort 的三路 compar 不同:isort 只需二路的”小于”判断(return ((*int1) < (*int2));)。字符串版本 string_is_smaller 的参数是 char**strcmp(*s1, *s2) < 0),输出 ASCII Be Please alphabetical ...strcmp 的 ASCII 次序(大写在前)而非字母表次序。

【内存布局图解】isort 处理 int32_t 数组时的字节视图)

 调用者数组 base(栈上)                        isort 的 current(堆上,size 字节)
 +----+----+----+----+----+----+----+         +----+
 | 38 | 27 | 43 |  3 |  9 | 82 | 10 |         | 27 |  <- 当前要插入的元素
 +----+----+----+----+----+----+----+         +----+
  ^              ^                             ^
  base + 0*size  base + 2*size(char* 算术)    malloc(size) 返回的独立块
  memcpy(array + index*size, array + (index-1)*size, size):每次搬动一个元素

【与汇编的对应】(元素地址计算与 memcpy 调用)

; ---- 泛型元素地址计算:array + index * size,必须按字节加 ----
; R1 = array(char* 基址), R2 = index, R3 = size
        ADD   R0,R2,#0        ; R0 = index
        JSR   MULT            ; R0 = index * size(LC-3 无乘法指令,用子程序)
        ADD   R0,R1,R0        ; R0 = (char*)base + index * size
        ; 把 R0 作为参数压栈后调用 MEMCPY,即可复制任意 size 字节的元素

常见错误与调试技巧

  • 比较函数参数类型写错:把 char* 数组的比较函数写成 const char* 而非 const char**,导致 strcmp 把字符串内容当成指针使用,段错误。 调试gcc -std=c99 -Wall -Werror 会给出 incompatible pointer type 警告;GDB 中 p *(char**)p1p (char*)p1 对比看哪个是合法地址。
  • 比较函数返回差值导致溢出return *a - *b;int32_t 极值附近溢出(UB),排序结果错乱。 调试gcc -fsanitize=signed-integer-overflow -g(或 -ftrapv)在溢出处中止;改用显式 </> 分支返回 -1/0/1
  • 分区边界写错造成死循环 / 辅助数组未检查:Hoare 分区若递归写成 quick(a, lo, p - 1);,当 p == lo 时左半区间不缩小而死循环;归并排序的 aux = malloc(...) 失败后直接使用则对 NULL 解引用。调试:前者用 gdbCtrl-C + bt 看递归深度爆炸,并打印 lo/hi/p 确认每次分割至少缩减一个元素(Hoare 必须递归 [lo,p][p+1,hi]);后者用 valgrind -q ./prog 看 “Invalid write”。
  • memcpy 处理重叠区域 / 递归爆栈:泛型排序中整段搬移若源与目标重叠必须改用 memmove;快速排序对有序输入的最坏递归深度为 O(n),会耗尽 8 MB 栈。调试:前者用 valgrind -q ./prog 看 “overlapping” 或用 x/8xb array 逐字节核对;后者用 ulimit -s 查栈上限、gdb 捕获 SIGSEGVbt 看是否成千上万个同名帧,再用随机/中位数 pivot 降低深度。
  • 误以为 qsort 稳定:想用”先按次关键字排、再按主关键字排”实现多关键字排序时,若 qsort 不稳定,结果是错的(本讲示例中两个 42 的次序确实未定义)。 调试:改用稳定的归并排序,或用”比较函数里直接比较全部关键字”(如 cmp_age_then_name)——这是最实用的修正。

关键要点

  • O(n²) 三种算法里,插入排序对近乎有序输入最好(代价 O(n + 逆序对数)),选择排序不具备适应性但写内存最少,冒泡排序加提前退出后最好情况可到 O(n)
  • 归并排序以 O(n) 辅助空间换取最坏情况 O(n log n)稳定性;快速排序原地、常数小、平均最快,但最坏 O(n²),必须靠随机化或中位数 pivot 规避。比较排序的下界 Ω(n log n) 来自决策树叶子数 n!;计数/基数排序用”键可枚举”这一额外信息突破下界,但只适用于整数键。
  • 泛型排序是把比较规则外置为函数指针回调qsort(base, nmemb, size, compar)(char*)base + i * size 做通用指针算术,比较函数必须返回三路结果;其参数是”指向元素的指针”(int 数组给 int*char* 数组给 char**),这一层解引用是最常见的错误来源。

思考题(带答案)

问题 1:为什么插入排序对”近乎有序”的数组极快,而选择排序无论输入如何都同样慢?请用比较次数说明。

答案:插入排序第 i 轮把 key 与左侧元素比较,遇到第一个不大于它的元素就 break,因此总比较次数为 n - 1 + II 为逆序对个数);近乎有序时 I 很小,总代价接近 O(n)(实测 7 个元素的有序输入只用 6 次比较)。选择排序第 i 轮必须扫描 a[i+1..n-1] 找最小值,没有提前退出的依据,比较次数恒为 n(n-1)/2(实测恒为 21)。

问题 2:下面的比较函数在什么输入下会出错?如何修正?

static int cmp(const void* p1, const void* p2)
{
    const int32_t* a = p1;
    const int32_t* b = p2;

    return *a - *b;
}

答案:当 *a - *b 超出 int32_t 范围时发生有符号整数溢出(UB):例如 *a = 2000000000*b = -2000000000 的差值为 4 × 10^9,超出 INT32_MAX,可能回绕成负数使排序颠倒。修正为显式三分支:if (*a < *b) { return -1; } if (*a > *b) { return 1; } return 0;