线性表链式存储结构
线性表的链式存储结构的特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以存在内存中未被占用的任意位置。
在链式存储结构中,除了要存储数据结构元素信息外,还要存储它的后继元素的存储地址(指针)。
存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称为指针或链。这两部分信息组成数据元素称为存储映像,称为结点(Node)。
n个结点链接成一个链表,即为线性表(a1,a2,a3…,an)的链式存储结构。
因为此链表的每个结点中只包含一个指针域,所以叫做单链表。
对于线性表来说总有个头有个尾,链表也不例外。链表的第一个几点的存储位置称为头指针,最后一个结点指针为空(NULL)。
头指针与头结点的异同
- 头指针是指链表指向第一个结点的指针,若链表有头结点,则指向头结点的指针。
- 头指针具有标识作用,所以常用头指针冠以链表的名字(指针变量的名字)。
- 无论链表是否为空,头指针均不为空。
- 头指针是链表的必要因素。
单链表存储结构
#define OK 1
#define ERROR 0
#define MAXSIZE 20
typedef int ElemType; //线性表数据类型
typedef struct Node
{
ElemType data; //数据域
struct Node *Next; //指针域
}Node;
typedef struct Node *LinkList;
结点有存放数据元素的数据元素的数据域和存放后继结点地址的指针域组成。
单链表的整表创建
对于每个单链表来说,它所占的空间的大小和位置不需要预先分配划定,可以根据实际的需要即时创建。
单链表的创建有两种方法,分别是头插法和尾插法,两者的差别是:头插法建立链表的算法虽然简单,但生成的链表中结点的次序和输入的顺序相反。
头插法
算法思路:
- 创建工作指针p,尾指针L,头指针h
- 初始化h的数据域和链域
- 用h给L的链域赋值,以此来初始化L
- 将数据写入p的数据域
- 将p与L前一个结点链接(p->h)
- 将L与前一个结点的链接断开,将L与p链接,以此来更新结点
代码实现:
//头插法
LinkList creat(int n)
{
LinkList p, L,h; //p为工作指针,L为尾指针,h为头指针
L = (LinkList)malloc(sizeof(Node));
h = (LinkList)malloc(sizeof(Node)); //为L,h分配内存
h->data = 0;
h->Next = NULL;
L->Next = h; //初始化L
for (int i = 0; i < n; i++)
{
if ((p = (LinkList)malloc(sizeof(Node))) == NULL)
{
printf("不能分配空间!");
exit(0);
}
printf("第%d个结点的数据域:", i + 1);
scanf("%d", &p->data);
p->Next = L->Next; //将p与L前一个结点链接
L->Next = p; //将L与前一个结点的链接断开,将L与p链接
}
return L;
}
尾插法
算法思路:
- 创建三个指针p(当前节点的前一个节点),s(当前节点),h(头结点指针)
- new h;
- 初始化h
- p = h;
- new s;p->Next=s;(将p与s链接起来)
- s->data=…(数据域写入数据)
- 清空s的链域,并更新p的链域为s
- 返回头结点指针h
代码实现:
LinkList creat(int n) //链表的初始化
{
LinkList p, h, s; /* *h保存表头结点的指针,*p指向当前结点的前一个结点,*s指向当前结点*/
int i; //计数器
if ((h = (LinkList)malloc(sizeof(Node))) == NULL)
{
printf("不能分配内存空间\n");
exit(0);
}
h->data = n; //表头结点数据域初始化为表长
h->Next = NULL; //表头结点的链域置空
p = h; //p指向表头结点
for (i = 0; i < n; i++)
{
if ((s = (LinkList)malloc(sizeof(Node))) == NULL) //分配新存储空间并检测,中介指针的创建
{
printf("不能分配内存空间\n");
exit(0);
}
p->Next = s; //将s的地址赋值给p所指向结点的链域,将p与s链接起来
printf("第%d个结点的数据域:", i + 1);
scanf("%d",&s->data); //将数据存入当前节点的数据域
s->Next = NULL;
p = s; //结点的更新
}
p->Next = NULL;
return h; //返回链表的头指针
}
单链表的读取
在单链表中,读取数据成员需要从头开始找,直到找到第i个元素为止。
所以,时间复杂度取决于i的位置,当i为1时,则不需要遍历,而i=n时,则遍历n-1次,因此最坏情况的时间复杂度为O(n).
链表的核心思想叫做“工作指针后移”,这其实是很多算法的常用技术。
实例:
int GetElem(LinkList L,int i,ElemType *e) //单链表的读取
{
int j;
LinkList p;
p = L->Next; //p指向第一个结点
j = 1;
while (p && j < i)
{
p = p->Next;
j++;
}
if (!p || i > j)
return ERROR;
*e = p->data;
return OK;
}
单链表结点的插入
算法思路:
- 创建头结点p,插入结点s
- 头结点p的初始化
- 找到第i个结点
- 为插入结点s开辟内存
- 将p的链域赋值给s的链域
- 结点s的地址赋值给p的链域
代码实现:
int Listinsert(LinkList L,int i,ElemType e)
{
int j;
LinkList p, s; //头结点、插入结点
p = L;
j = 1;
while (p && j < i) //寻找第i个结点
{
p = p->Next;
j++;
}
if (!p || j > i)
return ERROR;
s = (LinkList)malloc(sizeof(Node)); //开辟内存,存放数据和指针
s->data = e;
s->Next = p->Next; //不能调换顺序
p->Next = s;
return OK;
}
单链表结点的删除
算法思路:
- 创建结点p,将存放第一个元素的结点的地址赋值给p
- 找到存放第i-1个元素的结点,并赋值给p
- 用第i个元素的结点的链域给p的链域赋值
代码实现:
int Listdelete(LinkList L, int i, ElemType* e)
{
LinkList p;
int j;
p = L->Next;
j = 1;
if (i < 1 || p == NULL) //i不为正数,链表为空
return ERROR;
while (p && j < i-1)
{
p = p->Next;
j++;
}
if (!(p->Next) || j > i) //找不到或已到达链表的末端
return ERROR;
*e = p->Next->data;
p->Next = p->Next->Next;
return OK;
}
代码实现2:
int Listdelete(LinkList L, int i, ElemType* e)
{
int j;
LinkList p, q;
p = L; //头结点指针
j = 1;
while (p->Next && j < i) //寻找第i个结点
{
p = p->Next;
j++;
}
if (!(p->Next) || j > i) //没有找到或已到达链表的末端
return ERROR;
q = p->Next;
p->Next = q->Next;
*e = q->data;
free(q);
return OK;
}
单链表的整表删除
算法思路:
- 声明结点p和q
- 将第一个结点赋值给p,下一结点赋值给q
- 循环执行释放p和将q赋值给p的操作
代码实现:
//单链表整表的删除
int ClearList(LinkList L)
{
LinkList p, q;
p = L->Next; //将第一个结点赋值给p
while (p)
{
q = p->Next; //将下一结点赋值给q
free(p); //释放当前结点
p = q;
}
L->Next = NULL;
return OK;
}
这段算法代码里,常见的错误是:可能会觉得q是没有必要的存在,只需要在循环体中直接写 free§;p=p->Next;
但是,p是一个结点,它除了有数据域,还有指针域,当我们做free§时,其实是对整个结点的删除和内存的释放,也就是说,此时p->Next以及不存在了,就不能进行赋值了,所以我们要用q来记载p的下一个结点
总结
注意:
- 第i个结点存放的不是第i个元素,而是第i-1个元素,因为第一个节点是头结点,该结点的数据域中一般不存放任何数据,有时会存放表长。
- 因此,这也就造成了两种写法,一种是将指针p初始化为头结点地址(p=head),另外一种是将指针p初始化为存放第一个元素的结点的地址(p=head->Next)
- 但是,在寻找的时候,第一种写法(j<i-1),第二种写法(j<i),都是在寻找第i个结点,即存放第i-1个元素的结点。
总结:
- 无论是单链表的插入还是删除算法,第一步都是遍历寻找第i个元素,然后进行插入或者删除操作。从时间复杂度来看,是O(n).
- 那么,链式存储结构相比于顺序存储结构,它的优势究竟在哪里呢?
- 事实上是这样的,如果我们想在第i个位置开始,连续插入10个元素;对于顺序存储结构来说,每一次插入都需要移动n-1给位置,所以每次是O(n)。而对于单链表来说呢,找到第i个位置的指针,此时是O(n),而接下来就是简单的赋值移动指针,而已,时间复杂都都是O(1)。显然,对于频繁的插入和删除操作来说单链表的效率优势就在这里了~
单链表结构与顺序存储结构的优缺点
我们从存储分配方式、时间性能、空间性能三方面来做对比:
-
存储分配方式
a.顺序存储结构用一段连续的存储单元依次存储线性表的数据元素。
b.单链表采用链式存储结构,用一段任意的存储单元存放线性表的元素。 -
时间性能
查找
顺序存储结构O(1)
单链表O(n)插入和删除
顺序存储结构需要平均移动表厂一半的元素,时间为O(n)
单链表在计算出某位置的指针后,插入和删除时间仅为O(1) -
空间性能
顺序存储结构需要预先分配存储空间,分大了,容易造成空间浪费,分小了,容易发生溢出。
单链表不需要分配存储空间,只要有就可以分配,元素个数也不受限制。 -
综上所述:
如果线性表需要频繁查找,很少进行插入和删除操作时,宜采用顺序存储结构。若需要频繁插入和删除时,宜采用单链表结构。
腾讯算法题:中间结点算法题
如何求一个链表的中间结点呢?
比较直接的方法是先求出链表的长度L,然后用遍历的方法求得中间结点。时间复杂度为O(L + L/2).
代码实现:
LinkList mid(LinkList L)
{
LinkList p;
p = (LinkList)malloc(sizeof(Node));
p = L->Next;
int j = 0;
while (p)
{
p = p->Next;
j++;
}
p = L->Next;
for (int i = 0; i < j / 2; i++)
{
p = p->Next;
}
return p;
}
好的算法可以让面试官给你加分。
因此,快慢指针法的算法复杂度为O(L/2),定义一个快指针search,每次自增2,定义一个慢指针mid,每次自增1,当search到达链表的末尾时,mid为中间结点。
代码实现:
//快慢指针法
LinkList GetMid(LinkList L)
{
LinkList search, mid;
mid = search = L;
while (search->next !=NULL)
{
if (search->next->next !=NULL)
{
search = search->next->next;
mid = mid->next;
}
else
{
search = search->next;
mid = mid->next;
}
}
return mid;
}