[大话数据结构-读书笔记] 线性表

线性表

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



1 线性表的定义

线性表,从名字上你就能感觉到,是具有像线一样的性质的表。例如一个班级的小朋友,一个跟着一个排着队,有一个打头,有一个收尾,当中的小朋友每一个都知道他前面一个是谁,他后面一个是谁,这样如同有一根线把他们串联起来
了,就可以称之为线性袭。

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

线性表的几个关键点:

  • 它是一个序列,元素之间是有个先来后到的顺序。
  • 若元素存在多个,则第一个元素无前驱,而最后一个元素无后继,其他元素都有且只有一个前驱和后继。
  • 线性表强调是有限的。

若将线性表记为(a1,…,ai-1, ai, ai+1, …, an),则表中ai-1领先于ai,称ai-1是ai的直接前驱元素,ai+1是ai的直接后继元素。当i=1, 2,…,
n-1时,ai有旦仅有一个直接后继,当i=2,3,…, n时, ai有且仅有一个直接前驱。如下图所示。

img

所以线性表元素的个数n(n>0) 定义为线性表的长度,当n=0时,称为空表。

在非空表中的每个数据元素都有一个确定的位置,如ai是第一个数据元素,an
最后一个数据元素,ai是第i个数据元素,称i为数据元素ai在线性表中的位序。

在较复杂的线性表中, 一个数据元素可以由若干个数据项组成。

img



2 线性表的抽象数据类型

前面我们已经给了线性表的定义,现在我们来分析一下, 线性表应该有一些什么样的操作呢?

创建和初始化过程。
一开始没经验,把小朋友排好队后, 发现有的高有的矮,队伍很难看,于是就让小朋友解散重新排 一 这是一个线性表 清空重置的操作。 排好了队,我们随时可以叫出队伍某一位置的小朋友名字及他的具体情况。这种可以 根据位序得到数据元素也是一种很重要的线性表操作。
有时我们想知道,某个小朋友,比如麦兜是否是班里的小朋友。这种 查找某个元素是否存在的操作很常用。
而后有家长问老师,班里现在到底有多少个小朋友呀,这种 获得线性表长度的问题也很普遍。
对于一个幼儿园来说,加入一个新的小朋友到队列中,或因某个小朋友生病,需要移除某个位置,都是很正常的情况。对于一个线性表来说, 插入数据和删除数据都是必须的操作。

所以,线性表的抽象数据类型定义如下:

ADT    线性表(List)
Data
        /*线性表的数据对象集合为{a1,a2,……an},每个元素的类型均为DataType。其中,除第一个元素a1外,每一个元素有且只有一个直接前驱元素,除了最后一个元素an外,每一个元素有且只有一个直接后继元素。数据元素之间的关系是一对一的关系。*/
Operation
        InitList(*L):    //初始化操作,建立一个空的线性表L。
        ListEmpty(L):    //若线性表为空,返回true,否则返回false。
        ClearList(*L):    //将线性表清空。
        GetElem(L,i,*e):    //将线性表L中的第i个位置元素值返回给e。
        LocateElem(L,e):    //在线性表L中查找与给定值e相等的元素。
        ListInsert(*L,i,e):    //在线性表L中的第i个位置插入新元素e。
        ListDelete(*L,i,*e):    //删除线性表L中第i个位置元素,并用e返回其值。
        ListLength(L):    //返回线性表L的元素个数。
endADT

对于不同的应用,线性表的基本操作是不同的,上述操作是最基本的,对于实际问题中涉及的关于线性表的更复杂操作,完全可以用这些基本操作的组合来实现。

比如,要实现两个线性表集合A和B的并集操作。即要使得集合A=AUB。 说白了,就是把存在集合B中但并不存在A中的数据元素插入到A中即可。

仔细分析一下这个操作,发现我们只要循环集合B中的每个元素,判断当前元素是否存在A中,若不存在,则插入到A中即可。 思路应该是很容易想到的。

我们假设U表示集合A, Lb表示集合B,则实现的代码如下:

void union(List *La, list *Lb)
{
    int La_len, Lb_len, i;
    ElemType e;              /* 声明与La和Lb相同的数据元素 e */
    La_len = ListLength(La); /* 求线性表的长度 */
    Lb_len = List_length(Lb);
    for(i=1; i<=Lb_len; i++)
    {
        GetElem(Lb, i, e); /* 取Lb中第i个数据元素给e */
        if(!LocateElem(La, e, equal)) /* La中不存在和e相同的数据元素 */
            ListInsert(La, ++La_len, e); /* 插入 */
    }
}    

