清风日常

Stay curious. Stay gentle.

听一会儿

4 首收藏
LOVE 2000 封面

LOVE 2000

遠野ひかる

0:004:23
首页/C Primer Plus/正文

C Primer Plus(第6版)· 第17章:高级数据表示

程序的数据表示不仅决定“把数据放在哪里”,还决定“哪些操作容易完成、需要付出多少代价”。同样是一组记录,可以放在数组里,也可以连接成链表,或者按照键组织成查找树。选择取决于实际需要的操作。

文章目录 63 个章节

第17章:高级数据表示

程序的数据表示不仅决定“把数据放在哪里”,还决定“哪些操作容易完成、需要付出多少代价”。同样是一组记录,可以放在数组里,也可以连接成链表,或者按照键组织成查找树。选择取决于实际需要的操作。

本章的主线是:先描述数据与操作,再设计接口,最后选择表示方式并实现算法。 链表、队列和二叉查找树是理解这个过程的具体例子。以下代码以 C11 为背景。

1. 设计数据表示,先列出操作

假设要记录看过的电影,每条记录包含片名和评分:

c
typedef struct {
    char title[64];
    int rating;
} Film;

这只解决了一条记录的表示,还没有回答如何管理全部电影。

在选择容器前,先考虑:

  • 记录数量是否提前知道,运行中是否不断增加?
  • 是经常按照下标访问,还是顺序遍历全部记录?
  • 是否频繁插入、删除,是否需要保持排序?
  • 是否经常按照片名查找,是否允许重复?
  • 内存不足或达到容量上限时,调用者应该得到什么结果?

1.1 固定数组、动态数组与链式结构

表示方式 数量与容量 主要特点
固定长度数组 容量在声明中确定 简单、连续存储,可能预留过多或不足
动态分配的数组 分配时决定容量 能按运行时需求分配,元素仍连续
可扩容动态数组 保存长度与容量,必要时重新分配 随机访问方便,扩容可能移动全部元素
动态链式结构 通常逐个分配节点 不要求节点连续,但需要额外指针和分配管理

数组并不都要求在编译时确定长度。动态分配、变长数组和固定长度数组应分别理解;变长数组也不会因为加入新元素而自动扩容。

动态数组与链表都能处理运行时才知道的数量,区别在于布局和操作成本。

1.2 动态分配要检查长度和乘法

c
/* 已包含 <stdint.h>、<stdlib.h>,count 的类型是 size_t。 */
Film *movies = NULL;

if (count != 0 && count <= SIZE_MAX / sizeof *movies) {
    movies = malloc(count * sizeof *movies);
    if (movies == NULL) {
        /* 报告分配失败。 */
    }
}

输入的负数不能不经检查就转换成 size_t。分配前检查乘法是否溢出,分配后检查返回值。零个元素可以直接用空指针表示,不必依赖 malloc(0) 的返回形式。

使用 realloc() 扩容时,要通过临时指针接收结果;对于非零的新大小,分配失败时原内存块仍然有效。

2. 单向链表:让节点保存下一个节点的位置

数组通过连续布局和下标定位元素;单向链表通过节点里的指针定位后继节点。

c
typedef struct node {
    Film item;
    struct node *next;
} Node;

结构不能直接包含一个同类型的完整结构成员,否则无法得到有限大小;它可以包含指向自身类型的指针。

text
head
 ↓
[记录 A | next] → [记录 B | next] → [记录 C | NULL]

节点可以散布在不同内存位置。箭头表示保存的地址,不表示相邻的物理内存。

2.1 三类指针承担不同任务

指针 作用
头指针 head 找到第一个节点,是整个链的入口
尾指针 tail 找到最后一个节点,便于追加
临时指针 current 遍历或处理某个节点

空链表的头指针是 NULL。最后一个节点的 next 是 NULL。

头指针不是头节点。这里也没有额外分配一个不存储有效记录的哨兵节点;采用哨兵是另一种实现选择。

2.2 遍历时移动临时指针

c
for (const Node *current = head;
     current != NULL;
     current = current->next) {
    printf("%s: %d\n", current->item.title, current->item.rating);
}

current = current->next 改变的是当前访问位置,没有搬动节点。

如果只是显示数据,就不应不断修改唯一的头指针。失去头指针又没有其他入口时,程序可能再也无法找到原来的节点。

2.3 追加:先准备节点,再接入链中

推荐顺序:

  1. 分配节点,检查是否成功。
  2. 填好数据,并设置 next = NULL。
  3. 空链表时更新头指针;否则连接旧尾节点。
  4. 更新尾指针和数量。
c
Node *fresh = malloc(sizeof *fresh);
if (fresh == NULL) {
    return false;
}

fresh->item = item;
fresh->next = NULL;

if (list->tail == NULL) {
    list->head = fresh;
} else {
    list->tail->next = fresh;
}
list->tail = fresh;
++list->size;

先完成可能失败的分配,再修改原链表,可以保证分配失败时原内容与计数不变。

Film 包含字符数组,结构赋值会复制这些数组的内容。如果把片名改成 char *,结构赋值只复制指针,字符串的所有权必须重新设计。

2.4 清空:先保存后继,再释放当前节点

c
Node *current = list->head;
while (current != NULL) {
    Node *next = current->next;
    free(current);
    current = next;
}
list->head = NULL;
list->tail = NULL;
list->size = 0;

free(current) 之后,不能再读取 current->next。释放节点不会自动把所有指向它的指针设为 NULL。

循环条件应检查当前仍有效的链入口或遍历位置。不要一边释放节点,一边依赖没有同步更新的旧指针判断是否结束。

