继在之前博客“【数据结构】实现顺序表和单链表的创建和增删查改等操作”一文中,实现了单链表的创建和增删查改之后,本文会介绍一种在结构上更为复杂,但在实现增删查改等操作上具有巨大优势的一种新的链表结构——双向带头循环链表
实际上,除了结构上最为简单的单链表,链表还有其他结构,可以分为以下三种情况
,组合起来一共是八种结构
。
单向
:节点中除了存放的数据外,只存放一个指向下一个节点的指针,整个链表单向连接,只能一直往下走,不能返回上一个节点。
单链表节点的定义
:
typedef int SLDatatype;//将int定义为单链表的数据类型
struct SListNode
{
SLDatatype* data;//单链表的数据域
SListNode* next;//单链表的指针域:只存放下一个节点的地址
};
双向
:与单向链表相比,节点中多存放了一个指向上一个节点的指针,节点之间双向连接,既可以往下一个节点走,又能返回上一个节点。
双向链表的节点定义
:
typedef int DLDatatype;//将int定义为双向链表的数据类型
struct DListNode
{
DLDatatype data;
DListNode* prev;//存放上一个节点的地址
DListNode* next;//存放下一个节点的地址
};
带头与不带头的区别就是链表是否存在一个哨兵节点,它通常不保存任何数据,其主要目的是使链表标准化,如使链表永不为空、永不无头、简化插入和删除。
不带头链表
在我们的代码中通过增加哨兵节点往往能够简化边界条件,从而防止对特殊条件的判断,使代码更为简便优雅,在链表中应用最为典型。
将单链表中尾结点的指针域由空指针改为头结点,就使整个单链表形成了一个环,这种头尾相接的单链表称为循环链表。
同理,在双向链表中,尾结点指向下一个节点的指针就应指向链表的头结点,而头结点指向上一个节点的指针就应该指向尾结点。
将以上三种情况进行排列组合的话,我们其实能够得到2^3
,即八种不同的链表结构,但我们今天着重讲解的带头双向循环链表,一种结构上复杂,但在代码实现上十分方便的链表结构。
接下来是实现双向带头循环链表的创建和增删查改等函数的实现,通过下面的代码,就可以深刻体会到双向带头循环链表在实现这些操作上的优势,十分方便简洁,很多单链表要花费很大力气才能解决的难题,在双向带头循环链表面前迎刃而解。
注意:当双向带头循环链表为空时,此时仍是存在一个哨兵节点,这个哨兵节点的存在,就是为了方便我们的插入和删除节点,避免对一些特殊情况单独处理,并且因为是循环链表,哨兵节点存放的两个指针也应该指向自己。
typedef int LTDataType;//将int定义为链表的数据类型
typedef struct ListNode
{
LTDataType data;
struct ListNode* next;//指向下一个节点的指针
struct ListNode* prev;//指向上一个节点的指针
}ListNode;
ListNode* ListCreate()
{
//创建一个哨兵节点作为头结点
ListNode* head = (ListNode*)malloc(sizeof(ListNode));
//哨兵节点存放的数据设置为0,无效数据
head->data = 0;
//哨兵节点的指针域都要指向自己
head->prev = head;
head->next = head;
return head;//返回头结点
}
上面代码创建了一个存储无效数据并且节点中两个指针都存放自身地址的节点,那我们一个空的双向带头循环链表也就创建好了。
void ListPushBack(ListNode* plist, LTDataType x)
{
assert(plist);//防止误用,传入空指针
//plist是头结点指针,x是插入的新节点的数据
ListNode* tail = plist->prev;//找到尾结点tail
//创建新节点newnode
ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
tail->next = newnode;//将新节点连接在尾结点的后面
//给新结点的指针域和数据域赋值
newnode->data = x;
newnode->next = plist;//新节点的next指针指向头结点
newnode->prev = tail;//新节点的prev指针指向尾结点
plist->prev = newnode;//头结点的prev指针指向新节点
}
对比单链表的尾插,单链表每次尾插都要遍历一遍链表来找到尾结点,然后再将新的节点链接在尾结点的后面,比较麻烦。
但对于双向带头循环链表来说,头结点的上一个节点就是尾结点,省去了遍历链表的麻烦,直接插入新节点即可,十分方便高效。
同时,又因为带了哨兵节点,第一次尾插时,避免了头结点指针为空的情况,可直接尾插,不用改变头结点指针。
同样地,双向循环链表的尾删也免去了寻找尾结点的过程,可以直接通过头结点找到尾结点,同时也能很快找到尾结点的上一个节点,将其变成新的尾结点。
代码如下:
void ListPopBack(ListNode* plist)
{
assert(plist);//避免空指针传入
assert(plist->next!=plist)//避免将哨兵节点也删除
ListNode* tail = plist->prev;//找到尾结点tial
ListNode* tailprev = tail->prev;//记录尾结点的上一个节点tialprev
//释放尾结点并将尾指针置空
free(tail);
tail = NULL;
tailprev->next = plist;//新的尾结点的next指针指向头结点
plist->prev = tailprev;//头结点的prev指针指向新的尾结点tailprev
}
如果单链表进行尾删,我们需要创建两个指针变量通过遍历链表来寻找链表的尾结点和尾结点的上一个节点,而双向循环链表只需要两行代码就能搞定
,而且双向循环链表的优势不仅如此,在尾删多次后,链表为空,即只存在哨兵节点的时候,上面的程序也能成功实现,而不用像单链表一样,对链表的为空的特殊情况进行单独处理
。
注意:带头双向循环链表需要时刻注意的是,第一,每次尾删之后,要将新的尾结点与头结点连接起来,形成新的双向循环链表。
第二,警惕尾删多次之后把哨兵节点也删除了。当链表为空的时候,并不是说将所有节点包括哨兵节点也一并删除,而是此时仍然应保留哨兵节点,所以要加上断言,不能将哨兵节点也删除。
单链表的头插和带头双向循环链表的头插差别不大,唯一的区别可能就是单链表的头插会改变头指针,需要传递二级指针,而双向带头循环链表则不用,其头指针永远是指向哨兵节点的。
代码如下:
void ListPushFront(ListNode* plist, LTDataType x)
{
//创建新节点newnode并给数据域赋值
ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
newnode->data = x;
ListNode* next = plist->next;//记录头结点的下一个节点next
//新节点的下一个节点为next,上一个节点为头结点plist
newnode->next = next;
newnode->prev = plist;
//头结点的next指针指向新节点
plist->next = next;
//next的prev指针指向新节点
next->prev = newnode;
}
单链表的优势就在于头删头插的效率较高,而带头双向循环链表显然也具有这种优势。
代码如下:
void ListPopFront(ListNode* plist)
{
assert(plist);//防止传入空指针
assert(plist->next != plist);//防止哨兵节点被删除
ListNode* next = plist->next;//记录头结点的下一个节点next
ListNode* Nextnext = plist->next->next;//记录next的下一个节点Nextnext
//将next释放并将其置空
free(next);
next = NULL;
//头结点与Nextnext节点创建新的双向链接关系
plist->next = Nextnext;
Nextnext->prev = plist;
}
注意:同尾删一样,头删一样要考虑到不能将哨兵节点删除,并且时刻注意新的尾结点和头结点的链接关系。
此处,采用查找数据为x
的节点并返回节点指针的方式,实现链表的查找。
代码如下:
ListNode* ListFind(ListNode* plist, LTDataType x)
{
ListNode* cur = plist->next;//cur为头结点的下一个节点指针
//用cur去遍历链表寻找值为x的节点
while (cur != plist)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
}
注意:用cur指针去遍历链表时,
cur
要将除了哨兵节点之外的节点都遍历一遍,所以当cur回到头结点的时候,cur
就把链表中存储有效数据的节点都走了一遍,如果在这个过程中找到了对应的节点,就会返回该节点的指针。
pos
位置的前面进行插入双向循环链表在某个节点pos
(pos
为该节点的指针)的前面插入新节点是非常方便快速的,能直接通过该节点找到其上一个节点,此时要做的就是将新节点插入到pos
节点的上一个节点和pos
节点之间即可。
代码如下:
void ListInsert(ListNode* pos, LTDataType x)
{
ListNode* prev = pos->prev;//记录pos位置的上一个节点prev
//创建新节点newnode并赋值
ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
newnode->data = x;
//将prev、newnode、pos三个节点双向连接起来
prev->next = newnode;
newnode->next = pos;
pos->prev = newnode;
newnode->prev = prev;
}
再对比单链表,即使已经有了pos
位置节点的指针,想要在pos
位置之前插入新节点,也必须要有pos
位置上一个节点的指针,此时只有通过两个指针来再遍历一遍链表寻找到pos
节点,并记录pos
的上一个节点,才能进行插入操作。相比之下,双向循环链表就十分好用。
pos
位置的结点同理,删除pos
位置的节点也需要其上节点和下节点的指针,对这两个节点创建新的链接关系,而无疑,单链表又是需要繁琐的操作来寻找上节点,而双向循环链表又是一如既往地简单,一行代码便能记录上节点指针。
代码如下:
void ListErase(ListNode* pos)
{
//记录pos位置的上下节点prev、next
ListNode* prev = pos->prev;
ListNode* next = pos->next;
//释放掉pos节点并置空
free(pos);
pos = NULL;
//将next和prev双向连接
prev->next = next;
next->prev = prev;
}
注意:传入
pos
指针的时候,就要避免传入头指针,不能将带头双向循环链表的头结点删除。
其实,在实现了在pos
位置前面插入新节点和删除pos
位置节点这两个函数之后,我们可以对这两个函数进行复用,比如在头插尾插和头删尾删中根据需要调用这两个函数,就能实现头插尾插和头删尾删,而不用单独写一份代码。
就拿复用在pos
位置前面插入新节点来说,如果要实现头插,对于带头双向循环链表来说,链表的头实际上是哨兵节点的下一个节点,那我们其实只需要将新节点插入到这个节点之前,那么新节点就成了新的头,就实现了头插。
实现尾插,也是同样的思路,要将新节点插入到尾结点的后面,而尾结点的下一个节点其实是哨兵节点,那就意味着我们只要将新节点插入到哨兵节点的前面就能实现尾插。
头插尾插
代码如下:
//复用实现头插
void ListPushFront(ListNode* plist, LTDataType x)
{
assert(plist);
//在头结点的下一个节点之前插入值为x的节点,即头插
ListInsert(plist->next, x);
}
//复用实现尾插
void ListPushBack(ListNode* plist, LTDataType x)
{
assert(plist);
//在头结点之前插入值为x的节点,即尾插
ListInsert(plist, x);
}
同样的思路,删除pos
位置的节点,也能很轻松地实现头删尾删,如果是头删,实际删除的是头结点的下一个节点,那就把该节点的地址传给函数,删除该节点,如果是尾删,就把尾结点的地址传给函数,删除尾结点。
头删尾删
代码如下:
//复用实现头删
void ListPopFront(ListNode* plist)
{
assert(plist);//防止传入空指针
assert(plist->next != plist);//防止哨兵节点被删除
ListErase(plist->next);//删除头结点的下一个节点,即头删
}
//复用实现尾删
void ListPopBack(ListNode* plist)
{
assert(plist);//防止传入空指针
assert(plist->next != plist);//防止哨兵节点被删除
ListErase(plist->prev);//删除尾结点,即尾删
}
我们可以只实现在pos
位置前面插入新节点和删除pos
位置节点这两个函数,通过复用快速实现头插头删和尾插尾删,代码高效简洁,又能完美实现以上功能。
8
种,但一般常用单向无头不循环(单链表)和双向带头循环链表,前者结构简单,但实现增删查改不方便,效率低;后者结构复杂,实现各种功能十分便捷高效。pos
位置前面插入新节点和删除pos
位置节点这两个函数,能快速实现链表的头插尾插和头删尾删,极大地减少了代码量,简洁高效。