第20章 数组版数据库

20.1 问题从哪来#

前面几章学了结构体、数组、指针、函数,也开始用测试和不变量检查程序。用结构体能表示一个学生,用数组能存一批学生,用函数能把逻辑拆开。

但这些能力分散在不同的小程序里。每次写一个新功能,就得在 main 里重新声明数组、重新写循环、重新管理 count。如果要同时管理两份学生名单,比如一班和二班,就得复制一份数组和一份计数器。

上一章还留了一个提醒:程序不只是能跑,还要守住自己的边界。这里最重要的边界就是 count:它不能小于 0,也不能超过数组容量;插入、查找、删除、列出都只能围着有效记录转。

能不能把这些数据和操作打包在一起,让"数据库"变成一个独立的东西?


20.2 先看一个例子#

假设有一个程序,用户做了这几步操作:

  1. 插入学号 1,Alice,92 分。
  2. 插入学号 2,Bob,78 分。
  3. 插入学号 3,Carol,85 分。
  4. 按学号 2 查找,找到 Bob。
  5. 删除学号 2。
  6. 列出所有学生,只剩 Alice 和 Carol。

这几步的关键变化,是 rows 里有效记录的范围和 count 一起移动:

数组版数据库的一次完整操作轨迹

插入时新记录写到 rows[count],然后 count 加一。查找只读数组,不改变 count。删除时找到那条记录,把后面的记录往前挪一格,再让 count 减一。列出时只读 count 以内的记录。


20.3 最小实验#

数据库的核心结构只有两样东西:一个数组,一个计数器。

#define DB_CAPACITY 100

struct Student {
    int id;
    char name[32];
    int score;
};

struct DB {
    struct Student rows[DB_CAPACITY];   // 最多 100 条记录
    int count;                          // 当前有多少条
};

DB 结构内部:rows 数组和 count 计数器

rows 是一个长度为 100 的数组,每个元素是一个 struct Studentcount 记录当前有多少条有效数据。下标小于 count 的位置属于有效记录;从 count 开始的位置暂时不读,里面可能还保留着旧值。

四个操作围绕 struct DB 展开,每个函数都接收一个 struct DB *db 参数:

20.3.1 插入#

// 插入一条记录,成功返回 1,表满返回 0
int db_insert(struct DB *db, int id, const char *name, int score)
{
    if (db->count >= DB_CAPACITY) {
        return 0;                       // 表满了
    }
    struct Student *s = &db->rows[db->count];  // 指向下一个空位
    s->id = id;
    snprintf(s->name, sizeof(s->name), "%s", name);
    s->score = score;
    db->count = db->count + 1;          // 计数器加一
    return 1;
}

插入记录:写到当前空位,然后 count 加一

插入先用 count 找到下一个空位,再把新记录写进去,最后把 count 往后移一格。新记录永远追加在已有数据的后面。

20.3.2 查找#

// 按学号查找,找到返回该记录的指针,没找到返回 NULL
struct Student *db_find(struct DB *db, int id)
{
    for (int i = 0; i < db->count; i++) {
        if (db->rows[i].id == id) {
            return &db->rows[i];        // 找到了,返回指针
        }
    }
    return NULL;                        // 没找到
}

线性扫描:从第 0 条开始逐条比对,找到就停

查找从第 0 条开始,逐条比对学号。找到了就返回那条记录的地址,找不到就返回 NULL。最坏情况要把所有记录都扫一遍。

20.3.3 删除#

// 按学号删除,成功返回 1,没找到返回 0
int db_delete(struct DB *db, int id)
{
    int found = -1;
    for (int i = 0; i < db->count; i++) {
        if (db->rows[i].id == id) {
            found = i;                  // 记住位置
            break;
        }
    }
    if (found == -1) {
        return 0;                       // 没找到
    }
    // 把后面的元素往前挪一格
    for (int j = found; j < db->count - 1; j++) {
        db->rows[j] = db->rows[j + 1];
    }
    db->count = db->count - 1;          // 计数器减一
    return 1;
}

删除记录:先找到 Bob 的位置

删除函数先从头比较 rows[0]rows[1],找到 Bob 的位置以后,把 found 设成 1。

删除记录:Carol 前移,count 决定有效范围

找到目标以后,第二个循环执行 rows[1] = rows[2],让 Carol 补到空位。最后 count 减一,后面的旧内容不再算记录。

20.3.4 列出全部#

