3. 线性表基础:逻辑结构、存储结构与基本操作

线性表是数据结构中最基本、最常用的一种结构,也是后续学习栈、队列、串、数组和广义表的基础。本章将围绕线性表的逻辑结构、两种物理存储方式(顺序存储与链式存储)的优缺点对比,以及插入、删除、查找等核心操作的实现与时间复杂度分析展开。理解线性表的核心在于:逻辑上相邻的元素,在物理内存中如何组织? 这决定了操作的效率与适用场景。

线性表存储结构与操作复杂度对比 逻辑结构 a1, a2, ..., ai, ..., an 顺序存储(数组) a1 a2 a3 ... an 连续内存,随机访问 插入/删除需移动元素 查找:O(1) / O(n) 插入/删除:O(n) 链式存储(单链表) a1 → a2 → a3 → an ∧ 非连续内存,通过指针链接 插入/删除只需修改指针 查找:O(n)(需遍历) 插入/删除:O(1)(已知位置) 核心权衡:顺序存储 → 空间连续、随机访问快;链式存储 → 空间灵活、修改快 考研重点:根据操作频率选择存储结构,理解时间复杂度差异的本质原因

3.1 线性表的逻辑结构

线性表是由 n(n ≥ 0)个相同类型的数据元素组成的有限序列。当 n = 0 时称为空表。其逻辑特征为:存在唯一的“第一个”元素和唯一的“最后一个”元素;除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个直接后继。 这种一对一的关系构成了线性序列。

在考研中,需要区分线性表的逻辑结构与物理结构。逻辑结构描述数据元素之间的抽象关系,而物理结构(存储结构)则讨论如何在计算机内存中表示这种关系。线性表的逻辑结构是线性结构,但物理实现既可以是顺序的,也可以是链式的。

核心原理:线性表的逻辑结构强调“序”的概念。例如,学生成绩表、图书目录、多项式各项等都可以抽象为线性表。无论底层用数组还是链表实现,从用户视角看,它们都是“一串有序元素”。

3.2 顺序存储:顺序表

顺序表是用一组地址连续的存储单元依次存储线性表的元素。在 C 语言中通常用一维数组实现。设线性表首元素 a1 的存储地址为 LOC(a1),每个元素占用 c 个字节,则第 i 个元素 ai 的存储地址为:

LOC(ai) = LOC(a1) + (i - 1) * c

这一公式意味着顺序表支持随机存取,即只要知道下标,就可以在 O(1) 时间内访问任意元素。这是顺序表最大的优势。

3.2.1 顺序表的基本操作实现

以下为顺序表插入、删除、按值查找的核心代码逻辑(以静态数组为例):

#define MaxSize 100
typedef struct {
    int data[MaxSize];
    int length;  // 当前长度
} SqList;

// 插入:在第 i 个位置插入 e(i 从 1 开始)
bool ListInsert(SqList &L, int i, int e) {
    if (i < 1 || i > L.length + 1) return false;  // 位置非法
    if (L.length >= MaxSize) return false;         // 表满
    for (int j = L.length; j >= i; j--) {
        L.data[j] = L.data[j - 1];  // 后移元素
    }
    L.data[i - 1] = e;
    L.length++;
    return true;
}

// 删除:删除第 i 个元素,并用 e 返回
bool ListDelete(SqList &L, int i, int &e) {
    if (i < 1 || i > L.length) return false;
    e = L.data[i - 1];
    for (int j = i; j < L.length; j++) {
        L.data[j - 1] = L.data[j];  // 前移元素
    }
    L.length--;
    return true;
}

// 按值查找:返回第一个值为 e 的元素下标(从 1 开始),找不到返回 0
int LocateElem(SqList L, int e) {
    for (int i = 0; i < L.length; i++) {
        if (L.data[i] == e) return i + 1;
    }
    return 0;
}

3.2.2 复杂度分析

操作最好情况最坏情况平均情况说明
按位查找(i)O(1)O(1)O(1)随机存取,直接计算地址
按值查找O(1)(首元素)O(n)(末元素)O(n)需要逐个比较
插入O(1)(表尾插入)O(n)(表头插入)O(n)平均移动 n/2 个元素
删除O(1)(删除表尾)O(n)(删除表头)O(n)平均移动 (n-1)/2 个元素
记忆技巧:顺序表插入/删除的时间主要消耗在元素移动上。平均移动次数约为 n/2,因此时间复杂度为 O(n)。而查找(按位)不需要移动,直接通过地址计算,所以是 O(1)。

