目录
前言
###顺序存储结构不足的解决办法
前面我们讲的线性表的顺序存储结构。它是有缺点的,最大的缺点就是插入和删除时需要移动大量元素,这显然就需要耗费时间。能不能想办法解决呢?要解决这个问题,我们就得考虑一下导致这个问题的原因。
为什么当插入和删除时,就要移动大量元素,仔细分析后,发现原因就在于相邻两元素的存储位置也具有邻居关系。它们编号是1,2,3,...,n,它们在内存中的位置也是挨着的,中间没有空隙,当然就无法快速介入,而删除后,当中就会留出空隙,自然需要弥补。问题就出在这里。知道了问题所在,那么我们应该怎么解决呢?
我们反正也是要让相邻元素间留有足够余地,那干脆所有的元素都不要考虑相邻位置了,哪有空位就到哪里,而只是让每个元素知道它下一个元素的位置在哪里,这样,我们可以在第一个元素时,就知道第二个元素的位置(内存地址),而找到它;在第二个元素时,再找到第三个元素的位置(内存地址)。这样所有的元素我们就都可以通过遍历而找到。
###线性表的链式存储结构
线性表的链式存储结构的特点是用一组任意 的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。这就意味着,这些数据元素可以存在内存未被占用的任意位置。
以前在顺序结构中,每个数据元素只需要存数据元素信息就可以了。现在链式结构中,除了要存数据元素信息外,还要存储它的后继元素的存储地址。
因此,为了表示每个数据元素 与其直接后继数据元素 之间的逻辑关系,对数据元素来说,除了存储其本身的数据之外,还需存储一个指示其直接后继的信息(即直接后继的存储位置)。我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称做指针。这两部分信息组成数据元素 的存储映像,称为结点(Node)。
什么是单链表?
n个结点(的存储映像)连接成一个链表,即为线性表(、 ... )的链式存储结构,因为此链表的每个结点中只包含一个指针域,所以叫做单链表。单链表正是通过每个结点的指针域将线性表的数据元素按其逻辑次序链接在一起。单链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接实现的。
对于线性表来说,总得有个头有个尾,链表也不例外。我们把链表中第一个结点的存储位置叫做头指针,那么整个链表的存取就必须是从头指针开始进行了。之后的每一个结点,其实就是上一个的后继指针指向的位置。想象一下,最后一个结点,它的指针指向哪里?
最后一个,当然就意味着直接后继不存在了,所以我们规定,线性链表的最后一个结点指针为"空"(通常用 NULL或"^"符号表示)。
有时,我们为了更加方便地对链表进行操作,会在单链表的第一个结点前附设一个结点,称为头结点。头结点的数据域可以不存储任何信息,也可以存储如链表的长度等附加信息,头结点的指针域存储指向第一个结点的指针。如下图所示:
头指针 和 头结点的差异
- 头指针是指链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针头指针具有标识作用,
- 头结点是链表的必要元素结点是为了操作的统一和方便而设立的,放在第一无素的结点之前,其数据域一般无意义(也可存放链表的长度),有了头结点,对在第一元素结点前插入结点和删除第一结点其操作与其它结点的操作就统一了,头结点不一定是链表必须要素。
链表的实现
以下代码全部采用C语言编写,并且采用带头结点的结构实现。
链表的结构定义
typedef int SLlistDataType;
typedef struct SList
{
//数据域
SLlistDataType data;
//指针域
struct SList* next;
}SList;
//开辟结点内存
SList* CreatStorage()
{
SList* SLNode = NULL;
SLNode = (SList*)malloc(sizeof(SList));
return SLNode;
}
从这个结构定义中,我们也就知道,结点由存放数据元素的数据域、存放后继结点地址的指针域组成。假设p是指向线性表第i个元素的指针,则该结点a的数据域我们可以用 p->data 来表示,p--->data 的值是一个数据元素,结点的指针域可以用p->next 来表示,p->next 的值是一个指针。p->next 指向谁呢?当然是指向第个元素,即指向 的指针。也就是说,p->data=,那么p->next->data = 。
链表的插入
来看单链表的插入。假设存储元素e的结点为S,要实现结点p、p->next和s之间逻辑关系的变化,只需将结点s插入到结点p和p->next之间即可。可如何插入呢?这和顺序表有很大的差别,根本用不着惊动其他结点,只需要让 s->next 和 p->next 的指针做一点改变即可。
- s->next = p->next;
- p->next = s
解读这两句代码,也就是说让p的后继结点改成s的后继结点,再把结点s变成P的后继结点。
对于链表的头尾插入操作是一样的,代码都一样。相对而言,链表的头插和尾插是比较容易理解,同时链表的头插的时间复杂度为O(1),链表的尾插需要去遍历链表所以它的时间复杂度为O(n)。代码实现如下:
//尾插
void SListTailInsert(SList** phead, SLlistDataType x)
{
SList* NewNode;
SList* TailNode;
//当链表为空链表
if ((*phead)->next == NULL)
{
NewNode = CreatStorage();
NewNode->data = x;
NewNode->next = NULL;
//连接结点
(*phead)->next = NewNode;
}
//链表不为空
else
{
TailNode = (*phead);
//查找尾节点
while (TailNode->next != NULL)
{
TailNode = TailNode->next;
}
NewNode = CreatStorage();
NewNode->data = x;
//将尾结点的指针域交给新结点的指针域
NewNode->next = TailNode->next;
//连接新结点
TailNode->next = NewNode;
}
}
//头插
void SListHeadInsert(SList** phead, SLlistDataType x)
{
//无论该链表是否为空
SList* NewNode = CreatStorage();
NewNode->data = x;
NewNode->next = (*phead)->next;
(*phead)->next = NewNode;
}
注意:这里函数的形参使用二级指针来接收。如果是带头结点的链表其实可以不用传二级指针,但是如果没有带头结点一定要传二级指针,具体可以参考swap交换2个变量的值,形参是对实参的一种临时拷贝,它们的地址空间并不是一样的。
###链表的随机插入
对于链表的随机插入,需要考虑的因素要复杂一点。首先对于要插入的第i个结点最好是存在于[1, 链表长度],如果超出该范围,那就直接抛一个信息(异常)出来。由于我们插入的位置是第i个结点之前的位置,所以需要一个结点指针来记录第i个结点的前驱,连接如下
由于随机插入,所以需要去遍历链表同尾插的操作一样时间复杂度为O(n)。代码实现如下:
void SListRandomInsert(SList** phead, SLlistDataType x, int ItemNode)
{
int count = 0;
int flag = 0;//标记位,1:表明存在该结点
//移动结点
SList* SearchNode = NULL;
//记录目标结点的前一个结点
SList* PriorNode = (*phead);
SList* NewNode = CreatStorage();
//指向头指针
SearchNode = (*phead)->next;
//记录第i个结点
count = 1;
//遍历结点
while (SearchNode != NULL)
{
//当结点存在时
if (count == ItemNode)
{
flag = 1;
NewNode->data = x;
//连接结点
NewNode->next = PriorNode->next;
PriorNode->next = NewNode;
return;
}
else
{
PriorNode = SearchNode;
SearchNode = SearchNode->next;
count++;
}
}
if (!flag)
{
printf("链表中无该节点\n");
}
}
链表的删除
###链表的尾删
对于链表的尾删,也很简单,只需要遍历链表,找到尾结点的前驱节点,将前驱结点的nex改成NULL即可,同时不要忘记将尾结点的空间回收(free),不然会造成内存泄漏!!!由于链表的尾删需要进行遍历链表,所以它的时间复杂度为O(n)。
代码实现如下:
void SListTailDelete(SList** phead)
{
//获取头指针
SList* FirstNode = (*phead)->next;
//如果是空链表
if (FirstNode == NULL)
{
return;
}
else if (FirstNode->next == NULL)
{
//只有一个结点
(*phead)->next = FirstNode->next;
//释放结点的空间
free(FirstNode);
}
else
{
//尾结点
SList* TailNode;
//记录尾结点的前一个结点
SList* PriorNode = NULL;
//先指向链表的第一个节点,之后开始移动到尾节点
TailNode = (*phead)->next;
while (TailNode->next != NULL)
{
//将TailNode存放的结点先给PriorNode
PriorNode = TailNode;
TailNode = TailNode->next;
}
PriorNode->next = TailNode->next;
//将尾结点的空间释放
free(TailNode);
}
}
###链表的头删
链表的头删需要注意,这个链表是否为空,如果为空直接返回。反之,将头结点的next指向头指针的后继结点;同时,将头指针空间释放,将后继结点当成新的头指针。
头删不需要遍历链表,所以它的时间复杂度为O(1)。代码实现如下:
void SListHeadDelete(SList** phead)
{
if ((*phead)->next == NULL)
{
return;
}
else
{
//头指针
SList* FirstNode = (*phead)->next;
(*phead)->next = FirstNode->next;
free(FirstNode);
}
}
###链表的随机删除
链表的随机删除一样要考虑第i个结点是否存在,如果不存在直接返回;反之,需要记录删除结点的前驱,将前驱结点的next指向目标结点的后继结点。这个处理的逻辑和随机插入的逻辑是一个道理,只是一个是添加新结点,一个是释放结点。
由于随机删除需要遍历链表,所以它的时间复杂度为O(n)。代码实现如下:
void SListRandomDelete(SList** phead, int ItemNode)
{
int count = 0;
int flag = 0;
//移动结点
SList* SearchNode = NULL;
//记录目标结点的前一个结点
SList* PriorNode = (*phead);
SearchNode = (*phead)->next;
//已经指向第一个节点
count = 1;
while (SearchNode != NULL)
{
if (count == ItemNode)
{
flag = 1;
PriorNode->next = SearchNode->next;
free(SearchNode);
return;
}
else
{
PriorNode = SearchNode;
SearchNode = SearchNode->next;
count++;
}
}
if (!flag)
{
printf("链表中无该节点\n");
}
}
链表的查找
解释:根据函数传入的数据,取链表中查找是否存在该值,如果存在则返回该值位于链表的第i个结点,由于该值可能在链表中重复出现,所以只需要返回第一个出现的即可。反之,则返回-1。
该功能需要遍历链表,所以它的时间复杂度为O(n)。代码实现如下:
int SListSearchData(SList* phead, SLlistDataType x)
{
SList* SearchNode = phead->next;
int flag = 0;//作为标记数
int NodeCount = 0;//统计结点数
while (SearchNode != NULL)
{
NodeCount++;
if (SearchNode->data == x)
{
flag = 1;
return NodeCount;
SearchNode = SearchNode->next;
}
else
{
flag = 0;
SearchNode = SearchNode->next;
}
}
if(!flag)
return -1;
}
分析一下刚才我们讲解的单链表插入和删除算法,我们发现,它们其实都是由两部分组成:
- 第一部分就是遍历查找第i个元素;
- 第二部分就是插入和删除元素。
从整个算法来说,我们很容易推导出:它们的时间复杂度都是0(n)。如果在我们不知道第i个元素的指针位置,单链表数据结构在插入和删除操作上,与线性表的顺序存储结构是没有太大优势的。但如果,我们希望从第i个位置,插入10个元素,对于顺序存储结构意味着,每一次插入都需要移动n-i个元素,每次都是0(n)。而单链表,我们只需要在第一次时,找到第i个位置的指针,此时为 0(n),接下来只是简单地通过赋值移动指针而已,时间复杂度都是0(1)。显然,对于插入或删除数据越频繁的操作,单链表的效率优势就越是明显。
链表的特点
- 链表采用链式存储结构,用一组任意的存储单元存放线性表的元素
- 链表不需要提前分配存储空间,只要有就可以分配,元素个数也不受限制
- 链表不会造成空间的"碎片化"
- 正常情况下,链表的插入删除的时间复杂度都为O(n),但是链表在找出某位置的指针后,后续插入和删除时间仅为O(1)
到此线性表的两种存储结构就大部分完成了,那么我们可以通过对比它们两者之间差异得到以下结论:
- 若线性表需要频繁查找,很少进行插入和删除操作时,宜采用顺序存储结构。若需要频繁插入和删除时,宜采用单链表结构。比如说游戏开发中,对于用户注册的个人信息,除了注册时插入数据外,绝大多数情况都是读取,所以应该考虑用顺序存储结构。而游戏中的玩家的武器或者装备列表,随着玩家的游戏过程中,可能会随时增加或删除,此时再用顺序存储就不太合适了,单链表结构就可以大展拳脚。当然,这只是简单的类比,现实中的软件开发,要考虑的问题会复杂得多。
- 当线性表中的元素个数变化较大或者根本不知道有多大时,最好用单链表结构,这样可以不需要考虑存储空间的大小问题。而如果事先知道线性表的大致长度,比如一年12个月,一周就是星期一至星期日共七天,这种用顺序存储结构效率会高很多。
注意:该查找是指顺序表中第i个数据为多少,由于顺序表可以随机访问所以它的时间复杂度为O(1)。
总之,线性表的顺序存储结构和单链表结构各有其优缺点,不能简单的说哪个好,哪个不好,需要根据实际情况,来综合平衡采用哪种数据结构更能满足和达到需求和性能。