3. 抽象数据类型:把需求、接口与实现分开

抽象数据类型,简称 ADT,描述的是一类数据的属性,以及可以对它执行的操作。它首先是一份关于行为的约定,不依赖某一种编程语言或内存布局。

例如,“保存有先后顺序的电影记录,并允许追加、遍历和清空”,可以作为一个简单列表的抽象描述。

这里需要区分两个层次:列表描述逻辑上的序列;链表是一种用链接节点实现序列的方式。 一个列表接口也可以由数组实现。

3.1 从抽象到代码的三层

层次 要回答的问题 C 中的常见形式
抽象描述 数据有什么性质,允许哪些操作? 类型与操作的文字约定
编程接口 调用者如何提出这些操作? 类型名、函数原型、参数与返回值约定
具体实现 用什么结构和算法完成操作? 结构定义、节点管理、函数定义

使用者关心“追加电影是否成功”;实现者负责 malloc()、尾指针以及错误情况下的清理。

3.2 接口要写清前提与结果

以追加操作为例:

c
/* 前提:list 指向已初始化的列表;item 是合法电影记录。
   成功:在末尾保存 item 的副本,数量加一,返回 true。
   失败:列表保持原状,返回 false。 */
bool list_append(List *list, Film item);

还应明确:是否允许重复;容量是否有限;输出参数在失败时是否保持不变;回调能否修改容器;保存的是副本还是借用的地址。

“指针不为空”只是前提的一部分,还要求它指向有效对象,并且对象已经正确初始化。

3.3 初始化和清空不等价

初始化把尚未建立内容的对象设置为空;清空释放它已经拥有的资源,然后恢复为空。

对非空容器再次执行“直接把指针写成 NULL”的初始化,会丢失旧节点并造成内存泄漏。复用一个容器时,应先调用清空操作。

3.4 为什么接口经常统一接收地址

c
void list_init(List *list);
bool list_empty(const List *list);
size_t list_count(const List *list);

修改容器需要通过地址影响调用者的对象。只读操作也接收地址,可以避免复制整个结构,并让调用方式一致。

小结构按值传递也可以,但需要明确:复制含有指针的结构只复制指针值,不会自动复制节点。对这个浅副本执行释放,仍会影响原对象引用的节点。

3.5 typedef 与 const 要一起看

c
typedef Node *ListHandle;

此时 const ListHandle *p 相当于 Node * const *p:不能经由 p 改写那个头指针,但节点内容仍可修改。

即使使用 const List * 指向一个容器结构,也不能自动使结构中所有指针指向的节点变成只读。实现只读遍历时,仍可主动使用 const Node * 限制访问。

3.6 文件分工与隐藏程度

text
film_list.h:类型名、函数原型、使用约定、包含保护
film_list.c:结构操作、内存管理、static 辅助函数
app.c:获取输入,调用接口,显示结果

static 辅助函数只在所在翻译单元中可见,不是“同文件夹内可见”。

如果头文件公开 List 和 Node 的全部成员,调用者在语法上仍能直接访问它们。此时的数据隐藏依赖接口约定;改变结构布局后,相关源文件通常需要重新编译。

需要更强的隐藏时,可以在头文件只声明不完整类型:

c
typedef struct film_list FilmList;
FilmList *film_list_create(void);
void film_list_destroy(FilmList *list);

完整结构放在实现文件。调用者只能通过指针和接口操作,也不能在不知道结构大小的情况下直接定义一个 FilmList 对象。

4. 一个完整的电影列表实现

下面把接口和实现放在一个文件中,便于观察完整流程。实际项目中可以按照上一节拆分。

列表拥有节点,记录按值复制;片名必须以空字符结尾,评分为 0~10。追加失败时不改变列表。

c
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    char title[64];
    int rating;
} Film;

typedef struct node {
    Film item;
    struct node *next;
} Node;

typedef struct {
    Node *head;
    Node *tail;
    size_t size;
} List;

static void list_init(List *list)
{
    *list = (List){NULL, NULL, 0};
}

static bool list_append(List *list, Film item)
{
    if (list->size == SIZE_MAX || item.rating < 0 || item.rating > 10 ||
        memchr(item.title, '\0', sizeof item.title) == NULL) {
        return false;
    }

    Node *fresh = malloc(sizeof *fresh);
    if (fresh == NULL) {
        return false;
    }
    *fresh = (Node){item, NULL};

    if (list->tail == NULL) {
        list->head = fresh;
    } else {
        list->tail->next = fresh;
    }
    list->tail = fresh;
    ++list->size;
    return true;
}

static void list_visit(const List *list,
                       void (*visit)(const Film *, void *), void *context)
{
    for (const Node *p = list->head; p != NULL; p = p->next) {
        visit(&p->item, context);
    }
}

static void list_clear(List *list)
{
    Node *p = list->head;
    while (p != NULL) {
        Node *next = p->next;
        free(p);
        p = next;
    }
    list_init(list);
}

static void print_film(const Film *film, void *context)
{
    FILE *out = context;
    fprintf(out, "%s: %d\n", film->title, film->rating);
}

int main(void)
{
    List movies;
    const Film input[] = {{"Arrival", 9}, {"Soul", 8}, {"Dune", 9}};
    list_init(&movies);

    for (size_t i = 0; i < sizeof input / sizeof input[0]; ++i) {
        if (!list_append(&movies, input[i])) {
            fputs("cannot append film\n", stderr);
            list_clear(&movies);
            return EXIT_FAILURE;
        }
    }

    list_visit(&movies, print_film, stdout);
    printf("count: %zu\n", movies.size);
    list_clear(&movies);
    printf("after clear: %zu\n", movies.size);
    return EXIT_SUCCESS;
}

