目录
前言
上一篇学习链表相关内容我们介绍了如何创建静态单链表和动态单链表(初始化、内存分配、头插法和尾插法创建链表),以及带头节点单链表的增删改查。
接下来的本篇将会开始涉及数据结构中线性表的相关知识点,补充循环链表以及双向链表等等。
一、数据结构之线性表
线性表指的是相同数据类型的n个数据元素的有限序列。除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个直接后继。
元素关键字:有限、有序、同类、逻辑结构(对数据间关系的描述)
对于线性表而言,它有两种存储结构:顺序表和链表
注:线性表是逻辑结构,顺序表和链表是存储结构。
1、顺序表
概念:用一段地址连续的存储单元(一维数组)依次存储线性表中的各元素。
下面是顺序表的结构代码:
#define MAX 20 //数组长度(存储空间初始分配长度)
typedef int Type;
typedef struct {
Type data[MAX]; //数组
int howlong; //线性表的当前长度
}List; //重命名
看到这里应该明白了:用数组存放线性表,即为实现顺序表。但要区分一点:数组长度和线性表长度。
数组长度:存放线性表的存储空间的长度,在刚开始就申请好了,是固定值;
线性表长度:是可以变化的,只要不超过数组长度,就可以随意插入和删除元素。
顺序表是通过数组来实现的,那么就有一个优点:地址计算更加方便。在逻辑上相邻的元素,物理上也是相邻的,因此相关元素地址可以直接推出来。
说到数组就有一点:数组下标是从0开始的,而线性表是从1开始的,相对应位置关系如下图:
看了上述图中结构再结合顺序表地址连续的特性,我们大概也能想得到:如果我们要在顺序表中插入元素,那么就会像人插队一样,插入位置后面的元素都要往后挪一位给插入元素腾出位置。因此插入算法思路如下:
(1)顺序表插入算法
- 从最后一个元素开始向前遍历到需要插入元素位置i,将它们都向后移动一个位置
- 将插入元素a填入i处
- 顺序表长度+1
代码实现如下:
//在顺序表L中的第i个位置前插入一个元素e
for(j=L->howlong-1;j>=i-1;j--) //1操作
{
L->data[j+1]=L->data[j];
}
L->data[i-1]=e; //2操作
L->howlong++; //3操作
理解了顺序表的插入,那么接下来顺序表的删除也就同理可得:
(2)顺序表删除算法
- 取出删除元素
- 从删除位置开始往后遍历直到最后一个元素,使得它们都向前移一个位置
- 顺序表长度-1
与插入不太一样的点在于删除操作先取元素再遍历。代码实现如下:
//删除顺序表L中第i个位置上的数据
for(j=i;j<L->howlong;j++) //2操作
{
L->data[j-1]=L->data[j];
}
L->howlong--; //3操作
接下来是查找操作,主要靠返回值实现:
(3)顺序表插入算法
- 按位查找
- 按值查找
按位查找
//查找第i项
Type Find(List L, int i)
{
return L.data[i-1];
}
按值查找
//在顺序表L中查找一个元素值为e的元素,并返回其位置
int Locate(List L, int e){
for(int i=0; i<L.length; i++){
if(L.data[i]==e){
return i+1;//数组下标为i的元素值等于e,返回其位序i+1
}
return 0;//退出循环,说明查找失败
}
}
上面归纳了线性表的顺序存储结构。我们会发现对于这种结构而言,插入或删除元素会变得尤其麻烦,时间复杂度为O(n)也表明其耗时较长。
那么针对线性表,有没有什么方法能够解决这个问题呢?接下来就是线性表的链式存储结构就要登场了:
2、链表
概念:用一组任意的存储单元(可以是连续的也可以是不连续的)存储线性表中的各元素。
我们看看顺序存储结构和链式存储结构的定义对比:
也就是说相比于顺序表,链表结构的不同点在于它可“乱”,不必像顺序表那样需要排排好。like this:
上篇博客已经对链表定义、单链表的整表创建(头插+尾插)、链表的增删查改做了详细说明:学习链表相关(上)_Yan__Ran的博客-CSDN博客,接下来对剩余内容进行补充。
(1)单链表的整表删除
当我们不打算使用这个单链表的时,我们打算将其销毁释放空间,以便留出空间给其他程序使用。算法思路如下:
- 声明指针p和q
- 将第一个结点赋给p
- 做循环(将下一结点赋给q、释放p、将q赋给p)
代码实现如下:
//存在链表L,现要将L置为空表
void REMAKE(Link *L)
{
Link p,q; //操作1
p = (*L)->next; //操作2
while(p) //操作3
{
q = p->next; /*q指针不是多余的!相当于一个中间工具人,用来记录住谁是下一个结点,
以便于释放了上一个结点后把下一个结点拿来补充*/
free(p);
p = q;
}
(*L)->next = NULL; //将头结点指针域置空
return OK;
}
(2)静态链表
一些早期的高级语言没有指针操作
那么它们如何实现链表操作呢?
没错,使用数组代替指针可以解决这一问题。我们将用数组实现的链表叫做静态链表。
静态链表中每个数组的元素都有两个数据域,我们暂定为data和cur。其中cur(游标)相当于单链表中的next指针,用于存放后继数组的下标。
![](https://i-blog.csdnimg.cn/blog_migrate/c75b2863620b95440cf289ed1e73994c.png)
![](https://i-blog.csdnimg.cn/blog_migrate/e6c0afc87462bcb2205d2dd8e258f0b7.jpeg)
几个特殊结点的cur:
- 数组第一个元素(下标为0的结点):不包含有意义的数据,它的cur存储的是备用链表(空闲空间)第一个结点的下标。(如上图2中数组第一个元素的cur存放的是7)
- 数组最后一个元素:这里的cur存放的是第一个有实际数据结点的下标。
- 链表最后一个元素:(不一定是数组最后一个元素)这里的的cur一般存放0,表示它后面的结点为空。
静态链表的结构定义及其初始化
#define MAX 1000
/*静态链表结构定义*/
typedef struct{
int data;
int cur; //静态链表中的游标
}List,numb[MAX];
/*静态链表的初始化*/
void statices(numb space)
{
int i;
for(i=0;i<MAX-1;i++)
{
space[i].cur= i+1; //每个结点的游标指向下一个结点
}
space[MAX-1].cur=0; //备用区最后一个空间,后无可用空间,游标为0
}
静态链表的插入
对于静态链表的插入算法,我觉得是个很有意思的思路。
我们再拿上面的图2做例子,假设我现在要在“乙”和“丁”之间插入一个“丙”:
- 先叫丙跟在队尾,先到游标为8的位置上呆着;
- 接着去和乙说:“你的cur已经不再是游标为4的丁了,把你的cur改成8。”
- 接着再回去和丙说:“你把你的cur改为4。”
就这样在不影响表中其他元素的情况下,三步就完成了丙元素的插入。代码实现如下:
/*做插入操作前先给插入元素安排一个空闲位置*/
int Malloc(numb space)
{
int i = space[0].cur; //把第一个备用空闲区域的下标赋给i
if(space[0].cur)
{
space[0].cur = space[i].cur; //此处用于更新,调用一次更新一个空闲区域
}
return i; //返回上面那个空闲区域的下标
}
/*在静态链表L的游标i处插入数据e*/
void Putdata(numb L,int i,int e)
{
int j,k,l; //j相当于丙的下标,k相当于乙的下标
k = MAX-1; //最后一个数组元素的下标,这时候k=999
j = Malloc(L); //调用函数获得一个空闲区域的下标
if(j)
{
L[j].data = e; //在空闲区域处插入数据
for(l=1;l<=i-1;l++) //循环用于找到第i个元素之前的位置
{
k = L[k].cur; /*此时k是999,也就是数组最后一个结点,再+1就到了
第一个有数据结点的位置,接下来一个个往下走一直到i之前*/
}
L[j].cur = L[k].cur; //这步就相当于让丙把cur改成4(让丙的下一个元素是丁)
L[k].cur = j; //让乙把cur改成8(让乙的下一个元素是丙)
}
}
没理解可能会觉得有点复杂,理解了之后就会发现其实也就还好。
静态链表的删除
这时候甲突然离开了,于是乙成了第一个元素,而原本甲存在的位置成了空的。这时我们要手动回收这个空位置,使得如果再有元素加入时,优先考虑加入这个位置上:
/*将下标为k的结点回收到备用链表,当有新元素加入的时候优先考虑使用这个位置*/
void Free(numb space,int k) //k是要删除结点的下标
{
space[k].cur = space[0].cur; //把第一个元素的cur赋给要删除元素的cur
space[0].cur = k; //再把要删除结点的下标赋给第一个元素的cur
}
![](https://i-blog.csdnimg.cn/blog_migrate/e6c0afc87462bcb2205d2dd8e258f0b7.jpeg)
接下来是删除操作:
/*删除L中的第i个元素*/
void Delete(numb L,int i)
{
int j,k;
k = MAX-1; //K=999
for(j=1;j<=i-1;j++) //循环用于找到删除元素i之前的位置
{
k = L[k].cur;/*此时k是999,也就是数组最后一个结点,再+1就到了
第一个有数据结点的位置,接下来一个个往下走一直到i之前*/
}
j = L[k].cur; //让j变成删除元素的前一个元素的cur(指向删除元素)
L[k].cur = L[j].cur; //更新表头元素,让最后一个元素的cur变成甲的cur(指向乙)
Free(L,j); //调用函数让删除节点位置回收到备用链表中
}
说了那么多,其实就是理解好数组头和数组尾的cur所对应的位置, 就很好写了。
静态链表写完我真的会感慨指针的便利性。其实静态链表在支持指针操作的高级语言中很少被用到(指针不比这香?),但这种思考方式倒是挺巧妙的。就像书里说的,可以理解它的思想以备不时之需。
(3) 循环链表
![](https://i-blog.csdnimg.cn/blog_migrate/7067fb74f17834efcfa2dfbb7eb2025b.png)
概念:将单链表中终端结点的指针端由空指针改为指向头结点,头尾相连,就使整个单链表形成一个环。
循环链表与单链表的主要差异在于循环的判断上,原来是判断p->next是否为空,现在则是p->next不等于头结点,则循环未结束。
但是如果要遍历到最后一个元素,使用头指针的时间复杂度是O(n)。若此时我们不用头指针改用尾指针,查找最后一个和第一个的时间复杂度就都是O(1)了:
![](https://i-blog.csdnimg.cn/blog_migrate/e37ff85a7dc2e4c7cf9534856600a7d7.png)
(4)双向链表
概念:在单链表的每个结点中,再设置一个指向前驱结点的指针域。也就是说在双向链表中,每个结点都有两个指针域(一个指向直接前驱,一个指向直接后继)
双向链表其实多少有点用空间换时间的意思。
双向链表的结构定义如下:
#include <stdio.h>
typedef struct
{
int data; //数据域
struct DouList *before; //直接前驱指针域
struct DouList *next; //直接后继指针域
}DouList,*DouLinkList;
因为双向链表是单链表扩展出的结构,所以有很多操作都与单链表有着相似之处。但对于链表元素的插入和删除,就需要更改两个指针变量。
双向链表的插入
操作顺序:先搞定s(插入结点)的前驱和后继,再搞定p->next的前驱,最后搞定p的后继。
/*图中插入操作*/
s->before = p; //1操作
s->next = p->next; //2操作
p->next->before = s; //3操作
p->next = s; //4操作
顺序不能改变!顺序不能改变!顺序不能改变!
双向链表的删除
操作顺序:先搞定前驱结点的后继,再搞定后继结点的前驱。
p->before-next = p->next;
p->next->befor = p->before;
free(p);
记得最后要释放掉删除结点的内存。
一图总结本篇博客:
同时可以总结出一些线性表相关的结论:
- 当线性表需要进行频繁插入和删除时,宜采用单链表结构(时间复杂度仅为O(1))
- 当线性表中的元素个数变化大或者不确定时,适合用单链表结构(可以不需要考虑存储空间的大小问题);而如果线性表的长度确定,则适合用顺序表结构(效率高)