算法
思想
- 暴力算法怎么做
- 如何去优化
基础算法
一维差分
//预处理
for(int i=1;i<=n;i++)
{
a[i]=nextInt();
b[i]=a[i]-a[i-1];
}
//给(l,r)区间加d
while(m-- > 0)
{
int l=nextInt();
int r=nextInt();
int d=nextInt();
b[l]+=d;
b[r+1]-=d;
}
//得到结果
for(int i=1;i<=n;i++)
{
a[i]=a[i-1]+b[i];
cout.print(a[i]+" ");
}
二维前缀和
a[i][j]+=a[i-1][j]+a[i][j-1]-a[i-1][j-1];
二维差分
a[x2][y2]-a[x2][y1-1]-a[x1-1][y2]+a[x1-1][y1-1]
数据结构
稠密图:邻接矩阵
稀疏图:邻接表
图
总结
| 算法 | 类别 | 关键应用 | 时间复杂度 | 限制 |
|---|---|---|---|---|
| Dijkstra | 单源最短路径 | 正权图的最短路径 | O((V+E)log V) | 不能有负权边 |
| Bellman-Ford | 单源最短路径 | 负权图/检测负权环 | O(VE) | 比Dijkstra慢 |
| SPFA | 单源最短路径 | Bellman-Ford的队列优化(稀疏图) | 平均O(E),最坏O(VE) | 不稳定,可能被卡数据 |
| Floyd-Warshall | 全源最短路径 | 小规模图的任意两点最短路径 | O(V³) | 空间占用大(V²) |
| Prim | 最小生成树 | 稠密图的MST | O(E log V) | 无向图 |
| Kruskal | 最小生成树 | 稀疏图的MST(边排序+并查集) | O(E log E) | 无向图 |
朴素dijkstra,正权图的最短路径
int g[N][N]; // 存储每条边
int dist[N]; // 存储1号点到每个点的最短距离
bool st[N]; // 存储每个点的最短路是否已经确定
// 求1号点到n号点的最短路,如果不存在则返回-1
int dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
for (int i = 0; i < n - 1; i ++ )
{
int t = -1; // 在还未确定最短路的点中,寻找距离最小的点
for (int j = 1; j <= n; j ++ )
if (!st[j] && (t == -1 || dist[t] > dist[j]))
t = j;
// 用t更新其他点的距离
for (int j = 1; j <= n; j ++ )
dist[j] = min(dist[j], dist[t] + g[t][j]);
st[t] = true;
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
堆优化dijkstra
最短的点O(n^2)可以使用最小堆优化。
1. 一号点的距离初始化为零,其他点初始化成无穷大。
2. 将一号点放入堆中。
3. 不断循环,直到堆空。每一次循环中执行的操作为:
弹出堆顶(与朴素版diijkstra找到S外距离最短的点相同,并标记该点的最短路径已经确定)。
用该点更新临界点的距离,若更新成功就加入到堆中。
堆优化版的dijkstra是对朴素版dijkstra进行了优化,在朴素版dijkstra中时间复杂度最高的寻找距离
// 稀疏图用邻接表来存
int h[N], e[N], ne[N], idx;
int w[N]; // 用来存权重
int dist[N];
bool st[N]; // 如果为true说明这个点的最短路径已经确定
int dijkstra()
{
memset(dist,0x3f,sizeof(dist));
dist[1]=0;
priority_queue<PII, vector<PII>, greater<PII>> heap; // 定义一个小根堆
// 这里heap中为什么要存pair呢,首先小根堆是根据距离来排的,所以有一个变量要是距离,
// 其次在从堆中拿出来的时候要知道知道这个点是哪个点,不然怎么更新邻接点呢?所以第二个变量要存点
heap.push({0,1});
while(heap.size())
{
PII k=heap.top();
heap.pop();
int ver=k.second,distance=k.first;
if(st[ver]) continue; //看这个点有没有被访问过,如果被访问过说明已经是最短了,这个值是冗余的值
st[ver]=true;
for(int i=h[ver],i!=-1,i=ne[i])
{
int j=e[i];
if(dist[j]>distance+w[i]);
{
dist[j]=distance+w[i];
heap.push(dist[j],j);
}
}
}
}
bellman - ford算法,负权图/检测负权环
Bellman - ford 算法是求含负权图的单源最短路径的一种算法,效率较低,代码难度较小。其原理为连续进行松弛,在每次松弛时把每条边都更新一下,若在 n-1 次松弛后还能更新,则说明图中有负环,因此无法得出结果,否则就完成。
(通俗的来讲就是:假设 1 号点到 n 号点是可达的,每一个点同时向指向的方向出发,更新相邻的点的最短距离,通过循环 n-1 次操作,若图中不存在负环,则 1 号点一定会到达 n 号点,若图中存在负环,则在 n-1 次松弛后一定还会更新)
int n, m; // n表示点数,m表示边数
int dist[N]; // dist[x]存储1到x的最短路距离
struct Edge // 边,a表示出点,b表示入点,w表示边的权重
{
int a, b, w;
}edges[M];
// 求1到n的最短路距离,如果无法从1走到n,则返回-1。
int bellman_ford()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
// 如果第n次迭代仍然会松弛三角不等式,就说明存在一条长度是n+1的最短路径,由抽屉原理,路径中至少存在两个相同的点,说明图中存在负权回路。
for (int i = 0; i < n; i ++ )
{
for (int j = 0; j < m; j ++ )
{
int a = edges[j].a, b = edges[j].b, w = edges[j].w;
if (dist[b] > dist[a] + w)
dist[b] = dist[a] + w;
}
}
if (dist[n] > 0x3f3f3f3f / 2) return -1;
return dist[n];
}
spfa,Bellman-Ford的队列优化(稀疏图)
求最短路
spfa是对bellman-ford算法的优化。
Bellman_ford算法会遍历所有的边,但是有很多的边遍历了其实没有什么意义,我们只用遍历那些到源点距离变小的点所连接的边即可,只有当一个点的前驱结点更新了,该节点才会得到更新;因此考虑到这一点,我们将创建一个队列每一次加入距离被更新的结点。
-
st数组的作用:判断当前的点是否已经加入到队列当中了;已经加入队列的结点就不需要反复的把该点加入到队列中了,就算此次还是会更新到源点的距离,那只用更新一下数值而不用加入到队列当中。
即便不使用st数组最终也没有什么关系,但是使用的好处在于可以提升效率。 -
队列中存储的是dist更新的结点
-
SPFA算法看上去和Dijstra算法长得有一些像但是其中的意义还是相差甚远的:
1] Dijkstra算法中的st数组保存的是当前确定了到源点距离最小的点,且一旦确定了最小那么就不可逆了(不可标记为true后改变为false);SPFA算法中的st数组仅仅只是表示的当前发生过更新的点,且spfa中的st数组可逆(可以在标记为true之后又标记为false)。顺带一提的是BFS中的st数组记录的是当前已经被遍历过的点。
2] Dijkstra算法里使用的是优先队列保存的是当前未确定最小距离的点,目的是快速的取出当前到源点距离最小的点;SPFA算法中使用的是队列(你也可以使用别的数据结构),目的只是记录一下当前发生过更新的点。
int n; // 总点数
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N]; // 存储每个点到1号点的最短距离
bool st[N]; // 存储每个点是否在队列中
// 求1号点到n号点的最短路距离,如果从1号点无法走到n号点则返回-1
int spfa()
{
//初始化
memset(dist,0x3f,sizeof(dist));
dist[1]=0;
queue<int> q;
//把第一个结点放进去
q.push(1);
st[1]=true;
//对每个dist更新的结点处理
while(q.size())
{
auto t=q.front();
p.pop();
st[t]=false;
//遍历所有和结点t有关的结点
for(int i=h[t];i!=-1;i=ne[i])
{
int j=e[i];
if(dist[j]>dist[t]+w[i]) //如果从结点t这个路走小的话就更新
{
dist[j]=dist[t]+w[i];
if(!st[j]) // 如果队列中已存在j,则不需要将j重复插入
{
q.push(j);
st[j]=true;
}
}
}
}
if(dist[n]==0x3f3f3f3f) return -1;
return dist[n];
}
判断负环
在原图的基础上新建一个虚拟源点,从该点向其他所有点连一条权值为0的有向边。那么原图有负环等价于新图有负环。
此时在新图上做spfa,将虚拟源点加入队列中。然后进行spfa的第一次迭代,这时会将所有点的距离更新并将所有点插入队列中。如果在迭代的过程中发现有负环,那么根据抽屉原理,一定会有cnt>n存在
bool spfa()
{
queue<int> q;
for(int i=1;i<=n;i++)
{
st[i]=true;
q.push(i);
}
while(q.size())
{
auto t=q.front();
q.pop();
st[t]=false;
for(int i=h[t];i!=-1;i=ne[i])
{
int j=e[i];
if(dist[j]>dist[t]+w[i])
{
dist[j]=dist[t]+w[i];
cnt[j]=cnt[t]+1;
if(cnt[j]>=n) return true;
if(!st[j])
{
q.push(j);
st[j]=true;
}
}
}
}
return false;
}
floyd算法,小规模图的任意两点最短路径
初始化:
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
if (i == j) d[i][j] = 0;
else d[i][j] = INF;
// 算法结束后,d[a][b]表示a到b的最短距离
void floyd()
{
for (int k = 1; k <= n; k ++ )
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
prim,稠密图最小生成树
int g[N][N];
int dist[N];
bool st[N];
int prim()
{
memset(dist,0x3f,sizeof(dist));
dist[1]=0;
int res=0;
for(int i=0;i<n;i++)
{
int t=-1;
for(int j=1;j<=n;j++) //找距离当前集合最小的点
{
if(!st[j]&&(t==-1||dist[t]>dist[j]))
{
t=j;
}
}
if(dist[t]==0x3f3f3f3f){ //判断是否为孤立点
return 0x3f3f3f3f;
}
st[t]=true;
res+=dist[t];
for(int j=1;j<=n;j++) //更新其他点的距离
{
if(!st[j]) dist[j]=min(dist[j],g[t][j]);
}
}
return res;
}
Kruskal,稀疏图最小生成树
int n, m; // n是点数,m是边数
int p[N]; // 并查集的父节点数组
struct Edge // 存储边
{
int a, b, w;
bool operator< (const Edge &W)const
{
return w < W.w;
}
}edges[M];
int find(int x) // 并查集核心操作
{
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}
int kruskal()
{
sort(edges, edges + m);
for (int i = 1; i <= n; i ++ ) p[i] = i; // 初始化并查集
int res = 0, cnt = 0;
for (int i = 0; i < m; i ++ )
{
int a = edges[i].a, b = edges[i].b, w = edges[i].w;
a = find(a), b = find(b);
if (a != b) // 如果两个连通块不连通,则将这两个连通块合并
{
p[a] = b;
res += w;
cnt ++ ;
}
}
if (cnt < n - 1) return INF;
return res;
}
数学
基础
算术基本定理:每个大于 1 的正整数都可以被唯一地分解成素数的乘积(不考虑因子的顺序)
对于任意一个数N,其分解质因数的形式为\(N = (p_1^{x_1})(p_2^{x_2})(p_3^{x_3})…(p_k^{x_k})\),其中\(p_i\)为质数。
则N的约数个数为:\((x_1+1)(x_2+1)(x_3+1)…(x_k+1)\)
费马小定理:若p为素数,\(gcd(a,p)=1\),则\(a^{p-1}\equiv1(mod\ p)\)
本质上是拉格朗日定理在素数p的乘法同余群的应用
群论观点下的费尔马小定理 - 知乎
欧拉函数:
\(\varphi(n)\)表示小于等于n和n互质的数的个数。比如说。\(\varphi(1)=1\)

欧拉定理:若\(gcd(a,m)=1\),则\(a^{\varphi(m)}\equiv1(mod\ m)\)。
试除法判定质数
bool is_prime(int x)
{
if (x < 2) return false;
for (int i = 2; i <= x / i; i ++ )
if (x % i == 0)
return false;
return true;
}
分解质因数
对于任意一个数都可以分解质因数,而且一定是唯一的
static void divide(int n)
{
for(int i=2;i<=n/i;i++)
{
if(n%i==0)
{
int s=0;
while (n%i==0)
{
n/=i;s++;
}
cout.println(i+" "+s);
}
}
if(n>1) cout.println(n+" "+1);
}
质数筛
朴素
从2开始,从小到大依次把每个质数的所有倍数删掉
int primes[N], cnt; // primes[]存储所有素数
bool st[N]; // st[x]存储x是否被筛掉
void get_primes(int n)
{
for (int i = 2; i <= n; i ++ )
{
if (st[i]) continue;
primes[cnt ++ ] = i;
for (int j = i + i; j <= n; j += i)
st[j] = true;
}
}
诶氏筛法
由算数基本定理可以知道用质数可以把所有的合数都筛掉
线性筛法
由公理可知,n都可以被分解为多个质数的乘积,那么,在这些质数中,我们总能找到一个最小的数x,那么x就是n的最小质数
void get_primes(){
//外层从2~n迭代,因为这毕竟算的是1~n中质数的个数,而不是某个数是不是质数的判定
for(int i=2;i<=n;i++){
if(!st[i]) primes[cnt++]=i;
for(int j=0;primes[j]<=n/i;j++){//primes[j]<=n/i:变形一下得到——primes[j]*i<=n,把大于n的合数都筛了就
//没啥意义了
st[primes[j]*i]=true;//用最小质因子去筛合数
//1)当i%primes[j]!=0时,说明此时遍历到的primes[j]不是i的质因子,那么只可能是此时的primes[j]<i的最小质因子,所以primes[j]*i的最小质因子就是primes[j];
//2)当有i%primes[j]==0时,说明i的最小质因子是primes[j],因此primes[j]*i的最小质因子也就应该是prime[j],之后接着用st[primes[j+1]*i]=true去筛合数时,就不是用最小质因子去更新了,因为i有最小质因子primes[j]<primes[j+1],此时的primes[j+1]不是primes[j+1]*i的最小质因子,此时就应该
退出循环,避免之后重复进行筛选。
if(i%primes[j]==0) break;
}
}
}
int primes[N], cnt; // primes[]存储所有素数
bool st[N]; // st[x]存储x是否被筛掉
void get_primes(int n)
{
for (int i = 2; i <= n; i ++ )
{
if (!st[i]) primes[cnt ++ ] = i;
for (int j = 0; primes[j] <= n / i; j ++ )
{
st[primes[j] * i] = true;
if (i % primes[j] == 0) break;
}
}
}
试除法求约数
vector<int> get_divisors(int x)
{
vector<int> res;
for (int i = 1; i <= x / i; i ++ )
if (x % i == 0)
{
res.push_back(i);
if (i != x / i) res.push_back(x / i);
}
sort(res.begin(), res.end());
return res;
}
约数个数
对于任意一个数N,其分解质因数的形式为\(N = (p_1^{x_1})(p_2^{x_2})(p_3^{x_3})…(p_k^{x_k})\),其中\(p_i\)为质数。
则N的约数个数为:\((x_1+1)(x_2+1)(x_3+1)…(x_k+1)\)
// 基本思想
//如果 N = p1^c1 * p2^c2 * ... *pk^ck
//约数个数: (c1 + 1) * (c2 + 1) * ... * (ck + 1)
import java.util.*;
public class Main{
static int mod = (int)1e9 + 7;
public static void main(String[] args){
Map<Integer,Integer> map = new HashMap<>(); //创建一个哈希表
Scanner scan = new Scanner(System.in);
int n = scan.nextInt();
while(n -- > 0){
int x = scan.nextInt();
//下面这里是运用了分解质因数的模板,
for(int i = 2 ; i <= x / i ; i ++ ){
while(x % i == 0){
x /= i;
// map.getOrDefault(i,0) 这个是获取对应i位置的values值
map.put(i,map.getOrDefault(i,0) + 1);
}
}
if(x > 1) map.put(x,map.getOrDefault(x,0) + 1 );
}
long res = 1;
//map.keySet()获取所有的key值,map.values()获取所有的values值,两种方法都可以
for(int key : map.values()){
res = res * (key + 1) % mod;
}
System.out.println(res);
}
}
最大公约数/欧几里得算法
由欧几里得算法知道\((a,b)=(b,a\ mod\ b)\),既然两式公约数都是相同的,那么最大公约数也会相同。
所以得到式子\(gcd(a,b)=gcd(b,a\ mod\ b)\)
同时我们发现如果b是a的约数,那么b就是二者的最大公约数。
也就是说一直求到\(gcd(a,b)\)中的b为0时,在上一步的\(gcd(a,b)\)中b一定是a的倍数,这时候的a就是上一步的b。也就是a就是最大公约数
static int gcd(int a,int b)
{
return b!=0?gcd(b,a%b):a;
}
递归至 b == 0(即上一步的 a % b == 0)的情况再返回值即可。
最小公倍数
static int lcm(int a,int b)
{
return a/gcd(a,b)*b;
}
欧拉函数
int phi(int x)
{
int res = x;
for (int i = 2; i <= x / i; i ++ )
if (x % i == 0)
{
res = res / i * (i - 1);
while (x % i == 0) x /= i;
}
if (x > 1) res = res / x * (x - 1);
return res;
}
快速幂
利用十进制数的二进制表示

//求 m^k mod p,时间复杂度 O(logk)。
int qmi(int m, int k, int p)
{
int res = 1 % p, t = m;
while (k)
{
if (k&1) res = res * t % p;
t = t * t % p;
k >>= 1;
}
return res;
}
快速幂求逆元
乘法逆元
1. 什么是乘法逆元?
乘法逆元可以理解为:在某种运算规则下,一个数的“倒数”。
比如在普通的实数运算里:
- 2 的乘法逆元是
1/2,因为2 × (1/2) = 1。
但在模运算(取余数)的世界里,事情变得不一样:
- 我们要找一个数
x,使得(a × x) % m = 1,那么x就是a在模m下的乘法逆元。
简单说:乘法逆元就是“能让两个数相乘后,余数变成1”的那个数。
2. 为什么需要乘法逆元?
在数学和编程(比如密码学、大数运算)里:
- 除法运算很复杂,尤其是大数除法耗时较长;
- 但乘法运算很快,所以我们可以用乘法逆元替代除法。
比如:
- 平时算
a / b,但编程里可以改成a * inv(b)(其中inv(b)是b的逆元),这样就能用乘法代替除法,提高效率。
3.如何求乘法逆元
当模数m为质数的时候,b的乘法逆元为x=b^(n-2)
题目


#include <iostream>
using namespace std;
typedef long long LL;
LL qmi(int a, int b, int p)
{
LL res = 1;
while(b){
if(b & 1) res = res * a % p;
a = (LL)a * a % p;
b >>= 1;
}
return res;
}
int main()
{
int n; cin >> n;
while(n --){
int a, p;
cin >> a >> p;
if(a % p == 0) puts("impossible");
else cout << qmi(a, p - 2, p) << endl;
}
return 0;
}
组合数
I
通过公式\(C^{a}_{b}=C^{a-1}_{b}+C^{a-1}_{b-1}\)得到
// c[a][b] 表示从a个苹果中选b个的方案数
for (int i = 0; i < N; i ++ )
for (int j = 0; j <= i; j ++ )
if (!j) c[i][j] = 1;
else c[i][j] = (c[i - 1][j] + c[i - 1][j - 1]) % mod;
容斥原理
能被整除的数


#include <iostream>
#include <algorithm>
#include <unordered_set>
#include <cstring>
using namespace std;
const int N=20;
int p[N],n,m;
int main()
{
cin>>n>>m;
for(int i=0;i<m;i++) cin>>p[i];
int res=0;
for(int i=1;i<1<<m;i++)
{
int t=1;
int s=0;
for(int j=0;j<m;j++)
{
if(i>>j&1)
{
if((long long)t*p[j]>n)
{
t=-1;
break;
}
t*=p[j]; //注意这里是p[j]不是p[i]
s++;
}
}
if(t==-1) continue;
if(s&1) res+=n/t;
else res-=n/t;
}
cout<<res;
return 0;
}
博弈论
原理
公平组合游戏
若一个游戏满足:
- 由两名玩家交替行动
- 在游戏进行的任意时刻,可以执行的合法行动与轮到哪位玩家无关
- 不能行动的玩家判负
则称该游戏为一个公平组合游戏。
尼姆游戏(NIM)属于公平组合游戏,但常见的棋类游戏,比如围棋就不是公平组合游戏,因为围棋交战双方分别只能落黑子和白子,胜负判定也比较负责,不满足条件2和3。
有向图游戏
给定一个有向无环图,图中有一个唯一的起点,在起点上放有一枚棋子。两名玩家交替地把这枚棋子沿有向边进行移动,每次可以移动一步,无法移动者判负。该游戏被称为有向图游戏。
任何一个公平组合游戏都可以转化为有向图游戏。具体方法是,把每个局面看成图中的一个节点,并且从每个局面向沿着合法行动能够到达的下一个局面连有向边。
必败/胜状态
- 必胜状态:先手进行某一个操作,留给后手是一个必败状态时,对于先手来说是一个必胜状态。即先手可以走到某一个必败状态。
- 必败状态:先手无论如何操作,留给后手都是一个必胜状态时,对于先手来说是一个必败状态。即先手走不到任何一个必败状态。
Mex运算
设S表示一个非负整数集合.定义mex(S)为求出不属于集合S的最小非负整数运算,即:
mes(S)=min{x};
例如:S={0,1,2,4},那么mes(S)=3;
sg函数
在有向图游戏中,对于每个节点x,设从x出发共有k条有向边,分别到达节点y1,y2,····yk,定义SG(x)的后记节点y1,y2,····yk的SG函数值构成的集合在执行mex运算的结果,即:
SG(x)=mex({SG(y1),SG(y2)····SG(yk)})
特别地,整个有向图游戏G的SG函数值被定义为有向图游戏起点s的SG函数值,即 SG(G)=SG(s).
多个独立局面的SG值,等于这些SG值的异或和。也就是如果当前的局面的下一个局面都是2个以上的局面,那这个局面的SG值就是下一个状态的所有局面的SG值异或和
看谁先到达sg!=0的点。因为只有sg!=0的点,下一步才一定能走。所以如果sg=0,那下一步就可能无法行动。
所以sg=0代表必败态,sg!=0代表必胜态
有向图游戏的和
设G1,G2,····,Gm是m个有向图游戏.定义有向图游戏G,他的行动规则是任选某个有向图游戏Gi,并在Gi上行动一步.G被称为有向图游戏G1,G2,·····,Gm的和.
有向图游戏的和的SG函数值等于它包含的各个子游戏SG函数的异或和,即:
SG(G)=SG(G1) ^ SG(G2) ^···^ SG(Gm)
结论
sg函数的性质:
SG(i)=k,则i能到达的点一点包括[0,i)- 非0可以走向0
- 0只能走向非0
对于一个图G,如果SG(G)!=0,则先手必胜,反之必败
对于n个图,如果SG(G1) ^ SG(G2) ^ ... ^ SG(Gn) != 0,则先手必胜,反之必败
题目
Nim游戏
给定n堆石子,两位玩家轮流操作,每次操作可以从任意一堆石子中拿走任意数量的石子(可以拿完,但不能不拿),最后无法进行操作的人视为失败。
问如果两人都采用最优策略,先手是否必胜。
例如:有两堆石子,第一堆有2个,第二堆有3个,先手必胜。
#include <iostream>
using namespace std;
int main()
{
int n;cin>>n;
int res=0;
while(n--)
{
int t;cin>>t;
res^=t;
}
if(res) cout<<"Yes";
else cout<<"No";
return 0;
}
台阶Nim游戏

wp:AcWing 892. 台阶-Nim游戏 - AcWing
只需要考虑奇数阶的即可
集合-Nim游戏

求出每一个图的sg函数,异或一下即可。同时可以加上记忆化搜索
#include<iostream>
#include <cstring>
#include<unordered_set>
using namespace std;
const int N=110,M=10010;
int n,m;
int s[N],f[M];
int sg(int x)
{
if(f[x]!=-1) return f[x];
//记忆化搜索,x代表石子的数量
//对于这个题来说,只要石子的数量相同,sg函数相同
unordered_set<int> S;
for(int i=0;i<m;i++)
if(x>=s[i]) S.insert(sg(x-s[i]));
for(int i=0;;i++)
if(!S.count(i)) return f[x]=i;
}
int main()
{
cin>>m;
for(int i=0;i<m;i++) cin>>s[i];
memset(f,-1,sizeof(f));
cin>>n;
int res=0;
while(n--)
{
int x;cin>>x;
res^=sg(x);
}
res?puts("Yes"):puts("No");
return 0;
}
拆分Nim游戏

相比于集合-Nim,这里的每一堆可以变成小于原来那堆的任意大小的两堆。
相当于一个局面拆分成了两个局面,由SG函数理论,多个独立局面的SG值,等于这些局面SG值的异或和。
DP
通过局部最优解的传递性,保证全局最优解的存在性和可计算性
实际上是对爆搜的优化,可以用一个数来表示一堆情况的值
时间复杂度:状态数量 * 转移计算量
背包问题
01背包

朴素
原理
- 原问题的最优结构包含子问题的最优结构。
f(i,j)集合包含有i和没有i两种集合。所以f(i,j)是有i和没有i集合的最大值 的 最大值- 没有i:没有i的集合也就是
f(i-1,j)的集合,所以没有i的集合的最大值是f(i,j) - 有i:有i的集合 和 这个集合的最大值 都不方便表示,可以曲线救国。先把这个集合中的i去掉,就可以得到集合
f(i-1,j-w[i]),然后再加上i就可以表示 有i 的集合。
这种方式虽然无法表示有i的集合,但是可以表示有i的集合的最大值,也就是f(i-1,j-w[i])+v[i])
- 没有i:没有i的集合也就是
由上面两条可以得出转移方程f(i,j)=max(f(i-1,j),f(i-1,j-w[i])+v[i])
其中
max是由于f(i-1,j)和f(i-1,j-w[i])+v[i])是f(i,j)的子问题,而根据原问题的最优解包含子问题的最优解可以知道一定不可能有一个结构比子问题大,所以是f(i-1,j)和f(i-1,j-w[i])+v[i])的最大值
根据公式,通过计算所有子问题的最优解(按递推顺序),并利用这些子问题的最优解逐步推导,最终可以得到原问题的最优解。
所以所有状态的最优解都可以从初始状态递推得到
所以在01背包问题中,只要满足以下条件,即可通过初始状态递推得到原问题的最优解:
- 初始状态正确
- 状态转移方程正确。
- 计算顺序正确(按递推顺序覆盖所有状态)
思路
f[0][j] = 0 和 f[i][0] = 0 是各自状态下的唯一可能解(因此也是最优解)。
通过初始状态逐步推导,确保每个状态 f[i][j] 都基于已计算的最优子解(f[i-1][j] 和 f[i-1][j-w[i]])。
#include<iostream>
#include<cstring>
#include<queue>
using namespace std;
const int N=1020;
int v[N],w[N];
int f[N][N];
int main()
{
int n,m;cin>>n>>m;
for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
//f[0][1~m]=0;
//f[1~n][0]=0;
for(int i=1;i<=n;i++)
{
for(int j=0;j<=m;j++)
{
f[i][j]=f[i-1][j];
if(j>=v[i]) f[i][j]=max(f[i][j],f[i-1][j-v[i]]+w[i]);
}
}
cout<<f[n][m];
return 0;
}
一维优化
将状态f[i][j]优化到一维f[j],实际上只需要做一个等价变形。
从状态转移方程可以发现f[i][]是由f[i-1][]转移的,之前的f[i-2][],f[i-3][]...都没用,所以可以用一维来表示状态,并且逆置更新j,这样得到新状态的时候,所根据的仍是老状态的数据得到新状态。
为什么一维下枚举背包需要逆置
-
在二维情况下,状态
f[i][j]是由上一轮i - 1的状态得来的,f[i][j]与f[i - 1][j]是独立的。而优化到一维后,如果我们还是正序,则有f[较小体积]更新到f[较大体积],则有可能本应该用第i-1轮的状态却用的是第i轮的状态。 -
简单来说,一维情况正序更新状态
f[j]需要用到前面计算的状态已经被「污染」,逆序则不会有这样的问题。
状态转移方程为:f[j] = max(f[j], f[j - v[i]] + w[i]
for(int i=1;i<=n;i++)
{
for(int j=0;j<=m;j++)
{
f[i][j]=f[i-1][j]; // 优化前
f[j] = f[j]; // 优化后,该行自动成立,可省略
if(j>=v[i]) f[i][j]=max(f[i][j],f[i-1][j-v[i]]+w[i]); // 优化前
f[j] = max(f[j], f[j - v[i]] + w[i]); // 优化后,这里for(int j=0;j<=m;j++)要改成for(int j=m;j>=v[i];j--)
}
}
实际上,只有当枚举的背包容量 >= v[i] 时才会更新状态,因此我们可以修改循环终止条件进一步优化。
for(int i = 1; i <= n; i++)
{
for(int j = m; j >= v[i]; j--)
f[j] = max(f[j], f[j - v[i]] + w[i]);
}
最终代码
#include<iostream>
#include<cstring>
#include<queue>
using namespace std;
const int N=1020;
int v[N],w[N];
int f[N];
int main()
{
int n,m;cin>>n>>m;
for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
for(int i=1;i<=n;i++)
{
for(int j=m;j>=v[i];j--)
{
f[j]=max(f[j],f[j-v[i]]+w[i]);
}
}
cout<<f[m];
return 0;
}
完全背包

朴素
f[i,j]由f[i-1,j] , f[i-1,j-v]+w , f[i-1,j-2*v]+2*w , f[i-1,j-3*v]+3*w , .....转移而来。
根据朴素01背包的原理和思路,可以得到f[i,j]=max(f[i-1,j] , f[i-1,j-v]+w , f[i-1,j-2*v]+2*w , f[i-1,j-3*v]+3*w , .....)
#include<iostream>
#include<cstring>
#include<queue>
using namespace std;
const int N=1020;
int v[N],w[N];
int f[N][N];
int main()
{
int n,m;cin>>n>>m;
for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
for(int i=1;i<=n;i++)
{
for(int j=0;j<=m;j++)
{
for(int k=0;k*v[i]<=j;k++)
{
f[i][j]=max(f[i][j],f[i-1][j-k*v[i]]+k*w[i]);
}
}
}
cout<<f[n][m];
return 0;
}
k循环优化
f[i , j ] = max( f[i-1,j] , f[i-1,j-v]+w , f[i-1,j-2*v]+2*w , f[i-1,j-3*v]+3*w , .....)
f[i , j-v]= max( f[i-1,j-v] , f[i-1,j-2*v] + w , f[i-1,j-3*v]+2*w , .....)
由上两式,可得出如下递推关系:
f[i][j]=max(f[i,j-v]+w , f[i-1][j])
所以可以把k循环去掉
#include<iostream>
#include<cstring>
#include<queue>
using namespace std;
const int N=1020;
int v[N],w[N];
int f[N][N];
int main()
{
int n,m;cin>>n>>m;
for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
for(int i=1;i<=n;i++)
{
for(int j=0;j<=m;j++)
{
f[i][j]=f[i-1][j];
if(j>=v[i]) f[i][j]=max(f[i][j],f[i][j-v[i]]+w[i]);
}
}
cout<<f[n][m];
return 0;
}
一维优化
和朴素01背包的代码比较一下,可以发现非常相似,可以借用01背包一维优化来优化完全背包。
f[i][j] = max(f[i][j],f[i-1][j-v[i]]+w[i]);//01背包
f[i][j] = max(f[i][j],f[i][j-v[i]]+w[i]);//完全背包问题
由于01背包需要用到的是f[i-1][]层来得到f[i][]层,所以j需要从后往前,但是这里用到的是f[i][]层,所以要从前往后
for(int i = 1 ; i<=n ;i++)
for(int j = v[i] ; j<=m ;j++)//注意了,这里的j是从小到大枚举,和01背包不一样
{
f[j] = max(f[j],f[j-v[i]]+w[i]);
}
#include<iostream>
#include<cstring>
#include<queue>
using namespace std;
const int N=1020;
int v[N],w[N];
int f[N];
int main()
{
int n,m;cin>>n>>m;
for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
for(int i=1;i<=n;i++)
{
for(int j=v[i];j<=m;j++)
{
f[j]=max(f[j],f[j-v[i]]+w[i]);
}
}
cout<<f[m];
return 0;
}
多重背包

朴素
当 s[i]=1时,相当于01背包中的一件物品
当 s[i]>1时,相当于01背包中的多个一件物品
故我们可以死拆(把多重背包拆成01背包)
#include<iostream>
#include<cstring>
#include<queue>
using namespace std;
const int N=1020;
int v[N],w[N],s[N];
int f[N][N];
int main()
{
int n,m;cin>>n>>m;
for(int i=1;i<=n;i++) cin>>v[i]>>w[i]>>s[i];
for(int i=1;i<=n;i++)
{
for(int j=0;j<=m;j++)
{
for(int k=0;k*v[i]<=j&&k<=s[i];k++)
{
f[i][j]=max(f[i][j],f[i-1][j-k*v[i]]+k*w[i]);
}
}
}
cout<<f[n][m];
return 0;
}
二进制优化+01背包优化
在完全背包中,通过两个状态转移方程
f[i,j]=max(f[i−1,j],f[i−1,j−v]+w,f[i−1,j−2v]+2w,f[i−1,j−3v]+3w,.....)
f[i,j−v]=max(f[i−1,j−v],f[i−1,j−2v]+w,f[i−1,j−2v]+2w,.....)
可以得到 f[i][j]=max(f[i−1][j],f[i][j−v]+w)
在多重背包中
f[i,j]=max(f[i−1,j],f[i−1,j−v]+w,f[i−1,j−2v]+2w,.....f[i−1,j−Sv]+Sw)
f[i,j−v]=max( f[i−1,j−v],f[i−1,j−2v]+w,.....f[i−1,j−Sv]+(S−1)w,f[i−1,j−(S+1)v]+Sw)
通过两个方程对比可以发现f[i,j−v]的最后多了一个f[i−1,j−(S+1)v]+Sw导致不能用和完全背包一样的优化思路
但是可以使用二进制优化假设有一组商品,一共有11个。我们知道,十进制数字 11 可以这样表示
11=1011(B)=0111(B)+(11−0111(B))=0111(B)+0100(B)
正常背包的思路下,我们要求出含这组商品的最优解,我们要枚举12次(枚举装0,1,2....12个)。
现在,如果我们把这11个商品分别打包成含商品个数为1个,2个,4个,4个(分别对应0001,0010,0100,0100)的四个”新的商品 “, 将问题转化为01背包问题,对于每个商品,我们都只枚举一次,那么我们只需要枚举四次 ,就可以找出这含组商品的最优解。 这样就大大减少了枚举次数。
这种优化对于大数尤其明显,例如有1024个商品,在正常情况下要枚举1025次 , 二进制思想下转化成01背包只需要枚举10次。
优化的合理性的证明
先讲结论:上面的1,2,4,4是可以通过组合来表示出0~11中任何一个数的,还是拿11证明一下(举例一下):
首先,11可以这样分成两个二进制数的组合:11=0111(B)+(11−0111(B))=0111(B)+0100(B)
其中0111通过枚举这三个1的取或不取(也就是对0001(B),0010(B),0100(B)的组合),可以表示十进制数0~7( 刚好对应了 1,2,4 可以组合出 0~7 ) , 0~7的枚举再组合上0100(B)( 即 十进制的 4 ) ,可以表示十进制数 0~11。其它情况也可以这样证明。这也是为什么,这个完全背包问题可以等效转化为01背包问题。
这里优化也符合原问题的最优结构包含子问题的最优结构的原理,假设原问题的最优结构为00101100,那么这里就是相当于在求每一位的最优结构是1还是0
注意这里的N一定要开大,不要按照题目的N来设置,要不然大的数据量会报错。因为每件物品会被拆分成\(log(w_i)\)份,所以要在原来基础上乘\(log(w_i)\)
#include<iostream>
#include<cstring>
#include<queue>
using namespace std;
const int N=11020,M=2010;
int v[N],w[N];
int f[M];
int main()
{
int n,m;cin>>n>>m;
int cnt=0;
for(int i=1;i<=n;i++)
{
int a,b,s;
cin>>a>>b>>s;
int k=1;
while(k<=s)
{
cnt++;
v[cnt]=a*k;
w[cnt]=b*k;
s-=k;
k*=2;
}
if(s>0)
{
cnt++;
v[cnt]=a*s;
w[cnt]=b*s;
}
}
n=cnt;
for(int i=1;i<=n;i++)
{
for(int j=m;j>=v[i];j--)
{
f[j]=max(f[j],f[j-v[i]]+w[i]);
}
}
cout<<f[m];
return 0;
}
分组背包
有 N 组物品和一个容量是 V 的背包。
每组物品有若干个,同一组内的物品最多只能选一个。
每件物品的体积是 vijvij,价值是 wijwij,其中 ii 是组号,jj 是组内编号。求解将哪些物品装入背包,可使物品总体积不超过背包容量,且总价值最大。
输出最大价值。
输入格式
第一行有两个整数 N,VN,V,用空格隔开,分别表示物品组数和背包容量。
接下来有 N 组数据:
- 每组数据第一行有一个整数 SiSi,表示第 ii 个物品组的物品数量;
- 每组数据接下来有 SiSi 行,每行有两个整数 vij,wijvij,wij,用空格隔开,分别表示第 ii 个物品组的第 jj 个物品的体积和价值;
输出格式
输出一个整数,表示最大价值。
数据范围
0<N,V≤1000<N,V≤100
0<Si≤1000<Si≤100
0<vij,wij≤1000<vij,wij≤100输入样例
3 5 2 1 2 2 4 1 3 4 1 4 5输出样例:
8
朴素
由状态转移的图可以得到

#include<iostream>
#include<cstring>
#include<queue>
using namespace std;
const int N=11020,M=2010;
int v[N][N],w[N][N],s[N];
int f[M][M];
int main()
{
int n,m;cin>>n>>m;
for(int i=1;i<=n;i++)
{
cin>>s[i];
for(int j=1;j<=s[i];j++)
cin>>v[i][j]>>w[i][j];
}
for(int i=1;i<=n;i++)
{
for(int j=0;j<=m;j++)
{
f[i][j]=f[i-1][j];
for(int k=1;k<=s[i];k++)
{
if(v[i][k]<=j) f[i][j]=max(f[i][j],f[i-1][j-v[i][k]]+w[i][k]);
}
}
}
cout<<f[n][m];
return 0;
}
一维优化
结合01背包的一维优化思路,可以用滚动数组来优化为一维
#include<iostream>
#include<cstring>
#include<queue>
using namespace std;
const int N=11020,M=2010;
int v[N][N],w[N][N],s[N];
int f[M];
int main()
{
int n,m;cin>>n>>m;
for(int i=1;i<=n;i++)
{
cin>>s[i];
for(int j=1;j<=s[i];j++)
cin>>v[i][j]>>w[i][j];
}
for(int i=1;i<=n;i++)
{
for(int j=m;j>=0;j--)
{
for(int k=0;k<=s[i];k++)
{
if(v[i][k]<=j) f[j]=max(f[j],f[j-v[i][k]]+w[i][k]);
}
}
}
cout<<f[m];
return 0;
}
线性DP
数字三角形
给定一个如下图所示的数字三角形,从顶部出发,在每一结点可以选择移动至其左下方的结点或移动至其右下方的结点,一直走到底层,要求找出一条路径,使路径上的数字的和最大。
7 3 8 8 1 0 2 7 4 4 4 5 2 6 5输入格式
第一行包含整数 n,表示数字三角形的层数。
接下来 n 行,每行包含若干整数,其中第 i 行表示数字三角形第 ii 层包含的整数。
输出格式
输出一个整数,表示最大的路径数字和。
数据范围
1≤n≤500,
−10000≤三角形中的整数≤10000输入样例:
5 7 3 8 8 1 0 2 7 4 4 4 5 2 6 5输出样例:
30
状态表示f[i][j]
- 集合:从(1,1)到(i,j)所有路径的集合
- 属性:max,最大的路径数字和
状态计算:

- 所以状态转移方程为
f[i][j] = max(f[i-1][j-1]+a[i][j],f[i-1][j]+a[i][j])
初始化:
f[i][j]初始化为-INF,方便后面求max- 每一行的第0个和最后一个也初始化,因为后面算状态转移需要用到这两个
最后只要把f[n][i]遍历一遍求最值就可以
#include<iostream>
#include<cstring>
#include<queue>
using namespace std;
const int N=530,INF=1e9;
int n;
int a[N][N];
int f[N][N];
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
for(int j=1;j<=i;j++)
cin>>a[i][j];
for(int i=1;i<=n;i++)
for(int j=0;j<=i+1;j++) //这里初始化需要把每一行的第0个和最后一个也初始化,因为后面算状态转移需要用到这两个
f[i][j]=-INF;
f[1][1]=a[1][1];
for(int i=2;i<=n;i++)
for(int j=1;j<=i;j++)
f[i][j]=max(f[i-1][j]+a[i][j],f[i-1][j-1]+a[i][j]);
int ans=-INF;
for(int i=1;i<=n;i++)
ans=max(f[n][i],ans);
cout<<ans;
return 0;
}
最长上升子序列
给定一个长度为 N 的数列,求数值严格单调递增的子序列的长度最长是多少。
输入格式
第一行包含整数 N。
第二行包含 N 个整数,表示完整序列。
输出格式
输出一个整数,表示最大长度。
数据范围
1≤N≤1000
−109≤数列中的数≤109输入样例:
7 3 1 2 1 8 5 6输出样例:
4
状态表示f[i]:
- 集合:
f[i]表示从第一个数字开始算,以w[i]结尾的的上升序列。 - 属性:max,集合中最大上升序列的长度
状态计算:

- 在
w[i]>w[j]时,f[i]=max(f[i],f[j]+1)。 - 注意边界情况,若前面没有比i小的,
f[i]为1(自己为结尾)
初始化:
- 所有
f[i]置0
#include<iostream>
#include<cstring>
#include<queue>
using namespace std;
const int N=1300,INF=1e9;
int n;
int a[N];
int f[N];
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
{
cin>>a[i];
f[i]=1;
}
int ans=0;
for(int i=1;i<=n;i++)
{
for(int j=0;j<i;j++)
{
if(a[j]<a[i]) f[i]=max(f[j]+1,f[i]);
}
ans=max(ans,f[i]);
}
cout<<ans;
return 0;
}
最长公共子序列
给定两个长度分别为 N 和 M 的字符串 A 和 B,求既是 A 的子序列又是 B 的子序列的字符串长度最长是多少。
输入格式
第一行包含两个整数 N 和 M。
第二行包含一个长度为 N 的字符串,表示字符串 A。
第三行包含一个长度为 M 的字符串,表示字符串 B。
字符串均由小写字母构成。
输出格式
输出一个整数,表示最大长度。
数据范围
1≤N,M≤1000
输入样例:
4 5 acbd abedc输出样例:
3
状态表示f[i][j]:
- 集合:
f[i][j]表示在a[]的前i个字母和b[]中前j个字母构成的公共子序列 - 属性:max,集合中最长公共子序列的长度
状态计算:

- 如果
a[i]==b[j]那么f[i][j] = f[i - 1][j - 1] + 1
如果a[i]!=b[j]那么f[i][j] = max(f[i - 1][j], f[i][j - 1])
因为如果不相同,那么此时f[i][j]的值肯定不会大于f[i - 1][j]和f[i][j - 1]的最大值
那么一定会等于f[i - 1][j]和f[i][j - 1]的最大值
初始化:
- 所有
f[i][j]都为0
#include<iostream>
#include<cstring>
#include<queue>
using namespace std;
const int N=1300;
int n,m;
char a[N],b[N];
int f[N][N];
int main()
{
scanf("%d%d",&n,&m);
scanf("%s%s",a+1,b+1);
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
{
f[i][j]=max(f[i-1][j],f[i][j-1]);
if(a[i]==b[j]) f[i][j]=max(f[i][j],f[i-1][j-1]+1);
}
cout<<f[n][m];
return 0;
}
最短编辑距离
给定两个字符串 A 和 B,现在要将 A 经过若干操作变为 B,可进行的操作有:
- 删除–将字符串 A 中的某个字符删除。
- 插入–在字符串A 的某个位置插入某个字符。
- 替换–将字符串 A 中的某个字符替换为另一个字符。
现在请你求出,将A 变为 B 至少需要进行多少次操作。
输入格式
第一行包含整数 n,表示字符串 A 的长度。
第二行包含一个长度为 n 的字符串 A。
第三行包含整数 m,表示字符串 B 的长度。
第四行包含一个长度为 m 的字符串 B。
字符串中均只包含大小写字母。
输出格式
输出一个整数,表示最少操作次数。
数据范围
1≤n,m≤1000
输入样例:
10 AGTCTGACGC 11 AGTAAGTAGGC输出样例:
4
状态表示f[i][j]:
- 集合:
f[i][j]表示将a[1~i]变成b[1~j]的操作方式 - 属性:min
状态计算:
有三种操作,所以有三个子集
ok子集划分完了
考虑状态转移的时候
先考虑如果我没有进行这个操作应该是什么状态
然后考虑你进行这一步操作之后会对你下一个状态造成什么影响
然后再加上之前状态表示中你决策出来的那个DP属性
这样就可以自然而然地搞出来转移方程啦
1)删除操作:把a[i]删掉之后a[1~i]和b[1~j]匹配
所以之前要先做到a[1~(i-1)]和b[1~j]匹配
f[i-1][j] + 1
2)插入操作:插入之后a[i]与b[j]完全匹配,所以插入的就是b[j]
那填之前a[1~i]和b[1~(j-1)]匹配
f[i][j-1] + 1
3)替换操作:把a[i]改成b[j]之后想要a[1~i]与b[1~j]匹配
那么修改这一位之前,a[1~(i-1)]应该与b[1~(j-1)]匹配
f[i-1][j-1] + 1
但是如果本来a[i]与b[j]这一位上就相等,那么不用改,即
f[i-1][j-1] + 0
好的那么f[i][j]就由以上三个可能状态转移过来,取个min
初始化:
细节问题:初始化怎么搞
先考虑有哪些初始化嘛
1.你看看在for遍历的时候需要用到的但是你事先没有的
(往往就是什么0啊1啊之类的)就要预处理
2.如果要找min的话别忘了INF
要找有负数的max的话别忘了-INF
ok对应的:
1.f[0][i]如果a初始长度就是0,那么只能用插入操作让它变成b
f[i][0]同样地,如果b的长度是0,那么a只能用删除操作让它变成b
2.f[i][j] = INF
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner scan=new Scanner(System.in);
int N=1010;
char[] a=new char[N];
char[] b=new char[N];
int[][] f=new int[N][N];
int n=scan.nextInt();
String A=scan.next();
int m=scan.nextInt();
String B=scan.next();
for (int i = 1; i <= n ; i++) {
a[i]=A.charAt(i-1);
f[i][0]=i;
}
for (int i = 1; i <= m ; i++) {
b[i]=B.charAt(i-1);
f[0][i]=i;
}
for(int i=1;i<=n;i++)
{
for(int j=1;j<=m;j++)
{
f[i][j]=Math.min(f[i-1][j]+1,f[i][j-1]+1);
if(a[i]!=b[j]) f[i][j]=Math.min(f[i][j],f[i-1][j-1]+1);
else f[i][j]=Math.min(f[i][j],f[i-1][j-1]);
}
}
System.out.println(f[n][m]);
}
}
子序列计数DP
区间DP
模版
所有的区间dp问题枚举时,第一维通常是枚举区间长度,并且一般 len = 1 时用来初始化,枚举从 len = 2 开始;第二维枚举起点 i (右端点 j 自动获得,j = i + len - 1)
for (int len = 1; len <= n; len++) { // 区间长度
for (int i = 1; i + len - 1 <= n; i++) { // 枚举起点
int j = i + len - 1; // 区间终点
if (len == 1) {
dp[i][j] = 初始值
continue;
}
for (int k = i; k < j; k++) { // 枚举分割点,构造状态转移方程
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + w[i][j]);
}
}
}
石子合并
设有 N 堆石子排成一排,其编号为 1,2,3,…,N。
每堆石子有一定的质量,可以用一个整数来描述,现在要将这 N 堆石子合并成为一堆。
每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻,合并时由于选择的顺序不同,合并的总代价也不相同。
例如有 4 堆石子分别为
1 3 5 2, 我们可以先合并 1、2堆,代价为 4,得到4 5 2, 又合并 1、21堆,代价为 9,得到9 2,再合并得到 11,总代价为 4+9+11=24如果第二步是先合并 2、3 堆,则代价为 7,得到
4 7,最后一次合并代价为 11,总代价为 4+7+11=22。问题是:找出一种合理的方法,使总的代价最小,输出最小代价。
输入格式
第一行一个数 N 表示石子的堆数 N。
第二行 N 个数,表示每堆石子的质量(均不超过 1000)。
输出格式
输出一个整数,表示最小代价。
数据范围
1≤N≤300
输入样例:
4 1 3 5 2输出样例:
22
状态表示f[i][j]:
- 集合:将 i 到 j 这一段石子合并成一堆的方案的集合
- 属性:min
状态计算:
- 当
i=j时,合并代价为0,f[i][j]=0 - 当
i<j时,f[i][j]=min( f[i][k] + f[k+1][j] + (s[j]-s[i-1]) ),i <= k <= j-1
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner scan=new Scanner(System.in);
int N=310;
int INF=(int)1e9;
int[] s=new int[N];
int[][] f=new int[N][N];
int n=scan.nextInt();
for(int i=1;i<=n;i++) s[i]=scan.nextInt();
for(int i=1;i<=n;i++) s[i]+=s[i-1];
for (int len = 1; len <= n; len++) {
for (int i = 1; i+len-1 <= n ; i++) { //当长度为1的时候,就只有1个,所以要用i+len-1而不是i+len
int j=i+len-1;
if(len==1){ //初始化
f[i][j]=0;
continue;
}
f[i][j]=INF;
for (int k = i; k < j; k++) {
f[i][j]=Math.min(f[i][j],f[i][k]+f[k+1][j]+(s[j]-s[i-1]));
}
}
}
System.out.println(f[1][n]);
}
}
贪心
区间问题
区间选点