// 打印所有记录
void db_list(struct DB *db)
{
    if (db->count == 0) {
        printf("Database is empty.\n");
        return;
    }
    printf("ID  Name              Score\n");
    printf("------------------------\n");
    for (int i = 0; i < db->count; i++) {
        printf("%-6d%-12s%d\n",
               db->rows[i].id,
               db->rows[i].name,
               db->rows[i].score);
    }
    printf("------------------------\n");
    printf("Total %d records.\n", db->count);
}

列出全部:只遍历 count 以内的记录

列出就是遍历 rows[0]rows[count-1],逐条打印。循环条件是 i < db->count,不是 i < 100,这样只输出有效数据。

20.3.5 接上菜单循环#

把插入、查找、删除、列出四个函数接到菜单循环里。菜单本身不是重点,重点是每个选项最后调用哪一个数据库函数:

菜单项读入内容调用
1-插入idnamescoredb_insert(&db, id, name, score)
2-查找iddb_find(&db, id)
3-删除iddb_delete(&db, id)
4-列出db_list(&db)
0-退出break

可以先让 main 只支持插入和列出,确认 count 正确变化;再加查找;最后加删除。每加一个菜单项,都只检查一件事:这个分支有没有把输入转换成一次数据库函数调用。


20.4 编译运行#

把前面的结构体和函数接到一个小菜单里,就能得到一份完整的 db.c。先看完整程序,运行时再对照上面的四个函数。

#include <stdio.h>

#define DB_CAPACITY 100

struct Student {
    int id;
    char name[32];
    int score;
};

struct DB {
    struct Student rows[DB_CAPACITY];   // 最多 100 条记录
    int count;                          // 当前有多少条
};

// 插入一条记录,成功返回 1,表满返回 0
int db_insert(struct DB *db, int id, const char *name, int score)
{
    if (db->count >= DB_CAPACITY) {
        return 0;                       // 表满了
    }
    struct Student *s = &db->rows[db->count];  // 指向下一个空位
    s->id = id;
    snprintf(s->name, sizeof(s->name), "%s", name);
    s->score = score;
    db->count = db->count + 1;          // 计数器加一
    return 1;
}

// 按学号查找,找到返回该记录的指针,没找到返回 NULL
struct Student *db_find(struct DB *db, int id)
{
    for (int i = 0; i < db->count; i++) {
        if (db->rows[i].id == id) {
            return &db->rows[i];        // 找到了,返回指针
        }
    }
    return NULL;                        // 没找到
}

// 按学号删除,成功返回 1,没找到返回 0
int db_delete(struct DB *db, int id)
{
    int found = -1;
    for (int i = 0; i < db->count; i++) {
        if (db->rows[i].id == id) {
            found = i;                  // 记住位置
            break;
        }
    }
    if (found == -1) {
        return 0;                       // 没找到
    }
    // 把后面的元素往前挪一格
    for (int j = found; j < db->count - 1; j++) {
        db->rows[j] = db->rows[j + 1];
    }
    db->count = db->count - 1;          // 计数器减一
    return 1;
}

// 打印所有记录
void db_list(struct DB *db)
{
    if (db->count == 0) {
        printf("Database is empty.\n");
        return;
    }
    printf("ID  Name              Score\n");
    printf("------------------------\n");
    for (int i = 0; i < db->count; i++) {
        printf("%-6d%-12s%d\n",
               db->rows[i].id,
               db->rows[i].name,
               db->rows[i].score);
    }
    printf("------------------------\n");
    printf("Total %d records.\n", db->count);
}

int main(void)
{
    struct DB db;
    db.count = 0;                       // 初始化计数器

    while (1) {
        int choice;
        printf("1-Insert  2-Search  3-Delete  4-List  0-Exit\n");
        printf("Choice: ");
        if (scanf("%d", &choice) != 1) {
            break;                      // 输入非法,退出程序
        }

        if (choice == 0) {
            printf("Exited.\n");
            break;
        }

        if (choice == 1) {
            // 插入:读入学生信息,调用 db_insert
            int id;
            char name[32];
            int score;
            printf("ID: ");
            scanf("%d", &id);
            printf("Name: ");
            scanf("%31s", name);
            printf("Score: ");
            scanf("%d", &score);

            if (db_insert(&db, id, name, score)) {
                printf("Inserted successfully. Total %d records.\n", db.count);
            } else {
                printf("Database full, insertion failed.\n");
            }
        } else if (choice == 2) {
            // 查找:读入学号,调用 db_find
            int id;
            printf("Enter ID: ");
            scanf("%d", &id);

            struct Student *s = db_find(&db, id);
            if (s != NULL) {
                printf("Found: ID %d, Name %s, Score %d\n",
                       s->id, s->name, s->score);
            } else {
                printf("Not found.\n");
            }
        } else if (choice == 3) {
            // 删除:读入学号,调用 db_delete
            int id;
            printf("Enter ID: ");
            scanf("%d", &id);

            if (db_delete(&db, id)) {
                printf("Deleted successfully. Total %d records.\n", db.count);
            } else {
                printf("Not found, deletion failed.\n");
            }
        } else if (choice == 4) {
            db_list(&db);               // 列出全部记录
        } else {
            printf("Unknown option.\n");
        }

        printf("\n");
    }

    return 0;
}

