目录
1.顺序表
1.1初始化顺序表
1.2销毁顺序表
1.3检查容量并扩容
1.4把某个元素插入到下标为pos的位置
1.5头插和尾插
1.6删除下标为pos的元素
1.7头删和尾删
2.顺序表的问题及思考
3.链表
3.1链表的访问
3.2链表的增删查改
1.顺序表
顺序表的本质其实就是一个数组,实际上如果要对数组中某块空间进行删除是非常不方便的,因为如果要删就只能全删掉,或者用后面的覆盖前面的,从效果上来看是删掉了某个元素,那既然顺序表这么不方便,为什么还要使用顺序表?是因为顺序表有一个压倒性的优势就是能够通过下标来访问到我们需要的元素。
顺序表包括静态顺序表和动态顺序表,静态顺序表就是一个固定长度的数组,动态顺序表就是用malloc开辟的一块数组空间存储。实际上我们以前用C语言实现的动态版本的通讯录就充分使用了顺序表,以实现他的增删查改等功能。
下面来演示操作一个顺序表的常用操作
这是一个头文件sl.h,用来声明顺序表的类型与各种函数
接下来完成这些函数功能
1.1初始化顺序表
使用的是动态内存开辟的方式,刚上来a数组能放四个元素
1.2销毁顺序表
1.3检查容量并扩容
a的内存是通过malloc申请的,后面还可能会通过realloc来调整这块空间的大小。因此使用完成之后应该free。
在插入的时候,应该检查顺序表是否已满,如果满了,就扩容
一次性扩容成原来容量的两倍
1.4把某个元素插入到下标为pos的位置
插入之前应该先检查顺序表是否已经满了,为了防止插入的下标合法应该assert一下,当然也可以使用if语句判断,这里我用的是assert
1.5头插和尾插
把某个元素插入到顺序表最开头
可以单独写,就像上图中我注释掉的那段代码一样,先让所有元素往后挪动一位,然后把要插入的元素赋给下标为零的元素覆盖掉原来的值。
当然也可以直接调用刚才我们写的在中间插入元素的函数,头插实际上就是在下标为零的位置插入。
同理尾插就是在下标size的位置插入。
1.6删除下标为pos的元素
先找到这个元素,然后他后面的所有元素往前挪动一位,覆盖掉他,最后size--
1.7头删和尾删
头删和尾删的实现就可以直接调用该函数
头删就是删除下标为0的元素,尾删就是删除下标为size-1的元素
2.顺序表的问题及思考
问题:
1. 中间/头部的插入删除,时间复杂度为O(N)
2. 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
3. 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。
思考:如何解决以上问题呢?下面给出了链表的结构来看看。
3.链表
链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。
前面管理顺序表的时候,我们定义的结构体需要三个参数,第一个就是一个数组,用来存放数据,第二个是sz,用来记录这个数组中已经有了几个元素了,第三个是capacity,用来记录当前数组的最大容量。定义链表的结构体类型又需要哪些成员变量呢?首先当前内存中应该存着某个数值,而非数组,又因为链表在物理存储上并非连续,要从当前内存找到下一块内存,就需要一个指针,这个指针一般命名为next,而且链表我们,因此链表的结构体类型一般是这样
存着一个数值和一个同种结构体类型的指针,(结构体可以嵌套结构体指针,但是不能嵌套结构体类型,因为这样就会无限套娃而无法计算结构体类型的大小了)
3.1链表的访问
可以这样访问链表
这样的每一个单独的空间就是一个节点,上面的一个节点就包括了一个数值和一个指针,用来指向下一个节点。
在用函数操作链表之前,一定要明确是要修改结构体,还是要修改结构体指针,如果是修改结构体,在函数内应该使用结构体指针,如果是要修改结构体指针(一般是某个节点的地址),就要使用结构体指针的地址,也就是一个二级指针。由于我们经常需要改变某个节点的地址,因此我们会经常传一个结构体指针的地址。比如想要在链表头部插入一个节点,而我们用一个名为phead的结构体指针表示链表头部的地址,指向第一个节点,在我们头插一个节点之后我们显然要改变这个phead,也就是要改变结构体指针,那我们在传参的时候就应该传一个结构体指针的地址。
3.2链表的增删查改
如果想要在链表最前面插入一个数据,应该如何做?
只需要让新malloc的那块空间的节点指向原来最前面的那块空间即可。
SLTNode是我们刚才重命名的那个结构体类型,我们要在链表的头部插入一个新的元素,这个新的元素也是一个结构体类型,包含一个数值和一个指针,这个指针指向原来的phead。于是我们就是malloc了一块空间,大小能放一个SLTNode类型的结构体变量(绝对不能直接写成SLTNode*newnode,因为这是一个局部变量,调用完函数之后就销毁了要让新开辟的这块空间一直存在,应该使用malloc),newnode里面就是新开辟的空间的地址,但是这样写虽然开辟的空间一直存在,但是仍然有问题,比如我现在要测试一下
我们的预期应该是打印出来1 2 3 4,但是实际上什么也打印不出来,因为这是一个传值调用,你可能有疑问,这不是传的地址吗?怎么还是传值调用?这个plist是一个结构体指针,指向了某一个结构体,他是我们创建的一个局部变量,在这个TestSlist1函数中由于刚上来这个plist的值是NULL,我们可以认为他是链表的最后一个节点,现在我们要在这个节点的前面插入四个节点,内容分别是1,2,3,4以及下一个节点的地址,虽然plist是一个指针变量,里面存的是一个地址,但是仍然是一个变量,现在直接把plist传给了SLPushFront,当然是传值调用,SLPushFront函数会创建一个形参叫做phead,并把plist里面相同的值拷贝进去,然后SLPushFront函数里面又创建了一个newnode变量,把他和phead一顿操作,那也只是在SLPushFront的栈上进行的操作,根本不会影响plist的值,因此最终plist还是NULL,当然什么也打印不出来。真要传址调用,应该传&plist,我再说的通俗一点,如果我们想要通过函数改变int类型的,我们应该传int*类型的,那么我们现在想要用函数来改变SLTNode*类型的变量plist,我们应该传给函数的参数类型是SLTNode**类型。
回到我们的SLPushFront函数,要想通过他添加节点,并把原来phead改变,形参应该是phead的地址,也就是SLTNode**类型
对SLPushFront进行修改
然后测试的时候传plist的地址
即可实现预期
如果对传址调用的理解还没有那么深刻,那我再举一个尾插函数的例子
首选因为不管是头插还是尾插,都需要动态申请一块内存空间,为了避免代码的冗余,我们利用一个BuyLTNode函数来实现该功能
尾插的函数假如我这么写
首先我们创建了一个局部变量tail并初始化为phead也就是第一个节点的地址,那现在tail指向了第一个节点,要判断tail是否为尾部节点,就是看tail->next是否为NULL,于是利用while循环来找到这个尾部的节点,出循环之后,tail就指向了最后一个节点,我们使用BuyLTNode函数申请一个节点并把这个节点的地址赋给tail,由于使用BuyLTNode申请的节点中next已经被初始化成了NULL,那么当我们把这个地址赋给tail之后,tail就指向了一个节点,这个节点的next是NULL,data被初始化成了x,也就是我们希望插入的数值。此时tail指向了链表最后一个节点,完成了尾插操作。
但事实上这个代码并不能完成尾插的工作,因为我们这里不管是tail也好,还是newnode也好,都是局部变量,出了这个函数之后,这些局部变量就被销毁了,而我们使用BuyLTNode申请的那块空间却还在,但是这块空间却无法找到了,造成了内存泄漏。而且还有一个问题就是,当tail指向最后一个节点的时候,把这个节点的next也就是NULL赋给tail,这时候tail什么也不指向,我们也无法将tail->next修改掉,因此这种写法也无法把链表的节点连接起来。
如果把while循环的判断条件改成tail->next呢?
那就要看tail->next什么时候是NULL,我们发现此时tail指向的是最后一个节点,而非NULL,这样如果我们把tail->next改成尾插的节点的地址,就可以实现链表节点的连接。这样是不是就可以了了?
这样其实已经能够完成大部分情况下的尾插了,我们动态申请了一个节点,然后把这个节点的地址放在了newnode里面,又把newnode赋给了tail->next,也就是说本来tail指向的是NULL,现在tail指向的就是动态申请的这块空间,调用结束之后虽然newnode,tail等变量被销毁,但是链表的所有节点都在堆区上,因此所有节点的内容(data和next)都不会被销毁,也就是说内容已经改完了,也已经尾插了一个节点,虽然此时的用来指向链表头部的形参plist已经被销毁,但是好在这个plist传值调用只是链表首地址的一份临时拷贝,他与我们传的实参里面存放的内容是一样的,都是链表头部的地址,既然已经在堆区上完成了尾插的工作,我们当然可以通过传的实参来实现链表的打印。
但是如果刚上来链表是空的,也就是说plist是NULL,如果还是按照上面代码的逻辑,把空指针赋给了tail,然后是while的判断条件,这将会对NULL进行解引用,显然是不对的,那么我们能不能使用if语句把链表为空和非空这两种情况分开来看?也就是说现在改成了这样子
通过测试发现这种方式也不行,这是因为plist刚上来是NULL,我们要尾插一个节点,并让plist指向这个节点,也就是说我们想改变plist,但是我们在调用SListPushBack函数的时候却使用了传值调用,直接把plist的值传了过去,这样是无法改变plist的,要想在函数内改变plist,我们只能在传参的时候使用传址调用,把plist的地址传过去,使用一个SListNode**类型的二级指针来接收这个地址。正确写法如下
我们在传参的时候需要传一个节点的地址也就是&plist,并存放在pplist里面,这个指针就指向了plist,对他进行解引用就找到了plist,如果plist是NULL,那么执行的将会是把newnode赋给*pplist,这个*pplist就不是实参的一份临时拷贝了,而是确确实实是实参所在的单元,通过这个指针就可以改变这个单元的内容,也就当然可以修改plist的内容了。
注:如果要改结构体,需要用结构体指针,如果要改结构体指针,需要用结构体指针的地址,在尾插的函数中,只有链表为空的时候需要修改plist也就是结构体指针,当链表不为空的时候,修改的其实是节点里面的next也就是结构体。
要改变什么,就要用他的地址,并在函数里面对这个地址进行解引用
删除尾部节点
先考虑链表包含一个以上节点的情况,我们要做的是释放掉最后一个节点,并把倒数第二个节点的next置空,这个过程中我们改变的是结构体,应该使用结构体指针。
想要删除尾部节点首先应该找到尾部的节点,当tail指向尾部节点的时候free(tail),就可以把尾部节点这个空间释放掉,在使用free函数之后,通常要把tail置为空指针,实际上tail作为一个局部变量,置空与否都可以,因为出了这个函数tail变量就被销毁了,并不会产生访问他的情况,实际上我们应该把原来倒数第二个节点的next置为NULL,要改变节点,也就是结构体的一部分,我们应该使用结构体指针,于是我们使用了一个指针prev来找到倒数第二个节点并将这个节点的next置空。如图
尾删还有一种写法就是我们直接去找倒数第二个节点,如图
当链表只有一个节点,我们要做的是释放掉这个节点,并让plist置为NULL,因此我们是要改变结构体指针,应该使用结构体指针的地址,这也是我们把函数的形参设计成二级指针的原因。因此完整的尾删功能代码如下
同理头删也需要分三种情况讨论
实际上上面的代码完全可以把链表只有一个节点和链表有多个节点的情况合并起来,如图
只有一个节点也就是*pplist->next是NULL,先把*pplist也就是plist也就是指向唯一节点的这个指针拷贝给tmp,然后把tmp->next也就是NULL赋给*pplist也就是plist,这时候原本指向唯一节点的结构体指针就指向了NULL,与我们期望的逻辑相同。
单链表查找
找查的时候并不需要修改链表中的节点或者指针,因此也并不需要传二级指针,唯一需要注意的点就是while循环的判断条件,如果写成cur->next,当它为NULL的时候实际上cur指向了最后一个节点,而我们这时候没有进入while循环,因此就没有判断最后一个节点的data是不是我们要找的内容。