第19章 程序正确性:复杂度、测试和不变量

19.1 问题从哪来#

前面几章写过很多数据结构:数组、链表、栈、队列、树、哈希表。

这些结构都能保存或组织数据,其中数组、链表、树、哈希表还常用来查找数据。但写到这里会遇到两个很实际的问题:

  1. 这个版本是不是真的比另一个版本快?
  2. 插入、删除、查找很多次以后,结构有没有悄悄坏掉?

比如哈希表的理想查找是 $O(1)$,但如果所有 id 都落进同一个桶,它就退化成链表。二分查找很快,但前提是数组已经排好序。二叉搜索树查找很快,但前提是树没有退化得太严重。

这些前提不能只靠感觉。程序需要自己打印、计数、检查。不变量就是程序运行前后都应该守住的条件。


19.2 先看一个例子#

查找 13 这件事,几种结构走的路不同:

有序数组是 {1, 3, 5, 7, 9, 11, 13, 15}。顺序查找从第一个元素开始,一个一个往后比,看到 1, 3, 5, 7, 9, 11, 13 才停,一共检查 7 次。

二分查找利用“数组已经排好序”这个事实。第一次看中间的 7,发现 13 更大,就去右半边;第二次看 11,还是更大;第三次看 13,找到。它没有检查前面的每个数,只检查了 7, 11, 13 这 3 个位置。

链表查找也是从头往后走,但走几步取决于节点顺序。下面的程序用头插法建立链表,13 正好排在第二个节点,所以链表查找检查 2 次。二叉搜索树从根节点开始,比当前节点大就往右走,比当前节点小就往左走;这组数据里查找 13 会经过 7, 11, 13。哈希表更直接一些,先算 13 % 11 得到桶编号 2,再到 2 号桶里找。

可以给每个查找函数加一个 steps 计数器。每检查一个候选元素或节点,就让 steps 加一。运行后不只看到“找到了”,还能看到“找了几步”。


19.3 最小程序#

下面这个程序只做一件事:用几种结构查找同一个 id,并打印检查次数。第 18 章的哈希函数在这里命名为 bucket_of,意思是根据 id 算出桶编号。代码里还加了两个小检查:

  • 有序数组必须真的有序。
  • 哈希表里每个节点必须待在它应该待的桶里。
#include <stdio.h>
#include <stdlib.h>

#define N 8
#define TABLE_SIZE 11

struct Node {
    int id;
    struct Node *next;
};

struct TreeNode {
    int id;
    struct TreeNode *left;
    struct TreeNode *right;
};

struct Entry {
    int id;
    struct Entry *next;
};

// 检查并打印测试结果:ok 非零打印 OK,否则打印 FAIL
int check(const char *name, int ok)
{
    printf("%s: %s\n", name, ok ? "OK" : "FAIL");
    return ok; // 返回 ok 值供调用者进一步判断
}

// 检查数组是否按升序排列(不变量检查)
int is_sorted(int a[], int n)
{
    for (int i = 1; i < n; i++) {       // 从第二个元素开始逐个比较
        if (a[i - 1] > a[i]) {           // 发现前一个大于后一个,说明未排序
            return 0;
        }
    }
    return 1;                            // 全部检查通过,数组有序
}

// 顺序查找:从头到尾逐个比较,返回下标或 -1(未找到)
int linear_search(int a[], int n, int target, int *steps)
{
    *steps = 0;                          // 初始化比较次数
    for (int i = 0; i < n; i++) {
        (*steps)++;                      // 每比较一次就计数
        if (a[i] == target) {            // 找到目标
            return i;                    // 返回下标
        }
    }
    return -1;                           // 遍历完仍未找到
}

