【数据结构】顺序表的应用:动态通讯录项目与双指针经典算法

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者。

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位:把动态顺序表从"会写接口"推进到"会做项目",完整串起联系人建模、增删查改、文件持久化、菜单交互,以及两道顺序表经典算法题。

💡 学习目标:理解容器层与业务层如何解耦;能独立实现一个可持久化通讯录;掌握"移除元素"和"合并两个有序数组"的双指针思路;能从复杂度和内存模型解释顺序表的优缺点。

📌 环境约定 :代码使用标准 C99 写法,示例数据文件采用二进制格式。scanf_s、控制台中文编码等平台差异不在本文展开;正式项目应进一步处理输入长度、文件格式版本和写入失败恢复。


文章目录


一、从顺序表接口走向真实项目

上一讲实现动态顺序表时,我们关心的是这些基本操作:

  • 初始化与销毁;
  • 尾插、头插与指定位置插入;
  • 尾删、头删与指定位置删除;
  • 查找、访问、修改;
  • 容量不足时扩容。

这些接口解决了"怎样管理一段连续的动态空间",但它们还没有回答一个更重要的问题:顺序表究竟能拿来做什么?

通讯录非常适合作为第一个应用项目。一个联系人是一条结构化记录,多名联系人天然组成一个线性序列;添加、删除、查找、修改和展示,又恰好可以映射到顺序表的常见操作。

项目需求可以概括为:

  1. 至少能够存储 100 名联系人的信息;
  2. 每条记录包含姓名、性别、年龄、电话和地址;
  3. 支持添加、删除、查找、修改和展示;
  4. 程序退出后数据不能丢失,下次启动还要能够恢复。

第四点把项目从"内存练习"推进到了"完整应用"。内存随着进程结束而失效,想让历史信息保留下来,就必须把当前状态写入文件,并在下一次启动时读回来。

本项目真正串起了四块基础知识:结构体负责描述一条记录,动态内存负责容纳可变数量的记录,顺序表负责组织记录,文件操作负责跨运行周期保存记录。


二、先把项目分层:容器、业务、交互与文件

如果把所有代码都塞进 main,项目很快会变成大量 scanf、循环、realloc 和数组移动混在一起的"大函数"。更清晰的做法是把职责拆成四层。

层次 主要文件 职责
交互层 test.c 打印菜单、读取命令、调用业务接口
业务层 contact.c/.h 添加、删除、查找、修改、展示、加载和保存联系人
容器层 SeqList.c/.h 管理连续动态空间、扩容、移动元素和维护 size/capacity
持久化层 数据文件 在进程结束后保存联系人记录

这里最关键的复用技巧,是把顺序表的元素类型改成联系人类型:

c 复制代码
typedef struct PersonInfo SeqDataType;

于是,顺序表不再存 int,而是存一个个 PersonInfo。容器层仍然只关心"元素怎样插入和移动",并不需要理解姓名、电话或地址的业务含义。

业务层又可以给顺序表起一个更贴合语义的别名:

c 复制代码
typedef struct SeqList Contact;

这句话并没有创建一种新的内存布局。Contact 仍然是 struct SeqList,只是当它出现在通讯录代码中时,阅读者更容易理解变量的角色。

text 复制代码
菜单命令
   ↓
Contact 业务操作
   ↓
SeqList 容器接口
   ↓
连续的 PersonInfo 数组
   ↕
contact.dat

分层的价值:联系人字段发生变化时,不需要重新实现扩容;顺序表的扩容策略改变时,也不需要重写菜单和查找逻辑。


三、设计联系人与动态顺序表

3.1 一条联系人记录

先定义业务数据。数组长度必须包含字符串结尾的 \0,所以 11 位手机号至少需要 12 个字节。为了给国家码、分隔符或将来的格式变化留余量,这里直接使用 16。

c 复制代码
// contact.h
#pragma once

#include <stddef.h>

#define NAME_MAX_LEN  50
#define SEX_MAX_LEN    8
#define TEL_MAX_LEN   16
#define ADDR_MAX_LEN 100