这里,我们对于union 操作,用到了前面线性表基本操作ListLength、GetElem、
LocateElem、 Listlnsert 等,可见,对于复杂的个性化的操作,其实就是把基本操作组
合起来实现的。



3 线性表的顺序存储结构

3.1 顺序存储定义

线性表有两种物理存储结构:顺序存储结构和链式存储结构。

线性表的顺序存储结构:
用一段地址连续的存储单元依次存储线性表的数据元素。比如数组。

img

顺序存储结构的优缺点:

在存、读数据时,不管是哪个位置,时间复杂度都是O(1)。而插入或删除时,时间复杂度都是O(n)。这说明,它比较适合元素个数比较稳定,不经常插入和删除元素,更多的操作是存取数据的应用。

优点:

  1. 无须为表中元素之间的逻辑关系而增加额外的存储空间(和链式存储比较而言)。
  2. 可以快速地存取表中任意位置的元素。

缺点:

  1. 插入和删除操作需要移动大量元素。
  2. 当线性表长度变化较大时,难以确定存储空间的容量。
  3. 容易造成存储空间的碎片。


3.2 顺序存储方式

线性表的顺序存储方式就是在内存中找个初始地址,然后通过占位的形式,把一定的内存空间给占了,然后把相同数据类型的数据元素依次放在这块空地中。既然线性表的每个数据元素的类型都相同,所以可以用C语言的一维数组来实现顺序存储结构,即把第一个数据元素存到数组下标为0的位置中,接着把线性表相邻的元素存储在数组中相邻的位置。

来看(静态顺序)线性表的顺序存储的结构代码。

#define MAXSIZE 20  /* 存储空间初始分配量 */
typedef int  ElemType; /* "ElemType类型根据实际情况而定, 这里假设为int */
typedef struct
{
    ElemType  data[MAXSIZE];  /* 数组存储数据元素,最大值为MAXSIZE */
    int length; /* 线性表当前长度 */
}pSeqList;

这里,我们发现顺序存储结构需要三个属性:

  • 存储空间的起始位置:数组data,它的存储位置就是存储空间的存储位置。
  • 线性表的最大存储容量:数组长度MaxSize。
  • 线性表的当前长度:length。

注意:这里有两个概念"数组的长度"和"线性表的长度"需要区分一下。数组的长度是存放线性亵的存储空间的长度,存储分配后这个量是一般是不变的。线性表的长度是线性表中数据元素的个数,随着线性表插入和删除操作的进行,
这个量是变化的。


3.3 地址计算方法

C语言中的数组却是从0开始第一个下标的,于是线性表的第i个元素是要存储在数组下标为i-1的位置,即数据元素的序号和存放它的数组下标之间存在对应关系。

所以对于第i个数据元素ai的存储位置可以由a1推算得出:

LOC(Si) = LOC(s1) + (i-1)*c

通过这个公式,你可以随时算出线性表中任意位置的地址,不管它是第一个还是最后一个,都是相同的时间。那么我们对每个线性表位置的存入或者取出数据, 对于计算机来说都是相等的时间,也就是一个常数,因此用时间复杂度的概念来说,它的存取时间性能为0(1)。我们通常把具有这一特点的存储结构称为随机存取结构。


3.4 静态顺序线性表的实现

由于篇幅原因,静态顺序线性表的实现程序不在这里贴出,详细请看我的另一篇数据结构 - 静态顺序线性表的实行(C语言)



4 线性表的链式存储结构

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

这就要使用线性表的链式存储结构了,即链表,其特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。


4.1 线性表链式存储结构定义

链式结构中,为了表示每个数据元素ai与其直接后继数据元素ai+1之间的逻辑关系, 对数据元素ai来说, 除了存储其本身的信息之外,还需存储一个指示其直接后继的信息
(即直接后继的存储位置)。我们把存储数据元素信息的域称为数据域, 把存储直接后继位置的域称为指针域。这两部分信息组成数据元素ai 的存储映像,称为结点(Node)。

n个结点(ai的存储映像) 链结成一个链表,即为线性表(a1, a2,…, an) 的链式存储结构,因为此链表的每个结点中只包含一个指针域,所以叫做单链表。单链表正是通过每个结点的指针域将线性表的数据元素按其逻辑次序链接在一起,如下图所示。