// 二分查找:要求数组有序,每次将范围缩小一半
int binary_search(int a[], int n, int target, int *steps)
{
    int low = 0;                         // 查找范围下界
    int high = n - 1;                    // 查找范围上界
    *steps = 0;                          // 初始化比较次数

    while (low <= high) {                // 范围有效时继续
        int mid = low + (high - low) / 2; // 防止溢出的中间位置计算
        (*steps)++;                      // 记录本次比较

        if (a[mid] == target) {          // 找到目标
            return mid;
        }
        if (a[mid] < target) {           // 目标在右半部分
            low = mid + 1;               // 收缩下界
        } else {                         // 目标在左半部分
            high = mid - 1;              // 收缩上界
        }
    }
    return -1;                           // 范围缩小到空,未找到
}

// 链表头插:在链表头部插入新节点,返回新的头指针
struct Node *list_push_front(struct Node *head, int id)
{
    struct Node *node = malloc(sizeof(*node)); // 分配新节点
    if (node == NULL) {                    // 分配失败,保持链表不变
        return head;
    }
    node->id = id;                         // 设置节点数据
    node->next = head;                     // 新节点指向原来的头
    return node;                           // 新节点成为新的头
}

// 链表查找:遍历链表,找到返回 1,否则返回 0
int list_search(struct Node *head, int target, int *steps)
{
    *steps = 0;                              // 初始化比较次数
    for (struct Node *cur = head; cur != NULL; cur = cur->next) {
        (*steps)++;                          // 记录当前节点的比较
        if (cur->id == target) {             // 找到目标
            return 1;
        }
    }
    return 0;                                // 遍历结束未找到
}

// 二叉搜索树插入:按 BST 规则将 id 插入子树,返回子树根
struct TreeNode *tree_insert(struct TreeNode *root, int id)
{
    if (root == NULL) {                      // 到达空位置,在此创建新节点
        struct TreeNode *node = malloc(sizeof(*node)); // 分配节点
        if (node == NULL) {                  // 分配失败
            return NULL;
        }
        node->id = id;                       // 设置数据
        node->left = NULL;                   // 新节点暂时没有子节点
        node->right = NULL;
        return node;                         // 返回新节点作为子树的根
    }

    if (id < root->id) {                     // 目标小于当前节点,往左子树插入
        root->left = tree_insert(root->left, id);
    } else if (id > root->id) {              // 目标大于当前节点,往右子树插入
        root->right = tree_insert(root->right, id);
    }
    return root;                             // 相等时不插入,返回原根
}

// 二叉搜索树查找:从根开始比较,根据大小走左或右子树
int tree_search(struct TreeNode *root, int target, int *steps)
{
    *steps = 0;                              // 初始化比较次数
    while (root != NULL) {                   // 非空节点继续查找
        (*steps)++;                          // 记录本次比较
        if (root->id == target) {            // 找到目标
            return 1;
        }
        // 目标更小走左边,更大走右边
        root = (target < root->id) ? root->left : root->right;
    }
    return 0;                                // 到达空节点,未找到
}

// 哈希桶计算:对 TABLE_SIZE 取模得到桶编号
int bucket_of(int id)
{
    return id % TABLE_SIZE;                  // 取模运算,确保返回值在 [0, TABLE_SIZE) 范围内
}

// 哈希表插入:计算桶编号,将新节点链入对应桶的头部
void hash_insert(struct Entry *table[], int id)
{
    int bucket = bucket_of(id);              // 计算归属的桶编号
    struct Entry *entry = malloc(sizeof(*entry)); // 分配新节点
    if (entry == NULL) {                     // 分配失败,放弃插入
        return;
    }
    entry->id = id;                          // 设置数据
    entry->next = table[bucket];             // 新节点指向桶的当前头部
    table[bucket] = entry;                   // 新节点成为桶的新头部
}