typedef struct PersonInfo
{
    char name[NAME_MAX_LEN];
    char sex[SEX_MAX_LEN];
    int  age;
    char tel[TEL_MAX_LEN];
    char addr[ADDR_MAX_LEN];
} PeoInfo;

为什么不把电话号码定义成整数?因为电话号码不是参与算术的数值:

  • 可能以 0 开头;
  • 可能包含 +86、空格或短横线;
  • 位数可能超过某些整数类型范围;
  • 我们只需要存储、比较和显示它。

因此,电话号码应当按字符串保存。

3.2 顺序表的三个核心成员

c 复制代码
// SeqList.h
#pragma once

#include <stddef.h>
#include "contact.h"

typedef PeoInfo SeqDataType;

typedef struct SeqList
{
    SeqDataType* data;
    size_t size;
    size_t capacity;
} SeqList;

void SeqListInit(SeqList* ps);
void SeqListDestroy(SeqList* ps);
void SeqListPushBack(SeqList* ps, SeqDataType value);
void SeqListErase(SeqList* ps, size_t pos);

三个成员必须始终满足对象不变式:

text 复制代码
0 <= size <= capacity
capacity == 0 时,data 可以为 NULL
capacity > 0 时,data 指向至少 capacity 个元素的可用空间
有效联系人只位于 data[0] ~ data[size - 1]

所有公开接口都应维护这些条件。只要某次失败操作破坏了 size、capacity 或 data 之间的关系,后面的任意操作都可能越界。

3.3 初始化、扩容与尾插

c 复制代码
#include "SeqList.h"

#include <assert.h>
#include <stdio.h>
#include <stdlib.h>

void SeqListInit(SeqList* ps)
{
    assert(ps != NULL);
    ps->data = NULL;
    ps->size = 0;
    ps->capacity = 0;
}

static int SeqListCheckCapacity(SeqList* ps)
{
    if (ps->size < ps->capacity)
        return 1;

    size_t new_capacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
    SeqDataType* tmp = realloc(ps->data,
                               new_capacity * sizeof(SeqDataType));
    if (tmp == NULL)
    {
        perror("realloc");
        return 0;
    }

    ps->data = tmp;
    ps->capacity = new_capacity;
    return 1;
}

void SeqListPushBack(SeqList* ps, SeqDataType value)
{
    assert(ps != NULL);
    if (!SeqListCheckCapacity(ps))
        return;

    ps->data[ps->size] = value;
    ++ps->size;
}

这里用临时指针接收 realloc。如果直接写:

c 复制代码
ps->data = realloc(ps->data, new_capacity * sizeof(SeqDataType));

一旦扩容失败,realloc 返回 NULL,原地址就会被覆盖并丢失,形成内存泄漏。临时指针让失败路径能够保留原顺序表。

若需求明确要求"至少存储 100 人",也可以初始化时直接预留 100 个元素;但动态顺序表更常见的策略是从较小容量开始,在需要时按倍数增长。


四、初始化与加载:让历史数据回到内存

通讯录启动时不能只调用 SeqListInit。初始化得到的是空容器,随后还要把文件中的历史记录逐条读入。

先在头文件中声明业务接口:

c 复制代码
typedef struct SeqList Contact;

void ContactInit(Contact* con);
void ContactAdd(Contact* con);
void ContactDelete(Contact* con);
void ContactFind(const Contact* con);
void ContactModify(Contact* con);
void ContactShow(const Contact* con);
void ContactDestroy(Contact* con);

4.1 加载函数

c 复制代码
// contact.c
#include "contact.h"
#include "SeqList.h"

#include <stdio.h>
#include <string.h>

#define CONTACT_FILE "contact.dat"

static void ContactLoad(Contact* con)
{
    FILE* pf = fopen(CONTACT_FILE, "rb");
    if (pf == NULL)
    {
        // 首次运行时文件可能尚不存在,这不是致命错误。
        return;
    }

    PeoInfo info;
    while (fread(&info, sizeof(info), 1, pf) == 1)
    {
        SeqListPushBack(con, info);
    }

    if (ferror(pf))
        perror("fread");

    fclose(pf);
}

