C语言链表详解:从单链表到双向链表

C语言链表详解:从单链表到双向链表

往期回顾

指针合集
c语言基础
数据结构

目录


前言

书接上回当我们学习顺序表的时候,我们知道顺序表的底层是一段连续空间。

它的优点很明显:

  • 支持随机访问
  • 下标访问效率高
  • 尾插比较方便
  • 结构简单,容易理解

但是顺序表也有明显缺点:

  • 头部或中间插入、删除需要搬移大量元素
  • 动态顺序表空间不够时需要扩容
  • 扩容可能导致空间浪费
  • 扩容时还可能需要整体搬迁数据

于是我们继续学习另一种非常重要的数据结构:

链表。

链表和顺序表最大的不同在于:

顺序表要求物理空间连续,而链表不要求物理空间连续。

如果说顺序表像一排连在一起的座位,那么链表更像一节一节独立的火车车厢。

每节车厢可以独立存在,只要上一节车厢知道下一节车厢在哪里,整列火车就能连起来。

这篇文章就来系统梳理链表相关知识,包括:

  • 链表的基本概念
  • 单链表的结构和实现
  • 单链表常见增删查改接口
  • 链表的分类
  • 单链表经典 OJ 题
  • 基于单链表实现通讯录
  • 双向带头循环链表
  • 顺序表和链表的优缺点对比

一、为什么有了顺序表,还要学习链表?

顺序表底层是数组,所以它的物理空间是连续的。

例如:

text 复制代码
顺序表:

下标:  0    1    2    3    4
      +----+----+----+----+----+
数据: | 10 | 20 | 30 | 40 | 50 |
      +----+----+----+----+----+

这种结构的好处是:

c 复制代码
a[3]

可以直接通过下标访问,效率很高。

但是问题也很明显。


1. 中间插入需要搬移元素

如果我们想在 2030 之间插入 25

text 复制代码
插入前:

+----+----+----+----+----+
| 10 | 20 | 30 | 40 | 50 |
+----+----+----+----+----+

必须先把 30、40、50 往后挪:

text 复制代码
移动后:

+----+----+----+----+----+----+
| 10 | 20 |    | 30 | 40 | 50 |
+----+----+----+----+----+----+

然后才能插入:

text 复制代码
插入后:

+----+----+----+----+----+----+
| 10 | 20 | 25 | 30 | 40 | 50 |
+----+----+----+----+----+----+

如果数据很多,移动成本就很高。


2. 空间不够时需要扩容

动态顺序表虽然可以扩容,但扩容并不是免费的。

扩容通常要:

text 复制代码
申请新空间
拷贝旧数据
释放旧空间
更新指针

如果数据量很大,扩容时搬移数据也会有成本。


3. 链表解决了什么问题?

链表的核心思想是:

数据不要求连续存储,每个节点自己保存下一个节点的位置。

这样插入和删除时,很多情况下不需要整体搬移数据,只需要修改指针指向即可。


二、什么是链表?

链表是一种物理存储结构上非连续、非顺序的存储结构。

它的逻辑顺序不是靠数组下标维护,而是靠节点中的指针维护。

也就是说:

链表在逻辑上是连续的,但在物理内存上不一定连续。

例如:

text 复制代码
逻辑顺序:

10 -> 20 -> 30 -> 40

但它们在内存中可能是这样:

text 复制代码
内存地址不一定连续:

0x1000: [10 | 0x3000]
0x3000: [20 | 0x1800]
0x1800: [30 | 0x5000]
0x5000: [40 | NULL]

从逻辑上看,它们是连续的:

text 复制代码
10 -> 20 -> 30 -> 40 -> NULL

但从物理地址看,它们不一定挨在一起。(因此这里又衍生出来了在现代工程中"链表已死"的说法,这个问题我先挖个坑之后再填)


三、图解链表

链表非常像火车。

每一节车厢都是独立的。

淡季时,可以少挂几节车厢。

旺季时,可以多加几节车厢。

如果想删除某节车厢,只需要把前一节车厢和后一节车厢重新接起来。

如果想增加某节车厢,只需要把它接到合适的位置。

链表也是一样。

text 复制代码
单链表:

head
 |
 v
+------+--------+    +------+--------+    +------+--------+
| data | next   | -> | data | next   | -> | data | NULL   |
+------+--------+    +------+--------+    +------+--------+

每个节点就像一节车厢。

