赞
踩
本系列笔记分为三篇,系统总结了王道408数据结构课程的内容,加入了大量个人思考。
数据结构笔记——线性表、栈、队列、串(王道408)
数据结构笔记——树、图(王道408)
数据结构笔记——查找、排序(王道408)
数据结构的笔记相比于其他3门,笔记的重要性要低很多,毕竟对于选择408的同学来说,大二时候应该有足够的时间学习,所以基础是比较好的,再加上csdn上一大堆数据结构和算法的帖子,我再重复造轮子也没啥意思了。
所以我这篇文章不打算写的很细节,就是单纯地把思路提纯出来,并附上自己的理解,再搭配思维导图就行了,而不去记录过于细节的知识。
408的4门课,构建了计算机的根基。
计算机处理信息的原理如下:
现实世界有信息,通过数据来承载信息,而数据可以被计算机所处理。
数据的形式可以是数值和非数值,但是底层只能是0和1,这是计算机的数学原理和基础构造决定的。
概念讲完了,然后就是数据结构的定义了。
数据结构描述了一个数据对象内部,不同的数据元素组合的方式。
数据结构是普遍适用的,因为不关心数据元素内部的数据项,所以凡是数据元素,都可以用数据结构去组织,因此不同的数据对象内部,完全可以适用同一个数据结构去组织。
数据结构:
我们再把视角抬高,你会发现数据结构其实是一种ADT(抽象数据类型)。
数据类型=值的范围+可进行的操作
,进而可以细分为原子类型(int,float)和结构类型(struct)
数据结构定义=逻辑结构+可进行的操作
,和数据类型本质是一样的,所以把数据结构成为抽象数据类型,说白了,ADT描述了数据结构的逻辑部分,隐藏了实现部分,可以拿来即用。
定义好数据类型或者ADT后,使用者就可以使用,无论是哪种,使用者都可以拿来即用。
数据结构是原料,算法是处理步骤。
具体来说,数据结构负责将现实问题装入计算机,而算法就是一系列求解问题的步骤,将计算机中的问题求解出来。
算法其实就是一个可以执行下去(有穷,可行)的函数(输入,确定,输出)
第一个
最小的算法只要求这些,不强求正确,只要是确定的就行。
算法复杂度=时间复杂度+空间复杂度
事后统计(×):评估算法不应该用实际执行时间,因为影响因素太多了,而且有些算法不能事后统计
事前估计(√)
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的关系
内存开销来源:
递归调用的空间复杂度就有点像循环时候的时间复杂度,有时候也会算一个式子。
注意:
位序
是从1开始的静态分配方式中,InitList函数在初始化的时候,可以省略脏数据清洗的过程,但是Length=0不可以省略。
只要Length=0,那么在不违规访问的前提下,就不可能碰到脏数据,所以才可以省略
动态分配的原理:
扩容原理:
顺序表优缺点:
给定条件:
i=插入位序,并非实际下标(i-1),最后是要插到data[i-1]
先看核心部分:
非核心部分就是两个判断:
复杂度分析,经典的等差数列复杂度,最好1,最坏n,平均下来是O(n)
删除的时候,给定的i仍然是位序,实际删的是i-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已经无意义了,代表越界,后续进行判断
如果一切正常,就可以进行插入操作了:
很明显,第3步不能反,没过河怎么能拆桥呢?
如果越界,即i>Length,那么p在移动过程中就会变成NULL,提前退出循环,并触发判断语句,报错。
不带头结点
的按位插入,只需做点改进:
带不带头结点的根本区别在于,不带头结点时,i=1的逻辑与其他逻辑不同,i=1是在头指针与节点之间插入结点,而i=其他是在两个结点之间插入结点。
前面的是给定位序的插入,现在是给定节点的后插
:
后插其实很简单:
前面的位插,其实可以直接用封装好的后插代码InsertNextNode
给定节点的前插
就比较麻烦了,如果按照传统思路,还要先从头结点找到前驱,很难,很慢。
不如转换思路,我们又不是一定要插到p前面,只要效果一样就行。
最终的效果是要把插入元素的内容
放在p的前面,而不一定是那个节点,本质上我们插的是内容,那么可以改一下步骤:
这样的速度很快,而且效果一致。
其实开拓思路的思路就在于,唯效果论,尽可能去破除人为强加的条条框框,获得我们最基础,最核心的目标。
然后针对目标,制定方法,就可以跳出原有思路的限制,本质是跳出了原有隐性前提的限制。
按位序删除:
目标:删除第i个节点
寻找逻辑:p指向第i-1个,因此同样是j=0,然后移动i-1次
逻辑:q指向被删节点,然后直接用p拆桥
理论上,单论拆桥,q是不需要的,可以用p->next=p->next->next;代替
q唯一的作用就是拆桥以后的善后,free(p),没有p就无法释放被删除的节点,顺便承担了提升代码可读性的次要任务。
删除指定节点:
和前插同样的问题,找不到前驱。
前插思路还可以应用到删除目标节点上,本质仍然在于,我们要删的是内容,而不是p指向的节点,因此把下一个节点的内容挪到p节点里,删掉下一个节点就好:
注意bug
:这段代码没有考虑到p指向最后一个节点的情况,无论如何,当p指向最后一个节点,那么删除就必须依靠前驱了,因此一定是要从头遍历一次的,因为即使你知道p是最后一个节点,单靠p指针是无法回溯到前面的。
即使你增加判断,如果q==NULL,只有一个p也无法实现删除操作,问题不在于判断,而在于需要依靠前驱。
因此,最有效率
的做法是增加判断,如果q==NULL(p为末尾节点),就以O(n)为复杂度找前驱,除此之外的情况,都可以以O(1)的效率来直接后插。
如果可以回溯(双向链表),那就更简单了,这就是后话了
按位查的逻辑前面已经写过了:
最后返回一个指针,表示我们找到的节点。
右边王道书给的代码略有差异,刚开始让p指向第一个节点,那么j=1,后续逻辑同理。
后插和按位查找都可以封装进去,那么带头结点的按位插入就会变得非常简单,先找到前驱节点,然后直接后插就好,顶多再加一个越界判断。
按值查找,核心代码是一个遍历循环:
头插法是逆序的,可以用于逆置链表,尾插法是顺序的,执行的时候稍微要麻烦一点儿,多一个尾指针。
尾插法中,首先要建立头结点,之后对于每个元素的操作如下:
最后令尾结点的r->next=NULL,收尾。
头插法的流程:
头插法和尾插都要收尾,头插在一开始收尾,尾插在最后收:
首先定义后插
,分为4步:
前插
可以直接用后插实现,先反向找到前驱,对前驱后插
删除节点。
单链表是针对一个节点进行删除,而双链表更多地使用后删
:
定义了后删以后,清空链表
的操作也就比较简单了,对着头结点一顿后删。
至于双链表的遍历,就比较简单,一股脑循环到p=NULL就行
如果说要早点停,那就p-next!=NULL或者p->prior!=NULL
这样就可以让p在循环结束后指向尾结点/头结点,跳过对这两个节点的处理。
单链表,循环起来就是要让表尾节点的next指向头结点。
这个操作在建立之初就需要做,L->next=L
循环链表中,头结点是必不可少的,只有依靠L指针,才能确定循环链表的首尾:
循环单链表可以通过一个节点,顺着绕一圈找到表里的任何一个节点,比如前驱
循环单链表还有一种魔改版,就是让L指向尾结点。
如果L指向头结点,那么找到尾结点,需要O(n)
但是如果L指向尾结点,那么找到头结点只需要O(1)
如果一个操作需要频繁用到尾结点,那么让L保持在尾结点上也是一个不错的选择。
循环双链表,其实就是两个方向的循环单链表组合起来
因此判定表尾的操作和循环单链表一样,p->next==L
区别在于:
静态链表:用数组实现的链表,用下标来充当next指针
0号节点固定为头结点
但是注意,尾结点的next不是0,而是-1
定义的时候,有两种方法:
初始化静态链表:
查找,插入,删除,其实和链表思路一样,只不过next是用数字代替了。
静态链表有什么优点呢?
其实毫无优点,论速度,不如顺序表,方便不如链表,哪哪都不行。
唯一的应用场景:
指针
的底层语言里答题的时候,可以从逻辑结构,储存结构,基本操作三方面分别对比,选择最佳的结构。
都是线性结构
储存结构,顺序表采用数组连续高密度储存,链表采用指针离散有浪费地储存。
基本操作=创建销毁,增删查改
需要特别注意动态分配的顺序表,因为是用malloc分配的,所以一定要配合free释放,否则会导致堆上的内存泄露。而声明的数组就不需要这样,这是因为其在栈上,会自动回收。
前面说顺序表快,其实只是查找的时候快,而按位插的时候,顺序表和链表时间复杂度一样。
但是即便如此,其常数也是不同的,顺序表移动元素的成本是链表访问元素的k倍,可能是1000倍,越大的元素,顺序表移动的成本就越大。
顺序表:
按位查是O(1)
按值查找也可以在有序的前提下,通过二分算法加速成O(logN)
而链表都是O(n),需要遍历。
二维矩阵可以压缩为一维数组,节省冗余空间
平铺的时候,分为行优先和列优先,但是无论如何,计算思路都是一样的:
对称矩阵,只需要储存主对角线+下三角区
下面的公式给出了这种情况下的正向映射
整体思路就是对前面i-1行进行等差数列求和
,然后再加上当前行前面元素的个数
算其他映射也都是如此,先看前面i-1行/列,再考虑当前行
下三角矩阵,只需要额外多一个元素存常量
就行。
三对角矩阵,就是只有中间三条斜线才能存数据,其他都是0
整体来看,有n行,每行为3个元素,首尾两行是2个元素(3-1),后续进行计算就用这个视角理解就行。
来看正映射:
两者相加,再-1,就是以0为初始的数组下标了。
三对角矩阵还会考你逆映射,这里统一说一下逆映射
的思路:
注,下面王道书应该是有误,因为如果位序=下界,那么元素就在i-1行了。
又或者,这种思路里k指代数组下标,那么就是:下界≤数组下标<上界
稀疏矩阵压缩:
最基本的数组结构,采用<i,j,k>结构体数组,只有空间压缩,时间上比较慢,要遍历
而进阶版的十字链表
,我直呼牛逼,真的在满足了空间压缩的前提下,做到了最快:
具体是按照行还是列遍历呢?没有定论
那我提出一个思路,就是在行列数组上再附加一个数据,即该行/列内的元素个数,给定一个坐标,我们先比较i行和j列内部的元素哪个多,然后选择一个比较少的路线遍历。
假设i行有100个元素,而j列只有2个元素,肯定是从j列里面遍历,秒杀。
栈就是只能在栈顶增删的线性表。
GetTop和Pop都可以返回栈顶元素,区别只在于是否弹出,因为Pop要弹出,所以传入&S引用
栈会考一种出栈顺序的题,所有可能的出栈顺序加起来非常多,所以只能反向判断,判断哪个顺序不是可能的出栈顺序,一般是ABCD选择题会这么考。
顺序栈,top的含义是现在的
栈顶元素的下标
,top与空满的关系如下:
top==-1
,这也是判空
的依据。top==MaxSize-1
因为top指向现有栈顶下标,因此在入栈之前,要注意顺序:
出栈是逆过程:
为了保证进出栈的健壮性,会分辨判满和判空。
读取栈比出栈还简单,直接读取不回落。
前面的讨论,都是基于top代表现在的栈顶下标,还有一种方式:top代表下一个元素的位置,top-1才是现栈顶
在这种方式下,操作都会有偏移:
共享栈说实话作用不明,理论上可以弥补静态栈空间浪费的问题,但是感觉够呛。
姑且记着一个是-1,一个是MaxSize,然后满了即两个栈顶顶在一起了,top0+1=top1
栈这个结构,不需要随机读取,因此用链式结构完全可以碾压顺序表实现。
最直接的思路可能是加一个链表尾部的指针,那你就慢了,直接逆序看,用头结点当栈顶,更快。
至于怎么插入,怎么删除,自己脑子里想一下就行,仅仅针对第一个结点,要比给定位序的增删更加简单。
队列,直观意义上发挥的就是排队的作用,先进的先出(FIFO),因此队尾用来插入,队头用来删除
引入MOD运算后,逻辑上是环状的,所以顺序表队列又可以叫循环队列
每次有+1运算,就要配MOD防止溢出。
front:队头现在的下标
rear:指向队尾的下一个元素
如此设计有一个好处,就是入队和出队的操作顺序是一样的:
在出/入前,还要判断空满:
在这个环上,还可以快速计算出队列长度:
最直接的思路是:rear-front
考虑到环以后是:(rear-front+MaxSize)%MaxSize
最初的思路中,通过浪费一个元素的空间来区分满空,如果一个元素的空间比较大,那么浪费还是比较可观的。
此时可以引入一个size变量,通过size=0/MaxSize来区分是空还是满,而那个被浪费的元素空间也就可以利用起来了。这个size变量是int型,占用空间很小,所以更加经济实惠。
类似于size,还可以通过另外一种思路区分,在front=rear的时候,通过看最后一次操作是插入还是删除就可以判断出现在是空是满。
最开始rear是指向队尾下一个元素,还可以改成指向队尾。
那么整个逻辑就都会有偏差:
用链表实现队列,可以完全碾压数组。
先说初始化
带头结点,要先创建头结点,然后让front和rear指向头结点。
此时判空有两个特征:
如果不带头结点,就直接让front=rear=NULL
此时判空就直接看front是否为空就行
再说入队
带头结点的逻辑是统一的:
不带头结点,第一个节点的逻辑会有不同:
正常情况是对front节点进行后删
但是无论带不带头结点,都需要对最后一个元素特殊处理:
说白了,双端队列更像是双头栈,如果只看一端的话,就会退化成栈。
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特性,就可以用队列实现。
队列还可以用于计算机内部的缓冲。
一般来说,缓冲区其实就是就绪队列,先进先出,先来先服务,后来者可以先休息,等待服务。
概念区分
顺序储存采用数组,如果想要扩容,就要用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从前往后扫:
上面是KMP算法在碰到匹配失败点时的初始状态。
KMP的本质在于,只要确定不匹配的点位,就说明前面的都是匹配的
,就可以利用这些已知信息针对性的跳步,除了第一个就不匹配的时候,其他情况下i指针不需要回退
,而跳过的部分分为两类:
你需要明白,这个过程是不依赖于主串
的,仅仅依赖于子串本身,所以在一开始就可以计算出在不同点位不匹配时,j需要回退的位置,在具体扫描过程中无需重复计算。
woc真是妙啊,大天才!
需要特别声明的是,一般情况,i是不回退也不前进的,但是当第一个字符就不匹配时,ij都要同时前进一次,这个逻辑略微特殊,具体实现如下图:
为了代码代码逻辑一致,需要令next[1]=0
下图给出两个算法的区别:
实际上观察一下i,j的变动:
在不考虑偶尔卡住的i,从头到尾遍历完也就只需要O(n)的时间,考虑到计算next数组,实际时间是O(n+m),远远小于朴素匹配
只能用精妙来评价KMP算法了!
next1=0,next2=1,这两个是无脑的
从next3开始,以失败点左侧为分界线,逐步后移模式串,直到其前半部分可以和主串在分界线左边的部分完全匹配上,这部分就是匹配成功不需要匹配的,此时指向的j就是其next数组对应的值
(“很少有人能在第一次学KMP的时候,就彻底理解的”,巧了,本人就是,不仅理解了,还把整体过程以及核心思想归纳出来了)
j不能落在上次失败的字符上,否则下次匹配必然上来就失败。
比如因为b匹配失败了,那么j一定不能落在b上。
如果落在b上,接下来就需要继续移动模式串,如果还落到b,那就继续,一直移动直到非b为止,记录在next数组中,此时已经叫做nextval了
我们上面是直接求nextval的思路,实操过程中,会让你通过next数组来获得nextval数组
首先要明白,从j=2开始,就有可能在同一个地方跌倒了,所以即使next[2]是无脑写的,也可以进行优化,有两种情况:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。