数据结构笔记——线性表、栈、队列、串(王道408)

前言

本系列笔记分为三篇,系统总结了王道408数据结构课程的内容,加入了大量个人思考。

数据结构笔记——线性表、栈、队列、串(王道408)
数据结构笔记——树、图(王道408)
数据结构笔记——查找、排序(王道408)

数据结构的笔记相比于其他3门,笔记的重要性要低很多,毕竟对于选择408的同学来说,大二时候应该有足够的时间学习,所以基础是比较好的,再加上csdn上一大堆数据结构和算法的帖子,我再重复造轮子也没啥意思了。

所以我这篇文章不打算写的很细节,就是单纯地把思路提纯出来,并附上自己的理解,再搭配思维导图就行了,而不去记录过于细节的知识。

绪论

在这里插入图片描述

408的4门课,构建了计算机的根基。

在这里插入图片描述

计算机处理信息的原理如下:

  1. 现实-计算机。从现实到计算机,需要经过硬件+软件的转化,其中就需要数据结构表示现实事物。
  2. 内部处理。内部处理的过程中,需要高效,因此诞生了各种算法。
  3. 计算机-显示到现实。是步骤1的逆过程。

数据结构基本概念

在这里插入图片描述

基本概念

现实世界有信息,通过数据来承载信息,而数据可以被计算机所处理。

数据的形式可以是数值和非数值,但是底层只能是0和1,这是计算机的数学原理和基础构造决定的。

在这里插入图片描述

  1. 数据项
    • 在C语言中,int,float这些东西,都是最基本的数据单位,就是所谓的数据项
    • 数据项这个概念很矛盾,一边说不可分,但是另一边,有一些特殊的组合项是仍然可分的,比如如果用一个结构体当数据项,那么这个数据项就是组合项。
    • 我们姑且忽略组合项这种东西,就强行把数据项定义为不可分的基本单位就好。
  2. 数据元素
    • 在C语言中就是一个结构体实例,代表着一个具体的个体,比如张三。
  3. 数据对象
    • 同一类(相同性质)数据元素的集合
    • 在C语言中,就是同一个结构体原型实例化后的所有结构体实例的集合
  4. 数据
    • 数据对象的集合,代表了计算机中所有的数据

在这里插入图片描述

概念讲完了,然后就是数据结构的定义了。

数据结构描述了一个数据对象内部,不同的数据元素组合的方式。

数据结构是普遍适用的,因为不关心数据元素内部的数据项,所以凡是数据元素,都可以用数据结构去组织,因此不同的数据对象内部,完全可以适用同一个数据结构去组织。

数据结构三要素与ADT

在这里插入图片描述
数据结构:

  1. 定义:使用者视角
    • 逻辑结构定义。是什么
    • 数据运算定义。可以干什么
  2. 实现:开发者视角,需要考虑储存结构,这会影响到空间效率和时间效率。
    • 顺序,链式。
    • 索引储存。索引表通过key-value方式记录数据地址,给定一个key,可以马上得到地址,进而马上得到储存在内存中的值。优点是速度快,缺点是浪费一个索引表的空间,浪费内存。
      在这里插入图片描述
    • 散列储存。散列储存和索引一样快,但是却不需要借助索引表。散列储存本质上是通过一个映射函数代替索引表,这个函数就是Hash函数,输入一个key,直接输出地址,和索引表效果相同,但是并不占用内存。优点同索引,缺点在于,散列函数并不能100%利用内存空间,和索引储存半斤八两,都会浪费内存。

我们再把视角抬高,你会发现数据结构其实是一种ADT(抽象数据类型)。

数据类型=值的范围+可进行的操作,进而可以细分为原子类型(int,float)和结构类型(struct)
数据结构定义=逻辑结构+可进行的操作,和数据类型本质是一样的,所以把数据结构成为抽象数据类型,说白了,ADT描述了数据结构的逻辑部分,隐藏了实现部分,可以拿来即用。

定义好数据类型或者ADT后,使用者就可以使用,无论是哪种,使用者都可以拿来即用。

在这里插入图片描述

算法基本概念

在这里插入图片描述

算法定义

数据结构是原料,算法是处理步骤。

具体来说,数据结构负责将现实问题装入计算机,而算法就是一系列求解问题的步骤,将计算机中的问题求解出来。

算法五个基本特性