保存为 db.c,编译:

$ gcc db.c -o db

运行,依次操作:

1-Insert  2-Search  3-Delete  4-List  0-Exit
Choice:
$ 1
ID:
$ 1
Name:
$ Alice
Score:
$ 92
Inserted successfully. Total 1 records.

1-Insert  2-Search  3-Delete  4-List  0-Exit
Choice:
$ 1
ID:
$ 2
Name:
$ Bob
Score:
$ 78
Inserted successfully. Total 2 records.

1-Insert  2-Search  3-Delete  4-List  0-Exit
Choice:
$ 1
ID:
$ 3
Name:
$ Carol
Score:
$ 85
Inserted successfully. Total 3 records.

1-Insert  2-Search  3-Delete  4-List  0-Exit
Choice:
$ 2
Enter ID:
$ 2
Found: ID 2, Name Bob, Score 78

1-Insert  2-Search  3-Delete  4-List  0-Exit
Choice:
$ 3
Enter ID:
$ 2
Deleted successfully. Total 2 records.

1-Insert  2-Search  3-Delete  4-List  0-Exit
Choice:
$ 4

ID  Name              Score
------------------------
1     Alice       92
3     Carol       85
------------------------
Total 2 records.

1-Insert  2-Search  3-Delete  4-List  0-Exit
Choice:
$ 0
Exited.

删除 Bob 之后,Carol 从 rows[2] 挪到了 rows[1]count 从 3 变成 2。列出时只打印前两条,Carol 不会丢失。


20.5 数据/内存/流程里发生了什么#

20.5.1 DB 结构在内存里的样子#

struct DB 包含一个数组和一个整数。数组在内存里是连续的一大片,每个元素紧挨着下一个:

DB 结构内部:rows 数组和 count 计数器

count = 3 表示前三个位置有有效记录。后面的 97 个位置不属于当前数据库内容,程序不会把它们打印出来;至于里面的字节是什么,暂时不靠它们表达含义。

20.5.2 插入的流程#

插入学号 4,David,88 分:

插入记录:写到当前空位,然后 count 加一

检查 db->count 没有超过容量以后,s 指向 rows[3]。新记录写进这个位置,count 再从 3 变成 4。新记录总是追加在已有数据的末尾,不需要移动任何已有记录。

20.5.3 查找的流程#

查找学号 2:

线性扫描:从第 0 条开始逐条比对,找到就停

第 1 轮看 rows[0],Alice 的学号不是 2;第 2 轮看 rows[1],Bob 的学号是 2,函数返回 &rows[1]。最多比较 count 次。如果要找的学号不在表里,整个循环走完,返回 NULL

20.5.4 删除的流程#

删除学号 2(Bob):

删除记录:先找到 Bob 的位置

删除记录:Carol 前移,count 决定有效范围

先查找目标学号,找到 Bob 在 rows[1]。然后执行 rows[1] = rows[2],Carol 从位置 2 挪到位置 1。最后 count 从 3 变成 2。图里的 rows[2] 标成旧值,是因为数组没有自动擦除旧内容;程序只会访问 rows[0]rows[1],是 count 决定哪些位置还算数据库里的记录。

注意:移动元素这一步是删除操作最慢的地方。如果表里有 1 万条记录,删除第 1 条就要挪 9999 次。数组版数据库的删除效率取决于被删除记录的位置。


20.6 四个操作的复杂度#

操作最好情况最坏情况说明
插入$O(1)$$O(1)$总是写到末尾,不需要移动
查找$O(1)$$O(n)$第一条就命中 vs 扫完全部
删除$O(1)$$O(n)$表为空或只有一条时很快;一般要查找,找到后还可能移动后面的元素
列出$O(n)$$O(n)$必须遍历全部

插入最快,因为它总是在末尾追加。查找需要从头扫描,这是线性查找的代价。删除也要先查找;如果删的是中间或靠前的位置,还要把后面的元素往前挪。删除最后一条时不需要移动元素,但为了找到它,仍然要从前面一路比较过去。


