前言
书接上回,双链表便是集齐带头、双向、循环等几乎所有元素的单链表PLUS.
1.初始化、创建双链表
typedef int LTDataType;
typedef struct LTNode {LTDataType data;struct LTNode* next;struct LTNode* prev;
}LTNode;
不同于单链表,此时每个节点应当包含两个指针,一个指向前,一个指向后。
任然将创建节点和初始化双链表封装成两个函数
LTNode* LTBuyNode(LTDataType x) {LTNode* phead = (LTNode*)malloc(sizeof(LTNode));if (phead == NULL) {perror("malloc fail!");exit(1);}phead->data = x;phead->next = phead->prev = NULL;
}LTNode* LTInit() {LTNode* phead = LTBuyNode(-1);phead->next = phead;phead->prev = phead;return phead;
}
LTInit步骤中,phead便是我们的哨兵位,可以不予其data赋值,也可以赋予一个不太可能成为数据的值。但是我们需要将他的next指针和prev指针分别指向下一个节点(目前是他自己)和上一个节点(目前也是他自己),这样就形成了双链表的雏形
2.插入接口
2.1尾插
void LTNodePushBack(LTNode* phead,LTDataType x) {assert(phead);LTNode* newnode = LTBuyNode(x);//开始调整各个指针指向phead->next = newnode;
}
请各位稍加思考,开始调整指针的第一句对吗?
错了!我们认为此时的双链表只有一个头结点,所以尾差应该插在哨兵位后面,但我们函数的目的是适用于所有的尾插,这便是惯性思维 带来的错误。写各种功能函数时,提前构思出各种情况固然是好事,但对于我们新手与初学者而言,先在脑海中的普通且简答的情况下写出接口,再根据各个特殊情况调整才更加合适。
那我们还需要遍历链表找尾节点吗?
答案是否定的,由于循环链表的缘故,我们可以从头结点(哨兵位)找到现在的尾节点,也就是phead->prev
void LTNodePushBack(LTNode* phead,LTDataType x) {assert(phead);LTNode* newnode = LTBuyNode(x);//开始调整各个指针指向newnode->prev = phead->prev;newnode->next = phead;//先修改新节点的元素的指向,此时不会导致任何节点丢失phead->prev->next = newnode;phead->prev = newnode;
}
打印函数封装如下:
void LTNodePrint(LTNode* phead) {assert(phead);LTNode* pcur = phead->next;while (pcur != phead) {printf("%d->", pcur->data);pcur = pcur->next;}printf("\n");
}
不同于前面的单链表,此处我们没有再使用二级指针,原因如下:
当链表中只有哨兵位节点时,我们称链表为空链表,无论如何,我们不应该删除的哨兵位。
所以,不同于单链表,双链表一般情况不需要传二级指针
单链表很多时候设计修改自己的地址,所以需要使用二级指针,而双链表大多数可以直接通过一级指针修改指针指向的内容,不需要使用二级指针。
但比如说,实现删除链表的接口,此时就可以哨兵位的二级指针,因为涉及到修改、删除哨兵位。
不过一级指针也可以使用(只是最后需要手动置NULL),但是可以保证接口一致性,接口一致性能降低客户的使用成本。
再补充一个博主修改双链表指针指向的思路:
1.首先修改要插入节点的本身元素指针的指向。
2.再修改待插入元素的前驱和后驱的指针指向。
2.2头插
头插是在第一个有效节点之前插入数据,而不是在哨兵位之前插入。哨兵位之前插入数据和尾差无异。尾差才是在最后一个有效节点之后插入数据/哨兵位之前插入数据
void LTNodePushInfront(LTNode* phead, LTDataType x) {assert(phead);LTNode* newnode = LTBuyNode(x);newnode->next = phead->next;newnode->prev = phead;phead->next->prev = newnode;phead->next = newnode;
}
赋值思路依然如上:先给newnode的next和prev赋值,此时这样操作不会影响任何人,再依次改变前驱和后驱节点的指针指向
3.删除接口
3.1尾删
除了断言哨兵位是否为空,还要断言phead->next!=phead(只剩一个哨兵位也叫空链表,不能再进行删除操作),头删也是这个道理。
void LTNodePopBack(LTNode* phead) {assert(phead);assert(phead != phead->next);LTNode* ptail = phead->prev;ptail->prev->next = phead;phead->prev = ptail -> prev;free(ptail);
}
感觉到指针指向较多怕丢失时,也可以像上面这样定义一个新变量记录地址,也更加容易理解。
3.2头删
同理,为了不造成空间浪费,我们仍然定义一个新变量来记录想删除的第一个有效节点,方便使用free函数。
void LTNodePopInfront(LTNode* phead) {assert(phead);assert(phead != phead->next);phead->next->next->prev = phead;LTNode* del = phead->next;phead->next = phead->next->next;free(del);del = NULL;
}
4.指定位置的操作
4.1查找接口
为了便于获得指定位置的操作的实参,我们实现一个查找函数。
LTNode* LTFind(LTNode* phead, LTDataType x) {assert(phead);LTNode* pcur = phead->next;for (; pcur != phead; pcur = pcur->next) {if (pcur->data == x) {return pcur;}}printf("find LTData Failed!");return NULL;
}
4.3指定位置之后插入数据
void LTInsert(LTNode* pos, LTDataType x) {assert(pos);LTNode* newnode = LTBuyNode(x);newnode->prev = pos;newnode->next = pos->next;//先完成newnode的赋值pos->next->prev = newnode;pos->next = newnode;
}
4.4删除指定位置的节点
理不清楚关系就定义新变量,思路一下就简化了
void LTErase(LTNode* pos) {assert(pos);LTNode* prev = pos->prev;LTNode* next = pos->next;prev->next = next;next->prev = prev;free(pos);
}
最后全部的测试的通过了。
5.链表与顺序表的比较和数据结构小结
我们已经学习了两种类型的数据结构,下面进行小结
数据结构是与数据库/文件等价的一门课程,在高校中这两种管理方式也多以单独的课程开放。
那么就我们学习过的顺序表和链表两种结构而言,孰优孰劣呢?
顺序表:
(所谓随机访问并不是真的表示随机,而是说我想访问哪都可以直接访问)
链表(一般不说单链表,而说功能齐全的双链表)
就红字内容,我们再稍微简略的展开说说:
cpu是不会直接从内存中拿取数据的(速度:寄存器>缓存>内存>硬盘),一般情况都是从缓存中拿取数据(数据量小的时候寄存器也可以直接拿数据)。
大部分情况下,如果缓存中有数据,cpu就可以直接“命中”,没有就不命中,先从内存加载到缓存中再命中。
由局部性原理,cpu会一次性的直接去拿一定体量的连续数据(由硬件性质决定)。
而由于顺序表是连续的,比如下图,第一次没能命中,由于已经加载了没有命中的指针所指向的数据及其后面空间的数据,之后都能直接命中,而对于空间不连续的链表,大概率情况下是不会继续命中的,每一次都会经历从内存加载到缓存的过程,降低效率。甚至有可能造成缓存污染,也cpu一次性能装的数据有限,很多有用的数据可能被无效的节点之后的空间挤掉,造成污染。
过程如下:
(cache line为缓存)
在cache line的话直接命中,较高效
不在的话就不命中,去内存中找(比如顺序表是连续的内存,就会很方便找)。
6.小结
存在即合理,在当顺序表和链表没有其他接口的影响时,顺序表的查找会更快。
充分的理解各种数据结构,手撕各种数据结构才能在以后的学习中更方便选型。可参考:
与程序员相关的CPU缓存知识 | 酷 壳 - CoolShell