输出:

text
Arrival: 9
Soul: 8
Dune: 9
count: 3
after clear: 0

context 是传给回调的附加状态,这里是输出流。这样同一个遍历函数既可以显示记录,也可以执行统计等其他任务。

回调接收的 const Film * 是对内部记录的临时只读借用,不能释放它。该接口还约定:遍历期间回调不得增加、删除或清空当前列表,否则遍历指针可能失效。

4.1 用不变量检查实现

不变量指容器在每次公开操作完成后必须满足的关系:

  • 空表:size == 0,且 head == NULL、tail == NULL。
  • 非空表:首尾指针有效,tail->next == NULL。
  • 从 head 沿 next 能走过恰好 size 个节点,最后一个就是 tail。
  • 节点没有环,每个节点由这个列表负责释放一次。

头尾指针和计数让追加、读取数量都变成 O(1),但也增加了必须同步维护的状态。

4.2 “是否已满”不能代替实际分配检查

固定容量容器可以根据数量判断是否达到上限。没有固定上限的动态链表则没有一个稳定的“剩余节点容量”。

先试着 malloc() 再立刻 free(),只能说明那一次分配是否成功,不能保证后面的实际分配成功。直接让追加操作尝试分配,并返回真实结果,通常更清楚。

底层容器也不一定要在失败时调用 exit()。返回错误可以让调用者决定重试、保存数据、清理资源或结束程序。

5. 队列:只从尾部加入,从头部取出

队列的核心约定是先进先出,即 FIFO。先进入的元素先离开。

text
出队 ← front → [A] → [B] → [C] ← rear ← 入队

队列的常见操作是初始化、判断空或满、读取数量、入队、出队和清空。队列描述取放顺序,既可以用链表实现,也可以用数组实现。

5.1 出队需要同时返回状态与数据

c
bool queue_pop(Queue *queue, int *out);

返回 true 表示取出成功,实际元素写入 *out。空队列返回 false,本例约定不改变输出对象。

不能拿某个普通整数作为“空队列”的标记,因为那个整数本身也可能是合法数据。

out 必须指向一个有效、可写的独立对象,不能指向将被释放的节点内部。用一个局部变量接收结果最直观。

5.2 入队与出队的边界

入队要处理空队列:第一个节点既是首节点,也是尾节点。

出队要处理只剩一个节点:移走该节点后,必须同时把首尾指针恢复为 NULL,不能留下指向已释放内存的尾指针。

有容量上限的链式队列有两种不同失败原因:达到逻辑上限、节点分配失败。下面用 false 统一表示失败;需要区分原因时,可以使用错误枚举。

5.3 完整的链式整数队列

c
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>

typedef struct qnode {
    int item;
    struct qnode *next;
} QNode;

typedef struct {
    QNode *front;
    QNode *rear;
    size_t size;
    size_t capacity;
} Queue;

static void queue_init(Queue *q, size_t capacity)
{
    *q = (Queue){NULL, NULL, 0, capacity};
}

static bool queue_push(Queue *q, int item)
{
    if (q->size >= q->capacity) {
        return false;
    }
    QNode *fresh = malloc(sizeof *fresh);
    if (fresh == NULL) {
        return false;
    }
    *fresh = (QNode){item, NULL};

    if (q->front == NULL) {
        q->front = fresh;
    } else {
        q->rear->next = fresh;
    }
    q->rear = fresh;
    ++q->size;
    return true;
}

static bool queue_pop(Queue *q, int *out)
{
    if (q->front == NULL) {
        return false;
    }
    QNode *old = q->front;
    *out = old->item;
    q->front = old->next;
    --q->size;
    if (q->front == NULL) {
        q->rear = NULL;
    }
    free(old);
    return true;
}

static void queue_clear(Queue *q)
{
    int discarded;
    while (queue_pop(q, &discarded)) {
        /* 出队操作已经负责释放节点。 */
    }
}

int main(void)
{
    Queue q;
    int value;
    const int input[] = {40, 20, 55};
    queue_init(&q, 3);

    for (size_t i = 0; i < sizeof input / sizeof input[0]; ++i) {
        if (!queue_push(&q, input[i])) {
            queue_clear(&q);
            return EXIT_FAILURE;
        }
    }
    printf("extra accepted: %d\n", queue_push(&q, 99));
    while (queue_pop(&q, &value)) {
        printf("%d\n", value);
    }
    printf("remaining: %zu\n", q.size);
    queue_clear(&q);
    return EXIT_SUCCESS;
}

输出:

text
extra accepted: 0
40
20
55
remaining: 0

入队和出队只修改固定数量的链接,结构操作是 O(1)。这里的分析没有把通用内存分配器的内部成本视为固定的标准保证。

清空需要访问并释放全部节点,因此是 O(n)。清空后的队列仍保留原来的容量设置,可以继续入队。

6. 环形数组队列:移动边界,不搬动元素

如果普通数组每次出队都把后面的元素向前移动,出队就是 O(n)。另一种方式是记录首元素下标,取出后只移动这个下标。

当下标走到数组末尾时,让它回到开头,就能复用前面空出来的位置。这就是环形队列。

text
容量 4,逻辑队列是 C、D、E:

物理下标:  0    1    2    3
数组内容: [E]  [空] [C]  [D]
                     ↑
                   front

逻辑第 i 项的位置:(front + i) % capacity
下一次入队的位置:(front + size) % capacity

