算法与数据结构(2) ---线性表

线性表是最常用且是最简单的一种数据结构。

定义 线性表:零个或多个数据元素的有限序列。


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)。这就说明,它比较适合元素个数不太变化,而更多是存取数据的应用



线性表的顺序存储结构,在存、读取数据时,不管是在哪个位置,时间复杂度都是O(1)。而在插入或者删除时,时间复杂度都是O(n)。
这也就是线性表的顺序存储结构比较适合存取数据,不适合经常插入和删除数据的应用。
优点:
1.无需为了表示表中元素之间的逻辑关系而增加额外的存储空间(相对于链式存储而言)。
2.可以快速的存取表中任意位置的元素。
缺点:
1.插入和删除操作需要移动大量的元素。
2.当线性表长度变化较大时,难以确定存储空间的容量。
3.容易造成存储空间的“碎片”(因为线性表的顺序存储结构申请的内存空间都以连续的,如果因为某些操作(比如删除操作)导致某个部分出现了一小块的不连续内存空间,因为这一小块内存空间太小不能够再次被利用/分配,那么就造成了内存浪费,也就是“碎片”)


链式结构

前面我们讲的线性表的顺序存储结构,它最大的缺点就是插入和删除时需要移动大量元素,这显然就需要耗费时间。

那我们能不能针对这个缺陷或者说遗憾提出解决的方法呢?要解决这个问题,我们就得考虑一下导致这个问题的原因!

为什么当插入和删除时,就要移动大量的元素?

原因就在于相邻两元素的存储位置也具有邻居关系,它们在内存中的位置是紧挨着的,中间没有间隙,当然就无法快速插入和删除。

线性表的链式存储结构的特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以存在内存中未被占用的任意位置。

也就是说,链式存储结构的线性表由一个(可以使零)或者多个结点(Node)组成。每个节点内部又分为数据域和指针域(链)。数据域存储了数据元素的信息。指针域存储了当前结点指向的直接后继的指针地址。
因为每个结点只包含一个指针域,所以叫做单链表。顾名思义,当然还有双链表。

头指针与头结点的异同点。
头指针
① 头指针是指链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针

②头指针具有标识作用,所以常用头指针冠以链表的名字

③无论链表是否为空,头指针均不为空。头指针是链表的必要元素。

头结点
①头结点是为了操作的统一和方便而设立的,放在第一元素的结点之间,其数据域一般无意义。

②有了头结点,对在第一元素结点前插入结点,其操作与其它结点的操作就统一了。

③头结点不一定是链表必须要素。

单链表

式存储结构中,除了要存储数据元素信息外,还要存储它的后继元素的存储地址(指针)。

也就是说除了存储其本身的信息外,还需存储一个指示其直接后继的存储位置的信息。

我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。

指针域中存储的信息称为指针或链。

这两部分信息组成数据元素称为存储映像,或称为结点(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不等于头结点,则循环未结束。




























  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值