void ContactInit(Contact* con)
{
    SeqListInit(con);
    ContactLoad(con);
}

循环条件直接检查 fread 是否成功读到一条完整记录,优于先判断 feof:

c 复制代码
while (!feof(pf))  // 不推荐

EOF 标志只有在一次读取尝试失败后才会设置。用 feof 控制循环,容易多处理一次旧数据或写出绕弯的错误逻辑。

4.2 为什么加载时继续调用 SeqListPushBack

加载函数不应该自己修改 size、判断容量并调用 realloc。这些已经是容器层的职责。复用 SeqListPushBack 可以让所有插入统一遵守相同的不变式和扩容策略。

当然,这也带来一个性能细节:如果文件中有很多记录,逐条尾插可能触发多次扩容。更完整的文件格式可以在头部保存记录数,加载时一次性预留足够容量。不过对于教学项目,逐条插入更直观,也足够安全。


五、添加与展示:最直接的两项业务

5.1 添加联系人

为了避免无界 %s 写爆字符数组,可以给格式串指定最大宽度。宽度不包含结尾的 \0,因此要比数组容量小 1。

c 复制代码
void ContactAdd(Contact* con)
{
    PeoInfo info = {0};

    printf("姓名:");
    if (scanf("%49s", info.name) != 1)
        return;

    printf("性别:");
    if (scanf("%7s", info.sex) != 1)
        return;

    printf("年龄:");
    if (scanf("%d", &info.age) != 1)
        return;

    printf("电话:");
    if (scanf("%15s", info.tel) != 1)
        return;

    printf("地址:");
    if (scanf("%99s", info.addr) != 1)
        return;

    SeqListPushBack(con, info);
    printf("添加成功。\n");
}

数组名 info.name 在这里会转换为首元素地址,因此不应写成 &info.name。二者数值地址通常相同,但类型不同:

c 复制代码
info.name   // char*
&info.name  // char (*)[NAME_MAX_LEN]

可变参数函数无法替我们修正这种类型不匹配。启用编译器警告,能尽早发现问题。

示例中的 %s 仍不能读取包含空格的地址。若要支持"北京市 海淀区"这类输入,应改用 fgets,并统一处理上一轮输入留下的换行符。

5.2 展示联系人

c 复制代码
void ContactShow(const Contact* con)
{
    printf("%-16s %-8s %-6s %-16s %-30s\n",
           "姓名", "性别", "年龄", "电话", "地址");

    for (size_t i = 0; i < con->size; ++i)
    {
        const PeoInfo* p = &con->data[i];
        printf("%-16s %-8s %-6d %-16s %-30s\n",
               p->name, p->sex, p->age, p->tel, p->addr);
    }
}

这里使用 const Contact*,明确函数只读通讯录。const 不只是"防手滑",也是接口契约:调用者看到声明,就知道展示操作不会改变容器状态。

终端中中文字符的显示宽度并不总等于字节数,所以 printf 的字段宽度只能提供粗略对齐。若目标是稳定的跨平台表格,需要使用理解 Unicode 显示宽度的终端库或图形界面。


六、查找、删除与修改:先把姓名变成下标

顺序表擅长按下标访问,但用户输入的是姓名。因此业务层需要一个桥梁:扫描联系人数组,把姓名转换成位置。

6.1 按姓名查找位置

c 复制代码
static int ContactFindByName(const Contact* con, const char* name)
{
    for (size_t i = 0; i < con->size; ++i)
    {
        if (strcmp(con->data[i].name, name) == 0)
            return (int)i;
    }
    return -1;
}

这里用 -1 表示未找到,因此返回类型选择 int。如果项目可能存储超过 INT_MAX 条记录,应改用 size_t 并以 SIZE_MAX 作为失败哨兵,或通过输出参数返回位置。

按姓名线性扫描的时间复杂度为 O(N)。数组支持 O(1) 随机访问,指的是"已知下标时访问元素";它并不会让"按内容查找"自动变成 O(1)。

6.2 删除联系人

