Day1(基础算法)
讲师:余快
枚举法
例题1
给定一个数 \(x\),判断 \(x\) 是不是质数。
朴素算法:枚举 \([2,x-1]\) 之间所有的整数 \(i\),逐个判断 \(x\) 是否被 \(i\) 整除,若都不能整除则 \(x\) 是质数,时间复杂度 \(O(x)\),搞个 \(10^9\) 直接卡过。该怎么优化呢?
优化枚举范围:只需枚举到 \(\sqrt{x}\) 即可
例题2
P1149 [NOIP2008提高组]火柴棒等式
简要思路
for
循环 + check
判断。
首先枚举计算所有的数所需要的火柴数量(设立数组 \(g\),\(g_i\) 代表 i 这个数所需要的火柴数)。
再次,枚举所有在等式中可能出现的数(即 \([0,2000]\)),设立数组 \(a\) 来表示。
注意到火柴数最多为 \(24\) 根,去掉加号和等号的 \(4\) 根火柴,所以我们最多只能使用 \(20\) 根火柴。因此,我们不难推出:最大的等式大约是 \(1111 + 1111 = 2222\),所以我们只需要枚举每一个加数,其范围为 \([0,1111]\),再利用两个加数推出和,最后 check
判断一下火柴数是否符合条件即可。
代码:
#include<bits/stdc++.h>
#define int long long
using namespace std;
int n;
int g[]={6,2,5,5,4,5,6,3,7,6};//每个数所需要的火柴的根数
int a[2223]={6};//先把 0 需要的火柴数处理好
bool check(int addend1,int addend2,int sum){
if(a[addend1]+a[addend2]+a[sum]+2+2==n)return true;//别忘了加上加号和等号的火柴数
else return false;
}
signed main(){
cin>>n;
for(int i=1;i<=2222;i++){//提前处理好每一个可能的加数
int t=i;
while(t){//求每个数所用的火柴棒
a[i]+=g[t%10];
t/=10;
}
}
int ans=0;
for(int i=0;i<=1111;i++)
for(int j=0;j<=1111;j++)//枚举两个加数
if(check(i,j,i+j))ans++;
cout<<ans;
return 0;
}
例题3
P1115 最大子段和
暴力枚举(\(0\text {pts}\)):
枚举区间的左端点,右端点,然后从左端点开始到右端点逐个加起来。
时间复杂度 \(O(n^3)\)
前缀和优化的朴素算法(\(40\text {pts}\)):
先处理 \(s_i=a_1+a_2+ \dots +a_i\)
枚举区间的左端点 \(i\),右端点 \(j\),这段区间的和就是\(s_j ? s_{i?1}\)
时间复杂度 \(O(n^2)\)
优化枚举范围(\(100\text {pts}\)):
如果我们固定右端点 \(i\) ,那么我们选择的左端点 \(j\) 一定满足 \(s_{j ?1}\) 最小,这样的 \(s_i?s_{j?1}\) 是最大的。
于是我们从左到右枚举右端点 \(i\),维护 \([1,i]\) 里满足 \(s_{j?1}\) 最小的 \(j\),然后直接选择 \(j\) 作为左端点。
时间复杂度 \(O(n)\) 。
悄悄说一下,其实这道题正解是 dp。
代码:
#include<bits/stdc++.h>
#define int long long
using namespace std;
int f[200005];
int a[200005];
signed main(){
int n;
cin>>n;
for(int i=1;i<=n;i++)cin>>a[i];
f[1]=a[1];
int ans=-LONG_MAX;
for(int i=1;i<=n;i++){
f[i]=a[i];
f[i]=max(f[i],a[i]+f[i-1]);
ans=max(f[i],ans);
}
cout<<ans;
return 0;
}
例题4
P1638 逛画展
区间伸缩/尺取法:枚举区间左端点,不断往后选取数字直到出现所有 m 个画家为止。
左端点右移过程中,合法区间的右端点也一定在右移,所以右端点可以在上一次结果的基础上拓展,而不是重新由新的左端点开始枚举。
代码:太难了,不想写
模拟算法
模拟就是用计算机来模拟题目中要求的操作。
模拟题目通常具有码量大、操作多、思路繁复的特点。由于它码量大,经常会出现难以查错的情况,如果在考试中写错是相当浪费时间的。
例题1
P1328 [NOIP2014 提高组] 生活大爆炸版石头剪刀布
直接按题意模拟即可
#include<bits/stdc++.h>
using namespace std;
int na[114514],nb[114514];
signed main(){
int x,a,b;
cin>>x>>a>>b;
int f=0,j=0,ans=0,bns=0;
for(int i=1;i<=a;i++)cin>>na[i];
for(int i=1;i<=b;i++)cin>>nb[i];
for(int i=1;i<=x;i++){
f++;j++;
if(f>a)f=1;
if(j>b)j=1;
if(na[f]==0&&nb[j]==1)bns++;
if(na[f]==0&&nb[j]==2)ans++;
if(na[f]==0&&nb[j]==3)ans++;
if(na[f]==0&&nb[j]==4)bns++;
if(na[f]==1&&nb[j]==0)ans++;
if(na[f]==1&&nb[j]==2)bns++;
if(na[f]==1&&nb[j]==3)ans++;
if(na[f]==1&&nb[j]==4)bns++;
if(na[f]==2&&nb[j]==0)bns++;
if(na[f]==2&&nb[j]==1)ans++;
if(na[f]==2&&nb[j]==3)bns++;
if(na[f]==2&&nb[j]==4)ans++;
if(na[f]==3&&nb[j]==0)bns++;
if(na[f]==3&&nb[j]==1)bns++;
if(na[f]==3&&nb[j]==2)ans++;
if(na[f]==3&&nb[j]==4)ans++;
if(na[f]==4&&nb[j]==0)ans++;
if(na[f]==4&&nb[j]==1)ans++;
if(na[f]==4&&nb[j]==2)bns++;
if(na[f]==4&&nb[j]==3)bns++;
}
cout<<ans<<' '<<bns;
return 0;
}
例题2
P1563 [NOIP2016 提高组] 玩具谜题
中模拟,样例字符串到后面原形毕露,直接不装了/cf
#include<bits/stdc++.h>
using namespace std;
int n,m;
int x,y;
struct node{
int a;
string name;
}jjj[1000005];
signed main(){
cin>>n>>m;
for(int i=0;i<n;i++){
cin>>jjj[i].a>>jjj[i].name;
}
int jd=0;
for(int i=1;i<=m;i++){
cin>>x>>y;
if(jjj[jd].a==0&&x==0)jd=(jd+n-y)%n;
else if(jjj[jd].a==0&&x==1)jd=(jd+y)%n;
else if(jjj[jd].a==1&&x==0)jd=(jd+y)%n;
else if(jjj[jd].a==1&&x==1)jd=(jd+n-y)%n;
}
cout<<jjj[jd].name<<endl;
return 0;
}
技巧
写模拟题时,遵循以下的建议有可能会提升做题速度:
在动手写代码之前,在草纸上尽可能地写好要实现的流程。
在代码中,尽量把每个部分模块化,写成函数、结构体或类。
对于一些可能重复用到的概念,可以统一转化,方便处理。
调试时分块调试。模块化的好处就是可以方便的单独调某一部分。
写代码的时候一定要思路清晰,不要想到什么写什么,要按照落在纸上的步骤写。
实际上,上述步骤在解决其它类型的题目时也是很有帮助的。
习题
P1042 [NOIP2003 普及组] 乒乓球
P2670 [NOIP2015 普及组] 扫雷游戏
P3952 [NOIP2017 提高组] 时间复杂度
二分法
定义
二分的定义:在一个单调的有限数列上快速查找某一特定值的方法。
例题1
给你一个单调递增的数组,给你一个数x,问 $ > x, \ge x$的第一个数分别是什么?
可以用 STL 中的 lower_bound
和 upper_bound
轻松解决,不过在这里不讲。
事实上有不同二分的实现方法,刚刚接触二分的OIer经常在这个地方写错,进入死循环
//找第一个>q
int l=1,r=n;
while(l<r){
int mid=(l+r)>>1;
if (a[mid]<=q)l=mid+1;
else r=mid;
}
std::cout<<r;
//找第一个>=q
int l=1,r=n;
while(l<r){
int mid=(l+r)>>1;
if (a[mid]>=q)r=mid;
else l=mid+1;
}
std::cout<<r;
其实我们有最保守的写法
就是 \(l=mid+1;r=mid-1\)
而答案直接考虑 \(mid\)。
例题2
求一个正整数X开三次根后的结果,保留六位小数。
因为我们这里要二分实数,所以 while
循环的终止条件是:r-l <= eps
,其中 \(eps = 10^{-6}\) 。
解法1:
直接输出 \(x^{\frac{1}{3}}\) 可以用 pow
来实现。
解法2
假设我们不知道 pow 函数,只知道二分和 sqrt 函数,可以朴素的二分查找。
代码:调挂了,有空再写。
分治
快速幂
CSP-S2023第一轮 著名题目:
现在用如下程序来计算x^n,其时间复杂度为( )。
Quick_power(x,k):
if k==1 return x;
else return Quick_power(x,k/2)*Quick_power(x,k/2)*(k&1)?x:1;
A.O(n)
B.O(1)
C.O(log n)
D.O(n log n)
答案选A
详细讲解见 Day6 数论一节
归并排序
基础分治题目,不多赘述。
逆序对
在算法领域中可以用归并排序实现,还可以用数据结构中的树状数组。
对拍
:loop
make
100fen!
baoli
fc baoli.out a.out
if errorlevel==1 pause
goto loop
make为造数据的可执行文件
100fen! 为正解的可执行文件
baoli 为自己代码的可执行文件
fc用来比较他们的输出文件,如果不同则停止。
贪心
例题1
P4823 [TJOI2013] 拯救小矮人
我们把每个小矮人按照腿长与臂长的和来排序,然后用 dp (01背包)来选择小矮人。
边界 \(f_0 = \sum _{i = 1} ^ n a_i\)
目标 \(f_i \ge 0\) 成立时,\(i\) 的最大值。
转移方程:\(f_j \rightarrow \max(f_j,f_{j - 1} - a_i) [f_{j - 1} + b_i \ge h]\)。
Day2上午 搜索
DFS深度优先搜索
例题1
T1 数
#include<bits/stdc++.h>
#define int long long
int ans = 0;
int a[105];
bool vis[105];
int n, m;
int dfs(int now) {
if(now == n + 1){
int sum = 1;
for(int i = 1; i <= n; i++)
if(!vis[i])sum *= a[i];
return sum >= m;
}
vis[now] = true;
ans += dfs(now + 1);
vis[now] = false;
ans += dfs(now + 1);
return 0;
}
signed main(){
std::cin >> n >> m;
for (int i = 1; i <= n; i++) std::cin >> a[i];
dfs(1);
std::cout << ans;
return 0;
}
非常基础的爆搜,本人因开返回值为 int
的函数但忘记返回(本机测试不出来)而爆零,警钟长鸣。
或者用一个指数级别的 for 循环来解决,借助位运算状压,即可通过。
#include <iostream>
#define int long long
int a[15];
int ans = 0;
signed main() {
int n, m;;
std::cin >> n >>m;
for (int i = 1; i <= n; i++) std::cin >> a[i];
for (int i = 0; i < (1 << n); i++) {
int mul = 1;
for (int j = 1; j <= n; j++) {
if(i & (1 << (j - 1))) mul *= a[j];
}
if (mul >= m) ans++;
}
std::cout << ans;
return 0;
}
例题2
P1216 [IOI1994]数字三角形
算法1 爆搜(55分)
#include<bits/stdc++.h>
int ans = -1;
int n;
int a[1005][1005];
void dfs(int sum, int x, int y) {
if (x == n + 1) {
ans = std::max(ans, sum);
return;
}
dfs(sum + a[x][y], x + 1, y);
dfs(sum + a[x][y], x + 1, y + 1);
}
signed main(){
std::cin >> n;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
std::cin >> a[i][j];
dfs(0, 1, 1); //O(2^n)
std::cout << ans;
return 0;
}
算法2 记忆化搜索
#include<bits/stdc++.h>
int n;
int a[1005][1005];
bool vis[1005][1005];
int f[1005][1005];
int dfs(int x, int y) {
//返回值表示从 x, y 走到下面能获得的最大的和
//发现对于特定的 x, y,dfs(x, y) 的结果是一定的
//但是搜索过程中我们可能多次运用 dfs(x, y)
if (vis[x][y]) return f[x][y];
vis[x][y] = true; //表示我们算过 dfs(x, y)
if (x == n + 1) {
return f[x][y] = 0;
}
f[x][y] = a[x][y] + std::max(dfs(x + 1, y), dfs(x + 1, y + 1));
return f[x][y];
//复杂度为状态的个数,就是 x, y 的个数
//状态数 -> 状态转移个数 n^2
}
signed main(){
std::cin >> n;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
std::cin >> a[i][j];
//sum = a[1][1]
std::cout << dfs(1, 1); //O(n^2)
return 0;
}
例题3
P1074 [NOIP2009 提高组] 靶形数独
例题4
P1120 小木棍
爆搜即可通过,但要考虑的剪枝很多,时间复杂度很玄学。
剪枝策略:
-
相同的只搜一遍;
-
如果现在已经拼成的长度,在后面是不可能成功的。那么我们就认定这个分支是失败的。所以如果之后的分支再次遇见像这样的长度。我们就直接把他给返回就可以了。
-
如果刚开始拼接木棒的时候,第一根已经导致了拼接失败。那我们就可以判定当前的大分支是不可行的。至于为什么,是因为他在第一次已经不行。那么它在其他木棒中也是不可以的。
例题5
POJ 1753
广告:ExOJ POJ题库
直接模拟翻转的过程,注意翻转时的边界条件。搜索时搜两次,同样判断边界,分别为操作以及不操作的数量,而且要注意位置的更新。当棋盘中所有位置的颜色都一样(用一个函数判断)时,维护最小的答案。如果全部搜完了答案还是原来的数值,输出 Impossible
。
#include <iostream>
char ch[5][5];
int ans = 0x7fffffff;
bool check() { //判断所有格子颜色是否都相同
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
if(ch[i][j] != ch[0][0])
return false;
}
}
return true;
}
bool pdin(int x, int y) { //判断是否在棋盘内(没有越界)
return x < 4 && y < 4 && x >= 0 && y >= 0;
}
void change(int x, int y) { //单个位置修改
ch[x][y] = (ch[x][y] == 'w' ? 'b' : 'w');
}
void update(int x, int y) { //四周都要修改
change(x, y);
//修改时要判断边界
if (pdin(x + 1, y)) change(x + 1, y);
if (pdin(x, y + 1)) change(x, y + 1);
if (pdin(x - 1, y)) change(x - 1, y);
if (pdin(x, y - 1)) change(x, y - 1);
}
void dfs(int x, int y, int cnt){
if(check()) { //满足条件
ans = std::min(cnt, ans);
return;
}
if(!pdin(x, y)) return; //如果不在棋盘内,返回
int yy = (y + 1) % 4; //得到下一个位置的坐标(y为0 1 2 3)
int xx = x + (y + 1) / 4; //x 的值,取决于y + 1是否等于4,等于则x + 1
dfs(xx, yy, cnt);
update(x, y);
dfs(xx, yy, cnt + 1);
update(x, y);
//搜两种情况。注意最后要还原
}
signed main() {
for (int i = 0; i < 4; i++)
for (int j = 0; j < 4; j++)
std::cin >> ch[i][j];
dfs(0, 0, 0);
if (ans == 0x7fffffff) std::cout << "Impossible"; //没有情况
else std::cout << ans;
return 0;
}
例题6
P2668 [NOIP2015 提高组] 斗地主
搜索 + 大模拟,太麻烦不说了。
例题7
P2831 [NOIP2016 提高组] 愤怒的小鸟
dp或搜索,前置知识为二次函数和二元一次方程组。
折半搜索法
如果问题能分成两个相对独立的部分,且两部分搜索结果能很方便合并,那么就可以利用折半搜索来大大提高搜索的效率。
我们先搜前半部分,然后将前半部分的搜索结果存入 hash 表,再去搜后半部分,再把两次搜索结果合起来。
BFS宽度优先搜索
BFS可以直接用来求每条边边权为 \(1\) 的最短路问题,用队列来储存状态。
用来求每条边边权为 \(0\) 或 \(1\) 的宽搜算法 —— 01bfs
注意要用双端队列 deque
来存,在队首插入 \(0\) 边扩展的,在队尾插入 \(1\) 边扩展的。
例题1
P1144 最短路计数
思路:宽度优先搜索
作为BFS例题,有必要贴一下代码
#include<bits/stdc++.h>
using namespace std;
const int MAXN=1e6+10;
vector<int>z[MAXN];
int dep[MAXN],cnt[MAXN];
bool vis[MAXN];
void add_edge(int s,int e){
z[s].push_back(e);
}
signed main(){
int n,m;
cin>>n>>m;
for(int i=1;i<=m;i++){
int u,v;
cin>>u>>v;
add_edge(u,v);
add_edge(v,u);
}
queue<int>q;
q.push(1);
dep[1]=0;
vis[1]=true;
cnt[1]=1;
while(q.size()){
int p=q.front();
q.pop();
for(auto v:z[p]){
if(!vis[v]){
vis[v]=true;
q.push(v);
dep[v]=dep[p]+1;
}
if(dep[v]==dep[p]+1)
cnt[v]=(cnt[v]+cnt[p])%100003;
}
}
for(int i=1;i<=n;i++)
cout<<cnt[i]<<'\n';
return 0;
}
Day2下午 数据结构
二叉搜索树
二叉搜索树的性质
二叉搜索树具有以下性质:
- 这是一颗二叉树
- 每个节点的左儿子比自己小
- 每个节点的右儿子比自己大
如图,这两个都是二叉搜索树。可能会是一条链,所以容易被卡掉(极端情况下可能和用 for 循环没啥区别)。
节点建立
const int MAXN = 100005;
const int L = 0, R = 1; //定义常量,方便代码编写
struct node{ //一个树上节点
int son[2]; //记录左右儿子的下标,分别对应 son[L], son[R]
int val; //这个节点储存的值
}a[MAXN];
int cnt; //决定分配节点编号
二叉搜索树该如何建立呢?
- 将节点从根节点开始插入
- 与当前节点比较大小
- 如果比当前节点储存的值大,向右递归
- 如果比当前节点储存的值小,向左递归
关于二叉搜索树
二叉搜索树有什么用呢?
没用
那为什么要学习二叉搜索树呢?
非常简单的二叉树结构,方便大家熟悉二叉树如何编写,以及二叉树的构成。
——吴凯路
模板
P5076 【深基16.例7】普通二叉树
二叉堆
二叉堆的定义
-
二叉堆是一种特殊的二叉树
-
满足根节点比任意节点 大 / 小 (决定了是大根堆还是小根堆)
二叉堆的作用
一般二叉堆能解决什么问题呢?
在 \(O(log n)\) 的时间内插入一个元素
在 \(O(log n)\) 的时间内删除一个元素
在 \(O(1)\) 的时间内查询最大值
节点建立
需要储存左儿子,右儿子,自己的权值,子树大小
const int MAXN = 100005;
const int L = 0, R = 1; //定义常量,方便代码编写
struct node{ //一个树上节点
int son[2]; //记录左右儿子的下标,分别对应 son[L], son[R]
int val; //这个节点储存的值
int size; //这个节点对应的子树大小
}a[MAXN];
int cnt; //决定分配节点编号
插入操作
以大根堆为例
- 从根节点开始,插入一个值,如果当前值比根节点大,与根节点交换存储的值
- 然后往子树大小更小的儿子递归
- 直到某个儿子大小为0(即没有某一边儿子),新建一个节点来储存这个值
- 这样我们可以保证插入操作一定不超过log n次递归。
删除操作
以大根堆为例
- 将当前节点权值视为0,与最大的儿子交换权值并递归
- 直到节点是一个叶子(无左右儿子),然后删除该叶子。
- 这样我们可以保证删除操作一定不超过log n次递归。
- 删除根节点即可弹出最大值。
查询最大值操作
以大根堆为例
取根节点的权值即可。
STL中的堆——priority_queue
支持一切常用操作。
薄纱手写堆了属于是
模板
洛谷 P3378 模板 堆
习题
灵活运用:
洛谷 P1090
洛谷 P1631
洛谷 P2085
线段树
信息学竞赛中最最最重要的数据结构,最最最常见的数据结构 ——吴凯路
线段树的作用
线段树解决什么问题呢?
各种各样的序列操作问题,例如最常见的问题:
有一个长度为 \(N\) 的序列(可能有初始值)
然后有 \(Q\) 次操作,每次操作可能是以下两种之一:
修改一个位置的值
查询一个区间的权值和
\(1 \le N,Q \le 10^5\)
线段树的定义
线段树是一种二叉树结构
线段树上每个节点对应一个区间 \([L,R]\)
节点的左儿子对应 \([L,Mid]\),右儿子对应 \([Mid+1,R]\)。
运用
如何构建线段树?
首先令根节点的范围为 \([1,N]\),然后递归建立左右子节点。
一共需要 \(4N\) 个节点来建立线段树。
不过有特殊写法,动态开点只需要开二倍空间即可。
接下来写的是吴凯路老师讲的线段树,个人感觉很抽象()
单点修改
从根开始向下递归,用二分的思路找这个点,然后找到包含这个点的所有区间,修改对应区间的统计值。
区间查询
- 查询的时候将区间作为参数传入
- 如果查询区间是当前区间的,那么返回当前节点的统计值
- 如果查询区间是当前区间的一个子区间,根据将查询区间按情况递归进入左儿子,或者右儿子或者两边。
- 这样可以把一个查询区间拆成已有的不超过 \(2 \times \log_2 n\) 个区间。
大家一定这么感觉:太简单了,只要学过分治不就能写出来吗?
难度提升警告
有一个长度为 \(N\) 的序列(可能有初始值)
然后有 \(Q\) 次操作,每次操作可能是以下两种之一:
给某个区间的所有位置加上一个权值 \(c\)。
查询某个区间的权值和
\(1 \ge N,Q \ge 10^5\)
区间修改 & lazy标记
有这样一个问题,有一个长度为10的区间,和为205,现在对于区间中每一个位置增加了7,那么和为多少?
可以计算和为 \(205 + 7 \times 10 = 275\)
我们知道每个位置的具体值吗? 并不知道
与每个位置的具体值有关吗? 无关
所以我们使用类似于区间查询的方法,将每个区间修改操作,拆成 \(\log_2{n}\)个已有的节点对应的区间。
然后单个节点可以自己更新自己的统计值(比如区间和),不需要下发这个操作到所有子节点。
但是我们需要记录当前有多少尚未下发的区间加操作。(即所有未下发的区间加操作的和)因为如果查询的不是整个区间,而是某个更小的子区间,那么会对答案造成影响。
所以我们有了下传操作(pushdown)。
下传标记即为将未下发的操作合并后变成一个操作一起下发给左右儿子。(注意只下发给左右子儿子就够了)
然后清空当前节点暂存的操作。(因为已经下传走了)
例题1
P3372 【模板】线段树 1
#include<bits/stdc++.h>
using namespace std;
#define N 100005
#define LL long long
const int L=0,R=1;
LL v[N];
struct xds{
int son[2];
LL sum;//区间和, 随时随地的区间总和,已考虑了addtag
LL addtag;//区间加标记 现在每个人加了addtag分
}a[N*2];int cnt;
void build(int &k,int l,int r){//建立线段树
k=++cnt;
if(l==r){
a[k].sum=v[l];//初始化信息
}else{
int mid=(l+r)>>1;
build(a[k].son[L],l,mid);//递归建立左儿子
build(a[k].son[R],mid+1,r);//递归建立右儿子
a[k].sum=a[a[k].son[L]].sum+a[a[k].son[R]].sum;//合并左右儿子信息
}
}
// 给节点k,增加一个标记(告诉他所有人加分)
// 给节点k说,他管理的范围是[l,r],然后所有人增加 val 分
void addtag(int k,int l,int r,LL val){//节点k上做区间加
a[k].sum+=(r-l+1)*val; // 显然管理区间的总分增加了 人数 * val
a[k].addtag+=val; // 记录一下当前的tag
}
void pushdown(int k, int l, int r){
int mid = (l+r) >> 1;
addtag(a[k].son[L],l,mid,a[k].addtag);
addtag(a[k].son[R],mid+1,r,a[k].addtag);
a[k].addtag=0;
}
// 节点k,管理[l,r],对于区间[ql, qr] 集体增加 val
void modify(int k,int l,int r,int ql,int qr,LL val){//区间修改操作,比如区间加
if(ql==l&&r==qr){//全区间操作
addtag(k,l,r,val);
}else{
int mid=(l+r)>>1;
//下传并清空节点k的所有标记
pushdown(k, l, r);
if(qr<=mid) modify(a[k].son[L],l,mid,ql,qr,val);
else if(ql>mid) modify(a[k].son[R],mid+1,r,ql,qr,val);
else modify(a[k].son[L],l,mid,ql,mid,val),modify(a[k].son[R],mid+1,r,mid+1,qr,val);
a[k].sum=a[a[k].son[L]].sum+a[a[k].son[R]].sum;//下传后更新
}
}
LL query(int k,int l,int r,int ql,int qr){//区间查询操作,比如问区间和
if(ql==l&&r==qr){//全区间查询
return a[k].sum;
}else{
int mid=(l+r)>>1;
//下传并清空节点k的所有标记
pushdown(k, l, r);
LL ret = 0;
if(qr<=mid) ret=query(a[k].son[L],l,mid,ql,qr);
else if(ql>mid) ret=query(a[k].son[R],mid+1,r,ql,qr);
else ret=query(a[k].son[L],l,mid,ql,mid)+query(a[k].son[R],mid+1,r,mid+1,qr);
return ret;
}
}
int n, m, root;
int main(){
scanf("%d%d", &n, &m);
for(int i=1;i<=n;i++)
scanf("%lld", &v[i]);
build(root, 1, n);
for(int i=1;i<=m;i++){
int op, x, y;
LL k;
scanf("%d%d%d", &op, &x, &y);
if(op == 1){
scanf("%lld", &k);
modify(root, 1, n, x, y, k);
}else{
printf("%lld\n", query(root, 1, n, x, y));
}
}
return 0;
}
吴凯路老师的存树方式是动态开点,并且没有存 \(l,r\) 所以写法有一些奇特。我来贴一份详细注释、好理解的代码 。
线段树1——Lht(Pascal and C++)
例题2
P3373 【模板】线段树 2
在结构体中加一个 mul,即为乘法的lazy标记,和加法的lazy标记一样,用来更新区间答案以及打上/下传标记。
注意,在进行下传操作时,要先下传乘法的标记,再下传加法的标记,因为这样可以更方便地更新/获取答案
树状数组
树状数组与线段树
线段树是一种解决区间修改与区间查询的数据结构
树状数组是一种简单高效的解决单点修改与前缀查询的数据结构
也就是说,树状数组能实现的功能,线段树都可以
但是线段树通常比树状数组慢一倍时间,代码长三倍。
其实,树状数组可以看成线段树的 “青春版”。
树状数组的优劣势
-
优势:代码量短,速度较快。
-
劣势:树状数组只能支持单点修改和前缀查询,并不能支持区间修改和区间查询
树状数组的实现
\(lowbit_x\) 表示 \(x\) 最大的为 \(2\) 的整次幂的因数。
例如 \(lowbit_3=2^0=1, lowbit_{12}=2^2=4, lowbit_8=2^3=8\)
树状数组的节点i,管理的区间是 \([i-lowbit_i+1,i]\),也就是长度为 \(lowbit_i\),结尾为 \(i\) 的区间。
\(lowbit\) 函数如何在代码中实现呢?
从二进制角度考虑:
树状数组可以把任意前缀区间拆分成log n个已有区间。
所以树状数组可以支持一些单点修改,前缀和查询。
例如修改单点位置的值,求某一个前缀和。
代码:
const int N = 1000006;
#define low(x) ((x)&(-(x)))
int bits[N]; // 装着每个树状数组节点管理区间的区间和
void modify(int x,int val){//a[x]+=val
for(;x<N;x+=low(x)) bits[x]+=val; // 区间和增加 val
}
int query(int x){// 查询 [1,x] 的和
int ret=0;
for(;x!=0;x-=low(x)) ret+=bits[x];
return ret;
}
// [L, R] ?
// [1, R] - [1, L-1]
query(R) - query(L-1)
for(int i=1;i<=n;i++) modify(i, a[i]);// 把初始化变成n次单点修改
例题1
【模板】树状数组 1 - 洛谷
树状数组模板题。
简洁的代码:
#include<bits/stdc++.h>
using namespace std;
const int N = 1000006;
#define low(x) ((x)&(-(x)))
int bits[N]; // 装着每个树状数组节点管理区间的区间和
void modify(int x,int val){//a[x]+=val
for(;x<N;x+=low(x)) bits[x]+=val; // 区间和增加 val
}
int query(int x){// 查询 [1,x] 的和
int ret=0;
for(;x!=0;x-=low(x)) ret+=bits[x];
return ret;
}
int n, m;
int a[N];
signed main(){
scanf("%d%d",&n,&m);
for (int i=1;i<=n;i++){
scanf("%d", &a[i]);
modify(i, a[i]); // 逐个加入ai完成树状数组初始化
}
for(int i=1;i<=m;i++){
int op,x,y;
scanf("%d%d%d", &op, &x, &y);
if(op==1) modify(x, y);
else{
int ans=query(y)-query(x-1); // [x,y] = [1,y] - [1,x]
printf("%d\n", ans);
}
}
return 0;
}
Day3上午 数据结构
RMQ问题
给出一个长度为 \(n\) 的数列,有 \(m\) 次查询,每次查询给出两个数 \(l,r\),询问区间 \([l,r]\) 中的最大/小值是多少?
线段树
时间复杂度 \(O(n\log n)\)
#include<bits/stdc++.h>
using namespace std;
#define N 100005
#define LL long long
const int L=0,R=1;
LL v[N]; // RMQ 问题还是会给出每个人的初始分数
struct xds{
int son[2];
LL maxv, minv; // 变成维护区间最大值最小值
// 没有修改,那么没有任何tag
}a[N*2];int cnt;
void update(int k){ // 更新一个点的信息
a[k].maxv = max(a[a[k].son[L]].maxv, a[a[k].son[R]].maxv);
a[k].minv = min(a[a[k].son[L]].minv, a[a[k].son[R]].minv);
}
void build(int &k,int l,int r){//建立线段树
k=++cnt;
if(l==r){
a[k].maxv = v[l];
a[k].minv = v[l];
}else{
int mid=(l+r)>>1;
build(a[k].son[L],l,mid);//递归建立左儿子
build(a[k].son[R],mid+1,r);//递归建立右儿子
update(k);//合并左右儿子信息
}
}
LL query_min(int k,int l,int r,int ql,int qr){//区间查询操作,比如问区间和
if(ql==l&&r==qr){//全区间查询
return a[k].minv;
}else{
int mid=(l+r)>>1;
//下传并清空节点k的所有标记
LL ret = 0;
if(qr<=mid) ret=query_min(a[k].son[L],l,mid,ql,qr);
else if(ql>mid) ret=query_min(a[k].son[R],mid+1,r,ql,qr);
else ret=min(query_min(a[k].son[L],l,mid,ql,mid), query_min(a[k].son[R],mid+1,r,mid+1,qr));
return ret;
}
}
LL query_max(int k,int l,int r,int ql,int qr){//区间查询操作,比如问区间和
if(ql==l&&r==qr){//全区间查询
return a[k].maxv;
}else{
int mid=(l+r)>>1;
//下传并清空节点k的所有标记
LL ret = 0;
if(qr<=mid) ret=query_max(a[k].son[L],l,mid,ql,qr);
else if(ql>mid) ret=query_max(a[k].son[R],mid+1,r,ql,qr);
else ret=max(query_max(a[k].son[L],l,mid,ql,mid), query_max(a[k].son[R],mid+1,r,mid+1,qr));
return ret;
}
}
int n, m, root;
int main(){
scanf("%d%d", &n, &m);
for(int i=1;i<=n;i++)
scanf("%lld", &v[i]);
build(root, 1, n);
for(int i=1;i<=m;i++){
int op, x, y;
LL k;
scanf("%d%d%d", &op, &x, &y);
if(op == 1){ //op=1 表示查询区间 [x,y] 最小值
printf("%lld\n", query_min(root, 1, n, x, y));
}else{ //op=2 表示查询区间 [x,y] 最大值
printf("%lld\n", query_max(root, 1, n, x, y));
}
}
return 0;
}
ST表
优势:在 \(O(n \log n)\) 时间的预处理后,单次查询的时间复杂度仅需 \(O(1)\)。
前提算法——倍增法。
实现:
-
定义 \(f_{i,L}\) 表示以 \(L\) 为开头,长度为 \(2^i\) 的区间的最大(小)值,即对应于 \([L,L+2^i-1]\)。
-
首先我们思考递推边界,显然是对于所有的 \(f_{0,i}\) 都赋值为 \(a_i\)。为什么?根据 \(f\) 数组的定义,\(f_{0,i}\) 所对应的区间是 \([i,i+2^0-1] = [i,i]\)。诶,这不就一个数吗?那最大(小)值肯定是他自己(\(a_i\))了。
-
在递推时,我们把子区间的长度倍增,不难发现通过 \(f_{i-1,L}\) 可以推出 \(f_{i,L}\) ,有公式 \(f_{i,L}=\max(f_{i-1,L},f_{i-1,L+2^{i-1}})\),即长度为 \(2^i\) 的子区间的最大值是左右两半长度为 \(2^{i-1}\) 的子区间的最大值中较大的一个。
下图为ST表:
查询时,我们要处理一个 \(k\) 值,满足 \(2^k \le r-l+1 < 2^k+1\) ,也就是在 \(2\) 的 \(k\) 次幂小于区间长度的前提下最大的 \(k\),那么 “从 \(l\) 开始的 \(2^k\) 个数” 和 “以 \(r\) 结尾的 \(2^k\) 个数” 这两段一定覆盖了整个查询区间 \([l,r]\),这两段的最大值为 \(f_{k,l}\) 和 \(f_{k,r-2^k+1}\),两者中较大(小)的那个就是要求的区间最大(小)值了。
为了方便查找,我们要预处理 \(logn\) 数组(和 \(f\) 数组一起),\(logn_i\) 表示不超过区间长度的最大的 \(2\) 的整数次幂,即上文所提到的 \(k\) 值;换句话说,\(logn\) 数组就是来保存 \(1 \sim n\) 这 \(n\) 种区间长度各自对应的 \(k\) 值的。易得 \(logn_1=0\),然后递推求 \(logn_{i}=logn_{i/2}+1\),时间复杂度为 \(O(n)\)。每次查询时区间 \([l,r]\) 对应的 \(k\) 值为 \(logn_{r-l+1}\)。
其实还有一种方法,就是查的时候直接利用 cmath 库中的 log
函数(自然对数,即以常数 \(\textit{e}\) 为底的对数)求出 \(k=\frac{\log(r-l+1)}{\log(2)}\);也可以直接用 log2
函数(以 \(2\) 为底的对数)求值,即 \(k=\log_2{(r-l+1)}\)。不过 log
和 log2
的常数可能较大,不能保证严格 \(O(1)\),因此我们在实践中一般用 \(O(n)\) 预处理 \(logn\) 数组的方法来保证查询操作是严格 \(O(1)\) 的,以减小常数(避免被卡常)。
代码(wkl):
#include<bits/stdc++.h>
using namespace std;
const int N = 1e5+5;
inline int read(){
int x=0,f=1; // x 表示我们读到值, f表示数的符号
char ch=getchar(); // getchar 是获得输入内容的下一个字符
while (!isdigit(ch)){
if (ch=='-') f=-1; // 说明输入一个负数
ch=getchar(); // 不然的话下一个字符
}
while (isdigit(ch)){
x=x*10+(ch-'0'); // x=123, x=1234 = 123 * 10 + 4
ch=getchar(); // 读下一个字符
}
return x*f; // 数字乘上符号
}
const int INF = 0x3f3f3f3f; // 大概是 10的9次方多一些 INF+INF < 2^31
int n,m; // 假设我们序列长度为 n,一共有m个询问。
int a[N]; // a是初始值序列
//ST表
int st[20][N]; // st[i][L] 表示从 L 开始长度为 2^i 的区间的最大值
int logn[N]; // logn[i] 表示 2^logn[i] 次方不超过 i
// logn[1] = 0, logn[2] = 1, logn[3] = 1, logn[4] = 2, logn[5] = 2, logn[6]=2, logn[7]=2, logn[8]=3
// logn[i] = logn[i/2] + 1
void pre(){
for(int i=2;i<=n+2;i++) logn[i]=logn[i>>1]+1; // 预处理一下logn数组
for(int i=1;i<=n;i++) // 初始化st 表第一行 st[0], 即长度为1的序列
st[0][i]=a[i];
for(int i=1;(1<<i)<=n;i++) // (1<<i) = 2的i次方,
for(int j=1;j+(1<<i)-1<=n;j++){ // j 是区间起始位置, j+(1<<i)-1 是区间结尾
st[i][j]=max(st[i-1][j],st[i-1][j+(1<<(i-1))]);
// 例如 i=2, j=3, st[2][3] 表示区间 [3,6]
// 那么就是 st[2][3] = max(st[1][3], st[1][5]);
// 其中st[1][3] 表示区间 [3, 4], st[1][5] 表示区间 [5, 6]
}
}
int getmax(int l,int r){ // 查询[l, r]
if(l>r) return -INF; //
int d=logn[r-l+1]; // 不超过区间长度的最大的2的整数次幂
return max(st[d][l],st[d][r-(1<<d)+1]); // l开始长度为2的d次方的,和结束于r的长度为2的d次方的
}
int main(){
n = read(), m = read();
for(int i=1;i<=n;i++) a[i]=read();
pre();
for(int i=1;i<=m;i++){
int l=read(), r=read();
printf("%d\n", getmax(l,r));
}
return 0;
}
ST表——Lht
LCA
树是什么?
-
\(n\) 个点,\(n-1\) 条边的连通图。或者说,没有环的连通图。
-
有根树,无根树。
-
树根,树上节点的父亲、儿子、祖先、后代、兄弟
-
一个节点的深度定义为到根节点的距离
如何存储一棵树?
使用 vector[i]
存储与节点 \(i\) 相邻的点的编号(稀疏图的存储方式)
如何遍历一棵树?
从树根开始,DFS 递归遍历,每次遍历记录父亲是谁,避免死循环。
LCA(最近公共祖先)问题:
有一颗N个节点的树,有M次询问,每次询问给出两个点 \(u,v\),求 \(u,v\) 在树上的最近公共祖先(即深度最深的公共祖先)。
节点 \(A\) 是节点 \(B\) 的祖先当且仅当 \(A\) 在 \(B\) 到根的路径上。
反之,如果 \(B\) 在 \(A\) 的子树里,则 \(B\) 是 \(A\) 的后代。
解法1:
转换为 RMQ 问题。
先遍历一遍树,生成 欧拉遍历序。
我们记录给每个点它在欧拉遍历序中的任意一个出现位置 \(p_u\)。
那么查询 \(u,v\) 的最近公共祖先,即为查询区间 \([p_u,p_v]\) 中深度最潜的一个点是什么,即区间 \([p_u,p_v]\) 中的最小值。
例子:
欧拉遍历序:\(1, 2, 3, 2, 5, 6, 5, 2, 1, 4, 1\)
对应点深度:\(0, 1, 2, 1, 2, 3, 2, 1, 0, 1, 0\)
数组下标:\(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11\)
比如查询 \((5,4)\),则对应区间 \([5,10]\) 或者 \([7,10]\),
区间中深度最小值为 \(0\),对应了节点 \(1\)。
解法2:
树上倍增法。
预处理:
\(f_{i,j}\) 表示从节点 \(j\) 往上走 \(2^i\) 步会到哪里,例如 \(f_{i_0}\) 记录 \(i\) 的父亲。
由于向上走 \(2^i\) 步,等于走 \(2^{i-1}\) 步,再走 \(2^{i-1}\) 步
即因为 \(2^i=2^{i-1}+2^{i-1}\),
所以 \(f_{i,j}=f_{i-1,f_{i-1,j}}\)。
预处理时同时记录每个节点的深度 \(d_i\)。
查询时先通过倍增法使得深度更深的点,向上走到与另一个点相同的深度,然后再次运用倍增法走到两个点深度最浅的不同的祖先。
比如在下图中查询 \((5,9)\) 的最近公共祖先:
首先从 \(9\) 向上走到与 \(5\) 同一深度的 \(7\)。
然后 \(5\) 和 \(7\) 分别走到 \(2\) 和 \(4\),
于是 \((5,9)\) 的最近公共祖先就是 \(2\) 或者 \(4\) 的父亲。
代码(wkl):
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int maxn=110; // 最多的点数
const int maxm=210; // 最多的边数
int n;
vector<int> g[maxn]; // g[i] 存着与i相连的点
void AddEdge(int u,int v) { // 添加一个(u,v)双向边
g[u].push_back(v);
g[v].push_back(u);
}
int fa[20][maxn],dep[maxn]; // fa[i][k] 表示k节点往上走 2的i次方步到哪个祖先
// dep 存的是节点深度
void dfs(int x, int father){ // 树上遍历建立倍增表
fa[0][x] = father; // 记录一下
dep[x] = dep[father]+1; // 根节点深度为 1
for(int i=1;(1<<i)<=dep[x];i++)
fa[i][x]=fa[i-1][fa[i-1][x]];
for(auto to: g[x]) //遍历自己的儿子
if(to != father) // 别跑回父亲去了
dfs(to, x);
}
int lca(int x,int y) {
if(dep[x]<dep[y]) swap(x,y); // 先保证 x的深度比y的深度深
// 假设 dep[x] - dep[y] = 6 = 4 + 2
// 6 的二进制是 0110,对应了 4 (0100) 和 2(0010)
for(int i=19;i>=0;i--)
if( (dep[x]-dep[y]) & (1<<i)) // 拿2的整数次幂凑出我们要走的距离
// if( (dep[x]-dep[y]) >= (1<<i)) // 等效写法,能走就走
x=fa[i][x];
if(x == y) return x; // 如果某一个人是另一个人的直接祖先,返回
for(int i=19;i>=0;i--) // 倍增找深度最浅不相同的祖先
if(fa[i][x] != fa[i][y])
x=fa[i][x], y=fa[i][y];
return fa[0][x]; // 返回某个的直接父亲就是最近公共祖先
}
int main() {
n=5;
AddEdge(1,2);
AddEdge(2,3);
AddEdge(2,5);
AddEdge(5,6);
AddEdge(1,4);
AddEdge(4,7);
AddEdge(7,8);
AddEdge(7,9);
dfs(1,0);
printf("%d\n",lca(3,9));
return 0;
}
LCA——Lht
Day3下午 动态规划
dp入门
动态规划是什么
- 解决一类问题的方法
- 由三部分组成:有限状态 、状态值函数、状态转移方程
状态
-
状态就是能够恰好表达当前需要的信息的一组数据
-
我们只记录关键的变化量
-
不记录对答案没有影响的状态和绝对不变的数据。
-
通过已知信息和状态里可以推导出的数据不用记录。
举例:
扫雷大家一定都玩过,我们以此游戏为例来介绍 dp 中的状态。
\(10 \times 10\)大小已知 \(10\) 个雷位置的扫雷,记录每一个方块是否翻开就可以表示当前状态了。
注意,我们并没有记录用户扫雷点方块的顺序,因为这不重要,不影响游戏的继续和已有的得分。
以及一些绝对不变的数据,比如这个棋盘的尺寸和雷的位置,也不用记录,这些开始就是已知信息。
还有扫雷时每个位置的数字(比如 \(1、2\) 等),也不用记录,因为这些可以通过已知信息和状态里的数据推导得出。
而围棋,总共有 \(3^{361}\) 种状态,每个位置有 黑子、白子和没有子共 \(3\) 种状态,其实实际可能
会少一些,因为存在一个位置不能下或棋子没有气的情况。
状态转移方程
既然状态都被我们列举出来了
我们可以通过另一些状态计算某个状态的对应值 \(f_{state(状态)}\)
例如对于一个 \(10\times10\) 的有 \(10\) 个已知雷的扫雷而言,求解最小需要的胜利步数。
设 \(i\) 为记录了哪些位置被翻开了。
\(f_{i}\) 为一个整数表示需要的最小的步数
\(f_i \rightarrow \min(f(j)+1)\),其中 \(j\) 为可以通过一步操作转移到当前状态的前一些状态。
初始状态是所有位置都没被翻开的局面。
例题1
山用一个三角形表示,从山顶依次向下有 \(1\) 段、\(2\) 段、\(3\) 段等(不超过 \(1000\) 段)山路,每一段用一个数字 \(T(1 \le T \le 100)\) 表示,代表晴天小猪在这一段山路上需要爬的时间,每一次它都可以朝左上、右上两个方向走,小猪初始可以在山脚任意位置。
输出: 一个数,即小猪到达山顶所需要的最短时间。
状态 \(i,j\):小猪当前所在的行、列
\(f_{i,j}\):小猪到达目前位置所需要的最短的时间。
状态转移方程:\(f_{i,j} \rightarrow \min(f_{i_1,j_1},f_{i_2,j_2})+T_{i,j}\)
其中 \((i_1,j_1),(i_2,j_2)\) 分别对应了当前位置的左下方和右下方的位置,\(T_{i,j}\)为在这个位置所需要花费的时间。
那山脚怎么处理?
根据题意,直接初始化山脚 \(f_{i,j}=t_{i,j}\),\(i,j\) 为山脚的位置。
因为我们输入的三角形是对齐的,所以原来的右上位置就相当于对齐后的正上,即 \(i,j-1\)。
注意,当 \(j=1\) 时,\(f_{i-1,j-1}\) 为空,所以只更用 \(f_{i-1,j}\) 更新。
以及 \(i=j\) 时,\(f_{i-1,j}\) 为空,所以只更用 \(f_{i-1,j-1}\) 更新。
#include <bits/stdc++.h>
int n,T[1005][1005];
int f[1005][1005];
signed main() {
std::cin >> n;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
std::cin >> T[i][j];
for (int i = 1; i <= n; i++) f[n][i] = T[n][i];
for (int i = 1; i <= n; i++) //分类讨论
for (int j = 1; j <= i; j++) {
if (j == 1)
f[i][j] = f[i - 1][j] + T[i][j];
else if (i == j)
f[i][j] = f[i - 1][j - 1] + T[i][j];
else
f[i][j] = std::min(f[i - 1][j - 1], f[i - 1][j]) + T[i][j];
}
int minn = 1e18;
for (int i = 1; i <= n; i++)
minn = std::min(minn, f[n][i]);
std::cout << minn;
return 0;
}
例题2
有一个 \(N\) 行 \(M\) 列的棋盘每个位置上的奖励不同,走到某个位置就能获得该位置的奖励 \(val_{i,j}\),现在小明从左上角出发一直走到右下角,每次只能往右和往下走。求最多能获得多少奖励。
设计状态:
\(i,j\) : 现在所在位置
\(f_{i,j}\) : 从起点到目前位置能获得的最大奖励。
初始值 \(f_{1,1}=val_{1,1}\)
#include <bits/stdc++.h>
#define int long long
const int MAXN = 1e3 + 5;
int n, m;
int val[MAXN][MAXN];
int f[MAXN][MAXN];
signed main() {
std::cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
std::cin >> val[i][j];
memset(f, 0, sizeof(f));
for(int i = 1;i <= n; i++)
for(int j = 1; j <= n; j++)
dp[i][j] = max(dp[i - 1][j], dp[i][j -1]) + val[i][j];
std::cout << f[n][m];
return 0;
}
dp与搜索
dp与搜索的关系
-
动规和搜索都是求解状态值函数的方法
-
一个递推、一个递归
-
一个重复值只算一次,一个重复值可能算多次
-
只要能搜就能动规,只要能动规也能搜索
//例题2的搜索版本
int dfs(int x,int y){ //这就是需要的状态表示,返回状态值
a1=dfs(x-1,y); //这个是状态转移
a2=dfs(x,y-1);
return max(a1,a2)+a[x][y];
}
上图这是以上代码的搜索树,肉眼可见,这个搜索树会指数级增大,而且有大量的重复。
我们可以记录下每个状态得到的答案,这样第二次搜索到时,直接返回答案,
这就是记忆化搜索,本题搜索复杂度从 \(O(2^n+m)\) 降低到 \(O(mn)\)。
// dp版本
for(int i = 1;i <= n; i++)
for(int j = 1; j <= n; j++)
dp[i][j] = max(dp[i - 1][j], dp[i][j -1]) + val[i][j];
// 两个循环完美满足所有要求。(其实大多数都是可以几个循环解决)
从前往后的递推,每个状态只算一次。
状态转移方程的补充说明:
状态转移方程需要满足如下条件:
-
除了初始状态以外,每个状态都可以通过其他状态计算得出
-
依赖关系不能成环(成环了,该按照什么顺序算???)
我们还忘了一个dp的关键要素——状态值函数
通常,我们通过拓扑序遍历所有状态来逐个计算状态值函数。
Day5 图论
图的概念
有点有边的叫做图。
有向图:边有方向
无向图:边没有方向
度:一个顶点的度是指于该顶点相关联的边的条数(定义在无向图中)
对于有向图来说,一个顶点的度可细分为入度和出度
入度:与其关联的各边中,以其为终点的边数
出度:以改顶点为起点的边数。
自环:若一条边的两个顶点为同一顶点,则此边为自环。
路径:从一个顶点到另一顶点的可能经过同一个点和同一条边的路线
简单路径:不能走重复点和重复边的路径。
环:起点 = 终点的路径。
简单环:简单路径和环的集合体,保证起点 = 终点且除起点(终点)外不能经过重复的点和边的路径。
树相关
树:无向、无环、联通(任意两点间都可以互相到达)。
\(n\) 个点的树有 \(n-1\) 条边。
如果我们去除一些性质会变成什么样?
去掉联通:
森林:无向、无环、但不连通(有很多树组成)。
去掉无向:
有向树:有向、无环、联通。
外向树:所有的边都向外处指的有向树。
内向树:所有的边都通向一个点的有向树。
去掉无环:
章鱼图 / 基环树:无向、联通且只有一个环。
\(n\) 个点,\(n\) 条边。章鱼图 -环上的一条边 = 树。
仙人掌:无向、联通且有多个环。
其他图
DAG:有向无环图(在树形dp中常用)
二分图:把无向图的所有的点分为两个部分,第一部分的点连接的一定是第二部分的点,第二部分的点连接的一定是第一部分的点。也就是说一条边一定是连接第一部分和第二部分的点。不要求联通。树和森林就是二分图。在树中,深度为奇数的为第一部分,深度为偶数的为第二部分。
奇环:长度为奇数的环。
存在奇环的不是二分图,所有的二分图也不存在奇环。
存图
邻接矩阵
const int MAXN=1010;
//邻接矩阵
//缺点:需要n^2的内存
//优点:取i->j这条边的信息只需要O(1)
int bian[MAXN][MAXN];
//bian[i][j]表示从i点到j点这条边的信息
void add_edge(int s,int e,int d){
bian[s][e]=d;
}
边表
const int MAXN=1010;
//vector存图:边表
//缺点:取i->j这条边的信息只需要O(n)
//优点:这需要O(n+m)的内存
vector<pair<int,int> >g[MAXN];//g[i]用来储存所有从i出发的边
void add_edge(int s,int e,int d){//添加一条从s->e长度为d的边
g[s].push_back(make_pair(e,d));
}
二分图的判定——染色法
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int MAXN=1005;
int n,m;
vector<int>z[MAXN];
int col[MAXN];
//col[i]==0 i点还没决定放哪边
//col[i]==1 i点放左边
//col[i]==2 i点放右边
void add_edge(int s,int e){
z[s].push_back(e);
z[e].push_back(s);
}
signed main(){
cin>>n>>m;//n个点m条边
for(int i=1;i<=m;i++){
int s,e;
cin>>s>>e;
add_edge(s,e);
}
for(int i=1;i<=n;i++){//for循环不断寻找
if(col[i]==0){//还没被染过色
col[i]=1;//放左边
queue<int>q;//储存可以改变周围点颜色的点
q.push(i);
while(q.size()){
int p=q.front();
q.pop();
for(int i=0;i<z[p].size();i++){//可以与27行重复,因为定义域不同
int j=g[p][i];//这是一条从p到j的边
if(col[j]==0){//没有过染色
col[j]=3-col[p];
q.push(j);
}
else if(col[p]==col[j]){//相邻点的颜色相同,说明不是二分图
cout<<"No";
return 0;
}
}
}
}
}
cout<<"Yes";
return 0;
}
拓扑排序
我们要记录每个点的入度。首先把入度为 \(0\) 的点放入答案数组中,再把以它们的出度的点(它们所连的点)的入度 \(-1\)。
整体思路就是如果图中有入度为 \(0\) 的点,就把这个点删掉,同时也删掉这个点所连的边,也就是把这个点所连的所有点的入度 \(-1\)。
#include<bits/stdc++.h>
#define gt getchar
#define pt putchar
#define ll long long
#define ull unsigned long long
using namespace std;
const int MAXN=105;
vector<int>z[MAXN];//边表
void add_edge(int s,int e){
z[s].push_back(e);
}
int ind[MAXN];///ind[i]代表i点的入度
int result[MAXN];//result数组储存最后的排序结果
signed main(){
int n;
cin>>n;
for(int i=1;i<=n;i++){
int s,e;
cin>>s>>e;
add_edge(s,e);
ind[e]++;//入度增加
}
int cnt=0;
for(int i=1;i<=n;i++)
if(!ind[i])result[++cnt]=i;//把入度为0的点先算入结果
for(int i=1;i<=n;i++){
int p=result[i];//取一个已被算入结果(入度为0)的点
for(int j=0;j<z[p].size();j++){//枚举入度为0的点所连接的点
int q=z[p][j];
ind[q]--;//把这个点的入度减去1
if(!ind[q])result[++cnt]=q;//如果这个点的入度被减到0了,那么算入结果
}
}
for(int i=1;i<=n;i++)cout<<result[i]<<' ';
return 0;
}
最短路问题
\(1\) 号点到 \(2\) 到点,如果从上面走过去需要 \(7\) 的距离,从下面走过去则需要 \(9\) 的距离,那么这两条路中较短的就是点 \(1\) 到点 \(2\) 的最短路。
形式化的,最短路的定义就是从一个点到另一个点的所有路径中所经过的边边权之和最小的路径。
最短路问题一般分为单源最短路问题和多源最短路问题。
单源最短路问题:一个起点到其他点的的最短路,即起点是固定的。
多源最短路问题:多个起点到其他点的的最短路,起点不固定。
假如我们用 \(dist_{i,j}\) 来表示点 \(i\) 到点 \(j\) 的最短路,思考 \(dist_{i,j}\),\(dist_{i,k}\) 和 \(dist_{k,j}\) 之间有什么关系。
这就是整个最短路算法中最重要的式子,显然有 \(dist_{i,j} \le dist_{i,k} + dist_{k,j}\)。因为 \(dist_{i,j}\) 是点 \(i\) 到点 \(j\) 的最短路,而 \(dist_{i,k} + dist_{k,j}\) 也是从点 \(i\) 到点 \(j\) 的一条路径,所以后者不可能小于前者。
我们把这个式子叫作最短路的三角不等式。之所以叫这个名字,是因为连接 \(i,j,k\) 的三条边可以看做一个三角形,它的两边之和大于等于第三边(虽然在几何中不成立)。
到此为止,整个最短路的理论部分就讲完了。
Floyd
本质上是一个动态规划。
状态: \(dist_{i,j,k}\) 代表从 \(j\) 走到 \(k\) 且走过的点的编号都 \(\le i\) 的最短路。
目标值:最后求出 \(dist_{n,j,k}\) 来作为我们的答案。
初始化:
-
如果 \(j\) 到 \(k\) 有边,\(dist_{0,j,k}=\min(dist_{0,j,k},d_{j,k})\)。
-
如果 \(j\) 到 \(k\) 无边,\(dist_{0,j,k}=∞\)。
那剩下 \(i \not= 0\) 的情况怎么求呢?我们通过 \(dist\) 数组的定义可以分类讨论来制定转移方程(三次循环 \(1 \sim n\)):
- \(dist_{i-1,j,k}\),表示一条从 \(j\) 到 \(k\) 经过点的编号小于 \(i\) 的最短路,如果不存在则为 \(\infty\)。
-
\(dist_{i-1,j,i}+dist_{i-1,i,k}\),先从 \(j\) 到 \(i\) 再从 \(i\) 到 \(k\),把两个最短路相加(这两条路所经过点的编号必然小于 \(i\)),如果其一不存在则为 \(\infty\)。
最后 \(dist_{i,j,k}\) 的最短路就是两者取最小值,这两种情况其实也就是不经过 \(i\) 和经过 \(i\) 的情况。
容易写出这个 dp 的状态转移方程是 \(dist_{i,j,k}\rightarrow \min(dist_{i−1,j,k},dist_{i−1,j,i}+dist_{i−1,i,k})\)。
Floyd算法的时间复杂度为 \(O(n^3)\),空间复杂度为 \(O(n^3)\) 或 \(O(n^2)\)。
Dijkstra算法
如果想要使用Dijkstra算法,就必须保证所有边的边权都为正数。
确定出发节点 \(x\),用 \(dist_i\) 来表示点 \(x\) 到点 \(i\) 的最短路。
我们首先把 \(dist\) 数组初始化为 \(\infty\),再将 \(dist_x\) 设为 \(0\)。每次选取目前 \(dist\) 数组中最小值所对应的点,即已经求出最短路的点中最短路中最小的,然后让其对自己所连接的点进行松弛操作。这就是所有边的边权必须都为正数的原因,如果这张图中有负边权,我们不敢保证目前已经求出了某个点的最短路。
一个点的松弛操作就是用自己已经求出的最短路去更新其他点的最短路,进行完松弛操作后我们要用一个 \(vis\) 数组来标记它,保证以后不再使用。
当 \(n\) 个点全都被使用过后,所有点的最短路也就求出来了。
我们用 \(O(m)\) 的循环来给每个点进行松弛操作并做更新,内部还要用一个 \(O(n)\) 循环来找目前 \(dist\) 数组中最小值所对应的点,总复杂度为 \(O(nm)\),时间复杂度较高,考虑改进。
我们可以用一个 pair 类型的堆来维护目前 \(dist\) 数组中的值和节点编号,首先把初始值入堆,每次取出已求出最短路的点中最小的没被标记过的点,我们每次可以把被标记过的点弹出堆,然后再取出堆顶进行松弛操作。
这样优化过后,时间复杂度为 \(O((n+m)log(n+m))\),\(log(n+m)\) 中的 \(n+m\) 其实就是堆的大小,因为我们把 \(dist\) 初始值入堆,又在更新 \(m\) 条边的时候将元素放进过堆中,所以堆的历史总大小为 \(n+m\)。
Bellman-Ford算法
首先我们需要引入一个小结论:——个点到另一个点的最短路最多经过 \(n-1\) 条边,因为如果经过了超过 \(n\) 条边,说明有一个点经过了两次,就说明形成了一个环,我们不经过这个环才能保证是最短路。注意求最短路的中是不能存在负环的,可以绕负环无限圈,最短路就无法定义了,所以我们所有的讨论都是在不存在负环的情况下的。
我们的Bellman-Ford算法算法就是基于这个结论的,我们首先用三个数组读入这张图,把 \(dist\) 数组初始化为 \(\infty\),然后对 \(m\) 条边每条边都进行 \(n-1\) 次松弛操作,因为我们知道——个点到另一个点的最短路最多经过 \(n-1\) 条边,所以我们对每条边最多只要进行 \(n-1\) 次松弛操作就能求出最短路,每松弛一次就相当于减少一条边。
每次操作用一条边起点的最短路去更新这条边终点的最短路,即 \(dist_{e_j} \rightarrow \min{(dist_{e_j},dist_{s_j}+d_j)}\),共 \((n-1) \times m\) 次操作,时间复杂度为 \(O(nm)\)。
SPFA算法
SPFA算法的正式名称叫做队列优化的Bellman-Ford算法,顾名思义它的核心是一个队列,用来储存\textbf{可能}改变其他点最短路的点,还是用 \(dist\) 数组来储存出发点到每个点的最短路。首先我们把出发节点入队,然后把 \(dist\) 数组初始化为 \(\infty\),然后每次操作取出队首去对自己相连的点进行松弛操作,如果这个点的最短路被改短了且目前不在队列中,就把这个点入队。我们可以用一个 \(vis\) 数组来标记某个点是否在队列里面。
操作结束的终止条件是队列为空,因为如果没有点能再进行松弛操作就说明所有点的最短路已经求出来了。
SPFA算法的最坏时间复杂度和Bellman-Ford算法一样是 \(O(nm)\) 的。但在一般情况下(出题人使用随机数据),它的复杂度为 \(O(km)\),\(k\) 为 \(100 \sim 200\) 的常数。
如果出题人就是想卡你怎么办?我们可以不按照题目要求,把读进来的所有边 random_shuffle
一下,玄学改变搜索顺序可以使速度更快。
切忌在边权都为正的时候使用SPFA算法,但如果边权有为负数的,SPFA就是最好的选择。
最短路代码汇总
强连通分量
定义
在有向图中,找到若干个点和若干条边,使得每个点都可以互相走到,构成的图就叫做强连通分量。独立的点也可以作为一个强连通分量。每个点都一定要在一个强连通分量里。
问题:给你一张有向图,找到所有的强连通分量。
tarjan算法
树边:遍历过程中正常经过的边(即遍历时另一端端点是还没有被遍历过),即黑边。
横叉边:遍历过程中边的另一端已经访问过(但并不是当前节点的祖先节点或子树中的节点),即蓝边。
前向边:遍历过程中边的另一端是当前节点子树中的节点(这种情况下当前节点必须有不止一条出边),即黄(绿)边。
反祖边:遍历过程中边的另一端是当前节点的祖先节点,即红边。
找能组成环的非树边。
什么样的非树边能产生环?
回边:一条非树边连向自己的祖先,能形成环。
横叉边:不是回边的边(没连向自己的祖先,不能组成环)。
如果两条横叉边能组成环,那是因为 DFS 改变了树的形态,所以那不是横叉边,而是一条回边。
横叉边虽然不能组成环,但是可以扩大环的范围,但是要求连过去的边往上走的最上面的那个点必须是横叉边开始的点的 \(k\) 的祖先。
Day6上午 数学
取模运算基础
要算 \(n! \text{ mod } p\)
错误做法:
#include<bits/stdc++.h>
using namespace std;
signed main(){
int ans=1;
int n,p;
cin>>n>>p;
for(int i=1;i<=n;i++)ans*=i;
cout<<ans%p;
return 0;
}
如果算完阶乘再取模很可能爆掉,得出的结果就是错的。
正确做法:
边乘边模
#include<bits/stdc++.h>
using namespace std;
signed main(){
int ans=1;
int n,p;
cin>>n>>p;
for(int i=1;i<=n;i++)ans=ans*i%p;
cout<<ans;
return 0;
}
快速幂
求 \(a\) 的 \(b\) 次方对 \(p\) 取模的值。
快速幂在算法竞赛中有多种理解方式,接下来介绍两种较好理解的。
理解方式一:
我们在六年级的时候学过同底数幂的乘法法则,得知 \(x^a \times x^b = x^{a + b}\),因此 \(x^{\frac{y}{2}} \times x^{\frac{y}{2}} = x ^ y\),所以我们可以分治,直到指数为 \(0\)。记当前的结果为 \(z\),底数为 \(x\),指数为 \(y\),按照 \(z^y = z^{\frac{y}{2}} \times z^{\frac{y}{2}}\) 来计算。如果当前的 \(y\) 为偶数,直接返回计算的结果;如果 \(y\) 为奇数,就要把刚才得到的结果再乘上一个 \(x\),再返回。
int ksm(int x,int y,int p){
if(y==0)return 1;
int z=ksm(x,y/2,p);
z=1ll*z*z%p;
if(y%2)z=1ll*z*x%p;
return z;
}
理解方式二:
根据数学常识,每一个正整数可以表示为若干指数不重复的 \(2\) 的次幂的和(详见这道题)。也就是说,如果 \(b\) 在二进制表示下有 \(k\) 位,其中第 \(i(0 \le i < k)\) 位的数字是 \(c_i\),那么:\(b=c_{k-1}2^{k-1}+c_{k-2}2^{k-2}+\dots+c_{0}2^{0}\),于是:\(a^b=a^{c_{k-1} \times 2^{k-1}}\times a^{c_{k-2} \times 2^{k-2}}\times \dots \times a^{c_{0} \times 2^{0}}\).
因为 $k=\left \lceil \log_2{(b+1)} \right \rceil $,所以上式乘积项的数量不多于 \(\left \lceil \log_2{(b+1)} \right \rceil\) 个。又因为 \(a^{2^i}=(a^{2^{i-1}})^2\),所以我们很容易通过 \(k\) 次递推求出每个乘积项,当 \(c_i=1\) 时,把该乘积项累积到答案中。 b&1
运算可以取出 \(b\) 在二进制表示下的最低位,而 b>>1
运算可以舍去最低位,在递推的过程中将二者结合,就可以遍历 \(b\) 在二进制下表示的所有数位 \(c_i\),整个算法的时间复杂度为 \(O(\log_2{b})\) 。
int power(int a,int b,int p){
int ans=1%p;
for(;b;b>>=1){
if(b&1)ans=(long long)ans*a%p;
a=(long long)a*a%p;
}
return ans;
}
我们通过“右移运算”和“与运算”的结合,遍历了 \(b\) 的二进制表示下的每一位,在循环到第 \(i\) 次时(从 \(0\) 开始计数),变量 \(a\) 中储存的是 \(a^{2^i}\),若 \(b\) 该位为 \(1\),则把此时的变量 \(a\) 累积到答案 \(ans\) 中。
逆元
若 \(a \times b \equiv 1 (\text{mod } p)\),那么就称 \(b\) 为 \(a\) 在模 \(p\) 意义下的乘法逆元。借助逆元,我们就可以完成模意义下的除法操作了。
费马小定理:
\(p\) 为质数,\(\gcd(a,p) = 1\) 时,\(a^{p-1} \equiv 1(\text{mod } p)\).
两边同时 \(\div a\),得 \(a^{p-2} \equiv \frac{1}{a} (\text{mod } p)\).
我们发现 \(a^{p-2} \times a \equiv 1 (\text{mod } p)\).
\(a^{p-2}\) 即为 \(a\) 在模 \(p\) 意义下的乘法逆元,快速幂求出即可。
所以,在 \(p\) 为质数,\(a\) 与 \(p\) 互质的情况下,\(a \div b \text{ mod } p = a \times a^{p-2} \text{ mod } p\).
欧拉定理
\(\gcd(a,p)=1\) 时,\(a^{\varphi(p)}\equiv 1 \pmod p\).
\(\varphi(p)\) 为欧拉函数,表示在 \([1,p]\) 中有多少个数与 \(p\) 互质。
考虑 \(1 \sim p\) 和 \(p\) 互质的数 \(X_1...X_{\varphi(p)}\),我们将 \(X_i\) 变成 \(X^{'}_{i}=aX_i\%p\),由于 \(\gcd(a,p)=1\),这样新得到的 \(X_i\) 和 \(X^{'}_{i}=aX_i\%p\) 一定是一一对应的,且 \(X^{'}_{i}\) 一定和 \(p\) 互质。所以有 \(\prod^{\varphi(p)}_{i=1}X_i=\prod^{\varphi(p)}_{i=1}X^{'}_{i}\),两边同时乘 \(\prod^{\varphi(p)}_{i=1}X_i\) 的逆元,得 \(a^{\varphi(p)}\equiv 1 \pmod p\).文章来源:https://www.toymoban.com/news/detail-711801.html
我们发现如果一个数 \(p\) 是素数,那么 \(\varphi(p)=p-1\)(除 \(p\) 外都与它互质),这样就是费马小定理了。所以费马小定理实际上是欧拉定理的特殊情况。 这样 \(a\) 的逆元就是 \(a^{\varphi(p)-1}\)。文章来源地址https://www.toymoban.com/news/detail-711801.html
到了这里,关于国庆NOIP储备营讲课笔记的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!