3.3 链式存储:单链表

单链表通过一组任意的存储单元存储线性表元素,每个节点包含数据域和指针域。节点结构为:

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

链表的逻辑顺序通过指针链接实现,因此插入和删除操作只需修改指针指向,不需要移动元素。但链表不支持随机存取,查找某个位置的节点必须从头开始遍历。

3.3.1 单链表基本操作实现

// 头插法建立单链表(逆序)
LinkList List_HeadInsert(LinkList &L) {
    LNode *s; int x;
    L = (LinkList)malloc(sizeof(LNode));
    L->next = NULL;  // 带头节点
    scanf("%d", &x);
    while (x != 9999) {
        s = (LNode*)malloc(sizeof(LNode));
        s->data = x;
        s->next = L->next;
        L->next = s;   // 插入到表头
        scanf("%d", &x);
    }
    return L;
}

// 按序号查找节点
LNode* GetElem(LinkList L, int i) {
    if (i < 1) return NULL;
    LNode *p = L->next;  // 第一个数据节点
    int j = 1;
    while (p != NULL && j < i) {
        p = p->next;
        j++;
    }
    return p;
}

// 在第 i 个位置插入节点(带头节点)
bool ListInsert(LinkList &L, int i, int e) {
    LNode *p = GetElem(L, i - 1);  // 找到前驱
    if (p == NULL) return false;
    LNode *s = (LNode*)malloc(sizeof(LNode));
    s->data = e;
    s->next = p->next;
    p->next = s;
    return true;
}

// 删除第 i 个节点
bool ListDelete(LinkList &L, int i, int &e) {
    LNode *p = GetElem(L, i - 1);
    if (p == NULL || p->next == NULL) return false;
    LNode *q = p->next;
    e = q->data;
    p->next = q->next;
    free(q);
    return true;
}

3.3.2 复杂度分析

  • 按位查找:需要从头遍历,平均比较 n/2 次,时间复杂度 O(n)。
  • 插入/删除:如果已知前驱节点指针,修改指针的操作是 O(1);但通常需要先查找前驱,因此整体是 O(n)。
  • 头插法/尾插法建表:头插法每次在头部插入,时间复杂度 O(1),但元素顺序与输入相反;尾插法需要维护尾指针,每次插入 O(1),元素顺序与输入一致。
易错点:很多同学误以为链表插入/删除是 O(1)。实际上,“修改指针”本身是 O(1),但“找到插入/删除位置”需要遍历,所以完整操作是 O(n)。只有在已知前驱节点的情况下,插入/删除才是 O(1)。考研题目常在此设陷阱。

3.4 顺序表与链表的对比与选型

比较维度顺序表链表
存储空间静态分配(或动态扩容),空间连续动态分配,空间不连续,额外存储指针
随机访问O(1) 支持O(n) 不支持
插入/删除O(n) 需移动元素O(n) 需查找位置,但修改指针 O(1)
空间利用率无指针开销,但可能浪费预分配空间有指针开销(4/8字节每节点),但按需分配
适用场景频繁按位查找、元素个数稳定频繁插入/删除、元素个数动态变化

3.5 实战案例:学生成绩管理系统的存储结构选型

背景:某高校开发学生成绩管理系统,需要存储 5000 名学生的成绩(学号、姓名、成绩)。系统主要操作包括:按学号查询成绩(高频)、按成绩排序(中频)、新增/删除学生(低频)。

分析过程:

  1. 查询需求:按学号查询是最高频操作,学号可以映射为数组下标(或通过哈希),顺序表随机访问 O(1) 优势明显。
  2. 排序需求:顺序表在排序时可以利用局部性原理,缓存友好,效率更高。
  3. 插入/删除:低频操作,即使 O(n) 的移动成本也可接受。

决策:选择顺序表存储。若使用链表,每次按学号查询需要 O(n) 遍历,5000 条数据平均 2500 次比较,性能瓶颈明显。

结果:采用顺序表后,查询响应时间从平均 2ms 降至 0.01ms,排序效率提升 40%。

复盘:当“读多写少”时,顺序表是更优选择;若系统变为“频繁插入删除”(如实时更新的选课系统),则需重新评估链表方案。

总结复盘:线性表的选择核心是“操作频率”与“空间约束”的权衡。顺序表适合静态、查询密集的场景;链表适合动态、修改密集的场景。考研中,需要熟练掌握两种结构的代码实现、边界条件处理(如头节点、空表、位置越界)以及复杂度推导过程。