线性表是最常用且是最简单的一种数据结构。
定义 线性表:零个或多个数据元素的有限序列。
1.线性表是一个序列。
2.0个元素构成的线性表是空表。
3.线性表中的第一个元素无前驱,最后一个元素无后继,其他元素有且只有一个前驱和后继。
4.线性表是有长度的,其长度就是元素个数,且线性表的元素个数是有限的,也就是说,线性表的长度是有限的。
线性表的基本操作
InitList(*L): 初始化操作,建立一个空的线性表L。
ListEmpty(L): 判断线性表是否为空表,若线性表为空,返回true,否则返回false。
ClearList(*L): 将线性表清空。 GetElem(L,i,*e): 将线性表L中的第i个位置元素值返回给e。
LocateElem(L,e): 在线性表L中查找与给定值e相等的元素,如果查找成功,返回该元素在表中序号表示成功;否则,返回0表示失败。
ListInsert(*L,i,e): 在线性表L中第i个位置插入新元素e。
ListDelete(*L,i,*e): 删除线性表L中第i个位置元素,并用e返回其值。
ListLength(L): 返回线性表L的元素个数。
线性表的顺序存储结构 1线性结构 2顺序结构
线性结构
1 顺序表是指顺序存储结构的线性表,指的是用一段地址连续的存储单元依次存储线性表的数据元素。
顺序表表现在物理内存中,也就是物理上的存储方式,事实上就是在内存中找个初始地址,然后通过占位的形式,把一定的内存空间给占了,然后把相同数据类型的数据元素依次放在这块空地中。注意,这块物理内存的地址空间是连续的。
举 个例子,比如C语言中的基本变量的存储就是连续的存储在内存中的,比如声明一个整数i,在64位系统中整数i在内存中占8字节,那么系统就会在内存中为这 个整型变量分配一个长度为8个字节的连续的地址空间,然后把这个i的二进制形式从高地址向低地址存储,长度不足时候,最高位用0补齐。
线性表的操作
1 获取元素操作
对于线性表的顺序存储结构来说,我们要实现GetElem操作,即将线性表L中的第i个位置元素返回,其实是非常简单的。就程序而言,只要第i个元素在下标范围内,就是把数组第i - 1下表值返回即可。
来看代码:
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0
typedef int Status;
//Status是函数的类型,其值是函数结果状态代码,如OK等
//初始条件:顺序线性表L已经存在,1 ≤ i ≤ ListLength(L)
//操作结果:用e返回L中第i个元素的值
Status GetElem(SqList L,int i,ElemType *e)
{
if(L.length == 0 || i < 1 || i > L.length)
return ERROR;
*e = L.data[i - 1];
return OK;
}
注意这里返回值类型Status是一个整型,返回OK代表1,ERROR代表0。
2 插入操作
刚才我们也谈到,这里的时间复杂度为O(1)。我们现在来考虑,如果我们要实现ListInsert(*L,i,e),即在线性表L中第i个位置插入新元素e,应该如何操作?
插入算法的思路:
①如果插入位置不合理,抛出异常;
②如果线性表长度大于等于数组长度,则抛出异常或动态增加容量;
③从最后一个元素开始向前遍历到第i个元素,分别将它们都向后移一位;
④将要插入元素填入位置i处;
⑤表长加1。
实现代码如下:
//初始条件:顺序线性表L已存在,1 ≤ i ≤ ListLength(L)
//操作结果:在L的第i个位置插入新的数据元素e,L的长度加1
Status ListInsert(SqList *L,int i,ElemType e)
{
int k;
if(L->length == MAXSIZE)//当线性表已满
return ERROR;
if(i < 1 || i >L->length + 1)//当i不在范围内时
{
return ERROR;
}
if(i <= L->length)//若插入数据位置不在表尾
{
for(k = L->length-1;k > i-1;k--)
{
L->data[k + 1] = L->data[k];
}
}
L->data[i - 1] = e;//将新元素插入
L->length++;
return OK;
}
3 删除操作
删除算法的思路:
①如果删除位置不合理,抛出异常;
②取出删除元素;
③从删除元素位置开始遍历到最后一个元素位置,分别将它们向前移动一个位置;
④表长减1。
实现代码如下:
//初始条件:顺序线性表L已经存在,1 <= i <= ListLength(L)
//操作结果:删除L的第i个元素,并用e返回其值,L的长度减1
Status ListDelete(SqList *L ,int i , ElemType *e)
{
int k;
if(L->length == 0)//线性表为空
return ERROR;
if(i < 1 || i > L->length)//删除位置不正确
return ERROR;
*e = L->data[i];
if(i < L->length)
{
for(k = i;k < L->length;k++)
L->data[k - 1] = L->data[k];
}
L->length--;
return OK;
}
现在,我们来分析一下,插入和删除的事件复杂度。
现在我们来看最好的情况,如果一个元素要插入到最后一个位置,或者删除最后一个位置,此时时间复杂度为O(1),因为不需要移动元素的。
最坏的情况呢,如果元素要插入到第一个位置或者删除第一个元素,此时时间复杂度是多少呢?那就意味着所有元素向后或者向前,所以这个时间复杂度为O(n)。
至于平均的情况,由于元素插入到第i个位置,或者删除第i个元素,需要移动n - i个元素,每个位置插入或删除元素的可能性是相同的,也就是位置靠前,移动元素多,位置靠后,移动元素少。最终平均移动次数和最中间那个元素的移动次数相等,为(n - 1)/ 2。
根据时间复杂度的推导,平均时间复杂度还是O(n)。
这说明说明?线性表的顺序存储结构,在存、读数据时,不管是哪个位置,时间复杂度都是O(1);而插入或删除时,时间复杂度都是O(n)。这就说明,它比较适合元素个数不太变化,而更多是存取数据的应用
链式结构
前面我们讲的线性表的顺序存储结构,它最大的缺点就是插入和删除时需要移动大量元素,这显然就需要耗费时间。
那我们能不能针对这个缺陷或者说遗憾提出解决的方法呢?要解决这个问题,我们就得考虑一下导致这个问题的原因!
为什么当插入和删除时,就要移动大量的元素?
原因就在于相邻两元素的存储位置也具有邻居关系,它们在内存中的位置是紧挨着的,中间没有间隙,当然就无法快速插入和删除。
线性表的链式存储结构的特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以存在内存中未被占用的任意位置。
头指针与头结点的异同点。
头指针
① 头指针是指链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针
②头指针具有标识作用,所以常用头指针冠以链表的名字
③无论链表是否为空,头指针均不为空。头指针是链表的必要元素。
头结点
①头结点是为了操作的统一和方便而设立的,放在第一元素的结点之间,其数据域一般无意义。
②有了头结点,对在第一元素结点前插入结点,其操作与其它结点的操作就统一了。
③头结点不一定是链表必须要素。
单链表
式存储结构中,除了要存储数据元素信息外,还要存储它的后继元素的存储地址(指针)。
也就是说除了存储其本身的信息外,还需存储一个指示其直接后继的存储位置的信息。
我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。
指针域中存储的信息称为指针或链。
这两部分信息组成数据元素称为存储映像,或称为结点(Node)。
n个结点链接成一个链表,即为线性表(a1, a2, a3, …, an)的链式存储结构。
因为此链表的每个结点中只包含一个指针域,所以叫做单链表。
单链表结构与顺序结构的优缺点
简单地对单链表结构和顺序存储结构作对比。
1、存储分配方式:
顺序存储结构有一段连续的存储单元依然存储线性表的数据元素。
单链表采用链式存储结构,用一组任意的存储单元存放线性表的玩意。
2、时间性能:
查找:
- 顺序存储结构O(1)
- 单链表O(n)
插入与删除
- 顺序存储结构需要平均移动表长一半的元素,时间为O(n)
- 单链表在线出某位置的指针后,插入和删除时间仅为O(1)
3、空间性能
- 顺序存储结构需要预分配存储空间,分大了,浪费,分小了易发生上溢。
- 单链表不需要分配存储空间,只要有就可以分配,元素个数也不受限制。
通过上面的对比,我们可以得出一些经验性的结论:
若线性表需要频繁查找,很少进入插入和删除操作时,宜采用顺序存储结构。
若需要频繁插入和删除时,宜采用单链表结构。
比如游戏开发中,对于用户注册的个人信息,除了注册时插入数据外,绝大多数情况下都是读取,所以应该考虑用顺序存储结构。而游戏中的玩家的武器或者装备列表,随着玩家游戏过程中,可能随时增加或删除,此时应该用单链表比较合适。当然,这只是简单地类比。现实生活中的软件开发,要考虑的问题会复杂得多。
当线性表中的元素个数变化较大或者根本不知道有多大时,最好用单链表结构,这样可以不用考虑存储空间大小问题。
而如果事先知道线性表的大致长度,比如一年12个月,这种用顺序存储结构效率会高很多。
总之,线性表的顺序存储结构和单链表结构各有其优点,不是简单地说哪个不好,需要根据实际情况,来综合平衡采用哪种数据更能满足和达到需求和性能。
链表的各种变形
1 双向链表
在单链表中,有了next指针,这就使得我们要查找下一结点的事件复杂度为O(1)。可是如果我们要查找的是上一节点的话,那最坏的时间复杂度就是O(n)了,因为我们每次都要从头开始遍历寻找。
为了克服单向性这一缺点,设计出了双向链表。双向链表(double linked list)是在单链表的每个结点中,再设置一个指向其前驱结点的指针域。所以在双向链表中的结点都有两个指针域,一个指向直接后继,一个指向直接前驱。
2 静态链表
C语言具有指针能力,使得它可以非常容易地操作内存中的地址和数据,这比其他高级语言更加方便灵活。
后来的面向对象语言,如Java、C#等,虽不使用指针,但因为启用了对象引用机制,从某种角度上也间接实现了指针的某些作用。但对于一些语言,如Basic、Fortran等早期的编程高级语言,由于没有指针,链表结构就没办法实现。
有人想出用数组来代替指针,来描述链表。
首先我们用数组的元素都是由两个数据域组成,data和cur。也就是说,数组的每个下表都对应一个data和一个cur。数据域data,用来存放数据元素,也就是通常我们要处理的数据;而cur相当于单链表中的next指针,存放该元素后继在数组中的下表,我们把cur叫做游标。
我们把这种用数组描述的链表叫静态链表,这种描述方法还有起名叫做游标实现法。
为了我们方便插入数据,我们通常会把数组建立得大一些,以便有一些空闲空间可以方便插入不至于溢出。
3循环链表
对于单个链表,由于每个结点只存储了向后的指针,到了尾标志就停止了向后链的操作,这样当中某一结点就无法找到它的前驱结点了。
将单链表中终端结点的指针由空指针改为指向头结点,就使整个单链表形成一个环, 这种头尾相接的单链表称为单循环链表,简称循环链表。
循环链表解决了一个很麻烦的问题,如何从当中一个结点出发,访问到链表的全部结点。
循环链表和单链表的主要差异就是在于循环的判断条件上,原来是判断p->next是否为空,现在则是p->next不等于头结点,则循环未结束。