【数据结构】双向带头循环链表的实现和优势

【数据结构】双向带头循环链表的实现和优势

  • 前言
  • 一、链表的结构
      • 1.单向或者双向
      • 2.带头不带头
      • 3.循环或者非循环
  • 二、双向带头循环链表及其函数的实现
    • 1.创建双向带头循环链表
    • 2.链表尾插
    • 3.链表尾删
    • 4.链表头插
    • 5.链表头删
    • 6.链表查找特定值的节点
    • 7. 在`pos`位置的前面进行插入
    • 8.删除`pos`位置的结点
    • 9.复用实现头插尾插和头删尾删
  • 总结


前言

继在之前博客“【数据结构】实现顺序表和单链表的创建和增删查改等操作”一文中,实现了单链表的创建和增删查改之后,本文会介绍一种在结构上更为复杂,但在实现增删查改等操作上具有巨大优势的一种新的链表结构——双向带头循环链表


一、链表的结构

实际上,除了结构上最为简单的单链表,链表还有其他结构,可以分为以下三种情况,组合起来一共是八种结构

1.单向或者双向

单向:节点中除了存放的数据外,只存放一个指向下一个节点的指针,整个链表单向连接,只能一直往下走,不能返回上一个节点。
单链表
单链表节点的定义

typedef int SLDatatype;//将int定义为单链表的数据类型
struct SListNode
{
    SLDatatype* data;//单链表的数据域
    SListNode* next;//单链表的指针域:只存放下一个节点的地址
};

双向:与单向链表相比,节点中多存放了一个指向上一个节点的指针,节点之间双向连接,既可以往下一个节点走,又能返回上一个节点。

双向链表

双向链表的节点定义

typedef int DLDatatype;//将int定义为双向链表的数据类型
struct DListNode
{
    DLDatatype data;
    DListNode* prev;//存放上一个节点的地址
    DListNode* next;//存放下一个节点的地址
    
};

2.带头不带头

带头与不带头的区别就是链表是否存在一个哨兵节点,它通常不保存任何数据,其主要目的是使链表标准化,如使链表永不为空、永不无头、简化插入和删除。

带头链表
【数据结构】双向带头循环链表的实现和优势_第1张图片

不带头链表
【数据结构】双向带头循环链表的实现和优势_第2张图片
在我们的代码中通过增加哨兵节点往往能够简化边界条件,从而防止对特殊条件的判断,使代码更为简便优雅,在链表中应用最为典型。

3.循环或者非循环

将单链表中尾结点的指针域由空指针改为头结点,就使整个单链表形成了一个环,这种头尾相接的单链表称为循环链表。
同理,在双向链表中,尾结点指向下一个节点的指针就应指向链表的头结点,而头结点指向上一个节点的指针就应该指向尾结点。

将以上三种情况进行排列组合的话,我们其实能够得到2^3,即八种不同的链表结构,但我们今天着重讲解的带头双向循环链表,一种结构上复杂,但在代码实现上十分方便的链表结构。
【数据结构】双向带头循环链表的实现和优势_第3张图片

二、双向带头循环链表及其函数的实现

接下来是实现双向带头循环链表的创建和增删查改等函数的实现,通过下面的代码,就可以深刻体会到双向带头循环链表在实现这些操作上的优势,十分方便简洁,很多单链表要花费很大力气才能解决的难题,在双向带头循环链表面前迎刃而解。

1.创建双向带头循环链表

注意:当双向带头循环链表为空时,此时仍是存在一个哨兵节点,这个哨兵节点的存在,就是为了方便我们的插入和删除节点,避免对一些特殊情况单独处理,并且因为是循环链表,哨兵节点存放的两个指针也应该指向自己。
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;//返回头结点
}

上面代码创建了一个存储无效数据并且节点中两个指针都存放自身地址的节点,那我们一个空的双向带头循环链表也就创建好了。


2.链表尾插

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指针指向新节点
}

对比单链表的尾插,单链表每次尾插都要遍历一遍链表来找到尾结点,然后再将新的节点链接在尾结点的后面,比较麻烦。
但对于双向带头循环链表来说,头结点的上一个节点就是尾结点,省去了遍历链表的麻烦,直接插入新节点即可,十分方便高效。
同时,又因为带了哨兵节点,第一次尾插时,避免了头结点指针为空的情况,可直接尾插,不用改变头结点指针。


3.链表尾删

同样地,双向循环链表的尾删也免去了寻找尾结点的过程,可以直接通过头结点找到尾结点,同时也能很快找到尾结点的上一个节点,将其变成新的尾结点。

代码如下:

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
}

如果单链表进行尾删,我们需要创建两个指针变量通过遍历链表来寻找链表的尾结点和尾结点的上一个节点,而双向循环链表只需要两行代码就能搞定,而且双向循环链表的优势不仅如此,在尾删多次后,链表为空,即只存在哨兵节点的时候,上面的程序也能成功实现,而不用像单链表一样,对链表的为空的特殊情况进行单独处理

注意:带头双向循环链表需要时刻注意的是,第一每次尾删之后,要将新的尾结点与头结点连接起来,形成新的双向循环链表
第二警惕尾删多次之后把哨兵节点也删除了。当链表为空的时候,并不是说将所有节点包括哨兵节点也一并删除,而是此时仍然应保留哨兵节点,所以要加上断言,不能将哨兵节点也删除。


4.链表头插

单链表的头插和带头双向循环链表的头插差别不大,唯一的区别可能就是单链表的头插会改变头指针,需要传递二级指针,而双向带头循环链表则不用,其头指针永远是指向哨兵节点的
代码如下:

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;

}

5.链表头删

单链表的优势就在于头删头插的效率较高,而带头双向循环链表显然也具有这种优势。
代码如下:

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;
   
}

注意:同尾删一样,头删一样要考虑到不能将哨兵节点删除,并且时刻注意新的尾结点和头结点的链接关系。


6.链表查找特定值的节点

此处,采用查找数据为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就把链表中存储有效数据的节点都走了一遍,如果在这个过程中找到了对应的节点,就会返回该节点的指针。

7. 在pos位置的前面进行插入

双向循环链表在某个节点pospos为该节点的指针)的前面插入新节点是非常方便快速的,能直接通过该节点找到其上一个节点,此时要做的就是将新节点插入到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的上一个节点,才能进行插入操作。相比之下,双向循环链表就十分好用。

8.删除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指针的时候,就要避免传入头指针,不能将带头双向循环链表的头结点删除。

9.复用实现头插尾插和头删尾删

其实,在实现了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位置节点这两个函数,通过复用快速实现头插头删和尾插尾删,代码高效简洁,又能完美实现以上功能。


总结

  1. 双向带头循环链表为空的时候,仍然存在一个哨兵节点,该节点存放无效数据,指针域存放自身地址,它存在的主要目的是使链表标准化,如使链表永不为空、永不无头、简化插入和删除,简化边界条件,从而防止对特殊条件的判断。
  2. 链表结构一共有8种,但一般常用单向无头不循环单链表)和双向带头循环链表,前者结构简单,但实现增删查改不方便,效率低;后者结构复杂,实现各种功能十分便捷高效。
  3. 单链表头插头删效率高,实现其他功能就效率低下,而双向带头循环链表无论是头插头删、尾插尾删还是在删除指定节点和在指定节点前插入的效率都远高于单链表。
  4. 通过复用pos位置前面插入新节点删除pos位置节点这两个函数,能快速实现链表的头插尾插和头删尾删,极大地减少了代码量,简洁高效。

你可能感兴趣的:(链表,数据结构,c语言)