这里用 size 区分满和空,所以容量为 4 的数组可以保存 4 项。其他实现可能保留一个空槽,规则不能混用。

6.1 一个不需要逐项分配的实现

c
#include <stdbool.h>
#include <stdio.h>

enum { CAPACITY = 4 };

typedef struct {
    int data[CAPACITY];
    size_t front;
    size_t size;
} Ring;

static bool ring_push(Ring *q, int value)
{
    if (q->size == CAPACITY) {
        return false;
    }
    size_t pos = (q->front + q->size) % CAPACITY;
    q->data[pos] = value;
    ++q->size;
    return true;
}

static bool ring_pop(Ring *q, int *out)
{
    if (q->size == 0) {
        return false;
    }
    *out = q->data[q->front];
    q->front = (q->front + 1) % CAPACITY;
    --q->size;
    return true;
}

int main(void)
{
    Ring q = {0};
    int value;

    for (int i = 1; i <= CAPACITY; ++i) {
        if (!ring_push(&q, i)) {
            return 1;
        }
    }
    printf("full rejects: %d\n", !ring_push(&q, 99));
    for (int i = 0; i < 2; ++i) {
        if (!ring_pop(&q, &value)) {
            return 1;
        }
        printf("out: %d\n", value);
    }
    if (!ring_push(&q, 5) || !ring_push(&q, 6)) {
        return 1;
    }
    while (ring_pop(&q, &value)) {
        printf("out: %d\n", value);
    }
    return 0;
}

最终出队顺序仍然是 1、2、3、4、5、6。数组中的实际位置已经绕回,逻辑上的 FIFO 顺序没有变化。

本例容量很小,front + size 不会溢出。把实现推广到任意超大容量时,还要处理下标加法的溢出,不能只认为取模会修正已经溢出的结果。

实现 入队与出队的结构成本 主要代价
每次出队都搬移的数组 入队 O(1),出队 O(n) 反复移动元素
环形数组 都是 O(1) 预留容量,管理环绕下标
保存首尾指针的链表 都是 O(1) 节点指针、逐项分配与释放

7. 用队列模拟等待服务

队列可以表示等待咨询、购票或处理任务的人。容器解决先后顺序;模拟程序负责时间和业务规则。

7.1 保存的是顾客状态

c
typedef struct {
    long arrive;
    int service_minutes;
} Customer;

顾客开始服务时:

text
等待时间 = 开始服务的时刻 - 到达时刻

正在接受服务的顾客已经离开等待队列。因此,“队列长度”只计算等待的人,不包括服务中的人。

把整数队列改成顾客队列时,节点的元素类型以及相关参数、输出变量都要统一改成 Customer。这属于编译时替换类型;一个 typedef Item 并不会自动产生可同时储存任意类型的容器。

7.2 明确每分钟的事件顺序

一种离散时间模型可以写成:

text
每一分钟 t:
    判断是否有新顾客到来
        等待队列已满 → 记录被拒人数
        否则 → 记录到达时间与服务时长,加入队列

    如果服务员空闲,且队列非空:
        从队首取出顾客
        累计 t - arrive
        设置剩余服务时间
        开始服务人数加一

    如果正在服务:
        剩余服务时间减一
        若减到零,完成服务人数加一

    累计当前等待队列长度

这里先接收新顾客,再选择下一位开始服务,所以空闲时新顾客可能立即开始服务,等待时间为零。队满时的拒绝判断也发生在本轮取人之前。

这是模型的规则。改变事件顺序,边界时刻的统计也可能改变,应该先确定语义,再写代码。

7.3 统计量要用正确的分母

统计量 一种明确的计算方式
平均等待人数 每轮记录的等待人数总和 / 模拟轮数
已开始服务者的平均等待时间 这些顾客的等待时间总和 / 开始服务人数
接纳人数 成功入队的顾客数量
拒绝人数 因等待队列已满而未接纳的数量
完成服务人数 剩余服务时间减到零的次数

从队列取出一个顾客时,只能说明他开始接受服务,不能说明整个服务已经完成。模拟结束时,可能还有人在排队,也可能有人仍在接受服务。

如果没有顾客开始服务,平均等待时间的分母为零,应单独处理。模拟时长也必须大于零。

在没有顾客中途离开的模型中,应满足:

text
到达人数 = 接纳人数 + 拒绝人数
接纳人数 = 等待人数 + 已开始服务人数
已开始服务人数 = 已完成人数 + 正在服务人数

最后一项“正在服务人数”对单服务台是 0 或 1。

7.4 每分钟最多一人到达的限制

如果每小时平均到达 r 人,每分钟到达一人的概率可以设为 p = r / 60.0。这种模型要求 0 <= r <= 60,每分钟只进行一次是否到达的判断。

c
/* 已包含 <stdlib.h>;p 已检查在 [0, 1] 内。 */
double u = (double)rand() / ((double)RAND_MAX + 1.0);
bool arrives = u < p;

分母写成浮点数形式,避免先计算整数 RAND_MAX + 1 可能溢出。这个简单方案受到伪随机数离散精度限制,适合教学模拟。

如果输入每小时 120 人却仍每分钟只尝试生成一人,模型无法表达需求。需要缩短步长,或采用允许一个时间段内多次到达的模型。

服务时长用 rand() % 3 + 1 可得到 1、2、3 分钟;由于取模可能存在轻微偏差,它不是对任意实现都严格均匀的保证。

7.5 从结果理解负载

如果三种服务时长等概率,平均每人需要 2 分钟,一个服务台的平均工作能力约为每小时 30 人。

