• 数据结构笔记(王道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
    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 提前终止:如果碰到了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

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

    右边王道书给的代码略有差异,刚开始让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指代数组下标,那么就是:下界≤数组下标<上界

    在这里插入图片描述

    稀疏矩阵压缩:

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

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

    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的回退回退踩坑解决机制,就是合情合理的了。动态规划的感觉很像,以前面已经计算出的结果去计算新的结果。

    在这里插入图片描述

  • 相关阅读:
    基于Java实现的植物大战僵尸游戏
    elasticsearch算法之搜索模型(一)
    Golang:反射机制reflect
    安卓sd卡格式化数据恢复方法,您了解吗
    2024程序员常用的几种算法
    CF1325D ( 异或和和性质
    Java Double valueOf(double d)方法具有什么功能呢?
    [模块]ES6与cjs的混合开发
    毛球修剪器方案开发的工作原理和构成
    Ubuntu 20.04.05安装ceres-1.14.0
  • 原文地址:https://blog.csdn.net/weixin_50295745/article/details/133048008