前言
本系列笔记分为三篇,系统总结了王道408数据结构课程的内容,加入了大量个人思考。
数据结构笔记——线性表、栈、队列、串(王道408)
数据结构笔记——树、图(王道408)
数据结构笔记——查找、排序(王道408)
数据结构的笔记相比于其他3门,笔记的重要性要低很多,毕竟对于选择408的同学来说,大二时候应该有足够的时间学习,所以基础是比较好的,再加上csdn上一大堆数据结构和算法的帖子,我再重复造轮子也没啥意思了。
所以我这篇文章不打算写的很细节,就是单纯地把思路提纯出来,并附上自己的理解,再搭配思维导图就行了,而不去记录过于细节的知识。
绪论
408的4门课,构建了计算机的根基。
计算机处理信息的原理如下:
- 现实-计算机。从现实到计算机,需要经过硬件+软件的转化,其中就需要数据结构表示现实事物。
- 内部处理。内部处理的过程中,需要高效,因此诞生了各种算法。
- 计算机-显示到现实。是步骤1的逆过程。
数据结构基本概念
基本概念
现实世界有信息,通过数据来承载信息,而数据可以被计算机所处理。
数据的形式可以是数值和非数值,但是底层只能是0和1,这是计算机的数学原理和基础构造决定的。
- 数据项
- 在C语言中,int,float这些东西,都是最基本的数据单位,就是所谓的数据项
- 数据项这个概念很矛盾,一边说不可分,但是另一边,有一些特殊的组合项是仍然可分的,比如如果用一个结构体当数据项,那么这个数据项就是组合项。
- 我们姑且忽略组合项这种东西,就强行把数据项定义为不可分的基本单位就好。
- 数据元素
- 在C语言中就是一个结构体实例,代表着一个具体的个体,比如张三。
- 数据对象
- 同一类(相同性质)数据元素的集合
- 在C语言中,就是同一个结构体原型实例化后的所有结构体实例的集合
- 数据
- 数据对象的集合,代表了计算机中所有的数据
概念讲完了,然后就是数据结构的定义了。
数据结构描述了一个数据对象内部,不同的数据元素组合的方式。
数据结构是普遍适用的,因为不关心数据元素内部的数据项,所以凡是数据元素,都可以用数据结构去组织,因此不同的数据对象内部,完全可以适用同一个数据结构去组织。
数据结构三要素与ADT
数据结构:
- 定义:使用者视角
- 逻辑结构定义。是什么
- 数据运算定义。可以干什么
- 实现:开发者视角,需要考虑储存结构,这会影响到空间效率和时间效率。
- 顺序,链式。
- 索引储存。索引表通过key-value方式记录数据地址,给定一个key,可以马上得到地址,进而马上得到储存在内存中的值。优点是速度快,缺点是浪费一个索引表的空间,浪费内存。
- 散列储存。散列储存和索引一样快,但是却不需要借助索引表。散列储存本质上是通过一个映射函数代替索引表,这个函数就是Hash函数,输入一个key,直接输出地址,和索引表效果相同,但是并不占用内存。优点同索引,缺点在于,散列函数并不能100%利用内存空间,和索引储存半斤八两,都会浪费内存。
我们再把视角抬高,你会发现数据结构其实是一种ADT(抽象数据类型)。
数据类型=值的范围+可进行的操作
,进而可以细分为原子类型(int,float)和结构类型(struct)
数据结构定义=逻辑结构+可进行的操作
,和数据类型本质是一样的,所以把数据结构成为抽象数据类型,说白了,ADT描述了数据结构的逻辑部分,隐藏了实现部分,可以拿来即用。
定义好数据类型或者ADT后,使用者就可以使用,无论是哪种,使用者都可以拿来即用。
算法基本概念
算法定义
数据结构是原料,算法是处理步骤。
具体来说,数据结构负责将现实问题装入计算机,而算法就是一系列求解问题的步骤,将计算机中的问题求解出来。
算法五个基本特性
算法其实就是一个可以执行下去(有穷,可行)的函数(输入,确定,输出)
- 有穷性。
- 步骤有穷,时间有穷
- 程序≠算法,程序可能无穷(死循环)
- 确定性。
- 说白了算法就是一个函数,x确定y就确定
- 描述不能有歧义,比如最小,如果有两个一样小的,那就要修改成
第一个
最小的
- 可行性。
- 算法=基本运算有限次组合。其实和有穷性差不多
- 输入。
- 可以为0个
- 输出
- 至少一个,不然没啥用
算法只要求这些,不强求正确,只要是确定的就行。
好算法的进阶特性
- 正确。
- 实用算法的基本要求,算法不一定要求对,但是拿来用一定得对
- 可读。
- 伪代码或者代码+注释
- 健壮性。
- 容错率
- 高效率和低储存量需求
- 时间复杂度低和空间复杂度低
算法复杂度
算法复杂度=时间复杂度+空间复杂度
事后统计(×):评估算法不应该用实际执行时间,因为影响因素太多了,而且有些算法不能事后统计
事前估计(√)
时间复杂度
T:时间复杂度
n:问题规模
使用渐进记法,只考虑最高阶部分,且系数忽略,
T
(
n
)
=
O
(
n
3
)
T(n)=O(n^3)
T(n)=O(n3)
这个记法代表当n趋于无穷时,T和n是同阶无穷大量,比值为k,这个k是被我们忽略为1的
具体的高阶比较,下图给出直观理解,n的n次方>阶>指>幂>对>常
具体分析的时候,关键在于要写出基本操作执行次数x的表达式
,用n表达x,或者是写出n和x的关系后化简,然后运用复杂度运算规则
得到最后的渐进复杂度:
下面这个例子,这个大循环就是要分析的目标,内层循环总共执行n方次,而外层循环是n次,虽然可以一眼看出来是n方,但是还是要写出来表达式后再化简。
面对复杂问题,应该写出x,变形,化简,得到目标的阶数。
有的时候,x与具体情况有关,那么就有三种计算方法:
- 最好。参考意义不大
- 最坏。常用
- 平均。常用,这个平均需要写出概率分布,计算期望
空间复杂度
S:空间复杂度
S是空间开销与n的关系
内存开销来源:
- 变量定义/malloc
- 函数调用
递归调用的空间复杂度就有点像循环时候的时间复杂度,有时候也会算一个式子。
线性表
定义和基本操作
注意:
- 逻辑上,描述线性表的
位序
是从1开始的 - 物理上,用数组实现线性表下标从0开始。
顺序表
定义
静态分配方式中,InitList函数在初始化的时候,可以省略脏数据清洗的过程,但是Length=0不可以省略。
只要Length=0,那么在不违规访问的前提下,就不可能碰到脏数据,所以才可以省略
动态分配的原理:
- 将储存数据的空间与SqList数据结构分离,通过内部的指针Elem *data连接。
- 为了控制动态分配,需要用当前长度和理论最大长度两个变量
扩容原理:
- 双指针:使用两个指针,指向两片连续内存
- p继承旧的内存
- data指向新开内存
- 内存搬运+MaxLength扩容
- 释放旧内存
顺序表优缺点:
- 高密度(数据和控制分离)
- 高速(随机访问)
- 12优点的代价就是不方便,只要涉及到改变结构的操作,都很消耗资源
增删
给定条件:
i=插入位序,并非实际下标(i-1),最后是要插到data[i-1]
先看核心部分:
- 移位
- 核心操作: j-1 → j
- 起始:j=Length。Length代表末尾+1,是我们的目标
- 终点:j=i
- 因为要插到i-1,所以最后一次循环肯定是把i-1的数据搬到i
- 所以j≥i,可以保证最后一次循环是针对i-1的搬运
- 插入
- 增加长度
非核心部分就是两个判断:
- i的范围。i是位序,所以从1开始,到Length+1
- 溢出。Length=MaxSize后,不能进行插入
复杂度分析,经典的等差数列复杂度,最好1,最坏n,平均下来是O(n)
删除的时候,给定的i仍然是位序,实际删的是i-1元素
核心部分:
- 取出,从i-1取出元素到e
- 移位
- 核心操作:j-1 ← j
- 起始:j=i。因为要填到i-1(目标),所以从i开始
- 终点:j<Length。把数组最后一个元素(Length-1)搬运完就行
- 减长
复杂度和插入相同。
查
顺序表的按位查找很简单,随机存取,复杂度为O(1)。
考虑到健壮性,可以增加一个判断越界的句子。
顺序表的按值查找需要从前往后遍历,找到第一个符合条件的值
返回位序
,所以是return i+1;
如果没有找到,最后放一句return 0;
时间复杂度就是一个等差数列复杂度,因此是O(1),O(n),O(n)
链表
单链表
定义
单链表在每一个节点中,储存了数据元素+指针
相比于顺序表,其数据密度降低,指针带来的浪费是比例性的,但是其灵活性大大提高
因此,顺序表适合频繁查找的场景,其他场景可以一律使用链表,灵活性大有益处。
单链表定义中,L是一个指针,指向链表(其实是指向链表的第一个节点)。
如果不带头结点,那么空表=L为Null:L==NULL
如果是带头结点的,那么空表=头结点的next指针为Null:L->next==NULL
头结点在初始化的时候创建,只是其不储存数据,单纯是为了方便写代码
增删
先说带头结点的插入
,其优势在于任意合法位序的插入都可以按照这套逻辑来,因为都是两个节点之间插入一个节点
,包括第一个元素:
i代表位序,从1开始
起始:j=0,含义是p一开始指向第“0”个节点
循环次数:p最终要挪到目标位置的前一个位置,即i-1个节点,所以要移动i-1次,所以j=0开始,j<i-1
提前终止:如果碰到了p=NULL,那么就提前终止,此时p已经无意义了,代表越界,后续进行判断
如果一切正常,就可以进行插入操作了:
- p指向前驱,s指向待插入元素
- 先填充s指向的节点
- 更改链表结构:先让s对应元素指向链表(过河),再让p对应元素指向s对应元素(拆桥)
很明显,第3步不能反,没过河怎么能拆桥呢?
如果越界,即i>Length,那么p在移动过程中就会变成NULL,提前退出循环,并触发判断语句,报错。
不带头结点
的按位插入,只需做点改进:
- p一开始指向的就是1号元素,所以j=1,其余逻辑一样
- i为1的特殊情况:在最开始增加i==1的判断,要改变头指针,同样是先过河再拆桥
带不带头结点的根本区别在于,不带头结点时,i=1的逻辑与其他逻辑不同,i=1是在头指针与节点之间插入结点,而i=其他是在两个结点之间插入结点。
前面的是给定位序的插入,现在是给定节点的后插
:
后插其实很简单:
- 申请,赋值
- 过河,拆桥
前面的位插,其实可以直接用封装好的后插代码InsertNextNode
给定节点的前插
就比较麻烦了,如果按照传统思路,还要先从头结点找到前驱,很难,很慢。
不如转换思路,我们又不是一定要插到p前面,只要效果一样就行。
最终的效果是要把插入元素的内容
放在p的前面,而不一定是那个节点,本质上我们插的是内容,那么可以改一下步骤:
- 插入节点s
- 将p的内容移到s中
- 用e覆盖原来p的内容
这样的速度很快,而且效果一致。
其实开拓思路的思路就在于,唯效果论,尽可能去破除人为强加的条条框框,获得我们最基础,最核心的目标。
然后针对目标,制定方法,就可以跳出原有思路的限制,本质是跳出了原有隐性前提的限制。
按位序删除:
目标:删除第i个节点
寻找逻辑:p指向第i-1个,因此同样是j=0,然后移动i-1次
逻辑:q指向被删节点,然后直接用p拆桥
理论上,单论拆桥,q是不需要的,可以用p->next=p->next->next;代替
q唯一的作用就是拆桥以后的善后,free(p),没有p就无法释放被删除的节点,顺便承担了提升代码可读性的次要任务。
删除指定节点:
和前插同样的问题,找不到前驱。
前插思路还可以应用到删除目标节点上,本质仍然在于,我们要删的是内容,而不是p指向的节点,因此把下一个节点的内容挪到p节点里,删掉下一个节点就好:
- 用q指向后继
- 移动数据
- 过河拆桥,释放后继(q的作用仍然只是用于free)
注意bug
:这段代码没有考虑到p指向最后一个节点的情况,无论如何,当p指向最后一个节点,那么删除就必须依靠前驱了,因此一定是要从头遍历一次的,因为即使你知道p是最后一个节点,单靠p指针是无法回溯到前面的。
即使你增加判断,如果q==NULL,只有一个p也无法实现删除操作,问题不在于判断,而在于需要依靠前驱。
因此,最有效率
的做法是增加判断,如果q==NULL(p为末尾节点),就以O(n)为复杂度找前驱,除此之外的情况,都可以以O(1)的效率来直接后插。
如果可以回溯(双向链表),那就更简单了,这就是后话了
查
按位查的逻辑前面已经写过了:
- 最开始p指向头结点
- j=0
- 循环i次,所以j<i,如果越界,p=NULL后退出循环
最后返回一个指针,表示我们找到的节点。
右边王道书给的代码略有差异,刚开始让p指向第一个节点,那么j=1,后续逻辑同理。
后插和按位查找都可以封装进去,那么带头结点的按位插入就会变得非常简单,先找到前驱节点,然后直接后插就好,顶多再加一个越界判断。
按值查找,核心代码是一个遍历循环:
- 退出条件:p指向的元素为目标元素,或者p已经指向了NULL
- 初始化:让p指向第一个元素(其实指向头结点也是可以的,但是徒增1次循环损耗)
- 返回:p指针
建立
头插法是逆序的,可以用于逆置链表,尾插法是顺序的,执行的时候稍微要麻烦一点儿,多一个尾指针。
尾插法中,首先要建立头结点,之后对于每个元素的操作如下:
- 用s指针指向新节点,装入元素
- 用r指针链接新节点
- 更新r指针,使其指向尾结点
最后令尾结点的r->next=NULL,收尾。
头插法的流程:
- 新建节点
- 对头结点尾插
头插法和尾插都要收尾,头插在一开始收尾,尾插在最后收:
- 尾插法只能在最后一步执行完以后收尾r->next=NULL,因为尾结点总是在不断生成,只有完全插完,才能确定尾结点,然后收尾。
- 头插法最开始要让L->next=NULL,这是因为头插法的插入不影响尾结点,一开始就收尾是最好的。如果一开始不收尾,就得让一个指针跟踪尾结点,反而麻烦了。
双链表
首先定义后插
,分为4步:
- 过河:新节点先连接后继
- 注意,如果后继为NULL,则只需要单边连接就好
- 12步可以交换
- 拆桥:前驱连接新节点
- 34步可以交换
- 先过河再拆桥,仍然适用
前插
可以直接用后插实现,先反向找到前驱,对前驱后插
删除节点。
单链表是针对一个节点进行删除,而双链表更多地使用后删
:
- p指向节点,q指向后继(被删除节点)
- 操作:
- p指向q的后继
- q的后继指向p
- 可以交换顺序
- 三层健壮性判定:
- p是否为NULL
- p的后继是否为NULL
- p的后继的后继是否为NULL(q的后继是否为NULL)
- q只是为了free而生,其他操作可以被p替代
定义了后删以后,清空链表
的操作也就比较简单了,对着头结点一顿后删。
至于双链表的遍历,就比较简单,一股脑循环到p=NULL就行
如果说要早点停,那就p-next!=NULL或者p->prior!=NULL
这样就可以让p在循环结束后指向尾结点/头结点,跳过对这两个节点的处理。
循环链表
单链表,循环起来就是要让表尾节点的next指向头结点。
这个操作在建立之初就需要做,L->next=L
循环链表中,头结点是必不可少的,只有依靠L指针,才能确定循环链表的首尾:
- 判断表尾p->next==L
- 判断空表L->next==L。当头结点是表尾,自然就代表空表
循环单链表可以通过一个节点,顺着绕一圈找到表里的任何一个节点,比如前驱
循环单链表还有一种魔改版,就是让L指向尾结点。
如果L指向头结点,那么找到尾结点,需要O(n)
但是如果L指向尾结点,那么找到头结点只需要O(1)
如果一个操作需要频繁用到尾结点,那么让L保持在尾结点上也是一个不错的选择。
循环双链表,其实就是两个方向的循环单链表组合起来
因此判定表尾的操作和循环单链表一样,p->next==L
区别在于:
- 初始化的时候,要让L的next和prior都指向自己
- 插入和后删操作其实比双链表更简单了,因为前驱后继不存在NULL情况,无需判断特殊情况,直接过河+拆桥就可以。
静态链表
静态链表:用数组实现的链表,用下标来充当next指针
0号节点固定为头结点
但是注意,尾结点的next不是0,而是-1
定义的时候,有两种方法:
- 先定义Node,再去声明静态链表数组,缺点是意义不明
- 用宏定义+typedef直接定义静态链表结构,这样虽然僵化,但是直观
初始化静态链表:
- 头结点next赋-1
- 其他节点的next赋-2。之所以这么做,是为了后续找空节点方便
查找,插入,删除,其实和链表思路一样,只不过next是用数字代替了。
静态链表有什么优点呢?
其实毫无优点,论速度,不如顺序表,方便不如链表,哪哪都不行。
唯一的应用场景:
- 底层语言:底层原理很简单,可以被用在不支持
指针
的底层语言里 - os的FAT文件分配表:数据元素数量不变,实际上是把一个链表限制在一定范围内
总结对比
答题的时候,可以从逻辑结构,储存结构,基本操作三方面分别对比,选择最佳的结构。
逻辑结构、储存结构
都是线性结构
储存结构,顺序表采用数组连续高密度储存,链表采用指针离散有浪费地储存。
基本操作
基本操作=创建销毁,增删查改
创销
需要特别注意动态分配的顺序表,因为是用malloc分配的,所以一定要配合free释放,否则会导致堆上的内存泄露。而声明的数组就不需要这样,这是因为其在栈上,会自动回收。
增删
前面说顺序表快,其实只是查找的时候快,而按位插的时候,顺序表和链表时间复杂度一样。
但是即便如此,其常数也是不同的,顺序表移动元素的成本是链表访问元素的k倍,可能是1000倍,越大的元素,顺序表移动的成本就越大。
查
顺序表:
按位查是O(1)
按值查找也可以在有序的前提下,通过二分算法加速成O(logN)
而链表都是O(n),需要遍历。
应用:矩阵压缩
二维矩阵可以压缩为一维数组,节省冗余空间
平铺的时候,分为行优先和列优先,但是无论如何,计算思路都是一样的:
- 在计算一维数组地址的时候,都是基地址+偏移量
- 偏移量取决于前面有几个元素,具体情况具体分析
- 一般是先计算前i-1行里有多少个元素
- 再计算当前元素在第i行是第几个元素
- 两者相加,就代表这个元素整体上是第几个元素
- 修正量。注意数组元素是从1开始还是从0,矩阵元素从1还是从0,仍然是具体看。
对称矩阵,只需要储存主对角线+下三角区
下面的公式给出了这种情况下的正向映射
整体思路就是对前面i-1行进行等差数列求和
,然后再加上当前行前面元素的个数
算其他映射也都是如此,先看前面i-1行/列,再考虑当前行
下三角矩阵,只需要额外多一个元素存常量
就行。
三对角矩阵,就是只有中间三条斜线才能存数据,其他都是0
整体来看,有n行,每行为3个元素,首尾两行是2个元素(3-1),后续进行计算就用这个视角理解就行。
来看正映射:
- 前i-1行,假设每行为3,就是3(i-1),但是第一行要修正一下,因此外面再-1
- 第i行,为2+j-i
- 以2为基底,如果元素在这行正中间,那么其就是这行第2个元素
- 根据j-i的值,确定这个元素是在左,中,还是右,假设j=3,i=2,那么说明这个元素就是这行第2+1=3个元素
两者相加,再-1,就是以0为初始的数组下标了。
三对角矩阵还会考你逆映射,这里统一说一下逆映射
的思路:
- 确定行数。
- 假定元素在i行
- 下界:前i-1行所有元素
- 上界:前i行所有元素
- 必然是:下界<元素位序≤上界
- 根据上界/下界,计算,取整
- 一般来说只需要根据一边计算就行,结果都是一样的,一般是用带等号的一边(比如这里的上界)
注,下面王道书应该是有误,因为如果位序=下界,那么元素就在i-1行了。
又或者,这种思路里k指代数组下标,那么就是:下界≤数组下标<上界
稀疏矩阵压缩:
最基本的数组结构,采用<i,j,k>结构体数组,只有空间压缩,时间上比较慢,要遍历
而进阶版的十字链表
,我直呼牛逼,真的在满足了空间压缩的前提下,做到了最快:
- 两个数组,给出了行列的索引,以O(1)时间复杂度就可以锁定行/列
- 在锁定行/列的前提下,再进行内部遍历,就可以以极快速度找到对应元素
具体是按照行还是列遍历呢?没有定论
那我提出一个思路,就是在行列数组上再附加一个数据,即该行/列内的元素个数,给定一个坐标,我们先比较i行和j列内部的元素哪个多,然后选择一个比较少的路线遍历。
假设i行有100个元素,而j列只有2个元素,肯定是从j列里面遍历,秒杀。
栈和队列
栈
基本概念
栈就是只能在栈顶增删的线性表。
GetTop和Pop都可以返回栈顶元素,区别只在于是否弹出,因为Pop要弹出,所以传入&S引用
栈会考一种出栈顺序的题,所有可能的出栈顺序加起来非常多,所以只能反向判断,判断哪个顺序不是可能的出栈顺序,一般是ABCD选择题会这么考。
顺序储存实现
顺序栈,top的含义是现在的
栈顶元素的下标
,top与空满的关系如下:
- 空栈的时候,无元素,因此下标
top==-1
,这也是判空
的依据。 - 满栈的时候,top指向最后一个元素,即
top==MaxSize-1
因为top指向现有栈顶下标,因此在入栈之前,要注意顺序:
- 让top指向新栈顶位置(抬升)
- 插入新栈顶
出栈是逆过程:
- 先读取
- 再让top回落(栈顶逻辑上弹出,其实元素还在)
为了保证进出栈的健壮性,会分辨判满和判空。
读取栈比出栈还简单,直接读取不回落。
前面的讨论,都是基于top代表现在的栈顶下标,还有一种方式:top代表下一个元素的位置,top-1才是现栈顶
在这种方式下,操作都会有偏移:
- 入栈出栈操作顺序都要反一下
- 判空判满也都要在原来的基础上多1
- 读取栈顶return data[top-1]
共享栈说实话作用不明,理论上可以弥补静态栈空间浪费的问题,但是感觉够呛。
姑且记着一个是-1,一个是MaxSize,然后满了即两个栈顶顶在一起了,top0+1=top1
链式储存实现
栈这个结构,不需要随机读取,因此用链式结构完全可以碾压顺序表实现。
最直接的思路可能是加一个链表尾部的指针,那你就慢了,直接逆序看,用头结点当栈顶,更快。
至于怎么插入,怎么删除,自己脑子里想一下就行,仅仅针对第一个结点,要比给定位序的增删更加简单。
队列,直观意义上发挥的就是排队的作用,先进的先出(FIFO),因此队尾用来插入,队头用来删除
队列
基本概念
顺序储存实现
基本循环队列
引入MOD运算后,逻辑上是环状的,所以顺序表队列又可以叫循环队列
每次有+1运算,就要配MOD防止溢出。
front:队头现在的下标
rear:指向队尾的下一个元素
如此设计有一个好处,就是入队和出队的操作顺序是一样的:
- 先放入/取出数据
- 再移动rear/front指针
在出/入前,还要判断空满:
- 空:front和rear在环上重叠
- 满:rear和front在环上相邻
在这个环上,还可以快速计算出队列长度:
最直接的思路是:rear-front
考虑到环以后是:(rear-front+MaxSize)%MaxSize
改变判满/空方法:辅助变量
最初的思路中,通过浪费一个元素的空间来区分满空,如果一个元素的空间比较大,那么浪费还是比较可观的。
此时可以引入一个size变量,通过size=0/MaxSize来区分是空还是满,而那个被浪费的元素空间也就可以利用起来了。这个size变量是int型,占用空间很小,所以更加经济实惠。
类似于size,还可以通过另外一种思路区分,在front=rear的时候,通过看最后一次操作是插入还是删除就可以判断出现在是空是满。
改变rear含义
最开始rear是指向队尾下一个元素,还可以改成指向队尾。
那么整个逻辑就都会有偏差:
- front=rear,代表有一个元素。因此初始化的时候需要令rear=MaxSize-1,这样插入第一个元素后,rear=front
- 插入元素的顺序改变。需要先移动再插入
- 判空判满逻辑改变。之前是重叠,现在是差一位,至于判空判满的区分,仍然是浪费一个空间/辅助变量。
链式储存实现
用链表实现队列,可以完全碾压数组。
先说初始化
带头结点,要先创建头结点,然后让front和rear指向头结点。
此时判空有两个特征:
- 头结点后无元素
- front和rear指向同一个地方
如果不带头结点,就直接让front=rear=NULL
此时判空就直接看front是否为空就行
再说入队
带头结点的逻辑是统一的:
- 创建新节点,收尾
- 对rear结点进行一次后插
- 后插后移动rear指针,保持在队尾
不带头结点,第一个节点的逻辑会有不同:
- 第一个节点不后插,而是让front和rear指向该节点
- 其他节点逻辑同后插
正常情况是对front节点进行后删
但是无论带不带头结点,都需要对最后一个元素特殊处理:
- 带头结点:
- 最后一个节点的时候,后删之余还要让rear归位,指向头结点,表示已经空了
- 不带头结点:
- 最后一个节点的时候,不是后删,而是让front和rear归NULL
双端队列
说白了,双端队列更像是双头栈,如果只看一端的话,就会退化成栈。
3412
举例,3如果先出,那么剩下的(21),必然是逆序出来。
4321
举例,4如果先出,那么剩下的(321),必然是逆序
任何一个数先出来,那么就去进行排除:
- 排除掉已经出栈的,找到剩下的还在栈里的
- 在这些数里找到比这个数小的
找出的这些数必须逆序出来,否则就是有问题。
打脸了,上面那个思路虽然有用,但是对于双端队列是无效的,有用,但不多。
还是老老实实地模拟吧,给你ABCD选项,你自己一个一个试就行。
栈和队列的应用
括号匹配(栈)
大二学数据结构的时候,有道算法题就是这样。
思路很简单,就是左括号压入,右括号匹配,关键在于终止情况:
- 如果右括号有,左括号没(栈空),说明右括号单身
- 如果左右括号都有,但是不一致,那么就是不匹配
- 如果扫完整个串后,右括号消耗完了,但是左括号还有(栈非空),说明左括号单身
- 这些都通过了,那就是完美配对。
表达式求值(栈)
后缀表达式
中缀转后缀:
- 操作数顺序一定不变
- 操作符顺序有多种可能
- 在左优先思路下,其结果唯一,与转后缀算法结果统一
- 所谓左优先,其实就是从左往右看,只要能算就直接出结果
以下图为例:
按照左优先,先处理左边
最开始是A()+
然后扩写,A B()*+
再扩写,ABCD-*+
引入右半部分ABCD-*+()-
扩写ABCD-*+EF/-
后缀表达式的计算,其实手算和计算的思路是一模一样的,都是栈思想。
至于后缀转中缀,计算的过程本身就是在转成人类可以理解的中缀,所以转换过程蕴含在计算过程中。
前缀表达式
前缀和后缀是相反的:
- 中转前,要从右往左,右优先扫描。而中转后是左优先
- 运算中缀,要从右往左扫,送到栈里算。而运算后缀,是从左往右扫,送到栈里算。
- 出栈的时候,前缀是左操作数先出,而后缀是右操作数先出
中转前,按照右优先思路,就是从后往前扫,能先算的就先算:
最先算:/EF
加入-号:-()/EF
扩写(括号内部继续从右往左扫):-*B-CD/EF
加入+号:+A-*B-CD/EF
中缀转后缀算法
这个比较重要,单独拿出来,思想:
- 栈和后缀
- 栈储存运算符,用于确定运算顺序。
- 操作数是直接丢到生成的后缀式里的,至于运算符会根据次序弹到后缀式里
- 优先级
- 栈内运算符大于等于自己优先级,肯定是先算的
- 所以在压入当前操作符前,先弹出去
- 让其在后缀式中更靠近左端(先算)
- 界限符
- ( 发挥一个临时栈底的作用,实际上就是要强行修改运算符顺序了
- )清空临时栈。看到),或者再没有输入的时候,说明运算符顺序就已经确定,直接弹就行
你会发现,这个过程中,是从左往右输出后缀表达式的,恰好后缀表达式计算的过程是从左往右扫的
因此中缀转后缀,以及后缀计算,这两个过程可以用两个栈一次性实现了,左边负责转化,输出,右边负责计算,输入
最后就可以得到结果。
顺便提一嘴,这个东西很重要,在计组以及编译原理中,把人类语言转化为计算机可识别的无二义性的语言,就需要这个过程转化。
递归调用(栈)
结合计组来看,递归调用就是栈帧叠加的过程。
一个栈帧里面,最底部储存了调用返回地址,之后上面储存了局部变量和传入的参数。
本质上,栈起到的作用有两个:
- 隔离。在当前函数中修改数据,不会影响到其他栈帧中的数据
- 还原。返回地址。
具体到函数调用过程,如果只是简单的递归,那还比较简单,分叉情况就需要仔细讨论一下了:
- 画出递归树
- 先序遍历递归树
可以看到,有一些重复计算,因此递归算法有两个缺点:
- 重复计算
- 对栈空间消耗较大,如果递归较深,可能爆栈
- 递归过程慢
因此,递归算法通常会被改进成循环算法来提高时间和空间利用率,又或者会改成动态规划类的算法,降低重复计算。
队列的应用
队列的本质就是FIFO
而层次遍历也是如此:
从上到下,在遍历当前层的时候,会按序依次加入下一层的元素,而且只会加入一层元素。
因此就是先遍历完当前层,再遍历下一层,再遍历下下层,队列和层次遍历统一了。
广度优先遍历,其实也是一层一层,只不过是以一个点为中心向四周扩散,只要逐层,顺序,那就符合FIFO特性,就可以用队列实现。
队列还可以用于计算机内部的缓冲。
一般来说,缓冲区其实就是就绪队列,先进先出,先来先服务,后来者可以先休息,等待服务。
串
数据结构
定义
概念区分
- 位置:这个同前面的位序,逻辑概念一般都是从1开始
- 字符集编码。字符集指的是字符集合,而字符集编码是通过映射的方式对字符编码,同一个字符集可能有不同的编码,比如UTF-8,UTF-16
实现
储存结构
顺序储存采用数组,如果想要扩容,就要用malloc和free。
下图为4种储存方案,方案3是C语言字符串结构,方案12单独用空间储存长度,实现解耦,但是各有缺点
方案4是最佳选择,仅仅浪费了一个字符的空间,损失很小,但是带来了巨大的方便:位序与下标统一,且长度不限
链式储存扩容比较容易,但是其他操作都很麻烦,而且分节储存后,也会为操作带来不便,因此后续算法都是用数组方案4实现的。
算法实现
在方案4的前提下,位序和下标是一致的。
SubString函数,取的下标范围是pos到pos+len-1,总长len
开始还有一个越界判断,因为length和最大串长一致,因此很符合直观逻辑,即最后一个下标是否>length
循环中,因为位序统一的缘故,从i=1开始,以i<=length结尾
返回只看正负,所以直接用差值就好
Index函数非常粗暴,遍历,每次判断都要进行StrCompare,时间效率是比较低的,但是直观。
注意边界条件,两个数组尾部对齐的时候,是最后一次判断,因此循环次数是n-m+1,从1开始。
串匹配算法
朴素模式匹配算法
朴素模式匹配算法,类似于前面的SubString,但是这里没有进行调用,而是直接去写:
ij分别指向ST两个串,大循环是一个while循环,让i和j从前往后扫:
- 如果匹配失败了,ij就各自回退一定的量
- 最终退出条件有两种,会在退出后进行判断
- j超过了T边界,说明无误地扫完了子串,即匹配成功,此时想当于提前退出了(if条件)
- i超过了S边界,说明扫完了都没匹配上,即匹配失败(else部分)
KMP算法
上面是KMP算法在碰到匹配失败点时的初始状态。
KMP的本质在于,只要确定不匹配的点位,就说明前面的都是匹配的
,就可以利用这些已知信息针对性的跳步,除了第一个就不匹配的时候,其他情况下i指针不需要回退
,而跳过的部分分为两类:
- 肯定匹配不上的:比如下图中的i=2,3时
- 可能匹配且已经匹配好的:比如下图中的i=4
你需要明白,这个过程是不依赖于主串
的,仅仅依赖于子串本身,所以在一开始就可以计算出在不同点位不匹配时,j需要回退的位置,在具体扫描过程中无需重复计算。
woc真是妙啊,大天才!
需要特别声明的是,一般情况,i是不回退也不前进的,但是当第一个字符就不匹配时,ij都要同时前进一次,这个逻辑略微特殊,具体实现如下图:
为了代码代码逻辑一致,需要令next[1]=0
- 当j≠1,那么就直接让模式串指针按照通用逻辑移动
- 当j=1,说明第一个就失败。
- 先让j变0(统一的代码逻辑),后面再让i,j同时自增
- 实际上就是让j从1变0又变1,而i++,只不过为了代码逻辑统一服务才这么折腾的
下图给出两个算法的区别:
- ij同时自增的条件多了一个j==0,即j=next[1]之后
- 其实这里告诉我们,j=1时候的修改逻辑其实是非常精妙的,不仅统一了j=next[j]的代码逻辑,还统一了i++;j++的代码逻辑,一石二鸟,高手
- 匹配失败后i不需要回退,而只需要修改j
- 当然,j=1还会牵动i++
实际上观察一下i,j的变动:
- j根据情况回退
- i整体上每次循环都在前进,不回退
- 正常情况以及上一次刚开始就匹配失败的情况,i前进
- 非正常情况,i会卡一个循环不动
在不考虑偶尔卡住的i,从头到尾遍历完也就只需要O(n)的时间,考虑到计算next数组,实际时间是O(n+m),远远小于朴素匹配
只能用精妙来评价KMP算法了!
求next数组
next1=0,next2=1,这两个是无脑的
从next3开始,以失败点左侧为分界线,逐步后移模式串,直到其前半部分可以和主串在分界线左边的部分完全匹配上,这部分就是匹配成功不需要匹配的,此时指向的j就是其next数组对应的值
(“很少有人能在第一次学KMP的时候,就彻底理解的”,巧了,本人就是,不仅理解了,还把整体过程以及核心思想归纳出来了)
KMP优化——不在重复的地方跌到
j不能落在上次失败的字符上,否则下次匹配必然上来就失败。
比如因为b匹配失败了,那么j一定不能落在b上。
如果落在b上,接下来就需要继续移动模式串,如果还落到b,那就继续,一直移动直到非b为止,记录在next数组中,此时已经叫做nextval了
我们上面是直接求nextval的思路,实操过程中,会让你通过next数组来获得nextval数组
首先要明白,从j=2开始,就有可能在同一个地方跌倒了,所以即使next[2]是无脑写的,也可以进行优化,有两种情况:
- 一般情况(else),是直接原样拿过来的,无法优化
- 踩坑情况(if):
- 怎么才算踩坑呢?当前匹配失败的字符(T.ch[j])与我要转移到的点位字符相同(T.ch[next[j]]),即转以后仍然踩坑
- 怎么避免踩坑呢?二连跳。从第一次回退踩坑的位置(x),进一步回退到不踩坑的位置(nextva[x])。而这个x就是next[j],用next作用j是第一级跳,而用nextval再次作用,是第二级跳。
- 妙处就在于,nextval[[nextj]]一定存在。因为我在j踩坑的时候,next[j]的回退踩坑问题肯定已经处理完了,因此我以next[j]的回退踩坑解决机制为基础,构造j的回退回退踩坑解决机制,就是合情合理的了。动态规划的感觉很像,以前面已经计算出的结果去计算新的结果。