// 哈希表查找:先定位桶,再遍历该桶的链表
int hash_search(struct Entry *table[], int target, int *steps)
{
    int bucket = bucket_of(target);          // 根据目标算出桶编号
    *steps = 0;                              // 初始化比较次数

    for (struct Entry *cur = table[bucket]; cur != NULL; cur = cur->next) {
        (*steps)++;                          // 记录当前节点的比较
        if (cur->id == target) {             // 找到目标
            return 1;
        }
    }
    return 0;                                // 桶链表遍历完未找到
}

// 哈希表不变量检查:每个节点必须待在 bucket_of() 算出的桶中
int hash_bucket_ok(struct Entry *table[])
{
    for (int bucket = 0; bucket < TABLE_SIZE; bucket++) {       // 遍历每个桶
        for (struct Entry *cur = table[bucket]; cur != NULL; cur = cur->next) {
            if (bucket_of(cur->id) != bucket) {  // 节点算出桶与当前桶不匹配
                return 0;                         // 不变量被破坏
            }
        }
    }
    return 1;                                      // 所有节点检查通过
}

// 释放整个链表:从头到尾逐个释放节点
void free_list(struct Node *head)
{
    while (head != NULL) {                   // 还有节点未释放
        struct Node *next = head->next;      // 先保存下一个节点地址
        free(head);                          // 释放当前节点
        head = next;                         // 移到下一个节点
    }
}

// 递归释放整个二叉搜索树:后序遍历,先释放子树再释放根
void free_tree(struct TreeNode *root)
{
    if (root == NULL) {                      // 空树,递归基准情况
        return;
    }
    free_tree(root->left);                   // 释放左子树
    free_tree(root->right);                  // 释放右子树
    free(root);                              // 最后释放根节点
}

// 释放整个哈希表:遍历每个桶,逐个释放桶中链表的所有节点
void free_hash(struct Entry *table[])
{
    for (int i = 0; i < TABLE_SIZE; i++) {   // 遍历每个桶
        struct Entry *cur = table[i];         // 桶的头节点
        while (cur != NULL) {                 // 逐个释放桶中链表
            struct Entry *next = cur->next;   // 保存下一个节点
            free(cur);                        // 释放当前节点
            cur = next;                       // 移到下一个
        }
    }
}

int main(void)
{
    int sorted_ids[N] = {1, 3, 5, 7, 9, 11, 13, 15};    // 已经有序的数组,用于二分查找
    int insert_order[N] = {7, 3, 11, 1, 5, 9, 13, 15};   // 动态结构的插入顺序
    int target = 13;                                       // 要查找的目标
    int steps = 0;                                         // 记录查找比较次数

    struct Node *list = NULL;                              // 链表头
    struct TreeNode *tree = NULL;                          // 二叉搜索树根
    struct Entry *table[TABLE_SIZE] = {0};                 // 哈希表,初始所有桶为空

    // 按相同顺序向三个结构中插入数据
    for (int i = 0; i < N; i++) {
        list = list_push_front(list, insert_order[i]);     // 链表头插
        tree = tree_insert(tree, insert_order[i]);         // 二叉搜索树插入
        hash_insert(table, insert_order[i]);               // 哈希表插入
    }

    // 运行不变量检查
    check("Array sorted", is_sorted(sorted_ids, N));        // 验证 sorted_ids 有序
    check("Hash bucket position correct", hash_bucket_ok(table)); // 验证哈希表每个节点在正确桶中

    // 顺序查找(线性复杂度)
    linear_search(sorted_ids, N, target, &steps);
    printf("Linear search checks: %d\n", steps);

    // 二分查找(对数复杂度)
    binary_search(sorted_ids, N, target, &steps);
    printf("Binary search checks: %d\n", steps);

    // 链表查找(线性复杂度)
    list_search(list, target, &steps);
    printf("List search checks: %d\n", steps);

    // 二叉搜索树查找(平均对数复杂度)
    tree_search(tree, target, &steps);
    printf("Tree search checks: %d\n", steps);

    // 哈希表查找(平均常数复杂度)
    hash_search(table, target, &steps);
    printf("Hash search checks: %d\n", steps);

    // 释放所有动态分配的内存
    free_list(list);
    free_tree(tree);
    free_hash(table);
    return 0;
}

