【数据结构与算法】第8篇:线性表(四):双向链表与循环链表

一、双向链表

1.1 什么是双向链表

双向链表(Doubly Linked List)的每个节点包含三个部分:

  • 指向前驱节点的指针(prev)

  • 数据域(data)

  • 指向后继节点的指针(next)

text

复制代码
head ↔ [prev|data|next] ↔ [prev|data|next] ↔ [prev|data|next] ↔ NULL

优点

  • 可以从前往后遍历,也可以从后往前遍历

  • 删除节点时,不需要知道前驱节点(通过prev直接找到)

  • 插入操作更灵活

缺点

  • 每个节点多了一个指针,占用更多内存

  • 操作时多维护一个指针,代码稍复杂

1.2 节点结构定义

c

复制代码
typedef struct DNode {
    int data;
    struct DNode *prev;  // 指向前驱
    struct DNode *next;  // 指向后继
} DNode, *PDNode;

typedef struct {
    PDNode head;  // 头结点(哑结点)
    int size;
} DList;

和单链表一样,我们继续使用带头结点的方式,让操作更统一。

1.3 初始化与销毁

c

复制代码
void initDList(DList *list) {
    list->head = (PDNode)malloc(sizeof(DNode));
    if (list->head == NULL) {
        printf("初始化失败\n");
        exit(1);
    }
    list->head->prev = NULL;
    list->head->next = NULL;
    list->size = 0;
}

void destroyDList(DList *list) {
    PDNode cur = list->head;
    while (cur != NULL) {
        PDNode temp = cur;
        cur = cur->next;
        free(temp);
    }
    list->head = NULL;
    list->size = 0;
}

1.4 创建新节点

c

复制代码
PDNode createDNode(int value) {
    PDNode newNode = (PDNode)malloc(sizeof(DNode));
    if (newNode == NULL) return NULL;
    newNode->data = value;
    newNode->prev = NULL;
    newNode->next = NULL;
    return newNode;
}

1.5 插入操作

双向链表的插入比单链表简单,因为可以同时操作前后指针。

在指定位置插入

c

复制代码
int insertAt(DList *list, int pos, int value) {
    if (pos < 0 || pos > list->size) {
        printf("插入位置不合法\n");
        return -1;
    }
    
    PDNode newNode = createDNode(value);
    if (newNode == NULL) return -1;
    
    // 找到要插入位置的原节点(pos位置的节点)
    PDNode cur = list->head;
    for (int i = 0; i < pos; i++) {
        cur = cur->next;
    }
    // 此时cur是要插入位置的前一个节点(头结点或上一个节点)
    
    PDNode next = cur->next;  // 原来pos位置的节点
    
    // 连接新节点
    newNode->prev = cur;
    newNode->next = next;
    cur->next = newNode;
    if (next != NULL) {
        next->prev = newNode;
    }
    
    list->size++;
    return 0;
}

头插法(在第一个有效节点前插入):

c

复制代码
void insertAtHead(DList *list, int value) {
    insertAt(list, 0, value);
}

尾插法

c

复制代码
void insertAtTail(DList *list, int value) {
    insertAt(list, list->size, value);
}

1.6 删除操作

双向链表删除节点时,不需要遍历找前驱,直接通过当前节点的prev就能拿到。

c

复制代码
int deleteAt(DList *list, int pos) {
    if (pos < 0 || pos >= list->size) {
        printf("删除位置不合法\n");
        return -1;
    }
    
    // 找到要删除的节点
    PDNode cur = list->head->next;
    for (int i = 0; i < pos; i++) {
        cur = cur->next;
    }
    
    PDNode prev = cur->prev;
    PDNode next = cur->next;
    
    // 断开连接
    prev->next = next;
    if (next != NULL) {
        next->prev = prev;
    }
    
    int value = cur->data;
    free(cur);
    list->size--;
    return value;
}

1.7 遍历(正向和反向)

c

复制代码
void printForward(DList *list) {
    PDNode cur = list->head->next;
    printf("正向 size=%d, [", list->size);
    while (cur != NULL) {
        printf("%d", cur->data);
        if (cur->next != NULL) printf(" <-> ");
        cur = cur->next;
    }
    printf("]\n");
}

