初阶数据结构
第一章 时间复杂度和空间复杂度
第二章 动态顺序表的实现
第三章 单向链表的讲解与实现
文章目录
- 初阶数据结构
- 前言
- 一、什么是链表?
- 二、节点的定义:
- 三、单向链表接口函数
- 1、打印:
- 2、尾插:
- 3、头插:
- 4、尾删:
- 5、头删:
- 6、查找:
- 7、插入:
- (1)向前插入:
- (2)向后插入:
- (3)两者对比:
- 8、删除:
- 9、销毁:
前言
学习完顺序表之后,我们发现顺序表存在一定的缺陷和问题,尤为突出的问题就是插入和删除时时间的消耗以及开辟内存的时候空间的损耗。那么本章节所学习的链表则能有效地解决这些问题。
一、什么是链表?
链表:顾名思义就是将一个个数据连接起来,从而构成一个数据结构,下图中即为链表的逻辑结构与物理结构。
每一个链表都是由一节节的链结组成的,我们称之为节点。其中,每一个节点都是由两部分组成的,存储数据的部分叫做数据域,存储地址的部分叫做指针域。指向第一个节点的指针称之为头指针。
那么上述的单向链表如何实现呢?
二、节点的定义:
其定义如下:
typedef int ElementType;
typedef struct SListNode
{
ElementType data;
struct SListNode* next;
}SLTnode;
三、单向链表接口函数
1、打印:
我们创建一个cur指针,这个指针的目的就是去遍历每一个链表中的节点,然后判断这个节点是否为空。为了方便起见,我们下图的图示采用链表的物理结构,并将最后的空指针也假设一块虚拟的空间,这样便于我们理解。下图的逻辑我们即可写出打印链表元素的代码。
void SListPrint(const SListNode* phead)
{
if (phead != NULL)
{
SListNode* cur = phead;
while (cur != NULL)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
else
{
printf("Empty List!\n");
}
}
2、尾插:
尾插的实现逻辑如下,我们将新节点中的地址设置为空指针,然后将其前面的指针指向新的节点,即完成了尾插。
我们这里要分为两种情况。
第一种:链表为空
当链表为空的时候,我们的plist指针指向的是空,此时我们是不能通过->
对plist进行解引用的。所以我们需要单独判断一下。
第二种:链表不为空
这种情况下,我们实现尾插的动作一共分为两步,第一步利用tail指针找到尾节点。第二部将新的节点连接到原最后一个节点上。
//2、尾插
void SListPushBack(SListNode** pphead, ElementType dat)
{
assert(pphead!=NULL);
SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));
if (newnode == NULL)
{
printf("failed to creat space!");
exit(-1);
}
else
{
newnode->data = dat;
newnode->next = NULL;
}
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
SListNode* tail=*pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
}
另外,我们还需要注意一点,就是这里 为什么要用二级指针传入头指针?
我们知道,函数传参分为值传递和地址传递,值传递仅仅是将数据拷贝给了形参,形参发生变化时,实参不会随之而变。而值传递的方式则会引起形参和实参的同步改变。头指针是一个结构体类型的指针,而指针也是一种变量。如果我们将形参设置为一级指针,此时实参和形参的数据类型是一致的,所以发生的是值传递。
假设我们用的是值传递的方式,当链表为空,我们进行尾插的时候,我们会将头指针内的数据改为新节点的地址。但是我们提到了,值传递的方式不会引起实参的变化。因此,当此函数结束后,我们的头指针依旧是空。因此,我们这里需要传入的是二级指针,从而实现地址传递。
总结为一句话:需要修改链表的时候传入二级指针,不需要修改链表的时候传入一级指针。
3、头插:
头插的逻辑如下图所示,我们将头指针指向我们新的节点,然后将新节点的指针域指向原第一个节点的地址。
头插的逻辑非常简单,我们将头指针指向新的节点,将新节点的指针域指向原首节点的地址即可。
void SListPushFront(SListNode** pphead, ElementType dat)
{
assert(pphead!=NULL);
SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));
if (newnode == NULL)
{
printf("Failed to creat new space.\n");
exit(-1);
}
else
{
newnode->data = dat;
newnode->next = *pphead;
*pphead = newnode;
}
}
4、尾删:
尾删的逻辑其实很简单,我们只需要将倒是第二个节点的指针域设置为空指针,并且将最后一个节点释放掉。由于我们这种方法都基于两个节点,但是未必所有的链表都有两个节点,因此我们可以将情况分为以下三种:
(1)链表不为空,且元素个数大于等于2
这种情况就是非常普遍的,按照我们刚刚的逻辑模拟即可。要注意的是我们需要提前将倒数第二个位置的地址信息存储起来。
(2)链表为空
这种情况根本不需要尾删操作,直接返回或报错即可。
(3)链表不为空,但是元素只有一个
这种情况不容易想到,但是一但还使用我们刚刚的逻辑,会立马出现访问权限的错误。因为第一个节点就是尾节点,因此他的前面是没有节点的,此时我们是无法按照刚刚的逻辑执行。所以我们需要特判一下,直接将头指针置空,第一个节点释放即可。
我们根据上面的逻辑可以写出如下代码:
//4、尾删
void SListPopBack(SListNode** pphead, ElementType dat)
{
assert(pphead!=NULL);
assert(*pphead!=NULL);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SListNode* tail = *pphead;
SListNode* pre = NULL;
while (tail->next != NULL)
{
pre = tail;
tail = tail->next;
}
free(tail);
tail = NULL;
pre->next = NULL;
}
}
5、头删:
头删的逻辑就非常简单了,直接将头指针指向原第一个节点的指针域中所对的地址空间即可。
//5、头删
void SListPopFront(SListNode* *pphead)
{
assert(pphead!=NULL);
assert(*pphead!=NULL);
SListNode* temp = (*pphead)->next;
free(*pphead);
*pphead = NULL;
*pphead = temp;
}
6、查找:
查找很简单,直接遍历。注意返回值返回的是空指针或者所查元素的地址。
SListNode* SListFind(SListNode* phead,ElementType fin)
{
assert(phead!=NULL);
SListNode* cur = phead;
while (cur != NULL)
{
if (cur->data == fin)
{
return cur;
}
else
{
cur = cur->next;
}
}
return NULL;
}
7、插入:
(1)向前插入:
我们先来大致想一下向前插入的整体逻辑,我们在pos位置前面插入新的节点,只需要如图中所示,找到原前方的节点。然后让该节点指向所插入的节点,然后让所插入的节点指向pos所对的节点。
但是这里我们依旧需要注意一些细节问题:即我们要保证pos前面有节点,否则会再次出现尾删中所出现的访问越界的问题。
从这个问题出发我们就能很轻易地找到特殊情况,即头插。因此我们需要对这种特殊情况进行特判,当出现这种情况时,直接调用头插函数即可。
void SListInsertFront(SListNode**pphead, SListNode* pos, ElementType dat)
{
assert(pos != NULL);
assert(pphead!=NULL);
SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));
if (newnode == NULL)
{
printf("Failed to creat new space!\n");
exit(-1);
}
newnode->data = dat;
if (pos == *pphead)
{
SListPushFront(pphead,dat);
}
else
{
SListNode* prepos = *pphead;
while (prepos->next != pos)
{
prepos = prepos->next;
}
prepos->next = newnode;
newnode->next = pos;
}
}
(2)向后插入:
向后插入就非常简单了,因为我们pos位置所对的节点的指针域中就记录了后面的节点位置。因此我们直接就能插入新的元素,无需遍历寻找。并且不存在头插的特殊情况。
//8、向后插入
void SListInsertAfter(SListNode** pphead, SListNode* pos, ElementType dat)
{
assert(pos != NULL);
SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));
if (newnode == NULL)
{
printf("Failed to creat new space!\n");
exit(-1);
}
newnode->data = dat;
newnode->next = pos->next;
pos->next = newnode;
}
(3)两者对比:
向前插入的时间复杂度是O(N)。向后插入的时间复杂度是O(1)。因此向后插入是更高效的,如果想对向前插入进行算法上的优化的话,我们需要使用双向链表的数据结构。
8、删除:
删除的逻辑如下图所示,我们找到pos位置的前后节点,然后将该两个节点相连即可。但是同样有着头删的问题。所以我们以相同的方式特判一下,其余情况按照下图的逻辑模拟即可。
void SListErase(SListNode** pphead, SListNode* pos)
{
assert(pos!=NULL);
assert(pphead!=NULL);
assert(*pphead!=NULL);
if (pos == *pphead)
{
SListPopFront(pphead);
}
else
{
SListNode* prepos = *pphead;
while (prepos->next != pos)
{
prepos = prepos->next;
}
prepos->next = pos->next;
free(pos);
pos = NULL;
}
}
9、销毁:
销毁链表的时候,我们不能单纯地像释放顺序表那样只释放头指针。因为链表以这样的方式销毁时,是无法释放干净的,仅能释放头节点。所以我们应该通过下图所示的逻辑通过两个指针去释放每一个节点,最后将头指针设置为空。一定要将头指针设置为空指针!否则会出现野指针的问题!
void SListDestory(SListNode** pphead)
{
assert(pphead!=NULL);
assert(*pphead!=NULL);
SListNode* cur = *pphead;
while (cur!=NULL)
{
SListNode* temp = cur->next;
free(cur);
cur = NULL;
cur = temp;
}
*pphead = NULL;
}