算法其实就是一个可以执行下去(有穷,可行)的函数(输入,确定,输出)

  1. 有穷性。
    • 步骤有穷,时间有穷
    • 程序≠算法,程序可能无穷(死循环)
  2. 确定性。
    • 说白了算法就是一个函数,x确定y就确定
    • 描述不能有歧义,比如最小,如果有两个一样小的,那就要修改成第一个最小的
  3. 可行性。
    • 算法=基本运算有限次组合。其实和有穷性差不多
  4. 输入。
    • 可以为0个
  5. 输出
    • 至少一个,不然没啥用

算法只要求这些,不强求正确,只要是确定的就行。

好算法的进阶特性

  1. 正确。
    • 实用算法的基本要求,算法不一定要求对,但是拿来用一定得对
  2. 可读。
    • 伪代码或者代码+注释
  3. 健壮性。
    • 容错率
  4. 高效率和低储存量需求
    • 时间复杂度低和空间复杂度低

算法复杂度

算法复杂度=时间复杂度+空间复杂度

事后统计(×):评估算法不应该用实际执行时间,因为影响因素太多了,而且有些算法不能事后统计
事前估计(√)

时间复杂度

在这里插入图片描述
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与具体情况有关,那么就有三种计算方法:

  1. 最好。参考意义不大
  2. 最坏。常用
  3. 平均。常用,这个平均需要写出概率分布,计算期望

在这里插入图片描述

空间复杂度

在这里插入图片描述

S:空间复杂度

S是空间开销与n的关系

内存开销来源:

  1. 变量定义/malloc
  2. 函数调用

递归调用的空间复杂度就有点像循环时候的时间复杂度,有时候也会算一个式子。

在这里插入图片描述

线性表

在这里插入图片描述

定义和基本操作

在这里插入图片描述

注意:

  1. 逻辑上,描述线性表的位序是从1开始的
  2. 物理上,用数组实现线性表下标从0开始。

在这里插入图片描述

顺序表

定义

在这里插入图片描述

在这里插入图片描述

静态分配方式中,InitList函数在初始化的时候,可以省略脏数据清洗的过程,但是Length=0不可以省略。
只要Length=0,那么在不违规访问的前提下,就不可能碰到脏数据,所以才可以省略

在这里插入图片描述

动态分配的原理:

  1. 将储存数据的空间与SqList数据结构分离,通过内部的指针Elem *data连接。
  2. 为了控制动态分配,需要用当前长度和理论最大长度两个变量

扩容原理:

  1. 双指针:使用两个指针,指向两片连续内存
    • p继承旧的内存
    • data指向新开内存
  2. 内存搬运+MaxLength扩容
  3. 释放旧内存
    在这里插入图片描述

顺序表优缺点:

  1. 高密度(数据和控制分离)
  2. 高速(随机访问)
  3. 12优点的代价就是不方便,只要涉及到改变结构的操作,都很消耗资源

增删

在这里插入图片描述

给定条件:

i=插入位序,并非实际下标(i-1),最后是要插到data[i-1]

先看核心部分:

  1. 移位
    1. 核心操作: j-1 → j
    2. 起始:j=Length。Length代表末尾+1,是我们的目标
    3. 终点:j=i
      • 因为要插到i-1,所以最后一次循环肯定是把i-1的数据搬到i
      • 所以j≥i,可以保证最后一次循环是针对i-1的搬运
  2. 插入
  3. 增加长度

非核心部分就是两个判断:

  1. i的范围。i是位序,所以从1开始,到Length+1
  2. 溢出。Length=MaxSize后,不能进行插入

在这里插入图片描述

复杂度分析,经典的等差数列复杂度,最好1,最坏n,平均下来是O(n)

在这里插入图片描述
删除的时候,给定的i仍然是位序,实际删的是i-1元素

核心部分:

  1. 取出,从i-1取出元素到e
  2. 移位
    1. 核心操作:j-1 ← j
    2. 起始:j=i。因为要填到i-1(目标),所以从i开始
    3. 终点:j<Length。把数组最后一个元素(Length-1)搬运完就行
  3. 减长

在这里插入图片描述

复杂度和插入相同。

在这里插入图片描述

顺序表的按位查找很简单,随机存取,复杂度为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已经无意义了,代表越界,后续进行判断

如果一切正常,就可以进行插入操作了:

  1. p指向前驱,s指向待插入元素
  2. 先填充s指向的节点
  3. 更改链表结构:先让s对应元素指向链表(过河),再让p对应元素指向s对应元素(拆桥)