void printBackward(DList *list) {
    // 先找到尾节点
    PDNode cur = list->head;
    while (cur->next != NULL) {
        cur = cur->next;
    }
    printf("反向 [");
    while (cur != list->head) {
        printf("%d", cur->data);
        if (cur->prev != list->head) printf(" <-> ");
        cur = cur->prev;
    }
    printf("]\n");
}

1.8 完整演示

c

复制代码
int main() {
    DList list;
    initDList(&list);
    
    insertAtTail(&list, 10);
    insertAtTail(&list, 20);
    insertAtTail(&list, 30);
    printForward(&list);  // [10 <-> 20 <-> 30]
    
    insertAtHead(&list, 5);
    printForward(&list);  // [5 <-> 10 <-> 20 <-> 30]
    
    insertAt(&list, 2, 15);
    printForward(&list);  // [5 <-> 10 <-> 15 <-> 20 <-> 30]
    
    deleteAt(&list, 2);
    printForward(&list);  // [5 <-> 10 <-> 20 <-> 30]
    
    printBackward(&list); // [30 <-> 20 <-> 10 <-> 5]
    
    destroyDList(&list);
    return 0;
}

二、循环链表

2.1 什么是循环链表

循环链表(Circular Linked List)是首尾相连的链表:

  • 单向循环链表:尾节点的next指向头结点

  • 双向循环链表:尾节点的next指向头结点,头结点的prev指向尾节点

text

复制代码
单向循环链表:
head → [节点1] → [节点2] → [节点3] → ... → [节点n] ↘
        ↑_______________________________________________↙

双向循环链表:
head ↔ [节点1] ↔ [节点2] ↔ ... ↔ [节点n] ↔ head

特点

  • 从任意节点出发都能遍历整个链表

  • 适合需要循环处理的场景

2.2 单向循环链表的实现

c

复制代码
typedef struct CNode {
    int data;
    struct CNode *next;
} CNode, *PCNode;

typedef struct {
    PCNode head;  // 头指针(带头结点)
    int size;
} CList;

初始化(头结点的next指向自己,形成空循环):

c

复制代码
void initCList(CList *list) {
    list->head = (PCNode)malloc(sizeof(CNode));
    if (list->head == NULL) {
        printf("初始化失败\n");
        exit(1);
    }
    list->head->next = list->head;  // 指向自己,表示空链表
    list->size = 0;
}

插入(注意尾节点要指向头结点):

c

复制代码
void insertAtTail(CList *list, int value) {
    PCNode newNode = (PCNode)malloc(sizeof(CNode));
    newNode->data = value;
    
    // 找到尾节点(next指向头结点的节点)
    PCNode cur = list->head;
    while (cur->next != list->head) {
        cur = cur->next;
    }
    
    cur->next = newNode;
    newNode->next = list->head;
    list->size++;
}

遍历 (判断结束条件是 cur->next != list->head):

c

复制代码
void printCList(CList *list) {
    if (list->size == 0) {
        printf("空链表\n");
        return;
    }
    PCNode cur = list->head->next;
    printf("[");
    while (cur != list->head) {
        printf("%d", cur->data);
        if (cur->next != list->head) printf(" -> ");
        cur = cur->next;
    }
    printf("] -> (回到头)\n");
}

三、经典应用:约瑟夫环

3.1 问题描述

约瑟夫环问题:n个人围成一圈,从第一个人开始报数,数到m的人出列,然后从下一个人重新报数,直到所有人出列。求出列顺序。

3.2 思路分析

用循环链表非常合适:

  1. 创建一个包含n个节点的循环链表

  2. 从某个节点开始遍历,每数到m就删除当前节点

  3. 继续从下一个节点数,直到链表为空

3.3 代码实现

c

复制代码
#include <stdio.h>
#include <stdlib.h>

typedef struct JosephNode {
    int num;                    // 编号
    struct JosephNode *next;
} JosephNode;