解题思路:
- 按照区间的右端点对区间进行从小到大的排序
- 遍历每一个区间,如果该区间中不包含最后选的那个点,则选取区间右端点。如果包含最后选的那个点,则跳过。
- 最后选择的点的个数就是答案
证明:
假设最后的答案是ans个点,我们根据上述算法选出的点的个数是cnt个,下面来证明:
-
ans <= cnt首先我们的算法每个区间都一定会有一个点在里面,所以得到的答案一定是可行的,因此真实的最优解的点的个数
ans <= cnt -
ans >= res这样的算法算出来后,每个点之间都会有有个断点,可以把这个点选中的所有区间重叠区域当一个新的区间,只要这个点在这个区间里,就相当于这个算法解出来的解。
一共有cnt个点,也就是一共有cnt个这样的区间,所以覆盖每一个区间至少需要cnt个点。

首先,这些区间一定互相不相交,因为如果前后两个区间相交,那么后面的区间一定可以被前面的某个点覆盖,因此根据算法它也就不可能被选中右端点;这些区间互不相交也就说明覆盖到他们上面的点一定互不相同,因此至少需要选出这些区间个数个点,根据我们的算法,也就是至少需要
res个点,因此最终的答案ans >= res
所以也就证明了res = ans,也就证明了算法的正确性
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 100010;
int n;
//定义一个区间结构体
struct Range{
int l, r;
//重载小于号
bool operator < (const Range &w)const{
//根据右端点进行排序
return r < w.r;
}
}range[N];
int main(){
int n;
scanf("%d", &n);
for(int i = 0; i < n; i ++){
int l, r;
scanf("%d%d", &l, &r);
range[i] = {l, r};
}
//排序
sort(range, range + n);
//从左到右开始扫描
int res = 0;//当前选中的点的个数
int ed = -2e9;//上一个点的下标
//开始枚举所有的区间
for(int i = 0; i < n; i ++){
//如果当前这个区间被前面的点所覆盖
//那么一定会被上一个选中的点覆盖,
//同理,如果上一个点没有覆盖当前这个区间,则当前这个区间的右端点应该被选上
if(range[i].l > ed){//如果上一个点没有覆盖当前这个区间
res ++;
ed = range[i].r;
}
}
printf("%d", res);
return 0;
}
最大不相交区间数量

