DP杂篇

前言

一切仅为个人理解,若有误请大佬指出 orz


乱七八糟的

P7914 [CSP-S 2021] 括号序列

关于去重

首先,这是很显然的区间 DP 。但是只建两维状态就需要去重,作为一个蒟蒻我最不喜欢的就是去重了,所以可以再给状态多设一维方便转移

根据题意,新开的一维代表了当前括号序列的六种状态(详见第一篇题解),只要转移足够好就可以做到不重不漏

不同的状态可能会有相同的组成成分,要是在利用这相同组成成分转移时将两者都算了进去就会算重,所以可以利用最靠后的某种结构来转移

example.现在要转移*()*(),那么看似我们需要从*(),*()*()*,()**,()*()转移,但这样就会愉快地算重。所以就把最右边的()看作一个单位,它左边可以与*()相连,也可以与*()*相连,这样转移就没问题了

P6879 [JOI 2020 Final] 集邮比赛 3

关路灯属于同一种题。

  • 先断环为链,然后看数据范围,这显然是个区间DP,所以肯定会有 \(l,r\) 两维表示已经走完了的区间。

  • 走完了区间可能目前在左端,也有可能在右端,所以再设一维 \([0/1]\) 表示目前在左端点或右端点。

  • 根据题意,不一定能取到区间内的所有熊,而能否取到与时间密切相关;数据量不允许将时间放进 DP 状态内(显然也不能离散化),所以可以像某次模拟赛的题那样将答案放进状态中

最后,状态就是 \(f_{l,r,k,0/1}\) 表示走完 \([l,r]\) 拿了 \(k\) 个熊目前在区间左/右端点花费的最少时间,时间复杂度 \(O(n^3)\) 跑不满,完事儿

填表法&刷表法

填表法是用之前已经确定的 DP 状态确定现在的 DP 状态,比如石子合并,而刷表法是用现在已经确定的 DP 值更新可能由它转移的 DP 值。

在一些题目中,用刷表法会比填表法更加方便(需每个状态所依赖的状态对它的影响相互独立)。比如在这题中,因为该 DP 状态中包含答案,是否能 +1 需要额外判断,用刷表法转移就更加方便一些

矩阵加速DP:P2106 Sam数

有一些 DP 的转移是以求和的方式转移的,这就说明所有的 DP 状态的值其实都是最开始初始化的那几个值贡献的,只不过是贡献次数不同。

矩阵加速就是利用这个东西,在快速幂(以及三次方)的时间复杂度内求出最开始初始化的每个值贡献的次数来快速计算最终的答案。

比如说,假设真正的 DP 数组滚动后是一维的,相关矩阵是二维的,矩阵第 \(i\) 行的 \(a_{i,j}\) 可以理解为:转移到需要的状态 \(i\) 需当前的状态 \(j\) 贡献 \(a_{i,j}\) 次。

以这道题为例,很容易列出状态转移方程(这个时候就不要想记搜了……),因为 \(n\) 的数据范围过大,就可以用矩阵加速。

这道题的矩阵是固定的,在有的题中矩阵要根据输入生成。

质因子状压:P2150 [NOI2015] 寿司晚宴

首先,300 以内有 62 个质数,可以利用状压维护质因子相关信息(比如CF1114F),往上就没办法直接状压了,比如这道题。

大概有这样一条性质:一个数的质因子最多只能有一个大于自己的算术平方根。在此题中,500 的数据范围内大于等于 23 的质因子最多出现一次,23 以内的质数只有 8 个,所以可以分组转移使得不会有相同大质因子的数在同一集合中(这题方案合法的充要条件为质因子集合不交)

具体地,具有相同大质因子的数要么都在集合 1 中,要么都在集合 2 中,要么都不在,开两个 dp 数组分别记录都在集合 1 \集合 2 中的方案数分别转移,再开一个 dp 数组记录全局的即可(需去重)

P3643 [APIO2016] 划艇

首先有个显然的 DP 为设 \(f_{i,j}\) 为第 \(i\) 个学校选择 \(j\) 的方案数,转移式为

\[f_{i,j}=\sum_{k<j}{f_{i-1,k}} \]

但是由于第二维值域很大不能直接这么做。考虑给第二维离散化为若干连续段,即 \(f_{i,j}\) 表示为第 \(i\) 个学校选第 \(j\) 段的方案数。枚举不在 \(j\) 段的前 \(k\) 个学校,设 \([k+1,i]\) 学校有 \(p\) 个可以取到第 \(j\) 段,那么转移方程就是

\[g_{i}=\sum_{k=0}^{j-1}{f_{i,k}},f_{i,j}=\sum{g_{k}\times C_{len+p-1}^{p}} \]

组合数就是要在 \(len\) 个数中选最多 \(p\) 个数(不能不选)的方案数啦

复杂度 \(O(n^3)\)。这里的组合数不能递推得出(怒),但可以最外层枚举连续段编号 \(j\) 后通过组合数意义递推出盖层循环需要的 \(n\) 个组合数

代码
#include <bits/stdc++.h>
#define int long long
#define pii pair<int,int>
#define fst first
#define scd second
#define mkp make_pair
using namespace std;
const int N=1005;
const int MOD=1e9+7;
int n; pii a[N];
int tmp[N],cnt;
int f,c[N],g[N],ans;