在这里插入图片描述

很明显,第3步不能反,没过河怎么能拆桥呢?

在这里插入图片描述
如果越界,即i>Length,那么p在移动过程中就会变成NULL,提前退出循环,并触发判断语句,报错。

不带头结点的按位插入,只需做点改进:

  1. p一开始指向的就是1号元素,所以j=1,其余逻辑一样
  2. i为1的特殊情况:在最开始增加i==1的判断,要改变头指针,同样是先过河再拆桥

带不带头结点的根本区别在于,不带头结点时,i=1的逻辑与其他逻辑不同,i=1是在头指针与节点之间插入结点,而i=其他是在两个结点之间插入结点。

在这里插入图片描述

前面的是给定位序的插入,现在是给定节点的后插

后插其实很简单:

  1. 申请,赋值
  2. 过河,拆桥

前面的位插,其实可以直接用封装好的后插代码InsertNextNode

在这里插入图片描述

给定节点的前插就比较麻烦了,如果按照传统思路,还要先从头结点找到前驱,很难,很慢。

不如转换思路,我们又不是一定要插到p前面,只要效果一样就行。
最终的效果是要把插入元素的内容放在p的前面,而不一定是那个节点,本质上我们插的是内容,那么可以改一下步骤:

  1. 插入节点s
  2. 将p的内容移到s中
  3. 用e覆盖原来p的内容

这样的速度很快,而且效果一致。
其实开拓思路的思路就在于,唯效果论,尽可能去破除人为强加的条条框框,获得我们最基础,最核心的目标。
然后针对目标,制定方法,就可以跳出原有思路的限制,本质是跳出了原有隐性前提的限制。

在这里插入图片描述

按位序删除:

目标:删除第i个节点
寻找逻辑:p指向第i-1个,因此同样是j=0,然后移动i-1次
逻辑:q指向被删节点,然后直接用p拆桥
理论上,单论拆桥,q是不需要的,可以用p->next=p->next->next;代替
q唯一的作用就是拆桥以后的善后,free(p),没有p就无法释放被删除的节点,顺便承担了提升代码可读性的次要任务。

在这里插入图片描述
删除指定节点:

和前插同样的问题,找不到前驱。
前插思路还可以应用到删除目标节点上,本质仍然在于,我们要删的是内容,而不是p指向的节点,因此把下一个节点的内容挪到p节点里,删掉下一个节点就好:

  1. 用q指向后继
  2. 移动数据
  3. 过河拆桥,释放后继(q的作用仍然只是用于free)

注意bug:这段代码没有考虑到p指向最后一个节点的情况,无论如何,当p指向最后一个节点,那么删除就必须依靠前驱了,因此一定是要从头遍历一次的,因为即使你知道p是最后一个节点,单靠p指针是无法回溯到前面的。
即使你增加判断,如果q==NULL,只有一个p也无法实现删除操作,问题不在于判断,而在于需要依靠前驱。

因此,最有效率的做法是增加判断,如果q==NULL(p为末尾节点),就以O(n)为复杂度找前驱,除此之外的情况,都可以以O(1)的效率来直接后插。

在这里插入图片描述
如果可以回溯(双向链表),那就更简单了,这就是后话了

按位查的逻辑前面已经写过了:

  1. 最开始p指向头结点
  2. j=0
  3. 循环i次,所以j<i,如果越界,p=NULL后退出循环

最后返回一个指针,表示我们找到的节点。

右边王道书给的代码略有差异,刚开始让p指向第一个节点,那么j=1,后续逻辑同理。
在这里插入图片描述

后插和按位查找都可以封装进去,那么带头结点的按位插入就会变得非常简单,先找到前驱节点,然后直接后插就好,顶多再加一个越界判断。

在这里插入图片描述

按值查找,核心代码是一个遍历循环:

  1. 退出条件:p指向的元素为目标元素,或者p已经指向了NULL
  2. 初始化:让p指向第一个元素(其实指向头结点也是可以的,但是徒增1次循环损耗)
  3. 返回:p指针

在这里插入图片描述

建立

在这里插入图片描述
头插法是逆序的,可以用于逆置链表,尾插法是顺序的,执行的时候稍微要麻烦一点儿,多一个尾指针。

尾插法中,首先要建立头结点,之后对于每个元素的操作如下:

  1. 用s指针指向新节点,装入元素
  2. 用r指针链接新节点
  3. 更新r指针,使其指向尾结点