20.7 和第 8 章的对比#

第 8 章也用结构体数组存学生记录。区别在哪里?

第 8 章本章
数据存放main 里的局部变量struct DB 结构体
操作方式代码直接写在 mainif-else独立函数,接收 DB * 参数
功能添加、查找、打印插入、查找、删除、列出
多实例容易写成一堆分散变量可以声明多个 struct DB

把数据和操作装进结构体和函数,最大的好处是:一个程序里可以同时运行多个数据库。

struct DB class_a;
struct DB class_b;
class_a.count = 0;
class_b.count = 0;

db_insert(&class_a, 1, "Alice", 92);
db_insert(&class_b, 1, "Bob", 78);

两个数据库各自独立,互不干扰。这里的两份数据库都保存学生记录;如果要保存课程记录,需要再定义一套适合课程的结构体和函数。


20.8 常见坑#

坑 1:忘记初始化 count

struct DB db;
// 忘了 db.count = 0;
db_insert(&db, 1, "Alice", 92);   // db.count 的值不确定

局部变量不会自动清零。db.count 可能是任意值,插入时可能写到数组的随机位置。声明 struct DB 后,第一件事就是 db.count = 0

坑 2:删除时只挪了一格。

// 错:只挪了一次
db->rows[found] = db->rows[found + 1];

// 对:要把 found 之后的所有元素都往前挪
for (int j = found; j < db->count - 1; j++) {
    db->rows[j] = db->rows[j + 1];
}

如果只挪一格,rows[found+1] 的数据被覆盖了,但 rows[found+2] 及之后的数据没有动,中间会出现一个"洞"。

坑 3:删除后忘了 count 减一。

for (int j = found; j < db->count - 1; j++) {
    db->rows[j] = db->rows[j + 1];
}
// 忘了 db->count = db->count - 1;

结果:count 没变,但最后一条记录被挪走了,rows[count-1] 位置上会有重复数据。

坑 4:查找返回指针后,数据库发生了变化。

struct Student *s = db_find(&db, 2);
db_delete(&db, 1);          // 删除 Alice,Bob 会被前移
printf("%s\n", s->name);    // s 指向的位置可能已经被覆盖

db_find 返回的是指向 rows 数组内部的指针。上面的例子里,s 原来指向 Bob 所在的位置;删除 Alice 后,Carol 会前移到那个位置,s 看到的内容就可能变了。安全做法是找到之后立刻把数据复制出来:

struct Student *s = db_find(&db, 2);
if (s != NULL) {
    struct Student copy = *s;       // 复制一份
    // 之后用 copy,不用担心被移动
}

坑 5:snprintf 的缓冲区大小写错。

snprintf(s->name, sizeof(s->name), "%s", name);     // 对:用 sizeof 自动算大小
snprintf(s->name, sizeof(s->name), "%s", name);  // 更好:自动算大小
snprintf(s->name, 100, "%s", name);    // 错:name 只有 32 字节,越界了

snprintf 的第二个参数是目标缓冲区的大小。写大了会越界,写小了会截断。用 sizeof(s->name) 最安全。


20.9 自己试试看#

Q1:给 struct Student 加一个 age 字段(年龄),修改插入和列出函数。

提示:在 struct Student 里加 int age;,插入时多读一个数字,打印时多输出一列。

Q2:写一个 db_count 函数,返回数据库当前的记录数。

提示:直接返回 db->count

Q3:写一个 db_clear 函数,清空数据库的所有记录。

提示:把 count 设成 0 就行。不需要真的擦除数组里的数据——count 为 0 时,程序不会访问任何记录。

Q4:修改程序,让查找和删除操作在表为空时直接提示"数据库为空",而不是让用户输入学号再告诉"没找到"。

提示:在操作开始前检查 db->count == 0

Q5:声明两个 struct DB,一个存一班学生,一个存二班学生,分别插入几条记录,验证它们互不影响。

Q6:修改 db_delete 函数,让它删除所有匹配的记录,而不是只删第一条。

提示:删除一条后,后面的元素会前移到当前位置。这个时候不要立刻跳到下一个下标,要继续检查当前位置的新记录。


下一章的问题#

数组版数据库能插入、查找、删除、列出,四个操作都有了。但 DB_CAPACITY 现在写成 100,编译出来的数组容量就是固定的——最多只能放 100 条记录。

如果一个学校有 500 个学生,第 101 条记录插不进去。如果只存了 5 条记录,剩下的 95 个位置白白占着内存。

怎样让数据库的容量能根据数据量自动变大或变小?