19.4 编译运行#

保存为 correctness.c,编译:

$ gcc correctness.c -o correctness

运行,这份程序的输出如下:

$ ./correctness
Array sorted: OK
Hash bucket position correct: OK
Linear search checks: 7
Binary search checks: 3
List search checks: 2
Tree search checks: 3
Hash search checks: 1

不同插入顺序会改变链表和树的检查次数。哈希表也会受桶数量和冲突影响。这正是计数实验有用的地方:把“好像更快”变成“这组数据下走了几步”。

用检查次数比较不同查找方式


19.5 测试不是只看一次输出#

只跑一次 target = 13,只能说明这一个输入看起来没问题。可以继续换输入:

输入期望
target = 1能找到
target = 15能找到
target = 8找不到
空链表找不到,不崩溃
只有 1 个元素的树能找到或安全返回找不到

测试就是把这些情况写成可以重复运行的小实验。

测试用例把输入和期望结果摆在一起

check("Array sorted", is_sorted(sorted_ids, N)); 这种写法很朴素,但已经有测试的味道:给一个名字,检查一个条件,打印 OK 或 FAIL。


19.6 不变量#

不变量是不管执行多少次操作,都应该一直成立的条件。

结构不变量
有序数组对所有 0 <= i < n - 1,都有 a[i] <= a[i + 1]
0 <= top <= capacity
队列frontrear 始终落在数组范围内
二叉搜索树左子树都小于根,右子树都大于根
哈希表每个节点都在 id % TABLE_SIZE 对应的桶里

插入和删除最容易破坏不变量。比如二分查找依赖“数组有序”,如果插入新元素后忘了保持顺序,二分查找仍然会跑,但结果可能错。

几种数据结构各自需要保持的不变量


19.7 常见坑#

坑 1:只测最顺的一条路径。 比如只查存在的 id,不查不存在的 id。程序可能在“找不到”时越界或返回乱值。

坑 2:只看输出,不看结构。 哈希表打印能找到某个 id,不代表所有节点都在正确的桶里。可以写 hash_bucket_ok 检查结构本身。

坑 3:把复杂度当成固定时间。 $O(1)$ 不是永远一步,$O(\log n)$ 也不是永远比 $O(n)$ 快。小数据下常数开销会影响结果。复杂度看的是数据量增长时的趋势。

坑 4:测试代码也可能有 bug。 如果 check 的条件写反,错误会被打印成 OK。测试代码要尽量短,条件要能读懂。


19.8 自己试试看#

Q1:换目标值。target 改成 1、8、15,观察几种查找方式的检查次数。

Q2:破坏有序数组。sorted_ids 改成 {1, 7, 5, 3, 9, 11, 13, 15},看看 is_sorted 和二分查找会发生什么。

Q3:增加数据量。 把数组扩展到 16 个元素,再比较顺序查找和二分查找的检查次数。

Q4:制造哈希冲突。TABLE_SIZE 改成 2,观察哈希查找的检查次数是否增加。

Q5:给链表加长度检查。 写一个 list_count,插入 8 个节点后确认长度确实是 8。


下一章的问题#

前面几章已经把保存记录、查找记录、排序、树、哈希和结构检查放在一起练过。写小数据库时,这些检查不会消失:记录要能插入和查找,计数器要落在正确范围内,删除后结构也不能留下坏状态。

数据库不是一种全新的语法。它先是一个普通的 C 结构体,里面放记录数组和计数器,再配上一组插入、查找、删除、列出的函数。


阶段项目#

数据结构段落的能力可以放在一起比一比:阶段项目 3:数据结构查找对比器。同一批 id 装进无序数组、有序数组+二分、BST、哈希表,对一组查询比较每种结构的查找次数。练完再进入数据库生长线。