数据结构入门:深入理解顺序表与链表

在计算机科学的世界里,数据结构是构建高效算法的基石,而线性表作为最基础、最常用的数据结构之一,更是每个程序员必须掌握的核心知识点。线性表看似简单,却衍生出了顺序表、链表、栈、队列等多种实用结构,其中顺序表与链表更是线性表的 "左右护法"------ 它们分别代表了 "连续存储" 与 "离散存储" 两种截然不同的设计思想,在实际开发中各有千秋。今天,我们就从线性表的基本概念出发,一步步拆解顺序表与链表的实现逻辑、核心差异,并结合面试高频考点,带你真正吃透这两种基础数据结构。

一、线性表:一切的起点

在正式讲解顺序表与链表之前,我们必须先搞清楚一个前提概念:线性表(Linear List)

线性表的定义很明确:它是由n个(n≥0)具有相同特性的数据元素构成的有限序列。这里的 "序列" 意味着元素之间存在明确的逻辑顺序 ------ 除了第一个元素,每个元素都有唯一的前驱;除了最后一个元素,每个元素都有唯一的后继。简单来说,线性表在逻辑上就像一条 "直线",元素一个挨着一个排列。

举个生活中的例子:排队买咖啡的队伍、通讯录里按顺序存储的联系人、数组里的一串整数,这些都是线性表的实际体现。而在计算机中,我们常见的顺序表、链表、栈、队列、字符串,本质上都是线性表的 "变体"------ 它们都遵循线性表的逻辑结构,只是在物理存储和操作规则上有所差异。

需要特别注意的是:线性表的 "逻辑连续" 不等于 "物理连续"

  • 逻辑结构:指元素之间的逻辑关系(比如 "第一个元素后面是第二个元素"),这是线性表的核心特征,所有线性表都满足。
  • 物理结构:指元素在计算机内存中的实际存储位置。线性表的物理存储有两种常见形式:
    1. 连续存储:比如数组,元素在内存中占用连续的地址空间。
    2. 离散存储:比如链表,元素在内存中可能分散存储,通过指针 / 引用关联起来。

这两种物理存储形式,正是我们接下来要重点讨论的 "顺序表" 与 "链表" 的核心区别。

二、顺序表:数组的 "升级版"

顺序表是线性表的一种连续存储实现------ 它用一段物理地址连续的存储单元(通常是数组)来依次存储数据元素,并用一个变量记录有效数据的个数。可以说,顺序表就是对数组的 "封装",让数组的操作(增删查改)更符合线性表的逻辑。

2.1 顺序表的两种结构

根据数组的存储方式,顺序表分为 "静态" 和 "动态" 两种,它们的核心区别在于 "容量是否可扩展"。

1. 静态顺序表:固定容量的 "简易版"

静态顺序表使用定长数组存储元素,容量在定义时就固定不变。它的结构非常简单,适合已知数据量的场景。

用 C 语言代码定义如下(注意代码中的语法修正,原文档存在笔误):

cs 复制代码
// 定义数据类型(方便后续修改,比如改成char、float)
#define SLDataType int
// 静态顺序表的固定容量
#define N 7

typedef struct SeqList {
    SLDataType array[N];  // 存储元素的定长数组
    size_t size;          // 有效数据的个数(初始为0)
} SeqList;

静态顺序表的缺点很明显

  • 如果N定义得太大,会浪费内存空间(比如只存 3 个元素,却占用了 7 个元素的空间);
  • 如果N定义得太小,后续想新增元素时会 "空间不足",无法扩展。

因此,静态顺序表仅适用于数据量完全确定的场景,实际开发中很少使用。

2. 动态顺序表:可灵活扩容的 "实用版"

动态顺序表解决了静态顺序表的痛点 ------ 它使用动态开辟的数组 (比如 C 语言中的malloc/realloc分配的内存)存储元素,容量可以根据需要动态扩展。

用 C 语言代码定义如下:

cs 复制代码
#define SLDataType int