节点里通常包含两部分:

text 复制代码
1. 当前节点保存的数据
2. 下一个节点的地址

四、单链表节点结构设计

如果链表中保存的是整型数据,那么节点可以这样定义:

c 复制代码
typedef int SLTDataType;

typedef struct SListNode
{
    SLTDataType data;          // 当前节点保存的数据
    struct SListNode* next;    // 保存下一个节点的地址
} SLTNode;

这个结构体有两个成员:

1. data

c 复制代码
SLTDataType data;

用于保存当前节点的数据。

如果链表存的是 int,这里就是整型数据。

如果链表存的是联系人结构体,也可以把 SLTDataType 换成结构体类型。


2. next

c 复制代码
struct SListNode* next;

用于保存下一个节点的地址。

如果当前节点是最后一个节点,那么:

c 复制代码
next = NULL;

表示后面没有节点了。


单链表节点图

text 复制代码
一个节点:

+------+--------+
| data | next   |
+------+--------+
          |
          v
       下一个节点

多个节点连接起来:

text 复制代码
phead
 |
 v
+------+--------+    +------+--------+    +------+--------+
|  10  |   o----|--->|  20  |   o----|--->|  30  |  NULL  |
+------+--------+    +------+--------+    +------+--------+

五、单链表如何遍历打印?

链表不能像数组一样通过下标直接访问:

c 复制代码
a[i]

因为链表的节点不一定连续。

所以链表遍历只能从头节点开始,一个一个往后走。

遍历思路:

text 复制代码
1. 定义 cur 指向第一个节点
2. cur 不为空,就访问 cur->data
3. cur = cur->next,走向下一个节点
4. 直到 cur == NULL 停止

代码如下:

c 复制代码
void SLTPrint(SLTNode* phead)
{
    SLTNode* cur = phead;

    while (cur != NULL)
    {
        printf("%d -> ", cur->data);
        cur = cur->next;
    }

    printf("NULL\n");
}

图解:

text 复制代码
cur 初始指向第一个节点:

cur
 |
 v
+------+--------+    +------+--------+    +------+--------+
|  10  |   o----|--->|  20  |   o----|--->|  30  |  NULL  |
+------+--------+    +------+--------+    +------+--------+

打印 10 后:
cur = cur->next

                 cur
                  |
                  v
+------+--------+    +------+--------+    +------+--------+
|  10  |   o----|--->|  20  |   o----|--->|  30  |  NULL  |
+------+--------+    +------+--------+    +------+--------+

六、为什么单链表很多接口要传二级指针?

学习链表的时候大家可能会有一些疑问,那就是:

为什么有些函数参数是 SLTNode* phead,有些却是 SLTNode** pphead

比如:

c 复制代码
void SLTPrint(SLTNode* phead);
void SLTPushBack(SLTNode** pphead, SLTDataType x);

原因在于:

如果函数内部需要修改头指针本身,就必须传头指针的地址,也就是二级指针。


1. 只遍历,不修改头指针

打印链表时,只需要从头节点往后走,不需要修改外面的 phead

所以传一级指针就够:

c 复制代码
void SLTPrint(SLTNode* phead);

2. 插入、删除可能修改头指针

比如空链表尾插第一个节点:

text 复制代码
插入前:

phead = NULL

插入后:

text 复制代码
phead -> 新节点

此时函数内部必须改变外面的 phead

如果只传一级指针,相当于把 phead 的值拷贝一份传进去,函数内部改的是副本,外面的 phead 不会变。

所以要传:

c 复制代码
SLTNode** pphead

也就是头指针的地址。


二级指针图解

text 复制代码
外部变量:

phead
  |
  v
NULL

函数参数:

pphead = &phead

通过:

c 复制代码
*pphead = newnode;

才能真正修改外面的 phead


七、单链表常用接口总览

单链表常见接口包括:

c 复制代码
void SLTPrint(SLTNode* phead);

// 头部插入删除 / 尾部插入删除
void SLTPushBack(SLTNode** pphead, SLTDataType x);
void SLTPushFront(SLTNode** pphead, SLTDataType x);
void SLTPopBack(SLTNode** pphead);
void SLTPopFront(SLTNode** pphead);

// 查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x);

// 在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);

// 删除指定位置节点
void SLTErase(SLTNode** pphead, SLTNode* pos);

// 在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x);

// 删除指定位置之后的节点
void SLTEraseAfter(SLTNode* pos);

