7. 静态链表与循环链表:静态链表的概念与实现、循环链表、双向链表

好,咱们今天聊聊链表里几个“变种”。说实话,很多初学者学完单链表就觉得链表就那样了。其实不然。我在实际项目中遇到过不少场景,单链表根本搞不定,或者用起来特别别扭。这时候,静态链表、循环链表、双向链表就派上用场了。

你想想看,单链表有个硬伤——每个节点只知道下一个是谁,不知道上一个。而且它的内存是动态分配的,在某些嵌入式环境里,malloc 根本不能用。怎么办?嗯,这就是静态链表要解决的问题。

7.1 静态链表的概念与实现

静态链表,说白了就是用数组来模拟链表。每个数组元素既存数据,又存一个“游标”(cursor),指向下一个元素的下标。它既有数组的连续存储特性,又有链表的灵活插入删除能力。

我刚开始做嵌入式开发时,遇到过一块没有操作系统的芯片,堆空间小得可怜,动态分配根本不敢用。那时候就是用静态链表管理任务队列,稳得很。

7.1.1 结构定义

#define MAXSIZE 100

typedef struct {
    int data;       // 数据域
    int cur;        // 游标,指向下一个元素的下标
} StaticLinkList[MAXSIZE];

这里有个关键点:cur 为 0 表示无后继,相当于单链表的 NULL。数组的第 0 个元素和第 1 个元素通常被特殊对待——第 0 个作为备用链表的头结点,第 1 个作为数据链表的头结点。

7.1.2 初始化与分配

void InitList(StaticLinkList space) {
    for (int i = 0; i < MAXSIZE - 1; i++) {
        space[i].cur = i + 1;
    }
    space[MAXSIZE - 1].cur = 0;  // 最后一个元素的cur为0
}

int Malloc_SLL(StaticLinkList space) {
    int i = space[0].cur;        // 取备用链表的第一个空闲节点
    if (i) {
        space[0].cur = space[i].cur;  // 备用链表头指针后移
    }
    return i;
}

分配节点时,从备用链表头部取一个。释放节点时,把节点插回备用链表头部。这个思路我在管理内存池时也用过,本质是一样的。

核心思想:静态链表用数组下标代替指针,用游标代替 next 指针。插入删除不需要移动元素,只需要修改游标。

7.1.3 插入操作示例

Status ListInsert(StaticLinkList L, int i, int e) {
    int j, k, l;
    k = MAXSIZE - 1;  // 数据链表头结点
    if (i < 1 || i > ListLength(L) + 1) return ERROR;
    j = Malloc_SLL(L);
    if (j) {
        L[j].data = e;
        for (l = 1; l <= i - 1; l++) {
            k = L[k].cur;
        }
        L[j].cur = L[k].cur;
        L[k].cur = j;
        return OK;
    }
    return ERROR;
}

这段代码看着有点绕,其实逻辑和单链表一模一样——找到第 i-1 个节点,把新节点插进去。只不过这里的“指针”变成了数组下标。

避坑指南:我曾经在写静态链表删除操作时,忘了把被删节点归还到备用链表,结果跑了几次就“内存耗尽”了。记住:Free_SLL 一定要把节点插回备用链表头部,否则静态链表就是个一次性玩具。

7.2 循环链表

循环链表是什么?就是把单链表的尾节点的 next 指针指向头结点,形成一个环。为什么要这么做?

我记得有一次做音乐播放器的循环播放功能,用单链表实现时,每次播完最后一首都要手动把指针挪回头部,代码写得很别扭。换成循环链表后,一个指针从头走到尾,到尾自动回到头,完美契合循环播放的逻辑。

7.2.1 循环链表的优势

  • 从任意节点出发都能遍历整个链表——单链表做不到这一点
  • 尾节点访问头节点的时间复杂度为 O(1)——单链表需要 O(n)
  • 适合需要循环访问的场景——比如轮询任务、约瑟夫环问题

7.2.2 循环链表的合并

两个循环链表的合并,我当年面试时被考过。代码其实很简洁:

LinkList Merge(LinkList LA, LinkList LB) {
    LinkList p = LA->next;   // 保存LA的头节点
    LA->next = LB->next->next;  // LA尾节点指向LB的第一个数据节点
    free(LB->next);          // 释放LB的头节点
    LB->next = p;            // LB尾节点指向LA的头节点
    return LB;               // 返回合并后的尾指针
}

这里用尾指针表示循环链表,合并操作只需要修改两个指针,时间复杂度 O(1)。你想想看,如果是单链表,你得先遍历找到尾节点,那就 O(n) 了。

注意:循环链表的遍历结束条件不是 p == NULL,而是 p == head。很多初学者在这里翻车,写了个死循环还不知道。我曾经调试一个循环链表程序,卡了半小时才发现是判断条件写错了。

7.3 双向链表

双向链表,每个节点有两个指针——一个指向前驱,一个指向后继。说白了就是“前后都能走”。

为什么需要双向链表?我给你举个例子。你在文本编辑器里按“退格键”删除字符,如果用单链表存储文本,删除当前字符时你得先找到前一个节点,那就得从头遍历。双向链表直接通过 prior 指针就找到了,时间复杂度从 O(n) 降到 O(1)。

7.3.1 结构定义

typedef struct DuLNode {
    int data;
    struct DuLNode *prior;  // 前驱指针
    struct DuLNode *next;   // 后继指针
} DuLNode, *DuLinkList;

7.3.2 插入操作

Status ListInsert_DuL(DuLinkList L, int i, int e) {
    DuLinkList p = GetElemP_DuL(L, i);  // 找到第i个节点
    if (!p) return ERROR;
    DuLinkList s = (DuLinkList)malloc(sizeof(DuLNode));
    s->data = e;
    s->prior = p->prior;
    s->next = p;
    p->prior->next = s;
    p->prior = s;
    return OK;
}

这里要注意指针修改的顺序。我习惯先处理新节点的两个指针,再处理前驱和后继的指针。顺序搞反了,链表就断了。

个人经验:双向链表删除操作时,记得检查 p == L 的情况(删除头结点)。我曾经在写一个双向链表实现 LRU 缓存时,忘了处理头结点删除,结果每次淘汰最久未使用的节点时都会崩溃。调试了一下午才发现。

7.4 三种链表的对比

特性 静态链表 循环链表 双向链表
存储方式 数组(连续) 动态(不连续) 动态(不连续)
内存分配 静态(编译时确定) 动态(运行时分配) 动态(运行时分配)
遍历方向 单向 单向(可循环) 双向
查找前驱 O(n) O(n) O(1)
适用场景 嵌入式、无动态分配环境 循环播放、轮询、约瑟夫环 文本编辑、LRU缓存、需要频繁前后操作

这三种链表各有各的脾气。静态链表适合资源受限的环境,循环链表适合“绕圈”的场景,双向链表适合需要频繁前后移动的场景。选哪个,得看你的具体需求。

一句话总结:静态链表是“披着数组外衣的链表”,循环链表是“首尾相连的链表”,双向链表是“能进能退的链表”。理解它们的本质区别,比死记硬背代码重要得多。

链表变种知识体系 链表变种 静态链表 循环链表 双向链表 数组实现 无动态分配 首尾相连 循环遍历 双向指针 O(1)前驱 三种链表各有适用场景,选型需结合具体需求

好了,这一章的内容就到这儿。静态链表、循环链表、双向链表,每个都有自己独特的应用场景。我个人建议你动手实现一遍,尤其是静态链表的分配和回收逻辑,写一遍比看十遍都管用。遇到问题别怕,调试的过程才是真正学到东西的时候。

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