typedef struct SeqList {
    SLDataType* array;  // 指向动态开辟数组的指针(初始为NULL)
    size_t size;        // 有效数据的个数(初始为0)
    size_t capacity;    // 当前数组的容量(初始为0,记录能存多少元素)
} SeqList;

动态顺序表的核心优势

  • 容量按需扩展:当size == capacity(元素存满)时,通过realloc重新分配更大的内存空间(通常是原容量的 2 倍),避免空间浪费;
  • 内存利用率更高:初始时可以不分配空间,或分配较小的空间,后续根据数据量动态调整。

2.2 动态顺序表的核心接口实现

顺序表的核心操作是 "增删查改",我们需要为动态顺序表实现一套完整的接口函数。以下是关键接口的逻辑解析(附简化代码):

1. 初始化(SeqListInit)

初始化的目的是让顺序表处于 "可用状态"------ 初始时arrayNULLsizecapacity都为 0。

cs 复制代码
void SeqListInit(SeqList* psl) {
    assert(psl != NULL);  // 防止传入空指针,避免崩溃
    psl->array = NULL;
    psl->size = 0;
    psl->capacity = 0;
}
2. 容量检查与扩容(
2. 容量检查与扩容(CheckCapacity)

这是动态顺序表的 "灵魂接口"------ 每次新增元素前,先检查当前容量是否足够;如果不足,则进行扩容(通常扩为原容量的 2 倍,若原容量为 0 则先扩为 4)。

cs 复制代码
void CheckCapacity(SeqList* psl) {
    assert(psl != NULL);
    // 当有效数据个数等于容量时,需要扩容
    if (psl->size == psl->capacity) {
        // 计算新容量:原容量为0则扩为4,否则扩为2倍
        size_t newCapacity = (psl->capacity == 0) ? 4 : psl->capacity * 2;
        // 重新分配内存(realloc会保留原数组的元素)
        SLDataType* newArray = (SLDataType*)realloc(psl->array, newCapacity * sizeof(SLDataType));
        assert(newArray != NULL);  // 防止内存分配失败
        // 更新指针和容量
        psl->array = newArray;
        psl->capacity = newCapacity;
    }
}
3. 尾插(SeqListPushBack)

在顺序表的末尾添加一个元素,步骤如下:

检查容量(调用CheckCapacity);将元素存入array[size]的位置;size加 1(有效数据个数增加)。

cs 复制代码
void SeqListPushBack(SeqList* psl, SLDataType x) {
    assert(psl != NULL);
    CheckCapacity(psl);  // 先确保容量足够
    psl->array[psl->size] = x;  // 存入元素
    psl->size++;                // 有效个数+1
}
4. 尾删(SeqListPopBack)

删除顺序表末尾的元素,步骤如下:

  1. 检查顺序表是否为空(size == 0时无法删除);
  2. size减 1(无需实际 "删除" 元素,只需让后续操作忽略该位置即可)
cs 复制代码
void SeqListPopBack(SeqList* psl) {
    assert(psl != NULL);
    assert(psl->size > 0);  // 空表不能删
    psl->size--;
}
5. 头插(SeqListPushFront)

在顺序表的开头添加一个元素,步骤如下:

  1. 检查容量;
  2. 将所有元素从后往前依次后移 1 位(避免覆盖前面的元素);
  3. 将新元素存入array[0]的位置;
  4. size加 1。
cs 复制代码
void SeqListPushFront(SeqList* psl, SLDataType x) {
    assert(psl != NULL);
    CheckCapacity(psl);
    // 元素后移:从最后一个元素(size-1)移到size的位置
    for (size_t i = psl->size; i > 0; i--) {
        psl->array[i] = psl->array[i - 1];
    }
    psl->array[0] = x;
    psl->size++;
}
6. 头删(SeqListPopFront)

删除顺序表开头的元素,步骤如下:

  1. 检查顺序表是否为空;
  2. 将所有元素从前往后依次前移 1 位(覆盖第一个元素);
  3. size减 1。
cs 复制代码
void SeqListPopFront(SeqList* psl) {
    assert(psl != NULL);
    assert(psl->size > 0);
    // 元素前移:从第二个元素(1)移到0的位置
    for (size_t i = 0; i < psl->size - 1; i++) {
        psl->array[i] = psl->array[i + 1];
    }
    psl->size--;
}
7. 查找(SeqListFind)

查找指定元素x在顺序表中的位置,返回其下标(若未找到则返回 - 1)。

cs 复制代码
int SeqListFind(SeqList* psl, SLDataType x) {
    assert(psl != NULL);
    for (size_t i = 0; i < psl->size; i++) {
        if (psl->array[i] == x) {
            return i;  // 找到,返回下标
        }
    }
    return -1;  // 未找到
}
8. 销毁(SeqListDestroy)

动态顺序表使用了堆内存(malloc分配),必须手动释放,否则会导致内存泄漏。销毁的步骤如下:

  1. 释放array指向的内存;
  2. array置为NULLsizecapacity置为 0
cs 复制代码
void SeqListDestroy(SeqList* psl) {
    assert(psl != NULL);
    free(psl->array);  // 释放动态内存
    psl->array = NULL; // 避免野指针
    psl->size = 0;
    psl->capacity = 0;
}

2.3 顺序表的问题与思考

虽然顺序表实现简单、支持随机访问,但它也存在明显的局限性,这些局限性正是 "链表" 出现的原因:

1. 中间 / 头部插入删除效率低(时间复杂度 O (N))

无论是头插、头删,还是中间插入、中间删除,都需要移动大量元素(比如头插需要移动所有元素后移,头删需要移动所有元素前移)。数据量越大,移动的元素越多,效率越低。

比如一个有 10000 个元素的顺序表,头插一个元素需要移动 10000 个元素,这显然是低效的。

2. 扩容存在额外开销

动态顺序表的扩容(realloc)需要做三件事:

  1. 申请一块新的更大的内存;
  2. 将原数组的元素拷贝到新内存;
  3. 释放原内存。

这个过程会消耗额外的时间,尤其是当数据量很大时,拷贝元素的开销会非常明显。

3. 扩容会导致空间浪费

为了减少扩容的频率,我们通常会按 "原容量的 2 倍" 扩容。这就意味着:如果当前容量是 100,满了之后会扩到 200,但如果后续只新增了 5 个元素,就会浪费 95 个元素的空间。

这些问题让我们思考:有没有一种数据结构,能避免 "移动元素" 和 "扩容浪费"?答案就是 ------ 链表。

三、链表:离散存储的 "灵活派"

链表是线性表的另一种实现 ------ 它采用离散存储的方式,元素(称为 "结点")在内存中可以不连续,通过指针(或引用)将各个结点串联起来,形成逻辑上的线性结构。

如果说顺序表像 "一排连续的座位"(每个座位紧挨着),那么链表就像 "一串散落的珠子"(珠子之间用线连起来)------ 珠子(结点)可以放在不同的位置,线(指针)负责维系它们的顺序。

3.1 链表的基本结构

链表的核心是 "结点(Node)",每个结点包含两部分:

  1. 数据域(data):存储元素的值;
  2. 指针域(next):存储下一个结点的地址(或引用),用于连接下一个结点。

以单链表为例,一个包含 4 个元素(1、2、3、4)的链表在内存中的结构如下:

内存地址 数据域(data) 指针域(next)
0x0012FFA0 1 0x0012FFB0
0x0012FFB0 2 0x0012FFC0
0x0012FFC0 3 0x0012FFD0
0x0012FFD0 4 NULL
  • 第一个结点(头结点)的地址是0x0012FFA0,通过它的next可以找到第二个结点;
  • 最后一个结点的nextNULL,表示链表的末尾。

需要注意的是:

  1. 链表的结点通常从 "堆内存" 中申请(比如 C 语言的malloc),两次申请的内存可能连续,也可能不连续(由操作系统的内存分配策略决定);
  2. 链表的逻辑顺序由指针域决定,与物理地址无关 ------ 即使结点在内存中是 "乱序" 的,只要指针指向正确,逻辑上就是线性的。

3.2 链表的分类:8 种组合与 2 种常用结构

链表的结构非常灵活,根据 "指针方向""是否带头结点""是否循环" 三个维度,可以组合出 8 种不同的链表结构:

  1. 指针方向:单向(只有next指针)、双向(有nextprev指针);
  2. 头结点:带头(有一个不存数据的 "哨兵结点")、不带头(第一个结点就是数据结点);
  3. 循环:循环(最后一个结点的next指向头结点)、非循环(最后一个结点的nextNULL)。

虽然组合很多,但实际开发中最常用的只有两种

1. 无头单向非循环链表
  • 结构:没有头结点,第一个结点就是数据结点;只有next指针,不循环;
  • 特点:结构最简单,实现代码少,但操作(尤其是尾删、中间删除)效率较低(需要遍历找到前驱结点);
  • 用途:很少单独用来存储数据,更多作为其他数据结构的 "子结构",比如哈希表的哈希桶、图的邻接表;同时也是笔试面试的高频考点(比如反转链表、找中间结点)。

用 C 语言定义结点结构:

cs 复制代码
#define SLTDateType int

// 单链表结点结构
typedef struct SListNode {
    SLTDateType data;          // 数据域
    struct SListNode* next;    // 指针域:指向 next 结点
} SListNode;

四、顺序表与链表的核心区别:一张表看懂

顺序表与链表是线性表的两种极端实现 ------ 一个追求 "连续存储" 的高效访问,一个追求 "离散存储" 的灵活插入。它们的核心区别可以通过下表清晰对比:

对比维度 顺序表(SeqList) 链表(LinkedList)
存储空间 物理地址必须连续(数组) 逻辑连续,物理地址不一定连续(结点 + 指针)
随机访问 支持(通过下标访问,时间复杂度 O (1)) 不支持(需从表头遍历,时间复杂度 O (N))
插入 / 删除效率 中间 / 头部:需移动元素,O (N);尾部:O (1) 已知前驱结点时:O (1);未知时需遍历,O (N)
容量管理 动态顺序表需扩容(有额外开销,可能浪费空间) 无容量概念,按需申请结点(无浪费)
缓存利用率 高(数组连续存储,符合 CPU 缓存的 "局部性原理") 低(结点离散存储,缓存命中率低)
实现复杂度 简单(基于数组,操作直观) 复杂(需管理指针,避免断链、野指针)
应用场景 频繁访问(读多写少),如数据查询、排序 频繁插入删除(写多读少),如消息队列、购物车
相关推荐
大数据张老师6 小时前
数据结构——直接插入排序
数据结构·算法·排序算法·1024程序员节
给大佬递杯卡布奇诺7 小时前
FFmpeg 基本数据结构 AVPacket分析
数据结构·c++·ffmpeg·音视频
南方的狮子先生7 小时前
【数据结构】从线性表到排序算法详解
开发语言·数据结构·c++·算法·排序算法·1024程序员节
极客智造8 小时前
编程世界的内在逻辑:深入探索数据结构、算法复杂度与抽象数据类型
数据结构·算法·数学建模
ゞ 正在缓冲99%…8 小时前
leetcode375.猜数字大小II
数据结构·算法·leetcode·动态规划
水蓝烟雨10 小时前
0430. 扁平化多级双向链表
数据结构·链表
阿巴~阿巴~10 小时前
Linux线程与进程的栈管理、页表机制及线程封装
数据结构·线程·进程·线程封装·页表机制·栈管理
立志成为大牛的小牛10 小时前
数据结构——三十一、最小生成树(王道408)
数据结构·学习·程序人生·考研·算法
JMzz11 小时前
Rust 中的数据结构选择与性能影响:从算法复杂度到硬件特性 [特殊字符]
开发语言·数据结构·后端·算法·性能优化·rust