目录
单链表:
1.单链表概念:
2.单链表的实现:
1.单链表的打印与销毁:
2.单链表的头插尾插:
3.单链表的头删尾删:
4.单链表的查找与更改:
5.单链表的任意位置插入删除:
6.测试用例及注意事项:
双链表:
1.与单链表的比较:
2.双链表的实现:
1.双链表的初始化、打印与销毁:
2.双链表的尾插尾删:
3.双链表的头插头删:
4.双链表的节点查找与任意位置插入删除:
5.关于头插尾插头删尾删的简化:
6:测试用例:
链表与顺序表的比较:
每一个节点的地址是没有关联的,是随机的,一个存值和下一个的地址,最后一个结点指向NULL。
单链表是多次申请空间,每一个都是独立的,可以单独free掉一块空间。
头文件的定义:
typedef int SLNDataType;
typedef struct SListNode
{
SLNDataType val;
struct SListNode* next;
}SLNode;
// 链表的打印与销毁
void SLTPrint(SLNode* phead);
void SLTDestroy(SLNode** pphead);
// 头插尾插
void SLTPushBack(SLNode** pphead, SLNDataType x);
void SLTPushFront(SLNode** pphead, SLNDataType x);
// 头删尾删
void SLTPopBack(SLNode** pphead);
void SLTPopFront(SLNode** pphead);
// 查找与更改
SLNode* SLTFind(SLNode* phead, SLNDataType x);
void SLTCHanGe(SLNode** phead, SLNDataType x);
// 在pos位置插入
void SLTInsert(SLNode** pphead, SLNode* pos, SLNDataType x);
// 删除pos位置
void SLTErase(SLNode** pphead, SLNode* pos);
打印:
定义一个cur指针指向头节点,然后通过循坏遍历链表,打印val的值,直到cur指向的next节点不为NULL就代表这个链表走到尾了。
销毁:
在销毁链表时可以创建一个cur指针指向头节点,然后遍历链表,分别free掉链表的结点即可,注意在free掉cur指向的节点时要确保cur指针能正确指向cur的下一个节点。
// 打印
void SLTPrint(SLNode* phead)
{
SLNode* cur = phead;
while (cur != NULL)
{
printf("%d->", cur->val);
cur = cur->next;
}
printf("NULL\n");
}
// 销毁
void SLTDestroy(SLNode** pphead)
{
assert(pphead);
SLNode* cur = *pphead;
while (cur)
{
SLNode* next = cur->next;
free(cur);
cur = next;
}
*pphead = NULL;
}
在进行插入操作的时候可以单独将开辟新节点封装成一个函数,在需要的时候直接调用即可。
尾插:
在进行单链表的尾插时分两种情况,一是如果链表为空,则新插入的节点就定为头节点。如果链表不为空,则需要定义一个cur指针指向头节点然后遍历链表找到尾并进行尾插。
头插:
在进行单链表的头插时只需要将开辟好的新节点的next指向链表的头节点,再将指向链表头节点的指针更改为指向新节点即可。
// 开辟新节点
SLNode* CreateNode(SLNDataType x)
{
SLNode* newnode = (SLNode*)malloc(sizeof(SLNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(-1);
}
newnode->val = x;
newnode->next = NULL;
return newnode;
}
// 尾插
void SLTPushBack(SLNode** pphead, SLNDataType x)
{
SLNode* newnode = CreateNode(x);
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
// 遍历找尾
SLNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
}
// 头插
void SLTPushFront(SLNode** pphead, SLNDataType x)
{
SLNode* newnode = CreateNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
尾删:
在进行单链表的尾删时要确保链表内有元素才能进行删除,如果链表中只有一个元素,那就不需要创建新的指针去找尾节点,直接free头节点再将头节点置空即可,如果链表中有多个元素那么需要创建新的指针去找尾节点,然后free掉尾节点。
头删:
在进行单链表的头删时同样要确保链表内有元素才能进行删除,创建一个指向头节点的指针,然后将原来指向头节点的指针指向头节点的下一个节点,再free掉新创建的指针即可。
// 尾删
void SLTPopBack(SLNode** pphead)
{
assert(*pphead);
assert(pphead);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SLNode* tail = *pphead;
while (tail->next->next != NULL)
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
// 头删
void SLTPopFront(SLNode** pphead)
{
assert(*pphead);
assert(pphead);
SLNode* tmp = *pphead;
*pphead = (*pphead)->next;
free(tmp);
tmp = NULL;
}
查找:
在进行单链表的查找时可以定义一个指向头节点的指针cur,然后遍历链表,如果找到匹配的值就返回这一个节点,如果找不到,那就返回NULL,但是这种方法在面对链表存储了多个对应的值时只能返回第一个匹配的节点。
更改:
在进行对链表的节点更改的时候,需要借助查找函数SLTFind,返回一个指向需要更改的节点的指针,然后对其进行更改即可。
// 查找
SLNode* SLTFind(SLNode* phead, SLNDataType x)
{
SLNode* cur = phead;
while (cur)
{
if (cur->val == x) //存取多个值的时候,只能返回第一个结点
{
return cur;
}
cur = cur->next;
}
return NULL;
}
// 更改
void SLTCHanGe(SLNode** Node, SLNDataType x)
{
SLNode* pos = *Node;
pos->val = x;
}
首先对于链表任意位置的插入删除要严格限定该位置是链表中的有效节点,所以都要用assert去对pos位置进行断言。其次该函数需要搭配STLFind函数实行,通过STLFind函数确保pos是链表中的一个有效节点。
这里的任意位置插入实现的是在pos位置之前进行插入,在pos位置之后插入在实现上其实都差不多。删除实现的是删除pos节点。
插入:
在进行单链表pos节点前的插入可以分为两层,如果链表只有一个节点,那么pos位置之前的插入实际上就是链表的头插,可以直接复用前面的头插函数。如果链表有多个节点,可以直接定义一个指向头节点的指针cur,然后遍历链表找到pos节点,将pos节点前一个节点的next指向新插入的节点,新插入的节点的next指向pos节点即可。
删除:
在进行单链表任意位置的删除时也可以分为两层,同样的,如果链表只有一个节点,那么删除pos节点,实际上就同样可以复用前面的头删/尾删函数。如果链表有多个节点,可以直接定义一个指向头节点的指针cur,然后遍历链表找到pos节点,然后直接将pos的前一个节点的next指向pos的下一个节点,然后free掉pos节点即可。
// 插入
void SLTInsert(SLNode** pphead, SLNode* pos, SLNDataType x)
{
assert(pphead);
assert(pos);
assert(*pphead);
if (*pphead == pos)
{
SLTPushFront(pphead, x);
}
else
{
SLNode* cur = *pphead;
while (cur->next != pos)
{
cur = cur->next;
}
SLNode* newnode = CreateNode(x);
cur->next = newnode;
newnode->next = pos;
}
}
// 删除
void SLTErase(SLNode** pphead, SLNode* pos)
{
assert(pphead);
assert(*pphead);
assert(pos);
if (*pphead == pos)
{
SLTPopFront(pphead);
}
else
{
SLNode* cur = *pphead;
while (cur->next != pos)
{
cur = cur->next;
}
cur->next = pos->next;
free(pos);
pos = NULL;
}
}
测试用例:
void test1()
{
SLNode* plist = NULL;
printf("尾插:\n");
SLTPushBack(&plist, 1);
SLTPrint(plist);
SLTPushBack(&plist, 2);
SLTPrint(plist);
printf("头插:\n");
SLTPushFront(&plist, 3);
SLTPrint(plist);
printf("尾删:\n");
SLTPopBack(&plist);
SLTPrint(plist);
printf("头删:\n");
SLTPopFront(&plist);
SLTPrint(plist);
SLTDestroy(&plist);
return 0;
}
注意事项:
在对函数进行调用时这里传的是一级指针的地址,所以要使用二级指针去接收,在打印函数(SLTPrint)里因为没对里面的值进行更改,只是单纯打印所以并不需要传地址。
双链表与单链表相比多了一个指向前一个结点的“前驱指针”,就无需遍历链表去找尾节点了,但是在进行对链表的增加删除时得多去更改一个前驱指针。
所以对比可以简单分为两点:
1. 单向链表只能单方向地寻找表中的结点,双向链表具有对称性,从表中某一给定的结点可随意向前或向后查找。
2. 在作插入、删除运算时,双向链表需同时修改两个方向上的指针,单向链表则简便些。
下面实现的是一个简单的“带头双向循环”链表。
首先是头文件定义:
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* next; //后继指针
struct ListNode* prev; //前驱指针
LTDataType val;
}LTNode;
// 初始化、打印与销毁
LTNode* LTInit();
void LTPrint(LTNode* phead);
void LTDestroy(LTNode* phead);
// 尾插尾删
void LTPushBack(LTNode* phead, LTDataType x);
void LTPopBack(LTNode* phead);
// 头插头删
void LTPushFront(LTNode* phead, LTDataType x);
void LTPopFront(LTNode* phead);
// 查找
LTNode* LTFind(LTNode* phead, LTDataType x);
//任意位置的插入删除
void LTInsert(LTNode* pos, LTDataType x);
void LTErase(LTNode* pos);
对于双链表的初始化,我们可以创建一个哨兵位头节点,这是一个特殊的节点,它在链表里起着简化边界的作用,它本身不存储任何有效的数据。在链表中例如插入操作中就不需要特殊的去分别处理空链表和非空链表的情况了,因为这样创建后链表必定不为空,因为它始终存在一个哨兵位节点,即便它不存储任何有效值。
在创建链表新节点时可以开辟新节点的功能封装成一个函数,在需要时直接调用即可。
初始化:
在进行双链表的初始化时本质上是开辟一个哨兵位头节点,并让它的前驱和后继指针都指向回它自己,最后再返回哨兵位头节点的地址即可。
打印:
在进行双链表的打印时实际上要解决的问题是如何判断链表的尾节点,因为实现的这个链表是一个环,尾节点的后继指针指向的是哨兵位头节点,并不是NULL,所以在判别结束条件时可以创建一个指向哨兵位节点的下一个节点的针指cur,当cur指向的下一个节点为哨兵位节点时就代表cur指针指向了链表的尾节点。
销毁:
在进行双链表的销毁时,可以创建一个指向哨兵位节点的下一个节点的指针cur,然后遍历链表,分别单独的去free掉cur指向的节点,这里要注意的是,因为哨兵位节点是我们手动开辟出来的,所以最好也是要手动进行释放。
// 开辟新节点
LTNode* CreateLTNode(LTDataType x)
{
LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(-1);
}
newnode->val = x;
newnode->next = NULL;
newnode->prev = NULL;
return newnode;
}
// 初始化
LTNode* LTInit()
{
LTNode* phead = CreateLTNode(-1);
phead->next = phead;
phead->prev = phead;
return phead;
}
// 打印
void LTPrint(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
printf("%d -> ", cur->val);
cur = cur->next;
}
printf("\n");
}
// 销毁
void LTDestroy(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
LTNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
phead = NULL;
}
尾插:
在进行双链表的尾插时要注意前驱和后继指针的指向,哨兵位的前驱指针要指向新节点,原本的尾节点的后继指针也要指向新节点,而新节点的前驱指针要指向原本的尾节点,后继指针要指向哨兵位节点。
尾删:
在进行双链表的尾插时要确保链表中除了哨兵位节点外还有节点,也就是哨兵位节点的下一个指向不为哨兵位节点。因为在进行删除操作时是不能删掉哨兵位节点的。在进行尾删操作时可以创建一个指针tail指向哨兵位节点的前一个结点(也就是尾节点),然后再创建一个指针tailPrev指向尾节点的前一个,然后free掉tail指针指向的节点,再将新尾节点的后继指针指向哨兵位节点,哨兵位节点的前驱指针指向新尾节点即可。
// 尾插
void LTPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* tail = phead->prev;
LTNode* newnode = CreateLTNode(x);
tail->next = newnode;
newnode->prev = tail;
newnode->next = phead;
phead->prev = newnode;
}
// 尾删
void LTPopBack(LTNode* phead)
{
assert(phead);
assert(phead->next != phead);
LTNode* tail = phead->prev;
LTNode* tailPrev = tail->prev;
free(tail);
tailPrev->next = phead;
phead->prev = tailPrev;
}
头插:
在进行双链表的头插时同样的也是需要注意其4个指针的指向,哨兵位节点的后继指针指向新节点,新节点的前驱指针指向哨兵位节点,新节点的后继指针指向原本哨兵位节点的下一个节点,原本哨兵位节点的下一个节点的前驱指针指向新节点。
头删:
在进行双链表的头删时,要先确保链表中除了哨兵位节点之外还要有其他节点,因为在进行删除操作时是不能删除哨兵位节点的。删除操作只需要更改两个指针的指向即可,哨兵位节点的后继指针指向要删除的节点的下一个节点,然后要删除的节点的下一个节点的前驱指针指向哨兵位节点,最后再free掉要删除的节点即可。
// 头插
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = CreateLTNode(x);
LTNode* first = phead->next;
phead->next = newnode;
newnode->prev = phead;
newnode->next = first;
first->prev = newnode;
}
// 头删
void LTPopFront(LTNode* phead)
{
assert(phead);
assert(phead->next != phead);
LTNode* first = phead->next;
LTNode* second = first->next;
phead->next = second;
second->prev = phead;
free(first);
first = NULL;
}
查找:
双链表的查找函数也是遍历链表,查找到了返回该节点,查找不到就返回NULL,但这样写会存在一个缺陷,就是链表中存储多个相同值时只会返回第一个符合条件的节点,使用者可根据需要进行更改优化。
插入:
这里的任意位置插入删除实现的是在pos位置前进行插入,在pos位置后进行插入的话方法是一样的。实现在任意位置的插入需要借助查找函数(LTFind),然后更改对应的前驱和后继指针的指向即可。
删除:
在进行删除pos节点的操作时和前面一样,更改对应指针的指向,然后free掉pos节点即可。
// 查找
LTNode* LTFind(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
if (cur->val == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
// 在pos前面的插入
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* posPrev = pos->prev;
LTNode* newnode = CreateLTNode(x);
posPrev->next = newnode;
newnode->prev = posPrev;
newnode->next = pos;
pos->prev = newnode;
}
// 删除pos位置
void LTErase(LTNode* pos)
{
assert(pos);
LTNode* posNext = pos->next;
LTNode* posPrev = pos->prev;
posPrev->next = posNext;
posNext->prev = posPrev;
free(pos);
pos = NULL;
}
从前面的操作我们可以发现,好像对应的插入(删除)操作都有很多的共同点,那么这些共同点能否整合在一起,然后简化一下呢?答案显然是可以的,如下:
1.链表的头插可以对应在哨兵位节点下一个位置插入。
2.链表的头删可以对应删除哨兵位节点的下一个节点。
3.链表的尾插可以对应在哨兵位节点的上一个位置插入。
4.链表的尾删可以对应删除哨兵位节点的上一个节点。
所以双链表的头插尾插头删尾删可以简化成如下:
// 尾插
void LTPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTInsert(phead, x);
}
// 尾删
void LTPopBack(LTNode* phead)
{
assert(phead);
assert(phead->next != phead);
LTErase(phead->prev);
}
// 头插
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTInsert(phead->next, x);
}
// 头删
void LTPopFront(LTNode* phead)
{
assert(phead);
assert(phead->next != phead);
LTErase(phead->next);
}
void test1()
{
LTNode* plist = LTInit();
printf("尾插:\n");
LTPushBack(plist, 1);
LTPrint(plist);
LTPushBack(plist, 2);
LTPrint(plist);
printf("头插:");
LTPushFront(plist, 3);
LTPrint(plist);
printf("尾删:");
LTPopBack(plist);
LTPrint(plist);
printf("头删:");
LTPopFront(plist);
LTPrint(plist);
LTDestroy(plist);
}
与顺序表相比,双链表的优势在于:
1.任意位置插入删除都是O(1)。
2.按需申请释放,合理利用空间,不存在浪费。
但是同样存在些许缺陷,例如下标的随机访问不方便。更直观的比较为:
不同点 |
顺序表 |
链表 |
---|---|---|
存储空间上 |
物理上一定连续 |
逻辑上连续,但物理上不一定连续 |
随机访问 |
支持:O(1) |
不支持:O(N) |
任意位置插入或者删除元素 |
可能需要搬移元素,效率低O(N) |
只需修改指针指向 |
插入 |
动态顺序表,空间不够时需要扩容 |
没有容量的概念 |
应用场景 |
元素高效存储+频繁访问 |
任意位置插入和删除频繁 |
缓存利用率 |
高 |
低 |