c 复制代码
void SeqListErase(SeqList* ps, size_t pos)
{
    assert(ps != NULL);
    assert(pos < ps->size);

    for (size_t i = pos; i + 1 < ps->size; ++i)
        ps->data[i] = ps->data[i + 1];

    --ps->size;
}

void ContactDelete(Contact* con)
{
    char name[NAME_MAX_LEN];
    printf("要删除的姓名:");
    if (scanf("%49s", name) != 1)
        return;

    int pos = ContactFindByName(con, name);
    if (pos < 0)
    {
        printf("联系人不存在。\n");
        return;
    }

    SeqListErase(con, (size_t)pos);
    printf("删除成功。\n");
}

删除包含两个阶段:先查找,再搬移后续元素。

  • 查找最坏扫描 N 个元素;
  • 删除位置靠前时,最坏搬移 N-1 个元素;
  • 两项相加仍是 O(N),不是 O(N²)。

6.3 查找并展示

c 复制代码
static void PrintOne(const PeoInfo* p)
{
    printf("姓名:%s\n", p->name);
    printf("性别:%s\n", p->sex);
    printf("年龄:%d\n", p->age);
    printf("电话:%s\n", p->tel);
    printf("地址:%s\n", p->addr);
}

void ContactFind(const Contact* con)
{
    char name[NAME_MAX_LEN];
    printf("要查找的姓名:");
    if (scanf("%49s", name) != 1)
        return;

    int pos = ContactFindByName(con, name);
    if (pos < 0)
    {
        printf("联系人不存在。\n");
        return;
    }

    PrintOne(&con->data[pos]);
}

6.4 修改联系人

最简单的修改策略,是找到记录后重新输入全部字段:

c 复制代码
void ContactModify(Contact* con)
{
    char old_name[NAME_MAX_LEN];
    printf("要修改的姓名:");
    if (scanf("%49s", old_name) != 1)
        return;

    int pos = ContactFindByName(con, old_name);
    if (pos < 0)
    {
        printf("联系人不存在。\n");
        return;
    }

    PeoInfo updated = {0};
    printf("新姓名:");
    if (scanf("%49s", updated.name) != 1) return;
    printf("新性别:");
    if (scanf("%7s", updated.sex) != 1) return;
    printf("新年龄:");
    if (scanf("%d", &updated.age) != 1) return;
    printf("新电话:");
    if (scanf("%15s", updated.tel) != 1) return;
    printf("新地址:");
    if (scanf("%99s", updated.addr) != 1) return;

    con->data[pos] = updated;
    printf("修改成功。\n");
}

先读入临时对象,再一次性覆盖原记录,有一个重要好处:如果输入在中途失败,旧联系人不会处于"只改了一半"的状态。这是一种很实用的先构造完整新状态,再提交的思路。


七、保存、销毁与菜单循环

7.1 退出前保存

c 复制代码
static int ContactSave(const Contact* con)
{
    FILE* pf = fopen(CONTACT_FILE, "wb");
    if (pf == NULL)
    {
        perror("fopen");
        return 0;
    }

    size_t written = fwrite(con->data,
                            sizeof(PeoInfo),
                            con->size,
                            pf);
    int close_ok = fclose(pf) == 0;

    if (written != con->size || !close_ok)
    {
        fprintf(stderr, "通讯录保存不完整。\n");
        return 0;
    }

    return 1;
}

一次性写入 con->size 条记录,比循环调用 fwrite 更简洁。con->data 在 size == 0 时可以为 NULL,只要写入元素个数也是 0,就不会解引用它。

7.2 销毁顺序表

c 复制代码
void SeqListDestroy(SeqList* ps)
{
    assert(ps != NULL);
    free(ps->data);
    ps->data = NULL;
    ps->size = 0;
    ps->capacity = 0;
}

void ContactDestroy(Contact* con)
{
    if (ContactSave(con))
        printf("通讯录已保存。\n");

    SeqListDestroy(con);
}

释放以后把指针和两个计数都归零,可以让对象回到与"空顺序表"一致的可识别状态,降低误用悬空指针的风险。

7.3 菜单循环

c 复制代码
enum MenuOption
{
    EXIT,
    ADD,
    DELETE,
    FIND,
    MODIFY,
    SHOW
};

static void Menu(void)
{
    puts("********************************");
    puts("***** 1. 添加    2. 删除  *****");
    puts("***** 3. 查找    4. 修改  *****");
    puts("***** 5. 展示    0. 退出  *****");
    puts("********************************");
}

int main(void)
{
    Contact con;
    ContactInit(&con);

    int option = -1;
    do
    {
        Menu();
        printf("请选择:");
        if (scanf("%d", &option) != 1)
        {
            fprintf(stderr, "输入无效,程序结束。\n");
            option = EXIT;
        }

        switch (option)
        {
        case ADD:    ContactAdd(&con);    break;
        case DELETE: ContactDelete(&con); break;
        case FIND:   ContactFind(&con);   break;
        case MODIFY: ContactModify(&con); break;
        case SHOW:   ContactShow(&con);   break;
        case EXIT:   break;
        default:     puts("没有这个选项,请重新输入。");
        }
    } while (option != EXIT);

    ContactDestroy(&con);
    return 0;
}

课程代码中常见的一个小问题,是把 0 也落入 default,导致用户选择退出时先打印"输入有误"。显式添加 case EXIT 可以让控制流与菜单含义一致。


八、课堂代码之外必须补上的工程细节

教学代码通常突出核心知识,不会穷尽所有失败场景。把它扩展成真正可长期使用的小工具时,至少还要考虑下面这些问题。

8.1 字符数组必须给 \0 留位置

c 复制代码
char tel[11];

只能安全保存最多 10 个普通字符加终止符,不能保存完整的 11 位手机号。类似地,UTF-8 中文字符通常占多个字节,char sex[4] 未必足以保存"女性"等文本。

8.2 %s 不能读取带空格文本

姓名和电话通常没有空格,但地址很可能有。若要完整读取一行,可以使用:

c 复制代码
fgets(info.addr, sizeof(info.addr), stdin);
info.addr[strcspn(info.addr, "\n")] = '\0';

混用 scanf 与 fgets 时,需要先处理输入缓冲区中残留的换行。更稳妥的做法是所有输入都先 fgets 到缓冲区,再用 strtol 等函数解析数字。

8.3 重名联系人需要明确策略

当前 ContactFindByName 返回第一个匹配项。如果存在两名同名联系人,删除或修改哪一个就不够明确。可选策略包括:

  • 禁止姓名重复;
  • 以电话号码作为唯一标识;
  • 为每条记录生成不可重复的 ID;
  • 查找后展示所有匹配项,再让用户二次选择。

8.4 直接写结构体不保证长期兼容

二进制写入 PeoInfo 很方便,但文件会受到以下因素影响:

  • 编译器的结构体填充与对齐;
  • 整数大小和字节序;
  • 字段长度变化;
  • 新版本增加或删除字段;
  • 不同平台之间的编码差异。

因此,这种格式适合同一程序版本下的教学演示,却不适合作为跨平台、跨版本的长期交换格式。更稳健的设计可以选择 CSV、JSON、自定义带版本号的记录格式,或明确序列化每个字段。

8.5 保存失败时不能假装成功

fwrite 返回值和 fclose 都可能失败。正式程序常把数据写到临时文件,确认完全写入并刷新后,再用原子重命名替换旧文件。这样即使写入中途断电,也更不容易同时失去新旧两份数据。

8.6 业务接口也应该反馈失败

若 SeqListPushBack 扩容失败,它不应该只打印错误而让业务层继续宣布"添加成功"。更清晰的接口是返回状态:

c 复制代码
int SeqListPushBack(SeqList* ps, SeqDataType value);

调用者根据返回值决定是否打印成功、重试或退出。打印日志和返回错误是两件事:底层库应优先把结果交给上层决定。


九、经典算法一:移除元素

题目给定数组 nums 和一个值 val,要求原地移除所有等于 val 的元素,并返回新长度。数组前 k 个元素必须是保留下来的结果,尾部内容无需关心。

例如:

text 复制代码
输入:nums = [3, 2, 2, 3, 4, 2, 5], val = 2
输出:k = 4
有效前缀:[3, 3, 4, 5]

9.1 直接逐个删除为什么不理想

如果每遇到一个 val,就调用顺序表删除接口并搬移后面的全部元素,那么最坏情况下会发生:

text 复制代码
第 1 次删除移动 N-1 个元素
第 2 次删除移动 N-2 个元素
...

总成本可能达到 O(N²)。

9.2 快慢指针原地覆盖

更好的办法是让 read 扫描所有输入,让 write 指向下一个应该写入保留元素的位置。

c 复制代码
int removeElement(int* nums, int nums_size, int val)
{
    int write = 0;

    for (int read = 0; read < nums_size; ++read)
    {
        if (nums[read] != val)
        {
            nums[write] = nums[read];
            ++write;
        }
    }

    return write;
}

循环不变式可以这样描述:

每轮开始时,nums[0..write-1] 恰好保存了已经扫描部分中所有不等于 val 的元素,并且相对顺序不变。

当 read 扫描完数组,write 就是保留元素的数量,也就是新长度。

9.3 复杂度

  • 时间复杂度:O(N),每个元素只读取一次;
  • 额外空间复杂度:O(1);
  • 稳定性:保留元素的相对顺序不变。

这个思路本质上是原地稳定压缩。删除指定值、过滤无效记录、去掉不满足条件的元素,都可以套用相同框架。


十、经典算法二:合并两个有序数组

题目给出两个非递减数组:

  • nums1 的前 m 个位置是有效数据,后面预留 n 个位置;
  • nums2 有 n 个有效元素;
  • 要把两者合并到 nums1,并保持有序。
text 复制代码
nums1 = [1, 2, 3, 0, 0, 0], m = 3
nums2 = [2, 5, 6],          n = 3
结果  = [1, 2, 2, 3, 5, 6]

10.1 为什么从前向后写会覆盖数据

如果从 nums1[0] 开始放置较小元素,当 nums2[j] 应该插到前面时,会覆盖 nums1 中还没有参与比较的有效值。为了避免额外开一个数组,可以反过来处理:每次选择较大值,放到 nums1 尾部空位。

10.2 三指针写法

c 复制代码
void merge(int* nums1, int m, const int* nums2, int n)
{
    int i = m - 1;
    int j = n - 1;
    int write = m + n - 1;

    while (i >= 0 && j >= 0)
    {
        if (nums1[i] > nums2[j])
            nums1[write--] = nums1[i--];
        else
            nums1[write--] = nums2[j--];
    }

    while (j >= 0)
        nums1[write--] = nums2[j--];
}

最后只需要处理 nums2 的剩余元素:

  • 如果 nums2 还有值,它们必须复制到 nums1 前部;
  • 如果 nums1 还有值,它们本来就在正确位置,不需要移动。

10.3 边界情况

应至少验证:

text 复制代码
m = 0                    nums1 原有效区间为空
n = 0                    nums2 为空
nums1 全部小于 nums2     需要连续写入 nums2
nums1 全部大于 nums2     nums1 元素整体后移
存在大量重复值           结果仍保持非递减

10.4 复杂度

  • 时间复杂度:O(m+n);
  • 额外空间复杂度:O(1);
  • 前提:nums1 的实际容量至少为 m+n。

这道题的重要启发是:连续数组中如果目标区域的尾部有空闲空间,逆向写入常常能避免覆盖尚未处理的数据。


十一、顺序表的问题,以及为什么还需要链表

通讯录项目说明顺序表非常实用,但课程最后提出的三个问题也不能忽略。

11.1 中间或头部插入删除是 O(N)

顺序表要求有效元素连续排列。若在位置 pos 插入,就要把 [pos, size) 整体向后移动;若删除,就要把 (pos, size) 整体向前移动。

尾插在容量充足时是 O(1),但头插和中间插入通常是 O(N)。这不是代码写得不够巧,而是连续布局带来的固有成本。

11.2 扩容的一次成本是 O(N)