到达率接近这个水平时,随机波动容易形成长队;有容量上限时,多余需求表现为拒绝。即使平均到达率低于服务能力,也不代表任何时刻都无人等待。

应对多服务台时,可以选择共用一个队列,也可以为每个服务台分配队列。分配规则、每个服务台的忙闲状态和统计口径都属于模型的一部分。

固定随机种子有利于重现同一实现中的一次实验;不同 C 库不保证生成相同序列。比较模型时,宜观察多次运行结果和结束时的未完成工作,而不是只看一次短时间运行。

8. 数组和链表:把查找位置的成本算进去

操作 连续数组 单向链表
根据下标访问第 k 项 O(1) O(k),最坏 O(n)
遍历全部元素 O(n),通常具有较好的局部性 O(n),节点可能分散
在已知位置中间插入并保持顺序 最坏移动 O(n) 项 已知前驱时,链接修改 O(1)
删除指定位置并保持顺序 最坏移动 O(n) 项 已知前驱或入口链接时,链接修改 O(1)
按值查找,未排序 O(n) O(n)
已排序时二分查找 O(log n) 无法靠下标直接跳到中间

上表的链表插入、删除没有把节点分配、释放的内部成本当作语言标准保证的常数。

“链表删除是 O(1)”有前提:已经掌握需要修改的链接。若只有待删除的值,还要先遍历查找,整个操作仍可能是 O(n)。

只有头指针时,每次追加都需要找尾部,连续追加 n 项总成本可能是 O(n²)。保存尾指针可以让这些链接操作合计为 O(n)。

动态数组通过成倍扩容,也可以实现平均摊销 O(1) 的追加,只是某次扩容可能搬动 O(n) 个元素,并使指向旧缓冲区的指针失效。

8.1 有序链表也可以提前停止查找

顺序扫描一个有序序列时,一旦当前元素已经大于目标,就可以确定目标不存在。这样可以省下一部分比较,但最坏情况依然是 O(n)。

链表并非在逻辑上完全不能做“比较中点、缩小范围”,而是寻找中点需要沿链接移动。没有额外索引时,它不能像数组二分查找那样获得 O(log n) 的总访问时间。

9. 二分查找:维护尚未排除的范围

二分查找要求数组已经按照查找时使用的同一规则排好序。

每次比较中间元素后,排除一半候选范围。若数据未排序,排除的那一半可能恰好包含目标,算法就不再正确。

9.1 使用左闭右开的区间

用 [lo, hi) 表示候选下标,即包含 lo,不包含 hi:

text
初始:lo = 0,hi = n
非空:lo < hi
中点:mid = lo + (hi - lo) / 2

a[mid] < target → lo = mid + 1
a[mid] > target → hi = mid
a[mid] == target → 找到

这样不需要表示下标 -1,也避免在空数组上计算 n - 1 导致无符号下溢。

lo + (hi - lo) / 2 比 (lo + hi) / 2 更稳妥:前者在 0 <= lo <= hi <= n 的约束下不会因为两个大下标相加而溢出。

9.2 完整示例

c
#include <stdbool.h>
#include <limits.h>
#include <stdio.h>

/* a 指向含 n 项的升序数组;n == 0 时允许 a == NULL。
   index 可为 NULL;找不到时不修改 *index。 */
static bool binary_find(const int *a, size_t n, int target, size_t *index)
{
    size_t lo = 0;
    size_t hi = n;

    while (lo < hi) {
        size_t mid = lo + (hi - lo) / 2;
        if (a[mid] < target) {
            lo = mid + 1;
        } else if (a[mid] > target) {
            hi = mid;
        } else {
            if (index != NULL) {
                *index = mid;
            }
            return true;
        }
    }
    return false;
}

int main(void)
{
    const int data[] = {INT_MIN, -4, 0, 7, 7, 19, INT_MAX};
    const int targets[] = {7, 8, INT_MIN, INT_MAX};

    for (size_t i = 0; i < sizeof targets / sizeof targets[0]; ++i) {
        size_t index;
        if (binary_find(data, sizeof data / sizeof data[0],
                        targets[i], &index)) {
            printf("%d at %zu\n", targets[i], index);
        } else {
            printf("%d absent\n", targets[i]);
        }
    }
    printf("empty found: %d\n", binary_find(NULL, 0, 7, NULL));
    return 0;
}

重复元素存在时,这个版本返回命中的某一个位置,不保证是第一个。若需要第一个位置,应继续缩小到最左边的匹配边界。

每次迭代都减小候选区间,因此最终能够停止。时间复杂度是 O(log n),额外空间为 O(1)。排序本身的成本需要另算;不能把“排序后再查找一次”的全部过程都算成 O(log n)。

10. 二叉查找树:把有序关系编码到链接里

二叉树的每个节点最多有两个子节点。二叉查找树,简称 BST,在此基础上增加顺序约束。

对于不允许重复键的版本,每个节点都必须满足:

text
左子树中的所有键 < 当前键 < 右子树中的所有键

约束作用于整棵子树,而不只是直接的左、右孩子。

text
        8
       / \
      3   10
     / \    \
    1   6    14
       / \   /
      4   7 13

根是整棵树的入口;子树是某个节点和它的全部后代;没有子节点的节点叫叶节点。空指针表示相应子树为空。

10.1 查找只沿一条路径前进

查找 7:与 8 比较向左,与 3 比较向右,与 6 比较向右,最后命中 7。

如果走到 NULL,说明应该容纳目标的路径已经结束,目标不在树里。

比较后可以排除另一棵子树,但不保证排除的是全部节点的一半。只有树的高度得到控制,才能保证较短的查找路径。

