目录
什么是链表?
我们知道顺序表是以数组的形式存放数据,由于数组是连续存储的,所以顺序表是连续存放数据的,这个连续是实实在在的物理空间上的连续。
但这样存数据缺点很明显,静态则太死板,动态则容易造成空间浪费,那有没有一种数据结构能合理利用空间呢?链表它就来了。
链表 (ListNode) 是一种物理存储结构上非连续、非顺序
的存储结构,它以节点 (Node) 为存储单位,每个节点存放一个数据,并用指针链接其他链表:
实际上,链表的种类是非常多的,总的来分有三大类:
- 单向和双向
- 带头和不带头
- 循环和非循环(带环和不带环)
排列组合一下,就有了八种结构:
-
单向不带头非循环:
-
单向带头非循环:
-
单向不带头循环:
-
单向带头循环
-
双向不带头非循环:
-
双向带头非循环
-
双向不带头循环
-
双向带头循环
链表结构五花八门,实际见得最多的主要有两种:
无头单向非循环
和带头双向循环
。
而下面就具体介绍两种链表增删查改等接口功能的实现。
单链表
单链表就是上面提到的无头单向非循环链表,它的结构是最简单的,一般不会单独用来存数据,而是作为其他数据结构的子结构。
因为它一旦丢失了头节点的地址,整个数据就都无了,这是它最大的缺点。
当然,也正是因为它访问数据的特殊性,是很多 oj 题的宠儿。
因为它的结构太简单了,所以接口功能的实现就比较复杂。下面就详细介绍单链表增删查改等各个接口的实现。
创建节点
单链表结构很简单,
由一个个节点组成,
每个节点存放两个数据 - 数据和下一个节点的地址。
所以声明一个结构体类型。
typedef int SLTDataType;
typedef struct SLTNode
{
SLTDataType data;
struct SLTNode* next;
}SLTNode;
开辟一个新节点
无论是后续的头插还是尾插还是随机插入,
都不可避免地要创建新节点,
所以为了避免代码冗余,
单独定义一个函数完成开辟节点的工作。
//开辟一个节点
SLTNode* BuySListNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
newnode->data = x;
newnode->next = NULL;
return newnode;
}
单链表尾插
首先参数设计就有讲究。
我是要用一个指针 phead 指向链表的头,
而当链表为空的时候有 phead = NULL,
此时尾插就要改变 phead 的值,
让它指向新开辟的节点。
如果不改变 phead 的话,
由于它是一个一级指针,
用一个一级指针接收即可。
而这里要改变 phead 的值,
传值调用显然就不行,
所以要用一个二级指针来接收。
现在进行尾插。
尾插就要找尾,
定义一个 tail 指针,
让它一直向后走,
走到尾再把新节点接上就OK了。
尾插需要遍历链表,所以时间复杂度是O(N)
。
//单链表尾插
void SListPushBack(SLTNode** pphead, SLTDataType x)
{
SLTNode* newnode = BuySListNode(x);
//链表为空
if (*pphead == NULL)
*pphead = newnode;
//链表不为空
else
{
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
}
单链表尾删
尾删是比较麻烦的,
因为我不仅要找到尾,
还要找到尾结点的前一个节点。
所以需要用到两个指针 tail 和 prev。
而一旦用到两个指针的时候就要注意,
链表为空还好处理。
当链表只有一个节点的时候,
用 prev 是会出问题的,
所以要单独考虑。
这样就有三种情况。
尾删需要遍历链表,所以时间复杂度是O(N)
。
//单链表尾删
void SListPopBack(SLTNode** pphead)
{
SLTNode* tail = *pphead;
SLTNode* prev = NULL;
if ((*pphead) == NULL)
{
printf("链表为空无需删除\n");
return;
}
else if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
while (tail->next != NULL)
{
prev = tail;
tail = tail->next;
}
free(tail);
prev->next = NULL;
}
}
单链表头插
头插没什么复杂的,
创建一个新节点,
让新节点的 next 指向原来的头 phead,
然后再把 phead 指向新节点。
需要注意的还是参数部分。
由于要改变 phead 的值,
所以这里还是要用一个二级指针来接收。
由于不需要遍历链表,所以时间复杂度是O(1)
。
//单链表头插
void SListPushFront(SLTNode** pphead, SLTDataType x)
{
SLTNode* newnode = BuySListNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
单链表头删
头删也是比较简单的。
先找到第二个节点,
然后释放掉头节点,
将第二个节点作为新的头。
不过需要注意两个问题,
一个是上面提到的参数设计问题,
另一个就是链表为空的情况需要单独考虑。
由于不需要遍历链表,所以时间复杂度是O(1)
。
//单链表头删
void SListPopFront(SLTNode** pphead)
{
if ((*pphead) == NULL)
{
printf("链表为空无需删除\n");
return;
}
else
{
SLTNode* tmp = (*pphead)->next;
free(*pphead);
*pphead = tmp;
}
}
单链表查找
因为后面要实现在指定位置进行插入或删除,
所以要先找到那个指定位置。
直接遍历链表,
目标存在就返回目标节点的地址,
不存在就返回空指针。
由于要遍历数组,所以时间复杂度是O(N)
。
// 单链表查找
SLTNode* SListFind(SLTNode* plist, SLTDataType x)
{
SLTNode* cur = plist;
while (cur != NULL)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
单链表插入 - 在给定位置的后部插入
在给定位置的后部插入是比较简单的。
只需要找到指定位置和指定位置后面的一个节点,
将创建的新节点插在两个节点中间就OK。
这里就不会动头节点,所以只需要用一级指针来接受就可。
不需要遍历链表,所以时间复杂度是O(1)
。
//单链表插入 - 在目标后部插入
void SlistInsertAfter(SLTDataType x, SLTNode* pos)
{
assert(pos);
//走到这里目标节点一定存在
SLTNode* newnode = BuySListNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
单链表插入 - 在给定位置前面插入
后部插入简单,前面插入就恶心了。
想在前面插入,
我就要找到给定位置前面的那个节点,
然后把新节点插入两个节点之间,
这又要遍历链表。
而如果给定的位置就是头节点,
就相当于头插,
又得改变 phead 的指向,
又得拿二级指针来接收。
由于要遍历链表,所以时间复杂度是O(N)
。
//单链表插入 - 在目标前面插入
void SlistInsertBefore(SLTNode** pphead, SLTDataType x, SLTNode* pos)
{
assert(pos);
SLTNode* newnode = BuySListNode(x);
if (pos == *pphead)
{
newnode->next = pos;
*pphead = newnode;
}
else
{
SLTNode* cur = *pphead;
SLTNode* prev = NULL;
while (cur != pos)
{
prev = cur;
cur = cur->next;
}
newnode->next = pos;
prev->next = newnode;
}
}
单链表删除 - 删除给定位置的节点
删除当前位置也是个麻烦事,
因为不光要找到它后面的一个节点,
还要找到它前面的一个节点,
把这两个连起来。
而删除的位置是头的时候就相当于头删,
参数又要那二级指针来接收。
由于要遍历链表,所以时间复杂度是O(N)
。
//单链表删除 - 删除目标节点
void SListEraseCur(SLTNode** pphead, SLTNode* pos)
{
assert(pos);
if (pos == *pphead)
{
//相当于头删
SListPopFront(pphead);
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
}
}
单链表删除 - 删除给定位置后面的节点
这个就比上面那个简单多了,
只需要找到后面的第二个节点,
把给定位置处的节点和后面的第二个节点连起来即可。
当给定位置后面没有节点时就无需删除,
要单独拿出来讨论。
由于后删碰不到头节点,
所以用一个一级指针来接收就行。
//单链表删除 - 删除目标后面的节点
void SListEraseAfter(SLTNode* pos)
{
assert(pos);
if (pos->next == NULL)
{
printf("目标后面无节点,无需删除\n");
return;
}
else
{
SLTNode* tmp = pos->next;
pos->next = tmp->next;
free(tmp);
}
}
打印单链表
直接遍历打印,时间复杂度为O(N)
。
//打印单链表
void SListPrint(SLTNode* phead)
{
SLTNode* cur = phead;
while (cur != NULL)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
单链表销毁
单链表的每个节点都是动态内存开辟来的,
所以最后不能忘了释放内存。
时间复杂度为 O(N)
。
//销毁单链表
void SListPrint(SLTNode* phead)
{
SLTNode* cur = phead;
while (cur)
{
next = cur->next;
free(cur);
cur = next;
}
}
小结
至此,单链表其实并没有完全写好,像统计单链表中的数据啦、判断链表是否为空啦、给单链表排序啦…都没有写,但是这些接口并不难,可以说是十分简单,这里只是把几个关键的接口讲解了一下。
其实看完这些就会发现,单链表是个什么小牛马,动不动就要遍历。但单链表还是有它的优点所在的,因为结构简单,所以它会作为其他数据结构的子结构,如哈希桶、图的邻接表等等。
此外,由于单链表插入或者删除数据时间复杂度比较高,所以在实际应用中很少用单链表去储存数据,至于更好的解决方案,请看下文。
带头双向循环链表
带头双向循环链表结构是真的复杂,
但它使用起来是真的方便。
它有一个哨兵位,
有了这个哨兵位,
以后怎么操作都不会改变链表的头,
所以在设计函数参数时就不用像单链表一样考虑那么多。
但哨兵位是不用来存数据的。
多提一嘴,实际中使用的链表数据结构,都是带头双向循环链表。
下面就具体介绍到头双向循环链表的接口功能实现。
声明定义节点结构
是链表就有节点,
有节点就有结构。
所以与单链表一样,
先用结构体定义节点。
typedef int DataType;
typedef struct ListNode
{
struct ListNode* prev;
struct ListNode* next;
DataType data;
}ListNode;
开辟一个新节点
无论是开辟哨兵位的头节点,
还是尾插头插指定插,
都避不开开辟一个新节点,
所以把这部分单拎出来。
//创建一个新节点
ListNode* BuyListNode(DataType x)
{
ListNode* node = (ListNode*)malloc(sizeof(ListNode));
node->prev = NULL;
node->next = NULL;
node->data = x;
return node;
}
创建哨兵位的头节点
哨兵位没啥讲究,
因为它存放的数据没用,
所以随便传一个数据就行。
带头双向循环链表是循环的,
是首尾相接的,
所以当它只有一个节点时直接让它自成一体,
自己指向自己。
//创建哨兵位的头节点
ListNode* ListCreate()
{
ListNode* head = BuyListNode(0);
head->next = head;
head->prev = head;
return head;
}
尾插
带头双向循环链表的尾插就简单的多。
首先要找尾。
它不会像之前单链表一样遍历去找尾,
因为它的哨兵位的 prev 指向的就是尾。
所以只需要将新创建的节点接到原来尾巴的后边,
然后在新节点和哨兵位之间建立联系就OK了。
具体操作为:
- 新节点的 prev 指向原尾巴;
- 原尾巴的 next 指向新节点;
- 新节点的 next 指向哨兵位;
- 哨兵位的 prev 指向新节点。
时间复杂度为O(1)
。
//尾插
void ListPushBack(ListNode* phead, DataType x)
{
ListNode* newnode = BuyListNode(x);
ListNode* tail = phead->prev;
newnode->prev = tail;
tail->next = newnode;
newnode->next = phead;
phead->prev = newnode;
}
尾删
它的尾删同样简单。
找到它的尾巴和尾巴的前一个,
在新尾巴与哨兵位之间建立联系,
然后释放掉旧尾巴就好了。
具体操作为:
- 新尾巴的 next 指向哨兵位;
- 哨兵位的 prev 指向新尾巴。
时间复杂度为O(1)
。
//尾删
void ListPopBack(ListNode* phead)
{
if (phead->prev == phead)
{
printf("已经被掏空了,别删了\n");
return;
}
ListNode* tail = phead->prev;
ListNode* newtail = tail->prev;
free(tail);
newtail->next = phead;
phead->prev = newtail;
}
头插
头插,
千万不要上来就操作它的 phead,
因为 phead 是哨兵位,
而我们要做的是在哨兵位的后面位置插入数据。
所以只需要找到哨兵位的后面一个节点,
在该节点和哨兵位之间插入一个新节点就OK了。
具体操作为:
- 哨兵位的 next 指向 newnode;
- newnode 的 prev 指向 哨兵位;
- 原头节点的 prev 指向 newnode;
- newnode 的 next 指向 原头节点。
时间复杂度为O(1)
。
//头插
void ListPushFront(ListNode* phead, DataType x)
{
ListNode* newnode = BuyListNode(x);
ListNode* head = phead->next;
phead->next = newnode;
newnode->prev = phead;
head->prev = newnode;
newnode->next = head;
}
头删
当只有一个哨兵位的时候是不能再删除的。
正常情况下,
找到原来的头节点,
再找到原来的头节点的下一个节点,
在新头节点哨兵位之间建立联系,
最后别忘了释放掉原头节点。
具体操作为:
- 释放原头节点;
- 哨兵位的 next 指向新头节点;
- 新头节点的 prev 指向哨兵位。
时间复杂度为O(1)
。
void ListPopFront(ListNode* phead)
{
if (phead->prev == phead)
{
printf("已经被掏空了,别删了\n");
return;
}
ListNode* head = phead->next;
ListNode* newhead = head->next;
free(head);
phead->next = newhead;
newhead->prev = phead;
}
查找
后面还要在指定位置处插入或删除节点,
所以先把查找函数单拎出来。
直接遍历,
找到了就返回地址,
找不到就返回空指针。
时间复杂度为O(N)
。
//查找
ListNode* ListFind(ListNode* phead, DataType x)
{
ListNode* cur = phead->next;
while (cur != phead)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
在给定位置前面进行插入
双向带头循环链表实现起来这个功能还是挺简单的。
因为我可以直接通过 pos 找到它前面的节点,
然后在两个节点之间插入新节点即可。
具体操作为:
- 前节点的 next 指向新节点;
- 新节点的 prev 指向前节点;
- 新节点的 next 指向 pos ;
- pos 的 prev 指向新节点。
时间复杂度为O(1)
。
//在目标位置前面进行插入
void ListInsert(ListNode* pos, DataType x)
{
assert(pos);
ListNode* newnode = BuyListNode(x);
ListNode* prev = pos->prev;
prev->next = newnode;
newnode->prev = prev;
newnode->next = pos;
pos->prev = newnode;
}
删除给定位置的节点
通过 pos 找到它前后两个节点,
在前后两个节点之间建立联系,
然后释放掉 pos 处的节点即可。
时间复杂度为O(1)
。
//删除目标节点
void ListErase(ListNode* pos)
{
assert(pos);
ListNode* prev = pos->prev;
ListNode* next = pos->next;
free(pos);
prev->next = next;
next->prev = prev;
}
打印
遍历一遍哨兵位之后的节点就可。
时间复杂度为O(N)
。
//打印
void ListPrint(ListNode* phead)
{
ListNode* cur = phead->next;
while (cur != phead)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
销毁链表
因为每个节点都是 malloc 出来的,
所以最后一定不能忘记释放!
先把哨兵位之后的都释放掉,
最后释放哨兵位。
时间复杂度为O(N)
。
//销毁链表
void ListDestroy(ListNode* phead)
{
ListNode* cur = phead->next;
while (cur != phead)
{
ListNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
}
小结
至此,带头双向循环链表的几个主要功能就完成了。
很显然,虽然链表结构复杂了许多,但用起来也方便了许多,而且无论是插入数据还是删除数据时间复杂度都是O(1),十分的高效。所以经常拿它来存放数据。优势大大滴!
总结
顺序表和链表都写完了。
现在我们对比一下顺序表和链表的优缺点。
顺序表:
优点:
空间连续,支持通过下标进行随机访问
CPU高速缓存命中率高
缺点:
空间不够需要增容,带来一定的性能消耗,且容易造成空间浪费
头部或中间插入数据需要进行挪动,时间复杂度为O(N),效率比较低
链表:
优点:
按需申请内存,不存在空间浪费
任意位置插入或删除数据的时间复杂度为O(1),效率高
缺点:
不支持下标的随机访问
其中讲顺序表的优点有一条是CPU高速缓存命中率高
。
CPU在处理数据的时候是不会直接从内存中进行读取的,而是在缓存中寻找数据,如果缓存中有想要的数据,那就是命中了,反之则没有命中,此时缓存会去内存中再次读取数据。而缓存空间是很小的,一次并不能从内存中读取大量数据。所以在读取顺序表时,由于空间是连续的,一次能读取多个有效数据进来,那么CPU在缓存中读取数据时可能连续命中多次。而链表则不然,空间的不连续导致缓存一个可能只读取到了一个有效数据,那CPU读两次就空一次,效率就有所降低。
所以,链表和顺序表之间并没有绝对的优劣,只是要根据应用场景来判断具体要用谁。比如栈的实现就是用的顺序表,而队列的实现则是用的链表。