线性表
零个或多个数据元素的有限序列。数据结构中最常用和最简单的一种结构。
如:小学生排队,固定位置,每次排队都按照这个位置,可以很快清点人数,缺少时,左右两边的小朋友很快就知道。
线性表的定义
若将线性表记为(a1,.....ai-1,ai,ai+1,.....an), 则表中ai-1领先于ai,ai领先于ai+1 , 称 ai-1是 ai的直接前驱因素,ai+1是ai 的直接后继元素。当i=1, 2,..., n-1时, ai有且仅有一个直接后继,当i=2,3,..., n时, ai有且仅有一个直接前驱。如图:
所以, 线性表元素的个数n(n>=0)定义为线性表的长度,当n=0时,称为空表。
在较复杂的线性表中,一个数据元素可以由若干个数据项组成。
线性表的抽象数据类型
定义
对于不同的应用,线性表的基本操作是不同的。如,我们假设La表示集合A,Lb表示集合B,要实现A和B的并集操作,可以循环B中的每个元素,判断当前元素是否存在A中,若不存在,则插入到A中即可。也可以循环A中的元素插入到B中。
代码如下:
线性表的顺序存储结构
顺序存储定义
线性表的顺序存储结构,指的是用一段地址连续的存储单元依次存储线性表的数据元素。
如图:
顺序存储方式
线性表的顺序存储结构,说白了,就是在内存中找了块地儿,通过占位的形式,把一定内存空间给占了,然后把相同数据类型的数据元素一次存放在这块空地中。
每个数据元素的类型都相同,可以用一维数组来实现顺序存储结构。
结构代码
总结,顺序存储结构需要的三个属性
- 存储空间的起始位置:数组data,它的存储位置就是存储空间的存储位置。
- 线性表的最大存储容量:数组长度MaxSize。
- 线性表的当前长度:length。
数据长度与线性表长度区别
数据长度(数组的长度):是存放线性表的存储空间的长度,存储分配后这个量一般是不变的。(动态分配数组是可以变的,但是会带来性能上的损耗)。
线性表长度:是线性表中数据元素的个数,随着线性表插入和删除操作的进行,这个量是变化的。注:在任意时刻,线性表的长度应该小于等于数组的长度。
地址计算方法
数据元素的序号和存放它的数组下标之间存在对应关系
存储器中的每个存储单元都有自己的编号,这个编号称为地址。
由于每个数据元素,不管它是整型、实型还是字符型,他都是需要占用一定的存储单元空间的。
假设占用的是c个存储单元,那么线性表中第i+1个数据元素的存储位置和第i个数据元素的存储位置满足下列关系(LOC表示获得存储位置的函数)
LOC(ai+1) = LOC(ai)+c
所以对于第i个数据元素ai的存储位置可以由a1推算得出:
LOC(ai) = LOC(a1)+(i-1)*c
由下图理解:
通过这个公式,可以随时算出线性表任意位置的地址,不管是第一个还是最后一个,都是相同的时间。
因此它的存取时间性能为O(1)。我们通常把具有这一特点的存储结构称为随机存取结构。
顺序存储结构的插入与删除
获得元素操作
插入操作
算法思路
- 如果插入位置不合理,抛出异常;
- 如果线性表长度大于等于数组长度,则抛出异常或动态增加容量;
- 从最后一个元素开始向前遍历到第i个位置,分别将它们都向后移动一个位置;
- 将要插入元素填入位置i处;
- 表长加1。
实现如下:
删除操作
删除算法的思路:
- 如果删除位置不合理,抛出异常
- 取出删除元素
- 从删除元素位置开始遍历到最后一个元素位置,分别将他们都向前移动一个位置
- 表长减1
实现代码:
插入和删除的时间复杂度分析:
插入或删除最后一个元素,时间复杂度为O(1),因为不需要移动元素
最坏情况,如果需要插入或删除第一个元素,时间复杂度为O(n),因为要移动所有的元素向后或向前
总结:在存、读数据时,不管在哪个位置,时间复杂度都是O(1),而插入或删除时,时间复杂度都是O(n)。
线性表顺序存储结构的优缺点
优点:
- 无需为表示表中的元素之间的逻辑关系而增加额外存储空间
- 可以快速的存取表中任意位置的元素
缺点:
- 插入和删除操作需要移动大量元素
- 当线性表长度变化较大时,难以确定存储空间的容量
- 造成存储空间的“碎片”
线性表的链式存储结构
顺序存储结构不足的解决办法
基于顺序存储结构的缺点,提出解决思路,反正也是要让相邻元素间有足够余地,那么干脆所有的元素都不要考虑相邻位置了,哪里有空位就到哪里,而只是让每个元素知道它下一个元素的位置在哪里,这样,我们可以在第一个元素时,就知道第二个元素的位置(内存地址),而找到它;在第二个元素时,再找到第三个元素的位置。这样所有的元素都可以通过遍历找到。
线性表链式存储结构定义
为了表示每个数据元素ai与其直接后继数据元素ai+1之间的逻辑关系,对数据元素ai来说,除了存储其本身的信息之外,还需存储一个指示其直接后继的信息(即直接后继的存储位置)。我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称为指针或链。这两部分信息组成数据元素ai的存储映像,称为节点(Node)。
n个节点(ai的存储映像)链结成一个链表,即为线性表(a1,a2,……,an)的链式存储结构,因为此链表的每个节点中只包含一个指针域,所以叫做单链表。单链表正是通过每个结点的指针域将线性表的数据元素按其逻辑次序链接在一起。如图所示:
线性表的链式存储结构的特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。这就意味着,这些数据元素可以存在内存并未被占用的任意位置。如图:
顺序结构中,每个数据元素只需要存数据元素信息就可以了。但是在链式结构中,除了需要存数据元素信息外,还要存储它后继元素的存储地址。
链表中第一个结点的存储位置叫做头指针,整个链表的存取必须是从头指针开始进行。线性链表的最后一个节点指针为“空”(通常用NULL或"^"符号表示),如图:
为了更加方便对链表进行操作,会在单链表的第一个节点前附设一个结点,称为头结点。头结点的数据域可以不存储任何信息,也可以存储线性表的长度等附加信息。头结点的指针域存储指向第一个节点的指针,如图所示:
头指针和头结点的异同
头指针:
- 头指针是指链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针
- 头指针具有标识作用,所以常用头指针冠以链表的名字
- 无论链表是否为空,头指针均不为空。头指针是链表的必要元素
头结点:
- 头结点是为了操作的统一和方便而设立的,放在第一元素的结点之前,其数据域一般无意义(也可以存放链表的长度)
- 有了头结点,对在第一元素结点前插入结点和删除第一结点,其操作与其它结点的操作就统一了
- 头结点不一定是链表的必须要素
头指针 , 是指向链表中一个结点所在存储位置的指针。. 如果链表中有头结点,则头指针指向头结点;若链表中没有头结点,则头指针指向链表中第一个数据结点(也叫 首元结点 )。
没有头结点的单链表,如图:
带有头结点的单链表,如图:
空链表,如图:
线性表链式存储结构代码描述
结点由存放数据元素的数据域存放后继结点地址的指针域组成。
假设p是指向线性表第i个元素的指针,那么p->data的值是一个数据元素,结点ai的指针域可以用p->next来表示,p->next的值是一个指针,指向第i+1个元素,即指向ai+1的指针。也就是说,如果p->data=ai,那么p->next->data=ai+1,如图:
单链表的读取
线性表的顺序存储结构中,要计算任意一个元素的存储位置很容易;但在单链表中,第i个元素到底在哪,没办法一开始就知道,必须得从头开始找。
获得链表第i个数据的算法思路:
- 声明一个结点 p 指向链表的第一个结点,初始化 j 从 1 开始
- 当 j<i 时,就遍历链表,让 p 的指针向后移动,不断地指向下一结点, j 累加 1
- 弱到链表末尾 p 为空,则说明第 i 个元素不存在
- 否则查找成功,返回结点 p 的数据
代码如下:
算法的时间复杂度取决于 i 的位置,当 i=1 时,则不需要遍历,第一个数据就是, 当 i=n时 需要遍历 n-1 次,因此最坏情况的时间复杂度时O(n)。
由于单链表的结构中没有定义表长,所以事先不知道循环多少次,所以不能用for循环。
主要核心思想就是”工作指针后移“,这其实也是很多算法的常用技术。
单链表的插入与删除
单链表的插入
假设存储元素 e 的结点为 s ,如图:
不用动其它结点,只需让s->next 和 p->next 的指针做一点改变即可。
s->next = p->next;
p->next = s;
如图:
切记顺序问题,如果先 p->next=s; 再 s->next=p->next; 那么第一句p->next就给覆盖成 s 的地址了 。那么 s->next = p->next, 其实就等于 s->next = s,这样真正拥有ai+1数据元素的结点就没了上级,插入操作失败,所以顺序无论如何不能反。
插入后的链表,如图:
对于单链表的表头和表尾的特殊情况,操作时相同的, 如图:
单链表第 i 个数据插入结点的算法思路:
- 声明一结点 p 指向链表第一个结点, 初始化 j 从 1 开始
- 当 j<i 时, 就遍历链表, 让 p 的指针向后移动, 不断指向下一结点, j 累加 1
- 若到链表末尾 p 为空, 则说明第 i 个元素不存在
- 否则查找成功, 在系统中生成一个空节点 s
- 将数据元素 e 赋值给 s->data
- 单链表的插入标准语句 s->next = p->next; p->next = s;
- 返回成功
代码如下:
malloc函数是在堆中申请空间存放 e 数据 s 结点
单链表的删除
假设存储元素 ai 的结点为 q,要实现将结点 q 删除单链表的操作, 其实就是将它的前继结点的指针绕过,指向它的后继节点即可,如图:
q = p->next;
p->next = q->next;
单链表第 i 个数据删除节点的算法思路:
- 声明一结点 p 指向链表的第一个结点, 初始化 j 从 1 开始
- 当 j<i 时,就遍历链表,让 p 的指针向后移动, 不断指向下一个结点, j 累加 1
- 若到链表末尾 p 为空,则说明第 i 个元素不存在
- 否则查找成功,将欲删除的结点 p->next 赋值给 q
- 单链表的删除标准语句 p->next = q->next;
- 将 q 节点中的数据赋值给 e,作为返回
- 释放 q 结点
- 返回成功
代码如下:
free函数作用,释放内存,回收Node结点
总结:
单链表的插入和删除,都是由两部分组成:第一部分就是遍历查找第 i 个元素, 第二部分就是插入和删除元素
整体算法的时间复杂程度都是O(n),查找部分都是O(n),插入和删除时间复杂度则都是O(1)。
因此,对于插入或者删除数据越频繁的操作,单链表的效率优势就越是明显。
单链表的整表创建
顺序存储结构的创建,其实就是一个数组的初始化,即声明一个类型和大小的数组并赋值的过程。
单链表的存储结构不像顺序存储结构那么集中,它可以很散,是一种动态结构。对于每个链表来说,它所占空间的大小和位置是不需要预先分配划定的,可以根据系统的情况和实际的需求即时生成。
所以创建链表的过程就是一个动态生成链表的过程。即从”空表“的初始状态其,依次建立各元素结点,并逐个插入链表。
单链表整表创建的算法思路:
- 声明一结点 p 和计数器变量 i
- 初始化一空链表 L
- 让 L 的头结点的指针指向 NULL,即建立一个带头结点的单链表
- 循环:
- 生成一新结点赋值给 p
- 随机生成一数字赋值给 p 的数据域 p->data
- 将 p 插入到头结点与前一新节点之间
代码如下:
这种算法简称为头插法,如图:
同样,我们也可以每次把新结点都插在终端结点的后面,这种称为尾插法。
代码如下:
L指的是整个单链表,而 r 时指向尾结点的变量,r 会随着循环不断地变化结点,而 L 则是随着循环增长一个结点的链表。
r->next = p,将刚才表尾终端结点 r 的指针指向新节点 p ,如图,当中①位置连线表示这个意思。
r = p ,如图:
它的意思,就是本来 r 实在ai-1元素的结点,可现在它已经不是最后的结点了,现在最后的结点是ai,所以应该要让将 p 结点这个最后的结点赋值给 r。此时 r 又是最终的尾结点了。
循环结束后,把链表指针域置空,因此”r->next = NULL;“确认其是尾部。
单链表的整表删除
单链表整表删除的算法思路如下:
- 声明一结点 p 和 q
- 将第一个结点赋值给 p
- 循环:
- 将下一结点赋值给 q
- 释放 p
- 将 q 赋值给 p
代码如下:
单链表结构与顺序存储结构优缺点
存储分配方式
顺序存储结构用一段连续的存储单元依次存储线性表的数据元素,单链表采用链式存储结构,用一组任意的存储单元存放线性表的元素
时间性能
查找:顺序存储结构O(1), 单链表O(n)
插入和删除:顺序存储结构需要平均移动表长一半的元素,时间为O(n);单链表在线出某位置的指针后,插入和删除时间仅为O(1)
空间性能
顺序存储结构需要预先分配存储空间,分大了浪费,分小了易发生上溢
结论
若线性表需要频繁查找,很少进行插入和删除操作时,宜采用顺序存储结构。若需要频繁插入和删除时,宜采用单链表结构。绝大多数情况都是读取,所以应该考虑用顺序存储结构。而游戏中的玩家的武器或者装备列表,随着玩家的游戏过程中,可能会随时增加或删除,此时再用顺序存储就不合适了,单链表结构就可以大展拳脚。
当线性表的元素个数变化较大或者根本不知道有多大时,最好用单链表结构,这样可以不需要考虑存储空间的大小问题。而如果事先知道线性表的大致长度,如一年12个月,一周共7天,这种用顺序存储结构效率会搞很多。
静态链表---?
有些语言没有指针,链表结构如何实现呢?
用数组来代替指针,来描述单链表。首先让数组的元素都是由两个数据域组成, data 和 cur。也就是说,数组的每个下标都对应一个 data 和一个 cur。数据域 data,用来存放数据元素,而游标 cur 相当于单链表的 next指针,存放在该元素的后继在数组中的下标。这种用数组描述的链表叫做静态链表。这种描述方法又叫游标实现法。
1注:对于不提供结构体struct的语言,可以使用一对并行数组 data 和 cur 来处理。
数组的第一个和最后一个元素作为特殊元素处理,不存数据。这种未被使用的数组元素称为备用链表。
数组的第一个元素,即下标为 0 的元素的 cur 就存放备用链表的第一个结点的下标;而数组的最后一个元素的 cur 则存放第一个有数值的元素的下标,相当于单链表中的头结点的作用,当整个链表为空时, 则为 0 。如图:
代码如下:
假设我们已经将数据存入静态链表,比如分别存放着”甲“、”乙“、”丙“、”丁“、”戊“、”己“、”庚“等数据,则它将处于如图所示这种状态。
此时”甲“这里就存有下一元素”乙“的游标 2,而”庚“是最后一个有值元素,所以它的 cur 设为 0。而最后一个元素的 cur 则因”甲“是第一个有值元素而存有它的下标为 1。而第一个元素则因空闲空间的第一个元素下标为7,所以它的 cur 存有7。
静态链表的插入操作
为了辨明数组中哪些分量未被使用,解决办法是:将所有未被使用过的以及已被删除的分量用游标链成一个备用的链表,每当进行插入时,便可以从备用链表上取得第一个结点作为待插入的新节点。
作用返回一个下标值,就是数组头元素的 cur 存的第一个空闲的下标。
如图所示:
当执行插入语句时,我们的目的时在“乙”和“丁”之间插入“丙”。调用代码时,输入 i 值为 3,即把“丙”插入到数据的第 3 个位置,但不移动整个数组的顺序,只移动 cur 的值。如:“丙”在第 7 位置,但是索引 cur 是 3 。
第 4 行让 k=MAX_SIZE -1 ,即 k = 999
第 7 行, j=Malloc_SSL(L), 此时下标为 0 的 cur 增 1, 因 7 被“丙”占用而更改备用链表第一个空闲值为 8 。
第11~12行,for 循环 l 由 1 到 2,执行两次。 代码 k = L[k].cur;使得 k = 999,得到 k=L[999].cur 即 k =1,再得到 k =L[1].cur=2。即获取索引 i 的前一个结点所在的数组的位置。
第13 行, L[j].cur = L[k].cur;因 j=7,而 k=2 得到 L[7].cur = L[2].cur = 3。 即:将前一结点原指向下一个结点的 cur 赋值给插入的“丙”的下一结点指针 cur 。
第14行, L[k].cur = j;意思就是 L[2].cur=7。 即:将前一结点的指针 cur 指向插入的数据的数组的下标。
静态链表的删除操作
删除元素时,原来时需要释放结点的函数 free()。现在自行实现:
L[k].cur = L[j].cur 也就是 L[999].cur = L[1].cur=2。这其实就是告诉计算机“甲”已经离开了,“乙”才是第一个元素
此时意思为:“甲”走了,这个位置就空出来了,作为备用链表空闲位置的第一位,而它的 cur 指向备用链表空闲位置的第二位,也就是之前 space[0].cur 赋值给 space[1].cur ,即:space[1].cur=space[0].cur=8,而space[0]就指向空出来的这个位置,即:space[0].cur = k = 1 ,如图:
返回静态链表长度代码如下:
静态链表优缺点
优点:
- 在插入和删除操作时,只需要修改游标,不需要移动元素,从而改进了在顺序存储结构中的插入和删除操作需要移动大量元素的缺点
缺点:
- 没有解决连续存储分配带来的表长难以确定的问题
- 失去了顺序存储结构随机存取的特性
总结
静态链表其实是为了给没有指针的高级语言设计的一种实现单链表能力的方法。
循环列表
对于单链表,由于每个结点只存储了向后的指针,到了尾标志就停止了向后的链接操作。
将单链表中的终端结点的指针端由空指针改为指向头结点,就使整个单链表形成一个环,这种首尾相连的单链表称为单循环链表,简称循环链表。
循环链表带有头结点的空链表,如图:
非空的循环链表,如图:
循环链表和单链表的差异就在循环的判断条件上,原来判断 p->next 是否为空,现在则是 p->next 不等于头结点,则循环未结束。
如何用O(1)的时间有链表指针访问到最后一个结点:
不用头指针,而是用指向终端结点的尾指针来表示循环链表,如:
从上图可以看到,终端结点用rear表示,查找终端结点的时间复杂度是O(1),而开始结点,即rear->next->next,其时间复杂也是O(1)。
如果要将两个循环链表合并成一个表时,可以通过尾指针,如下图:
若想把它们合并,如图操作:
代码如下:
双向链表
双向链表实在单链表的每个结点中,再设置一个指向其前驱结点的指针域。即:双向链表的结点都有两个指针域,一个指向直接后继,一个指向直接前驱。
线性表的双向链表存储结构,如图:
同单链表一样,双向链表也可以是循环链表。
双向链表的循环带头结点的空链表,如图:
非空链表的循环带头结点的双向链表,如图:
对于链表的某一结点 p,它的后继的前驱和前驱的后继都是它自己:
p->next->prior = p = p->prior->next
如:上海的下一站是苏州,那么上海下一站的前一站是哪?当然是上海
双向链表是单链表中扩展出来的结构,它的很多操作和单链表相同,比如求长度的 ListLength, 查找元素的 GetElem,获得元素位置的 LocateElem。
但是在插入和删除时,需要更改两个指针的变量。
插入操作时,顺序很重要,千万不能写反。我们假设存储元素 e 的结点为 s,要实现将结点 s 插入到结点 p 和 p->next之间需要下面几步,如图:
代码如下:
第 2 、3 步都用到了 p->next,如果第 4 步先执行,则会使 p->next提前变成 s,插入就失败了。
顺序是先搞定 s 的前驱和后继,在搞定后结点的前驱,最后解决前结点的后继。
删除操作,如下:
代码如下:
顺序是先搞定前驱的后继,再搞定后继的前驱,最后释放 p 结点。
双链表可以有效提高算法的时间性能,但是空间上要大一些。是用空间来换时间。
总结回顾