void init()
{
    for (int i=1;i<=n;i++) tmp[++cnt]=a[i].fst;
    for (int i=1;i<=n;i++) tmp[++cnt]=a[i].scd+1;
    sort(tmp+1,tmp+1+cnt);
    cnt=unique(tmp+1,tmp+1+cnt)-tmp-1;
    for (int i=1;i<=n;i++)
    {
        a[i].fst=lower_bound(tmp+1,tmp+1+cnt,a[i].fst)-tmp;
        a[i].scd=lower_bound(tmp+1,tmp+1+cnt,a[i].scd+1)-tmp;
    }
}
int qsm(int a,int b)
{
    int res=1;
    while (b)
    {
        if (b&1) res=(res*a)%MOD;
        a=a*a%MOD,b>>=1;
    }
    return res;
}
signed main()
{
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);

    cin>>n;
    for (int i=1;i<=n;i++) cin>>a[i].fst>>a[i].scd;

    init(); c[0]=g[0]=1;
    for (int j=1;j<cnt;j++)
    {
        int len=tmp[j+1]-tmp[j];
        for (int i=1;i<=n;i++) c[i]=c[i-1]*qsm(i,MOD-2)%MOD*(len+i-1)%MOD;
        for (int i=n;i>=1;i--)
        {
            if (a[i].fst>j||a[i].scd<=j) continue;
            f=0; int pp=1;
            for (int p=i-1;p>=0;p--)
            {
                f=(f+c[pp]*g[p]%MOD)%MOD;
                pp+=(a[p].fst<=j&&a[p].scd-1>=j?1:0);
            }
            g[i]=(g[i]+f)%MOD;
        }
    }    

    for (int i=1;i<=n;i++) ans=(ans+g[i])%MOD;
    cout<<ans; return 0;
}

计数相关

状态怎么设呢,感觉会有一维代表“选不选”或是与相关性质有关的状态,然后转移的时候考虑这一维是怎样贡献的:是 \(u\) 自带的,还是 \(v\) 贡献的

线段树合并优化 DP:P6773 [NOI2020] 命运

关于调试

调半天发现是线段树的 query 函数没返回值 [龙图问号]

第一次出错的时候就感觉是取模有问题,看了一圈都没看见,结果最后还真是有个地方忘取模了 [龙图疑惑]

首先这显然是树形 DP,因为答案和约束有关,所以状态中还要体现约束

然后有个性质:对于一些约束 \((u_1,v),(u_2,v)\)…,若深度最深的 \(u_i\) 约束条件被满足了,那么所有的约束条件都可以满足。状态的设计可以利用这个性质:设 \(f_{x,i}\) 表示在 \(x\) 子树中满足所有被包含的约束条件,未被满足的约束条件(贯穿 \(x\) 节点)的 \(u\) 的最深深度为 \(i\) 的方案数。

考虑从子节点 \(v\)\(x\) 的转移。若给边 \((x,v)\) 染色,那么所有下端在 \(v\) 子树内的约束都可被满足,贡献即为 \(f_{x,i}\leftarrow f'_{x,i}\times \sum_{j=0}^{dep_x}{f_{v,j}}\)

若不给边 \((x,v)\) 染色,那么状态中的 \(i\) 可能由 \(x\) 贡献,也有可能由 \(v\) 贡献,转移就是 \(f_{x,i}\leftarrow f'_{x,i}\times \sum_{j=0}^{i}{f_{v,j}}+f_{v,i}\times \sum_{j=0}^{i-1}{f'_{x,j}}\) (注意两个节点一起贡献 \(i\) 的不要算重)

这里有个求和号,可以用个 \(g_{x,i}\) 表示 \(\sum_{j=0}^i{f_{x,j}}\)。最后的转移式就是 \(f_{x,i}\times(g_{v,dep_u}+g_{v,i})+f_{v,i}\times g_{u,i-1}\)。那么,统计 \(g_{i,j}\) 又成了问题。

同时发现,\(f_{x,i}\) 是没法直接当数组开的,所以直接线段树启动,DP 的转移变成了线段树之间的加减乘除,也就是线段树合并

\(su,sv\) 分别表示父节点 DP 相关的前缀和以及子节点 DP 相关的前缀和。因为 \(g_{v,dep_u}\) 是个定值,所以先用它给 \(sv\) 赋值,会改变的部分 \(g_{v,i},g_{u,i-1}\) 一边线段树合并一边加(注意 \(su\) 统计的是到 \(i-1\) 的部分,\(sv\) 相反,所以一个是在转移后加,一个是在转移前加)。

然后就这样乱七八糟不知道为啥对地做完了。

连通性相关 DP:P8867 [NOIP2022] 建造军营

怎么做啊怎么做,和连通性有关是吧,先缩个点。缩完点后是树,每个节点内的非割边可以随便选,太好了开始跑树形 DP。

设状态 \(f_{u,0/1}\) 表示 \(u\) 子树内不建/建军营并使得所有军营连通的方案数,钦定 \(u\) 子树外的点全都不选且绝对不选连接 \(u\) 的那条边。统计答案时,因为一定要建军营,所以 \(f_{u,1}\) 会贡献;在该子树外还会有边的选择方案,所以它贡献时还要乘上子树外的边选择的方案数。

怎么转移呢怎么转移呢,如果不建军营那连接的那条边就可选可不选,也就是 \(\Pi f_{v,0} \times 2\) 。那要是建军营,看目前有没有军营,有的话爱选不选,就是 \(f_{u,1}\times (f_{v,1}+f_{v,0}\times 2)\) ,要是没军营就要 \(v\) 子树贡献了,也就是 \(f_{v,1}\times f_{u,0}\)

然后就乱七八糟不知道为什么不重不漏地做完了


期望概率

或者说是别样的计数 DP。但就是不会做

\(X,Y\) 为两个离散型随机变量,有全期望公式

\[E[E[X|Y]]=EX \]

全期望公式可以将问题分解为更易计算的子期望 ——DeepSeek

在题中的应用像是将变量 \(Y\) 看作 \(X\) 的前驱,\(E[X|Y]\) 一般是可以直接用定值表示的,全期望公式就变成递推式 \(E(X)=\sum p_i\times(E(y_i)+w(y_i,X))\)

P3232 [HNOI2013] 游走

根据贪心策略,肯定是期望出现次数越多的边编号越小。那么现在问题变成了求每条边的期望出现次数

\(f_u\) 表示节点 \(u\) 的期望出现次数,那么边 \((u,v)\) 的期望出现次数就是 \(\frac{f_u}{deg_u}+\frac{f_v}{deg_v}\)(感觉挺直白的,用全期望公式代入也很好理解)。现在问题又变成了求每个点的期望出现次数