// 销毁链表
void SListDestroy(SLTNode** pphead);

这些接口基本覆盖了单链表的增删查改操作。


八、单链表节点的创建

每次插入数据,都要先创建一个新节点。

节点一般从堆区申请:

c 复制代码
SLTNode* BuySLTNode(SLTDataType x)
{
    SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
    if (newnode == NULL)
    {
        perror("malloc fail");
        exit(1);
    }

    newnode->data = x;
    newnode->next = NULL;

    return newnode;
}

为什么节点一般从堆上申请?

因为链表节点数量通常不固定。

需要插入一个数据,就申请一个节点。

删除一个数据,就释放一个节点。

这比顺序表的整体扩容更灵活。


九、单链表尾插和头插

1. 尾插

尾插就是在链表最后插入新节点。

分两种情况。


情况 1:链表为空
text 复制代码
phead = NULL

直接让 phead 指向新节点即可。

text 复制代码
phead
 |
 v
+------+------+
|  10  | NULL |
+------+------+

情况 2:链表不为空

要先找到最后一个节点,然后让最后一个节点的 next 指向新节点。

text 复制代码
插入前:

phead
 |
 v
+------+--------+    +------+------+
|  10  |   o----|--->|  20  | NULL |
+------+--------+    +------+------+

尾插 30:

phead
 |
 v
+------+--------+    +------+--------+    +------+------+
|  10  |   o----|--->|  20  |   o----|--->|  30  | NULL |
+------+--------+    +------+--------+    +------+------+

代码:

c 复制代码
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
    assert(pphead);

    SLTNode* newnode = BuySLTNode(x);

    if (*pphead == NULL)
    {
        *pphead = newnode;
    }
    else
    {
        SLTNode* tail = *pphead;
        while (tail->next != NULL)
        {
            tail = tail->next;
        }

        tail->next = newnode;
    }
}

尾插需要找尾节点,所以时间复杂度是:

text 复制代码
O(n)

如果额外维护尾指针,可以优化尾插。


2. 头插

头插就是在链表最前面插入节点。

图解:

text 复制代码
插入前:

phead
 |
 v
+------+--------+    +------+------+
|  20  |   o----|--->|  30  | NULL |
+------+--------+    +------+------+

头插 10:

phead
 |
 v
+------+--------+    +------+--------+    +------+------+
|  10  |   o----|--->|  20  |   o----|--->|  30  | NULL |
+------+--------+    +------+--------+    +------+------+

关键步骤:

c 复制代码
newnode->next = *pphead;
*pphead = newnode;

代码:

c 复制代码
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
    assert(pphead);

    SLTNode* newnode = BuySLTNode(x);

    newnode->next = *pphead;
    *pphead = newnode;
}

头插不需要遍历链表,时间复杂度是:

text 复制代码
O(1)

十、单链表尾删和头删

1. 头删

头删比较简单。

只需要:

text 复制代码
1. 保存原来的第一个节点
2. 让 phead 指向第二个节点
3. 释放原来的第一个节点

图解:

text 复制代码
删除前:

phead
 |
 v
+------+--------+    +------+--------+    +------+------+
|  10  |   o----|--->|  20  |   o----|--->|  30  | NULL |
+------+--------+    +------+--------+    +------+------+

删除后:

phead
 |
 v
+------+--------+    +------+------+
|  20  |   o----|--->|  30  | NULL |
+------+--------+    +------+------+

代码:

c 复制代码
void SLTPopFront(SLTNode** pphead)
{
    assert(pphead);
    assert(*pphead);

    SLTNode* del = *pphead;
    *pphead = (*pphead)->next;

    free(del);
}

时间复杂度:

text 复制代码
O(1)

2. 尾删

尾删要删除最后一个节点。

分两种情况:

情况 1:只有一个节点
text 复制代码
phead -> [10 | NULL]

删除后:

text 复制代码
phead = NULL
情况 2:有多个节点

需要找到尾节点的前一个节点。

text 复制代码
删除前:

phead
 |
 v
+------+--------+    +------+--------+    +------+------+
|  10  |   o----|--->|  20  |   o----|--->|  30  | NULL |
+------+--------+    +------+--------+    +------+------+

删除 30 后:

phead
 |
 v
+------+--------+    +------+------+
|  10  |   o----|--->|  20  | NULL |
+------+--------+    +------+------+

代码:

c 复制代码
void SLTPopBack(SLTNode** pphead)
{
    assert(pphead);
    assert(*pphead);

    if ((*pphead)->next == NULL)
    {
        free(*pphead);
        *pphead = NULL;
    }
    else
    {
        SLTNode* prev = NULL;
        SLTNode* tail = *pphead;

        while (tail->next != NULL)
        {
            prev = tail;
            tail = tail->next;
        }

        free(tail);
        prev->next = NULL;
    }
}

尾删需要遍历找到尾节点,时间复杂度是:

text 复制代码
O(n)

十一、单链表查找

查找某个值,只能从头开始遍历。

c 复制代码
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
    SLTNode* cur = phead;

    while (cur != NULL)
    {
        if (cur->data == x)
        {
            return cur;
        }

        cur = cur->next;
    }

    return NULL;
}

如果找到了,返回该节点地址。

如果没找到,返回 NULL

时间复杂度:

text 复制代码
O(n)

因为链表不支持随机访问。


十二、单链表在指定位置插入和删除

1. 在指定位置之前插入

pos 节点之前插入新节点。

如果 pos 是头节点,就等价于头插。

否则需要找到 pos 的前一个节点。

图解:

text 复制代码
原链表:

10 -> 20 -> 30 -> NULL

在 30 前插入 25:

10 -> 20 -> 25 -> 30 -> NULL

代码:

c 复制代码
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
    assert(pphead);
    assert(pos);

    if (*pphead == pos)
    {
        SLTPushFront(pphead, x);
    }
    else
    {
        SLTNode* prev = *pphead;

        while (prev->next != pos)
        {
            prev = prev->next;
        }

        SLTNode* newnode = BuySLTNode(x);
        prev->next = newnode;
        newnode->next = pos;
    }
}

2. 删除指定位置节点

如果 pos 是头节点,就等价于头删。

否则找到 pos 的前一个节点,然后跳过 pos

图解:

text 复制代码
原链表:

10 -> 20 -> 30 -> 40 -> NULL

删除 30:

10 -> 20 -> 40 -> NULL

代码:

c 复制代码
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
    assert(pphead);
    assert(*pphead);
    assert(pos);

    if (*pphead == pos)
    {
        SLTPopFront(pphead);
    }
    else
    {
        SLTNode* prev = *pphead;

        while (prev->next != pos)
        {
            prev = prev->next;
        }

        prev->next = pos->next;
        free(pos);
    }
}

3. 在指定位置之后插入

这个操作比"在指定位置之前插入"更简单。

因为已经有了 pos,直接改 pos->next 即可。

c 复制代码
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
    assert(pos);

    SLTNode* newnode = BuySLTNode(x);

    newnode->next = pos->next;
    pos->next = newnode;
}

4. 删除指定位置之后的节点

c 复制代码
void SLTEraseAfter(SLTNode* pos)
{
    assert(pos);
    assert(pos->next);

    SLTNode* del = pos->next;
    pos->next = del->next;

    free(del);
}

这两个 After 操作都不需要找前驱节点,所以非常适合单链表。


十三、单链表完整代码实现

下面给出一个简化版单链表实现。


1. SList.h

c 复制代码
#pragma once

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

typedef int SLTDataType;

typedef struct SListNode
{
    SLTDataType data;
    struct SListNode* next;
} SLTNode;

void SLTPrint(SLTNode* phead);

void SLTPushBack(SLTNode** pphead, SLTDataType x);
void SLTPushFront(SLTNode** pphead, SLTDataType x);

void SLTPopBack(SLTNode** pphead);
void SLTPopFront(SLTNode** pphead);

SLTNode* SLTFind(SLTNode* phead, SLTDataType x);

void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);
void SLTErase(SLTNode** pphead, SLTNode* pos);

void SLTInsertAfter(SLTNode* pos, SLTDataType x);
void SLTEraseAfter(SLTNode* pos);

void SListDestroy(SLTNode** pphead);

2. SList.c

c 复制代码
#include "SList.h"

SLTNode* BuySLTNode(SLTDataType x)
{
    SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
    if (newnode == NULL)
    {
        perror("malloc fail");
        exit(1);
    }

    newnode->data = x;
    newnode->next = NULL;

    return newnode;
}

void SLTPrint(SLTNode* phead)
{
    SLTNode* cur = phead;

    while (cur != NULL)
    {
        printf("%d -> ", cur->data);
        cur = cur->next;
    }

    printf("NULL\n");
}

