【数据结构】带头双向循环链表的实现:链表的分类 | 定义双链表 | 初始化接口 | 打印接口 | 创建新节点 | 头插与尾插

719 阅读8分钟

​一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

前言:

本章节将继续讲解链表,在上一章节中我们学习了单链表,本章将对其他的链表进行简要介绍,旨在让读者理解单链表和双链表各自存在的意义。将着重讲解带哨兵位双向循环链表,对常用的接口函数进行逐个讲解,本章开始引入可以将思路轻松转换成代码的 "思路草图" 方法。站在初学者的角度上进行讲解和分析。通过本章的学习,还能够帮助大家理解解 "代码复用" 的意义。

一、链表的分类

0x01 链表的分类

① 单向或者双向

② 带头或者不带头

③ 循环或者非循环

0x02 常用的链表

根据上面的分类我们可以细分出8种不同类型的链表,这么多链表我们一个个讲解这并没有意义。我们实际中最常用的链表是 "无头单向非循环链表" 和 "带头双向循环链表" ,至于 "无头单项非循环链表" 我们在上一章已经讲述过了,我们下面将讲解 "带头双向循环列表" !

📚 解读:

① 无头单向非循环链表:结构简单,一般不会单独用来存储数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等。此外,在笔试中单链表的出现频率较多。

② 带头双向循环链表:结构最复杂,但是实现反而简单。一般用来单独存储数据,实际中使用的链表数据结构都是带头双向链表。另外,这个结构虽然结构复杂,但是使用代码实现后会发现结构会带来很多优势。双向链表严格来说只需要快速的实现两个接口,insert 和 earse 头尾的插入和删除就可以搞定了,这就是结构的优势!

二、双链表的定义和实现

0x00 定义双链表

typedef int DLNodeDataType;       // DLNodeDataType == int

typedef struct DoubleListNode {
    DLNodeDataType data;          // 用来存放结点的数据
    struct DoubleListNode* next;  // 指向后继节点的指针
    struct DoubleListNode* prev;  // 指向前驱节点的指针
} DLNode;  // 重命名为DLNode

🔑 解读:和之前一样,为了方便后续使用我们将类型 typedef 一下。首先创建结构体,因为双链表,所以我们将它取为 DoubleListNode。为了方便后续地使用,我们再把这个结构体重命名成 DLNode(非常合理的简写,DoubleListNode)。

0x01 接口函数

📚 这是需要实现几个接口函数:

DLNode* DListInit();
// DLNode* CreateNewNode(DLNodeDataType x);
void DListPrint(DLNode* pHead);
void DListPushBack(DLNode* pHead, DLNodeDataType x);
void DListPushFront(DLNode* pHead, DLNodeDataType x);
void DListPopBack(DLNode* pHead);
void DListPopFront(DLNode* pHead);
DLNode* DListFind(DLNode* pHead, DLNodeDataType x);
void DListInsert(DLNode* pos, DLNodeDataType x);
void DListEarse(DLNode* pos);

0x02 初始化双链表(DListInit)

我们之前在学习无头非循环单链表时,我们使用的是二级指针的方法来接收参数的。本节我们将采用传递返回值的方法来完成。

💬 DList.h

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

typedef int DLNodeDataType;       // DLNodeDataType == int

typedef struct DoubleListNode {
    DLNodeDataType data;          // 用来存放结点的数据
    struct DoubleListNode* next;  // 指向后继节点的指针
    struct DoubleListNode* prev;  // 指向前驱节点的指针
} DLNode;  // 重命名为DLNode
       
DLNode* DListInit();

🔑 解读:既然要初始化带头的链表,就需要动态内存开辟,所以我们要引入 #include <stdlib.h> 这个头文件。我们既然要采用返回值的方法,我们就需要把函数类型设定为 DLNode* 。

💬 DList.c

DLNode* DListInit() {
    //哨兵位头节点
    DLNode* pHead = (DLNode*)malloc(sizeof(DLNode));
    pHead->next = pHead;
    pHead->prev = pHead;

    return pHead;
}

🔑 解读:这里我们使用 malloc 函数开辟一块空间作为 "哨兵位" pHead ,最后将其进行一个初始化。最后再将 pHead 作为结果返回回去,外面就可以接收到了。这就是返回值的方法,当然这里也可以采用二级指针的方法来完成。

0x03 双向链表打印(DListPrint)

💬 DList.h

void DListPrint(DLNode* pHead);

🔑 解读:用结构体指针 pHead 接收, 这里的 pHead 表示哨兵位。

💬 DList.c

void DListPrint(DLNode* pHead) {
    assert(pHead != NULL); //防止pHead为空
    
    DLNode* cur = pHead->next; //因为pHead存的不是有效数据,所以要从pHead的下一个节点开始
    while(cur != pHead) {
        printf("%d ", cur->data); //打印
        cur = cur->next;
    }
    printf("\n"); //换行
}

🔑 解读:

① 我们要防止 pHead 为空,暴力解决方法就是用 assert 断言下即可。

② 创建遍历指针 cur,因为 pHead 是哨兵位所以存的不是有效数据,我们想要遍历链表就需要从 pHead->next 开始(即第一个有效数据节点),当 cur 等于 pHead 就相当于全部走了一遍了,这时就结束。

0x04 创建新节点(CreateNewList)

⚡ 创建新节点要经常用,为了方便复用,根据经验我们先把 CreateNewNode 写好:

DLNode* CreateNewNode(DLNodeDataType x) {
    //动态内存开辟一块 DLNode 大小的空间给 new_node
    DLNode* new_node = (DLNode*)malloc(sizeof(DLNode));
    //检查malloc
    if (new_node == NULL) {
        printf("malloc failed!\n");
        exit(-1);
    }
    //放置数据
    new_node->data = x;
    //初始化
    new_node->next = NULL;
    new_node->prev = NULL;
    //返回
    return new_node;
}

0x05 双向链表尾插(DListPushBack)

💬 DList.h

void DListPushBack(DLNode* pHead, DLNodeDataType x);

🔑 解读:因为不用改变 pList,所以不需要使用二级指针。

DLNode* pList = DListInit();

💬 DList.c

void DListPushBack(DLNode* pHead, DLNodeDataType x) {
    assert(pHead != NULL); //防止pHead为空
   
    DLNode* tail = pHead->prev; //创建尾指针
    DLNode* new_node = CreateNewNode(x); //创建新节点

    //思路草图: pHead                 tail   new_node(尾插目标)
    tail->next = new_node;
    new_node->prev = tail;
    new_node->next = pHead;
    pHead->prev = new_node;
}

🔑 解读:

① 首先防止 pHead 为空。

② 因为要实现尾插,我们要找出尾部节点。我们这里并不需要像之前学单链表时需要创建寻尾指针找到尾部节点,直接从 pHead->prev 那里取就可以了。是不是非常的方便?找都不用找了直接

O(1)

解决,真的是不爽不要钱!随后创建新节点,直接调用我们刚才写的 CreateNewNode 接口即可。

③ 实现尾插操作,画出来可以更好地写出代码。在注释里写一个简单地思路草图也是可以的,只要画好他们之间的链接关系,再写代码会变得非常简单:

思路草图: pHead tail new_node(尾插目标)

然后再根据尾插的思路,我们就可以轻松写出代码了:

tailnew_node 相互链接起来:

    tail->next = new_node;
    new_node->prev = tail;

new_nodepHead 相互链接起来:

    new_node->next = pHead;
    pHead->prev = new_node;

( "双链表"的尾插就这么写好了,是不是比之前学的 "单链表" 简单多了?)

🔍 如果你没有看懂草图,可以看下面画的更详细的解析图:

💬 Test.c

我们来测试一下我们刚才写的几个接口:

#include "DList.h"

void TestList1() {
    DLNode* pList = DListInit();
    DListPushBack(pList, 1);
    DListPushBack(pList, 2);
    DListPushBack(pList, 3);
    DListPushBack(pList, 4);
    DListPrint(pList);
}

int main() {
    TestList1();

    return 0;
}

🚩 运行结果:

0x06 双向链表头插(DListPushFront)

💬 DList.h

void DListPushFront(DLNode* pHead, DLNodeDataType x);

🔑 解读:双向链表头插,即在第一个数据前面,也就是哨兵位和第一个数据之间插入一个数据。

💬 DList.c

void DListPushFront(DLNode* pHead, DLNodeDataType x) {
    assert(pHead != NULL); //防止pHead为空
    
    DLNode* new_node = CreateNewNode(x); //创建新节点
    DLNode* pHeadNext = pHead->next; //标出第一个节点

    //思路草图: pHead  new_node(头插目标)  pHeadNext
    pHead->next = new_node;
    new_node->prev = pHead;
    new_node->next = pHeadNext;
    pHeadNext->prev = new_node;
}

🔑 解读:

① 首先防止 pHead 为空。

② 既然是插入,那就创建新节点。双向链表头插,我们要找到第一个节点,通过 pHead->next 就可以拿到了,我们将它取名为 pHeadNext(表示第一个节点)。在哨兵位和第一个数据之间插入数据,画出草图就是:

思路草图: pHead new_node(头插目标) next

根据草图我们开始写代码,将他们互相链接起来即可!

pHeadnew_node 相互链接起来:

    pHead->next = new_node;
    new_node->prev = pHead;

new_nodepHeadNext 相互链接起来:

    new_node->next = pHeadNext;
    pHeadNext->prev = new_node;

💬 Test.c

#include "DList.h"

void TestList2() {
    DLNode* pList = DListInit();
    DListPushFront(pList, 10);
    DListPushFront(pList, 20);
    DListPushFront(pList, 30);
    DListPushFront(pList, 40);

    DListPrint(pList);
}

int main() {
    TestList2();

    return 0;
}

🚩 运行结果: