推荐的刷题工具书:算法竞赛宝典
一 图论
1.1 图以及图在计算机中的表示
图由节点和边组成,节点代表现实中的物体,边代表这些物体之间的关系。从关系的层面来看,线性表和树结构都可以看做是图的特例。图中重要的概念有(具体定义请查阅相关书目):有向图、无向图、入度、出度、度、路径、环路
在理解了图的基本概念和术语之后,接下来的问题就是如何将图这种数据结构放在计算机中,使得计算机能够处理图.图在计算机中的表示有两种方法:一种是用矩阵的形式进行存储称为领接矩阵法,另一种是用链表的形式进行存储称为领接表法.
领接矩阵:对于有n个节点的图,建立一个n*n的矩阵,当两个节点之间有边相连时,在领接矩阵中有一个相应的赋值表示,没有边连接时有一个相应的赋值表示.
领接表:对于每个节点和它所连接的节点建立一个链表,用多个链表表示图。
1.2 搜索
搜索并不是对于图这种数据结构所独有的一种方法,搜索是对问题进行求解时的一种通用思路.如果将求解问题看做是在问题空间中查找问题解的过程,搜索要解决的问题就是如何迅速、准确的找到问题的可行解。如果我们是沿着某一个方向一直向前进行搜索,只有当达到不可行解时才回退回来,继续查找其它的方向,那么这种搜索思想叫做深度优先搜索.如果我们同时尝试不同的可能解,那么这种搜索思想叫做广度优先搜索.搜索在图中的应用是为了解决”如何访问且仅访问一次所有的图节点”这个问题.
1.2.1 深度优先搜索(DFS)
核心思想
从某个状态开始,不断的转移状态,直到无法转移,然后回退到前一步的状态,继续转移到其他状态,如此不断重复,直至找到最终的解.在每个搜索节点x上,面对多条分支时,任意选一个走下去,执行递归,直至无法继续进行递归,回溯到点x,再考虑其它的边.在对节点遍历的过程中,赋予节点以标记,该标记叫做时间戳,表明当前节点已经访问过。深度搜索的过程中会产生一棵搜索树,其节点为问题空间中的某个状态,边是由问题空间中的一个点(某个状态)与成功发生递归的点进行连接所构成的.为了避免重复访问,我们对状态进行记录和检索,就是上面说的时间戳。为了使程序更加高效,我们提前剪除搜索树上不可能是答案的子树和分支,称为剪枝
一般的深度优先搜索模板
#深度优先搜索
def dfs(要进行递归的当前状态变量):
标记当前状态变量已经被访问
if 到达了问题空间的边界:(递归边界)
#已经搜索完了问题空间的一个分支
#进行回退之前的相应处理
更新目标变量
return
for 当前状态变量所连接的所有其余状态变量:(递归体)
标记这个连接的其余状态变量的状态为已经访问
dfs(这个其余状态变量)
#回退,取消标记点,方便其它的与这个状态变量相连的
#变量能够进行连接
标记这个连接的其余状态变量的状态为没有被访问
对于图的深度优先搜索模板
#访问顶点u
def dfs(u):
#标记当前顶点已经被访问
visit[u]=True
for 从u出发能够到达的所有顶点v:
#如果还没有被访问
if not visit[v]:
#对v进行深度优先搜索
dfs(v)
#遍历图G
def traver(G):
for G的所有顶点u:
if not visit[u]:
dfs(u)
算法实现
对于以领接矩阵存储的图进行深度优先搜索
#最大顶点数
MAXV=1000
#最大值
import sys
MAX_VALUE=sys.maxsize
#当前图G顶点数
n=int(input())
#图G的领接矩阵
G=[[0]*MAXV for i in range(MAXV)]
#状态标记
visit=[False for i in range(MAXV)]
#深度优先搜索
def dfs(u,depth):
'''
:param u: 当前访问顶点
:param depth: 当前深度
:return:
'''
#状态标记
visit[u]=True
for v in range(n):
#如果顶点v未被访问,且u可达v
if not visit[v] and not G[u][v]==MAX_VALUE:
#对顶点v进行深度优先搜索
dfs(v,depth+1)
#遍历图G
def traver(G):
for u in range(n):
if not visist[u]:
dfs(u,1)
例题:给定整数a1,a2,…,an,判断是否可以从中选出若干数,使它们的和恰好为k
#输入数字数量
n=int(input())
#数字
number_list=list(map(int,input().split()))
#要组成的数字
k=int(input())
#深度搜索
def dfs(i,sum)
"""
:param i: 当前搜索的数字项
:param sum: 当前搜索得到的和
:return:
"""
#搜索完毕
if i==n:
return sum==k
#不加上a[i]的情况
if dfs(i+1,sum)
return True
#加上a[i]
if dfs(i+1,sum+a[i])
return True
return False
1.2.2 广度优先搜索(BFS)
核心思想
广度优先搜索总是先搜索距离初始状态最近的状态,按照开始状态->只需1次转移就可以到达的所有状态->只需2次转移就可以到达的所有状态->…,这样的顺序进行搜索,对于同一个状态,广度优先搜索只经过一次(注意对比深度优先搜索,同一个状态在深度优先搜索过程中可能会被多次访问)。深度优先搜索隐式的利用了栈进行计算,而广度优先搜索则利用了队列.搜索时首先将初始状态加入队列,然后把从这个状态能够转移到的所有状态,并且还未访问过的状态加入队列,弹出队首的状态,重复这个过程,直到队列为空。广度优先搜索按照距开始状态由近及远的顺序进行搜索,因此可以用来求带有最短路径、最少操作这类字眼的优化问题。广度优先搜索是逐层遍历搜索树的算法,所有状态按照入队的先后顺序具有层次单调性,如果每一层扩展恰好对应一步,那么当一个状态第一次被访问入队时,就得到了从初始状态到达该状态的最少步数.
一般的广度优先搜索模板
#广度优先搜索
def bfs(开始状态):
将开始状态加入队列
while 队列非空:
当前状态=取出队列的队首元素
for 从当前状态所有可达的状态:
如果可达状态没有被访问, 将可达状态加入队列
对于图的广度优先搜索为
#广度优先搜索
def bfs(u):
#队列
queue q
将u入队
#标记已经访问
visit[u]=True
while q非空:
取出q的队首元素x进行访问
for 从x出发所有可达的顶点v:
if not visit[v]:
将v入队
visit[v]=True
#遍历图G
def traver(G):
for G的所有顶点u:
if not visit[u]:
bfs(u)
算法实现
图以领接矩阵存储
#最大顶点数
MAXV=1000
#最大值
import sys
MAX_VALUE=sys.maxsize
#图的顶点数
n=int(input())
#图G
G=[[]*MAXV for i in range(MAXV)]
#状态标记
visit=[False for in range(MAXV)]
#队列
from collections import deque
q=deque()
#广度优先搜索
def bfs(u):
'''
:param u: 当前状态
:return:
'''
#将当前状态加入队列
q.append(u)
#标记u已经访问
visit[u]=True
while not q.count()==0:
#取队首元素
u=q.popleft()
for v in range(n):
if not visit[v] and not G[u][v]==INT.MAX_VALUE:
q.append(v)
visit[v]=True
def traver(G):
for u in range(n):
if not visit[u]:
bfs(u)
例题:给定一个大小为N*M的迷宫,迷宫由通道和墙壁组成,每一步可以向领接的上下左右四格的通道移动,求出从起点到终点所需的最小步数。
#设置最大数
import sys
MAX_VALUE=sys.maxsize
#读入迷宫
n,m=map(int,input().split())
maze=[[] for i in range(n)]
for i in range(n):
maze[i]=list(map(int,input().split()))
#起点
sx,sy=map(int,input().split())
#终点
gx,gy=map(int,input().split())
#到各个位置的最短距离
d=[[MAX_VALUE]*m for i in range(n)]
#方向
dx=[1,0,-1,0]
dy=[0,1,0,-1]
#广度优先搜索
from collections import deque
queue=deque()
def bfs():
#起点加入队列
queue.add((sx,sy))
d[sx][sy]=0
while queue.count()!=0:
#取队首元素
temp=queue.popleft()
#到达终点,搜索结束
if temp[0]==gx and temp[1]==gy:
break
#向四个方向行走
for i in range(4):
newx=temp[0]+dx[i]
newy=temp[1]+dy[i]
#可以移动,且未被访问
if 0<=newx<=n and 0<=newy<=m and maze[newx][newy]!='#' and d[newx][newy]==MAX_VALUE:
queue.add((newx,newy))
d[newx][newy]=d[temp[0]][temp[1]]+1
return d[gx][gy]
1.3 最短路径
最短路径问题是求在以图中两个节点为起点和终点的路径中,边的权值和最小的那条路径
1.3.1 单源最短路径
单源最短路径是指给定一张有向图G=(V,E),V是点集,E是边集,|V|=n,|E|=m,节点以[1,n]之间的连续整数编号,(x,y,z)描述一条从x出发,到达y,长度为z的有向边.设1号点为起点,求长度为n的数组dist,其中dist[i]表示从起点到节点i的最短路径的长度.
简单来说就是指定图中的一个节点作为起点,求这个起点到图中其它节点的最短路径.
Dijkstra算法
算法思想
以起始点为中心向外层扩展(广度优先搜索思想),直到扩展到终点为止.
引入节点距离松弛的概念.
通过Dijkstra算法计算图G中的最短路径时,需要指定起点s(即从顶点s开始计算)。
引进两个集合S和U。
S的作用是记录已求出最短路径的顶点(以及相应的最短路径长度),而U则是记录还未求出最短路径的顶点(以及该顶点到起点s的距离)。
(1) 初始,S中只有起点s;U中是除s之外的所有顶点,并且U中顶点到s的距离为相应图中节点s到这些节点边的权重。
(2) 然后从U中找出距离节点s最短的顶点,并将其加入到S中;
(3) 接着借助新加入S节点更新U中的顶点到集合S的距离。
(4) 然后再从U中找出距离起点s路径最短的顶点,并将其加入到S中;
(5) 接着更新U,重复该操作,直到遍历完所有顶点。
#求s到图G中其它节点的最短距离
def dijkstra(G,s,dist):
"""
:param G: 图G
:param s: 最短路径的起点
:param dist: s到图G中其它顶点的最短距离
:return:
"""
初始化
for i in range(图的顶点数):
u=使dist[u]为最小,且还未被访问的顶点的标号
标记u已被访问
for 从u出发能够到达的所有顶点v:
if v未被访问 and 以u为中介点可以使s到顶点v的距离更短:
更新dist[v]
算法实现
图以领接矩阵存储
import sys
# 最大节点数
max_node = 1000
# 最大值
max_value = sys.maxsize
# 图的顶点数、边数
m, n = map(int, input().split())
# 最短距离
d = [max_value for i in range(m)]
# 访问状态
visit = [False for i in range(m)]
# 图
G = [[max_value for i in range(m)] for j in range(m)]
# 输入图
for i in range(n):
u, v, w = map(int, input().split())
G[u][v] = w
G[v][u] = w
# Dijkstra
def dijkstra(s, n):
# s为最短路径起点
# n为节点数
# 起点最短距离置0
d[s] = 0
# 最短距离d初始化+松弛其它边
# 循环n次
for i in range(n):
# 找距离最小节点
u = -1
min_value = sys.maxsize
# 第一次找到的一定是起点
for j in range(n):
if not visit[j] and d[j] < min_value:
u = j
min_value = d[j]
if u == -1:
return
# 利用u进行松弛
# 标记访问
visit[u] = True
for v in range(n):
if not visit[v] and G[u][v] != max_value and d[u] + G[u][v] < d[v]:
# 进行松弛
d[v] = d[u] + G[u][v]
s = int(input())
dijkstra(s, m)
print(d)
1.3.2 多源最短路径
相对于单源最短路径,多源最短路径是求图中任意两个顶点之间的最短距离
Floyd算法
算法思想
如果存在顶点k,使得以k作为中介点时,顶点i和顶点j的当前最短距离能够缩短,则使用顶点k作为顶点i和顶点j的中介点,即当dis[i][k]+dis[k][j]<dis[i][j]时,令dis[i][j]=dis[i][k]+dis[k][j]
算法实现
for k in range(n):
for i in range(n):
for j in range(n):
if dis[i][k]+dis[k][j]<dis[i][j]:
dis[i][j]=dis[i][k]+dis[k][j]
1.4 最小生成树
给定一个无向图,如果它的某个子图中任意两个顶点都互相连通,并且形成一棵树,那么这棵树就叫做这个图的生成树,如果边上有权值,那么使得边权和最小的生成树叫做最小生成树.
给定一个带权的无向图G=(V,E),n=|V|,m=|E|,由V中全部n个顶点和E中n-1条边构成的无向连通子图被称为G的一棵生成树,边的权值之和最小的生成树被称为无向图G的最小生成树.
对于图的最小生成树有一个常用的结论
“任意一棵图的最小生成树一定包含图中权值最小的边”
最小生成树的算法都是围绕这个定理展开的
1.4.1 Prim算法
算法思想
创建两个集合,一个集合s代表最小生成树上的节点,另一个集合u代表图中其它的顶点。开始时将一个节点v加入s中,然后在u中寻找和v连接且边的权值最小的节点n,将n加入s,然后在u中寻找和n连接且边的权值最小的节点m,将m加入s…,重复这个过程,直到s中节点数为图的节点数,Prim算法适合边稠密的图。
算法模板
#prim最小生成树
def prim(G,dist):
"""
:param G: 图
:param dist: 顶点到最小生成树节点集合的最短距离
:return:
"""
初始化
for i in range(图的顶点数):
u=使dist[u]最小的,还未被访问过的顶点的标号
标记u
for 从u出发能够到达的所有顶点v:
if not visit[v] and 以u为中介点能够缩短v到集合s的距离dist[v]:
dist[v]=G[u][v]
算法实现
图以领接矩阵存储
import sys
# 最大节点数
max_node = 1000
# 最大值
max_value = sys.maxsize
# 图的顶点数、边数
m, n = map(int, input().split())
# 最短距离
d = [max_value for i in range(m)]
# 访问状态
visit = [False for i in range(m)]
# 图
G = [[max_value for i in range(m)] for j in range(m)]
# 输入图
for i in range(n):
u, v, w = map(int, input().split())
G[u][v] = w
G[v][u] = w
# prim
def prim(s, n):
# s为起点
# n为节点数
# 起点的最短距离置0
d[s] = 0
# 最小生成树权值
ans = 0
# 最短距离d初始化+寻找其它节点
# 循环n次
for i in range(n):
# 找距离最小节点
u = -1
min_value = sys.maxsize
# 第一次找到的一定是起点
for j in range(n):
if not visit[j] and d[j] < min_value:
u = j
min_value = d[j]
if u == -1:
return
# 标记访问
visit[u] = True
# 添加权值
ans += d[u]
for v in range(n):
if not visit[v] and G[u][v] != max_value and G[u][v] < d[v]:
# 进行更新
d[v] = G[u][v]
return ans
s = int(input())
print(prim(s, m))
1.4.2 Kruskal算法
算法思想
将图中所有的边按照权值进行从小到大的排列,每次选择最小的边,如果将当前边加入到最小生成树后,最小生成树中不存在回路(环),则加入这条边到最小生成树中…重复这个过程,直到最小生成树中边的数目是图中边的数目减1,Kruskal算法适合点稠密的图。
算法模板
#kruskal算法
def kruskal():
令最小生成树边权之和为ans
最小生成树当前边的数量为Num_Edge
将所有边按照权值大小从小到大排序
for 从小到大枚举所有边:
if 当前边两个端点不在同一个连通块内:
将该边加入最小生成树
ans+=当前边的权值
Num_Edge+=1
if Num_Edge=顶点数-1:
break
return ans
算法实现
import sys
# 最大节点数
max_node = 1000
# 最大值
max_value = sys.maxsize
# 图的顶点数、边数
m, n = map(int, input().split())
# 最短距离
d = [max_value for i in range(m)]
# 访问状态
visit = [False for i in range(m)]
# 图
G = [[max_value for i in range(m)] for j in range(m)]
# 边
class Edge:
def __init__(self):
self.u = 0
self.v = 0
self.w = 0
# 边集合
edge_set = [Edge() for i in range(n)]
for i in range(n):
edge_set[i].u, edge_set[i].v, edge_set[i].w = map(int, input().split())
# 排序
edge_set = sorted(edge_set, key=lambda x: x.w)
# 并查集
father = [i for i in range(m)]
def findFather(x):
a = x
while x != father[x]:
x = father[x]
# # 路径压缩
# while a != father[a]:
# z = a
# a = father[a]
# father[z] = x
return x
def kruskal(m, n):
"""
:param m: 图的顶点数
:param n: 图的边数
:return: 最小生成树的边权之和
"""
ans = 0
Num_Edge = 0
# 遍历所有边
for i in range(n):
# 两个端点所在的集合
u_father = findFather(edge_set[i].u)
v_father = findFather(edge_set[i].v)
# 不在同一个集合
if u_father != v_father:
# 合并集合
# 将边加入最小生成树
father[u_father] = v_father
ans += edge_set[i].w
Num_Edge += 1
# 已经生成了最小生成树
if Num_Edge == (m - 1):
break
# 无法连通
if Num_Edge != (m - 1):
return -1
else:
return ans
print(kruskal(m, n))
二 动态规划
2.1 基本思想
动态规划(dynamic programming,DP)是一种设计思想,没有固定的模板,极其灵活,需要具体问题具体分析。
动态规划一般用来解决关于最优化的问题,当题目中带有‘最少’,‘最多’这些字眼时,一般可以使用动态规划来解决
动态规划将一个复杂的问题分解成若干子问题,通过综合子问题的最优解来得到原问题的最优解,一般使用递归或者递推来编写动态规划程序,递归写法又称作记忆化搜索。
递推的计算方式是自底向上,从问题边界开始,不断向上解决问题,直到解决了目标问题,而递归写法是自顶向下,从目标问题开始,将它分解成子问题的组合,直到分解至边界为止。
如果一个问题的最优解可以由其子问题的最优解有效的构造出来,那么称这个问题拥有最优子结构,一个问题必须拥有最优子结构,才能使用动态规划去解决。
求解动态规划问题主要是要:
- 理清问题要求解的最优目标
- 设计状态转移方程
2.2 最大连续子序列和
:给定一个数字序列A1,A2,An,求i,j(1<=i<=j<=n),使得Ai+…+Aj最大
解题思路:
令状态dp[i]表示以A[i]作为末尾的连续序列最大的和
则问题转化为求dp数组的最大值
对于dp[i]的值,只有两种情况
1.这个最大和的连续序列只有一个元素,即Ai
2.这个最大和的连续序列有多个元素,即A[p]+…+Ai
综上,状态转移方程为
dp[i]=max{A[i],dp[i-1]+A[i]}
代码实现:
#最大值
maxn=10001
#数字序列
A=[0 for i in range(maxn)]
#状态序列
dp=[0 for i in range(maxn)]
#数字数量
n=int(input())
for i in range(n):
A[i]=int(input())
#边界
dp[0]=A[0]
#状态转移
for i in range(n):
dp[i]=max(A[i],dp[i-1]+A[i])
#获取状态最大值
k=0
for i in range(n):
if dp[i]>dp[k]:
k=i
2.3 最长不下降子序列
:在一个数字序列中,找到一个最长的子序列,使得这个子序列是不下降的
解题思路:
令dp[i]表示以A[i]结尾的最长不下降子序列长度
对于dp[i]的值,只有两种情况
1.如果存在A[i]之前的元素A[j],使得A[j]<=A[i],且dp[j]+1>dp[i],则
把A[i]跟在以A[j]结尾的最长不下降子序列后,形成一条更长的不下降子序列
dp[i]=dp[j]+1
2.如果A[i]之前的元素都比A[i]大,则A[i]自成一条最长不下降子序列,dp[i]=1
则状态转移方程为:
dp[i]=max{1,dp[j]+1}
代码实现:
# 最长不下降子序列
# 数字个数
n = int(input())
# 数字序列
A = list(map(int, input().split()))
# 状态序列
dp = [0 for i in range(n)]
# 最大的不下降序列长度
ans = -1
# 状态转移
for i in range(n):
# 边界,先假设每个元素自成一个子序列,长度为1
dp[i] = 1
# 看前面的元素
for j in range(i):
# 如果能构成不下降序列
if A[i] >= A[j] and dp[j] + 1 > dp[i]:
dp[i] = dp[j] + 1
ans = max(ans, dp[i])
print(ans)
2.4 最长公共子序列
:给定两个字符串A,B求一个字符串,使得这个字符串是A和B的最长公共部分。
解题思路:
令dp[i][j]表示字符串A的i号位置和字符串B的j号位置之前的最长公共子序列
对于dp[i][j]的值,只有两种情况
1.若A[i]==B[j],则字符串A和字符串B的最长公共子序列增加了一位,dp[i][j]=dp[i-1][j-1]+1
2.若A[i]!=B[j],则字符串A和字符串B的最长公共子序列无法延长,dp[i][j]=max(dp[i-1][j],dp[i][j-1])
综上,状态转移方程为:
dp[i][j]=dp[i-1][j-1] A[i]==B[j]/max(dp[i-1][j],dp[i][j-1]) A[i]!=B[j]
代码实现:
#数据个数
n=int(input())
#字符串A
A=''
#字符串B
B=''
#状态数组
dp=[[0]*n for i in range(n)]
#输入字符串
A=input()
b=input()
lenA=len(A)+1
lenB=len(B)+1
#边界
for i in range(lenA+1):
dp[i][0]=0
for j in range(lenB+1):
dp[0][j]=0
#状态转移
for i in range(1,lenA+1):
for j in range(1,lenB+1):
if A[i]==B[j]:
dp[i][j]=dp[i-1][j-1]+1
else:
dp[i][j]=max(dp[i-1][j],dp[i][j-1])
print(dp[lenA][lenB])
2.5 最长回文子串
:给定一个字符串s,求s的最长回文子串的长度
解题思路:
令dp[i][j]表示s[i]到s[j]表示的子串是否是回文子串,是赋值为1,否则为0
则dp[i][j]的取值只有两种情况
1.若s[i]==s[j],那么只要s[i+1]到s[j-1]是回文子串,s[i]到s[j]就是回文子串
2.若s[i]!=s[j],那么s[i]到s[j]肯定不是回文子串
综上,状态转移方程为:
dp[i][j]=dp[i+1][j-1] s[i]==s[j]/0 s[i]!=s[j]
代码实现:
maxn=1010
#字符串
s=input()
#状态列表
dp=[[0]*maxn for i in range(maxn)]
lens=len(s)
#回文字符串长度
ans=1
#边界
for i in range(lens):
dp[i][i]=1
if i<len-1:
if s[i]==s[i+1]:
dp[i][i+1]=1
ans=2
#状态转移
#子串长度
for l in range(3,lens+1):
#左端点
for i in range(lens-l+1):
#右端点
j=i+l-1
if s[i]==s[j] and dp[i+1][j-1]==1:
dp[i][j]=1
ans=l
2.6 背包问题
:(0-1背包)有n件物品,每件物品重量为w[i],价值为c[i],现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大,其中每种物品都只有1件
解题思路:
令dp[i][v]为前i件物品恰好放入容量为v的背包中所能获得的最大价值
对于dp[i][v]只有两种情况
1.不放第i件物品,那么问题转化为前i-1件物品恰好装入容量为v的背包中
所能获得的最大价值,即dp[i-1][v]
2.放入第i件物品,那么问题转化为前i-1件物品恰好放入容量为v-w[i]的背包
中所能获得的最大价值,dp[i-1][v-w[i]]+c[i]
综述,状态转移方程为
dp[i][v]=max(dp[i-1][v],dp[i-1][v-w[i]]+c[i])
代码实现:
for i in range(n+1):
for v in range(w[i],V+1):
dp[i][v]=max(dp[i-1][v],dp[i-1][v-w[i]]+c[i])
#采用滚动数组,压缩空间
#物品最大件数
maxn=100
#最大容量
maxv=1000
#物品重量
w=[0 for i in range(maxn)]
#物品价值
c=[0 for i in range(maxn)]
#对于第i件物品,容量为k时的最大价值
dp=[0 for i in range(maxv)]
#物品件数
n=int(input())
#背包容量
,V=int(input())
#输入数据
for i in range(1,n+1):
w[i]=int(input())
for i in range(1,n+1):
c[i]=int(input())
#边界
for v in range(1,V+1):
dp[v]=0
#状态转移
for i in range(1,n+1):
for v in range(V,w[i]-1,-1):
dp[v]=max(dp[v],dp[v-w[i]]+c[i])
print(max(dp))
三 基本算法
3.1 模拟类问题
模拟类的问题是题目给你要求,按照题目要求的步骤进行操作。
主要是理清题目要求之间的变量和这些变量之间的关系,以及如何模拟题目中说的那个操作。
常见的有日期类模拟问题,结合某种场景的模拟问题。
3.2 查找,排序,贪心
查找类的问题一般可以用对于已经排序的元素进行二分查找
排序的问题一般是根据题目要求利用所使用语言的排序方法进行自定义排序
贪心法是一种解决优化问题的启发性方法,它总是选择当期状态下的局部最优,以此来达到全局的最优。但是贪心法的正确性并不是显而易见的,需要一定的证明。
3.3 哈希(hash)思想
哈希思想又称作散列思想,将具有相似属性的元素聚到一起,相当于在解决问题之前对元素进行了一个分类,可以加快一些操作的速度。
3.4 基础数据结构
有一些问题直接就是依赖于某种特定的数据结构,比如树。
其实这样的题目就是在考察基础的数据结构操作,需要对一些数据结构操作比较熟练,比如链表的插入,树的遍历等
3.5 位运算
算术右移等于除以2向下取整,左移是乘以2
b&1运算可以取出b在二进制表示下的最低位,b>>1可以舍去最低位,将二者结合就可以遍历b在二进制下的所有数位
将一个长度为m的bool数组用一个m位二进制整数表示并存储
取出n在二进制表示下的第k位:(n>>k)&1
取出n在二进制表示下的第0-k-1位(后k位):n&((1<<k)-1)
将n在二进制表示下的第k位取反:n^(1<<k)
将n在二进制表示下的第k位赋值为1:n|(1<<k)
将n在二进制表示下的第k位赋值为0:n|(~(1<<k))
当n为偶数时:n^1=n+1
当n为奇数时:n^1=n-1
lowbit(x)定义为非负整数n在二进制表示下最低位的1及其后面所有的0构成的数值。例如当n=10时,其二进制为1010,则lowbit(n)=2= (10){2}(10){2} 其计算公式为:lowbit(n)=n&(-n),lowbit配合Hash可以找出整数二进制表示下所有是1的位
3.6 递推与递归
一个实际问题的各种可能情况构成的集合通常称为状态空间,而程序的运行则是对于状态空间的遍历,算法和数据结构则通过划分,归纳,提取,抽象来帮助提高程序遍历状态空间的效率,递推和递归是遍历状态空间的两种基本方式。
对于一个待求解的问题,当它局限在某个边界,某个小范围或者某种特殊情况下,其答案往往是已知的。如果能够将这个解答的应用场景扩大到原问题的状态空间,且扩展过程的每个步骤具有相似性,就可以考虑使用递推或者递归。
从已知的问题边界向原问题正向推导的扩展方式就是递推。
从原问题寻找把状态空间缩小到已知的问题边界的路线,再通过该路线反向回溯的遍历方式就是递归。
3.7 并查集
并查集是一种可以动态维护若干个互不重叠的集合的数据结构,支持以下两种基本操作
- 查询一个元素属于哪个集合
- 把两个集合合并成一个大集合
在并查集中,采用代表元的方法,为每个集合选择一个固定元素作为这个集合的代表。使用一个树状数组存储每一个集合,为提高查询效率,引入路径压缩和按秩合并的思想。
class UnionFindSet(object):
"""并查集"""
def __init__(self, data_list):
"""初始化两个字典,一个保存节点的父节点,另外一个保存父节点的大小
初始化的时候,将节点的父节点设为自身,size设为1"""
self.father_dict = {}
self.size_dict = {}
for node in data_list:
self.father_dict[node] = node
self.size_dict[node] = 1
def find_head(self, node):
"""使用递归的方式来查找父节点
在查找父节点的时候,顺便把当前节点移动到父节点上面
这个操作算是一个优化
"""
father = self.father_dict[node]
if node != father:
father = self.find_head(father)
self.father_dict[node] = father
return father
def is_same_set(self, node_a, node_b):
"""查看两个节点是不是在一个集合里面"""
return self.find_head(node_a) == self.find_head(node_b)
def union(self, node_a, node_b):
"""将两个集合合并在一起"""
if node_a is None or node_b is None:
return
a_head = self.find_head(node_a)
b_head = self.find_head(node_b)
if a_head != b_head:
a_set_size = self.size_dict[a_head]
b_set_size = self.size_dict[b_head]
if a_set_size >= b_set_size:
self.father_dict[b_head] = a_head
self.size_dict[a_head] = a_set_size + b_set_size
else:
self.father_dict[a_head] = b_head
self.size_dict[b_head] = a_set_size + b_set_size
#测试
if __name__ == '__main__':
a = [1, 2, 3, 4, 5]
union_find_set = UnionFindSet(a)
union_find_set.union(1, 2)
union_find_set.union(3, 5)
union_find_set.union(3, 1)
print(union_find_set.is_same_set(2, 5)) # True
四 一些例题
4.1 位运算
1.求a*b%p,其中a,b,p都很大,只要你用普通的乘法就超时
def mul(a, b, p):
ans = 0
while b:
# 取b二进制的最后一位
# 不为0的时候相加
if b & 1 == 1:
# 计算结果
# 第一个ans是1*a*2^0
ans = (ans + a) % p
# 计算下一个a*2^1
a = (a * 2) % p
# 舍去b二进制的最后一位
b >>= 1
return ans
2.给定一张n个点的带权无向图,点从0-n-1进行编号,求起点0到终点n-1的最短Hamilton路径。
解题思路:使用一个n位二进制数字,若其第i位为1,则表示第i个点已经被访问,否则,未被访问。令F[i,j]表示目前处于点j时的最短路径,i表示当前各个点访问状态的二进制值。则在起点时,F[1,0]=0,最终要求的是F[(1<<n)-1,n-1](其中((1<<n)-1表示一个二进制数字后n位都是1)。在任意时刻,有递推公式F[1,0]=min{F[i(1<<j),k]+weight(k,j)](其中i(1<<j)表示将二级制数i的第j位置1),表示从j基于贪心进行路径试探。
# 状态数组,初始化为一个很大的值
status_list = [[0x3f] * (1 << 20) for i in range(20)]
# 权重
weight_list = [[0] * 20 for i in range(20)]
# 读入权重部分省略
# 对于含有n个节点的带权无向图
# 求从起点0到终点n-1的最短哈密尔顿路径
def hamilton(n):
status_list[1][0] = 0
for i in range(1 << n):
for j in range(n):
for k in range(n):
# 如果第k个节点可达
# 通过节点k进行松弛
if (i ^ (1 << j)) >> k & 1:
status_list[i][j] = min(status_list[i][j], f[i ^ (1 << j)][k] + weight_list[k][j])
return status_list[(1 << n) - 1][n - 1]
3.一个boss的防御战线由n扇防御门组成,其中第i扇防御门的属性包括一个运算op和一个参数t,运算是OR,XOR,AND中的一种,参数是非负整数,若在为通过这扇防御门时攻击力为x,则通过这扇防御门后攻击力变为x op t,最终boss受到的伤害是玩家的初始攻击力x经过所有n扇防御门后得到的攻击力,玩家的初始攻击力只能是[0,m]之间的一个整数,玩家希望通过选择合适的初始攻击力,使得最终的伤害值最大,求这个伤害值。
解题思路:题意是让我们选择[0,m]之间的一个整数x,经过给定的n次位运算,使结果ans最大。位运算的主要特点之一是在二进制表示下不进位,在x可以任意选择的情况下,参与位运算的各个位之间是独立无关的。ans的第k位是几,只与x的第k位是几有关。所以我们可以从高位到低位依次填0,或者1.
x的第k位应该填1,当且仅当:
- 已经填好的更高位构成的数值加上1<<k以后不超过m
- 用每个参数的第k位参与位运算,若初值为1,则n次位运算后结果为1,若初值为0,则n次位运算之后结果为0
如果不满足上述条件,要么填1会超过m的范围,要么填1不如填0更优。
# 操作->参数键值对
op_number_map = [{} for i in range(100001)]
# 读入键值对步骤省略
# 用参数的第bit位进行n次运算
def calculate(bit, now):
"""
:param bit: 参数的第bit位
:param now: 用参数的第bit位运算n次后的结果
:return: 用参数的第bit位运算n次后的结果
"""
for i in range(1, n + 1):
# 取出第bit位
x = (op_number_map[i].values() >> bit) & 1
# 进行相应的位运算
if op_number_map[i].keys() == 'AND':
now &= x
elif op_number_map[i].keys() == 'OR':
now |= x
else:
now ^= x
return now
# 当前选择的攻击力
val = 0
# 结果
ans = 0
# 从最高位到最低位进行填数
for bit in range(29, -1, -1):
# 用第bit位初始值位0进行填数
ans_with_0 = calculate(bit, 0)
ans_with_1 = calculate(bit, 1)
# 如果满足填1的两个条件
if val + (1 << bit) <= m and ans_with_0 < ans_with_1:
val += (1 << bit)
ans += ans_with_1 << bit
else:
ans += ans_with_0 << bit
print(ans)
4.2 递推与递归
1.从1-n个整数中随机选取任意多个,输出所有可能的选择方案(递归实现指数型枚举)
# 所选择的数字
select_number = []
n = int(input())
def process(x):
# x是当前选择的数字
# 最多选择到第n个数字
global n
global select_number
if x == n + 1:
# 输出选择的数字
for i in range(len(select_number)):
print(select_number[i], end=' ')
print()
return
# 不选择x这个数字
process(x + 1)
# 选择x这个数字
select_number.append(x)
# 求解在选择x条件下的子问题
process(x + 1)
# 回溯,清理状态
select_number = select_number[:-1]
process(1)
2.从1-n这n个数字中随机选出m个,输出所有可能的选择方案(递归实现组合型枚举)
# 所选择的数字
select_number = []
n = int(input())
def process(x):
# 如果当前已经选择了m个数字,或者无法从剩下的数字中一共选择出m个数字
if len(select_number) > m or len(select_number) + (n - x + 1) < m:
return
# x是当前选择的数字,也是当前共选择的数字数量
# 最多选择到第n个数字
global n
global select_number
if x == n + 1:
# 输出选择的数字
for i in range(len(select_number)):
print(select_number[i], end=' ')
print()
return
# 不选择x这个数字
process(x + 1)
# 选择x这个数字
select_number.append(x)
# 求解在选择x条件下的子问题
process(x + 1)
# 回溯,清理状态
select_number = select_number[:-1]
process(1)
3.求1-n的全排列所有可能的次序(递归实现排列型枚举)
# 选择的数字
select_number = [0 for i in range(20)]
# 状态标记
status = [False for i in range(20)]
def caculate(k):
# k代表当前已经选择的数字数量
if k == n + 1:
for i in range(1, n + 1):
print(select_number[i], end=' ')
print()
return
for i in range(1, n + 1):
if status[i]:
# 第i个数字已经选择
continue
# 标记
select_number.insert(k, i)
status[i] = True
caculate(k + 1)
# 回溯,清楚标记
status[i] = False
select_number[k] = 0
caculate(1)
4.3 前缀和、差分
1.给定一个长度为n的数列,每次可以选择一个区间[l,r],使下标在这个区间内的数都加1或者减1,求至少需要多少次操作才能使数列中的所有数字一样,并求出最终得到的数列的数量。
解题思路:求出a的差分序列b,其中b1=a1,bi=ai-ai-1,题目对序列a的操作可以转化为对差分序列的操作,相当于每次选出差分序列中的任意两个数字,一个加1,一个减1,(分解为基础操作)目标是把b2,b3…bn变为全0,从b1,b2,…bn+1中选两个数有四种方案:
选bi,bj,2<=i,j<=n;这种操作会改变b2,b3,...bn中两个数的值,应该在保证bi,bj一正一负的前提下,尽可能采取这种操作(贪心策略)
选b1,bj,2<=j<=n;
选bi,bn+1,2<=i<=n;
选b1,bn+1(没有意义)
综上,如果假设b2,b3,…bn中正数的数量是p,负数的数量是q,首先尽可能多的执行第一种操作,可执行min(p,q)次。
剩余|p-q|个未配对,每个可以与b1,bn+1配对,执行第2,3种操作,共需|p-q|次
则最小操作次数为min(p,q)+|p-q|次。
只有第2,3种操作会产生不同的b1值,即产生不同的序列a,那么不同的序列a的个数为|p-q|+1
2.有N头牛站成一行,两头牛能够相互看见,当且仅当它们中间的牛的身高都比它们矮。现在,我们只知道其中最高的牛是第p头,它的身高是H,不知道剩余N-1头牛的身高。但是,我们还知道M对关系,每对关系指明了某两头牛A_{i}A_{i}和B_{i}B_{i}可以相互看见,求每头牛的身高最大可能是多少。
解题思路:M对关系指明了牛身高之间的相对大小关系,建立一个数组C,数组初始化为0,若一条关系指明A_{i}A_{i}和B_{i}B_{i}可以相互看见,那么 A_{i}A_{i} +1~ B_{i}B_{i} -1的牛一定都比它们矮,我们把数组中这些位置的值减1,表示它们的身高至少比A_{i}A_{i},B_{i}B_{i}小1.
因为第P头牛身高最高,所以最终C[p]一定为0,那么最后第i头牛的身高就为H+C[i].
利用前缀和“把对一个区间的操作转化为对区间左右两个端点的操作”这种思想,对A_{i}A_{i} +1~ B_{i}B_{i} -1值都减1这个操作进行优化。
建立一个数组D,对于每对A_{i}A_{i}和B_{i}B_{i},令D[A_{i}A_{i}+1]-1,D[B_{i}B_{i}]+1,表示“身高减小1的影响,从[A_{i}A_{i}+1开始,持续到B_{i}B_{i}-1”,那么 C[i]=\Sigma_{j=1}{i}D[j]C[i]=\Sigma_{j=1}{i}D[j]
4.4 二分法
1.有N本书排成一行,已知第i本的厚度是 A_{i}A_{i} ,把它们分成连续的M组,使T最小化,其中T表示厚度之和最大的一组的厚度。
解题思路:利用二分法转化为判定问题。如果我们以把书划分为M组的方案作为定义域,厚度之和最大的一组的厚度作为评分,需要最小化这个厚度值,也就是评分越小越优。那么,假设最终答案为S,因为S的最优性,如果要求每组的厚度都<S,那么这M组一定不能容纳这些书,需要更多的组才能把书分完。如果每组的厚度可以>S,那么一定存在一种分书方案可以使得组数不会超过M。
# 每本书的厚度
a = [0 for i in range(10000)]
# 书的数量
n = int(input())
# 把n本书分成m组,每组厚度之和<=size,是否可行
def judge(size):
# 分成的组数
group = 1
# 分组时每组的厚度
rest = size
# 模拟分组
for i in range(1, n + 1):
# 如果可以分到同一组
if a[i] <= rest:
rest -= a[i]
# 以不同的厚度分下一组
else:
group += 1
rest = size - a[i]
return group <= m
# 二分法进行判定
left = 0
# 总厚度
right = sum(a)
while left < right:
mid = (left + right) / 2
if judge(mid):
right = mid
else:
left = mid + 1
print(left)
2.给定正整数数列A,求一个平均数最大的,长度不小于L的连续的子段
解题思路:利用二分法转化为判定问题。判定是否存在一个长度不小于L的子段,平均数不小于二分的值。
如果把数列中每个数都减去二分的值,问题就转化为判定‘是否存在一个长度不小于L的子段,子段和非负’
那么,我们需要解决以下两个问题:
求一个子段,它的和最大,没有长度不小于L这个限制
求一个子段,它的和最大,子段的长度不小于L
解决了问题2之后,我们只需要看一下最大子段和是不是非负数,就可以确定二分法的判定上下界了。
# 数列
a = [0 for i in range(100001)]
b = [0 for i in range(100001)]
sum = [0 for i in range(100001)]
# 序列数字数量
N = int(input())
# 子段长度
L = int(input())
# 输入序列
for i in range(1, N + 1):
a[i] = int(input())
# 浮点比较值
eps = 1e-5
# 二分左右端点
left = -1e6
right = 1e6
# 二分判定
while right - left > eps:
mid = (left + right) / 2
# 减去
for i in range(1, N + 1):
b[i] = a[i] - mid
# 前缀和,求第一个问题
for i in range(1, N + 1):
sum[i] = (sum[i - 1] + b[i])
ans = -1e10
min_val = 1e10
# 求第二个问题
for i in range(L, N + 1):
min_val = min(min_val, sum[i - L])
ans = max(ans, sum[i] - min_val)
# 确定上下界,进行二分判定
if ans >= 0:
left = mid
else:
right = mid
print(int(right * 1000))
4.5 排序
1.对于一个序列a,若i<j且a[i]>a[j]则称a[i]与a[j]构成逆序对,求一个序列a内的逆序对数。
解题思路:使用归并排序的想法可以求出一个长度为n的序列中的逆序对数
def merge(left,mid,right):
#合并a[left-mid],a[mid+1-right]
#a是待排序数组,b是临时数组,cnt是逆序对的个数
i=left
j=mid+1
for k in range(left,right+1):
if j>right or i<=mid &&a[i]<=a[j]:
b[k]=a[i++]
else:
b[k]=a[j++]
cnt+=mid-i+1
for k in range(left,right+1):
a[k]=b[k]
4.6 倍增
1.给定一个整数M,对于任意一个整数集合S,定义校验值如下:从集合S中取出M对数字,使得每对数字的差的平方之和最大,这个最大值就是集合S的校验值。现在给定一个长度为N的数列A以及一个整数T。我们要把A分成若干段,使每一段的校验值都不超过T。求最少需要分成几段?
解题思路:对于一个集合S,显然应该取S中最大的和最小的进行配对,次大和次小的进行配对…这样求出的校验值最大。而为了让数列的段数最少,每一段都应该在校验值不超过T的前提下,尽量包含更多的数字。采用倍增的思想加速查找过程:
初始化p=1,R=L
求出[L,R+p]这一段区间的校验值,若校验值<=T则R+=p,p*=2,否则p/=2
重复上一步,直到p的值变为0,此时R就为所求
4.8 质因数分解
1.算术基本定理(质因数分解定理):任何一个大于1的正整数都能唯一分解为有限个质数的乘积。
# 质因数分解列表
# 其中若i为质数且N分解时包含i
# 则p[i]代表N分解时包含多少个i
p = [0 for i in range(100000)]
def primeFactorization(N):
# 将N进行质因数分解
i = 1
while i <= (N // i):
# 这里用了素数筛法的思想
# 若当前i是质数
# 则i的倍数一定不是质数
# 这些倍数已经被记录在p[i]分解信息中
i += 1
# 求用了多少个这个质数
while N % i == 0:
p[i] += 1
N //= i
# 最后分解完剩下一个质数
if N > 1:
p[N] += 1