在前面的博客中,我们学习了最简单的链表类型——单向、不带哨兵位、不循环,今天我们要来学习的是具有链表中最复杂的结构类型——双向、带哨兵位、循环的链表。我们先来看一下两者的结构示意图。
注:头和哨兵位为同一个东西,下面均以哨兵位称呼。
从图中我们不难发现,两个链表的结构简直是天差地别,第二种比第一种复杂太多了,那么第二种的实现同样会比第一种的实现难上很多吗?答案是否定的,虽然第二种的结构更加复杂,但是它的结构可以说是链表所有结构类型中最优秀的, 它的实现也是链表所有结构类型中最容易完成的,如果你不相信,通过下面的学习,你会发现它的优秀之处。
#define ListDataType int
typedef struct ListNode
{
struct ListNode* prev;
ListDataType data;
struct ListNode* next;
}ListNode;
双向链表的节点比单链表的节点多定义了一个prev指针,指向该节点前面的节点(如果是哨兵位则指向链表的最后的一个节点,达到循环的效果),别小看这多出来的一个prev,正是因为它才让双向链表比单链表更加优秀,我们可以通过prev直接找到该节点的上一个节点而不用再通过遍历的方式来寻找,这直接节省了尾插、尾删等的时间消耗。、
因为我们的双向链表带有哨兵位,那么我们就有必要通过初始化来为链表创造一个哨兵位。
ListNode* ListInit(void)
{
ListNode* SentinelNode = (ListNode*)malloc(sizeof(ListNode));
if (SentinelNode == NULL)
{
perror("malloc");
exit(-1);
}
//开始时,哨兵位的next和prev都指向自己
SentinelNode->next = SentinelNode;
SentinelNode->prev = SentinelNode;
return SentinelNode;
}
哨兵位内不存储有效数据,只是单纯的方便对链表进行操作,哨兵位的存在可以帮助我们来减少一些特殊情况的判断,如头插、尾插的时候不用再判断链表是否为空来决定是否要改变plist(指向链表头的指针)的指向了,并且让链表传入接口时不用再传入二级指针了。
在头插、尾插等多处我们都需要创造新节点,所以我们可以单独将创造节点的功能分割出来封装成一个函数,这样使用就更加方便,程序也不会显得很臃肿。
static ListNode* ListNodeCreat(const ListDataType val)
{
ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
if (newnode == NULL)
{
perror("malloc");
exit(-1);
}
newnode->data = val;
newnode->next = NULL;
newnode->prev = NULL;
return newnode;
}
void ListPushBack(ListNode* plist, const ListDataType val)
{
assert(plist);
ListNode* newnode = ListNodeCreat(val);
ListNode* tail = plist->prev;
tail->next = newnode;
newnode->prev = tail;
plist->prev = newnode;
newnode->next = plist;
}
有了prev我们再也不需要通过遍历找尾了,因为plist->prev就是我们需要找的尾,这是双向且循环给我们带来的好处,找到尾后的插入就很简单了。
void ListPopBack(ListNode* plist)
{
assert(plist);
//判断链表是否为空(不算哨兵位)
assert(plist->next != plist);
ListNode* tail = plist->prev;
ListNode* TailPrev = tail->prev;
free(tail);
tail = NULL;
TailPrev->next = plist;
plist->prev = TailPrev;
}
找到尾后的尾删同样简单,不过不要忘记判断链表内是否有节点可删(哨兵位不可删)。我们可以通过定义多个变量来方便操作,不要担心多定义变量的消耗,这几个变量对内存的消耗都是常数级的,只要不是那种内存有严格限制的机器都不用担心这些内存的消耗。
void ListPushFront(ListNode* plist, const ListDataType val)
{
assert(plist);
ListNode* newnode = ListNodeCreat(val);
ListNode* HeadNext = plist->next;
plist->next = newnode;
newnode->prev = plist;
newnode->next = HeadNext;
HeadNext->prev = newnode;
}
哨兵位的存在不用让我们不用再判断是否要改plist,这样我们就不需要再针对特殊情况再单独写一种处理方法,也不用再传入二级指针。
void ListPopFront(ListNode* plist)
{
assert(plist);
assert(plist->next != plist);
ListNode* HeadNode = plist->next;
ListNode* HeadNodeNext = HeadNode->next;
free(HeadNode);
HeadNode = NULL;
plist->next = HeadNodeNext;
HeadNodeNext->prev = plist;
}
我们要记住,我们说的头都是不包括哨兵位的时候的头节点,即哨兵位的下一个节点。
void ListPrint(const ListNode* plist)
{
assert(plist);
ListNode* cur = plist->next;
while (cur != plist)
{
printf("%d ", cur->data);
cur = cur->next;
}
printf("\n");
}
因为哨兵位内不存储有效数据,所以我们打印链表都是从哨兵位的后一个节点开始向后遍历,然后当cur通过循环结构回到哨兵位时停止打印循环。
ListNode* ListFind(const ListNode* plist, const ListDataType val)
{
assert(plist);
ListNode* cur = plist->next;
while (cur != plist)
{
if (cur->data == val)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
查找的思路与打印的思路很像,都是从哨兵位的后一个节点开始到哨兵位结束,找到了就返回目标节点的地址,没找就返回NULL。
在我们实现无头单向不循环链表时,在目标节点前插入节点相当麻烦,但是在我们现在这个非常优秀的结构下,这个接口的实现不过是小菜一碟。
void ListInsert(ListNode* pos, const ListDataType val)
{
assert(pos);
ListNode* newnode = ListNodeCreat(val);
ListNode* PosPrev = pos->prev;
PosPrev->next = newnode;
newnode->prev = PosPrev;
newnode->next = pos;
pos->prev = newnode;
}
我们现在短短数行就完成了当初需要许多麻烦处理的接口,这难道不能说明这个结构的优秀吗?
void ListErase(ListNode* pos)
{
assert(pos);
ListNode* PosPrev = pos->prev;
ListNode* PosNext = pos->next;
free(pos);
pos = NULL;
PosPrev->next = PosNext;
PosNext->prev = PosPrev;
}
我们销毁链表同样从哨兵位的后一个节点开始,等到其他节点都销毁完了后再来销毁哨兵位。
void Listdestroy(ListNode* plist)
{
assert(plist);
ListNode* cur = plist->next;
while (cur != plist)
{
ListNode* next = cur->next;
free(cur);
cur = next;
}
free(plist);
plist = NULL;
}
因为我们没有传入二级指针,所以我们没法对函数外的plist实参做出更改,这就要求使用者在销毁链表后额外将plist设置为空指针来防止野指针的出现。
当然我们也可以传入二级指针然后在该接口中就完成plist的置空,但是为了接口的一致性(其他的接口都传入一级指针,总不应该就只有这个接口传入二级指针吧?),所以我们可以将这个工作交给使用者完成,就如C语言库中的free()函数。
我们在完成在特定位置前的插入和特定位置的删除后,就可以通过服用将这两个接口i将其他的插入删除接口全部替换。
//尾插
ListInsert(plist, val);
//尾删
ListErase(plist->prev);
//头插
ListInsert(plist->next, val);
//头删
ListErase(plist->next);
根据上面的学习我们可以看出,带头双向循环链表的结构给我们实现相关接口带来了很大的便利,这是一个非常有用的结构,值得我们来深入学习并运用。
以上为我个人学习过程中的认识与思考,如有错误还请指正。