最后令尾结点的r->next=NULL,收尾。

在这里插入图片描述

头插法的流程:

  1. 新建节点
  2. 对头结点尾插

头插法和尾插都要收尾,头插在一开始收尾,尾插在最后收:

  1. 尾插法只能在最后一步执行完以后收尾r->next=NULL,因为尾结点总是在不断生成,只有完全插完,才能确定尾结点,然后收尾。
  2. 头插法最开始要让L->next=NULL,这是因为头插法的插入不影响尾结点,一开始就收尾是最好的。如果一开始不收尾,就得让一个指针跟踪尾结点,反而麻烦了。

在这里插入图片描述

双链表

在这里插入图片描述

首先定义后插,分为4步:

  1. 过河:新节点先连接后继
    • 注意,如果后继为NULL,则只需要单边连接就好
    • 12步可以交换
  2. 拆桥:前驱连接新节点
    • 34步可以交换
  3. 先过河再拆桥,仍然适用

在这里插入图片描述

前插可以直接用后插实现,先反向找到前驱,对前驱后插

删除节点。

单链表是针对一个节点进行删除,而双链表更多地使用后删

  1. p指向节点,q指向后继(被删除节点)
  2. 操作:
    • p指向q的后继
    • q的后继指向p
    • 可以交换顺序
  3. 三层健壮性判定:
    • p是否为NULL
    • p的后继是否为NULL
    • p的后继的后继是否为NULL(q的后继是否为NULL)
  4. q只是为了free而生,其他操作可以被p替代

在这里插入图片描述

定义了后删以后,清空链表的操作也就比较简单了,对着头结点一顿后删。

至于双链表的遍历,就比较简单,一股脑循环到p=NULL就行
如果说要早点停,那就p-next!=NULL或者p->prior!=NULL
这样就可以让p在循环结束后指向尾结点/头结点,跳过对这两个节点的处理。

在这里插入图片描述

循环链表

在这里插入图片描述

单链表,循环起来就是要让表尾节点的next指向头结点。
这个操作在建立之初就需要做,L->next=L

循环链表中,头结点是必不可少的,只有依靠L指针,才能确定循环链表的首尾:

  1. 判断表尾p->next==L
  2. 判断空表L->next==L。当头结点是表尾,自然就代表空表

在这里插入图片描述

循环单链表可以通过一个节点,顺着绕一圈找到表里的任何一个节点,比如前驱

循环单链表还有一种魔改版,就是让L指向尾结点。
如果L指向头结点,那么找到尾结点,需要O(n)
但是如果L指向尾结点,那么找到头结点只需要O(1)
如果一个操作需要频繁用到尾结点,那么让L保持在尾结点上也是一个不错的选择。

在这里插入图片描述

循环双链表,其实就是两个方向的循环单链表组合起来
因此判定表尾的操作和循环单链表一样,p->next==L

区别在于:

  1. 初始化的时候,要让L的next和prior都指向自己
  2. 插入和后删操作其实比双链表更简单了,因为前驱后继不存在NULL情况,无需判断特殊情况,直接过河+拆桥就可以。

在这里插入图片描述

静态链表

静态链表:用数组实现的链表,用下标来充当next指针

0号节点固定为头结点
但是注意,尾结点的next不是0,而是-1

在这里插入图片描述

定义的时候,有两种方法:

  1. 先定义Node,再去声明静态链表数组,缺点是意义不明
  2. 用宏定义+typedef直接定义静态链表结构,这样虽然僵化,但是直观

在这里插入图片描述

初始化静态链表:

  1. 头结点next赋-1
  2. 其他节点的next赋-2。之所以这么做,是为了后续找空节点方便

查找,插入,删除,其实和链表思路一样,只不过next是用数字代替了。

在这里插入图片描述
静态链表有什么优点呢?

其实毫无优点,论速度,不如顺序表,方便不如链表,哪哪都不行。
唯一的应用场景:

  1. 底层语言:底层原理很简单,可以被用在不支持指针的底层语言里
  2. os的FAT文件分配表:数据元素数量不变,实际上是把一个链表限制在一定范围内

总结对比

在这里插入图片描述

答题的时候,可以从逻辑结构,储存结构,基本操作三方面分别对比,选择最佳的结构。

逻辑结构、储存结构

都是线性结构

储存结构,顺序表采用数组连续高密度储存,链表采用指针离散有浪费地储存。

在这里插入图片描述

基本操作

基本操作=创建销毁,增删查改

在这里插入图片描述

创销

需要特别注意动态分配的顺序表,因为是用malloc分配的,所以一定要配合free释放,否则会导致堆上的内存泄露。而声明的数组就不需要这样,这是因为其在栈上,会自动回收。

在这里插入图片描述

在这里插入图片描述

增删

前面说顺序表快,其实只是查找的时候快,而按位插的时候,顺序表和链表时间复杂度一样。

但是即便如此,其常数也是不同的,顺序表移动元素的成本是链表访问元素的k倍,可能是1000倍,越大的元素,顺序表移动的成本就越大。

在这里插入图片描述

顺序表:

按位查是O(1)
按值查找也可以在有序的前提下,通过二分算法加速成O(logN)

而链表都是O(n),需要遍历。

在这里插入图片描述

应用:矩阵压缩

在这里插入图片描述
在这里插入图片描述

二维矩阵可以压缩为一维数组,节省冗余空间

平铺的时候,分为行优先和列优先,但是无论如何,计算思路都是一样的:

  1. 在计算一维数组地址的时候,都是基地址+偏移量
  2. 偏移量取决于前面有几个元素,具体情况具体分析
    • 一般是先计算前i-1行里有多少个元素
    • 再计算当前元素在第i行是第几个元素
    • 两者相加,就代表这个元素整体上是第几个元素
  3. 修正量。注意数组元素是从1开始还是从0,矩阵元素从1还是从0,仍然是具体看。

在这里插入图片描述

对称矩阵,只需要储存主对角线+下三角区

下面的公式给出了这种情况下的正向映射
整体思路就是对前面i-1行进行等差数列求和,然后再加上当前行前面元素的个数
算其他映射也都是如此,先看前面i-1行/列,再考虑当前行

在这里插入图片描述

下三角矩阵,只需要额外多一个元素存常量就行。

在这里插入图片描述

三对角矩阵,就是只有中间三条斜线才能存数据,其他都是0

整体来看,有n行,每行为3个元素,首尾两行是2个元素(3-1),后续进行计算就用这个视角理解就行。

来看正映射:

  1. 前i-1行,假设每行为3,就是3(i-1),但是第一行要修正一下,因此外面再-1
  2. 第i行,为2+j-i
    • 以2为基底,如果元素在这行正中间,那么其就是这行第2个元素
    • 根据j-i的值,确定这个元素是在左,中,还是右,假设j=3,i=2,那么说明这个元素就是这行第2+1=3个元素

两者相加,再-1,就是以0为初始的数组下标了。

在这里插入图片描述

三对角矩阵还会考你逆映射,这里统一说一下逆映射的思路:

  1. 确定行数。
    • 假定元素在i行
    • 下界:前i-1行所有元素
    • 上界:前i行所有元素
    • 必然是:下界<元素位序≤上界
  2. 根据上界/下界,计算,取整
    • 一般来说只需要根据一边计算就行,结果都是一样的,一般是用带等号的一边(比如这里的上界)

注,下面王道书应该是有误,因为如果位序=下界,那么元素就在i-1行了。
又或者,这种思路里k指代数组下标,那么就是:下界≤数组下标<上界

在这里插入图片描述

稀疏矩阵压缩:

最基本的数组结构,采用<i,j,k>结构体数组,只有空间压缩,时间上比较慢,要遍历

而进阶版的十字链表,我直呼牛逼,真的在满足了空间压缩的前提下,做到了最快:

  1. 两个数组,给出了行列的索引,以O(1)时间复杂度就可以锁定行/列
  2. 在锁定行/列的前提下,再进行内部遍历,就可以以极快速度找到对应元素

具体是按照行还是列遍历呢?没有定论
那我提出一个思路,就是在行列数组上再附加一个数据,即该行/列内的元素个数,给定一个坐标,我们先比较i行和j列内部的元素哪个多,然后选择一个比较少的路线遍历。

假设i行有100个元素,而j列只有2个元素,肯定是从j列里面遍历,秒杀。

在这里插入图片描述

栈和队列

基本概念

在这里插入图片描述

栈就是只能在栈顶增删的线性表。

GetTop和Pop都可以返回栈顶元素,区别只在于是否弹出,因为Pop要弹出,所以传入&S引用

栈会考一种出栈顺序的题,所有可能的出栈顺序加起来非常多,所以只能反向判断,判断哪个顺序不是可能的出栈顺序,一般是ABCD选择题会这么考。

在这里插入图片描述

顺序储存实现

在这里插入图片描述
顺序栈,top的含义是现在的栈顶元素的下标,top与空满的关系如下:

  1. 空栈的时候,无元素,因此下标top==-1,这也是判空的依据。
  2. 满栈的时候,top指向最后一个元素,即top==MaxSize-1

在这里插入图片描述
因为top指向现有栈顶下标,因此在入栈之前,要注意顺序:

  1. 让top指向新栈顶位置(抬升)
  2. 插入新栈顶

出栈是逆过程:

  1. 先读取
  2. 再让top回落(栈顶逻辑上弹出,其实元素还在)

为了保证进出栈的健壮性,会分辨判满和判空。

读取栈比出栈还简单,直接读取不回落。

在这里插入图片描述

前面的讨论,都是基于top代表现在的栈顶下标,还有一种方式:top代表下一个元素的位置,top-1才是现栈顶

在这种方式下,操作都会有偏移:

  1. 入栈出栈操作顺序都要反一下
  2. 判空判满也都要在原来的基础上多1
  3. 读取栈顶return data[top-1]

在这里插入图片描述

共享栈说实话作用不明,理论上可以弥补静态栈空间浪费的问题,但是感觉够呛。

姑且记着一个是-1,一个是MaxSize,然后满了即两个栈顶顶在一起了,top0+1=top1

在这里插入图片描述

链式储存实现

在这里插入图片描述
栈这个结构,不需要随机读取,因此用链式结构完全可以碾压顺序表实现。

最直接的思路可能是加一个链表尾部的指针,那你就慢了,直接逆序看,用头结点当栈顶,更快。

至于怎么插入,怎么删除,自己脑子里想一下就行,仅仅针对第一个结点,要比给定位序的增删更加简单。

在这里插入图片描述

队列,直观意义上发挥的就是排队的作用,先进的先出(FIFO),因此队尾用来插入,队头用来删除

在这里插入图片描述

队列

基本概念

在这里插入图片描述

顺序储存实现

在这里插入图片描述

基本循环队列

引入MOD运算后,逻辑上是环状的,所以顺序表队列又可以叫循环队列
每次有+1运算,就要配MOD防止溢出。

front:队头现在的下标
rear:指向队尾的下一个元素

如此设计有一个好处,就是入队和出队的操作顺序是一样的:

  1. 先放入/取出数据
  2. 再移动rear/front指针

在出/入前,还要判断空满:

  1. 空:front和rear在环上重叠
  2. 满:rear和front在环上相邻

在这里插入图片描述
在这个环上,还可以快速计算出队列长度:

最直接的思路是:rear-front
考虑到环以后是:(rear-front+MaxSize)%MaxSize

改变判满/空方法:辅助变量

最初的思路中,通过浪费一个元素的空间来区分满空,如果一个元素的空间比较大,那么浪费还是比较可观的。

此时可以引入一个size变量,通过size=0/MaxSize来区分是空还是满,而那个被浪费的元素空间也就可以利用起来了。这个size变量是int型,占用空间很小,所以更加经济实惠。

在这里插入图片描述
类似于size,还可以通过另外一种思路区分,在front=rear的时候,通过看最后一次操作是插入还是删除就可以判断出现在是空是满。

在这里插入图片描述

改变rear含义

最开始rear是指向队尾下一个元素,还可以改成指向队尾。

那么整个逻辑就都会有偏差:

  1. front=rear,代表有一个元素。因此初始化的时候需要令rear=MaxSize-1,这样插入第一个元素后,rear=front
  2. 插入元素的顺序改变。需要先移动再插入
  3. 判空判满逻辑改变。之前是重叠,现在是差一位,至于判空判满的区分,仍然是浪费一个空间/辅助变量。

在这里插入图片描述

在这里插入图片描述

链式储存实现

用链表实现队列,可以完全碾压数组。
在这里插入图片描述

在这里插入图片描述

先说初始化

带头结点,要先创建头结点,然后让front和rear指向头结点。

此时判空有两个特征:

  1. 头结点后无元素
  2. front和rear指向同一个地方

如果不带头结点,就直接让front=rear=NULL

此时判空就直接看front是否为空就行

在这里插入图片描述

再说入队

带头结点的逻辑是统一的:

  1. 创建新节点,收尾
  2. 对rear结点进行一次后插
  3. 后插后移动rear指针,保持在队尾

不带头结点,第一个节点的逻辑会有不同:

  1. 第一个节点不后插,而是让front和rear指向该节点
  2. 其他节点逻辑同后插

在这里插入图片描述

正常情况是对front节点进行后删
但是无论带不带头结点,都需要对最后一个元素特殊处理:

  1. 带头结点:
    • 最后一个节点的时候,后删之余还要让rear归位,指向头结点,表示已经空了
  2. 不带头结点:
    • 最后一个节点的时候,不是后删,而是让front和rear归NULL

在这里插入图片描述

双端队列

在这里插入图片描述

说白了,双端队列更像是双头栈,如果只看一端的话,就会退化成栈。

在这里插入图片描述

3412举例,3如果先出,那么剩下的(21),必然是逆序出来。
4321举例,4如果先出,那么剩下的(321),必然是逆序

任何一个数先出来,那么就去进行排除:

  1. 排除掉已经出栈的,找到剩下的还在栈里的
  2. 在这些数里找到比这个数小的

找出的这些数必须逆序出来,否则就是有问题。

在这里插入图片描述
打脸了,上面那个思路虽然有用,但是对于双端队列是无效的,有用,但不多。

还是老老实实地模拟吧,给你ABCD选项,你自己一个一个试就行。

栈和队列的应用

括号匹配(栈)

在这里插入图片描述

大二学数据结构的时候,有道算法题就是这样。

思路很简单,就是左括号压入,右括号匹配,关键在于终止情况:

  1. 如果右括号有,左括号没(栈空),说明右括号单身
  2. 如果左右括号都有,但是不一致,那么就是不匹配
  3. 如果扫完整个串后,右括号消耗完了,但是左括号还有(栈非空),说明左括号单身
  4. 这些都通过了,那就是完美配对。

在这里插入图片描述

表达式求值(栈)

在这里插入图片描述

后缀表达式

中缀转后缀:

  1. 操作数顺序一定不变
  2. 操作符顺序有多种可能
    • 在左优先思路下,其结果唯一,与转后缀算法结果统一
    • 所谓左优先,其实就是从左往右看,只要能算就直接出结果

以下图为例:

按照左优先,先处理左边
最开始是A()+
然后扩写,A B()*+
再扩写,ABCD-*+

引入右半部分ABCD-*+()-
扩写ABCD-*+EF/-
在这里插入图片描述

后缀表达式的计算,其实手算和计算的思路是一模一样的,都是栈思想。

至于后缀转中缀,计算的过程本身就是在转成人类可以理解的中缀,所以转换过程蕴含在计算过程中。

在这里插入图片描述

在这里插入图片描述

前缀表达式

前缀和后缀是相反的:

  1. 中转前,要从右往左,右优先扫描。而中转后是左优先
  2. 运算中缀,要从右往左扫,送到栈里算。而运算后缀,是从左往右扫,送到栈里算。
    • 出栈的时候,前缀是左操作数先出,而后缀是右操作数先出

中转前,按照右优先思路,就是从后往前扫,能先算的就先算:

最先算:/EF
加入-号:-()/EF
扩写(括号内部继续从右往左扫):-*B-CD/EF
加入+号:+A-*B-CD/EF

在这里插入图片描述

中缀转后缀算法

这个比较重要,单独拿出来,思想:

  1. 栈和后缀
    • 栈储存运算符,用于确定运算顺序。
    • 操作数是直接丢到生成的后缀式里的,至于运算符会根据次序弹到后缀式里
  2. 优先级
    • 栈内运算符大于等于自己优先级,肯定是先算的
    • 所以在压入当前操作符前,先弹出去
    • 让其在后缀式中更靠近左端(先算)
  3. 界限符
    • ( 发挥一个临时栈底的作用,实际上就是要强行修改运算符顺序了
    • )清空临时栈。看到),或者再没有输入的时候,说明运算符顺序就已经确定,直接弹就行

你会发现,这个过程中,是从左往右输出后缀表达式的,恰好后缀表达式计算的过程是从左往右扫的
因此中缀转后缀,以及后缀计算,这两个过程可以用两个栈一次性实现了,左边负责转化,输出,右边负责计算,输入
最后就可以得到结果。

顺便提一嘴,这个东西很重要,在计组以及编译原理中,把人类语言转化为计算机可识别的无二义性的语言,就需要这个过程转化。
在这里插入图片描述

