1-8更新:之前写的KMP算法求pnext的部分有些错误,感谢我的好室友让我发现了问题,现在修改之后应该说的比以前更清楚了。
整理笔记主要目的是为了方便以后复习,其中我觉得写的比较好的部分是图论部分的几个算法(尤其是AOE网)(也可能是因为比较简单),其他部分写的不太好…
如有错误欢迎指正,感激不尽! QAQ
栈的应用
栈的应用:中缀表达式与后缀表达式之间的转换,表达式计算
(这个题我也没搞太懂,写的很烂,建议不要看)
后缀表达式、前缀表达式都比较简单(仅限二元运算符并且不支持负数)
后缀表达式直接用一个栈来计算,前缀表达式可以递归
这是计算前缀表达式的代码:
#前缀表达式
global p
p = 0
def f(s):
"计算表达式s直到首个字符匹配的部分已经计算完,或者整个表达式计算完"
global p
if p>=len(s):
print("Unknown error")
return
sign = s[p]
if sign.isdigit():
p+=1
return int(sign)
p+=1
n1 = f(s)
n2 = f(s)
if sign=="+":
return n1+n2
elif sign =="-":
return n1-n2
elif sign == "*":
return n1*n2
elif sign =="/":
return n1//n2
else:
print("Undefined sign")
return None
最难的是中缀表达式的计算以及它和其他类型表达式(以后缀为例)的转换。
这两个问题又可以转化成一个问题,理由如下:
对于中缀转后缀来说:
- 中缀表达式转化为后缀表达式的过程中,数字的顺序是不变的
- 在后缀表达式中运算符号出现的位置就是进行运算的位置,也就是说一旦扫描到这个运算符就要把栈顶的两个元素拿出来运算
- 因此中缀转后缀和中缀的计算区别只是遇到运算符号的时候是输出还是进行计算,如果要把中缀转后缀的代码改成计算中缀的代码,加一个数据栈就行
-
将待处理的字符串拆分为单独的项(运算符或者数字
-
一次拿出来一项进行分析
-
如果遇到数字直接输出
-
如果遇到右括号,将栈里面的项逐个弹出,直到弹出左括号
-
遇到运算符Oi:
如果栈中没有运算符,说明表达式不合法
从栈顶拿出运算符Oi-1,(在中缀表达式中,两个相邻的运算符之间只能相隔一个数字或者一个结果为数字的完整子表达式(?),不妨即为b),这个时候b已经被输出,(如果是在运算的话b应该现在在计算后缀表达式那个栈的栈顶,b之前有符号说明b之前还有数字(记为b’),也就是说如果此时输出一个二元运算符是合法的,
如果Oi-1的优先级不低于Oi,就是下面这种情况:
中 缀 表 达 式 局 部 : . . . b ′ O i − 1 b O i . . . 后 缀 表 达 式 局 部 : . . . b ′ b O i − 1 . . . 中缀表达式局部: \ \ ...b'\ O_{i-1} \ b \ O_i \ ... \\ 后缀表达式局部: \ \ ...b'\ b \ O_{i-1} ... 中缀表达式局部: ...b′ Oi−1 b Oi ...后缀表达式局部: ...b′ b Oi−1...
-
栈中存的是待执行的运算
几种队列的实现
顺序队列、顺序循环队列、链式队列、队列应用
优先队列的实现
线性表
插入元素:找位置,插入
栈顶为最后一个元素
实现easy,效率低
堆
Def:完全二叉树
要求二叉树的0到h-1层节点都满,如果最下面一层的节点不满,应该填满左边
这样我们的完全二叉树就可以用简单线性表来表示了,通过计算层之间下标的关系就可以找到节点的父亲或者孩子
存储在线性表中的堆中元素和其孩子之间的关系:
j的父亲是 (j-1)//2
基于堆,可以实现优先队列
具体方法:
-
定义:父节点的优先级大于等于子节点的完全二叉树形成堆
-
方法
-
插入
先向堆的最后插入元素,此时仍然是完全二叉树
要将其调整为堆:
如果新元素优先级大于其父节点,交换,向上传递,over
-
弹出堆顶元素
直接删除堆顶元素后,剩余两个堆,把堆的最后一个元素放到堆顶,
选现在堆顶元素及其两个孩子中最小的放到堆顶,向下传递
-
基于堆这个数据结构,还可以实现一种高效的排序–堆排序
下面是堆排序的代码:
def heap_sort(elems):
def shiftdown(elems,e,begin,end):
'''
这个函数的功能是把e插入begin的地方,然后向下调整,end参数是为了限制堆的范围
'''
i,j = begin , begin*2+1
while j<end:
if j+1 < end and elems[j+1] < elems[j]:
j+=1 #j是i的两个孩子中最小的那个
if e<elems[j]: #可不可以改成 <=???
break
elems[i] = elems[j] #e>=elem,将elems[j]挪上去,往下走,相当于是要在j的地方插入新元素e
i,j = j ,2*j+1 #由上往下调整
elems[i] = e
end = len(elems)
for i in range(end//2,-1,-1): #将元素插入堆中,从后到前(?,这里没太懂
shiftdown(elems,elems[i],i,end)
for i in range((end-1),0,-1): #依次弹出堆顶元素放到最后面,调用shiftdown调整
e = elems[i]
elems[i] = elems[0]
shiftdown(elems,e,0,i)
关于循环生成堆,我的理解是这样的
**开始:**有两种情况,对于这个堆来说,i之后的元素都不会扰乱这个堆的结构,因此要从i开始,依次“向堆中加入”前面的元素
为什么从后到前对元素进行shiftdown: 插入一个元素后的调整是从上往下的,从后到前保证了shiftdown某个元素的时候列表中他之后的元素都符合堆序,注意,这里说的堆序是对于整个大堆来说,而不是把后面的部分看成子堆。换句话说,这个生成堆的过程就是将一个乱序的完全二叉树从下往上调整成一个堆,调整某个元素的时候只需要管他下面的元素,而上面的元素之后再调整。
弹出元素的理解相对简单,就是把堆顶元素放到后面已经排好序部分的最前面,然后把原来这个位置的元素插入到堆顶(用shiftdown)。
KMP算法
一种高效的字符串匹配算法
关键思想: 预处理、利用已经产生的信息
朴素匹配算法:
def naive_match(t,p):
m,n = len(p),len(p)
i,j = 0,0
while i<n:
if p[j]==t[i]:
i,j = i+1,j+1
else:
i,j = i-j+1,0;
if j==m:
return i-j
return None
问题在于每次匹配失败就得从i-j+1开始,KMP算法想办法避免了这种重复性的工作
KMP算法的思路:
匹配到t[j] != s[i]时,这个字符之前的串相等,如果模式串的 t0,t1,…,tj-1这个子串(记为T0吧)中存在相等的前缀和后缀(设最长为k,即前缀为t0,…,tk-1),下一次比较的时候可以直接将模式串向前移动直到T0的前缀和主串对应的后缀重叠,那么我们知道这一段前缀必然和s中相应位置的字符相等,随后可以直接从位置k开始比较。
- 一个新的问题是如何找到每个j对应的k
k表示 t0,t1,…,tj-1中最长 前缀==后缀 的长度(即图中蓝色部分),如果不存在,则长度为0,用列表pnext表示。pnext[j]
的含义是t0,t1,…,tj-1中最长 前缀==后缀 的长度,例如对于串 “aaabaaa”,pnext[6] = 2 而不是3。
注意这里说的前缀==后缀不包括t0,t1,…,tj-1自身,因此pnext[1] = 0
还有一个值得注意的地方是如果j==0,也就是说j之前模式串匹配的结果已经不包含信息了,那么下一个应该从头开始匹配(用模式串的0去匹配主串的i+1),这里我们用一个特殊的-1来表示,写匹配循环的时候特判一下。
-
pnext[0] = -1
pnext[1] = 0
-
设
k = pnext[i]
-
如果
t[k] == t[i]
pnext[i+1] = k+1
-
如果
t[k] != t[i]
k = pnext[k]
,继续比较
-
这里相当于拿t0,t1,…,ti去匹配自己(在下图就是把上面那个字符串看做模板,下面看做要匹配的对象)。
而i之前的pnext值都已算出,所以也可以看做是在局部使用我们前面的匹配算法,如下图,如果 t[k] != t[i] ,上面那条字符串往前走,继续比较
(蓝色代表已经匹配)
求pnext的代码:
def get_pnext(p):
i,k,m = 0,-1,len(p)
pnext = m * [-1]
while i<m-1: #为什么是m-1,因为i=m-2,进入循环,就可以求出pnext[m-1]了
if k==-1 or p[i]==p[k]:
i,k = i+1,k+1 #注意这里加过1了
pnext[i] = k
else:
k = pnext[k]
return pnext
数组和广义表
稀疏矩阵的表示
-
使用三元组表示,结构中存有行数、列数、非零元素的个数,还有三元组列表。三元组列表按照顺序存储
-
运算
-
转置:
- 方法1 交换行列个数,调整顺序表,注意调整顺序表之后还要是顺序的(确定行优先或者列优先)
- 方法2 快速转置(ppt,没看)
-
乘法(ppt有,还没看)
-
广义表的 概念及表示
广义表(Lists,又称为列表 ):是由n(n ≧0)个元素组成的有穷序列: LS=(a1,a2,…,an)
其中ai或者是原子项,或者是一个广义表。LS是广义表的名字,n为它的长度。若ai是广义表,则称为LS的子表。
(2) 广义表可以被其它广义表所共享,也可以共享其它广义表。广义表共享其它广义表时通过表名引用。
(3) 广义表本身可以是一个递归表。
(4) 根据对表头、表尾的定义,任何一个非空广义表的表头可以是原子,也可以是子表, 而表尾必定是广义表。
-
节点
一类是表结点,用来表示广义表项,由标志域,表头指针域,表尾指针域组成;
另一类是原子结点,用来表示原子项,由标志域,原子的值域组成
-
python表示
可以规定都是List,List 中单元素的为数据,多元素的为表。
例如 [[1] , [[1],[3]] , [[2],[14],[12]] ]
树
二叉树的表示
- 二叉树表 , 每个节点对应一个三元组 [n,l,r] 分别为该节点的数据, 左子节点, 右子节点
- 二叉树类
二叉树的非递归遍历(important)
先序遍历: 一直向左走,走的过程中遍历p节点,然后把p.r加入栈
def xxbl(tree,proc):
s =[]
p = tree.root
while s or p:
while p:
proc(p)
s.append(p.r)
p = p.l
p = s.pop
中序遍历:
向左走 --> 压栈
弹栈 --> proc§ --> 向右迈一步(遍历右孩子)
def zxbl(tree,proc):
s = []
p = tree.root()
while s or p:
while p:
s.append(p)
p = p.l
p = s.pop()
proc(p)
p = p.r
后序遍历:
从根向下走(优先向左,不能向左就向右),直到到达叶子节点的子节点,过程中把路过的节点都加入栈
t=弹出栈顶元素 --> proc(t) --> 如果t是某节点的左子节点 t = s[-1].r 如果t是某节点的右子节点,T=None
def hxbl(tree,proc):
t = tree.root
s =[]
while s or t:
while t:
s.append(t)
t = t.l if t.l else t.r
#此时栈顶元素为叶子
t = s.pop()
proc(t)
if s and t == s[-1].l:
t = s[-1].r
else:
#进入这里代表t是一个右节点(或者根),下面应该遍历t的父亲,但是不能沿着t的父亲向左下走,用赋值为None的方法避免了前面的循环,妙哇!
t = None
# t=s.pop() if s else None #如果改成这一句就不对,这样会循环访问左子节点
多叉树(林)— 二叉树的转换
多叉树能和他的一个子集之间形成同构映射,这说明多叉树集合是一个无限集合 (废话
因此储存树可以用 孩子–兄弟表示法
图
图的存储
-
邻接矩阵
-
邻接表
-
用Python的字典 : (i,j) --> aij
Python官方的字典是使用散列表实现的,因此查找效率也是比较快的,但是书上说“是否适合大型图需要检验”,我觉得可能是因为散列表用于太多元素可能会有很多冲突,导致效率变低
要实现的功能: 初始化, 加点 ,加边 ,搜索边,查找从一个节点连出的边
图的相关算法
最小生成树
Kruskal 算法
- 要点1 选最短边,用 优先队列
- 要点2 “避圈” 我觉得可以使用并查集, 给每个连通分量找一个祖宗 , 或者直接用遍历的方法合并连通分量(书上的方法
Prim算法
-
原理: MST性质
将网络G分为两个集合, 如果e是横跨两个集合的边中权值最小的,则最小生成树里面一定有e
-
方法:
从一个顶点出发, 依次按照MST性质扩充这个子图, 最终使得整个图连通
最短路径
Dijkstra算法(标号法)
功能: 给定一个顶点求到其他所有顶点的最短距离,所有边权值必须>=0才能用
基本思想和Prim算法类似,从起点出发,把这个点看做我们现已知最短路的点的集合(记为U),逐步向外扩张
这个算法的核心是三个概念:
- 已知最短路径的集合
- 已知最短路径(T标号,也就是书上的cdis,可以说cdis是dis的上界)
- 绝对最短路径 (P标号,也就是书上的dis)
每一步要进行的操作:
算出并修改V-U中顶点的cdis,从中挑一个最小的并入U集合
补充说明:
之所以有的点(记为vi)是cdis而不是dis,是因为我们不知道集合U之外有没有这样一个点vj,使得v0–>vj–>vi这条路线比已知的(也就是仅仅局限于U内部的)路径短。
但是如果vi的cdis是目前算出来cdis中最小的,那么从v0直接到vi比 先从v0到V-U中任何点再回到vi的任何路径都近,因此vi的cdis就是dis
Floyd算法
这个算法用于一次性求解所有点对之间的最短路径。考虑用矩阵来表示,Aij表示从顶点i到j的最短路径长度,顶点为v0,v1,…vn-1。
基本想法是递推,最初考虑邻接矩阵为我们暂时的解矩阵,然后设法得到A0,A1,…,Ak, …,An。k的含义是在这个暂时的“最优解”矩阵Ak里面,我们暂时求得的最短路径都是只考虑了途径点的标号小于等于k的路径。
具体方法:
-
开始 A = 邻接矩阵
-
k = 0 考虑经过的顶点包含v0,那么对于每一对i,j,A0[i][j] = min{ A[i][j] , A[i][0] + A[0][j]}
-
k = 1 A1[i][j] = min{ A0[i][j] , A0[i][1] + A[1][j]} 得到从i到j,并且途径点序号全部小于等于1的最短路径
从i到j,并且途径点序号全部小于等于1的最短路径分为两类,一类是经过1的( A0[i][1] + A[1][j]),一类是不经过1的(A0[i][j])
这样,我们求出的A1[i][j]就是最短的
这里用了图论中的一个性质:如果s是从ai到aj的一个最短路径,且s经过中间点ak,则s被ak分成的s1,s2分别是ai到ak和ak到aj的最短路径
-
k = 2 。。。。。。
-
k = n 最终结果
总之
A
k
+
1
[
i
]
[
j
]
=
m
i
n
{
A
k
[
i
]
[
j
]
,
A
k
[
i
]
[
k
]
+
A
k
[
k
]
[
j
]
}
A_{k+1} [i][j] = min \{A_k[i][j] \ , \ A_k[i][k]+A_k[k][j] \}
Ak+1[i][j]=min{Ak[i][j] , Ak[i][k]+Ak[k][j]}
理解了之后程序实现不难
拓扑排序 AOV网
AOV网, 有向无权图, 顶点表示活动,有向边表示活动之间的优先关系
拓扑排序(Topological Sort) :由某个集合上的一个偏序得到该集合上的一个全序的操作。
就是说有一堆任务,他们之间有一些有优先关系,你现在要找到一个顺序做完这些事
实现:
- 从图中选择一个没有前驱的节点输出
- 去掉这个节点发出的边
- 回到1,直到图中没有满足条件的边(剩下的为空图或者环)
关键路径 AOE网
AOE网,带权有向图,顶点表示事件,有向边表示活动,边的权表示活动持续时间
顶点表示的事件是 它的入边表示的活动都已经完成,出边表示的活动可以开始的状态
与AOV网不同的是AOE网支持并行
AOE网要研究两个问题:
-
完成整个工程至少需要多少时间?
-
哪些活动是影响工程进度(费用)的关键?
教材和ppt都是直接给了一堆定义,什么最早可能发生时间、最迟允许发生时间、关键路径…我试图先理解这个算法是干什么,然后引入这些定义。
- 完成整个工程至少需要多少时间?
完成工程即到达结束事件(结束事件记为Vn-1,开始事件记为V0)
考虑到达Vk的最短时间。什么时候能完成Vk,前提是已经完成Vk之前的(也就是发出指向Vk的边)的事件都要完成,并且从这些顶点到达Vk的活动都要完成(必要条件),这里应该取的是最大值。
为了递推地求这个“最短时间”我们引入事件的最早可能发生时间 ee[i]
由上面的分析得到如下公式
e
e
[
0
]
=
0
e
e
[
j
]
=
m
a
x
{
e
e
[
i
]
+
w
[
i
,
j
]
∣
<
v
i
,
v
j
>
∈
E
}
ee[0] = 0\\ ee[j] = max\{ee[i]+w[i,j]\ |\ <v_i,v_j>\in E \}
ee[0]=0ee[j]=max{ee[i]+w[i,j] ∣ <vi,vj>∈E}
递推到结束事件即可求出完成整个工程至少需要的时间
- 哪些活动是影响工程进度(费用)的关键?
可以这样想:首先,结束事件必然是关键事件,考虑哪些事件的拖延会导致结束事件的延迟,在刚刚我们求最早可能发生时间的过程中,取到的那个最大的 ee[i] + w[i,j]就是事件j之前不能拖延的事件,也就是关键事件,所以判断关键事件的条件就是 ee[j] == ee[i] + w[i,j] 前提是j已经确定为关键事件,那么这个边<vi,vj>对应的活动也就不能推迟了,<vi,vj>就属于关键路径
求解关键路径采用从终点逆推的方法
我觉得如果只是要判断关键路径是哪条,不需要用书上的办法(不需要引入最迟允许发生时间),但是引入这个概念的好处是可以得到每个活动可以允许推迟多久
定义: 事件(顶点)的最迟允许发生时间le[i]
公式:
l
e
[
n
−
1
]
=
e
e
[
n
−
1
]
l
e
[
i
]
=
m
i
n
{
l
e
[
j
]
−
w
[
i
,
j
]
∣
<
v
i
,
v
j
>
∈
E
}
le[n-1] = ee[n-1] \\ le[i] = min\{le[j]-w[i,j] \ | \ <v_i,v_j>\in E\}
le[n−1]=ee[n−1]le[i]=min{le[j]−w[i,j] ∣ <vi,vj>∈E}
求解le[i]只需要考虑所有以vi为起点的出边集
满足条件ee[k]==le[k]的顶点为不能推迟的事件,但是我们要判断那个活动不能推迟 也就是要找到关键路径,注意如果一个边的两个端点都是关键事件,这个边不一定是关键活动,那么如何判断是不是关键活动呢?
这里又要引入一些定义:
活动ak = <vi,vj>
- 活动的最早可能开始时间:e[k] = ee[i]
- 活动的最迟允许开始时间: l[k] = le[j] - w[i,j]
- 活动的时间余量: l[k]-e[k]
如果一个活动的时间余量等于0那么它属于关键路径。
内部排序及其复杂度分析
定义:
- 稳定性: 关键码相同的数据排序后是否保持原来的顺序
- 适应性:对接近有序的序列工作更快的排序算法称其有适应性
分类:
(书上的表格
外部排序,文件,内存管理
基本定义
- 文件
- 定义
- 结构
- 物理结构 顺序结构,链接结构,索引结构
- 逻辑结构 记录之间形成一种线性结构(逻辑上的),称为文件的逻辑结构
文件的组织方式指的是文件的物理结构。
-
平均查找长度 ASL
也就是平均每做一次查找需要比较的次数
定义:
A S L = ∑ p i c i ASL = \sum p_i c_i ASL=∑pici
pi是查找第i个元素的概率(一般为1/n)ci是查找这个元素需要比较的次数
-
对于顺序查找
A S L = 1 / n ∑ i = 1 n i = 1 n n ( n + 1 ) 2 = n + 1 2 ASL = 1/n \sum_{i=1} ^n i=\frac 1n \frac {n(n+1)}2=\frac{n+1}2 ASL=1/ni=1∑ni=n12n(n+1)=2n+1 -
对于折半查找,可以把它想象成二叉树
A S L = ∑ i = 1 l o g 2 ( n + 1 ) i 2 i − 1 ≈ l o g 2 ( n + 1 ) − 1 ASL = \sum _{i=1}^{log_2 (n+1)} i \ 2^{i-1} \approx log_2 (n+1)-1 ASL=i=1∑log2(n+1)i 2i−1≈log2(n+1)−1
-
ISAM
基本文件、磁道索引、柱面索引、主索引
磁道索引、柱面索引、主索引有什么关系?
主索引 --> 柱面索引 --> 磁道索引 范围依次变小,类似于b树(看成树就是从根节点依次往下)
磁道是真正存储记录的地方
-
溢出区
在每个柱面上,开辟了一个溢出区,存放从该柱面的磁道上溢出的记录。同一磁道上溢出的记录通常由指针相链接
-
记录的检索
根据关键字查找时,首先从主索引中查找记录所在的柱面索引块的位置;再从柱面索引块中查找磁道索引块的位置;然后再从磁道索引块中查找出该记录所在的磁道位置;最后从磁道中顺序查找要检索的记录
-
记录的删除
只需找到要删除的记录,对其做删除标记,不移动记录。当经过多次插入和删除操作后,基本区有大量被删除的记录,而溢出区也可能有大量记录,则周期性地整理ISAM文件,形成一个新的ISAM文件
这不就是我整理电脑桌面的方法
VSAM
基本方法:采用的是基于B+树的动态索引结构
构成: 索引集、顺序集和数据集
定义: 控制区间
数据集分成多个控制区间;VSAM进行I/O操作的基本单位是控制区间,由一组连续的存储单元组成,同一文件的控制区间大小相同
定义: 控制区域
顺序集中的每个结点及与它所对应的全部控制区间组成一个控制区域
插入和删除: 与B+树类似,动态管理内存,通过控制区间和控制区域的分裂实现插入,直接删除
外部排序
利用外存对数据进行排序
-
归并排序
时间 = 产生初始归并段 + I/O操作 + 内部归并
内存管理与兄弟伙伴算法
关键词: 动态存储,空间分配
介绍几种方法:
三种分配策略:
将空闲块组织成一个链表
- 首次拟合法 先分配第一个, 没顺序
- 最佳拟合法 优先分配小的
- 最差拟合法 优先分配大的
2. 边界标识法
-
特点: 顺序片段管理内存, 内存区域头部和底部设置标识, 表示是否为空闲块, 回收时如果相邻区域空闲则合并
-
分配:
-
选定适当常量e,设待分配空闲块、请求分配空间的大小分别为m 、 n 。
◆ 当m-n≤e时:将整个空闲块分配给用户;
◆ 当m-n>e时:则只分配请求的大小n给用户;
-
每次查找时从不同的结点开始——上次刚分配结点的后继结点开始
-
3. 伙伴系统
这种内存管理方法要求内存池中的片段大小都是2的幂次
结构:
为了再分配时查找方便起见,我们将所有大小相同的空闲块建于一张子表中。每个子表是一个双重链表(是双向链表吗),这样的链表可能有m+1个,将这m+1个表头指针用向量结构组织成一个表,这就是伙伴系统的可利用空间表
我的理解是,总表是一个类似于二维链表的东西,把一些子表链起来,每个子表里面有大小相同的内存块。
分配:
◆ 若存在2k-1<n≤2k-1的空闲子表结点:则将子表中的任意一个结点分配之;
◆ 若不存在2k-1<n≤2k-1的空闲子表结点:则从结点大小为2k+1的子表中找到一个空闲结点,将其中一半分配给程序,剩余的一半插入到结点大小为2k的子表中。
释放:
如果两个内存块满足:
- 两个内存块大小相同
- 相邻
- 来源于同一个(大小是这两个内存块之和的)大内存块
则称他们为兄弟
对于首地址为p,大小为2^p的内存块来说:
b u d d y ( p , k ) = { p + 2 k if p = 0 ( m o d 2 k + 1 ) p − 2 k if p = 2 k ( m o d 2 k + 1 ) buddy(p,k) = \begin{cases} p+2^k &\text{if } p=0(mod \ 2^{k+1}) \\ p-2^k &\text{if } p=2^k(mod \ 2^{k+1}) \end{cases} buddy(p,k)={p+2kp−2kif p=0(mod 2k+1)if p=2k(mod 2k+1)
如果新释放的内存块的兄弟也空闲,那么就合并,并尝试将合并后的新节点放入相应子表,如果这个节点也可以和他的兄弟合并,继续合并…