// 创建约瑟夫环
JosephNode* createJosephus(int n) {
    if (n <= 0) return NULL;
    
    JosephNode *head = (JosephNode*)malloc(sizeof(JosephNode));
    head->num = 1;
    head->next = NULL;
    
    JosephNode *tail = head;
    for (int i = 2; i <= n; i++) {
        JosephNode *newNode = (JosephNode*)malloc(sizeof(JosephNode));
        newNode->num = i;
        newNode->next = NULL;
        tail->next = newNode;
        tail = newNode;
    }
    tail->next = head;  // 形成环
    return head;
}

// 约瑟夫环求解
void josephus(JosephNode *head, int m) {
    if (head == NULL) return;
    
    JosephNode *cur = head;
    JosephNode *prev = NULL;
    
    // 找到尾节点(作为prev的初始值)
    prev = cur;
    while (prev->next != head) {
        prev = prev->next;
    }
    
    int count = 1;
    while (cur->next != cur) {  // 只剩一个节点时停止
        if (count == m) {
            // 删除当前节点
            printf("%d ", cur->num);
            prev->next = cur->next;
            free(cur);
            cur = prev->next;
            count = 1;  // 重置计数
        } else {
            prev = cur;
            cur = cur->next;
            count++;
        }
    }
    // 输出最后一个
    printf("%d\n", cur->num);
    free(cur);
}

int main() {
    int n = 7, m = 3;
    printf("%d个人,数到%d出列,顺序:", n, m);
    JosephNode *head = createJosephus(n);
    josephus(head, m);
    return 0;
}

运行结果:

text

复制代码
7个人,数到3出列,顺序:3 6 2 7 5 1 4

四、双向链表 vs 单链表

操作 单链表 双向链表
头插 O(1) O(1)
尾插 O(n) O(1)(维护尾指针)
中间插入 O(n) O(n)(但不用找前驱)
删除已知节点 O(n)(需要前驱) O(1)
反向遍历 不支持 O(n)
内存占用 1个指针/节点 2个指针/节点

适用场景

  • 单链表:简单、省内存,适合单向遍历

  • 双向链表:需要双向遍历、频繁删除已知节点的场景


五、小结

这一篇我们讲了:

结构 特点 应用
双向链表 每个节点有prev和next,删除更高效 需要双向遍历的场景
循环链表 首尾相连,从任意点可遍历 约瑟夫环、环形缓冲区

重点

  • 双向链表的插入删除要同时维护prev和next

  • 循环链表的遍历结束条件不再是NULL,而是回到头结点

  • 约瑟夫环是循环链表的经典应用

下一篇我们会讲栈和队列,它们是受限的线性表,但在很多场景下非常有用。


六、思考题

  1. 双向链表的插入操作中,如果插入位置是末尾,需要注意什么?

  2. 用双向循环链表实现约瑟夫环,和用单向循环链表有什么区别?

  3. 为什么双向链表删除节点时不需要找前驱节点?

  4. 尝试实现一个双向链表的反转函数。

相关推荐
岑梓铭2 小时前
《考研408数据结构》第三章2(栈、队列应用)复习笔记
数据结构·笔记
wangchunting2 小时前
数据结构-线性数据结构
java·开发语言·数据结构
tankeven2 小时前
HJ149 数水坑
c++·算法
小陈工4 小时前
Python安全编程实践:常见漏洞与防护措施
运维·开发语言·人工智能·python·安全·django·开源
John_ToDebug10 小时前
浏览器扩展延迟加载优化实战:如何让浏览器启动速度提升50%
c++·chrome·windows
是娇娇公主~10 小时前
C++ 中 std::deque 的原理?它内部是如何实现的?
开发语言·c++·stl
SuperEugene10 小时前
Axios 接口请求规范实战:请求参数 / 响应处理 / 异常兜底,避坑中后台 API 调用混乱|API 与异步请求规范篇
开发语言·前端·javascript·vue.js·前端框架·axios
Fly Wine10 小时前
Leetcode之有效字母异位词
算法·leetcode·职场和发展
WalterJau11 小时前
C 内存分区
c语言