显然有 \(f_u=\sum{\frac{f_v}{deg_v}}\)\(u,v\) 之间有边直接相连。那么这可以用高斯消元求解,要注意 \(f_1\) 在最开始时就有个 \(1\) 的贡献,\(f_n\)\(0\) 不会贡献。然后就没了,时间复杂度 \(O(n^3)\)

代码
#include <bits/stdc++.h>
#define pii pair<int,int>
#define mkp make_pair
#define fst first
#define scd second
using namespace std;
const int N=505;
const int M=125005;
int n,m;
vector <int> e[N];
pii E[M];
int d[N];
double a[N][N],f[N],g[M],ans,sum;

void calc()
{
    for (int i=1;i<n;i++)
    {
        int mx=i;
        for (int j=i+1;j<n;j++) mx=(a[mx][i]<a[j][i]?j:mx);
        for (int j=1;j<=n;j++) swap(a[mx][j],a[i][j]);
        for (int j=n;j>=i;j--) a[i][j]/=a[i][i];
        for (int j=1;j<n;j++)
        {
            if (i==j) continue;
            double tmp=a[j][i];
            for (int k=1;k<=n;k++) a[j][k]-=tmp*a[i][k];
        }
    }
    for (int i=1;i<n;i++) f[i]=a[i][n];
}
signed main()
{
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);

    cin>>n>>m;
    for (int i=1,u,v;i<=m;i++)
    {
        cin>>u>>v;
        e[u].push_back(v),e[v].push_back(u);
        E[i]=mkp(u,v);
    }

    for (int i=1;i<=n;i++) d[i]=e[i].size(),a[i][i]=-1;
    for (int i=1;i<n;i++)
    for (int j=0;j<d[i];j++) a[i][e[i][j]]=(e[i][j]==n?0:1.0/d[e[i][j]]);
    a[1][n]=-1; calc();

    for (int i=1,u,v;i<=m;i++) g[i]=f[u=E[i].fst]/d[u]+f[v=E[i].scd]/d[v];
    sort(g+1,g+1+m);
    for (int i=1;i<=m;i++) ans+=(m-i+1)*g[i];

    cout<<fixed<<setprecision(3)<<ans;
    return 0;
}

金牌导航·期望概率 E.彩色圆环

看到环,一般是破环为链去重,但是这道题中还要考虑“连续段”,所以此处钦定“链首”是第一个元素所在的连续段

既然这么设计,那么链尾的颜色肯定不能与链首相同;而除去第一段的剩余若干段的贡献期望值是独立的,所以考虑 DP 剩余段的贡献期望值

\(f_{i,0/1}\) 表示考虑 \(i\) 个元素,尾段颜色与第一段颜色不同/相同的期望值,转移见代码。计算答案考虑枚举第一段的长度 \(i\),最终答案就是 \(\sum{i^2\times p_i\times f_{n-i}}\) 再加上整个环颜色相同的贡献。注意第一个元素在第一段中的不同位置带来的贡献

代码
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=205;
int n;
double m,p[N];
double f[N][2],ans;

signed main()
{
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);

    cin>>n>>m; m=1.0/m;
    p[1]=1; for (int i=2;i<=n;i++) p[i]=p[i-1]*m;

    f[0][1]=1;
    for (int i=1;i<=n;i++)
    for (int j=1;j<=i;j++)
    {
        f[i][1]+=f[i-j][0]*j*p[j]*m;
        f[i][0]+=f[i-j][0]*j*p[j]*(1-m*2);
        f[i][0]+=f[i-j][1]*j*p[j]*(1-m);
    }    

    for (int i=1;i<n;i++) ans+=f[n-i][0]*i*p[i]*i;
    cout<<fixed<<setprecision(5)<<ans+n*p[n];
    return 0;
}

P3750 [六省联考 2017] 分手是祝愿

考虑最优解是什么样的。对于灯 \(i\) ,只有编号为 \(i\) 的倍数的灯可能会改变它的状态,而肯定要考虑完 \(2i,3i,\)… 的灯后再判断是否要操作灯 \(i\)。于是从大到小遍历 \(i\),若遍历到时 \(a_i\) 仍为 \(1\) 就进行一次操作。容易发现这样操作的方案是唯一的且操作顺序不会影响方案

对于一次“随机操作”,我们不关心它具体选了什么点,我们只关心它是否选择的方案中的点。那么设 \(f_i\) 表示还剩 \(i\) 个点没有选择,选到方案中的点的期望操作次数。解方程后可得 \(f_i=\frac{n}{i}\times (f_{i+1}+1)-f_{i+1}\)。那很好做了,求出到剩余 \(k\) 个点的期望次数再 \(+k\) 求总期望即可

代码
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+5;
const int MOD=100003;
int n,k,a[N];
int cnt,f[N],ans;

int qsm(int a,int b)
{
    int res=1;
    while (b)
    {
        if (b&1) res=res*a%MOD;
        a=a*a%MOD; b>>=1; 
    }
    return res;
}
signed main()
{
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);

    cin>>n>>k;
    for (int i=1;i<=n;i++) cin>>a[i];

    for (int i=n;i>=1;i--)
    {
        if (!a[i]) continue;
        cnt++;
        for (int j=1;j*j<=i;j++)
        {
            if (i%j) continue;
            a[j]^=1; a[i/j]^=1;
            if (j*j==i) a[j]^=1;
        }
    }

    ans=min(k,cnt)%MOD;
    for (int i=n;i>=1;i--) f[i]=(n*qsm(i,MOD-2)%MOD*(f[i+1]+1)%MOD-f[i+1]+MOD)%MOD;
    for (int i=cnt;i>k;i--) ans=(ans+f[i])%MOD;
    for (int i=1;i<=n;i++) ans=(ans*i)%MOD;
    cout<<ans;
    return 0;
}

