线性表基础:从定义到链式实现

大家好,我是你们的老朋友。今天我们来聊聊线性表——这个数据结构里最基础、最常用的结构之一。

说实话,我做了十几年嵌入式开发,线性表几乎天天都在用。从传感器数据采集到通信协议栈,到处都有它的影子。你想想看,如果连线性表都搞不明白,后面的栈、队列、树这些就更别想了。

线性表的定义

线性表,说白了就是一组数据元素排成一条线。每个元素都有且只有一个前驱和一个后继(除了头尾)。

举个例子:一个班级的学生名单、一天的温湿度采样值、一个字符串中的字符——这些都是线性表。

线性表的数学定义: 一个线性表是 n (n ≥ 0) 个数据元素的有限序列,记作 (a₁, a₂, ..., aₙ)。

  • n = 0 时称为空表
  • a₁ 是第一个元素,aₙ 是最后一个元素
  • 每个元素有唯一的序号(位序)

这里有个容易混淆的点:数组下标从0开始,但线性表的位序从1开始。我在项目中见过不少新手因为这个搞出越界bug,嗯,要注意。

顺序存储结构

顺序存储,就是用一块连续的存储空间来存放线性表。说白了就是数组。

它的特点很鲜明:

  • 随机存取:O(1) 时间就能访问任意位置
  • 插入删除麻烦:平均要移动一半的元素
  • 空间固定:一旦分配,扩容很痛苦

我记得有一次做数据采集系统,用了顺序表存采样数据。结果客户突然要求把采样率翻倍,原来的数组大小直接不够用。改代码改得我头皮发麻——这就是顺序存储的典型痛点。

顺序表的实现

#define MAXSIZE 100

typedef struct {
    int data[MAXSIZE];  // 用数组存数据
    int length;         // 当前长度
} SeqList;

// 初始化
void InitList(SeqList *L) {
    L->length = 0;
}

// 插入操作
int ListInsert(SeqList *L, int i, int e) {
    if (i < 1 || i > L->length + 1) return 0;  // 位置不合法
    if (L->length >= MAXSIZE) return 0;         // 表满了
    
    for (int j = L->length; j >= i; j--) {
        L->data[j] = L->data[j-1];  // 后移元素
    }
    L->data[i-1] = e;
    L->length++;
    return 1;
}

我的习惯: 顺序表适合"读多写少"的场景。如果你要频繁插入删除,趁早用链表。

链式存储结构

链式存储就不一样了。每个元素不光存数据,还存一个指针指向下一个元素。这样就不需要连续空间了,东一块西一块都能串起来。

你想想看,这就像寻宝游戏——每个线索指向下一个线索的位置。只要找到头,就能顺着指针走完整个表。

单链表的结构

typedef struct Node {
    int data;           // 数据域
    struct Node *next;  // 指针域
} LNode, *LinkList;

每个节点包含两部分:

  • 数据域:存放实际数据
  • 指针域:存放下一个节点的地址

我曾经踩过的坑: 写链表操作时忘记检查指针是否为NULL,结果程序跑着跑着就崩了。从那以后,我每条链表操作前都会加一句 if (p == NULL) return; ——这已经成了我的肌肉记忆。

单链表的基本操作

单链表的操作,核心就是玩指针。我建议你画图理解,光看代码容易晕。

1. 创建链表(头插法)

LinkList CreateList_Head(int n) {
    LinkList L = (LinkList)malloc(sizeof(LNode));
    L->next = NULL;  // 带头结点的空链表
    
    for (int i = 0; i < n; i++) {
        LNode *p = (LNode*)malloc(sizeof(LNode));
        scanf("%d", &p->data);
        p->next = L->next;  // 新节点指向第一个节点
        L->next = p;        // 头结点指向新节点
    }
    return L;
}

头插法每次把新节点插在头结点后面。这样最后得到的链表顺序和输入顺序是相反的。如果你想要正序,就用尾插法。

2. 查找操作

// 按位查找
LNode *GetElem(LinkList L, int i) {
    if (i < 0) return NULL;
    LNode *p = L;
    int j = 0;
    while (p != NULL && j < i) {
        p = p->next;
        j++;
    }
    return p;  // 如果i大于表长,返回NULL
}

链表的查找只能从头开始遍历,时间复杂度O(n)。这就是为什么我说链表适合"写多读少"的场景。

3. 插入操作

int ListInsert(LinkList L, int i, int e) {
    LNode *p = GetElem(L, i-1);  // 找到第i-1个节点
    if (p == NULL) return 0;
    
    LNode *s = (LNode*)malloc(sizeof(LNode));
    s->data = e;
    s->next = p->next;  // 新节点指向原第i个节点
    p->next = s;        // 前驱节点指向新节点
    return 1;
}

关键点: 插入时一定要先让新节点指向后继,再让前驱指向新节点。顺序反了就会丢失后面的节点——我见过太多人犯这个错了。

4. 删除操作

int ListDelete(LinkList L, int i, int *e) {
    LNode *p = GetElem(L, i-1);  // 找到前驱
    if (p == NULL || p->next == NULL) return 0;
    
    LNode *q = p->next;  // q是要删除的节点
    *e = q->data;
    p->next = q->next;   // 跳过q
    free(q);              // 释放内存
    return 1;
}

删除操作要注意释放内存。在嵌入式开发中,内存泄漏是大事。我有个项目就是因为频繁malloc/free导致内存碎片,最后系统跑着跑着就挂了。

顺序表 vs 链表:怎么选?

对比项 顺序表 单链表
存取方式 随机存取 O(1) 顺序存取 O(n)
插入/删除 平均 O(n) O(1)(已知位置)
空间分配 静态,一次性分配 动态,按需分配
存储密度 高(只存数据) 低(还要存指针)
适用场景 读多写少,大小已知 写多读少,大小不确定

我个人习惯是:能用顺序表就用顺序表。为什么?因为数组的缓存局部性好,CPU访问起来快。链表虽然插入删除方便,但指针跳来跳去,缓存命中率低,实际性能往往不如预期。

知识体系总览

下面这张图把本章的核心内容串起来了,你可以对照着复习:

线性表 顺序存储结构 链式存储结构 数组实现 · 随机存取 插入/删除需移动元素 空间静态分配 节点+指针 · 顺序存取 插入/删除只需改指针 空间动态分配 核心操作:创建 · 查找 · 插入 · 删除

好了,线性表的基础就讲到这里。顺序表和链表各有千秋,关键是根据实际场景选对工具。下一节我们会深入栈和队列,你会发现它们本质上就是受限的线性表——有了今天的基础,后面学起来会轻松很多。


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