目录
前言
本篇文章我将会向大家介绍数据结构中一种常用的链表结构, 带头双向循环链表(双链表)
。
这种链表结构最复杂,一般用在单独存储数据。实际中使用的链表顺序结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后就会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了。
我将会介绍双链表的以下功能:初始化、打印、尾插、尾删、头插、头删、查找、任意位置前插入、删除任意位置,销毁。
每一个功能我都会向大家展示相应的效果,并且分析它就是如何来实现的,下面开始介绍。
1. 概念及结构
1.1 认识双链表
上一篇文章中我曾介绍了单链表几种基本功能的实现,不论大家有没有看过,我在先在此简单回顾一下单链表的结构。
单链表又名无头单项非循环链表
,这类链表的每个结点含两个成员,一个存放该结点的数据,另一个存放指向下一个结点的地址。
下面我们再来看双链表的结构。
仅仅观察这两类链表的名字就是一件十分有趣的事,双链表似乎正好是单链表的一种相对结构。
那么我们就通过和单链表对比来分析一下双链表的结构:
- 带头:何谓这里的"头",在此处肯定是指
链表头结点
。很显然两种链表都是有头结点的,那么为什么说单链表无头,而双链表有头呢?仔细观察,双链表的头结点并未存放任何内容,仅仅在此充当"头"的作用,真正第一个结点是从头结点的下一个结点开始的。这样做有什么好处吗?下面实现双链表功能时,大家很快就会感受到了。 - 单向:单链表的结点内仅有指向下一个结点的指针,所以通过当前结点只能访问到下一个结点,是一种单向结构。而双链表结点内既有指向下一个结点的指针,还有指向上一个结点的指针,通过当前结点既能访问上一个结点又能访问下一个结点,所以说双链表是一种双向结构。
- 循环:我们知道单链表从头结点指向尾结点,再指向空指针,头尾之间没有任何联系,一次遍历完就结束。而双链表的头结点里有指向尾结点的指针,尾结点有指向头结点的指针。链表可以从头指向尾,又能从尾指向头,所以说双链表事循环结构。
说到这里不知道会不会有人产生这种想法,这个双链表的结构好复杂啊,它的功能实现起来是不是要比单链表难上好多倍啊。可以告诉大家的是,恰恰相反,正是由于双链表结构相对于单链表的完整性,双链表的功能实现起来要比单链表简单许多倍。我对双链表的总结是:结构复杂,实现简单
。
1.2 双链表的存储
根据上面分析的双链表结构,我们来定义一个双链表结点:
typedef int LTDataType;
typedef struct ListNode
{
LTDataType data;
struct ListNode* next;
struct ListNode* prev;
}ListNode;
双链表结点有三个成员,一个变量存放该结点数据,还有两个指针一个指向上一个结点,一个指向下一个结点。
结点创建好之后我们就来实现双链表的基本功能。
2. 双链表功能实现
首先创建一个工程,建三个文件(如果有人不熟悉这种写法只需关注后面的功能即可):
2.1 申请新结点
想要实现一个链表就要往链表中放入结点,所以我们先来实现双链表申请新结点的操作。
ListNode* ListCreate(LTDataType x)
{
ListNode* node = (ListNode*)malloc(sizeof(ListNode));
node->next = NULL;
node->prev = NULL;
node->data = x;
return node;
}
申请新节点的代码并不难,首先需要拿到该结点数据域的值,然后动态开辟一块新结点空间。新结点的两个指针一开始先初始化为空指针,再将结点的数据放入数据域,这样一个新结点就开辟成功了。最后我们再将新结点的地址返回出去。
2.2 双链表的初始化
在上篇文章介绍单链表的时候我并未对单链表进行初始化,那是因为单链表的头指针开辟出来之后直接指向空指针即可,不需要再进行任何改动。等到第一个结点申请出来,头指针直接指向第一个结点。而双链表创建的头指针开辟之后就要指向一个头结点,这个头结点并不会存放双链表的任何内容,仅仅在此充当头的作用。
所以双链表一开始我们要申请一块空间充当头结点,初始化代码如下:
ListNode* ListInit()
{
ListNode* phead = BuyListNode(0);
phead->_next = phead;
phead->_prev = phead;
return phead;
}
借助申请新结点的函数先申请一个新结点赋给头指针,然后根据双向链表的特性,头指针指向下一个结点和上一个结点的指针都指向它自己。
2.3 双链表的打印
要打印一个链表肯定需要将链表的所有结点都遍历一遍,而我们知道双链表真正第一个结点是从头结点的下一个结点开始
,尾结点的特点是指向下一个结点的指针指向头结点
。
根据这两点我们就可以来实现一个双链表的打印,打印代码如下:
void ListPrint(ListNode* phead)
{
assert(phead);
ListNode* cur = phead->next;
while (cur != phead)
{
printf("%d ", cur->data);
cur = cur->next;
}
printf("\n");
}
因为双链表创建之初就存在头结点,所以函数一开始我们要断言拿到的头指针不为空指针。
创建一个临时指针从头结点的下一个结点开始遍历每个结点进行打印,等到该指针指向头结点时停止打印。
2.4 双链表的尾插
实现链表的尾插第一步先要找到链表的尾结点。回想当初在找单链表的尾结点时,我们是根据单链表尾结点指针域为空,从头结点开始遍历找到尾结点,很显然这种做法的时间复杂度为O(N)。
那么双链表在找尾结点的时候还需不需要再遍历一遍尾结点呢?当然不需要,双链表头结点中指向前一个结点的指针正好指向尾结点,因此我们可以直接通过头结点找到尾结点,时间复杂度仅为O(1),这就是双链表双向循环的优势。
还有,在实现单链表尾插时还需要分两种情况,判断单链表中有结点还是无结点。而由于双链表带头的特性,双链表在一开始就有一个头结点,所以不需要再判断链表中是否存在结点的情况,这就是双链表带头的优势所在。
原链表:
尾插之后的链表:
尾插的实现代码如下:
void ListPushBack(ListNode* phead, LTDataType x)
{
assert(phead);
ListNode* tail = phead->prev;
ListNode* newnode = BuyListNode(x);
tail->next = newnode;
newnode->prev = tail;
newnode->next = phead;
phead->prev = newnode;
}
通过头结点找到尾结点,再调用申请新结点的函数申请一个新结点。
接下来要做的就是头结点,新结点,尾结点这三个结点之间的链接。尾结点指向下一个结点的指针指向新节点,新结点的指向前一个结点的指针指向尾结点。新结点指向下一个结点的指针指向头结点,头结点指向上一个结点的指针指向新结点。
这样我们就可以成功的将三个结点链接在一起,实现尾插操作。
下面调用测试文件中的Test函数来验证一下尾插操作,代码如下:
#include "DoubleLinkList.h"
void Test()
{
ListNode* phead = ListInit();
ListPushBack(phead, 1);
ListPrint(phead);
ListPushBack(phead, 2);
ListPrint(phead);
ListPushBack(phead, 3);
ListPrint(phead);
}
int main()
{
Test();
return 0;
}
往链表中尾插元素,每插入一个元素打印一次,运行结果:
可以看到新插入的元素尾插在原链表结点的后面。
2.5 双链表的尾删
实现尾删操作同样要先通过然链表的头结点找到尾结点,然后释放掉尾结点,再将头结点和尾结点的上一个结点链接在一起。
原链表:
尾删后的链表:
尾删的实现代码如下:
void ListPopBack(ListNode* phead)
{
assert(phead);
assert(phead->next != phead);
ListNode* tail = phead->prev;
ListNode* tailPrev = tail->prev;
tailPrev->next = phead;
phead->prev = tailPrev;
free(tail);
tail = NULL;
}
尾删的时候我们不仅需要断言头指针不为空,还要断言链表中不能只有一个头结点。因为双链表本身就自带一个头结点,如果你在尾删的时候只有一个头结点,那就不能再删除了,这样会破坏双链表的基本结构。
实现的代码也是比较简单,定义两个指针,一个指向尾结点,一个指向尾结点的上一个结点。释放掉尾结点之后将头结点和尾结点的上一个结点链接在一起。
接下来调用Test函数来验证尾删的操作,代码如下:
#include "DoubleLinkList.h"
void Test()
{
ListNode* phead = ListInit();
ListPushBack(phead, 1);
ListPushBack(phead, 2);
ListPushBack(phead, 3);
ListPushBack(phead, 4);
ListPushBack(phead, 5);
ListPrint(phead);
ListPopBack(phead);
ListPrint(phead);
ListPopBack(phead);
ListPrint(phead);
ListPopBack(phead);
ListPrint(phead);
}
int main()
{
Test();
return 0;
}
先尾插进五个元素,然后调用尾删函数删除元素,每调用一次尾删函数打印一次双链表观察效果,
运行结果:
可以看到每调用一次尾删函数就会删掉一个双链表的尾结点。
2.6 双链表的头插
双链表的头插就是申请一个新结点,然后将头结点,新结点,头结点的下一个结点三个结点链接在一起。
头插前的链表:
头插后链表:
头插的实现代码如下:
void ListPushFront(ListNode* phead, LTDataType x)
{
assert(phead);
ListNode* first = phead->next;
ListNode* newnode = BuyListNode(x);
phead->next = newnode;
newnode->prev = phead;
newnode->next = first;
first->prev = newnode;
}
定义两个指针,一个指向头结点的下一个结点,一个指向新结点,然后将根据双链表的链接关系将头结点,头结点下一个结点,新结点三个结点链接在一起。
下面调用Test函数来验证一下头插操作:
#include "DoubleLinkList.h"
void Test()
{
ListNode* phead = ListInit();
ListPushBack(phead, 1);
ListPushBack(phead, 2);
ListPushBack(phead, 3);
ListPrint(phead);
ListPushFront(phead, -1);
ListPrint(phead);
ListPushFront(phead, -2);
ListPrint(phead);
ListPushFront(phead, -3);
ListPrint(phead);
}
int main()
{
Test();
return 0;
}
先尾插三个元素,然后调用头插函数每插入一个元素打印一次,
运行结果:
可以看到每调用一次头插函数就会在链表原来头结点前插入一个元素。
2.7 双链表的头删
双链表的头删首先找到头结点的下一个结点,也就是双链表真正的头结点,也是本次我们需要释放的结点。该结点释放完之后再将头结点和头结点下一个结点的下一个结点链接在一起,这样就能实现双链表的头删操作了。
头删前链表:
头删后链表:
头删的代码如下:
void ListPopFront(ListNode* phead)
{
assert(phead);
assert(phead->next != phead);
ListNode* first = phead->next;
ListNode* second = first->next;
phead->next = second;
second->prev = phead;
free(first);
}
首先一开始还是要断言头指针不为空,并且链表不能仅剩一个头结点。
然后定义两个指针一个指向头结点下一个结点,一个指向头结点下一个结点的下一个结点。
接下来释放头结点的下一个结点,根据双链表的链接关系将头结点和头结点下下一个结点链接在一起。
下面调用Test函数来验证一下头删操作,代码如下:
#include "DoubleLinkList.h"
void Test()
{
ListNode* phead = ListInit();
ListPushBack(phead, 1);
ListPushBack(phead, 2);
ListPushBack(phead, 3);
ListPushBack(phead, 4);
ListPushBack(phead, 5);
ListPrint(phead);
ListPopFront(phead);
ListPrint(phead);
ListPopFront(phead);
ListPrint(phead);
ListPopFront(phead);
ListPrint(phead);
}
int main()
{
Test();
return 0;
}
先插入五个元素,然后每调用一次前删函数打印一次观察前删的效果,
运行结果如下:
可以看到每调用一次头删函数就会删掉链表原本的头结点。
2.8 双链表的查找
在链表中查找一个结点和链表的打印十分相似,需要将链表从头结点开始遍历一遍,如果找到要查到的结点,返回该结点的地址,如果找不到返回空指针即可。
查找的实现代码如下:
ListNode* ListFind(ListNode* phead, LTDataType x)
{
assert(phead);
ListNode* cur = phead->next;
while (cur != phead)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
可以看到就是一个简单的链表遍历过程,下面我们调用Test函数来验证一下查找函数,代码如下:
#include "DoubleLinkList.h"
void Test()
{
ListNode* phead = ListInit();
ListPushBack(phead, 1);
ListPushBack(phead, 2);
ListPushBack(phead, 3);
ListPushBack(phead, 4);
ListPushBack(phead, 5);
ListPrint(phead);
ListNode* pos = ListFind(phead, 4);
pos->data = 0;
ListPrint(phead);
}
int main()
{
Test();
return 0;
}
先向链表中插入五个元素,然后调用查找函数找到结点值为4的结点,再将该结点的值改为0,打印链表观察,
运行结果:
可以看到成功找到值为4的结点,并将它的内容改为0.
2.9 双链表在任意位置前插入
介绍单链表的时候我曾经说过,单链表只能往任意位置后插入,这是由于单链表的单向性质,不能通过当前结点找到上一个结点。
而对于双链表结构在插入的时候就能找到当前结点上一个结点,这样就可以很轻松的将新结点,当前结点以及当前结点的上一个结点链接在一起。
任意位置插入的实现代码如下:
void ListInsert(ListNode* pos, LTDataType x)
{
assert(pos);
ListNode* posPrev = pos->prev;
ListNode* newnode = BuyListNode(x);
posPrev->next = newnode;
newnode->prev = posPrev;
newnode->next = pos;
pos->prev = newnode;
}
定义两个指针,一个指向当前结点的上一个结点,一个指向新结点,再根据双链表的链接关系将三个结点链接在一起即可。
下面调用Test函数来验证一下这个这个函数,代码如下:
#include "DoubleLinkList.h"
void Test()
{
ListNode* phead = ListInit();
ListPushBack(phead, 1);
ListPushBack(phead, 2);
ListPushBack(phead, 3);
ListPushBack(phead, 4);
ListPushBack(phead, 5);
ListPrint(phead);
ListNode* pos = ListFind(phead, 4);
ListInsert(pos, 0);
ListPrint(phead);
}
int main()
{
Test();
return 0;
}
先插入五个元素,然后在值为4的结点前插入一个值为0的结点,
运行结果:
可以看到成功将0插入到值为4的结点之前。
2.10 双链表删除任意位置
删除任意位置的结点,找到该结点之后,释放掉该结点,再将该结点的上一个结点和该结点的下一个结点链接在一起。
删除任意位置的代码如下:
void ListErase(ListNode* pos)
{
assert(pos);
ListNode* posPrev = pos->prev;
ListNode* posNext = pos->next;
free(pos);
posPrev->next = posNext;
posNext->prev = posPrev;
}
定义两个指针一个指向当前结点的上一个结点,一个指向当前结点的下一个结点。释放掉当前结点,再根据双链表的指向关系将上一个结点和下一个结点链接在一起。
下面调用Test函数检测删除任意位置函数,代码如下:
#include "DoubleLinkList.h"
void Test()
{
ListNode* phead = ListInit();
ListPushBack(phead, 1);
ListPushBack(phead, 2);
ListPushBack(phead, 3);
ListPushBack(phead, 4);
ListPushBack(phead, 5);
ListPrint(phead);
ListNode* pos = ListFind(phead, 4);
ListErase(pos);
ListPrint(phead);
}
int main()
{
Test();
return 0;
}
先插入5个元素,再找到值为4的结点,调用删除函数将其删掉,
运行结果如下:
可以看到成功将值为4的结点删掉。
2.11 双链表的销毁
现在我们来实现双链表最后一个功能双链表的销毁。
回看一下前面定义的双链表内存结构,双链表的结点内存是用malloc函数在堆上动态开辟出来的,如果用完不释放的话就可能会造成内存泄漏的问题,因此链表使用完之后我们需要释放掉链表的每一个结点。
释放每一个结点只需要遍历一遍整个链表,然后用free函数释放掉链表的每一个结点即可,实现代码如下:
void ListDestory(ListNode** pphead)
{
assert(*pphead);
ListNode* cur = *pphead;
while (cur != *pphead)
{
ListNode* next = cur->next;
free(cur);
cur = next;
}
free(*pphead);
*pphead = NULL;
}
由于释放完头结点之后我们要将头结点的地址值为空指针,所以传参的时候需要传递头指针的地址,用一个二级指针来接收,这样才可以在函数内部改变头指针的值。
遍历链表的时候,由于链表是循环的,所以第一遍遍历的时候需要头结点来作为结束标志。因此我们从头结点的下一个结点开始遍历,释放到头结点的时候结束循环。然后在循环的外部再将头结点释放掉并且置为空指针,这样这个双链表就算销毁完成了。
双链表的所有功能到这里就介绍完了。
3. 顺序表和链表总结
最近的三篇文章中我写了一篇有关顺序表,两篇有关链表,最后我想在这里简单的总结一下顺序表和链表。
3.1 顺序表的优点
- 1.可以随机访问。
- 2.缓存命中率比较高(顺序表在物理空间是连续的,预加载比较有优势)。
3.2 顺序表的缺点
- 中间/头部的插入删除,时间复杂度为O(N)。
- 增容需要申请新的空间,拷贝数据,释放旧空间,会有不小的损耗。
- 增容一般是呈二倍的增长,势必会有一定的空间浪费。例如当前的容量为100,满了以后增容到两百,而我们只插入了5个数据,后面就没有数据的插入了,那么就浪费了95个空间。
3.3 链表的优点
- 1.任意位置插入和删除的时间复杂度为O(1)
- 2.没有增容问题,插入一个开辟一个空间。
3.4 链表的缺点
- 以结点为单位存储不支持随机访问。
到这里你还能说链表比顺序表好,又或者说顺序表强于链表吗?可以看到顺序表和链表是两种互补的结构,链表可以实现顺序表难以实现的功能,同样顺序表也能实现链表不易实现的操作,两种结构都是不可或缺的。在实际应用中,我们应该根据不同的需求选用适合的结构。
本篇文章到这里就结束了,希望能够为大家带来帮助。