1. 概念及结构
中的指针链接次序实现的 。
2. 链表的分类
在实际中的链表通常有8种链表结构:
1. 单向或者双向结构:
这种结构中,每个结点都有两个指针变量来分别存储它的上一个结点地址叫做直接前驱,和下一个结点地址叫做直接后继。
2. 带头或者不带头结构:
在这种结构中,带头结点本身不存存有效储数据,它的作用相较于不带头结点可以在处理空表比较有优势, 使用头结点时无论表是否为空头指针都指向头结点对于空表和非空表的操作是一致的。而不带头结点在遇到两种情况就需要进行判断则操作是不一致的。
3. 循环或非循环结构:
对于循环的链表可以实现循环访问,适用于解决涉及到循环问题的场景。
在明白上面这些结构后,我们可以将分别组合形成不同的8种结构分别是:无头单向非循环,无头单向循环,无头双向非循环,无头双向循环,带头单向非循环,带头单向循环,带头双向非循环,带头双向循环。
但实际种常用的还是这两种结构:
1. 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结
构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
2. 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都
是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带
来很多优势,实现反而简单了,后面我们代码实现了就知道了。
3. 链表的实现
3.1 无头单向非循环链表
需要实现的接口:
3.1.1 插入数据
尾插代码如下:
// 单链表尾插
void SListPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode= BuySListNode(x);
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
}
注意:当不带头结点的链表首次尾插入数据时需要将该指针指向这个第一次插入的结点
尾插示意图:
在进行尾插数据时我们需要通过头结点依次往后寻找找到最后一个结点的位置,然后在后面链接新的结点。时间复杂度为O(N)。
头插代码如下:
// 单链表的头插
void SListPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = BuySListNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
头插示意图:
头插相较于尾插就更加简单,头插时不需要管该指针是否为空,直接将新的结点的next指向该指针即可。时间复杂度为O(1)。
3.1. 2 删除数据
注意:在删除数据时都要注意一点就是若当前链表数据已经没有了进行此操作就会失败报错。
尾删代码如下:
// 单链表的尾删
void SListPopBack(SLTNode** pphead)
{
assert(pphead);
assert(*pphead); //但单链表已经是NULL了就没法删除了
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SLTNode* tail = *pphead;
while (tail->next->next != NULL)
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
尾删示意图:
尾删时还是需要从头开始找找到当前结点下一个结点的下一个结点为NULL。时间复杂度为O(N)。
头删代码如下:
// 单链表头删
void SListPopFront(SLTNode** pphead)
{
assert(pphead);
assert(*pphead); //已经是NULL了就没法删了
SLTNode* head = *pphead;
*pphead = (*pphead)->next;
free(head);
head = NULL;
}
头删示意图:
如图所示头删操作也是比较简单,把指向第一个结点的指针指向下一个结点,然后释放掉头结点就行了。时间复杂度为O(1)。
3.1.3 查找数据
// 单链表查找
SLTNode* SListFind(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead;
while (cur)
{
if (cur->a == x)
return cur;
cur = cur->next;
}
return NULL;
}
对于单链表的查找,就只能根据头指针依次遍历查找,时间复杂度为O(N)。
3.1.4 修改数据
代码如下:
int main()
{
SLTNode* plist = NULL;
SListPushBack(&plist, 1);
SListPushBack(&plist, 2);
SListPushBack(&plist, 3);
SListPushBack(&plist, 4);
SLTNode* pos=SListFind(plist, 3);
pos->a = 6;
SListPrint(plist);
SLTDestroy(&plist);
return 0;
}
在修改数据时需要先利用SListFind方法找到需要修改数据结点的位置,直接修改就行。
3.1.5 在特定位置插入删除数据
1. 单链表在pos位置之后插入x:
void SListInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* newnode = BuySListNode(x);
SLTNode* nextnode = pos->next;
pos->next = newnode;
newnode->next = nextnode;
}
插入示意图:
分析思考为什么不在pos位置之前插入?
解答:在pos位置插入需要找到pos上一个位置,就必须从头开始遍历查找,当链表很长时就非常费时间效率不高
在pos位置插入代码如下:
// 在pos的前面插入
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pphead);
assert(pos);
if (*pphead == pos)
{
SListPushFront(pphead, x); //头插
}
SLTNode* prev = *pphead;
while (prev->next != pos) //找prev前一个位置
{
prev = prev->next;
}
SLTNode* newnode = BuySListNode(x);
prev->next = newnode;
newnode->next = pos;
}
2. 单链表删除pos位置之后的值:
void SListEraseAfter(SLTNode* pos)
{
assert(pos);
assert(pos->next);
SLTNode* delnode = pos->next;
pos->next = delnode->next;
free(delnode);
delnode = NULL;
}
删除示意图:
分析思考为什么不删除pos位置?
解答:删除pos位置需要pos上一个位置的地址,所以还是需要给头指针,依次遍历查找pos上一个结点地址。
删除pos位置代码如下:
// 删除pos位置
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead);
assert(pos);
if (*pphead == pos)
{
/*free(*pphead);
*pphead = NULL;
return;*/
SListPopFront(pphead); //头删
}
else
{
//找到前一个位置
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
}
}
3.2 带头双向循环链表
需要实现的接口:
3.2.1 初始化单链表
相较于上一个链表,在使用这个链表之前需要进行初始化操作。
//获取一个结点
ListNode* BuyListNode(LTDataType x)
{
ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
if (NULL == newnode)
{
perror("malloc");
return NULL;
}
newnode->data = x;
newnode->next = NULL;
newnode->prev = NULL;
return newnode;
}
//初始化
ListNode* LTInit()
{
ListNode* newnode=BuyListNode(-1);
newnode->next = newnode;
newnode->prev = newnode;
return newnode;
}
这一步初始化我们直接得到一个带头的结点并且使得他所指向的头指针和尾指针都指向自己。
3.2.2 插入数据
在实现带头双向循环链表时无论尾插还是头插操作都很简单,并且可以抽取出在任意位置插入数据的函数,代码像下面这样:
//插入数据
void LTInsert(ListNode* pos, LTDataType x)
{
assert(pos);
ListNode* newnode = BuyListNode(x);
ListNode* prev = pos->prev;
newnode->prev = prev;
prev->next = newnode;
newnode->next = pos;
pos->prev = newnode;
}
进而尾插和头插代码可以更加简洁:
//尾插
void LTPushBack(ListNode* phead, LTDataType x)
{
LTInsert(phead, x);
}
//头插
void LTPushFront(ListNode* phead, LTDataType x)
{
LTInsert(phead->next, x);
}
头插示意图:
尾插示意图:
3.2.3 删除数据
删除数据我们也同样能提取出一个函数来实现带头双向循环链表的数据删除,代码如下:
void LTErase(ListNode* pos)
{
assert(pos);
ListNode* prev = pos->prev;
ListNode* next = pos->next;
prev->next = next;
next->prev = prev;
free(pos);
//pos = NULL;
}
相同地,尾删和头删一样调用这个函数就能快速实现这两个功能
//尾删
void LTPopBack(ListNode* phead)
{
assert(!LTEmpty(phead));
LTErase(phead->prev);
}
//头删
void LTPopFront(ListNode* phead)
{
assert(!LTEmpty(phead));
LTErase(phead->next);
}
值得注意的是,只要删除数据都需要判断是否数据已经为空了,若没有数据了则删除失败报错提醒。
头删示意图:
尾删示意图:
插入删除总结:当我们实现了带头双向循环链表的插入和删除数据我们发现,与单链表相比,当前链表无论是在头或者在尾效率都很高,时间复杂度相同为O(N)。这个链表实现也更为简单,所以听起来复杂的链表实现起来并不复杂。
3.3.4 查找数据
查找代码如下:
//查找数据
ListNode* LTFind(ListNode* phead, LTDataType x)
{
assert(phead);
assert(!LTEmpty(phead));
ListNode* cur = phead->next;
while (cur != phead)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
当前链表的查找都只能通过遍历来查询要查找的数据。
3.3.5 修改数据
int main()
{
ListNode* plist = LTInit();
LTPushBack(plist, 1);
LTPushBack(plist, 2);
LTPushBack(plist, 3);
LTPushFront(plist, 0);
LTPushFront(plist, -1);
LTPushFront(plist, -2);
ListNode* pos = LTFind(plist, 0);
pos->data = 9;
LTPrint(plist);
LTDestroy(plist);
plist = NULL;
return 0;
}
修改数据也一样,先查找要修改元素的位置然后直接进行修改即可。
4. 顺序表和链表的区别
不同点 | 顺序表 | 链表 |
存储空间上 | 物理上一定连续 | 逻辑上连续,但物理上不一定 连续 |
随机访问 | 支持O(1) | 不支持:O(N) |
任意位置插入或者删除 元素 | 可能需要搬移元素,效率低 O(N) | 只需修改指针指向 |
插入 | 动态顺序表,空间不够时需要 扩容 | 没有容量的概念 |
应用场景 | 元素高效存储+频繁访问 | 任意位置插入和删除频繁 |
缓存利用率 | 高 | 低 |