P10504 守卫者的挑战

挺显然的一个 DP,不知道设计状态的时候在犹豫什么

“合法”的两个限制一个是成功次数 \(\geqslant l\),另一个是地图碎片数量 $\leqslant $ 背包总容量,因为数据范围很小,所以可以直接压进 DP 状态

设状态 \(f_{i,j,k}\) 表示考虑前 \(i\) 个时间,成功 \(j\) 次背包剩余容量为 \(k\) 的概率。根据状态直接转移即可。注意到过程中的 \(k\) 一维允许为负,最终非负便算合法,那么在转移时加个偏移量即可

代码
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=205;
const int K=2500;
int n,l,kk;
int a[N];
double p[N],f[N][N][K],ans;

signed main()
{
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);

    cin>>n>>l>>kk; kk=min(kk,n);//大于 n 的容量无用且占用空间
    for (int i=1;i<=n;i++) { cin>>p[i]; p[i]/=100.0; }
    for (int i=1;i<=n;i++) { cin>>a[i]; a[i]=min(a[i],n); }

    f[0][0][kk+205]=1;
    for (int i=1;i<=n;i++)
    for (int j=0;j<=i;j++)
    for (int k=1;k<=n*2+205;k++) f[i][j][k]=(j>0?p[i]*f[i-1][j-1][max(0ll,k-a[i])]:0)+(1.0-p[i])*f[i-1][j][k];

    for (int i=l;i<=n;i++)
    for (int j=205;j<=n*2+205;j++) ans+=f[n][i][j];
    
    cout<<fixed<<setprecision(6)<<ans;
    return 0;   
}

数据结构优化

P2467 [SDOI2010] 地精部落

题目中要求了相邻两个元素的大小关系,所以转移时只关心上个元素 & 这个元素的取值,就有个较为显然的 DP 设计为 \(f_{i,j}\) 表示考虑前 \(i\) 个元素,\(a_i=j\) 的合法方案数。但是一开始就按 \([1,n]\) 的值域来算的话很难转移,因为不知道哪些数已经被选择了。

若给选择的 \(i\) 个元素离散化,那么此时值域就会变为 \([1,i]\),插入一个数 \(j\) 后相当于将原本大于等于 \(j\) 的数 +1,值域就变成了 \([1,i+1]\)。那很好了, \(f_i\) 钦定此时值域为 \([1,i]\),转移再考虑值域的“扩张”插入 \([1,i+1]\) 的数即可

代码
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=4205;
int n,MOD;
int f[N],sum[N],ans;

signed main()
{
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);

    cin>>n>>MOD; f[1]=1;
    for (int i=2;i<=n;i++)
    {
        for (int j=1;j<=n;j++) sum[j]=(sum[j-1]+f[j])%MOD;
        for (int j=1;j<=i;j++)
        {
            if (i&1) f[j]=sum[j-1]%MOD;
            else f[j]=(sum[n]-sum[j-1]+MOD)%MOD;
        }
    }

    for (int i=1;i<=n;i++) ans=(ans+f[i])%MOD;
    cout<<(ans*2)%MOD;
    return 0;
}

P7302 [NOI1998] 免费的馅饼

接完 \(i\) 馅饼后能接到 \(j\) 馅饼的充要条件是 \(2|t_i-t_j|\geqslant |p_i-p_j|\),两个绝对值拆掉之后是俩式子

\[2t_i-p_i\geqslant 2t_j-p_j,2t_i+p_i\geqslant 2t_j+p_j \]

看似需要分类讨论,但“容易发现”只要两个式子全都满足就可以转移了。这是二维偏序的关系,所以上二维数点就可

我差点写个 CDQ。

代码
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+5;
int w,n;
struct node { int t,p,v,b; }a[N];
int tmp[N],f[N],ans;
struct BIT
{
    int tr[N];
    int lowbit(int x) { return x&-x; }
    void add(int x,int k) { while (x<=n) { tr[x]=max(tr[x],k); x+=lowbit(x); } }
    int mx(int x)
    {
        int res=0;
        while (x) { res=max(res,tr[x]); x-=lowbit(x); }
        return res;
    }
}bt;

bool cmp(node x,node y) { return x.t*2+x.p<y.t*2+y.p; }
void init()
{
    for (int i=1;i<=n;i++) tmp[i]=a[i].b;
    sort(tmp+1,tmp+1+n);
    int cnt=unique(tmp+1,tmp+1+n)-tmp-1;
    for (int i=1;i<=n;i++) a[i].b=lower_bound(tmp+1,tmp+1+cnt,a[i].b)-tmp;
}
signed main()
{
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);

    cin>>w>>n;
    for (int i=1;i<=n;i++) { cin>>a[i].t>>a[i].p>>a[i].v; a[i].b=a[i].t*2-a[i].p; }

    sort(a+1,a+1+n,cmp); init();
    for (int i=1;i<=n;i++) { f[i]=bt.mx(a[i].b)+a[i].v; bt.add(a[i].b,f[i]); }

    for (int i=1;i<=n;i++) ans=max(ans,f[i]); cout<<ans;
    return 0;
}

P3287 [SCOI2014] 方伯伯的玉米田

首先有个性质:选择的区间一定是后缀,因为假设原本选择的区间为 \([l,r]\),额外包含后缀 \([r+1,n]\) 一定不会影响其单调性。那就很好做了,设 \(f_{i,j}\) 表示第 \(i\) 个玉米被 \(j\) 个选择的后缀覆盖的以 \(i\) 结尾的最长子序列长度,转移方程有

