小蝠猫学算法#4——[线性表表示]
文章寄语
命运负责洗牌和发牌,而我们只能出牌。
—— 叔本华<<人生的智慧>>
引言
数据结构笔记第四篇。线性表的顺序表示[考纲有此内容]
知识点大致按照《Hello!算法》书籍进行。因为数据结构与算法在非常多的考试中都有。
本人自知学疏才浅,一定有许多错误,恳请批评指正。
下面是几个上述提到的链接:
《Hello!算法》网页版 全国计二级官网 洛谷官网-刷题可用星空如此灿烂,灯塔与之响应。出发吧
第四节 线性表的顺序表示
线性表定义
线性表是具有相同数据类型的有限序列(想成数组).特点如下
- 当表长为0时,即表中没有元素,称为空表.
- 第一个元素称为表头元素,最后一个元素称为表尾元素。
- 什么直接前驱直接后驱,简单来讲就是除了表尾,每个元素的后面都有且仅有一个元素;同理除了表头,每个元素的前面都有且仅有一个元素。即具有逻辑上的顺序性。
- 因为相同数据类型,所以每个元素占用的空间相同。
- 仅仅讨论逻辑性,即每个元素的位置,逻辑关系等。而不关心这些元素到底是什么。
线性表的基本操作
主要操作如下:
InitList(&L);
初始化表,构造一个名为L的空线性表。涉及对L实际修改,需要加上引用。Length(L);
统计表L中元素的个数。也可以说是长度。LocateElem(L, e);
定位元素,即在L中按值查找值为e的元素。GetElem(L, i);
获取元素,即给定i,在L中按位获取第i位的元素。ListInsert(&L, i, e);
插入元素,在L中的第i位上插入元素e,一般是前插。涉及对L实际修改,需要加上引用。ListDelete(&L, i, &e);
删除元素,删除L中的第i位元素e,并返回e的值。涉及对L实际修改,需要加上引用。e主函数应该有东西绑,不太理解。PrintList(L);
按前后顺序打印输出L的所有元素值。Empty(L);
判断L是否是空表,即元素个数是否为0。空表返回1,否则返回0。DestroyList(&L);
销毁线性表,释放内存(抽屉里的空间)。涉及对L实际修改,需要加上引用。
笨蛋笔记
关于C++中引用符号’&’:
1 | void swap(int &x, int &y) { |
在这个例子里,想要实现的功能是交换这两个数字的值。一般情况下,我会写 int swap(int x, int y)
。
但是写到最后发现,咦,函数不是只能返回一个值吗?不能返回两个值。如果返回两个值,需要用到坐标类型的结构。相对于只是简单交换两个数的问题,显然是有点小题大做。
而不写函数直接在主函数内的操作的话,这确实是一个好主意。但是,如果需要交换数字的地方很多,总不能每个地方都写吧。我从来不写重复的代码x。
补充说明1:c++引用号
后来我发现,c++里面有一个 &
符号。其实是一种元素别名。对于上面的交换数字的代码,我想用自己的语言解释解释。
首先在主函数内定义好了a和b的值,然后使用 swap(a, b)
传入a和b的值。在 void swap(int &x, int &y)
内开始处理。
因为 &
引用号是一种元素别名,他需要一个东西来绑定。上述这两句代码,使用了 &
后,x即和a永久绑定,y即和b永久绑定。
在函数内部 void swap(int &x, int &y)
中,对x,y的修改,其实就是直接对a,b进行了修改。对引用的操作等同于对原始变量的操作。
所以我的上述问题就解决了。函数内的x,y将直接对主函数的a,b进行覆盖改写,甚至不用返回值,使用void即可。复杂性大大大降低。
补充说明2:引用注意
x,y是变量,所以他们也当然需要声明,不过声明时就应该直接和原始变量绑定(初始化),不能改绑,也不能先声明后绑定(初始化)。
如果是类似于 void swap(int &x, int &y)
,将引用作为函数参数时。上一句话也说了,类似于 int &x
相当于直接定义x了,下面直接用就行。
还有一点,有时候不需要用到引用号。简单来说就是,如果涉及到对绑定这组东西的实际修改,就都要加。如果没有实际操作只是访问,查找什么的,就不用加。
他不占用额外内存空间,而且安全易懂。所以,随便用。
题外闲话3:c++引用和c指针
和c语言的指针不同,指针的本质是一种数据类型,用于存储内存地址。指针比较危险,因为他是直接操纵底层,而且没有什么保护。
如果是空指针,就像员工手上有个物品,但他不知道放在哪个抽屉里(未指向任何存储单元)。虽然三思后行(先置空后赋值;先拿在手上想好了再放进去)的方法很对,但还是很危险。
如果是野指针,就像员工把物品硬塞到本来已经有东西的抽屉。因为抽屉的某部分已经放不了了(不可用内存区域),硬塞进去后期拿这个东西(操作此元素)的时候就可能会炸了。
因为计算机是非常死板的,他只会遵守命令,多余的事他都不会干。发生以上两种事情的时候,就要给他发出命令,给他解决办法,以面对不知道怎么办的困境。这个就不展开了。
顺序表定义
线性表
顺序存储即顺序表,其将相同类型的元素顺序放置在一个大块,连续的内存中。元素在顺序表中的位次和在内存中的相同。如果某元素在顺序表中是第二个元素,那么在内存中(仅在分配的这块上面)也是第二位。
这个位次称作为元素在这个表中的”索引”。这个特点书上又称作为”逻辑顺序与其存储的物理顺序相同”
因为是相同元素类型,所以分配给每个元素单元的内存储存大小都是相同的。又因为它类似于等差数列的特性,可以通过索引和首地址轻松的算出任何元素的内存地址。
假设索引从0开始,首元素地址为00,每个元素分到4字节:
- 第0个元素内存地址为:00
- 第1个元素内存地址为:00 + 4 * 1
- 第n-1个元素内存地址为:00 + 4 * (n - 1)
所以我们可以任意的算出任何内存地址:首元素地址 + 元素大小 * 当前元素索引.这个任意算内存地址的性质叫”随机存取”,即随机元素都可以算出。
数组
数组是顺序存储的一种最常见的形式,他就是线性表。一维数组可以静态分配,也可以动态分配。
注意:
- 索引和位序是两种不一样的东西。位序更像是刚开始学不习惯从0开始,而用更好理解的从1开始。”座位”,这就是位序。索引就是数组下标,像是学了一段时间习惯了从0开始。这就是索引。
静态分配内存
所谓的静态分配,就是提前约定好这个顺序表(数组)的长度。一旦长度被确定好,因为同种类型元素,每个元素的内存空间都是相同的。现在总空间也被约定好了。总空间即元素个数 * 元素大小。
但是因为长度已经被约定好(比如说50)。如果此时对该数组的操作,导致出现了本该不存在的第51个元素。就像一栋楼只有5楼,坐电梯到6楼了。因为没有建造(数组没有定义)这个楼层,所以会导致恐慌。(程序报错)
这个报错就是常见的”数组越界”。 (ArrayIndexOutOfBoundsException) 解决办法也很简单,要不扩大数组,要不删除元素。一般都是扩大数组
Tips:
- 仅在C语言中,字符串数组的定义长度会包含结束符\0。意味着如果输入是5位,而长度定义也只有5位。因为包含1位结束符,他只能读取前面4位,从而导致第5位被忽略了。其他语言和数据类型不影响。所以我写c习惯数组长度+1(
动态分配内存
动态分配有一个单独的语句,它将提前申请一块内存,将这些元素存入。当顺序表占满后,如果还有元素存入,他可能会在原基础上扩或者重新申请。
就像是机构申请了一个有5层楼的住宅楼,每层2户,假设1户只住1个人。那么这栋楼就只能住10个人。当有第11个人想要住进来的时候,这时候有两种可能。
- 在原基础上扩展:最常用的办法,当没有违建,水电充足,空间充足的情况下,直接在加建几层楼就好了。只要和当地申请就好了(但是基本不可能xx都建好了怎么会让加建x)
- 重新申请:当因素限制或者没有空间了,可以重新申请一栋更大的楼。这栋楼可以有10层,把原来的住户强行转移后废弃原来的房子即可。(因为地皮(内存即抽屉)是有限空间,申请大概率驳回xx)
在C语言中,动态分配内存一般使用 malloc
函数。当这块内存使用完毕时,如数组已经操作好了得出结果了,或是数组不需要了要销毁了,用 free
函数释放该内存。不释放会导致内存泄露,让系统内存爆满,严重拖慢速度和性能。
一般来说都是报错的。除了 malloc
函数,还可以使用 calloc
函数动态分配内存(Ciallo~(∠・ω< )⌒☆)。
两者区别是:
- 前者malloc函数只有1个参数,直接申请开辟多少内存空间,单位是字节。空间值可能是随机的。
- 后者calloc函数需要2个参数,需要分开告诉申请个数和元素大小。同时会把空间值全部初始化成0。
相对下c++就直接用new函数实现。代表这是新的,。不过它们都需要指向当前数组的指针,还有限制最大元素量和记录当前元素防止其超过。
c语言的动态分配: 使用malloc函数开辟ElemType指当前元素数据类型,int或char这种。sizeof指获取当前元素大小。initsize指初始表长度。即对数组malloc了一个元素大小*个数的内存空间。
1 | L.data = (ElemType*)malloc(sizeof(ElmeType) * InitSize); |
c++语言的动态分配: 使用new函数开辟ElemType指当前元素数据类型,int或char这种。initsize指初始表长度。即对数组new了一个类型为ElemType初始个数为initsize的内存空间。
1 | L.data = new ElmeType[InitSize]; |
- 优点①:可以任意的算出任何内存地址,即可以随机存取。在常数级时间内就可以找到。
- 优点②:存储密度高,因为只存值,不存其他别的。
- 缺点①:在中间添加或删除元素时,会导致牵一发而动全身。需要把后面的元素全部前移或后移,非常的麻烦。
- 缺点②:前面笔记做过了,内存空间就像一个抽屉,而这个抽屉的空间是有限的。数组就像是一个大盒子,能放进去,但是不能最大化利用空间。碎片率很高(空隙很多),不够灵活。而且他还要连续的大块空间,中间要是有什么东西挡着就放不进去了。除非内存整理,把这个移到别的地方去。不然就放不进去,要另寻其他的内存空间。
顺序表的基本操作
Ⅰ.初始化元素
初始化元素有两种方式,一种为给定初始值,一种为无初始值。
当未指定初始值时,大部分编程语言会默认为0。但是在c语言中,因为不指定初始值的情况下是随机值。所以当进行数值操作时,可能会有意想不到的结果。
在内存方面,静态分配内存已经提前帮数组申请好了空间,元素仅需要一个一个入住即可。初始时数组长度为0。
而动态分配内存是暂时先申请定义内指定的内存空间。初始化时长度为0。当满了以后,扩容数组最大长度,一般增量和原来一致,扩成原来两倍。
Ⅱ.访问元素
前文写过笔记了。数组元素被存储在连续的内存空间中,这意味着计算数组元素的内存地址非常容易。
发现数组首个元素的索引为0,这似乎有些反直觉,因为从1开始计数会更自然。
但从地址计算公式的角度看,索引本质上是内存地址的偏移量。首个元素的地址偏移量是0,因此它的索引为0是合理的。
访问元素的时间复杂度为常数级。
Ⅲ.遍历元素
可以用索引,即下标遍历,也可以直接遍历[for和foreach函数]。
Ⅳ.插入元素
这是数组的缺点,因为元素在数组中和在内存中均是”连续存储”,什么是”连续存储”,就是数组中间不能有空隙,不能断,不能跳。就和排队一样
因为这个特性。当如果我们要在学校排队是插队,后面的人必须要全数往后退一步,腾出位置才能让其插入。数组里面也是一样。
但是有一种情况,因为数组是有界(长度固定)的,他被限制在一个房间里,如果最后一个人已经贴着后墙壁了,他就退不了了(空间已满)。
这种情况就会插入失败,并且有两种状况。一种是导致尾部元素数据丢失,一种是直接报错。
1 | //对数组L的第i位插入数据类型为ElemType的元素e |
如上述所讲,如果没有 if(L.length >= MaxSize)
语句的话,而现在 L.length == MaxSize
会产生两种状况。
因为 L.data[j] = L.data[j - 1];
代码,在for里限制了j的最大值始终是数组长度。这时候最后一位(j位)就会被前一位j-1位覆盖掉,最后一位数据丢失,就没了。
然而如果把 L.data[j] = L.data[j - 1];
代码换成 L.data[j + 1] = L.data[j];
呢?虽然看起来功能相同,但是在数组L中,第length+1位是不存在的(与if的位序不能混淆),这时候就会报错。
注意:
- 为防止忘记,= 赋值语句,是把右边的值赋给左边,左边会变右边不会。
对于书上的问题:
if语句用length+1是因为其是顺序表位序,他从1开始计数,原数组的最后一位就是length。可以添加在最后一位后面,所以是length+1。
而for语句中是数组索引,他严格来说是一个偏移量。偏移量一般从0开始计数,所以原数组的最后一位就是length-1。可以添加在最后一位后面,所以是length
而添加元素最坏的情况是在第一位数前面插入,那么所有元素都要往后移一位,操作了n次。所以是线性阶。
因此顺序表插入元素算法的时间复杂度是线性阶
Ⅵ.移除元素
和添加元素相同,只不过是元素前移。和插入元素几个不一样的点的话,我感觉是以下几点:
- ① if语句中现在是length而不是length+1。因为是删除元素,他可以在最后一位删除。但是他不会干出删除最后一位的后一位这种奇怪事。
- ② 队列元素-1,不同考虑队列满。所以没有第二个判断。
- ③ 删除某个位置上的元素相当于那个位置上的元素已经没有用了,直接用后面的元素覆盖,所以是
L.data[j - 1] = L.data[j]
。前一位被后一位覆盖了。 - ④ 总元素个数-1,而不是+1。
和添加元素一样,但是现在只有一种情况了。
因为最后一位的前一位j-1被最后一位j覆盖了。现在倒数第二位就是最后一位的值。那么最后一位的值其实就是多出来的,这个值没有意义。为了省力,我们也不用特意去操作这个数。
和添加元素一样,在头部删除元素,后面的值全部要前移,操作n次。所以是线性阶。
因此顺序表删除元素算法的时间复杂度是线性阶
Ⅶ.查找元素
这里指按值查找,因为最坏情况需要全部遍历一遍才能找到,所以也是线性阶。
不过按序号查找很简单,因为有随机读取的特性,所以可以做到常数阶。
Ⅷ.扩容元素
因为数组扩容后,这些扩容的空间不可预测,没人知道扩容以后,这些空间是不是能用的。就像塞在抽屉里的东西一样,我不知道他变大了以后会不会把抽屉撑爆,所以不安全。
所以一般不会在原基础上扩展,而是重新申请,也是书上的观点。数组的长度是不可变的,只能通过重新建立一个更大的数组,把原来的元素全部复制过去来实现。
因为复制转移要全部遍历一遍,所以也是线性阶时间复杂度。而且在数组元素个数巨多的情况下更是耗时比较离谱
顺序表的优缺点
很简单,前面写过好几次了。优点是只存数值,密度高。支持随机访问。还有一个书上写的*缓存局部性(数组加载时还会加载周围的数据)*。
缺点也很明显,增删查都是线性阶
总结
文章结语
我们现在所做的,只不过是我们自认为动态规划中的贪心罢了。
——博客园某用户
笔记完毕 编辑完毕时间CST 7.18 22:55
最后编辑时间8.15 23:40
- 本文标题: 小蝠猫学算法#4——[线性表表示]
- 本文作者: 蝙蝠猫BatBattery
- 定文于 : 2024-07-17 20:55:00
- 翻新于 : 2024-09-22 14:04:51
- 本文链接: https://smallbat.cn/hellonotes/hellonotes-4/
- 版权声明: 懒得写版权,本篇只是学习笔记.转载,引用随意,添加来源即可.