img

我们把链表中第一个结点的存储位置叫做头指针,那么整个链表的存取就必须是从头指针开始进行了。之后的每一个结点,其实就是上一个的后继指针指向的位置。想象一下,最后一个结点,它的指针指向哪里?
最后一个,当然就意味着直接后继不存在了,所以我们规定,线性链表的最后一个结点后继指针指向空,如下图所示。

img

有时,我们为了更加方便地对链装进行操作,会在单链表的第一个结点前附设一个结点,称为头结点。头结点的数据域可以不存储任何信息,也可以存储如线性表的长度等附加信息,头结点的指针域存储指向第一个结点的指针,如下图所示。

img


4.2 头指针与头结点的异同点

头指针与头结点的异同点,如下图所示:

img



5 单链表

5.1 单链表的介绍以及实现

单链表也是一种线性表,只不过使用的是链式存储结构。链表中的数据是以结点来表示的,每个结点的构成:数据域([数据元素的映像) + 指针域(指示后继元素存储位置)。如果单链表不做特别说明,一般指的是动态单链表。

由于篇幅原因,动态单链表的实现程序不在这里贴出,详细请看我的另一篇数据结构 - 动态单链表的实行(C语言)


5.2 单链表结构与顺序存储结构的优缺点

è¿éåå¾çæè¿°

经验性结论:

1.若线性表需要频繁查找,很少进行插入和删除操作时,宜采用顺序存储结构;若需要频繁插入和删除时,宜采用链式存储结构。比如用户注册的个人信息,除了注册时插入数据外,绝大多数都是读取,所以应该考虑用顺序存储结构。

2.当线性表中的元素个数变化较大或者根本不知道有多大时,最好用链式存储结构,这样可以不需要考虑存储空间的大小问题。



6 静态链表

6.1 静态链表的介绍以及实现

对于一些语言,如Basic、 Fortran 等早期的编程高级语言,由于没有指针,链表结构按照前面我们的讲法,它就没法实现了。怎么办呢?
有人就想出来用数组来代替指针,来描述单链表。真是不得不佩服他们的智慧,
我们来看着他是怎么做到的。

首先我们让数组的元素都是由两个数据域组成,data 和 cur。也就是说,数组的每个下标都对应一个 data 和一个cur。数据域data,用来存放数据元素,也就是通常我们要处理的数据;而游标cur相当于单链表中的next指针,存放该元素的后继在数组中的下标。

我们把这种用数组描述的链表叫做静态链表,这种描述方法还有起名叫做游标实现法。

由于篇幅原因,静态链表的实现程序不在这里贴出,具体的实现请看数据结构 - 静态单链表的实行(C语言)


6.2 静态链表优缺点

img

总的来说,静态链表其实是为了给没有指针的高级语言设计的一种实现单链表能力的方法。尽管大家不一定会用得上,但这样的思考方式是非常巧妙的,应该理解其思想,以备不时之需。



7 循环链表

7.1 循环链表的介绍以及实现

将单链表中终端结点的指针端自空指针改为指向头结点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表(circular linked
list)。

相比单链表,循环链表解决了一个很麻烦的问题。 即可以从任意一个结点出发,而不一定是要从头结点出发,就可访问到链表的全部结点。

为了使空链表与非空链表处理一致,我们通常设一个头结点,当然, 这并不是说,循环链表一定要头结点,这需要注意。 循环链表带有头结点的空链表如下图所示。

img

对于非空的循环链表就如下图所示。

img

其实循环链表和单链表的主要差异就在于循环的判断条件上,原本是判断p->next是否为空,现在则是p-> next不等于头结点,则循环未结束。

在单链表中,我们有了头结点时,我们可以用O(1)的时间访问第一个结点,但对于要访问到最后一个结点,却需要O(n)时间,因为我们需要将单链表全部扫描一遍。有没有可能用O(1)的时间由链表指针访问到最后一个结点呢?当然可以。

不过我们需要改造一下这个循环链表,不用头指针,而是用指向终端结点的尾指针来表示循环链表(如下图所示),此时查找开始结点和终端结点的时间复杂度都是O(1)。

img

例如要将两个循环链表合并成一个表时,有了尾指针就非常简单了。 比如下面的这两个循环链表,它们的尾指针分别是 rearA 和 rearB,如下图所示。

img

要想把它们合井,只需要如下的操作即可,如下图所示。

img

p = rearA->next; /* 保存A链表的头结点 */
rearA->next = rearB->next->next; /* 指向B链表的第一个结点(不是头结点) */
rearB->next = p; /* 将原A链表的头结点赋值给B链表的尾结点指针域 */
free(p); /* 释放A链表,因为后面只要用到rearB */



8 双向链表

我们在单链表中,有了next指针,这就使得我们要查找下一结点的时间复杂度为
O(1)。可是如果我们要查找的是上一结点的话,那最坏的时间复杂度就是O(n)了,因为我们每次都要从头开始遍历查找。

为了克服单向性这一缺点,我们设计出了双向链表。双向链表
(double linked List) 是在单链表的每个结点中,再设置一个指向其前驱结点的指针域。
所以在双向链表中的结点都有两个指针域,一个指向直接后继,另一个指向直接前驱。

typedef struct DulNode
{
    ElemType data;
    struct DulNode *prior; /* 直接前驱指针 */
    struct DulNode *next; /* 直接后继指针 */
}

既然单链表也可以有循环链表,那么双向链表当然也可以是循环链表。带头结点的双向循环空链表如下图(上),带头结点的双向循环非空链表如下图(下)。

img

img

由于这是双向链表,那么对于链表中的某一个结点p,它的后继的前驱是谁?当然还是它自己。它的前驱的后继自然也是它自己,即:

p->next->prior = p = p->prior->next;

双向链表是单链表中扩展出来的结构,所以它的很多操作是和单链表相同的,比如求长度的ListLength,查找元素的GetElem,获取元素位置的LocateElem等,这些操作都只要涉及一个方向的指针即可,另一指针多了也不能提供什么帮助。

双向链表既然是比单链表多了如可以反向遍历查找等数据结构,那么也就需要付出一些小的代价:在插入和删除时,需要更改两个指针变景。

我们现在假设存储元素e的结点为s,要实现将结点s插入到结点p和p->next之间需要下面几步,如下图所示。

img

s->prior = p;        /* 把p赋值给s的前驱,如图中① "/
s->next = p->next;   /* 把p->next赋值给s的后继,如图② */
p->next->prior = s;  /* 把s赋值给p->next的前驱,如图③ */
p->next = s;         /* 把s赋值给p的后继,如图中④ */

关键在于它们的顺序,由于第2步和第3步都用到了p->next。如果第4步先执行,则会使得p->next提前变成了s,使得插入的工作完不成。 所以我们不妨把上面这张图在理解的基础上记忆(很重要),顺序是先搞定s的前驱和后继,再搞定后结点的前驱,最后再解决前结点的后继。

若要删除结点p,只需要下面两步骤,如下图所示。

img

p->prior->next = p->next;   /* 把p->next赋值给p->prior的后继,如图中① */
p->next->prior = p->prior;  /* 把p->prior赋值给p->next的前驱,如图中② */
free(p);                    /* 释放结点 */

好了,简单总结一下,双向链表相对于单链表来说,要更复杂一些,毕竟多了prior指针,对于插入和删除时,需要格外小心。另外它由于每个结点都需要记录两份指针,所以在空间上是要占用略多一些的。不过,由于它良好的对称性,使得对某个结点的前后结点的操作,带来了方便,可以有效提高算法的时间性能。说白了,就是用空间来换时间。



9 总结

这一章,我们主要讲的是线性表。

先谈了它的定义,线性表是零个或多个具有相同类型的数据元素的有限序列。然后谈了线性表的抽象数据类型,如它的一些基本操作。

之后我们就线性表的两大结构做了讲述,先讲的是比较容易的顺序存储结构,指的是用一段地址连续的存储单元依次存储线性表的数据元素。通常我们都是用数组来实现这一结构。

后来是我们的重点,由顺序存储结构的插入和删除操作不方便,引出了链式存储结构。它具有不受固定的存储空间限制,可以比较快捷的插入和删除操作的特点。然后我们分别就链式存储结构的不同形式,如单链表、循环链表和双向链表做了讲解,另外我们还讲了若不使用指针如何处理链表结构的静态链表方法。

总的来说,线性表的这两种结构(如下图所示)其实是后面其他数据结构的基础,另外最好记住各线性表的插入删除等示意图,才能快速写出其程序。

img

转载于:https://www.cnblogs.com/linuxAndMcu/p/10301662.html

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值