10.2 插入是在空链接处建立新节点

沿查找路径前进:键小就向左,键大就向右,相等则按重复策略处理。最终遇到空链接时,把新节点接在这里。

新节点的左右指针都应初始化为 NULL。如果分配失败,树的链接和数量应保持原状。

与先查重、再从根重新寻找插入位置相比,一次遍历直接找到“待写入的空链接”可以避免重复走路。

10.3 比较规则是容器定义的一部分

宠物记录可以先比较名字,名字相同再比较种类:

c
/* Pet 的 name、kind 都是有效的 C 字符串。 */
static int pet_compare(const Pet *a, const Pet *b)
{
    int result = strcmp(a->name, b->name);
    return result != 0 ? result : strcmp(a->kind, b->kind);
}

名字和种类都相同,才视为同一个键。若只按名字比较,“同名不同种类”也会变成重复项。

插入、查找和删除必须使用一致的比较规则。记录已经入树后,不能直接修改参与排序的字段;需要先移除旧键,再插入新键。

strcmp() 返回值看正、负、零即可,不保证恰好为 1 或 -1。比较整数时直接用 < 和 >,避免用相减的结果判断大小而发生有符号溢出。

11. 删除树节点:先保证剩余节点仍能找到

11.1 没有子节点

把指向它的链接改为 NULL,再释放节点。

11.2 只有一个子树

把指向它的链接改成指向唯一的子树根,再释放旧节点。整个子树的位置随入口变化,内部节点不需要逐个移动。

11.3 同时有左右子树

一种直接重接子树的方法是:

  1. 找到左子树最右边的节点,也就是左子树中最大的键。
  2. 把旧右子树接到这个节点的空右链接上。
  3. 用旧左子树替换被删节点的位置。
  4. 释放被删节点。

删除下图中的 8:

text
删除前:            删除后:

      8                  3
     / \                / \
    3   10             1   6
   / \                    / \
  1   6                  4   7
     / \                      \
    4   7                      10

这个连接是合法的,因为旧右子树的所有键都大于被删键,也就大于旧左子树的所有键。

另一种常见方法是用中序前驱或后继的记录替换目标记录,再删除相应的前驱或后继节点。涉及动态字符串等资源时,记录替换还必须遵守所有权约定。

11.4 二级指针表示“应该修改的链接”

需要修改的可能是:

c
tree->root
parent->left
parent->right

它们的类型都是 TreeNode *;取地址以后,类型都是 TreeNode **。因此可以用同一套逻辑处理根节点和普通子节点。

c
TreeNode **link = &tree->root;

此时 link 指向一个指针对象,*link 才是当前节点。向下走时:

c
link = &(*link)->left;

找到目标后,给 *link 赋值,就会实际修改树的入口或父节点的孩子指针。与保存“父节点、当前节点”两个指针相比,这是另一种表达同一修改需求的方法。

12. 一个完整的整数二叉查找树

下面的树不允许重复键,设置容量上限,并分别报告重复、已满和分配失败。所有操作要求传入有效的树对象;遍历回调不得修改这棵树。

c
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>

typedef struct tree_node {
    int key;
    struct tree_node *left;
    struct tree_node *right;
} TreeNode;

typedef struct {
    TreeNode *root;
    size_t size;
    size_t capacity;
} Tree;

typedef enum {
    INSERTED, DUPLICATE, TREE_FULL, NO_MEMORY
} InsertResult;

static void tree_init(Tree *tree, size_t capacity)
{
    *tree = (Tree){NULL, 0, capacity};
}

static bool tree_contains(const Tree *tree, int key)
{
    const TreeNode *p = tree->root;
    while (p != NULL) {
        if (key == p->key) {
            return true;
        }
        p = key < p->key ? p->left : p->right;
    }
    return false;
}

static InsertResult tree_insert(Tree *tree, int key)
{
    TreeNode **link = &tree->root;
    while (*link != NULL) {
        if (key == (*link)->key) {
            return DUPLICATE;
        }
        link = key < (*link)->key ? &(*link)->left : &(*link)->right;
    }
    if (tree->size >= tree->capacity) {
        return TREE_FULL;
    }

    TreeNode *fresh = malloc(sizeof *fresh);
    if (fresh == NULL) {
        return NO_MEMORY;
    }
    *fresh = (TreeNode){key, NULL, NULL};
    *link = fresh;
    ++tree->size;
    return INSERTED;
}

static bool tree_erase(Tree *tree, int key)
{
    TreeNode **link = &tree->root;
    while (*link != NULL && (*link)->key != key) {
        link = key < (*link)->key ? &(*link)->left : &(*link)->right;
    }
    if (*link == NULL) {
        return false;
    }

    TreeNode *old = *link;
    if (old->left == NULL) {
        *link = old->right;
    } else if (old->right == NULL) {
        *link = old->left;
    } else {
        TreeNode *max_left = old->left;
        while (max_left->right != NULL) {
            max_left = max_left->right;
        }
        max_left->right = old->right;
        *link = old->left;
    }
    free(old);
    --tree->size;
    return true;
}

static void inorder(const TreeNode *p,
                    void (*visit)(int, void *), void *context)
{
    if (p == NULL) {
        return;
    }
    inorder(p->left, visit, context);
    visit(p->key, context);
    inorder(p->right, visit, context);
}

static void free_nodes(TreeNode *p)
{
    if (p == NULL) {
        return;
    }
    free_nodes(p->left);
    free_nodes(p->right);
    free(p);
}

static void tree_clear(Tree *tree)
{
    free_nodes(tree->root);
    tree->root = NULL;
    tree->size = 0;
}