void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
    assert(pphead);

    SLTNode* newnode = BuySLTNode(x);

    newnode->next = *pphead;
    *pphead = newnode;
}

void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
    assert(pphead);

    SLTNode* newnode = BuySLTNode(x);

    if (*pphead == NULL)
    {
        *pphead = newnode;
    }
    else
    {
        SLTNode* tail = *pphead;

        while (tail->next != NULL)
        {
            tail = tail->next;
        }

        tail->next = newnode;
    }
}

void SLTPopFront(SLTNode** pphead)
{
    assert(pphead);
    assert(*pphead);

    SLTNode* del = *pphead;
    *pphead = (*pphead)->next;

    free(del);
}

void SLTPopBack(SLTNode** pphead)
{
    assert(pphead);
    assert(*pphead);

    if ((*pphead)->next == NULL)
    {
        free(*pphead);
        *pphead = NULL;
    }
    else
    {
        SLTNode* prev = NULL;
        SLTNode* tail = *pphead;

        while (tail->next != NULL)
        {
            prev = tail;
            tail = tail->next;
        }

        free(tail);
        prev->next = NULL;
    }
}

SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
    SLTNode* cur = phead;

    while (cur != NULL)
    {
        if (cur->data == x)
        {
            return cur;
        }

        cur = cur->next;
    }

    return NULL;
}

void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
    assert(pphead);
    assert(pos);

    if (*pphead == pos)
    {
        SLTPushFront(pphead, x);
    }
    else
    {
        SLTNode* prev = *pphead;

        while (prev->next != pos)
        {
            prev = prev->next;
        }

        SLTNode* newnode = BuySLTNode(x);
        prev->next = newnode;
        newnode->next = pos;
    }
}

void SLTErase(SLTNode** pphead, SLTNode* pos)
{
    assert(pphead);
    assert(*pphead);
    assert(pos);

    if (*pphead == pos)
    {
        SLTPopFront(pphead);
    }
    else
    {
        SLTNode* prev = *pphead;

        while (prev->next != pos)
        {
            prev = prev->next;
        }

        prev->next = pos->next;
        free(pos);
    }
}

void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
    assert(pos);

    SLTNode* newnode = BuySLTNode(x);

    newnode->next = pos->next;
    pos->next = newnode;
}

void SLTEraseAfter(SLTNode* pos)
{
    assert(pos);
    assert(pos->next);

    SLTNode* del = pos->next;
    pos->next = del->next;

    free(del);
}

void SListDestroy(SLTNode** pphead)
{
    assert(pphead);

    SLTNode* cur = *pphead;

    while (cur != NULL)
    {
        SLTNode* next = cur->next;
        free(cur);
        cur = next;
    }

    *pphead = NULL;
}

十四、链表的分类

链表的结构非常多样。

一般可以从三个角度分类:

text 复制代码
1. 单向 / 双向
2. 带头 / 不带头
3. 循环 / 不循环

这三个条件组合起来,一共有:

text 复制代码
2 × 2 × 2 = 8

种链表结构。


1. 单向链表和双向链表

单向链表

每个节点只保存下一个节点的地址。

text 复制代码
d1 -> d2 -> d3 -> NULL
双向链表

每个节点既保存下一个节点的地址,也保存上一个节点的地址。

text 复制代码
NULL <- d1 <-> d2 <-> d3 -> NULL

2. 带头链表和不带头链表

不带头链表

第一个节点就是有效数据节点。

text 复制代码
phead -> d1 -> d2 -> d3 -> NULL
带头链表

头节点通常是哨兵位,不存有效数据。

text 复制代码
head -> d1 -> d2 -> d3

哨兵位的好处是:

统一空链表和非空链表的处理逻辑。


3. 循环链表和非循环链表

非循环链表

最后一个节点指向 NULL

text 复制代码
d1 -> d2 -> d3 -> NULL
循环链表

最后一个节点指回头部。

text 复制代码
d1 -> d2 -> d3
^           |
|___________|

实际最常见的两种链表

虽然链表类型很多,但实际最常见的是:

text 复制代码
1. 无头单向非循环链表
2. 带头双向循环链表

无头单向非循环链表结构简单,常用于笔试面试题,也常作为其他数据结构的子结构。

带头双向循环链表结构看起来复杂,但实现很多操作时反而更方便。