容量不足时通常要:

  1. 申请更大的连续空间;
  2. 复制或搬移原数据;
  3. 释放旧空间;
  4. 更新指针与容量。

一次扩容可能很贵,但按倍数扩容时,连续多次尾插的均摊复杂度 仍可做到 O(1)。原因是容量越大,扩容发生得越不频繁;把偶尔发生的大成本分摊到多次成功插入上,每次平均成本保持常数级。

11.3 预留容量可能浪费空间

假设容量从 100 扩到 200,随后只再插入 5 个元素并长期不变,那么剩余 95 个槽位就处于闲置状态。

可以通过以下策略缓解:

  • 选择合适增长因子,例如 1.5 倍而不是固定 2 倍;
  • 已知数据规模时提前 reserve;
  • 在明确不再增长时执行收缩;
  • 对元素很大或插入删除频繁的场景,改用其他数据结构。

不过,频繁收缩同样会带来复制成本和容量抖动。空间与时间之间没有免费的选择。

11.4 链表解决了什么,又付出了什么

链表把元素放在彼此独立的结点中,通过指针连接。已知目标结点时,局部插入删除不需要整体搬移,也不要求申请一整块更大的连续空间。

代价则是:

  • 无法按下标 O(1) 随机访问;
  • 每个结点额外保存指针;
  • 结点分散,缓存局部性通常弱于顺序表;
  • 动态分配次数更多,代码和内存管理更复杂。

所以问题不是"顺序表和链表谁更高级",而是:

场景 更自然的选择
需要频繁按下标访问 顺序表
数据规模稳定、读取多 顺序表
主要在尾部追加 动态顺序表
已知结点位置后频繁局部插删 链表
无法获得大块连续空间 链式结构可能更合适
强调缓存友好与遍历速度 顺序表通常占优

理解这些权衡,才算真正理解数据结构选择。


十二、高频问答、验收清单与练习

12.1 高频问答

Q1:为什么通讯录可以直接复用顺序表?

因为顺序表管理的是一组同类型元素。把元素类型从 int 换成 PeoInfo,插入、删除、扩容和下标访问的结构规律并没有改变。

Q2:顺序表能存 100 人,是不是必须一开始申请 100 个位置?

不必。可以初始申请 100 个,也可以从 4 或 8 开始按倍数扩容。前者减少小规模扩容次数,后者让初始占用更小。

Q3:为什么查找联系人是 O(N),数组访问不是 O(1) 吗?

已知下标访问才是 O(1)。按姓名查找时并不知道下标,只能逐个比较;除非额外维护哈希索引或其他搜索结构。

Q4:删除联系人后要不要清空最后一个槽位?

逻辑上不需要。size 减一后,原最后位置已经不属于有效区间。若元素中含敏感数据或资源句柄,则可能需要主动擦除或执行析构式清理;当前纯值结构体没有这个要求。

Q5:fread 读到一半记录怎么办?

返回值不会是 1,加载循环停止。更完善的格式应校验文件长度、魔数、版本和记录数,并对损坏文件给出明确提示。

Q6:为什么不能用 feof 作为读取循环的先决条件?

因为 EOF 只有在读取动作越过文件结尾后才被置位。应先调用读取函数,再根据它的返回值判断是否得到完整数据。

Q7:动态顺序表尾插一定是 O(1) 吗?

单次操作不一定。触发扩容的那次尾插可能是 O(N);在按倍数扩容的前提下,连续尾插的均摊复杂度是 O(1)。

Q8:移除元素为什么不调用 SeqListErase?

循环删除会反复搬移后缀,最坏达到 O(N²)。快慢指针一次扫描就能完成同样的稳定压缩。

Q9:合并有序数组为什么从后往前?

因为 nums1 的空位在尾部。从尾部写入较大值,不会覆盖前部尚未比较的有效元素。

Q10:二进制文件一定比文本文件好吗?

不是。二进制直接写结构体简单、读写快,但可读性和兼容性差;文本或结构化格式更便于调试、迁移和版本演进。应根据目标选择。

12.2 项目验收清单