递归调用(栈)

结合计组来看,递归调用就是栈帧叠加的过程。

一个栈帧里面,最底部储存了调用返回地址,之后上面储存了局部变量和传入的参数。
本质上,栈起到的作用有两个:

  1. 隔离。在当前函数中修改数据,不会影响到其他栈帧中的数据
  2. 还原。返回地址。

在这里插入图片描述
具体到函数调用过程,如果只是简单的递归,那还比较简单,分叉情况就需要仔细讨论一下了:

  1. 画出递归树
  2. 先序遍历递归树

可以看到,有一些重复计算,因此递归算法有两个缺点:

  1. 重复计算
  2. 对栈空间消耗较大,如果递归较深,可能爆栈
  3. 递归过程慢

因此,递归算法通常会被改进成循环算法来提高时间和空间利用率,又或者会改成动态规划类的算法,降低重复计算。

在这里插入图片描述

队列的应用

队列的本质就是FIFO

而层次遍历也是如此:

从上到下,在遍历当前层的时候,会按序依次加入下一层的元素,而且只会加入一层元素。
因此就是先遍历完当前层,再遍历下一层,再遍历下下层,队列和层次遍历统一了。

在这里插入图片描述

广度优先遍历,其实也是一层一层,只不过是以一个点为中心向四周扩散,只要逐层,顺序,那就符合FIFO特性,就可以用队列实现。

在这里插入图片描述
队列还可以用于计算机内部的缓冲。

一般来说,缓冲区其实就是就绪队列,先进先出,先来先服务,后来者可以先休息,等待服务。

数据结构

定义

在这里插入图片描述

概念区分

  1. 位置:这个同前面的位序,逻辑概念一般都是从1开始
  2. 字符集编码。字符集指的是字符集合,而字符集编码是通过映射的方式对字符编码,同一个字符集可能有不同的编码,比如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从前往后扫:

  1. 如果匹配失败了,ij就各自回退一定的量
  2. 最终退出条件有两种,会在退出后进行判断
    • j超过了T边界,说明无误地扫完了子串,即匹配成功,此时想当于提前退出了(if条件)
    • i超过了S边界,说明扫完了都没匹配上,即匹配失败(else部分)

KMP算法

在这里插入图片描述

上面是KMP算法在碰到匹配失败点时的初始状态。

KMP的本质在于,只要确定不匹配的点位,就说明前面的都是匹配的,就可以利用这些已知信息针对性的跳步,除了第一个就不匹配的时候,其他情况下i指针不需要回退,而跳过的部分分为两类:

  1. 肯定匹配不上的:比如下图中的i=2,3时
  2. 可能匹配且已经匹配好的:比如下图中的i=4

在这里插入图片描述

你需要明白,这个过程是不依赖于主串的,仅仅依赖于子串本身,所以在一开始就可以计算出在不同点位不匹配时,j需要回退的位置,在具体扫描过程中无需重复计算。

woc真是妙啊,大天才!

需要特别声明的是,一般情况,i是不回退也不前进的,但是当第一个字符就不匹配时,ij都要同时前进一次,这个逻辑略微特殊,具体实现如下图:

为了代码代码逻辑一致,需要令next[1]=0

  1. 当j≠1,那么就直接让模式串指针按照通用逻辑移动
  2. 当j=1,说明第一个就失败。
    • 先让j变0(统一的代码逻辑),后面再让i,j同时自增
    • 实际上就是让j从1变0又变1,而i++,只不过为了代码逻辑统一服务才这么折腾的

在这里插入图片描述

下图给出两个算法的区别:

  1. ij同时自增的条件多了一个j==0,即j=next[1]之后
    • 其实这里告诉我们,j=1时候的修改逻辑其实是非常精妙的,不仅统一了j=next[j]的代码逻辑,还统一了i++;j++的代码逻辑,一石二鸟,高手
  2. 匹配失败后i不需要回退,而只需要修改j
    • 当然,j=1还会牵动i++

实际上观察一下i,j的变动:

  1. j根据情况回退
  2. 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]是无脑写的,也可以进行优化,有两种情况:

  1. 一般情况(else),是直接原样拿过来的,无法优化
  2. 踩坑情况(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的回退回退踩坑解决机制,就是合情合理的了。动态规划的感觉很像,以前面已经计算出的结果去计算新的结果。

在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

亦梦亦醒乐逍遥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值