线性表之顺序表:从底层原理到工程实践

各位同学,今天我们来聊聊线性表中最基础、最常用的一种实现——顺序表。说白了,顺序表就是用一块连续的内存空间,把数据一个挨一个地存起来。你想想看,这就像一排编号的储物柜,每个柜子放一个元素,找起来特别快。

我在做嵌入式开发的头几年,几乎天天跟顺序表打交道。那时候内存资源紧张,用得好能省不少事,用不好嘛……嗯,后面我会讲到一些坑。

1. 顺序表的定义与初始化

先看定义。顺序表的核心数据结构通常长这样:

#define MAX_SIZE 100

typedef struct {
    int data[MAX_SIZE];  // 静态数组存储元素
    int length;          // 当前元素个数
} SeqList;

这里我用了静态数组,好处是简单直接,坏处是大小固定。初始化时,把 length 置为 0 就行:

void InitList(SeqList *L) {
    L->length = 0;
    // 静态数组不需要额外分配内存
}

我个人习惯在初始化时把 data 数组全部清零,虽然 C 标准不要求,但能避免一些野值带来的调试困扰。有一次我在项目中排查一个 bug,查了两天才发现是未初始化的数组里残留了上一步的数据……从那以后,我初始化时都会顺手 memset 一下。

小技巧: 如果你用静态数组,建议在 InitList 里加上 memset(L->data, 0, sizeof(L->data)),虽然多花一点点时间,但能省去很多排查的麻烦。

2. 插入与删除操作

插入和删除是顺序表的核心操作,也是容易出问题的地方。

插入操作

在位置 i 插入一个元素,需要把 i 及之后的所有元素往后挪一位。代码实现:

int InsertList(SeqList *L, int i, int e) {
    // 检查合法性
    if (i < 1 || i > L->length + 1) return -1;
    if (L->length >= MAX_SIZE) return -1;  // 表满

    // 从后往前挪
    for (int j = L->length; j >= i; j--) {
        L->data[j] = L->data[j-1];
    }
    L->data[i-1] = e;
    L->length++;
    return 0;
}

注意这里是从后往前挪,如果从前往后挪,数据会被覆盖掉。我曾经见过一个新手同事写反了,结果插入后数据全乱了。

删除操作

删除位置 i 的元素,把后面的元素往前挪:

int DeleteList(SeqList *L, int i, int *e) {
    if (i < 1 || i > L->length) return -1;

    *e = L->data[i-1];
    for (int j = i; j < L->length; j++) {
        L->data[j-1] = L->data[j];
    }
    L->length--;
    return 0;
}
注意: 删除操作的时间复杂度是 O(n),因为要移动大量元素。如果你频繁做插入删除,顺序表可能不是最佳选择——这时候该考虑链表了。

3. 查找与遍历

查找分两种:按位置查找和按值查找。

按位置查找很简单,直接下标访问:

int GetElem(SeqList *L, int i, int *e) {
    if (i < 1 || i > L->length) return -1;
    *e = L->data[i-1];
    return 0;
}

按值查找需要遍历:

int LocateElem(SeqList *L, int e) {
    for (int i = 0; i < L->length; i++) {
        if (L->data[i] == e) return i + 1;  // 返回位序
    }
    return 0;  // 没找到
}

遍历就更直接了,一个 for 循环搞定。我在调试嵌入式设备时,经常写一个 PrintList 函数来打印所有元素,方便看数据对不对。

4. 动态数组的实现与扩容策略

静态数组大小固定,用起来不够灵活。实际项目中,我更喜欢用动态数组——说白了就是根据需要动态扩容。

动态顺序表的结构:

typedef struct {
    int *data;      // 指向动态分配的内存
    int length;     // 当前元素个数
    int capacity;   // 当前容量
} DynSeqList;

初始化时先分配一小块内存:

void InitDynList(DynSeqList *L, int initCap) {
    L->data = (int *)malloc(initCap * sizeof(int));
    L->length = 0;
    L->capacity = initCap;
}

插入时如果满了,就扩容:

int InsertDynList(DynSeqList *L, int i, int e) {
    if (i < 1 || i > L->length + 1) return -1;

    // 检查是否需要扩容
    if (L->length >= L->capacity) {
        int newCap = L->capacity * 2;  // 扩容策略:翻倍
        int *newData = (int *)realloc(L->data, newCap * sizeof(int));
        if (newData == NULL) return -1;  // 扩容失败
        L->data = newData;
        L->capacity = newCap;
    }

    // 挪位插入
    for (int j = L->length; j >= i; j--) {
        L->data[j] = L->data[j-1];
    }
    L->data[i-1] = e;
    L->length++;
    return 0;
}

扩容策略很关键。我见过有人每次只扩一个元素,结果频繁 realloc,性能惨不忍睹。常用的策略有两种:

策略 说明 优点 缺点
翻倍扩容 每次容量翻倍(×2) 均摊时间复杂度 O(1) 可能浪费空间
固定增量 每次增加固定大小(如 10) 空间利用率高 频繁扩容时性能差

我个人更推荐翻倍扩容。虽然偶尔会多占点内存,但整体性能稳定。在嵌入式场景下,如果内存特别紧张,可以改成 1.5 倍扩容——这是 Java 的 ArrayList 用的策略,空间和时间比较均衡。

核心要点: 顺序表用连续内存存储,随机访问快(O(1)),但插入删除慢(O(n))。动态数组解决了大小固定的问题,但扩容时要小心内存碎片和性能开销。

知识体系总览

下面这张图把顺序表的核心知识点串起来了,你可以对照着复习:

顺序表 定义与初始化 静态数组 动态数组 插入与删除 O(n) 移动 边界检查 查找与遍历 随机访问 O(1) 按值遍历 O(n) 扩容策略 翻倍扩容 固定增量 时间复杂度:随机访问 O(1) | 插入删除 O(n) | 查找 O(n)

好了,顺序表的核心内容就这些。记住:它适合读多写少的场景,比如配置表、查找表。如果你要频繁插入删除,还是考虑链表吧。


公众号:蓝海资料掘金营,微信deep3321