十六、基于单链表实现通讯录

前面用顺序表实现过通讯录,现在也可以用单链表实现。

通讯录中的每个联系人可以用结构体表示:

c 复制代码
#define NAME_MAX 100
#define SEX_MAX  10
#define TEL_MAX  20
#define ADDR_MAX 100

typedef struct PersonInfo
{
    char name[NAME_MAX];
    char sex[SEX_MAX];
    int age;
    char tel[TEL_MAX];
    char addr[ADDR_MAX];
} PeoInfo;

然后把单链表中保存的数据类型改成:

c 复制代码
typedef PeoInfo SLTDataType;

这样链表中的每个节点就保存一个联系人。


1. 通讯录初始化

如果需要从文件中加载历史数据:

c 复制代码
void LoadContact(contact** con)
{
    FILE* pf = fopen("contact.txt", "rb");
    if (pf == NULL)
    {
        return;
    }

    PeoInfo info;
    while (fread(&info, sizeof(info), 1, pf))
    {
        SLTPushBack(con, info);
    }

    fclose(pf);
}

初始化时调用:

c 复制代码
void InitContact(contact** con)
{
    LoadContact(con);
}

2. 添加联系人

c 复制代码
void AddContact(contact** con)
{
    PeoInfo info;

    printf("请输入姓名:");
    scanf("%s", info.name);

    printf("请输入性别:");
    scanf("%s", info.sex);

    printf("请输入年龄:");
    scanf("%d", &info.age);

    printf("请输入联系电话:");
    scanf("%s", info.tel);

    printf("请输入地址:");
    scanf("%s", info.addr);

    SLTPushBack(con, info);

    printf("添加成功!\n");
}

添加联系人本质上就是:

text 复制代码
把一个联系人结构体尾插到单链表中。

3. 按名字查找联系人

c 复制代码
contact* FindByName(contact* con, char name[])
{
    contact* cur = con;

    while (cur)
    {
        if (strcmp(cur->data.name, name) == 0)
        {
            return cur;
        }

        cur = cur->next;
    }

    return NULL;
}

因为链表不支持随机访问,所以查找只能从头遍历。

时间复杂度:

text 复制代码
O(n)

4. 删除联系人

c 复制代码
void DelContact(contact** con)
{
    char name[NAME_MAX];

    printf("请输入要删除的用户姓名:");
    scanf("%s", name);

    contact* pos = FindByName(*con, name);
    if (pos == NULL)
    {
        printf("要删除的用户不存在,删除失败!\n");
        return;
    }

    SLTErase(con, pos);

    printf("删除成功!\n");
}

删除联系人本质上是:

text 复制代码
先查找节点,再删除节点。

5. 展示联系人

c 复制代码
void ShowContact(contact* con)
{
    printf("%-10s %-4s %-4s %-15s %-20s\n",
        "姓名", "性别", "年龄", "联系电话", "地址");

    contact* cur = con;

    while (cur)
    {
        printf("%-10s %-4s %-4d %-15s %-20s\n",
            cur->data.name,
            cur->data.sex,
            cur->data.age,
            cur->data.tel,
            cur->data.addr);

        cur = cur->next;
    }
}

6. 保存通讯录

c 复制代码
void SaveContact(contact* con)
{
    FILE* pf = fopen("contact.txt", "wb");
    if (pf == NULL)
    {
        perror("fopen fail");
        return;
    }

    contact* cur = con;

    while (cur)
    {
        fwrite(&(cur->data), sizeof(cur->data), 1, pf);
        cur = cur->next;
    }

    fclose(pf);
}

程序退出时保存数据,下一次启动时再读取。

这样通讯录就可以持久化。


十七、双向带头循环链表

单链表虽然结构简单,但有一个明显问题:

找前一个节点比较麻烦。

比如删除某个节点时,如果只知道当前节点 pos,单链表还要从头遍历找到 pos 的前一个节点。

于是引入双向链表。

双向链表节点里有两个指针:

text 复制代码
prev:指向前一个节点
next:指向后一个节点

结构体:

c 复制代码
typedef int LTDataType;

typedef struct ListNode
{
    struct ListNode* next;
    struct ListNode* prev;
    LTDataType data;
} LTNode;

什么是带头双向循环链表?

带头双向循环链表有一个特殊节点:

text 复制代码
哨兵位头节点

它不存有效数据,只负责站在那里"放哨"。

结构图:

text 复制代码
        +---------------------------------------+
        |                                       |
        v                                       |
+------+--------+    +------+--------+    +------+--------+
| head |  next  |<-->| d1   |  next  |<-->| d2   |  next  |
+------+--------+    +------+--------+    +------+--------+
   ^                                           |
   |                                           |
   +-------------------------------------------+

更抽象一点:

text 复制代码
head <-> d1 <-> d2 <-> d3
 ^                         |
 |_________________________|

它有几个特点:

text 复制代码
1. 有哨兵位头节点
2. 每个节点有 prev 和 next
3. 尾节点的 next 指向 head
4. head 的 prev 指向尾节点

哨兵位有什么好处?

哨兵位不存有效数据,但它非常有用。

它可以让很多边界情况变简单:

  • 空链表
  • 头插
  • 尾插
  • 头删
  • 尾删
  • 任意位置插入删除

都可以统一处理。

空链表时:

text 复制代码
head->next = head
head->prev = head

也就是说:

text 复制代码
head 自己指向自己

图解:

text 复制代码
空的带头双向循环链表:

+------+
| head |
+------+
  ^  |
  |__|

这比单链表空指针处理更优雅。


十八、双向链表常用接口实现

1. 初始化

c 复制代码
LTNode* BuyLTNode(LTDataType x)
{
    LTNode* node = (LTNode*)malloc(sizeof(LTNode));
    if (node == NULL)
    {
        perror("malloc fail");
        exit(1);
    }

    node->data = x;
    node->next = NULL;
    node->prev = NULL;

    return node;
}

LTNode* LTInit()
{
    LTNode* phead = BuyLTNode(0);

    phead->next = phead;
    phead->prev = phead;

    return phead;
}

初始化后,phead 是哨兵位。


2. 判断是否为空

c 复制代码
bool LTEmpty(LTNode* phead)
{
    assert(phead);

    return phead->next == phead;
}

如果头节点的 next 指向自己,说明链表为空。


3. 打印链表

c 复制代码
void LTPrint(LTNode* phead)
{
    assert(phead);

    LTNode* cur = phead->next;

    while (cur != phead)
    {
        printf("%d <-> ", cur->data);
        cur = cur->next;
    }

    printf("head\n");
}

注意:

c 复制代码
cur != phead

因为这是循环链表,不是判断 cur != NULL


4. 在指定位置之前插入

双向链表插入非常优雅。

假设在 pos 前插入 newnode

text 复制代码
prev        pos
 |           |
 v           v
A <-------> B

插入后:

text 复制代码
A <-------> newnode <-------> B

代码:

c 复制代码
void LTInsert(LTNode* pos, LTDataType x)
{
    assert(pos);

    LTNode* newnode = BuyLTNode(x);
    LTNode* prev = pos->prev;

    prev->next = newnode;
    newnode->prev = prev;

    newnode->next = pos;
    pos->prev = newnode;
}

这段代码非常重要。

它体现了双向链表的核心:

插入就是修改前后两个节点的指针关系。


5. 尾插和头插

有了 LTInsert,尾插和头插可以直接复用。

尾插是在头节点之前插入:

c 复制代码
void LTPushBack(LTNode* phead, LTDataType x)
{
    assert(phead);

    LTInsert(phead, x);
}

为什么在 phead 前插入就是尾插?

因为循环链表里:

text 复制代码
phead->prev 是尾节点

phead 前插入,就相当于插到尾部。


头插是在第一个有效节点之前插入:

c 复制代码
void LTPushFront(LTNode* phead, LTDataType x)
{
    assert(phead);

    LTInsert(phead->next, x);
}

6. 删除指定位置节点

c 复制代码
void LTErase(LTNode* pos)
{
    assert(pos);

    LTNode* prev = pos->prev;
    LTNode* next = pos->next;

    prev->next = next;
    next->prev = prev;

    free(pos);
}

删除操作只需要让前后两个节点重新连接起来。

图解:

text 复制代码
删除前:

A <-------> pos <-------> B

删除后:

A <---------------------> B

7. 尾删和头删

有了 LTErase,尾删和头删也可以复用。

c 复制代码
void LTPopBack(LTNode* phead)
{
    assert(phead);
    assert(!LTEmpty(phead));

    LTErase(phead->prev);
}
c 复制代码
void LTPopFront(LTNode* phead)
{
    assert(phead);
    assert(!LTEmpty(phead));

    LTErase(phead->next);
}