static void print_key(int key, void *context)
{
    FILE *out = context;
    fprintf(out, "%d ", key);
}

int main(void)
{
    Tree tree;
    const int keys[] = {8, 3, 10, 1, 6, 14, 4, 7, 13};
    const int removed[] = {3, 14, 1, 8};
    tree_init(&tree, 9);

    for (size_t i = 0; i < sizeof keys / sizeof keys[0]; ++i) {
        if (tree_insert(&tree, keys[i]) != INSERTED) {
            tree_clear(&tree);
            return EXIT_FAILURE;
        }
    }
    printf("duplicate rejected: %d\n", tree_insert(&tree, 8) == DUPLICATE);
    printf("full rejected: %d\n", tree_insert(&tree, 2) == TREE_FULL);
    inorder(tree.root, print_key, stdout);
    putchar('\n');

    for (size_t i = 0; i < sizeof removed / sizeof removed[0]; ++i) {
        if (!tree_erase(&tree, removed[i])) {
            tree_clear(&tree);
            return EXIT_FAILURE;
        }
    }
    inorder(tree.root, print_key, stdout);
    putchar('\n');
    printf("contains 7: %d; contains 8: %d\n",
           tree_contains(&tree, 7), tree_contains(&tree, 8));
    printf("erase absent: %d; count: %zu\n",
           tree_erase(&tree, 99), tree.size);
    tree_clear(&tree);
    return EXIT_SUCCESS;
}

输出:

text
duplicate rejected: 1
full rejected: 1
1 3 4 6 7 8 10 13 14
4 6 7 10 13
contains 7: 1; contains 8: 0
erase absent: 0; count: 5

示例依次删除了两个子树的节点、一个子树的节点、叶节点和根节点。找不到目标时,不修改链接和计数。

清空后保留容量设置。这个实现只让树拥有整数节点;若节点记录另有动态资源,释放节点前还需要释放记录自身拥有的资源。

13. 树的遍历与清空

每个节点有两个分支,递归很自然地对应“对左子树做同样的事、处理当前节点、对右子树做同样的事”。

遍历方式 访问顺序 常见用途
前序 当前节点 → 左子树 → 右子树 先处理上层,再处理后代
中序 左子树 → 当前节点 → 右子树 按 BST 的键顺序输出
后序 左子树 → 右子树 → 当前节点 先释放后代,再释放父节点

中序遍历能输出升序序列,依赖于 BST 的顺序不变量;对任意二叉树,中序结果不一定有序。

三种遍历都访问每个节点一次,因此时间为 O(n)。递归调用所需的额外空间是 O(h),其中 h 是树高。

13.1 清空时的有效访问顺序

完整示例使用后序释放:先清空左右子树,再释放当前节点。读取 p->left、p->right 时,p 仍然有效。

也可以先释放左子树,再释放当前节点,最后处理右子树,但必须在释放当前节点之前保存右子树地址。不能释放父节点以后,再通过父节点读取孩子指针。

13.2 空树与空的树指针不同

c
Tree tree;
tree_init(&tree, 20);

这是一个有效的空树对象,tree.root == NULL。但 Tree *p = NULL 根本没有指向树对象。

如果接口允许传入空的树指针,必须在解引用前完整处理:

c
if (tree == NULL) {
    return;
}

只给递归释放加上空指针判断,然后无条件执行 tree->root = NULL,并不能防止空指针解引用。本笔记中的完整实现采用更简单的接口前提:树对象的地址必须有效。

14. 树高决定查找效率

BST 的查找、插入和删除主要沿树的路径进行,成本都与树高 h 有关。

树的形状 树高 查找、插入、删除
高度保持平衡 O(log n) O(log n)
普通 BST 的最坏情况 O(n) O(n)

若依次插入 1、2、3、4、5,普通 BST 可能变成:

text
1
 \
  2
   \
    3
     \
      4
       \
        5

查找 5 要经过所有节点。此时它在搜索路径上已经接近单向链表。

输入顺序会影响树形。普通 BST 不会自动保持平衡;随机顺序在适当假设下有较好的期望高度,但不是对所有输入的保证。

14.1 满、完全、平衡不是同一概念

对于一棵有 h 层的二叉树,最多有 2^h - 1 个节点,这对应每层都被填满的情况。

平衡强调路径高度受到约束,不要求节点填满每一层,也不要求左右子树节点数量完全相等。高效查找需要控制高度,不需要等待树“满员”。

14.2 平衡树增加了维护工作

AVL 树会维护各节点左右子树高度差不超过 1,并在插入、删除后通过调整链接恢复平衡。这样可以保证树高为 O(log n)。

代价是实现更复杂,需要额外状态和维护步骤。普通 BST 更适合用来理解有序链接、查找路径与删除重接,不能直接把它当作已有最坏情况效率保证的索引。

递归遍历和清空也受树高影响。退化树可能产生很深的递归;处理规模大或输入不可控的数据时,应考虑平衡机制或适当的非递归实现。

15. 重复数据:键与记录不一定一一对应

“不允许重复”只是某个容器的策略,并不是树的必然属性。需要先说明什么算相同,以及重复时如何处理。

15.1 词频:一个键对应计数

c
typedef struct {
    char word[64];
    size_t count;
} WordCount;

键是 word,count 是这个键关联的数据。第一次出现时插入节点并设为 1;再次出现时找到同一节点并增加计数。

比较时不能把 count 算进键,否则同一个单词在计数变化以后就可能改变排序位置。