\[f_{i,j}=max(f_{i',k})+1,i'\in[1,i),k\in[0,j],a_{i'}+k\leqslant a_i+j \]

因为 \(k\) 的范围很小,所以直接上二维树状数组维护最值就行

代码
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e4+5;
const int K=505;
const int inf=2e5;
int n,k,a[N];
int f[N][K],ans;
struct BIT
{
    int tr[K][6005];
    int lowbit(int x) { return x&-x; }
    void add(int x,int y,int kk)
    {
        x++;
        for (int i=x;i<=k+1;i+=lowbit(i))
        for (int j=y;j<=6000;j+=lowbit(j)) tr[i][j]=max(tr[i][j],kk);
    }
    int mx(int x,int y)
    {
        int res=0; x++;
        for (int i=x;i;i-=lowbit(i))
        for (int j=y;j;j-=lowbit(j)) res=max(res,tr[i][j]);
        return res;
    }
}bt;

signed main()
{
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);

    cin>>n>>k;
    for (int i=1;i<=n;i++) cin>>a[i];

    for (int i=1;i<=n;i++)
    {
        for (int j=0;j<=k;j++)
        {
            f[i][j]=bt.mx(j,a[i]+j)+1;
            ans=max(ans,f[i][j]);
        }  
        for (int j=0;j<=k;j++) bt.add(j,a[i]+j,f[i][j]); 
    }
    cout<<ans;
    return 0;
}

决策单调性/斜率优化

好像到现在也不会用四边形不等式。

设二元函数 \(w(x,y)\),若其满足

\[w(a,c)+w(b,d)\leqslant w(a,d)+w(b,c),a<b<c<d \]

那么称其满足四边形不等式

\(p\)\(f_i\) 的最优决策点,那么有

\[f_p+w(p,i)\leqslant f_j+w(j,i),j<p \]

\(i'>i\),若 \(w(x,y)\) 满足四边形不等式,则有

\[w(j,i)+w(p,i')\leqslant w(j,i')+w(p,i) \]

变形后有

\[w(p,i')-w(p,i)\leqslant w(j,i')-w(j,i) \]

与第一个式子相加,有

\[f_p+w(p,i')\leqslant f_j+w(j,i') \]

说明 \(f_{i'}\leftarrow p\) 一定比 \(f_{i'}\leftarrow j\) 更优,满足决策单调性

推式子的时候一般使用 \(x,x+1,y,y+1\),因为可由其推广至上述式子(懒得打了)

P3515 [POI 2011] Lightning Conductor

绝对值很麻烦,此处只讨论 \(j<i\) 的情况,\(j>i\) 的情况相当于是翻转过来再跑一遍,不多说了

给绝对值拆掉后,可以得出式子

\[p\geqslant h_j-h_i+\sqrt{i-j} \]

\[p=max(h_j+\sqrt{i-j})-h_i \]

\(h_j+\sqrt{i-j}\) 的函数图像是显然的,其斜率递减,前面的函数会被后面的函数反超,所以符合决策单调性。若是设 \(w(x,y)=\sqrt{x-y}\) 用四边形不等式证也是好证的。然后就没然后了

(其实是懒得打 latex 了)

代码
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=5e5+5;
const int EPS=1e-8;
int n,a[N];
int p1[N],p2[N];
long double sq[N];
int q[N],hd,tl,k[N];

inline long double val(int i,int j) { return a[j]+sq[i-j]; }
inline long double val2(int i,int j) { return a[j]+sq[j-i]; }
inline int BS(int i,int j)
{
	int l=i,r=n+1;
	while (l<r)
	{
		int mid=(l+r)>>1;
		if (val(mid,j)-val(mid,i)>EPS) r=mid;
		else l=mid+1;
	}
	return r;
}
inline int BS2(int i,int j)
{
	int l=0,r=j;
	while (l<r)
	{
		int mid=(l+r+1)>>1;
		if (val2(mid,j)-val2(mid,i)>EPS) l=mid;
		else r=mid-1;
	}
	return l;
}
signed main()
{
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	
	cin>>n;
	for (int i=1;i<=n;i++) cin>>a[i];
	for (int i=1;i<=n;i++) sq[i]=sqrt(i);
	
	q[hd=tl=1]=1;
	for (int i=2;i<=n;i++)
	{
		while (hd<tl&&k[hd]-1<i) hd++;
		p1[i]=ceil(val(i,q[hd]))-a[i];
		while (hd<tl&&BS(q[tl],i)<=k[tl-1]) tl--;
		k[tl]=BS(q[tl],i),q[++tl]=i;
	}
	for (int i=0;i<=tl;i++) q[i]=k[i]=0; 
	hd=tl=1,q[hd]=n;
	for (int i=n-1;i>=1;i--)
	{
		while (hd<tl&&k[hd]+1>i) hd++;
		p2[i]=ceil(val2(i,q[hd]))-a[i];
		while (hd<tl&&BS2(q[tl],i)>=k[tl-1]) tl--;
		k[tl]=BS2(q[tl],i),q[++tl]=i;
	}
	
	for (int i=1;i<=n;i++) cout<<max(max(p1[i],p2[i]),0ll)<<"\n";
	return 0;
}

DP 套 DP

这类 DP 往往都是计数问题,而计数的状态是内层 DP 值,所以外层的 DP 要体现内层的不同 DP 值。设外层 DP 的状态为 \(f_{i,S}\),内层 DP 的状态为 \(f'_{i,j}\),因为外层 DP 已枚举了 \(i\) 所以其 \(S\) 就是指 \(f'_i\) 这一维的若干 DP 值

以最长公共子序列举例,因为固定 \(f'_i\) 后的 \(j\) 维状态差分后可状压,所以 \(S\) 存的是第二维状压后的差分值;再以最大独立集举例,因为其第二维只是 \(0/1\) 表示是否强制选该点,所以外层 DP 直接带俩值 \(f(u,val_0,val_1)\) 就行。

注意时间复杂度是总状态数\(\times\)转移复杂度,若时间复杂度假了可以考虑优化 DP 设计(比如给前后值差分缩小 DP 的值域)或优化转移。一般来说应该都是优化 DP 设计吧 [思考]

P4590 [TJOI2018] 游园会

题意中的奖励等级就是俩串的最长公共子序列,他的转移方程是

\[f_{i,j}=\begin{cases}f_{i-1,j-1}+1,s_i=t_j\\ max(f_{i,j-1},f_{i-1,j}),s_i\neq t_j \end{cases} \]

容易发现 DP 的转移只依赖 \(s_i,t_j\) 和之前的 DP 值。考虑枚举兑奖串的第 \(i\) 位,将其与 \(s_j\) 匹配。注意到 \(f_{i,j}\)\(f_{i,j+1}\) 最多相差 1,所以可以状压差分后的 \(f_i\)

所以最外层的 DP 为 \(f_{i,S}\) 表示枚举兑奖串的前 \(i\) 位,此时内层 DP 的差分为 \(S\) 的方案数。因为在这道题中还有着不能出现 NOI 的限制,所以还需一维表示当前枚举串尾包含 NOI 多长的前缀

然后没了。这道题要滚动数组不然会 MLE,然后总状态为 \(O(|S|\times 2^K)\) (其中 \(S\) 是字符集),但是需要记忆化,不然每次都现求会 TLE

代码
#include <bits/stdc++.h>
#define ll long long
using namespace std;
const int N=1005;
const int M=16;
const int K=4e4;
const int MOD=1e9+7;
string s;
char c[3]={'N','O','I'};
int n,m;
ll f[2][K][3],ans[M];
int g[M],sum1[M],nxt[3][K];

int cnt(int i) { return __builtin_popcount(i); }
void add(int i2,int j2,int i1,int j1,int k)
{
	if (k==0) f[i2][j2][1]=1ll*(f[i2][j2][1]+f[i1][j1][0]+f[i1][j1][1]+f[i1][j1][2])%MOD;
	else if (k==1)
	{
		f[i2][j2][2]=1ll*(f[i2][j2][2]+f[i1][j1][1])%MOD;
		f[i2][j2][0]=1ll*(f[i2][j2][0]+f[i1][j1][0]+f[i1][j1][2])%MOD;
	}
	else f[i2][j2][0]=1ll*(f[i2][j2][0]+f[i1][j1][0]+f[i1][j1][1])%MOD;
}
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);

	cin>>m>>n>>s; s=" "+s;
	memset(nxt,-1,sizeof nxt);
	f[0][0][0]=1;
	for (int i=1;i<=m;i++)
	{
	
	for (int j=0;j<(1<<n);j++)
		for (int k=0;k<3;k++)
		{
			if (nxt[k][j]!=-1) { add(1,nxt[k][j],0,j,k); continue; }
			char ch=c[k];
			int kk=0;
			for (int l=1;l<=n;l++)
			{
				sum1[l]=sum1[l-1]+((j>>(l-1))&1);
				if (s[l]==ch) g[l]=sum1[l-1]+1;
				else g[l]=max(g[l-1],sum1[l]);
			}
			for (int l=1;l<=n;l++) if (g[l]-g[l-1]) kk|=1<<(l-1);
			nxt[k][j]=kk;
			add(1,kk,0,j,k);
			for (int l=1;l<=n;l++) sum1[l]=0,g[l]=0;
		}
		for (int j=0;j<(1<<n);j++) f[0][j][0]=f[1][j][0],f[0][j][1]=f[1][j][1],f[0][j][2]=f[1][j][2];
		for (int j=0;j<(1<<n);j++) f[1][j][0]=f[1][j][1]=f[1][j][2]=0;
	}
		
	for (int i=0;i<(1<<n);i++) ans[cnt(i)]=1ll*(ans[cnt(i)]+f[0][i][0]+f[0][i][1]+f[0][i][2])%MOD;
	for (int i=0;i<=n;i++) cout<<ans[i]<<"\n";
	return 0;
}

P8352 [SDOI/SXOI2022] 小 N 的独立集

树上背包同款神秘复杂度

首先,内层最大独立集的 DP 式子是好想的。设 \(g_{u,0/1}\) 表示强制不选/选节点 \(u\) 的子树内最大独立集,转移方程为

\[g_{u,0}=\sum{max(g_{v,0},g_{v,1})},g_{u,1}=a_u+\sum{g_{v,0}} \]

那么外层 DP 只需将两个值存进去即可,即设 \(f(u,val_0,val_1)\) 表示 \(g_{u,0}=val_0,g_{u,1}=val_1\) 的方案数。注意到转移只关心 \(g_{u,0},max(g_{u,0},g_{u,1})\),所以可以令外层 DP 的 \(val_1\) 表示 \(max(g_{u,0},g_{u,1})\)

此时的状态数就高达 \(O(n^3k^2)\) 了,时空都过不去,考虑优化。

“容易”发现 \(val_1-val_0\leqslant k\),所以可以用差分的形式表示出 \(val_1\)。其实这确实挺容易发现的,当 \(val_0\geq val_1\) 的时候这个差值就为 \(0\)\(val_0<val_1\) 时因为级数的部分 \(val_0\) 是要大于 \(val_1\)的,所以两者的差值肯定不超过 \(a_u\),即不超过 \(k\)

没问题了,再说下滚动数组。这里的转移式子为

\[f'(u,i',j')\times f(v,i,j)\rightarrow f(u,i'+i+j,max(j'-j,0)) \]

这两维似乎并没有什么很明显的单调性。好吧这似乎有单调性。不管了,反正额外用一个数组记录 \(f'\) 再转移肯定没错,反正不卡常不要贪这点空间

接下来是时间复杂度。一共有 \(O(n^2k^2)\) 种状态,每种状态理论上会贡献 \(nk\) 次。但加上特判后状态总数和贡献次数总有一个不是满的,所以实际复杂度大概在 \(O(n^2k^3)\) 左右

为什么我代码这么慢啊
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=1003;
const int K=6;
const int MOD=1e9+7;
int n,k;
vector <int> tr[N];
int f[N][N*K][K],ans[N*K];
int g[N][N*K][K],siz[N];

void add(int &x,int y) { x=(x+y)%MOD; }
void dfs(int x,int _fa)
{
	for (int i=1;i<=k;i++) f[x][0][i]=1;

	int _size=tr[x].size(); siz[x]=1;
	for (int ii=0,v;ii<_size;ii++)
	{
		if ((v=tr[x][ii])==_fa) continue;
		dfs(v,x);
		for (int i=0;i<=siz[x]*k;i++)
		for (int j=0;j<=k;j++) g[x][i][j]=f[x][i][j],f[x][i][j]=0;
		for (int i=0;i<=siz[x]*k;i++)
		for (int j=0;j<=k;j++)
		{
			if (!g[x][i][j]) continue;
			for (int i_=0;i_<=siz[v]*k;i_++)
			for (int j_=0;j_<=k;j_++) add(f[x][i+i_+j_][max(0ll,j-j_)],(g[x][i][j]*f[v][i_][j_])%MOD);	
		}
		siz[x]+=siz[v];
	}
}
signed main()
{
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);

	cin>>n>>k;
	for (int i=1,u,v;i<n;i++)
	{
		cin>>u>>v;
		tr[u].push_back(v);
		tr[v].push_back(u);
	}

	dfs(1,0);
	for (int i=0;i<=n*k;i++)
	for (int j=0;j<=k;j++) ans[i+j]=(ans[i+j]+f[1][i][j])%MOD;
	for (int i=1;i<=n*k;i++) cout<<ans[i]<<"\n"; return 0;	
}

动态 DP

P3781 [SDOI2017] 切树游戏

树上 FWT+DDP。

首先,朴素 DP 为 \(f_{u,i}\) 表示强制选择 \(u\) ,其子树内异或和为 \(i\) 的非空连通子树个数;\(g_{u,i}\) 表示在 \(u\) 子树范围内异或和为 \(i\) 的非空连通子树个数。有转移方程

\[f(u,i)\leftarrow f'(u,i)+\sum_{j\oplus k=i} f'(u,j)\times f(v,k),g(u,i)=f(u,i)+\sum g(v,i) \]

异或卷积就上 FWT。设 \(F_u\) 为给 \(f_u\) 正变换之后的数组,\(G_u\) 同理,那么根据 FWT 的线性变换(\(FWT(a+b)=FWT(a)+FWT(b)\))就有

\[F_u\leftarrow F'_u+F'_u\times F_v,G_u=F_u+\sum G_v \]

\[F_{u}= a_u\times \prod(1+F_v),G_u=F_u+\sum G_v \]

其中 \(a_u\) 表示的是通过 \(v_u\) 赋初值后 FWT 的数组。因为这道题中带修,所以需要上 DDP

根据 DDP 的思想,将重儿子的贡献单独拎出来用矩阵转移,轻儿子的贡献塞到矩阵里。设 \(v'\) 为轻儿子,\(F0_u\)\(u\) 的轻儿子对 \(F_u\) 的贡献,\(G0_u\) 同,那么有转移方程

\[F0_u=\prod{(F_{v'}+1)},G0_u=\sum G_{v'} \]

\[F_u=a_u\times F0_u\times (F_{son_u}+1),G_u=F_u+G0_u+G_{son_u} \]

这个时候转移矩阵就很显然咯

\[\begin{pmatrix} a_u\times F0_u &0&a_u\times F0_u\\ a_u\times F0_u &1&a_u\times F0_u+G0_u\\ 0&0 &1 \end{pmatrix} \begin{pmatrix} F_{son_u}\\ G_{son_u}\\ 1 \end{pmatrix} = \begin{pmatrix} F_u\\ G_u\\ 1 \end{pmatrix} \]

直接线段树维护矩阵乘积后树剖即可。但在过程中有一些需要注意的点:

  1. 若直接开 \(3\times 3\) 的矩阵,再算上多项式 \(O(128)\) 的空间,此时线段树的空间复杂度就到达了 \(O(2608N)\),空间直接炸完。考虑优化矩阵,一番手模后发现矩阵乘法后一直会变的只有四个值,剩下的数都是 0/1 的常量,所以可以用四个值代替原本 \(\begin{pmatrix} a&0&b\\c&1&d\\0&0&1\end{pmatrix}\) 的矩阵

  2. 在撤销轻儿子对 \(F0\) 的贡献 \(F_{v'}+1\) 时,若其在模意义下为 \(0\) 无法通过逆元完成“撤销”。可以将每个数用 \(x\times 0^y\) 的形式表示,若 \(y\) 为零其值就是 \(x\),反之为零。若撤销的数 \(k\) 为零,那就给 \(y\) 的数量减一,反之就可以直接乘逆元(该说不说这确实很简洁方便,要是我自己想的话估计会 pass 掉若干没正确性或无敌难写的记录方法后选择用一个相对好写但还是很麻烦的方法搞)

究竟是我实现得太差还是树剖常数大?
#include <bits/stdc++.h>
using namespace std;
const int N=3e4+5;
const int MOD=10007;
int n,m,q,w[N];
vector <int> tr[N];
int son[N],fa[N],siz[N],inv[N];
int dfn[N],tme,idx[N],top[N],btm[N];

struct node
{
	int x,y;
	inline int num() { return (y?0:x); }
	inline void init(int k) { for (int i=0;i<128;i++) x=k,y=0; }
	node operator *(const int &k) const
	{
		node res={x,y};
		if (k) res.x=(res.x*k)%MOD;
		else res.y++; return res;
	}
	node operator /(const int &k) const
	{
		node res={x,y};
		if (k) res.x=(res.x*inv[k])%MOD;
		else res.y--; return res;
	}
}f0[N][128];
struct NODE
{
	int a[128];
	void init(int k) { for (int i=0;i<128;i++) a[i]=k; }
	NODE operator +(const NODE &b) const
	{
		NODE res;
		for (int i=0;i<128;i++) res.a[i]=(a[i]+b.a[i])%MOD;
		return res;
	}
	NODE operator +(const int &k) const
	{
		NODE res;
		for (int i=0;i<128;i++) res.a[i]=(a[i]+k)%MOD;
		return res;
	}
	NODE operator *(const NODE &b) const
	{
		NODE res; 
		for (int i=0;i<128;i++) res.a[i]=(1ll*a[i]*b.a[i])%MOD;
		return res;
	}
	NODE operator -(const NODE &b) const
	{
		NODE res;
		for (int i=0;i<128;i++) res.a[i]=(a[i]-b.a[i]+MOD)%MOD;
		return res;
	}
}g[N],g0[N],f[N],a[N];
struct Matrix
{
	NODE a,b,c,d;
	Matrix operator *(const Matrix k) const { return (Matrix){a*k.a,a*k.b+b,c*k.a+k.c,c*k.b+d+k.d}; }
};
struct Segment_Tree
{
	struct node { int l,r; Matrix sum; }tr[N<<2];
	NODE tmp;
	void push_up(int id) { tr[id].sum=tr[id<<1].sum*tr[id<<1|1].sum; }
	void upd(Matrix &aa,int x)
	{
		for (int i=0;i<128;i++) tmp.a[i]=(f0[x][i].num()*a[x].a[i])%MOD;
		aa.a=aa.b=aa.c=aa.d=tmp; aa.d=tmp+g0[x]; 
	}
	void build(int id,int l,int r)
	{
		tr[id].l=l,tr[id].r=r;
		if (l==r) { upd(tr[id].sum,idx[l]); return ; }
		int mid=(l+r)>>1;
		build(id<<1,l,mid),build(id<<1|1,mid+1,r);
		push_up(id);
	}
	void update(int id,int pos)
	{
		if (tr[id].l==tr[id].r&&tr[id].l==pos) { upd(tr[id].sum,idx[pos]); return ; }
		int mid=(tr[id].l+tr[id].r)>>1;
		if (pos<=mid) update(id<<1,pos);
		else update(id<<1|1,pos); push_up(id);
	}
	Matrix query(int id,int l,int r)
	{
		if (tr[id].l>=l&&tr[id].r<=r) return tr[id].sum;
		int mid=(tr[id].l+tr[id].r)>>1; 
		if (mid>=l&&mid+1<=r) return query(id<<1,l,r)*query(id<<1|1,l,r);
		else if (mid>=l) return query(id<<1,l,r);
		else if (mid+1<=r) return query(id<<1|1,l,r);
	}
}Tr;

void fwt_xor (int *a,int op)
{
	for (int i=2;i<=128;i<<=1)
	for (int p=i>>1,j=0;j<127;j+=i)
	for (int k=j;k<j+p;k++)
	{
		int p1=a[k],p2=a[k+p];
		a[k]=((p1+p2)%MOD*op)%MOD,a[k+p]=((p1-p2+MOD)%MOD*op)%MOD;
	}
}
void dfs1(int x,int _fa)
{
	fa[x]=_fa; siz[x]=1;
	int _size=tr[x].size(),v;
	for (int i=0;i<_size;i++)
	{
		if ((v=tr[x][i])==_fa) continue;
		dfs1(v,x); siz[x]+=siz[v];
		if (siz[v]>siz[son[x]]) son[x]=v;
	}
}
void dfs2(int x,int _top)
{
	top[x]=_top,dfn[x]=++tme,idx[tme]=x;
	if (!son[x]) btm[x]=x;
	else { dfs2(son[x],_top); btm[x]=btm[son[x]]; }

	a[x].a[w[x]]=1; fwt_xor(a[x].a,1);
	for (int i=0;i<128;i++) f0[x][i].x=1,f0[x][i].y=0;
	int _size=tr[x].size(),v;
	for (int i=0;i<_size;i++)
	{
		if ((v=tr[x][i])==fa[x]||v==son[x]) continue;
		dfs2(v,v); g0[x]=g0[x]+g[v];
		for (int j=0;j<128;j++) f0[x][j]=f0[x][j]*((f[v].a[j]+1)%MOD);
	}

	f[x]=a[x]*(f[son[x]]+1); 
	for (int i=0;i<128;i++) f[x].a[i]=(f[x].a[i]*f0[x][i].num());
	g[x]=f[x]+g0[x]+g[son[x]];
}
void _update(int x,int y)
{
	a[x].init(0); a[x].a[y]=1; fwt_xor(a[x].a,1);
	Tr.update(1,dfn[x]); int now=x;
	while (now)
	{
		now=btm[now];
		Matrix res=Tr.query(1,dfn[top[now]],dfn[now]);
		now=top[now];
		if (fa[now])
		{
			for (int i=0;i<128;i++) f0[fa[now]][i]=f0[fa[now]][i]/((f[now].a[i]+1)%MOD)*((res.b.a[i]+1)%MOD);
			g0[fa[now]]=g0[fa[now]]-g[now]+res.d; Tr.update(1,dfn[fa[now]]);
		}
		f[now]=res.b,g[now]=res.d; now=fa[now];
	}
}
int _query(int x) { NODE res=g[1]; fwt_xor(res.a,5004); return res.a[x]; }
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);

	cin>>n>>m;
	for (int i=1;i<=n;i++) cin>>w[i];
	for (int i=1,u,v;i<n;i++)
	{
		cin>>u>>v;
		tr[u].push_back(v);
		tr[v].push_back(u);
	}
	
	dfs1(1,0); dfs2(1,1); Tr.build(1,1,n);
	cin>>q; string op; int x,y; inv[1]=1;
	for (int i=2;i<MOD;i++) inv[i]=inv[i]=(MOD-MOD/i)*inv[MOD%i]%MOD;
	while (q--)
	{
		cin>>op>>x;
		if (op=="Query") cout<<_query(x)<<"\n";
		else { cin>>y; _update(x,y); }
	}
	return 0;
}
posted @ 2025-02-25 16:51  沄沄沄  阅读(80)  评论(3)    收藏  举报