欢迎光临
我们一直在努力

链表和动态数组(从定义到作用)

一、链表

1. 链表的物理结构定义

链表由若干个节点(Node)组成。每个节点在内存中是一个独立分配的堆内存块。节点之间通过存储地址来建立连接。

节点(Node)的结构(用结构体定义):

struct Node {
int data; // 数据域
struct Node *next; // 指针域,存储下一个节点的起始地址
};

物理内存特征(与数组的本质区别):

  • 数组:所有元素在堆上连续排列(0x1000, 0x1004, 0x1008…)。

  • 链表:每个节点由 malloc 独立分配。节点之间的地址不需要连续(例如:节点1在 0x1000,节点2在 0x8000,节点3在 0x2000)。连接它们的唯一依据,是前一个节点 next 指针里存放的地址值。


2. 链表的三大核心组件

  • 头指针(Head Pointer):指向第一个节点的指针变量。这是你访问整个链表的唯一入口。

  • 节点(Node):包含数据和一个指向下一个节点的指针。

  • 尾节点(Tail):最后一个节点,它的 next 指针必须显式赋值为 NULL。


  • 3. 创建第一个节点(动态分配)

    创建节点的本质是:在堆上申请一块 sizeof(struct Node) 大小的内存,填入数据,并将 next 置为 NULL。

    #include <stdlib.h>
    struct Node* create_node(int value) {
    struct Node *new_node = (struct Node*)malloc(sizeof(struct Node));
    if (new_node == NULL) return NULL;
    new_node->data = value;
    new_node->next = NULL; // 非常重要:新节点的终点必须是 NULL
    return new_node;
    }


    4. 头插法(在链表头部插入节点)

    这是链表效率最高的操作(时间复杂度 O(1)),因为你不需要遍历链表。

    操作步骤(严格顺序):

  • 创建新节点 new。

  • 将 new->next 指向当前的 head(如果 head 为 NULL,则 new->next 指向 NULL)。

  • 更新 head 指针,使其指向 new。

  • 代码实现

    void push_front(struct Node **head, int value) {
    struct Node *new = create_node(value);
    if (new == NULL) return;
    new->next = *head; // 新节点指向旧的头节点
    *head = new; // 头指针指向新节点
    }


    5. 遍历链表(访问所有数据)

    遍历就是从 head 出发,沿着 next 指针逐一跳转,直到遇到 NULL 为止。核心是使用一个临时指针 cur 来移动,不要直接移动 head(否则会丢失整个链表)。

    代码实现:

    void print_list(struct Node *head) {
    struct Node *cur = head; // 临时指针
    while (cur != NULL) {
    printf("%d ", cur->data);
    cur = cur->next; // 跳到下一个节点的起始地址
    }
    }


    6. 删除头部节点(唯一不需要遍历的删除)

    删除头部节点只涉及 head 指针的移动和内存释放。

    操作步骤(必须按此顺序,否则会丢失内存地址):

  • 用临时指针 temp 存储 head 的地址(准备释放)。

  • 将 head 移动到下一个节点(head = head->next)。

  • 释放 temp 指向的旧头节点内存。

  • 代码实现:

    void pop_front(struct Node **head) {
    if (*head == NULL) return;
    struct Node *temp = *head;
    *head = (*head)->next;
    free(temp);
    }


    7. 完整的释放整个链表(防止内存泄漏)

    你必须逐个节点释放,不能只释放 head,否则后面的节点全部丢失。

    void free_list(struct Node *head) {
    struct Node *cur = head;
    while (cur != NULL) {
    struct Node *next = cur->next; // 先保存下一个地址
    free(cur); // 释放当前
    cur = next; // 移到下一个
    }
    }


    8. 你必须建立的底层认知(与动态数组的关键区别)

    操作动态数组(连续内存)链表(离散内存)
    访问第 N 个元素 直接 arr[N-1](O(1)) 必须从头遍历 N-1 次(O(N))
    头部插入 必须移动 N 个元素(O(N)) 只修改 head 和一个 next(O(1))
    内存分配 一次性申请大块 每个节点单独申请
    内存连续性 绝对连续 绝对离散

    二、动态数组

    动态数组,是一种在程序运行时,可以根据实际存储的元素数量,自动调整自身内存大小的顺序存储结构。

    1.为什么需要动态数组?(对比静态数组)

    静态数组与动态数组的区别

    对比维度静态数组(int arr[10])动态数组(自己封装)
    内存位置 栈区(Stack)或静态区 堆区(Heap)
    大小确定 编译时就必须确定,写死了 运行时根据需求随时变化
    生命周期 离开作用域自动销毁 由程序员手动控制(malloc/free)
    灵活性 无法扩容,空间多了浪费,少了溢出 空间不够就扩容,实现“无限装”
    返回值传递 不能作为函数返回值返回(销毁了) 可以通过指针/地址自由传递

    结论:静态数组是“死”的,动态数组是“活”的。在写真正的项目(如聊天记录、游戏背包)时,动态数组是必需品。


    2.动态数组的内存模型(核心概念)

    动态数组在逻辑上是连续的,在物理上(堆内存)也是连续的。为了管理它,我们通常用一个结构体(管家)来捆绑三样东西:

    typedef struct {
    类型 *data; // ① 仓库钥匙(指向堆内存首地址)
    size_t capacity; // ② 仓库总容量(最多能放多少件)
    size_t length; // ③ 仓库已用面积(现在放了多少件)
    } DynamicArray;

  • data 指向的那块内存,是你在堆上通过 malloc 或 realloc 向操作系统“租”来的。

  • capacity 和 length 是管理账本,它们住在栈上(或结构体里),但它们描述的是堆上的情况。

  • 铁律:任何时候,必须保证 length <= capacity。这是程序的“生死线”。


  • 3.七个核心操作与算法要点

    一个合格的动态数组,必须包含这七个操作。

    1. 初始化(Init)

    • 动作:向系统申请第一块地(malloc)。

    • 检查:如果 malloc 返回了 NULL,一定要处理,否则后面使用会崩溃。

    • 记账:把结构体的 data 指向那块地,capacity 设成申请的大小,length 设为 0。

    2. 扩容(Resize / Expand)

    • 触发条件:当 length == capacity 时,再添加就装不下了,必须扩容。

    • 新容量选择:通常采用 “翻倍策略”(new_cap = capacity * 2)。这样做的目的是把多次扩容的操作均摊到每次插入上,整体效率最高。

    • 移动逻辑:扩容不是“原地把墙推倒扩出去”,而是可能整个搬家。

    • ⚠️ realloc 陷阱

      // 错误的写法(绝对禁止)
      data = realloc(data, new_size);
      // 如果 realloc 失败返回 NULL,原来的 data 丢失了(内存泄漏且数据丢失)!

      // 正确的写法(必背)
      int *temp = realloc(data, new_size);
      if (temp == NULL) {
      // 处理失败,原 data 依然有效,赶紧撤退
      return false;
      }
      data = temp; // 成功:换新钥匙

    3. 尾部添加(Add / Push Back)

    • 操作:在最末尾放一个数。

    • 步骤:先看需不需要扩容,需要就扩。然后把值放进 data[length] 的位置,最后 length++。

    4. 指定位置插入(Insert)—— 移动方向是核心难点

    • 场景:在中间的某个下标(比如 index)插入一个数,后面的全部得让位。

    • 步骤:

    • 检查下标是否合理(index 必须在 0 到 length 之间)。

    • 检查容量,满了就扩。

    • 关键动作(后移):必须从最后一个有效元素(length-1)开始,倒着往前搬,直到 index。

      • 为什么必须倒着? 如果你从 index 开始正着往后搬,会把后面的数据覆盖掉。

    • 在空出来的 index 位置放入新值。

    • length++。

    5. 按位置删除(Delete At)—— 移动方向反着来

    • 场景:拿走中间的某个数,后面的要往前补位。

    • 步骤:

    • 检查下标是否合理(index 必须在 0 到 length-1 之间)。

    • (可选)把被删的值存到外面给用户。

    • 关键动作(前移):必须从 index+1 开始,正着往后搬,直到最后。

      • 为什么必须正着? 如果你倒着往前搬,同样会把前面的数据覆盖错乱。

    • length–。

    6. 清空(Clear)

    • 动作:逻辑上清空。

    • 做法:只需一行 length = 0。

    • 注意:绝对不要在这里 free(data)!清空是“把货搬走,但仓库留下”,下次还能直接往里堆。

    7. 销毁(Destroy)

    • 动作:物理上拆除。

    • 步骤(必须一步不落):

    • free(data)(把地还给系统)。

    • data = NULL(把钥匙销毁,防止变成野指针)。

    • capacity = 0; length = 0;(把账本清零,保持一致)。


    4.必背的六条铁律(考试/面试常客)

    铁律一:范围保护
    所有涉及下标访问的操作,必须先判断 index < length。访问越界是C语言最致命的隐形炸弹。

    铁律二:内存失败保护
    只要用了 malloc 或 realloc,紧跟其后必须有 if (ptr == NULL) 的判断。

    铁律三:realloc 必用临时指针
    把这条当成肌肉记忆,写错就等着内存泄漏。

    铁律四:移动数据的方向感

    • 插入时:从后往前(防止被覆盖)。

    • 删除时:从前往后(防止被覆盖)。

    铁律五:无符号数的减法陷阱
    size_t 是无符号的。永远不要写 if (x – y < 0),因为这是不可能的。请写 if (x < y) 来判断是否越界。

    铁律六:销毁后必须“三归零”
    free 之后,一定要手动把 data 置 NULL,把长度和容量归零。否则就成了“虽然房子没了,但手里的账本还在说这里有人住”的野指针/悬空引用状态。

    三、二者区别

    1. 本质区别:地址能不能“心算”出来?

    • 动态数组(连续内存):

      • 地址规则:第 N 个元素地址 = 起始地址 + N × 字节数。

      • 访问代价:CPU 只需要做一次加法运算,就能直接定位到第 N 个元素。这叫 O(1) 随机访问。

      • 硬件优势:因为地址连续,CPU 会提前将相邻元素加载进高速缓存,读数据极快。

    • 链表(离散内存):

      • 地址规则:第 N 个元素地址 = 你得先找到 1 号,让 1 号告诉你 2 号在哪,让 2 号告诉你 3 号在哪……直到 N。

      • 访问代价:想访问第 100 个元素,你必须重复 99 次“取指针→跳转”的操作。这叫 O(N) 顺序访问。

      • 硬件劣势:每个节点的地址都散落在堆的各处,CPU 无法预判下一个地址在哪,只能频繁中断去内存里找,效率较低。


    2. 插入/删除操作:你刚才的“感觉”是对的,但你漏看了“搬家费”

    • 动态数组(插在头部):

      • 你确实只需要写 arr[0] = new,但在此之前,你必须把现有的 100 个元素全部往后挪一格。这叫“内存搬移”,成本与数据量成正比。

    • 链表(插在头部):

      • 你只需要执行两步:new->next = head; head = new;。无论链表是 1 个还是 100 万个节点,操作耗时都是固定的。

    结论:如果你的需求是“快速查找第 N 个值”,数组完胜;如果你的需求是“不断在头部插入新数据”,链表完胜。


    四、从结构体角度解读二者区别

    1. 数据存储位置不同

    • 动态数组(arryress):结构体内部只有一个指向堆内存数据区的指针。真正的元素数据在堆上的另一块连续空间中。

    • 链表(Node + LinkedList):结构体内部直接包含数据成员(data)和指向下一个节点的指针(next)。节点本身就是堆内存中一块完整的独立区域。

    核心区别:动态数组的“元素”是结构体指向的东西;链表的“节点”就是结构体本身。


    2. 结构体是否自引用

    • 动态数组(arryress):结构体成员中没有指向自身类型的指针。它只包含普通指针(ptr_arr)和两个整数。

    • 链表(Node):结构体内部必须包含一个指向自身类型(struct Node *next)的指针。这是链表的必要条件。

    核心区别:链表的结构体具有“自引用性”(递归指向自身),动态数组的结构体没有。


    3. 管理型结构体与数据型结构体是否分离

    • 动态数组(arryress):没有分离。arryress 结构体同时扮演“数据容器”和“元数据管理器”两个角色(ptr_arr存数据,lengt和arr_count管大小)。

    • 链表:严格分离。Node 结构体只负责数据和链接(单一的“数据载具”),LinkedList 结构体专门负责管理(head指向第一个载具,length记录载具数量)。

    核心区别:动态数组只有一个结构体类型;链表有两个结构体类型(一个节点,一个管理器)。


    4. 结构体大小的确定方式

    • 动态数组(arryress):其 sizeof(arryress) 是固定且确定的(例如 24 字节)。因为它的成员(指针和两个 size_t)在编译时就完全确定了。

    • 链表(Node 和 LinkedList):

      • sizeof(Node) 是固定且确定的(例如 16 字节),但整个链表的大小是“节点大小 × 节点数”,这个总大小在编译时完全未知。

      • sizeof(LinkedList) 也是固定且确定的(例如 16 字节),但它只统计管理信息,不包括任何节点数据。

    核心区别:动态数组的元素大小由“数组长度 × 元素大小”在运行时确定;链表的总大小由“节点数 × 节点大小”在运行时确定,但每个节点本身都是独立且大小固定的。


    总结

    • 动态数组的结构体:只有一份,在栈上,指向堆上一大块连续数据。

    • 链表的结构体:有两份,管理者在栈上,节点数据在堆上,且每个节点都是独立分散的独立结构体实体。

    赞(0)
    未经允许不得转载:171主机测评 » 链表和动态数组(从定义到作用)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址