这还需要明确单词边界、大小写规则、超长词处理以及计数溢出规则。数据结构负责保存与查找,文本解析负责判定哪些输入表示同一个词。

15.2 同名宠物:一个键对应多个记录

如果名字是树的键,同名宠物的种类可以放进该节点管理的列表:

text
名字索引树
     ↓
  "Sam" → [猫] → [狗] → [山羊]

树负责按名字定位,列表负责管理这个名字对应的多条记录。这是把两种数据结构组合使用。

需要区分“删除 Sam 的一条记录”和“删除整个 Sam 节点”:删除某个种类后,如果列表为空,才可能继续移除名字节点。释放整个树时,每个节点内部的列表也要清空。

16. 从单向链表扩展到双向链表和栈

16.1 双向链表增加前驱链接

c
typedef struct dnode {
    Film item;
    struct dnode *previous;
    struct dnode *next;
} DNode;

保存尾指针后,可以从尾部沿 previous 反向遍历。插入和删除时,必须同时维护两个方向的链接,以及首尾边界。

已知某个有效节点时,双向链表通常可以直接取得它的前驱;单向链表若只给节点地址,通常还要从头寻找前驱。增加的能力需要额外指针空间和维护成本。

单向链表也可以借助递归实现逆序显示:先处理后继,再显示当前记录。这不改变链接,但需要 O(n) 层调用空间,不适合无界长度。

16.2 栈限制在同一端取放

栈是后进先出,即 LIFO。后压入的元素先弹出。

text
push A → push B → push C
pop 的顺序:C、B、A

它可以由数组实现,也可以用单向链表的头部实现。链式实现把头指针作为栈顶,压入与弹出都只操作头部,不需要寻找尾部。

栈的接口通常包括初始化、判空、判满、压入、弹出、查看栈顶与清空。弹出和出队一样,可以通过返回值报告成功与否,通过输出参数传出数据。

用栈反转字符时,逐个压入,再逐个弹出即可。若处理的是 UTF-8 中文,逐字节反转会破坏编码;需要先明确元素代表字节、Unicode 码点,还是用户看到的完整字符。

17. 接口边界、资源与输入处理

17.1 容器拥有资源时不能随意浅复制

c
List first;
list_init(&first);
/* 向 first 添加记录后…… */
List second = first;

这不会复制节点。两个容器的头尾指针会指向同一组节点,分别清空它们可能导致重复释放。

如果需要独立副本,应逐项分配并复制;中途失败时还要清理已经复制的部分。如果需要转移所有权,转移完成后应把原容器恢复为空,并明确这种操作的接口。

17.2 替换实现仍需保持行为约定

一个列表可以由链表改成数组,但如果原来允许持续追加,新的固定容量实现却只能存 100 项,这就是可观察的行为差异。

接口不变只能减少调用代码的修改。要做到可替换,还需要兼容容量、失败、顺序、重复处理和指针有效期等约定。

同样,把 Item 改成另一种类型,并不总能只改一行 typedef:比较、复制、销毁和格式化输出是否依赖元素类型,都需要检查。

17.3 输入循环必须能处理 EOF

c
int ch;
while ((ch = getchar()) != '\n' && ch != EOF) {
    /* 丢弃当前行剩余内容。 */
}

只等换行而不检查 EOF,可能在输入已经结束时永远循环。getchar() 的结果应先保存在 int 中,以区分所有字符值和 EOF。

交互程序读取菜单、评分和名称时,都要检查读取是否成功。数字可用 fgets() 读整行后再解析,确认范围与末尾内容合法,避免错误输入污染下一轮。

大小写归一化也要一致:插入、查找、删除都使用同一种规则。调用字符分类和转换函数时,用 toupper((unsigned char)*p) 等形式避免把负的普通 char 值直接作为参数;这类逐字节处理也不等于完整的 Unicode 大小写转换。

17.4 调试容器时优先观察不变量

链表重点观察头尾关系、实际节点数、末尾空链接和是否成环。队列重点观察空队列、单节点队列、满队列及反复入出队后的状态。BST 还要检查所有后代的排序范围,而不仅是每对父子节点。

分配失败应当发生在“原结构尚未被破坏”的位置。删除成功后检查计数,删除不存在的项时检查原内容,清空后检查是否能再次使用。

调试输出不应通过解引用已经释放的节点来确认释放结果。内存检查工具可以帮助发现越界、释放后访问和重复释放,但接口约定与所有权仍需在设计阶段说明。

18. 按操作需求选择表示方式

主要需求 可优先考虑 需要留意
经常通过下标读取 连续数组或动态数组 扩容及中间插入的移动成本
顺序遍历,已知位置频繁插入删除 链表 定位成本、节点额外空间
固定容量、先进先出 环形数组队列 满空判定与下标环绕
按需分配、先进先出 保存首尾指针的链式队列 分配失败与首尾边界
按键查找并保持有序 二叉查找树 普通 BST 的退化风险
后进先出 栈 栈顶边界和容量规则

先明确数据的含义、允许的操作和失败规则,再选择能让这些操作简单可靠的表示。接口负责稳定地表达需求,实现负责维护链接、顺序和资源,不变量负责把两者连接起来。

Aron 的清风日常

作者 Aron · 发布于 2026.08.22

愿这份记录能为你提供一点参考。发现问题,欢迎通过 GitHub 或 邮件交流。

如果这篇记录对你有用,留下一份喜欢。

← 所有文章继续阅读C Primer Plus(第6版)· 附录整理:语法与标准库速查 →
SEARCH MY NOTES

在记录里,找一点答案

搜索标题、标签与全文 · 按 Esc 关闭