8. 查找

c 复制代码
LTNode* LTFind(LTNode* phead, LTDataType x)
{
    assert(phead);

    LTNode* cur = phead->next;

    while (cur != phead)
    {
        if (cur->data == x)
        {
            return cur;
        }

        cur = cur->next;
    }

    return NULL;
}

9. 销毁

c 复制代码
void LTDestroy(LTNode* phead)
{
    assert(phead);

    LTNode* cur = phead->next;

    while (cur != phead)
    {
        LTNode* next = cur->next;
        free(cur);
        cur = next;
    }

    free(phead);
}

十九、顺序表和链表的对比

最后,我们把顺序表和链表放在一起比较。

对比项 顺序表 链表
物理存储 物理空间连续 物理空间不一定连续
逻辑结构 逻辑连续 逻辑连续
随机访问 支持,O(1) 不支持,O(n)
头部插入删除 需要移动元素,O(n) 修改指针,通常更方便
中间插入删除 需要移动元素,O(n) 找到位置后修改指针
尾插 空间足够时 O(1) 单链表无尾指针时 O(n),双向循环链表可 O(1)
空间利用 可能有预留空间浪费 按需申请节点
扩容问题 动态顺序表需要扩容 没有容量概念
缓存友好性 连续存储,缓存友好 节点分散,缓存命中率较低
适合场景 频繁随机访问 频繁插入删除

顺序表适合什么场景?

顺序表适合:

text 复制代码
1. 频繁随机访问
2. 尾插较多
3. 数据量相对稳定
4. 需要缓存友好

比如:

  • 动态数组
  • 需要下标访问的场景
  • 排序算法中的数组存储

链表适合什么场景?

链表适合:

text 复制代码
1. 频繁插入删除
2. 不要求随机访问
3. 数据规模变化较大
4. 不希望频繁扩容搬移数据

比如:

  • 哈希桶
  • 图的邻接表
  • LRU 缓存结构中的节点组织
  • 一些需要频繁插入删除的任务

全文总结

1. 链表是什么?

链表是一种物理空间不一定连续的数据结构。

它通过节点中的指针,把逻辑上相邻的数据连接起来。


2. 单链表节点由什么组成?

单链表节点通常包含:

text 复制代码
1. 当前节点的数据
2. 下一个节点的地址

3. 单链表为什么很多接口要传二级指针?

因为插入、删除可能会修改头指针本身。

如果要在函数内部修改外面的 phead,就要传 &phead


4. 单链表的优点

  • 按需申请空间
  • 插入删除不需要整体搬移元素
  • 结构简单,适合面试题
  • 适合作为其他数据结构的子结构

5. 单链表的缺点

  • 不支持随机访问
  • 找前驱节点麻烦
  • 尾插尾删通常需要遍历
  • 指针操作容易出错

6. 双向带头循环链表为什么常用?

因为它有:

text 复制代码
1. 哨兵位头节点
2. prev 指针
3. next 指针
4. 循环结构

这些设计让插入、删除操作更统一,边界情况更少。


7. 顺序表和链表怎么选?

如果你需要频繁随机访问,优先考虑顺序表。

如果你需要频繁插入删除,优先考虑链表。

但没有一种结构是万能的。

数据结构真正重要的不是背概念,而是理解:

不同结构适合不同场景。


相关推荐
lsx2024061 小时前
《Foundation 均衡器:深入解析其工作原理与应用领域》
开发语言
常常有1 小时前
中间件与依赖系统:构建高效 Web 后端的双重利器
开发语言·python·中间件·fastapi
金玉满堂@bj1 小时前
Go 语言能做什么?
开发语言·后端·golang
ooseabiscuit1 小时前
Laravel6.x新特性全解析
java·开发语言·后端·mysql·spring
bnmoel1 小时前
数据结构深度剖析顺序表:结构、扩容与增删查改全解析
c语言·数据结构·算法·顺序表
枕星而眠1 小时前
一篇吃透 C++ 核心基础:初始化、引用、指针、内联、重载、右值引用
开发语言·数据结构·c++·后端·visual studio
Royzst1 小时前
一、集合概述(前置基础)
开发语言·windows·python
Season4501 小时前
C/C++的类型转换
c语言·开发语言·c++
平安的平安1 小时前
Python大模型Function Calling实战:让AI拥有工具使用能力
开发语言·人工智能·python