完成项目后,不妨逐项检查:

  1. 空通讯录能正常启动、展示和退出;
  2. 首次运行没有数据文件时不会崩溃;
  3. 添加超过初始容量后仍能正确扩容;
  4. 删除首元素、中间元素、尾元素都正确;
  5. 查找不存在联系人时不会访问 data[-1];
  6. 修改中途输入失败时,旧记录不会只改一半;
  7. 保存后重新启动,所有记录都能恢复;
  8. 所有 fopen 都有对应 fclose;
  9. 所有动态空间最终都被释放;
  10. 手机号数组给 11 位号码和 \0 留出了空间;
  11. 扩容失败时不会丢失原指针;
  12. 菜单选择 0 能安静退出,不误报输入错误。

12.3 动手练习

  1. 用静态顺序表实现同样的通讯录,比较容量边界与接口差异。
  2. 把 SeqListPushBack 改为返回状态,让"添加成功"只在真正插入后打印。
  3. 使用 fgets 重写输入模块,使地址能够包含空格。
  4. 为联系人增加唯一 ID,并支持按 ID 删除或修改。
  5. 增加姓名排序功能,思考排序后查找是否能使用二分查找。
  6. 在文件头写入魔数、版本号和记录数,并校验损坏文件。
  7. 使用临时文件保存,成功关闭后再替换正式数据文件。
  8. 为动态顺序表增加 Reserve 和 ShrinkToFit 接口,观察空间变化。
  9. 为 removeElement 编写包含空数组、全删除、无删除和交替元素的测试。
  10. 为 merge 验证 m=0、n=0、完全前置、完全后置和重复值场景。
  11. 统计在不同增长因子下,插入一万个元素发生的扩容次数和总拷贝量。
  12. 阅读下一讲单链表,重新实现通讯录,再比较两种容器的代码复杂度和运行特征。

总结

顺序表的真正价值,不在于背下几个接口,而在于看见一条清晰的应用链路:

用结构体描述业务数据 → 用动态顺序表组织记录 → 用业务函数完成增删查改 → 用文件读写跨运行周期保存状态。

通讯录项目还提醒我们,容器和业务应当分层。SeqList 只维护连续空间、容量和有效元素;Contact 负责姓名、电话以及用户动作;菜单只负责交互。职责越清楚,后续替换输入方式、文件格式甚至底层容器就越容易。

两道经典算法进一步展示了连续数组上的重要技巧:

  • 移除元素使用快慢指针,一次扫描完成原地稳定压缩;
  • 合并有序数组从后向前写,利用尾部空闲区避免覆盖未处理数据。

最后,顺序表的缺点同样来自它最重要的特点------连续存储。连续布局带来随机访问和缓存友好,也带来中间移动、整体扩容和预留空间。链表不是对顺序表的简单替代,而是针对另一组约束做出的权衡。

当你能够根据访问方式、插删位置、数据规模和空间约束选择结构,而不是只凭题目关键词套模板时,数据结构才真正从"课堂代码"变成了解决问题的工具。

相关推荐
其实防守也摸鱼1 小时前
CTF Web 6 题全解 Writeup
linux·sql·学习·算法·安全·ctf·xss
宇明一不急1 小时前
string和byte的相互转换
算法
波力海苔夹心脆6751 小时前
VisionPro 与 VisionMaster 对比(上):软件定位、开发环境与核心视觉工具对照
图像处理·算法·计算机视觉·c#·视觉检测·个人开发·vp9
动词ing2 小时前
【题目练习】盛最多水的容器
算法·目标检测
海绵宝宝转agent2 小时前
2026-10-14算法+面经总结
java·笔记·算法·缓存
虚无的纽扣2 小时前
【力扣刷题】第四天:三数之和、四数之和
算法·leetcode·职场和发展
ZiLing2 小时前
2026 ROS 2 Lyrical 工程实例(一):从零搭建 AMR 激光 SLAM——Gazebo、slam_toolbox 与 Nav2 全链路跑通
算法
巴糖2 小时前
为什么你的大模型总在胡说八道?—— top_k /top_p/temperature 调参实战手册
算法
rou2 小时前
LeetCode410.分割数组的最大值
算法