与区间选点问题的做法一样
- 按照区间的右端点对区间进行从小到大的排序
- 遍历每一个区间,如果该区间中不包含最后选的那个点,则选取区间右端点。如果包含最后选的那个点,则跳过。
- 最后选择的区间的个数就是答案
证明:
假设最后的答案是ans个区间,我们根据上述算法选出的区间的个数是res个,下面来证明:
-
ans >= res
根据上一题的结论,首先我们的算法得到的每个区间都一定互不相交,所以得到的答案一定是可行的,因此真实的最优解的区间的个数ans >= res -
ans <= res-
反证法:
假设最优解ans > res,也就是说存在ans个互相不相交的区间,那么在上一题的选点中至少需要选中ans个点,与至少选中res个点矛盾,因此ans <= res -
正面证明
没有被选中的区间,一定是被某个选中区间pass掉的,把选中的某个区间和被该区间pass掉的所有区间看做一个集合,则共有
res个集合。每个集合中的区间,被pass掉的区间的右端点一定大于该集合中选中区间的右端点,因为是按照右端点排序后遍历的,所有选中区间只能pass掉右端点比他大的区间。
每个集合中的区间,被pass掉的区间的左端点一定小于该集合中选中区间的右端点,因为如果某个区间的左端点大于选中区间的右端点,则给区间一定不会被pass掉。(pass掉的条件是该区间左端点小于选中区间的右端点)
综合上面两个条件:每个集合中的区间一定两两相交。
假设
ans > res,根据抽屉原理,一定有某个集合中被选中了1个以上的区间。又因为同一集合中的区间两两相交,因此,如果ans > res,则必定有区间是相交的,和题意矛盾,因此ans不能大于res。
-
代码与上题一样
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 100010;
int n;
//定义一个区间结构体
struct Range{
int l, r;
//重载小于号
bool operator < (const Range &w)const{
//根据右端点进行排序
return r < w.r;
}
}range[N];
int main(){
int n;
scanf("%d", &n);
for(int i = 0; i < n; i ++){
int l, r;
scanf("%d%d", &l, &r);
range[i] = {l, r};
}
//排序
sort(range, range + n);
//从左到右开始扫描
int res = 0;//当前选中的点的个数
int ed = -2e9;//上一个点的下标
//开始枚举所有的区间
for(int i = 0; i < n; i ++){
//如果当前这个区间被前面的点所覆盖
//那么一定会被上一个选中的点覆盖,
//同理,如果上一个点没有覆盖当前这个区间,则当前这个区间的右端点应该被选上
if(range[i].l > ed){//如果上一个点没有覆盖当前这个区间
res ++;
ed = range[i].r;
}
}
printf("%d", res);
return 0;
}
区间分组

思路:
- 按照区间的左端点从小到大进行排序
- 从前往后遍历每一个区间
- 判断当前区间能否放入现有的一个组中(判断当前区间的左端点
l是否小于某个区间最大的右端点,即若l > Max.r,则当前这个区间可以放入这个组中)- 若可以放入一个组中,放进去并更新区间的最大的右端点的坐标
- 若不能放入前面的某个组中,则必须开一个新的组,放入当前区间
- 判断当前区间能否放入现有的一个组中(判断当前区间的左端点
证明:
假设最后的最优解是ans, 我们算法得到的答案是res,下面来证明:
-
ans <= res根据我们的算法,这样得到的结果一定是一个合法的方案,因为我们遍历完每一个区间的时候都能保证得到的组都是合法的,所以最优解一定有
ans >= res, -
ans >= res省流版:对于算法得到的res个组,这res个组一定有交集,而ans是没有交集的最小数,所以
ans>=res对于这种情况,我们来考察算法中分到第
res个组的时候,如下图所示:
此时当前区间由于与前面
res- 1个组都有交集,因此需要分配第res个组,我们此时考察当前这个区间的左端点,对于前res- 1个组,对于任意一个组中一定存在一个区间被当前区间的这个左端点覆盖,因为当前区间的左端点l小于任意一个组的Max.r,而这些所有区间又是按照左端点从小到大排序的,所以当前区间的左端点一定会被某个组中某个区间覆盖,因此我们一定至少可以找到cnt个区间有交集,焦点就是当前这个区间的左端点,所以分组就必须大于等于res所以算法得到的结果
res就是最优解
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
import java.util.Arrays;
import java.util.PriorityQueue;
class Range implements Comparable<Range>{
int l,r;
public Range(int l,int r)
{
this.l=l;
this.r=r;
}
public int compareTo(Range o)
{
return Integer.compare(l, o.l);
}
}
public class Main {
// public static StreamTokenizer cin=new StreamTokenizer(new BufferedReader(new InputStreamReader(System.in)));
// public static PrintWriter cout=new PrintWriter(new OutputStreamWriter(System.out));
static StreamTokenizer cin=new StreamTokenizer(new BufferedReader(new InputStreamReader(System.in)));
static PrintWriter cout=new PrintWriter(new OutputStreamWriter(System.out));
static int N=100010,INF=0x3f3f3f3f,n;
static Range[] range=new Range[N];
public static void main(String[] args) throws IOException
{
n=nextInt();
for(int i=0;i<n;i++)
{
int l=nextInt();
int r=nextInt();
range[i]=new Range(l, r);
}
Arrays.sort(range,0,n);
PriorityQueue<Integer> minheap = new PriorityQueue<>(); // 小根堆
for(int i=0;i<n;i++)
{
Range r=range[i];
if(minheap.isEmpty()||minheap.peek()>=r.l) minheap.add(r.r);
else {
minheap.poll();
minheap.add(r.r);
}
}
cout.print(minheap.size());
cout.flush();
}
public static int nextInt() throws IOException {
cin.nextToken();
return (int)cin.nval;
}
public static long nextLong() throws IOException {
cin.nextToken();
return (long)cin.nval;
}
public static double nextDouble() throws IOException {
cin.nextToken();
return cin.nval;
}
public static String nextString() throws IOException {
cin.nextToken();
return cin.sval;
}
public static void closeAll()
{
cout.close();
}
}
区间覆盖

思路:
- 将所有区间按照左端点从小到大进行排序
- 从前往后枚举每个区间,在所有能覆盖start的区间中,选择右端点的最大区间,然后将start更新成右端点的最大值

哈夫曼树
合并果子



import java.util.*;
public class Main{
public static void main(String[] args){
Scanner scan = new Scanner(System.in);
int N = 10010;
int n = scan.nextInt();
Queue<Integer> minheap = new PriorityQueue<>();
for(int i = 0 ; i < n ; i ++ ){
int x = scan.nextInt();
minheap.add(x);
}
int res = 0;
for(int i = 0 ; i < n ; i ++ ){
if(minheap.size() > 1){ // 为什么是大于1呢,如果剩余一组就说明是最后的结果了
int a = minheap.poll(); // 将最小的数取出来后弹出
int b = minheap.poll(); //将第二小的数取出来后弹出
res += a + b; // 将每一次两堆最小值相加之后的加过累加
minheap.add(a + b);//然后将他放入到堆中
}
}
System.out.println(res);
}
}
题
滑动窗口(单调队列)
题目
给定一个大小为 n≤106n≤106 的数组。
有一个大小为 kk 的滑动窗口,它从数组的最左边移动到最右边。
你只能在窗口中看到 kk 个数字。
每次滑动窗口向右移动一个位置。
以下是一个例子:
该数组为 [1 3 -1 -3 5 3 6 7],kk 为 33。
| 窗口位置 | 最小值 | 最大值 |
|---|---|---|
| [1 3 -1] -3 5 3 6 7 | -1 | 3 |
| 1 [3 -1 -3] 5 3 6 7 | -3 | 3 |
| 1 3 [-1 -3 5] 3 6 7 | -3 | 5 |
| 1 3 -1 [-3 5 3] 6 7 | -3 | 5 |
| 1 3 -1 -3 [5 3 6] 7 | 3 | 6 |
| 1 3 -1 -3 5 [3 6 7] | 3 | 7 |
你的任务是确定滑动窗口位于每个位置时,窗口中的最大值和最小值。
输入格式
输入包含两行。
第一行包含两个整数 n 和 k,分别代表数组长度和滑动窗口的长度。
第二行有 n 个整数,代表数组的具体数值。
同行数据之间用空格隔开。
输出格式
输出包含两个。
第一行输出,从左至右,每个位置滑动窗口中的最小值。
第二行输出,从左至右,每个位置滑动窗口中的最大值。
输入样例:
8 3
1 3 -1 -3 5 3 6 7
输出样例:
-1 -3 -3 -3 3 3
3 3 5 5 6 7
wp
开始先想到用num存放数字,a[]和b[]存放大小关系。
先拿最大值讨论,一开始想用a[]标记窗口中每个数字的大小关系,下一个窗口再重新比较。但是这样做会有夹在中间的数用不到。
比如大小关系为1,3,2,4,其中3永远用不到。因为1在的话轮不到3,2在的话也轮不到3。同时2一定比3后出去,也就是3在窗口的时候,2一定也在窗口,所以3可以直接在排列大小的数组a[]中去掉。这样就变成了1,null,2,3。
实际上如果从开头一直遵守这个规则,可以在每个窗口移动后检验新加入的数字num[j]和a[]中最后一个数字的关系,如果num[j]>=a[t],那就把a[t]的数给挪出去,直到num[j]<a[t],这时让num[j]变成a[]的最小的数,也就是a[++t]=j。
同时每次窗口移动后要检查a[]中最大的数还在不在窗口,如果不在窗口里,就把这个数挪出去,也就是if(a[h]<j-k+1) h++;
#include <iostream>
using namespace std;
const int N=100000000;
int n,k;
int num[N];
int a[N],b[N];//a[],b[]中存储的是num[]的下标
int main()
{
int i,j;
scanf("%d %d",&n,&k);
for(i=1;i<=n;i++)cin>>num[i];
int h=0,t=0;
for(j=1;j<=n;j++)
{
while(h<=t&&num[j]<num[b[t]]) t--;
b[++t]=j;
if(b[h]<j-k+1) h++;
if(j-k+1>=1) printf("%d ",num[b[h]]);
}
printf("\n");
h=0,t=0;
for(j=1;j<=n;j++)
{
while(h<=t&&num[j]>num[a[t]]) t--;
a[++t]=j;
if(a[h]<j-k+1) h++;
if(j-k+1>=1) printf("%d ",num[a[h]]);
}
return 0;
}
Tire字符串统计
题目
维护一个字符串集合,支持两种操作:
I x向集合中插入一个字符串 xx;Q x询问一个字符串在集合中出现了多少次。
共有 NN 个操作,所有输入的字符串总长度不超过 105105,字符串仅包含小写英文字母。
输入格式
第一行包含整数 NN,表示操作数。
接下来 NN 行,每行包含一个操作指令,指令为 I x 或 Q x 中的一种。
输出格式
对于每个询问指令 Q x,都要输出一个整数作为结果,表示 xx 在集合中出现的次数。
每个结果占一行。
数据范围
1≤N≤2∗1041≤N≤2∗104
输入样例:
5
I abc
Q abc
Q ab
I ab
Q ab
输出样例:
1
0
1
wp
用son[][]表示结点,结点数据,下一个结点的序号
son[p][u]= ++idx;这句中,
p:结点序号
u:子结点有没有数据为u的
idx:代表数据为u的结点的序号
son[N][26]相当于一开始创建了很多结点
idx表示结点序号,同时也表示最大的结点是什么
每次需要新的结点时直接用++idx,因为son[++idx]一定没有被使用
#include <iostream>
using namespace std;
const int N=10000010;
int son[N][26],cnt[N],idx;
char str[N];
void insert(char *str)
{
int p=0;
for(int i=0;str[i];i++)
{
int u=str[i]-'a';
if(!son[p][u]) son[p][u]= ++idx;
p=son[p][u];
}
cnt[p]++;
}
int query(char *str)
{
int p=0;
for(int i=0;str[i];i++)
{
int u=str[i]-'a';
if(!son[p][u]) return 0;
p=son[p][u];
}
return cnt[p];
}
int main()
{
int m;
cin >> m;
while(m--)
{
char op[2];
scanf("%s%s", op, str);
if(*op == 'I') insert(str);
else printf("%d\n", query(str));
}
return 0;
}
合并集合
题目
一共有 nn 个数,编号是 1∼n1∼n,最开始每个数各自在一个集合中。
现在要进行 mm 个操作,操作共有两种:
M a b,将编号为 aa 和 bb 的两个数所在的集合合并,如果两个数已经在同一个集合中,则忽略这个操作;Q a b,询问编号为 aa 和 bb 的两个数是否在同一个集合中;
输入格式
第一行输入整数 nn 和 mm。
接下来 mm 行,每行包含一个操作指令,指令为 M a b 或 Q a b 中的一种。
输出格式
对于每个询问指令 Q a b,都要输出一个结果,如果 aa 和 bb 在同一集合内,则输出 Yes,否则输出 No。
每个结果占一行。
数据范围
1≤n,m≤1051≤n,m≤105
输入样例:
4 5
M 1 2
M 3 4
Q 1 2
Q 1 3
Q 3 4
输出样例:
Yes
No
Yes
wp
用p[n]代表第n个数字的父节点,p[x]=x时表示这个节点是根节点。要想找跟节点,只需要按着p[x]一个个向上找,找到p[x]=x就代表找到根节点了。
同时在找的过程中可以直接把p[x]的值变成根节点,实现路径压缩
#include <iostream>
using namespace std;
const int N = 100010;
int n, m;
int p[N];
int find(int x){ //返回x的祖先节点 + 路径压缩
//祖先节点的父节点是自己本身
if(p[x] != x){
//将x的父亲置为x父亲的父亲,实现路径的压缩
p[x] = find(p[x]);
}
return p[x];
}
int main(){
scanf("%d%d", &n, &m);
for(int i = 1; i <= n; i ++) p[i] = i; //初始化,让数x的父节点指向自己
while(m --){
char op[2];
int a, b;
scanf("%s%d%d", op, &a, &b);
if(op[0] == 'M') p[find(a)] = find(b); //将a的祖先点的父节点置为b的祖先节点
else{
if(find(a) == find(b)) puts("Yes");
else puts("No");
}
}
return 0;
}
八数码(dfs)
题目
在一个 3×33×3 的网格中,1∼81∼8 这 88 个数字和一个 x 恰好不重不漏地分布在这 3×33×3 的网格中。
例如:
1 2 3
x 4 6
7 5 8
在游戏过程中,可以把 x 与其上、下、左、右四个方向之一的数字交换(如果存在)。
我们的目的是通过交换,使得网格变为如下排列(称为正确排列):
1 2 3
4 5 6
7 8 x
例如,示例中图形就可以通过让 x 先后与右、下、右三个方向的数字交换成功得到正确排列。
交换过程如下:
1 2 3 1 2 3 1 2 3 1 2 3
x 4 6 4 x 6 4 5 6 4 5 6
7 5 8 7 5 8 7 x 8 7 8 x
现在,给你一个初始网格,请你求出得到正确排列至少需要进行多少次交换。
输入格式
输入占一行,将 3×33×3 的初始网格描绘出来。
例如,如果初始网格如下所示:
1 2 3
x 4 6
7 5 8
则输入为:1 2 3 x 4 6 7 5 8
输出格式
输出占一行,包含一个整数,表示最少交换次数。
如果不存在解决方案,则输出 −1−1。
输入样例:
2 3 4 1 5 x 7 6 8
输出样例
19
wp
把每一种状态以及从初始状态到当前状态经过的距离表示成一个节点,用dfs从根节点开始,寻找符合最终状态的节点。
如何表示这个节点:
-
用字符串
string表示当前状态,可以利用一维数组转二维数组确定x的位置,并且改变状态 -
用一个
map<string,int>表示这个状态下这个节点的距离
于是queue只需要存储对应的string就可以了,distance只需要用map找到这个string对应的数字就可以。
#include <iostream>
#include <algorithm>
#include <queue>
#include <unordered_map>
using namespace std;
int bfs(string start)
{
//定义目标状态
string end = "12345678x";
//定义队列和dist数组
queue<string> q;
unordered_map<string, int> d;
//初始化队列和dist数组
q.push(start);
d[start] = 0;
//转移方式
int dx[4] = {1, -1, 0, 0}, dy[4] = {0, 0, 1, -1};
while(q.size())
{
auto t = q.front();
q.pop();
//记录当前状态的距离,如果是最终状态则返回距离
int distance = d[t];
if(t == end) return distance;
//查询x在字符串中的下标,然后转换为在矩阵中的坐标
int k = t.find('x');
int x = k / 3, y = k % 3;
for(int i = 0; i < 4; i++)
{
//求转移后x的坐标
int a = x + dx[i], b = y + dy[i];
//当前坐标没有越界
if(a >= 0 && a < 3 && b >= 0 && b < 3)
{
//转移x
swap(t[k], t[a * 3 + b]);
//如果当前状态是第一次遍历,记录距离,入队
if(!d.count(t))
{
d[t] = distance + 1;
q.push(t);
}
//还原状态,为下一种转换情况做准备
swap(t[k], t[a * 3 + b]);
}
}
}
//无法转换到目标状态,返回-1
return -1;
}
int main()
{
string c, start;
//输入起始状态
for(int i = 0; i < 9; i++)
{
cin >> c;
start += c;
}
cout << bfs(start) << endl;
return 0;
}

浙公网安备 33010602011771号