动态规划

动态规划是把一类具有相同点的状态的一起处理,极大优化搜索。

dp复杂度一般\(≥O(状态数)\)

1.基础知识

1.1.解题思路

先设计好朴素的dp方程,列出状态表示→从“最后一步”考虑状态转移,若不能转移,考虑从“最后一步”和题目关键元素或限制补充状态表示→再从状态、转移上考虑优化,或者对原来的dp方程进行等价变形(\(e.g.\)所有第i层状态由第i-1层的某个状态转移过来,则可省略第i维。第i层的每个状态都由第i-1层的所有状态转移过来,则可省略除第i维外的所有维度)

1.2.阎氏dp分析法——从集合的角度思考问题

\[动态规划\begin{cases}状态表示f[i][j][...]\begin{cases}集合:(限制条件)的所有方法\\ 属性:Max/Min/方案数 \end{cases}\\ 状态计算——集合的划分 \end{cases} \]

集合划分依据(重要):1.别人推自己:“最后”:当前的状态是由哪个状态通过最后一步转移过来的;2.自己推别人:一个已知状态应该更新哪些后序阶段的未知状态。

集合划分原则:1.不遗漏。2.不重复(仅对集合属性是方案数有此要求)。

1.3.dp的实现方式

  • 若dp的转移是DAG:保证用到当前状态时在DAG上所有该状态的入边已经转移完全。
  • 若dp的转移有环:环形dp处理、分层图跑dijkstra或spfa、高斯消元……。

一般采用递推枚举dp的转移顺序。

若不能直接递推枚举dp的转移顺序:记忆化搜索、分层图跑dijkstra或spfa、dp套dp……。

1.3.1.递推实现

for(int i=1;i<=n;i++) f[i]=f[i-1];

1.3.2.递归实现——记忆化搜索

适用条件:

  • 会重复遍历某个状态。

    • 网格图上下左右走。
    • 图。

    降低复杂度为\(O(状态数)\)

  • 递推循环层数多。

int dp(int x)
{
    int &v=f[x];//注意别写错成int v=&f[x];
    if(v>=0) return v;//不要写v!=-1,因为-nan!=-1也为真会返回-nan!!!
    
    //abaabaaba
    v=dp(v);
    
    return v;
}

memset(f,-1,sizeof(f));
for(int i=1;i<=n;i++) dp(i);

1.4.dp求具体方案

1.4.1.路径追踪

\(pre[i][...]\):f[i][...]是由pre[i][...]转移而来的。

若转移的状态只有1个(\(e.g.\)\(f[i][...]=\max\limits_{j=1}^{i-1}f[j][...]\)\(f[i][...]\)是由\(f[j][...],j\in[1,i-1]\)其中的一个转移而来),则pre[i][...]是一个struct(记录状态的所有维度以及决策)数组。在转移过程中,一旦当前的转移条件成立时,记录从哪个状态转移而来。(\(e.g.\)最值转移中只要当前最值条件成立,当前先记录从哪个状态转移而来,如果后面出现更优的转移,则之前的记录会被覆盖)

若转移的状态有多个,则pre[i][...]是一个vector。在转移过程中,记录所有合法转移而来的状态。

执行完dp后从目标状态进行倒推,采用记忆化搜索,倒推路径是pre[i][...],记录决策。

1.4.2.状态倒推

适用条件:路径追踪的方法不适用或MLE时。

从目标状态倒推回初始状态的整个转移路径,如果出现某一状态等于另一个状态的转移,则记录这个决策为方案的一部分。

\(e.g.\)《动态规划2.3.10. 背包问题求具体方案 》

1.5.费用提前计算思想

当a→b的转移的费用为x+y,其中费用y会累加到b→...的转移的费用,且题目只关心最后的费用时,令a→b中状态b的费用=x+后面状态的数量*y。

1.6.启示

  1. 动态规划是全局视野的。dp要计算当前状态的所有值把握全局。

    \(e.g.\)关路灯:走到一个路灯计算一个路灯的耗电量是眼界狭隘的,应该走一步计算所有路灯的耗电变化量把握全局。

  2. 状态计算时,最重要的是集合划分,即状态之间最后一个不同点。e.g.AcWing 280.陪审团讲解视频

  3. 推理状态转移方程时,要看几个状态间什么是不变的(相同的),什么是变化的(不同的)。e.g.AcWing 280.陪审团讲解视频

  4. 循环顺序:阶段 -> 状态 -> 决策

  5. 如果f[]中不包含某个信息,就不必考虑该信息的后效性。e.g.AcWing 312.乌龟棋

  6. 有时可以通过额外的算法确定dp的计算顺序。e.g.预处理贪心排序。

  7. 数据结构优化dp:多维dp在执行内部循环时,把外部循环变量看作定值。状态转移取最优决策时,简单的限制条件用循环顺序处理,复杂的用数据结构维护。

  8. f[i][j]:把j放在内层循环常数会小。

  9. 把二元项拆成一元项,可以方便后面优化。

  10. 环形dp中,两点之间的路径有2条,对于一个点可以通过距离限定另一个点的选择范围强制令两点之间的路径只有一条。

  11. 对于“区间类”线性dp(\(e.g.\)\(\forall i\in[1,n],f[r[i]]=\max\{ f[l[i]-1] \}+(r[i]-l[i]+1)\)),把每个区间的左端点存在右端点的vector用于转移。

  12. \(f_{i,j}←f_{i-1,?}\Rightarrow f_{i,j}←f_{i,j-1}\),有时会有意想不到的优化。

    \(e.g.\)一个\(O(N)\)转移的式子\(f[i][j]=\sum\limits_{k=0}^j (f[i-1][k]*a^{j-k}*b)\)

    把j-1代入得:\(f[i][j-1]=\sum\limits_{k=0}^{j-1} (f[i-1][k]*a^{j-1-k}*b)\)

    所以\(f[i][j]=f[i][j-1]*a+f[i-1][j]*b\)。由此做到\(O(1)\)转移。

  13. 当前视野较难推知到全局视野:从全局逆推:\(f[i]\):从i到终点的……。记忆化搜索。

例题1:饼干

例题2:陪审团

2.线性dp

2.1.路径取数模型

特征:路径最值问题。

2.1.1.数字三角形

\(f[i][j]\):从(1,1)走到(i,j)的所有方案中,路径上的数字的和最大是多少。

状态转移:\(f[i][j]=\max(f[i-1][j-1]+a[i][j],f[i-1][j]+a[i][j])\):分别表示从左上角和上方最后一步走到(i,j)。

边界:\(f[1][1]=a[1][1]\)。另外转移时不能越过三角形的界限。

目标:\(\max\limits_{1≤j≤N} \{ f[N,j] \}\)

int n,ans=-INF;
int a[N][N];    //数字三角形
int f[N][N];    //f[i][j]:从(1,1)走到(i,j)的所有方案中,路径上的数字的和最大是多少

scanf("%d",&n);
for(int i=1;i<=n;i++)
    for(int j=1;j<=i;j++)
        scanf("%d",&a[i][j]);

memset(f,0,sizeof f);
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-1]+a[i][j],f[i-1][j]+a[i][j]);
        
for(int j=1;j<=n;j++) ans=max(ans,f[n][j]);
printf("%d\n",ans);

2.1.2.矩阵取数

具体状态表示及转移见代码。

  • 例题1:1取矩阵数
int a[N][N];    //方格数
int f[N][N];    //f[i][j]:从(1,1)走到(i,j)的所有方案中,路径上的数字的和最大是多少

scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)
    for(int j=1;j<=m;j++)
        scanf("%d",&a[i][j]);

memset(f,0,sizeof f);
for(int i=1;i<=n;i++)
    for(int j=1;j<=m;j++)
        f[i][j]=max(f[i][j-1],f[i-1][j])+a[i][j];
        
printf("%d\n",f[n][m]);
  • 例题2:2取矩阵数
int w[N][N];
int f[N*2][N][N];   //f[k][i][j]:2条路线同时走了k步,一条目前位于(i,k-i),另一条(j,k-j)

scanf("%d%d",&m,&n);
for(int i=1;i<=m;i++)
    for(int j=1;j<=n;j++)
        scanf("%d",&w[i][j]);

for(int k=1+1;k<=m+n;k++)   //从(1,1)到(m,n)
    for(int i=1;i<=m && k-i>=1;i++)
        for(int j=1;j<=m && k-j>=1;j++){
            /*
            f[k-1][i][...] -> f[k][i][...]表示从(i,(k-1)-i)即((i,k-i-1))走到(i,k-i),即向右走
            f[k-1][i-1][...] -> f[k][i][...]表示从(i-1,(k-1)-(i-1))即((i-1,k-i))走到(i,k-i),即向下走
            */
            f[k][i][j]=max(f[k][i][j],f[k-1][i-1][j-1]);
            f[k][i][j]=max(f[k][i][j],f[k-1][i-1][j]);
            f[k][i][j]=max(f[k][i][j],f[k-1][i][j-1]);
            f[k][i][j]=max(f[k][i][j],f[k-1][i][j]);

            f[k][i][j]+=w[i][k-i];
            if(i!=j) f[k][i][j]+=w[j][k-j]; //不能重复增加好感度
        }

printf("%d\n",f[m+n][m][m]);、
  • 例题3:k取矩阵数

    《图论.4..建图应用》

    图论

2.2.最长上升子序列模型

特征:序列最值问题(序列中的数会相互影响,含有偏序关系)。

计算当前节点时,先转移求最值,再加上自己的信息。

2.2.1.最长上升子序列模型\(O(N^2)\)

\(f[i]\):所有以a[i]为结尾的“最长上升子序列”的最大长度。

状态转移:\(f[i]=\max\limits_{0≤j<i,a[j]<a[i]} \{ f[j] \}+1\)

边界:\(f[i]=1\),只有a[i]一个数。

目标:\(\max\limits_{1≤i≤N} \{ f[i] \}\)

int a[N];//序列
int f[N];

scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);

for(int i=1;i<=n;i++)
{
    f[i]=1;
    for(int j=1;j<i;j++) if(a[j]<a[i]) f[i]=max(f[i],f[j]+1);
}

for(int i=1;i<=n;i++) ans=max(ans,f[i]);
printf("%d\n",ans);

2.2.2.应用

具体状态表示及转移见代码。

  • 例题1:最长上升子序列\(O(N \log N)\)

    二分查找在q中找>=a[i]的数中最小的一个,不存在返回len+1。

    大数往后面填,小数覆盖前面。

int a[N];   //序列
int q[N],len;   //最长上升子序列

scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);

for(int i=1;i<=n;i++)
{
    int l=1,r=len+1;    //len+1:给大数留个空
    while(l<r)  //在q中找>=a[i]的数中最小的一个,不存在返回len+1
    {
        int mid=(l+r)>>1;
        if(q[mid]<a[i]) l=mid+1;
        else r=mid;
    }
    len=max(len,r); //如果a[i]是大数二分返回len+1,更新答案
    q[r]=a[i];  //大数往后面填,小数覆盖前面
}
printf("%d\n",len);
  • 例题2:最长公共子序列
char a[N],b[N];
int f[N][N];    //f[i][j]:前缀子串a[1~i]与b[1~j]的“最长公共子序列”的长度

scanf("%d%d",&n,&m);
scanf("%s%s",a+1,b+1);

//边界:f[i,0]=f[0,j]=0
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);
    }
        
printf("%d\n",f[n][m]);
  • 例题3:最长先升后降子序列

    最长先升后降子序列=从前往后递推的最长上升子序列+从后往前递推的最长下降子序列-公共交点。

int f[N],g[N];  //从前往后递推的最长上升子序列和从后往前递推的最长下降子序列

for(int i=1;i<=n;i++)
{
    f[i]=1;
    for(int j=1;j<i;j++) if(a[j]<a[i]) f[i]=max(f[i],f[j]+1);
}

for(int i=n;i>=1;i--)
{
    g[i]=1;
    for(int j=n;j>i;j--) if(a[i]>a[j]) g[i]=max(g[i],g[j]+1);
}

for(int i=1;i<=n;i++) ans=max(ans,f[i]+g[i]-1); //还要减去公共交点i
printf("%d\n",ans);
  • 例题4:最大上升子序列和

    求一个序列所有上升子序列中子序列和的最大值。

    只需把最长上升子序列模型更改一下计算贡献的方式即可。

for(int i=1;i<=n;i++)
{
    f[i]=a[i];
    for(int j=1;j<i;j++) if(a[j]<a[i]) f[i]=max(f[i],f[j]+a[i]);
}
  • 例题5:最长公共上升子序列

    对于两个数列 A 和 B,如果它们都包含一段位置不一定连续的数,且数值是严格递增的,那么称这一段数是两个数列的公共上升子序列,而所有的公共上升子序列中最长的就是最长公共上升子序列了。求最长公共上升子序列的长度。

    题解

int n,ans;
int a[N],b[N];
int f[N][N];

int main(){
    scanf("%d",&n);
    for(int i=1;i<=n;i++) scanf("%d",&a[i]);
    for(int i=1;i<=n;i++) scanf("%d",&b[i]);

    for(int i=1;i<=n;i++){
        int ma=1;
        for(int j=1;j<=n;j++){
            f[i][j]=f[i-1][j];
            if(a[i]==b[j]) f[i][j]=max(f[i][j],ma);
            if(a[i]>b[j]) ma=max(ma,f[i-1][j]+1);
        }
    }

    for(int i=1;i<=n;i++) ans=max(ans,f[n][i]);
    printf("%d\n",ans);

    return 0;
}
  • 例题6:求序列\(A\)长度为\(M\)的严格递增子序列的个数

    求序列\(A\)长度为\(M\)的严格递增子序列的个数。

    \(f[i][j]\):前\(j\)个数以\(A_j\)结尾的数列,长度为\(i\)的严格递增子序列有多少个(\(i\)\(j\)均可作阶段)。

    特殊地,令\(A_0=-INF\)

    状态转移方程

const int INF=1<<30,MOD=1e9+7;

memset(f,0,sizeof f);
a[0]=-INF;

f[0][0]=1;
for(int i=1;i<=m;i++)
    for(int j=1;j<=n;j++)
        for(int k=0;k<j;k++)
            if(a[k]<a[j]) f[i][j]=(f[i][j]+f[i-1][k])%MOD;
            
int ans=0;
for(int i=1;i<=n;i++) ans=(ans+f[m][i])%MOD;

数据结构优化

树状数组维护前缀和。

在序列A(不包括\(A_0\))中的数值的值域上建立树状数组。

把外层循环i看作定值。当j增加1时,k的取值范围从0≤k<j变为0≤k<j+1,也就是多了一个k=j新决策。

设一个决策\((A_k,f[i-1,k])\)

  1. 插入一个新决策。在j增加1前,把\((A_j,f[i-1,j])\)插入集合:把\(A_k\)上的位置的值增加\(f[i-1,k]\)
  2. 给定一个值\(A_j\),查询满足\(A_k<A_j\)的二元组对应的\(f[i-1,j]\)的和:在树状数组计算\([1,A_j-1]\)的前缀和。
//树状数组维护前缀和
#include<bits/stdc++.h>
using namespace std;

const int N=1005,MOD=1e9+7;
int t,n,m,ans;
int a[N],f[N][N];   //f[i][j]:前i个数以Aj结尾的数列,长度为j的严格递增子序列有多少个(i、j均可作阶段)
int nums[N],cnt;    //离散化
int tr[N];  //树状数组维护前缀和

inline int lowbit(int x){
    return x&-x;
}

void add(int x,int v){
    while(x<=cnt){
        tr[x]=(tr[x]+v)%MOD;
        x+=lowbit(x);
    }
    return ;
}

int sum(int x){
    int res=0;
    while(x>0){
        res=(res+tr[x])%MOD;
        x-=lowbit(x);
    }
    return res;
}

int main(){
    scanf("%d",&t);
    for(int C=1;C<=t;C++){
        ans=0;
        scanf("%d%d",&n,&m);
        cnt=0;
        for(int i=1;i<=n;i++){
            scanf("%d",&a[i]);
            nums[++cnt]=a[i];
        }

        //离散化
        sort(nums+1,nums+cnt+1);
        cnt=unique(nums+1,nums+cnt+1)-nums-1;   //注意这里要-1
        for(int i=1;i<=n;i++) a[i]=lower_bound(nums+1,nums+cnt+1,a[i])-nums+1;


        for(int i=1;i<=n;i++) f[i][1]=1;
        for(int j=2;j<=m;j++){
            for(int i=1;i<=cnt;i++) tr[i]=0;
            for(int i=1;i<=n;i++){
                f[i][j]=sum(a[i]-1);
                add(a[i],f[i][j-1]);
            }
        }

        for(int i=1;i<=n;i++) ans=(ans+f[i][m])%MOD;

        printf("Case #%d: %d\n",C,ans);
    }
    return 0;
}
  • 例题7:求序列\(A\)长度大于等于2且满足\(\forall i,C_{a_i}^{a_{i+1}}\)是奇数的不上升子序列的个数

    保证1≤n≤211985,1≤a_i≤233333。所有的a_i互不相同。原题链接。

    \(C_{a_i}^{a_{i+1}}\)是奇数\(\Leftrightarrow\)\(C_{a_i}^{a_{i+1}}\bmod 2=1\)

    即卢卡斯拆位过程中不能出现\(C_0^1\)

    也就是说\(C_n^m\)中二进制下不存在某一位n为0而m为1,即二进制下m是n的子集。

    问题就变成了求子序列的个数,满足每一项在二进制下是前一项的子集。而且既然都是前一项的子集了,序列也一定是不上升的

    又因为a≤233333还互不相同,所以直接设\(f[i]\):以权值i结尾的合法子序列个数。枚举子集从自己转移给别人。

    代码链接。

  1. 把一个序列A变成非严格单调递增的,至少需要修改 序列总长度-A的最长不下降子序列长度 个数。
  2. 把一个序列A变成严格单调递增的,至少需要修改 构造序列B[i]=A[i]-i,序列总长度-B的最长不下降子序列长度 个数。
  3. 把一个序列A变成严格单调的,至少需要花费多少偏移量?
  4. 给定一个序列A,可以选择一个区间 [l,r],使下标在这个区间内的数都加一或者都减一,至少要修改 差分后的序列\(**\max(\sum|所有正数|,\sum|所有负数|)**\)$ \(次,且可以得到不同的序列\) |\sum|所有正数|-\sum|所有负数||+1$ 种。
  5. 求序列A的最大连续子段和:O(N)扫描该数列,不断把新的数加入子段,当子段和变成负数时,把当前的整个子段清空。扫描过程中出现过的最大子段和即为所求。
  6. Dilworth 定理(“覆盖”与“最值”的转换思想):最长上升子序列的长度=最小的用不上升子序列覆盖序列的子序列个数。

2.3.背包模型

特征:组合类最值问题(预选范围内的元素不会互相影响)。

背包的瓶颈在于体积,它是NPC问题。因此当题目的体积很大时,考虑题目中的特殊条件。

  • 生成函数。

2.3.1.基础知识

循环顺序:阶段 -> 状态 -> 决策。


|       |       |       ...     |       |
————————————————————————————————————————————————————》
体积    r       r+v     r+2v    ...     j-v     j

01背包

求所有前缀的最大值,故用一个变量维护就够了。

f[i][j]=max(f[i-1][j],f[i-1][j-v]+wi);

如果省略第i维,采用j正序循环,在更新f[i][j]前,f[i-1][j-v]已经被f[i][j-v]覆盖,但是j倒序循环就没有这个问题。故可用一维数组优化,j倒序循环。(如果用滚动数组优化,j正序循环)

完全背包

求所有前缀的最大值,故用一个变量维护就够了。

f[i][j]=max(f[i-1][j],f[i][j-v]+wi);

如果省略第i维,在更新f[i][j]前,可以保证f[i-1][j]f[i][j-v]没有被覆盖,故可用一维数组优化,j正序循环。

多重背包

求滑动窗口的最大值,故要用单调队列维护。

f[i][j]=max(f[i-1][j],f[i-1][j-v]+wi,f[i-1][j-2v]+2wi,...,f[i-1][j-siv]+siwi);

如果省略第i维,采用j正序循环,在更新f[i][j]前,f[i-1][j-nv]已经被f[i][j-nv]覆盖,且由于要配合单调队列j不能倒序循环,故只可用滚动数组优化,j正序循环。

2.3.2. 01背包 时间\(O(NM)\) 空间\(O(M)\)

每个物品可被选一次或不被选。

#include<bits/stdc++.h>
using namespace std;

const int N=1005;
int n,m;
int v[N],w[N];
int f[N];    //f[j]:背包中放入总体积不超过j的物品的最大价值和(物品的体积和<=j)

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d%d",&v[i],&w[i]);
    
    /*如果要求物品的体积和恰好等于j,则加上:
    memset(f,-0x3f,sizeof f);
    f[0]=0;
    */
    
    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]);
    
    printf("%d\n",f[m]);
    
    return 0;
}

2.3.2.1.应用

例题1

给定n(n≤100)个区间\([l_i,r_i]\)(\(0≤l_i≤r_i≤10^3,l\in Z\))和一个实数x,第i个区间可以在\([l_i,r_i]\)的范围内选1个数或者不选,求选出的数的总和在不超过x的情况下与x的差值的最小值。

选择1个区间,至少增加\(l_i\)的下界,并且还有\(len_i=r_i-l_i\)的“伸缩量”。

记l=增加量下界,len_l=增加量下界为l时所对应的某个伸缩量。

\(ans=min\{x,\max\limits_{l=0}^{x} \{l+len_l\} \}\)

那么对于每一个下界,其伸缩量越大越好。所以可以考虑动态规划。

f[i]:下界和为i的情况下的最大伸缩量。恰好下界保证是非负整数。

边界:f[0]=0,其他均为−∞。

转移:每个区间选或者不选,显然是01背包去做。把l[i]看作体积,len[i]看作价值。

\(ans=min\{x,\max\limits_{i=0}^{x} \{i+f[i]\} \}\)

注意到数据范围\(l_i≤250\),记\(fup=\sum\limits_{i=1}^n l_i\),dp第一维的上界是min(x,fup)。

练习题。

2.3.3. 完全背包 时间\(O(NM)\) 空间\(O(M)\)

每个物品可被选任意次。

#include<bits/stdc++.h>
using namespace std;

const int N=1005,INF=0x3f3f3f3f;
int n,m,ans=-INF;
int v[N],w[N];
int f[N];    //f[j]:背包中放入总体积不超过j的物品的最大价值和

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d%d",&v[i],&w[i]);

    /*如果要求物品的体积和恰好等于j,则加上:
    memset(f,-0x3f,sizeof f);
    f[0]=0;
    */

    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]);
    
    for(int j=1;j<=m;j++) ans=max(ans,f[j]);
    printf("%d\n",ans);
    
    return 0;
}

2.3.4. 多重背包

每个物品i最多可被选\(s_i\)件。

题解:彩色铅笔dalao Orz

2.3.4.1.单调队列优化 时间\(O(NM)\) 空间\(O(M)\)

#include<bits/stdc++.h>
using namespace std;

const int N=1005,M=20005;
int n,m;
int v[N],w[N],s[N];
int f[2][M];    //存的是价值
int q[M];   //单调队列优化,存的是f[(i-1)&1][j]中的j

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d%d%d",&v[i],&w[i],&s[i]);
    
    for(int i=1;i<=n;i++)
        for(int r=0;r<v[i];r++){    //r:r=j%v[i]
            int hh=0,tt=-1;
            for(int j=r;j<=m;j+=v[i]){
                while(hh<=tt && j-q[hh]>s[i]*v[i]) hh++;
                
                while(hh<=tt && f[(i-1)&1][q[tt]]+(j-q[tt])/v[i]*w[i]<=f[(i-1)&1][j]) tt--;
                q[++tt]=j;
                f[i&1][j]=f[(i-1)&1][q[hh]]+(j-q[hh])/v[i]*w[i];
            }
        }
    
    printf("%d\n",f[n&1][m]);
    
    return 0;
}

2.3.4.2.二进制拆分法 时间\(O(M*(logC_1+logC_2+...+logC_N))\) 空间\(O(M)\)

#include<bits/stdc++.h>
using namespace std;

const int N=12005,M=2005;
int n,m,idx;
int v[N],w[N];
int f[M];    //f[j]:背包中放入总体积为j的物品的最大价值和
int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++){
        int a,b,s;
        scanf("%d%d%d",&a,&b,&s);
        
        int k=1;
        while(k<=s){
            idx++;
            v[idx]=a*k;
            w[idx]=b*k;
            s-=k;
            k<<=1;
        }
        if(s>0){
            idx++;
            v[idx]=a*s;
            w[idx]=b*s;
        }
    }

    for(int i=1;i<=idx;i++)
        for(int j=m;j>=v[i];j--)
            f[j]=max(f[j],f[j-v[i]]+w[i]);

    printf("%d\n",f[m]);

    return 0;
}

2.3.5. 混合背包

一件物品可以选1、s、\(+\infty\)件。

把多重背包用二进制优化,这样就变成做多个01背包了。

于是问题就变成01背包和完全背包。

#include<bits/stdc++.h>
using namespace std;

const int N=1005;
int n,m;
int v[N],w[N],s[N];
int f[N];    //f[j]:背包中放入总体积为j的物品的最大价值和

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d%d%d",&v[i],&w[i],&s[i]);

    for(int i=1;i<=n;i++){
        //完全背包
        if(!s[i]) for(int j=v[i];j<=m;++j) f[j]=max(f[j],f[j-v[i]]+w[i]);
        
        //把多重背包用二进制优化
        //这样就变成做多个01背包了
        else{
            //01背包
            if(s[i]==-1) s[i]=1;
            
            //二进制优化
            for(int k=1;k<=s[i];k*=2){
                for(int j=m;j>=k*v[i];j--) f[j]=max(f[j],f[j-k*v[i]]+k*w[i]);
                s[i]-=k;
            }
            /*注意,不可以写成:
            for(int k=0;k<=9;k++) if((s>>k)&1) for(int j=m;j>=v*(1<<k);j--) f[j]=max(f[j],f[j-v*(1<<k)]+w*(1<<k));
            因为假设m=7v,s=8,该物品只会被拆成1个体积为8v价值为8w的物品,就什么也放不下,但是本来可以放7个该物品的
            */
            
            if(s[i]) for(int j=m;j>=s[i]*v[i];j--) f[j]=max(f[j],f[j-s[i]*v[i]]+s[i]*w[i]);
        }
    }
    
    printf("%d\n",f[m]);
    
    return 0;
}

2.3.6. 分组背包 时间\(O(NM)\) 空间\(O(M)\)

给定背包体积,若干个物品的体积、价值和组号,一个组内的物品只能选1件,求最大价值。

由于省略第一维i:前i个组,所以体积要倒序循环。

时间复杂度的计算:对于每一个组i(阶段):\(O(MS_1)+O(MS_2)+\cdots+O(MS_n)=O(M(S_1+S_2+\cdots+S_n))=O(MN)\)

#include<bits/stdc++.h>
using namespace std;

const int N=105;
int n,m;
int v[N][N],w[N][N],s[N];
int f[N];    //f[j]:背包中放入总体积为j的物品的最大价值和

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++){
        scanf("%d",&s[i]);
        for(int j=1;j<=s[i];j++)
            scanf("%d%d",&v[i][j],&w[i][j]);
    }

    for(int i=1;i<=n;i++)   //阶段
        for(int j=m;j>=0;j--)   //状态
            for(int k=1;k<=s[i];k++)    //决策
                if(v[i][k]<=j) f[j]=max(f[j],f[j-v[i][k]]+w[i][k]);
    
    printf("%d\n",f[m]);
    
    return 0;
}

2.3.7. 有依赖的背包(树形背包) \(O(NM^2)\)

树形背包复杂度大。如果:

  • “物品体积为1”时,应使用那个树形dp\(O(N^2)\),而不是复杂度更高的树形背包\(O(NM^2)\)
  • 森林中每棵树的节点都少时,枚举一棵树所有的方案分为一组,对森林进行分组背包\(O(NM)\)

1.f[u][j]:以u为根的子树从中选出了总体积为j的物品放入背包,物品的最大总价值之和。

1+.如果有森林,建立虚拟源点变为1棵树。

2.dfs遍历;

3.先把根节点的体积空出来,

采用分组背包的思想:把每个子树分组,从每个子树组中只能选一个子树的子树物品组f[][k](前面已经计算好了f[][k])。

for(i:循环每个组(子树)){
    int son=e[i];
        
    dfs(son);   //dfs遍历

    for(j:循环体积(注意把根节点的体积空出来))
        for(k:循环决策:挑组里的哪个物品(子树的子树物品组f[son][k]))
            f[u][j]=max(f[u][j],f[u][j-k]+f[son][k]);
}

4.最后把根节点的体积塞回去。

for(int i=m;i>=1;i--) f[u][i]=f[u][i-1]+w[u];
f[u][0]=0;
#include<bits/stdc++.h>
using namespace std;

const int N=105;
int n,m,root;
int v[N],w[N];
int h[N],e[N],ne[N],idx;
int f[N][N];    //f[u][j]:以u为根的子树从中选出了总体积为j的物品放入背包,物品的最大总价值之和

void add(int a,int b){
    e[++idx]=b;
    ne[idx]=h[a];
    h[a]=idx;
}

void dp(int u){
    for(int i=h[u];i!=0;i=ne[i]){   //循环物品组
        int son=e[i];
        
        dp(son);
        
        // 分组背包
        for(int j=m-v[u];j>=0;j--)  //循环体积,m-v[u]:把根节点的体积空出来
            for(int k=0;k<=j;k++)   //循环决策,注意这里要取等
                f[u][j]=max(f[u][j],f[u][j-k]+f[son][k]);
    }
    
    //将物品u加进去
    for(int j=m;j>=v[u];j--) f[u][j]=f[u][j-v[u]]+w[u];
    for(int j=0;j<v[u];j++) f[u][j]=0;
    
    return ;
}

int main(){
    scanf("%d%d",&n,&m);
    
    for(int i=1;i<=n;i++){
        int fa;
        scanf("%d%d%d",&v[i],&w[i],&fa);
        if(fa==-1) root=i;
        else add(fa,i);
    }
    
    dp(root);
    
    printf("%d\n",f[root][m]);
    
    return 0;
}

2.3.8. 二维费用背包 时间\(O(NVM)\) 空间\(O(VM)\)

#include<bits/stdc++.h>
using namespace std;

const int N=1005,K=105;
int n,V,M;
int v[N],m[N],w[N];
int f[K][K];    //f[j]:背包中放入总体积为j的物品的最大价值和

int main(){
    scanf("%d%d%d",&n,&V,&M);
    for(int i=1;i<=n;i++) scanf("%d%d%d",&v[i],&m[i],&w[i]);
    
    for(int i=1;i<=n;i++)
        for(int j=V;j>=v[i];j--)
            for(int k=M;k>=m[i];k--)
                f[j][k]=max(f[j][k],f[j-v[i]][k-m[i]]+w[i]);
    
    printf("%d\n",f[V][M]);
    
    return 0;
}

2.3.9. 背包问题求方案数 时间\(O(NM)\) 空间\(O(M)\)

以01背包问题求方案数为例:

01背包模型\(f[j]\)

正常的模板,不再赘述……

  1. 最优方案的方案数

    路径追踪\(g[j]\)

    参考于 彩色铅笔 dalao Orz

    状态表示\(g[j]\)—集合:当前已使用体积恰好是\(j\)的,且价值为最大的方案

    状态表示\(g[j]\)—属性:方案的数量\(Sum\)

    状态转移\(g[j]\)
    \(如果f[j]=f[j] 则 g[j]+=g[j]\)
    \(如果f[j]=f[j-v[i]]+w[i] 则 g[j]+=g[j-v[i]]\)

    初始状态:g[0][0] = 1

    小优化

    可以把 gf 写进一个循环里,再判断 f转移路径 的同时用 g 跟踪 转移路径

    然后再用 01背包朴素优化,把第一维消掉即可

#include<bits/stdc++.h>
using namespace std;

const int N=1005,MOD=1e9+7;
int n,m,ans;
int w[N],v[N];
int f[N],g[N];  //f[i]:背包中放入总体积为j的物品的最大价值和;g:路径跟踪,当前已使用体积恰好是j的,且价值为最大的方案总数

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d%d",&v[i],&w[i]);
    
    g[0]=1; //这步别忘记!
    for(int i=1;i<=n;i++)
        for(int j=m;j>=v[i];j--){
            int res,tot=0;
            
            //先备份以便比较
            res=max(f[j],f[j-v[i]]+w[i]);
            
            //追踪路径
            if(res==f[j]) tot=(tot+g[j])%MOD;
            if(res==f[j-v[i]]+w[i]) tot=(tot+g[j-v[i]])%MOD;
            
            //完毕后赋值
            f[j]=res,g[j]=tot;
        }
    
    //统计总的满足条件方案数
    for(int j=0;j<=m;j++) if(f[j]==f[m]) ans=(ans+g[j])%MOD;
    printf("%d\n",ans);
    
    return 0;
}
  1. 装满背包的方案数

    \(f[i]\):装满容量为i的背包的方案数。

    边界:\(f[0]=1\)

    做一遍类01背包。

#include<bits/stdc++.h>
using namespace std;

const int N=2010;
int n,m;
int v[N];
int f[N],g[N];

int main()
{
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d",&v[i]);
    
    f[0]=1;
    for(int i=1;i<=n;i++)
        for(int j=m;j>=v[i];j--)
            f[j]+=f[j-v[i]];
            
    printf("%d\n",f[m]);
    
    return 0;
}
- 例题:自然数组合

  给定 N 个正整数 A1,A2,…,AN,从中选出若干个数,使它们的和为 M,求有多少种选择方案。
int n,m,a[N],f[M];

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d",&a[i]);

    f[0]=1;
    for(int i=1;i<=n;i++)
        for(int j=m;j>=a[i];j--)
            f[j]+=f[j-a[i]];

    printf("%d\n",f[m]);

    return 0;
}
#### 拓展1:若去除第i件物品,求装满方案数?

方案数具有可减性:

$g[i]$:$f$的备份。
g[0]=1;
for(int i=1;i<=n;i++)
{
    for(int j=1;j<=m;j++)
    {
        if(j<w[i]) g[j]=f[j];
        else g[j]=f[j]-g[j-w[i]];//放入第i件物品的过程是f[j]+=g[j-w[i]],其中g去除了第i件物品,于是现在放入1个第i件物品。故逆过程是g[j]=f[j]-g[j-w[i]]
    }
    printf("%d\n",g[m]);
}
#### 拓展2:完全背包装满背包的方案数

$f[i]$:装满容量为i的背包的方案数。

边界:f[0]=1。

做一遍类完全背包。
f[0]=1;
for(int i=1;i<=n;i++)
    for(int j=v[i];j<=m;j++)
        f[j]+=f[j-v[i]];
printf("%d\n",f[m]);
- 例题1:自然数拆分

  给定一个自然数 N,要求把 N 拆分成若干个(≥2)正整数相加的形式。参与加法运算的数可以重复,拆分方案不考虑顺序,求拆分的方案数 mod2147483648 的结果。
f[0]=1;
for(int i=1;i<=n;i++)
    for(int j=i;j<=n;j++)
        f[j]=(f[j]+f[j-i])%MOD;

printf("%lld\n",((f[n]-1)%MOD+MOD)%MOD);//f[n]-1:拆分成>=2个正整数
- [例题2:NOIP2018提高组货币系统](https://www.acwing.com/problem/content/534/)

  手动模拟观察性质:

  1. $a_1,\dots,a_n $的数都能被$ b_1,\dots,b_m$ 表示出来。(显然成立)
  2. 在最优解中, $b_1,\dots,b_m$ 一定都是从 $a$中选出来的。
  3. $b_1,\dots,b_m $一定不能被其它 $b_i$ 表示出来。(显然成立)

  综合以上性质,我们得到了如下算法流程:

  1. 将 a从小到大排个序。
  2. 对于 a 中的每一项 $a_i$ ,判断其能否被$a_1\sim a_{i-1}$间的数表示(完全背包装满背包的方案数,f[a[i]]的方案数≥1就能被表示)。若能,则 $a_i$ 不必选,否则要选 $a_i$ 。
#include<bits/stdc++.h>
using namespace std;

const int N=110,M=3e4;
int t,n,ans;
int a[N];
int f[M];

int main()
{
    scanf("%d",&t);
    while(t--)
    {
        scanf("%d",&n);
        for(int i=1;i<=n;i++) scanf("%d",&a[i]);
        
        sort(a+1,a+n+1);
        
        ans=0;
        memset(f,0,sizeof f);
        f[0]=1;
        for(int i=1;i<=n;i++)
        {
            if(f[a[i]]==0) ans++;
            for(int j=a[i];j<=a[n];j++) f[j]+=f[j-a[i]];
        }
        
        printf("%d\n",ans);
    }
    return 0;
}

2.3.10. 背包问题求具体方案

以01背包问题求具体方案为例:

01背包模型\(f[i][j]\)

注意:由于本题路径追踪需要输出具体方案,故必须开二维状态\(f[i][j]\)

\(path[i]\):单纯记一下路径答案,而非状态转移

  1. 递推写法

    转载自 彩色铅笔 dalao Orz

    输出方案 其实就是输出方案的 转移路径

    先做一遍正常的 背包DP ,然后从 目标状态 倒推回 初始状态 的整个 转移路径 即可;

    说的白话一点就是,在考虑第 i 件物品时,选择了 还是 不选 的策略到达了第 i+1 件物品。

int v = V;  // 记录当前的存储空间

// 因为最后一件物品存储的是最终状态,所以从最后一件物品进行循环
for (从最后一件循环至第一件)
{
  if (g[i][v])
  {
    选了第 i 项物品;
    v -= 第 i 项物品的价值;
  } else
    未选第 i 项物品;
}
题目里还要求了输出 **字典序最小的方案**;

而在倒推 状态转移路径 的时候,只能在 分叉转移 的时候,即 当前 物品既可以 又可以 不选 时,优先
因此,我们本题的 背包DP 需要倒过来(从N递推到1)做,然后再 从1倒推回N 找出路径;
这样在抉择时,如果出现 分叉转移,我们就优先 当前物品即可。

#include<bits/stdc++.h>
using namespace std;

const int N=1005;
int n,m;
int v[N],w[N];
int f[N][N];    //f[i][j]:从前i种物品选出总体积为j的物品放入背包,物品的最大价值和
int path[N],pidx;   //记录答案

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d%d",&v[i],&w[i]);

    //01背包dp朴素做法
    for(int i=n;i>=1;i--)   //为了输出字典序最小的方案
        for(int j=0;j<=m;j++){  //01背包的朴素做法j是正序循环
            f[i][j]=f[i+1][j];
            
            //选了i
            if(j>=v[i]) f[i][j]=max(f[i][j],f[i+1][j-v[i]]+w[i]);   //i+1:因为i倒序循环
        }
    
    //从目标状态倒推回初始状态的整个转移路径 
    for(int i=1,j=m;i<=n;i++)
        if(j>=v[i] && f[i][j]==f[i+1][j-v[i]]+w[i]){    //选了i
            path[++pidx]=i;
            j-=v[i];
        }
    
    for(int i=1;i<=pidx;i++) printf("%d ",path[i]);
    puts("");
    
    return 0;
}
  1. 递归写法

    当然也可以从 拓扑图 的角度来 分析理解,具体可以参考 彩色铅笔dalao的博客:【分组背包+背包DP输出方案—拓扑图分析】 ,里面也有 递归\(**DFS**\)迭代 的写法(其实就是在跟踪状态转移的路径)

#include<bits/stdc++.h>
using namespace std;

const int N=1005;
int n,m;
int v[N],w[N];
int f[N][N];    //f[i][j]:从前i种物品选出总体积为j的物品放入背包,物品的最大价值和
int path[N],pidx;   //路径追踪

//从终点倒推回起点的整个拓扑图路径
void dfs(int i,int j,int last){
    if(i==0) return ;
    
    for(int a=last+1;a<=n;a++)
        if(j>=v[a] && f[a][j]==f[a+1][j-v[a]]+w[a]){    //选了a
            path[++pidx]=a;
            dfs(n-1,j-v[a],a);
            return ;
        }
    
    return ;
}

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d%d",&v[i],&w[i]);

    //01背包dp朴素做法
    for(int i=n;i>=1;i--)   //为了输出字典序最小的方案
        for(int j=0;j<=m;j++){  //01背包的朴素做法j是正序循环
            f[i][j]=f[i+1][j];
            
            //选了i
            if(j>=v[i]) f[i][j]=max(f[i][j],f[i+1][j-v[i]]+w[i]);   //i+1:因为i倒序循环
        }
    
    //从目标状态倒推回初始状态的整个转移路径 
    dfs(n,m,0);
    
    for(int i=1;i<=pidx;i++) printf("%d ",path[i]);
    puts("");
    
    return 0;
}

2.3.11附录

辅助感性理解

01背包朴素做法

朴素线性dp \(O(NM)\)

#include<bits/stdc++.h>
using namespace std;

const int N=1005;
int n,m;
int v[N],w[N];
int f[N][N];    //f[i][j]:从前i种物品选出总体积为j的物品放入背包,物品的最大价值和

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d%d",&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-1][j-v[i]]+w[i]);
        }
    
    printf("%d\n",f[n][m]);
    
    return 0;
}

滚动数组优化 时间\(O(NM)\) 空间\(O(M)\)

#include<bits/stdc++.h>
using namespace std;

const int N=1005;
int n,m;
int v[N],w[N];
int f[2][N];    //f[i][j]:从前i种物品选出总体积为j的物品放入背包,物品的最大价值和

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d%d",&v[i],&w[i]);

    for(int i=1;i<=n;i++)
        for(int j=0;j<=m;j++){
            f[i&1][j]=f[(i-1)&1][j];
            
            //放得下
            if(j>=v[i]) f[i&1][j]=max(f[i&1][j],f[(i-1)&1][j-v[i]]+w[i]);
        }
    
    printf("%d\n",f[n&1][m]);
    
    return 0;
}

完全背包朴素做法 \(O(NM)\)

#include<bits/stdc++.h>
using namespace std;

const int N=1005;
int n,m;
int v[N],w[N];
int f[N][N];    //f[i][j]:从前i种物品选出总体积为j的物品放入背包,物品的最大价值和

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d%d",&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]); //注意这里和01背包不同:由状态i转移过来
        }
    
    printf("%d\n",f[n][m]);
    
    return 0;
}

多重背包朴素做法:直接拆分法 时间\(O(M*(C1+C2+...+CN))\) 空间\(O(M)\)

#include<bits/stdc++.h>
using namespace std;

const int N=105;
int n,m,ans;
int v[N],w[N],s[N];
int f[N];    //f[j]:背包中放入总体积为j的物品的最大价值和

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d%d%d",&v[i],&w[i],&s[i]);
    
    for(int i=1;i<=n;i++)
        for(int k=1;k<=s[i];k++)
            for(int j=m;j>=v[i];j--)
                f[j]=max(f[j],f[j-v[i]]+w[i]);

    printf("%d\n",f[m]);

    return 0;
}

分组背包朴素做法 时间\(O(NM)\) 空间\(O(MS)\)

#include<bits/stdc++.h>
using namespace std;

const int N=105;
int n,m;
int v[N][N],w[N][N],s[N];
int f[N][N];    //f[i][j]:只从前i组物品中选,当前体积小于等于j的最大价值和

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++){
        scanf("%d",&s[i]);
        for(int j=1;j<=s[i];j++)
            scanf("%d%d",&v[i][j],&w[i][j]);
    }

    for(int i=1;i<=n;i++)   //阶段
        for(int j=m;j>=0;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]);
        }
    printf("%d\n",f[n][m]);
    
    return 0;
}

背包问题求方案数朴素做法 时间\(O(NM)\) 空间\(O(NM)\)

#include<bits/stdc++.h>
using namespace std;

const int N=1005,MOD=1e9+7;
int n,m,ans;
int w[N],v[N];
int f[N][N],g[N][N];  //f[i][j]:从前i种物品选出总体积为j的物品放入背包,物品的最大价值和;g:路径跟踪,从前i种物品中当前已使用体积恰好是j的,且价值为最大的方案总数

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++) scanf("%d%d",&v[i],&w[i]);
    
    //朴素01背包dp
    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]);
        }

    //追踪路径    
    g[0][0]=1; //这步别忘记!
    for(int i=1;i<=n;i++)
        for(int j=0;j<=m;j++){
            //可以由不选转移过来
            if(f[i][j]==f[i-1][j]) g[i][j]=(g[i][j]+g[i-1][j])%MOD;
            
            //这里不可以直接用else
            //可以由选转移过来
            if(j>=v[i] && f[i][j]==f[i-1][j-v[i]]+w[i]) g[i][j]=(g[i][j]+g[i-1][j-v[i]])%MOD;
        }
    
    //统计总的满足条件方案数
    for(int j=0;j<=m;j++) if(f[n][j]==f[n][m]) ans=(ans+g[n][j])%MOD;
    printf("%d\n",ans);
    
    return 0;
}

2.3.12.应用

将题意抽象为背包问题:→背包容积、→物品、→物品的体积、→物品的价值、→每种物品选几个、→求Max/Min/方案数\(\Rightarrow\)xx背包模型。

2.4.状态机模型

特征:不同状态之间有可以互相转移过程\(e.g.\)股票可以从买转移到卖,然后又从卖转移到买。而背包模型物品放入背包后就不会再拿出来了,没有可以互相转移的过程。

状态机强调过程,考虑从上一个时间点的各个状态转移到现在的状态。

\(f[当前阶段,状态]\)

边界:\(\begin{cases} 若合法:f[0,bool]=0 \\ 若不合法:f[0,bool]=-INF \end{cases}\)

1.4.1.股票买卖I

每天股票有不同的价格,不能同时参与多笔交易(即必须在再次购买前出售掉之前的股票),可以多次交易,求最大利益。

状态机模型如下:

\(f[i][bool]\):前i天且第i天是否持有股票状态的最大利益

状态转移:\(\begin{cases} f[i][0]=\max(f[i-1][0],f[i-1][1]+w[i]) \\ f[i][1]=\max(f[i-1][0]-w[i],f[i-1][1]) \end{cases}\)

边界:\(f[0][0]=0\),\(f[0][1]=-INF\)

答案:显然第n天一定是卖出股票才能得到最大利益:\(f[n][0]\)

scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&w[i]);
f[0][0]=0,f[0][1]=-INF;
for(int i=1;i<=n;i++)
{
    f[i][0]=max(f[i-1][0],f[i-1][1]+w[i]);
    f[i][1]=max(f[i-1][0]-w[i],f[i-1][1]);
}
printf("%d\n",f[n][0]);//第n天一定是卖出股票才能得到最大利益

1.4.2.股票买卖II

每天股票有不同的价格,不能同时参与多笔交易(即必须在再次购买前出售掉之前的股票),最多k次交易,每笔交易都有f的手续费,求最大利益。

“最多k次交易”:再开一维\(f[i,j,bool]\):前i天,完成的完整交易数是j,且第i天是否持有股票状态的最大利益。

“每笔交易都有f的手续费”:直接把f加入方程,当状态0→1或1→0增加手续费。

1.4.3.股票买卖III

每天股票有不同的价格,不能同时参与多笔交易(即必须在再次购买前出售掉之前的股票),卖出股票后进入1天冷冻期无法在第二天买入股票,可以多次交易,求最大利益。

多了一个“冷冻期”状态不要紧,用状态机在前一天3个状态进行转移。

\(f[i][bool]\):前i天且第i天是否持有股票状态的最大利益

状态转移:\(\begin{cases} f[i][0]=\max(f[i-1][0],f[i-1][2]) \\ f[i][1]=\max(f[i-1][0]-w[i],f[i-1][1]) \\ f[i][2]=f[i-1][1]+w[i] \end{cases}\)

边界:\(f[0][0]=0\),\(f[0][1]=f[0][2]=-INF\)

答案:显然第n天一定是卖出股票才能得到最大利益:\(\max(f[n][0],f[n][2])\)

2.5.区间dp\(O(N^3)\)

适用条件:连续+合并。

阶段:区间长度。状态:区间的左、右端点。决策:划分区间的方法,分界点。

转移:由2个(或若干个)组成它的区间(显然比它更小(已算出dp值)且包含于它)所代表的状态转移而来。难点在于枚举分界点。

边界:长度为1的“元区间”。

同时为了快速计算区间,一般预处理前缀和。

答案:

  • 如果问的是将多个元素合并为一个元素,最终那一个元素的最值:输出f[1][n]。
  • 如果问的是合并中出现的最值:用\(max\)把转移过程中的所有最大值全部记录下来。输出\(max\)

2.5.1.区间dp——区间石子合并

\(f[l][r]\):把区间第l堆和第r堆石子合并成一堆需要消耗的最小体力。

状态转移:\(f[l][r]=\min\limits_{l≤k<r} \{ f[l][k]+f[k+1][r] \} + \sum\limits_{i=l}^{r} a[i]\)

边界:\(\forall l \in [1,N],f[l][l]=0\),其余为正无穷。

目标:\(f[1][N]\)

scanf("%d",&n);
for(int i=1;i<=n;i++){
    scanf("%d",&m[i]);
    f[i][i]=0;//长度为1的“元区间”
    sum[i]=sum[i-1]+m[i];//预处理前缀和
}

memset(f,0x3f,sizeof f);
for(int len=2;len<=n;len++/*阶段*/)
    for(int l=1;l+len-1<=n;l++/*状态:左端点*/){
        int r=l+len-1;//状态:右端点
        for(int k=l;k<r;k++/*决策。注意是左闭右开,因为两个区间是l~k、k+1~r*/) f[l][r]=min(f[l][r],f[l][k]+f[k+1][r]);
        f[l][r]+=sum[r]-sum[l-1];
    }

printf("%d\n",f[1][n]);

2.5.2.区间dp求具体方案

追踪数组\(g[l][r]\):存储使f[l][r]=calc(f[l][k],f[k+1][r])有最值的k。

输出方案:

void dfs(int l,int r)
{
    if(l>r) return ;
    printf("%d ",g[l][r]);
    dfs(l,g[l][r]);
    dfs(g[l][r]+1,r);
    return ;
}

for(int l=1;l<=n;l++) g[l][l]=0;
dfs(1,n);

2.5.3.区间dp——染色问题

设计状态:

  • 要求染成给定的颜色:\(f[l][r]\):把[l,r]染成给定的颜色的最小代价。
  • 要求染成同一种颜色:\(f[l][r]\):把[l,r]染成同一种颜色a[r]的最小代价。

状态转移方程:\(f[l][r]=\min\{f[l][k]+f[k+1][r]+[真值表达式]\}\)

优化:可以把真值表达式拆成2个方程:\(f[l][r]=\min\{f[l+1][r],f[l][r-1]\}+?,f[l][r]=\min\{f[l][k]+f[k+1][r]\}+?\)

2.5.3.区间dp与树的序列

树形结构的dp可以通过树的序列(下面以二叉树的中序遍历为例)用区间dp解决。

\(f[l][r]\):所有中序遍历是[l,r]这一段的二叉树的集合。

划分区间时:k:当前子树的根;[l,k-1]:左子树;[k+1,r]:右子树。

于是循环决策k时,闭区间:for(int k=l;k<=r;k++)

当kl时,左子树为空;当kr时,右子树为空。

目标:整颗树f[l][r]。

2.5.4.二维区间dp

平面dp。

\(f[x_1][y_1][x_2][y_2]\):子矩阵(x1,y1)(x2,y2)……

划分方法:分成两个子矩阵:(x1,y1)(i,y2)+(i+1,y1)(x2,y2)或(x1,y1)(i,y2)+(i+1,y1)(x2,y2)或(x1,y1)(x2,i)+(x1,i+1)(x2,y2)或(x1,y1)(x2,i)+(x1,i+1)(x2,y2)。

预处理二维前缀和。

由于循环层数太多,采用记忆化搜索。

  • 例题:NOI1999棋盘分割
#include<bits/stdc++.h>
using namespace std;

const int N=15,M=9;
const double INF=1e9;
int n;
int s[M][M];
double f[M][M][M][M][N];
double X;

int get_sum(int x1,int y1,int x2,int y2)
{
    return s[x2][y2]-s[x1-1][y2]-s[x2][y1-1]+s[x1-1][y1-1];
}

double get(int x1,int y1,int x2,int y2)
{
    double sum=get_sum(x1,y1,x2,y2)-X;
    return 1.0*sum*sum/n;
}

double dp(int x1,int y1,int x2,int y2,int k)
{
    double &v=f[x1][y1][x2][y2][k];
    if(v>=0) return v;
    if(k==1) return v=get(x1,y1,x2,y2);
    
    v=INF;
    for(int i=x1;i<x2;i++)
    {
        v=min(v,get(x1,y1,i,y2)+dp(i+1,y1,x2,y2,k-1));
        v=min(v,dp(x1,y1,i,y2,k-1)+get(i+1,y1,x2,y2));
    }
    for(int i=y1;i<y2;i++)
    {
        v=min(v,get(x1,y1,x2,i)+dp(x1,i+1,x2,y2,k-1));
        v=min(v,dp(x1,y1,x2,i,k-1)+get(x1,i+1,x2,y2));
    }
    
    return v;
}

int main()
{
    scanf("%d",&n);
    for(int i=1;i<=8;i++)
        for(int j=1;j<=8;j++)
        {
            scanf("%d",&s[i][j]);
            s[i][j]+=s[i-1][j]+s[i][j-1]-s[i-1][j-1];
        }
    
    X=1.0*s[8][8]/n;
    
    memset(f,-1,sizeof f);
    printf("%.3lf\n",sqrt(dp(1,1,8,8,n)));
    
    return 0;
}

2.6.类Floyd模型\(O(N^3)\)

适用条件:求2点间满足特殊要求/特殊计算代价方式的最短路。

\(f_{k,i,j}\):经过若干个编号不超过k的节点(不包括起终点),从i到j的最短路长度。

状态转移:\(f_{k,i,j}=min(f_{k-1,i,j},f_{k-1,i,k}\oplus f_{k-1,k,j})\)

边界:\(f_{0,i,j}=d_{i,j}\)。其中d为邻接矩阵。

第一维可以省略。

2.6.1.计算代价的方式是路径上编号的最值\(O(N^3+\log N)\)

原最值问题较难求解,应转化为二分+可行问题

\(f_{k,i,j}\):布尔数组,经过若干个编号不超过k的节点(不包括起终点),从i到j是否可达。

状态转移:\(f_{k,i,j}=f_{k-1,i,j}|(f_{k-1,i,k}\&f_{k-1,k,j})\)

使用bitset优化:

bitset<N> f[N][N];

for(int i=1;i<=n;i++) for(int j=1;j<=n;j++) if(d[i][j]) f[0][i][j]=1;//边界

for(int k=1;k<=n;k++)
    for(int i=1;i<=n;i++)
    {
        f[k][i]=f[k-1][i];
        if(f[k-1][i][k]) f[k][i]|=f[k-1][k];
    }

边界:\(f_{0,i,j}=d_{i,j}\)。其中d为邻接矩阵。

k从小到大二分找到第一个\(f_{k,s,t}\)为true的k,即为答案。

注意dp数组不包括起终点的信息,因此最终答案要根据题意判断是否对起终点的编号取最值。

2.6.2.类Floyd与广义矩阵乘法

《图论4.2.3.1.恰好经过k条边》

2.7.序列·插入·连续段模型\(O(N^2)\)

适用条件:序列问题。当且仅当i,j相对大小关系确定时,(i,j)的贡献可拆开i,j独立计算;或转移要求满足相邻左右相对大小关系。

将原题意转化为将n个元素\(a_i\)从小到大依次插入n个空位,当前相邻的元素形成1个连续段。插入实际上是插空法,“空”:空隙,而不是具体第几个空位。连续段之间钦定有序。

从小到大插入的意义:插入的元素>之前已经插入的元素,插入的元素<还没有插入的元素。这样当插入元素时,就已经知道了它与左右相邻元素的相对大小关系,可以独立确定i的贡献。

下面以dp求方案数为例:

\(f_{i,j,d}\):从小到大插入了i个元素,形成了j个连续段,确定了d(\(d\in[0,2]\))个起/终点(空位的最左/右边)。

转移:

要注意特殊处理起/终点。

  1. \(a_i\)插入到某个连续段的两边之一。

    1. \(a_i\)不作为起/终点。

      \(f_{i,j,d}←f_{i-1,j,d}*(2*j-d)\)(2j-d):除了起/终点的左/右边不能插入,\(a_i\)可选择每个连续段的左/右边中的一个插入。

    2. \(a_i\)作为起/终点。

      \(f_{i,j,d}←f_{i-1,j,d-1}*(2-(d-1))\)

  2. \(a_i\)插空当前单独作为新的连续段。

    1. \(a_i\)不作为起/终点。

      \(f_{i,j,d}←f_{i-1,j-1,d}*(j-d)\)。*(j-d):除了起/终点的左/右边不能插空,原来j-1个连续段有j个空供\(a_i\)插空。

    2. \(a_i\)作为起/终点。

      \(f_{i,j,d}←f_{i-1,j-1,d-1}*(2-(d-1))\)

  3. \(a_i\)插入到两个连续段之间,合并两个连续段成为一个大连续段。

    \(f_{i,j,d}←f_{i-1,j+1,d}*j\)。*j:原来j+1个连续段之间有j个空供\(a_i\)合并。

边界:\(f_{0,0,0}=1\)

\(ANS=f_{n,1,2}\)

注意要特判n=1时的情况,因为当n=1时1个点同时作为起/终点。

2.8.拍卖会模型

3.树形dp

  1. 给定一棵无根树,我们可以任选一个节点为根。

  2. 一般以节点从深到浅(子树从小到大)的顺序作为dp的“阶段”。状态表示中第一维通常是节点编号(以u为根节点的子树)。

    \(f[u]\):以u为根节点的子树。

  3. 对于每个点,假设其是叶子节点,为其设定正确的边界条件(初值),然后一般可以自然处理不合法情况。

  4. 状态转移:考虑一条树边(u,v)。

    • 树形dp和线性dp在转移上有本质上的不同。

      树形dp

//先决定点u上的物品
siz[u]=1;
f[u][0]=//不选择点u上的物品
f[u][1]=//选择点u上的物品

f[u][j+k/*注意这里不需要考虑点u是否选择,因为在上面已经考虑过了*/]=max(f[u][j+k],f[u][j]+f[v][k]);

  线性dp
f[u][k]=max(f[u][k],f[v][k]);//不选择点u上的物品
f[u][k+1]=max(f[u][k+1],f[v][k]);//选择点u上的物品
在回溯时,从子结点向节点x进行状态转移。
  • 当点u在其的当前层时,f[u]表示点u与已合并的子树的信息。故f[u]的边界(初始值)表示单独一个点u的信息。
  • 当点u即将回溯时,由于所有的子树信息都已经合并到点u,故此时f[u]才真正表示设计状态时的定义:以u为根的子树……

技巧

  • 子树与子树间计算贡献,使用一次把子树加入点u,每次把新加的子树和已经加入的子树(信息在点u上)计算贡献\(O(N)\)。而不是枚举子树与子树\(O(N^2)\)
  • 计算方案数时,对于同一个v:加法原理;不同的v:乘法原理。
  • f[v]不会考虑点u的状态。
  • 有些树形结构的dp可以通过树的序列用区间dp解决,参见《动态规划2.5.3.区间dp与树的序列》。

3.1.树形dp——求树的直径

DP的实质就是从集合的角度把每个方案归类,统一考虑。这里我们采取按点分类的方法。具体来说,我们任选一个点作为根节点,使这棵树成为一棵有根树。对于每个点 u ,点 u 及其子树的直径有两种情况:1.直径没有经过点 u;2.直径经过点 u 。

对于第 1 种情况,我们直接枚举 u 的每棵子树的直径再求个 max ;对于第 2 种情况,我们求出子树中所有节点到 u 的路径,取最大的两个相加即可。最后两种情况得到的答案再取一个 max就是 u 子树直径的最终答案。

int d[N];   //d[i]:从i出发以i为根的子树,能到达最远的距离
void dp(int u,int fa)
{
    for(int i=h[u];i!=0;i=ne[i])
    {
        int j=e[i];
        if(j==fa) continue;
        dp(j,u);
        
        //注意下面的代码两行顺序不能反!!!
        length=max(length,d[u]+d[j]+w[i]);
        d[u]=max(d[u],d[j]+w[i]);
    }
    return ;
}

3.2.树形dp与合并子树信息模型(size和m剪枝)\(\min \{O(N^2),O(NM)\}\)

适用条件:1.\(f[u][i]\):以u为根的子树且选了i个物品的最值。2.常见树形dp,树形计数dp;3.信息可被拆分到子树。

若有依赖关系——结合状态机模型:\(f[u][i][bool]\):以u为根的子树、选了i个物品且点u上的物品有没有选的最值。

  • 当点u在其的当前层时,f[u]表示点u与已合并的子树的信息。故f[u]的边界(初始值)表示单独一个点u的信息。

    边界:先决定点u上的物品。f[u][0]=不选择点u上的物品,f[u][1]=选择点u上的物品。

  • 当点u即将回溯时,由于所有的子树信息都已经合并到点u,故此时f[u]才真正表示设计状态时的定义:以u为根的子树……

子树依次向其父亲节点合并信息。

使用siz[u](以u为根的子树大小)剪枝以保证复杂度。

时间复杂度

\(O(N^2)\):siz[u]在加入子树v前先进行转移,相当于是统计点对(a,b),而点对(a,b)一共有\(O(N^2)\)个,且只在lca(a,b)被统计一次。因此复杂度是\(O(N^2)\)

  • \(O(NM)\)

不能高效换根。

int siz[N];//siz[u]:以u为根的子树大小
int f[N][N];    //f[u][i]以u为根的子树且选了i个物品的最大价值
int backup[N];//备份数组

void dfs(int u,int fa)
{
    //先决定点u上的物品
    siz[u]=1;
    f[u][0]=//不选择点u上的物品
    f[u][1]=//选择点u上的物品
    
    for(int i=h[u];i!=0;i=ne[i])
    {
        int v=e[i];
        if(v==fa) continue;
        dfs(v,u);
        
        //备份数组是因为f[u][j+k]的信息会更新
        //注意更新到下标为siz[u]为止,否则复杂度将不能保证
        for(int j=0;j<=siz[u];j++)
        {
            backup[j]=f[u][j];
            f[u][j]=0;//计数类问题赋成0,最值问题赋成无穷大/小
        }
        //m是题目问题中“树上选择m个物品的最大价值”中的m
        for(int j=0;j<=min(m,siz[u])/*此处的siz[u]是加入子树v前的大小,两个元素取min缺一不可*/;j++)
            for(int k=0;k<=min(m-j,siz[v]);k++)
                f[u][j+k]=max(f[u][j+k],backup[u][j]/*注意这里使用backup*/+f[v][k]);
                
        siz[u]+=siz[v];//注意这里才加上siz[v]!!!
    }
    
    return ;
}

//恰好选m个
printf("%d\n",f[root][m]);

//最多选m个
int ans=-INF;
for(int i=0;i<=m;i++) ans=max(ans,f[root][i]);
printf("%d\n",ans);

3.3.树形dp与背包模型\(O(NM^2)\)

采见《动态规划2.3.7. 有依赖的背包(树形背包) 》

注意

  1. 当森林中每棵树的节点都少时,枚举一棵树所有的方案分为一组,对森林进行分组背包\(O(NM)\)
  2. 树形背包采用树上选物品模型,只能减小常数,不能减小时间复杂度为\(\min \{O(N^2),O(NM)\}\)。因为一个物品有了体积的维度,根据树上选物品模型的时间复杂度分析,此时时间复杂度最坏仍为\(O(NM^2)\)

3.4.树形dp与状态机模型

特征:子结点的状态会影响父节点的状态。

\(f[u][bool]\):以u为根的子树,且节点u处于bool状态……

递归回溯时,将当前节点不同的状态由对应的子结点的状态转移过来。

3.4.1.父子相互制约模型

特征:父节点的状态和子节点的状态相互制约影响。

下面以“定义选择一个点,与这个点直接相连的点都会被染色。在树上选择最少的点,使得所有点都被染色。”为例。

当前节点u有3个状态:f[u][0]:当前节点的父节点被选择,当前节点不用被选择;f[u][1]:当前节点的其中一个子结点被选择,当前节点不用被选择。f[u][2]:当前节点被选择;

赋上边界条件(初值):\(f_{i,2}=1\)

其中值得注意的2个地方是:1. 状态0虽然信息与父节点有关,但是仍然是由子结点转移过来。2. f[v]不会考虑点u的状态。

\(f_{i,0}=\sum\limits_{j\in son_i}\min(f_{j,1},f_{j,2})\)

\(f_{i,1}=\min\limits_{k\in son_i}(f_{k,2}+\sum_{j\in son_i-\{k\}}\min(f_{j,1},f_{j,2}))\)

\(f_{i,2}=\sum\limits_{j\in son_i}\min(f_{j,0},f_{j,1},f_{j,2})\)

3.4.2.不相交链模型

适用条件:从树上选出若干条不相交的链。

\(f[u][i][0\sim 2]\):以u为根的子树中,选了i条链,且

  • 0:点u还没开始接链不计入链的数量。

    当在点u的阶段时,该状态为未选点u

    当点u作为其他点的儿子时,该状态不用于转移。(因为此时其含义与状态2:点u不会再与其他点接了是有重叠的,在回溯时把这种情况归为状态2)

  • 1:点u正在接链不计入链的数量。

    当在点u的阶段时,该状态为1条正在接的直链不包含单独一点u成链(因为点u在2=1+1的转移时会导致单独一点u成链+点v正在接的链误转移到点u接1条拐弯的链,但是单独一点u成链+点v正在接的链=点u正在接1条直链)。

    当点u作为其他点的儿子时,该状态额外包含单独一点u成链

  • 2:点u接完了已闭合的链,不会再与其他点接了。计入链的数量。

    当在点u的阶段时,该状态为1条拐弯的链

    当点u作为其他点的儿子时,该状态额外包含:不选点u、1条已闭合的直链。

边界:f[u][0][0]=初值。其他都赋为不合法。

状态转移:

“选了i条链”:配合树上选物品模型做到\(\min \{O(N^2),O(NM)\}\)

  • 当在点u的阶段时,对于一条边\((u,v)\)注意使用备份数组!

    \(f[u][j+k][0]←f[u][j][0]+f[v][k][2]\)

    \(f[u][j+k][1]←f[u][j][1]+f[v][k][2]\)

    \(f[u][j+k][1]←f[u][j][0]+f[v][k][1]\)

    \(f[u][j+k][2]←f[u][j][2]+f[v][k][2]\)

    \(f[u][j+k+1][2]←f[u][j][1]+f[v][k][1]\)

  • 当回溯时,即点u将作为其他点的儿子时,f[u]将额外包含一些状态:注意下面的顺序!

    \(f[u][i][1]←f[u][i][0]\),单独一点u成链。

    \(f[u][i][2]←f[u][i][0]\),不选点u。

    \(f[u][i+1][2]←f[u][i][1]\),注意此时的f[u][i][1]是在\(f[u][i][1]←f[u][i][0]\)更新之后的f[u][i][1]。

答案:\(ans=f[root][i][2]\)

  • 例题:求在n个点的树上选择k条不相交的链的方案数。
void dfs(int u,int fa)
{
    siz[u]=1;
    f[u][0][0]=1;
    for(int i=h[u];i!=0;i=ne[i])
    {
        int v=e[i];
        if(v==fa) continue;
        dfs(v,u);
        for(int j=0;j<=siz[u];j++)
        {
            memcpy(backup[j],f[u][j],sizeof f[u][j]);
            memset(f[u][j],0,sizeof f[u][j]);
        }
        for(int j=0;j<=siz[u];j++)
            for(int k=0;k<=siz[v];k++)
            {
                add(f[u][j+k][0],backup[j][0]*f[v][k][2]%MOD);
                add(f[u][j+k][1],backup[j][1]*f[v][k][2]%MOD);
                add(f[u][j+k][1],backup[j][0]*f[v][k][1]%MOD);
                add(f[u][j+k][2],backup[j][2]*f[v][k][2]%MOD);
                if(j+k+1<=siz[u]+siz[v]) add(f[u][j+k+1][2],backup[j][1]*f[v][k][1]%MOD);
            }
        siz[u]+=siz[v];
    }
    for(int i=0;i<=siz[u];i++)
    {
        add(f[u][i][1],f[u][i][0]); //当点u作为其他点的儿子时,点u正在接链包含单独一点u成链
        add(f[u][i][2],f[u][i][0]); //当点u作为其他点的儿子时,点u接完了已闭合的链(不会再与其他点接了)包含点u还没开始接链(不选点u)
        if(i+1<=siz[u]) add(f[u][i+1][2],f[u][i][1]);   //当点u作为其他点的儿子时,点u接完了已闭合的链(不会再与其他点接了)包含点u接了1条已闭合的直链
    }
    return ;
}

printf("%lld\n",f[n][k][2]);

3.5.换根dp(二次扫描与换根法)

特征:给定一棵无根树,需要计算以每个节点为根的整棵树的信息。

  1. 先考虑直接选点1为根,如何做具有可逆性的(不可逆性的则需要额外的技巧,见下文。)dp(\(f[u]\):以u为根的子树……。若点u是整棵树根节点,则以u为根的子树即为以u为根的整棵树。)使得能求出点1的答案。
  2. 下面分为三种类型。一道题目可能会同时用到多种类型。
    • 类型一

      init(u):初始化:假设点u是叶子节点,为其设定正确的边界条件(初值)。

      merge(u,v):正常树形dp的转移:从以儿子v为根的子树到以u为根的子树的状态转移。

      split(u,v)merge(u,v)的逆操作:从已执行“从以儿子v为根的子树到以u为根的子树的状态转移”,还原到未执行其。

      若`merge(u,v)`的操作序列为$\{A_1,\cdots,A_x\}$,则`split(u,v)`的操作序列为$\{A_x^{-1},\cdots,A_1^{-1}\}$。
      
      #### 不可逆操作的处理
      
      将f[v]排成一行,通过补集(前后缀)的信息求得不含f[v]的f[u]。
      
vector<int> e[N];//使用基于vector的邻接表,以便前后缀处理

void dfs2(int u,int fa)
{
    /*若e[u]不含fa,则需要特判:if(e[u].empty()) return ;*/
    vector<type> p(e[u].size()),s(e[u].size());//前后缀
    p[0]=f[e[u][0]];
    for(int i=1;i<e[u].size();i++) /*把p[i-1]和f[e[u][i]]合并到p[i]*/
    s[e[u].size()-1]=f[e[u].back()];
    for(int i=e[u].size()-2;i>=0;i--) /*把s[i+1]和f[e[u][i]]合并到s[i]*/
    for(int i=0;i<e[u].size();i++)
    {
        int v=e[u][i];
        if(v==fa) continue;
        init(u);
        if(i-1>=0) /*把p[i-1]合并进f[u]*/
        if(i+1<e[u].size()) /*把s[i+1]合并进f[u]*/
        /*已得到不含f[v]的f[u],进行后续操作……*/
        dfs2(v,u);
        /*……*/
    }
    /*还原f[u]:
    方法一:通过p和s求得不含f[fa]的f[u]。
    方法二:使用备份。
    */
    return ;
}
split(u,v);
merge(v,u);
    `calc(u)`:计算以u为根的整棵树的信息。
void dfs1(int u,int fa)
{
    init(u);
    for(int i=h[u];i;i=ne[i])
    {
        int v=e[i];
        if(v==fa) continue;
        dfs1(v,u);
        merge(u,v);
    }
    return ;
}

void dfs2(int u,int fa)
{
    calc(u);
    for(int i=h[u];i;i=ne[i])
    {
        int v=e[i];
        if(v==fa) continue;
        split(u,v);
        merge(v,u);
        dfs2(v,u);
        
        //方法一
        split(v,u);
        merge(u,v);
        
        //方法二:备份数组还原
    }
    return ;
}

dfs1(1,0);
dfs2(1,0);
- 类型二
    1. 第一次扫描,任选一个节点为根,在“有根树”上执行一次树形dp。求出f[u]:以u为根的**子树**……**(回溯后发生,自底向上的状态转移)**

        转移:$son_u→u$。

          像正常树形dp那样转移。
    2. 第二次扫描,从刚才的根出发,对整棵树深度优先遍历。求出g[u]:以u为根的**整棵树**……**(递归前发生,自顶向下的推导)**

        转移:$u→son_u$。

          令$res=g[u]\ominus f[son_u]$,表示假如选点$son_u$为整棵树的根,res为以u为根的子树的值。

          $g[son_u]←f[son_u]\oplus res$。
- 类型三
    1. 第一次扫描自底向上,仅求出ans[1]的值。
    2. 第二次扫描自顶向下,比较u和$son_u$的差异,必要时借助类型一求出差异的部分,用ans[u]和差异的部分求出$ans[son_u]$。
  • 例题:积蓄程度
#include<bits/stdc++.h>
using namespace std;

const int N=2e5+5,M=N*2,INF=0x3f3f3f3f;
int t,n,root,ans;
int h[N],e[M],w[M],ne[M],idx;
int deg[N],d[N],f[N];   //deg[i]:节点i的度数;d[i]:以i为根的子树,把i作为源点,从i出发流向子树的最大流量;f[i]:把i作为源点,流向整个水系的最大流量;

void add(int a,int b,int c){
    e[++idx]=b;
    w[idx]=c;
    ne[idx]=h[a];
    h[a]=idx;
    return ;
}

void dp(int u,int fa){  //回溯后发生,自底向上的状态转移
    d[u]=0;
    for(int i=h[u];i!=0;i=ne[i]){
        int j=e[i];
        if(j==fa) continue;
        dp(j,u);
        if(deg[j]==1) d[u]+=w[i];
        else d[u]+=min(w[i],d[j]);
    }
    return ;
}

void dfs_f(int u,int fa){   //递归前发生,自顶向下的推导
    for(int i=h[u];i!=0;i=ne[i]){
        int j=e[i];
        if(j==fa) continue;
        if(deg[u]==1) f[j]=d[j]+w[i];
        
        //f[u]是包含点j的,故由d[j]+f[u]->f[j]时要排除f[u]中点j的贡献
        else if(deg[j]==1) f[j]=min(w[i],f[u]-w[i]);
        else f[j]=d[j]+min(w[i],f[u]-min(w[i],d[j]));
        dfs_f(j,u);
    }
    return ;
}

int main(){
    scanf("%d",&t);
    while(t--){
        idx=0,root=1,ans=0;
        memset(deg,0,sizeof deg);
        memset(h,0,sizeof h);
        
        scanf("%d",&n);
        for(int i=1;i<n;i++){
            int a,b,c;
            scanf("%d%d%d",&a,&b,&c);
            add(a,b,c),add(b,a,c);
            deg[a]++,deg[b]++;
        }
        
        dp(1,-1);
        
        f[1]=d[1];
        dfs_f(1,-1);
        
        for(int i=1;i<=n;i++) ans=max(ans,f[i]);
        
        printf("%d\n",ans);
    }
    return 0;
}

3.6.长链优化树形dp\(O(N)\)

《数据结构·字符串和图8.2.2.2.长链优化树形dpO(N)》

数据结构·字符串和图

3.7.基环树dp

《图论10.4.2.有向图——基环树dp》

图论

3.8.笛卡尔树dp

《数据结构‧11.2.1.笛卡尔树dp》

3.9.树形dp的空间优化

先遍历重儿子,重儿子回溯后再给当前节点申请空间,再遍历轻儿子。并且每当儿子向当前节点合并信息后,把儿子的空间释放。可以用vector动态申请释放空间。

空间复杂度\(O((\log n)*其他维度)\)

最坏情况是从当前节点到根节点的每条重链的链顶的父亲开了O(其他维度)的空间,最多经过O(log n)条重链。

4.有向无环图上的dp

4.1.有向无环图上的dp

《图论.3.有向无环图上的dp》

图论

4.2.借助有向无环图的反图做到\(O(朴素dp)\)预处理\(O(1)\)查询

前提条件:

  1. 对于多个给定的x,求出在x的情况下同一个终点(DAG上同一个点)的dp值。
  2. 转移方式具有交换律。\(e.g.\)对于计算概率,\(1*p_1*p_2=1*p_2*p_1\)
  3. 转移构成的 DAG的形态和边(对于第i个状态的转移方式)是固定的,和x无关。
  4. 不同的x只是使得DAG上的出发点不同,但是无论是哪个出发点,初值都相同。

以上条件都常见于概率dp中。

考虑建出原 DAG 的反向 DAG,即如果原图上 u → v 有权值为 w 的边,新图上有 v → u 权值为 w 的边。

\(e.g.\)若原图的转移方程是\(f[i][j]=\sum\limits_{k=j}^n (f[i-1][k]*a^{k-j}*b)\),则反图上\(f[i][j]=\sum\limits_{k=0}^j (f[i+1][k]*a^{j-k}*b)\)(因为原图是f[i][j]转移到f[i+1][≤j],所以反图是f[i+1][≤j]转移给f[i][j])。

在反图上令\(f[终点]=初值\)\(O(朴素dp)\)预处理计算所有位置的 dp 值。现在只需要\(O(1)\)调用反图上初始化位置的 dp值就能得到在原图从该初始化位置出发的终点dp值。

4.3.自动机上dp

前置知识

《动态规划8.8.dp套dp》

4.3.1.KMP

适用条件:有关字符串一匹一的dp问题。

\(f_{i,j}\):s[1..i]的后缀已经匹配到t[1..j]的……。

转移(由自己转移到别人):设val表示一次s[1..i]的后缀成功匹配到\(t[1..len_t]\)的贡献。

  1. 若s[i+1]=t[j+1],

    1. \(j+1==len_t\)\(f_{i+1,ne[j+1]}←f_{i,j}+val\)
    2. 否则,\(f_{i+1,j+1}←f_{i,j}\)
  2. 若j≥1 ,\(f_{i,ne[j]}←f_{i,j}\)

    故第二维j需要倒序枚举。

  3. \(f_{i+1,0}←f_{i,j}\)

边界:\(f_{0,0}=0\),其余均为\(-\infty\)

时间复杂度\(O(len_slen_t)\)

4.3.2.AC自动机

适用条件:有关字符串一匹多的dp问题。

\(f_{i,j}\):走了i步,当前在AC自动机上的节点j的……。

转移(由自己转移到别人):先建立AC自动机。转移时枚举当前的决策c,\(f_{i+1,tr[j].son[c]}←f_{i,j}\)

边界:\(f_{0,0}=0\),其余均为\(-\infty\)

注意:如果不进行拓扑排序,建立AC自动机时就需要额外加上下面的代码:tr[tr[u].son[c]].ed|=tr[tr[tr[u].son[c]].fail].ed;//ed:当前节点是否是一个字符串的末尾

时间复杂度\(O(len_t\sum len_{s_i})\)

5.环形dp与环形问题

5.1.破环成链转化为线性dp

把环断开成链,然后复制一倍接在末尾。

注意N开2倍!!!

破环成链转化为线性dp

下面以《环路运输》为例:

把二元项拆成一元项,方便后面优化:设dis(i,j)=i-j,则\(A_i+A_j+dis(i,j)=(A_i+i)+(A_j-j)\)。此时可把只含i的项看作定值。

破环成链后,也就是对于每个i求滑动窗口长度在len/2(为保证只含i的项看作定值,dis(i,j)必须等于j-i,因此滑动窗口的长度是len/2)的权值\(A_j-j\)最大值,单调队列做到线性求解。

int n,len,ans;
int a[N*2];
deque<int> ma;

scanf("%d",&n);
for(int i=1;i<=n;i++){
    scanf("%d",&a[i]);
    a[i+n]=a[i];
}

len=n/2;
for(int i=1;i<=n*2;i++){
    if(ma.size() && ma.front()<i-len) ma.pop_front();
    ans=max(ans,i-ma.front()+a[ma.front()]+a[i]);
    while(ma.size() && a[ma.back()]-ma.back()<=a[i]-i) ma.pop_back();
    ma.push_back(i);
}

printf("%d\n",ans);

破环成链转化为区间dp

区间右端点是到n*2终止!!!

下面以环形石子合并为例:

int n,nn,mi=INF,ma=-INF;
int w[N],sum[N];
int fi[N][N],fa[N][N];//fi[l][r]:把区间第l堆和第r堆石子合并成一堆需要消耗的最小体力

int main()
{
    scanf("%d",&n);
    nn=n<<1;
    for(int i=1;i<=n;i++)
    {
        scanf("%d",&w[i]);
        w[i+n]=w[i];
    }
    
    for(int i=1;i<=nn;i++) sum[i]=w[i]+sum[i-1];
    
    memset(fi,0x3f,sizeof fi);
    memset(fa,-0x3f,sizeof fa);
    for(int l=1;l<=nn;l++) fi[l][l]=fa[l][l]=0;//边界
    for(int len=2;len<=n;len++)
        for(int l=1;l+len-1<=nn/*注意这里是nn!!!*/;l++)
        {
            int r=l+len-1;
            for(int k=l;k<r;k++)
            {
                fi[l][r]=min(fi[l][r],fi[l][k]+fi[k+1][r]);
                fa[l][r]=max(fa[l][r],fa[l][k]+fa[k+1][r]);
            }
            fi[l][r]+=sum[r]-sum[l-1];
            fa[l][r]+=sum[r]-sum[l-1];
        }
        
    for(int l=1;l<=n;l++)
    {
        mi=min(mi,fi[l][l+n-1]);
        ma=max(ma,fa[l][l+n-1]);
    }
    printf("%d\n%d\n",mi,ma);
    
    return 0;
}

5.2.两次dp,强制断开相连

第一次dp破环成链,按照线性问题求解;第二次通过恰当的条件和赋值,保证计算出的状态等价于把断开的位置强制相连。

例题:休息时间

5.3.类环上两点距离(距离)模型

求一个简单环上任意两点的最短距离

选择环上任意一点rt。

预处理:s[u]:以s[rt]=0为起点,u所在环的距离前缀和。scir:u所在环的环长。

询问:dis(x,y)=min(abs(s[x]-s[y]),scir-abs(s[x]-s[y]))。

//预处理
rt=1;
s[rt]=0;
for(int i=2;i<=n;i++) s[i]=s[i-1]+dis(i,i-1);
s[rt]=scir=s[n];

5.4.两向扩展

环上固定一点st。\(f_{i,j}\):已考虑st逆时针方向的i个点,顺时针方向的j个点。

阶段是i+j。然后枚举i,j就自然确定了。

6.有后效性的dp

  • 若dp的转移是DAG:保证用到当前状态时在DAG上所有该状态的入边已经转移完全。
  • 若dp的转移有环:环形dp处理、分层图跑dijkstra或spfa、高斯消元……。

一般采用递推枚举dp的转移顺序。

若不能直接递推枚举dp的转移顺序:记忆化搜索、分层图跑dijkstra或spfa、dp套dp……。

6.1.概率dp

有时数学期望列出的式子各阶段间无后效性,但是状态转移上具有后效性构成了环形,此时应用高斯消元优化dp。\(e.g.\)f[i][j]=calc(f[i][j-1],f[i][j+1],f[i-1][...])中i无后效性,j有后效性。

于是对于每一外层i(无后效性),其中内部维度j(有后效性)的转移不再是普通的递推,而是高斯消元解m元1次方程组。

《数学4.概率论》

数学

6.2.分层图最短路

《图论4.3.分层图最短路》

图论

7.dp状态优化和其他dp状态

dp复杂度一般\(≥O(状态数)\)

优化状态时思考哪些维度的状态可以被其他维度的状态计算出来,然后把这些状态删除,得到极小状态集。

7.1.状态压缩dp

适用条件:“状态”需要记录“轮廓”的详细信息,且数据范围较小。

前置知识:《基础知识1.6.状态压缩》

地图类题目数据范围给的是1≤n*m≤100,均需要把二维压缩到一维:

//压缩
int sets(int x,int y){
    return (x-1)*m+y;
}

//还原
PII get(int state)
{
    int x=(state-1)/m+1,y=(state-1)%m+1;//注意是state-1
    return {x,y};
}

int state=sets(x,y);//压缩

//还原
PII t=get(state);
x=t.first,y=t.second;

7.1.1.压缩信息类

当n的范围≤20时,可以用一个整数压缩所有的信息:其中二进制表示下第i位是0表示第i个事件是false;是1表示true。

对于一个整数,逐一拆分成各个位上的1,逐一分析事件:

int log_2[1<<N];
for(LL i=0;i<n;i++) log_2[1<<i]=i;

for(int S=0;S<=limit;S++)
    for(int s=S;s;s-=lowbit(s))
    {
        int pos=log_2[lowbit(s)];//第pos事件是true
    }

求一个整数中1的个数:

《基础算法1.3.统计a中 1 的个数离线预处理》

7.1.2.压缩地图类

特征:“填充网格图形”,填充的图形仅与相邻的若干行有关,易按照“行”划分阶段。

核心

用二进制(0、1)表示状态。

e.g.某一行有8块土地,分别在第1、3、6块土地上放炮,则用01串表示为10100100,将这个二进制转换成十进制164存储在state数组中。

步骤

预处理阶段

  1. 存储单个区域的不合法答案:存储地图mapp;
  2. 枚举一行内的合法答案:检查左右情况,并存入前驱state;

for循环阶段

循环顺序:阶段 -> 状态 -> 决策。

在state中枚举逐行之间的情况。

for循环内部阶段

  1. 检查逐行之间的答案是否合法:检查上下情况:state[第x行]&state[第x-1行]
  2. 接着检查单个区域内的答案是否合法:state与mapp进行与运算:state[state_id]&mapp[row]
  3. 然后进行dp运算;

注意事项

  • 状态压缩dp行数和列数最好从0开始
  • 循环顺序:阶段 -> 状态 -> 决策
  • 循环阶段i时,本题循环到n+1(从0开始)行,这样最终答案自然在f[n+1&1][0][0]
  • 例题1:炮兵阵地
#include<bits/stdc++.h>
using namespace std;

const int N=105,M=1<<10;
int n,m;
int mapp[N],cnt[M],f[2][M][M];
vector<int> state;

//检查左右大炮情况
bool check(int a){
    for(int i=0;i<m;i++) if((a>>i&1) && ((a>>i+1&1) || (a>>i+2&1))) return false;
    return true;
}

//返回这个数的二进制表示下有多少个1
int count(int a){
    int res=0;
    while(a){
        res+=a&1;
        a>>=1;
    }
    return res;
}

int main(){
    scanf("%d%d",&n,&m);
    
    //存储单个区域的不合法答案:存储山地地图
    for(int i=0;i<n;i++)    //行数从1开始易错
        for(int j=0;j<m;j++){
            char a;
            cin>>a;
            /*
            scanf是lj好吧
            --------我是分割线--------
            除了用cin,还可以像下面这样写:
            char a=getchar();
            while (!isalpha(a)) a=getchar();
            */
            if(a=='H') mapp[i]+=1<<j;
        }
        
    //枚举一行内的合法答案:检查左右情况,并存入前驱state
    for(int i=0;i<(1<<m);i++){
        if(check(i)){
            state.push_back(i);
            cnt[i]=count(i);
        }
    }
    
    
    for(int i=0;i<n+2;i++)  //i:阶段,本题循环到n+1(从0开始)行,这样最终答案自然在f[n+1&1][0][0]
        for(int j=0;j<state.size();j++)
            for(int k=0;k<state.size();k++) //j(第i-1行的状态)和k(第i行的状态):状态
                for(int l=0;l<state.size();l++){    //l(第i-2行的状态)和i(第i-1行的状态):决策:由之前哪个状态转移过来
                    //检查逐行之间的答案是否合法:检查上下情况
                    //接着检查单个区域内的答案是否合法:是否在山地上装炮
                    //然后进行dp运算
                    int a=state[l],b=state[j],c=state[k];   //按照行数顺序编排abc,依次为i-2、i-1和i
                    if((a&b) || (a&c) || (b&c)) continue;
                    if(c&mapp[i]) continue;
                    f[i&1][j][k]=max(f[i&1][j][k],f[i-1&1][l][j]+cnt[c]);
                }
                
    printf("%d\n",f[n+1&1][0][0]);
        
    return 0;
}
  • 例题2:拯救大兵瑞恩——有钥匙的迷宫

    BFS+状态压缩。

    用状态压缩保存身上的钥匙情况。二进制下第i位是1表示有第i种类型的钥匙。

const int dx[]={1,-1,0,0};
const int dy[]={0,0,1,-1};
int n,m,p,k,s;
int g[N][N][N][N];//g[x1][y1][x2][y2]:从(x1,y1)到(x2,y2)所需要的钥匙类型
int key[N][N];//key[x][y]:(x,y)放的钥匙
int dis[N][N][1<<N];//dis[x][y][state]最小步数
struct Data
{
    int x,y,state;
};

int bfs()
{
    memset(dis,0x3f,sizeof dis);
    queue<Data> q;
    q.push({1,1,key[1][1]});
    dis[1][1][key[1][1]]=0;
    while(q.size())
    {
        auto t=q.front();
        q.pop();
        if(t.x==n && t.y==m) return dis[t.x][t.y][t.state];
        for(int i=0;i<4;i++)
        {
            int x_ne=t.x+dx[i],y_ne=t.y+dy[i];
            if(x_ne<=0 || x_ne>n || y_ne<=0 || y_ne>m) continue;
            int &door=g[t.x][t.y][x_ne][y_ne];
            if(door==0 || (door>=1 && !((t.state>>door)&1))) continue;
            int state_ne=t.state|key[x_ne][y_ne];
            if(dis[x_ne][y_ne][state_ne]>dis[t.x][t.y][t.state]+1)
            {
                dis[x_ne][y_ne][state_ne]=dis[t.x][t.y][t.state]+1;
                q.push({x_ne,y_ne,state_ne});
            }
        }
    }
    return -1;
}

int main()
{
    memset(g,-1,sizeof g);
    scanf("%d%d%d",&n,&m,&p);
    scanf("%d",&k);
    while(k--)
    {
        int x1,y1,x2,y2,q;
        scanf("%d%d%d%d%d",&x1,&y1,&x2,&y2,&q);
        g[x1][y1][x2][y2]=q;
        g[x2][y2][x1][y1]=q;
    }
    scanf("%d",&s);
    while(s--)
    {
        int x,y,q;
        scanf("%d%d%d",&x,&y,&q);
        key[x][y]|=1<<q;
    }
    printf("%d\n",bfs());
    return 0;
}

7.1.3.插头dp

连通性状态压缩dp的一种。

特征:“填充网格图形”,填充的图形与整个网格有关,需要进一步提炼“轮廓”的特点。一般题目会给一个网格(棋盘),和连通性限制。

7.1.3.1.基础知识——最短哈密顿距离\(O(2^N*N)\)

给定n个点,给定一个邻接矩阵,求从0号节点到n-1号节点,不重不漏经过每个点恰好一次的路径的最短距离。

状态压缩dp解决。

\(f[state][j]\):所有从0走到j,走过的所有点是state(二进制下1代表走过)的路径。

状态转移:走过的倒数第一个点是j,按走过的倒数第二个点划分:\(f[state][j]=\min\limits_{k}^{state-(1<<j)>>k\&1} \{ f[state-(1<<j)][k]+a[k][j] \}\)

int n;
int a[N][N],f[M][N];

int main()
{
    scanf("%d",&n);
    for(int i=0;i<n;i++)
        for(int j=0;j<n;j++)
            scanf("%d",&a[i][j]);
            
    memset(f,0x3f,sizeof f);
    f[1][0]=0;
    for(int state=0;state<(1<<n);state++)
        //if(state&1)//这里还可以加个小剪枝:所有路径肯定经过0号节点
        for(int j=0;j<n;j++)
            if((state>>j)&1)
                for(int k=0;k<n;k++)
                    if(((state-(1<<j))>>k)&1)
                        f[state][j]=min(f[state][j],f[state-(1<<j)][k]+a[k][j]);
    printf("%d\n",f[(1<<n)-1][n-1]);
    return 0;
}

7.1.3.2.插头dp

N*M棋盘问题当然可以用状态压缩dp解决,但是时间复杂度是\(O(2^{N*M}*N*M)\),无法承受。这时我们可以用插头dp解决。

7.1.3.2.1.回路

下面我们用一道模板题具体讲解插头dp:

  • 模板题题面

    给你一个 n×m 的棋盘,有的格子是障碍,问共有多少条回路满足经过每个非障碍格子恰好一次。

    如图,n=m=4,(1,1),(1,2) 是障碍,共有 2 条满足要求的回路。

    输入格式

    第一行包含两个整数 n,m。

    接下来 n 行,每行包含一个长度为 m 的字符串,字符串中只包含 *.,其中 * 表示障碍格子,. 表示非障碍格子。

    输出格式

    输出一个整数,表示满足条件的回路数量。

    数据范围

    2≤n,m≤12

    输入样例:

4 4
**..
....
....
....
2

原题实际上是问棋盘上有多少种不同的Hamilton路径。

通过观察我们发现:每个格子有6种状态(上下左右进,上下左右出,\(C_4^2=6\))。因此,插头dp以一个格子作为一个阶段

插头dp一个格子一个格子枚举时,我们发现,有用的信息是已枚举的格子和未枚举的格子的分界线——“轮廓”。我们只需要记录边界线的状态,是否有边伸出这个线(称之为插头), 还要记录伸出来的边的连通性。轮廓线的状态分为3种:没有边进出,有边进(一个回路的左半边),有边出(一个回路的右半边)。

\(f[i][j][state]\):目前递推到(i,j)且轮廓线状态是state的所有方案数。

  • state轮廓线状态的表示

    括号表示法

    优点:效率比最小表示法高,不用像最小表示法那样维护连通信息,可以借助四进制压缩信息。

    缺点:适用题目必须有以下性质:1.整个网格构成一个回路;2.两两配对,边界上有一个边进就有一个边出;3.任意路径不相交。

    本题有上述的3个性质,因此每一个“进边(一个回路的左半边)”“1”表示,“出边(一个回路的右半边)”“2”表示,没有边进出用“0”表示。这就好像一个括号序列,“1”和“2”之间两两配对。

    \(e.g.\)上面的图的最小表示法是101022。

    于是我们可以类似二进制那样用四进制压缩信息。(本质上是用两位的二进制表示一位的四进制,四进制更方便因此我们不用三进制)

大部分插头dp困难的地方在于状态转移。大部分插头dp的状态转移需要分类讨论

  • 状态转移

    状态的转移实际上是上图的实线的分界线的状态转移到虚线的分界线的状态。

    由于插头dp是从左到右,从上到下一个格子一个格子枚举,因此当前枚举的格子,分界线一定是_|—形,我们设当前枚举的格子左边一个分界线(第一列的格子仍然视为左边有一个分界线)的状态是x,上边是y:(截图自墨染空的题解

    ![](https://secure2.wostatic.cn/static/9p1qxTip7vodKbb828vUAq/屏幕截图 2023-01-14 213915.jpg)

    画图非常方便理解!

技巧:

  1. 我们只会用到上一个阶段的信息,因此用滚动数组优化。

  2. 即使用四进制压缩信息,该四进制数仍然很大,需要使用哈希。

    注意如果使用vector而不使用哈希存每一阶段的状态,因为同一阶段转移到相同的状态无法合并,所以每一阶段结束时需要多一个\(O(\log)\)排序并合并相同的状态。

  3. 类似于状态压缩dp,用数组存储有效状态。

  4. 如果给定的n*m网格n<m,交换n、m,用四进制压缩较小的位数。

  5. 不要忘记开long long哦~

大部分插头dp解题方法:根据“网格”知道这是插头dp+背下面的模板+设计轮廓线状态+画图分析状态转移

#include<bits/stdc++.h>
using namespace std;

typedef long long LL;
const int N=5e4+10,M=N*2+7;//M:模数,>=2~3N的幸运数字
int n,m;
int edx,edy;    ////存下最后一个不是障碍物的点的坐标,方便确定在哪里将回路闭合是合法的
LL ans;
bool mapp[20][20];  //true表示能走(边界初始是false所以不能走)
int q[2][N],cnt[2]; //q[bool][idx]:滚动数组,通过存储第idx的哈希值来记录哪些状态供转移;cnt[bool]:滚动树组,存储上一层有多少个状态,同时也充当本层的“idx”
int h[2][M];    //h[bool][hash]滚动数组,存储原状态
LL v[2][M]; //v[bool][hash]:滚动数组,存储哈希对应的原状态的方案数

//哈希(开源寻址法)
int find(int cur,int state)
{
    int t=state%M;
    while(h[cur][t]!=-1 && h[cur][t]!=state)
    {
        t++;
        if(t==M) t=0;
    }
    return t;
}

void insert(int cur,int state,LL w)
{
    int t=find(cur,state);
    if(h[cur][t]==-1)
    {
        q[cur][++cnt[cur]]=t;
        h[cur][t]=state;
        v[cur][t]=w;
    }
    else v[cur][t]+=w;//如果是求方案数则相加,如果是求最值则取最值
    return ;
}

//四进制:本质上是用两位的二进制表示一位的四进制
//四进制下右移K位就是二进制下右移2*K位
int get(int state,int k)//求第k个格子的状态,四进制的第k位数字
{
    return (state>>(k*2))&3;    //&3:类似于二进制,消除最高位只保留最后2位
}

int sets(int v,int k)//构造四进制的第k位数字为v的数
{
    return v*(1<<(k*2));
}

int main()
{
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++)
    {
        char str[20];
        scanf("%s",str+1);
        for(int j=1;j<=m;j++)
            if(str[j]=='.') //不断覆盖,覆盖到最后剩下的就是真正的最后一个可以放的格子
            {
                mapp[i][j]=true;
                edx=i,edy=j;
            }
    }
    
    memset(h,-1,sizeof h);
    int cur=0;
    insert(cur,0,1);    //初始界限状态是0,什么都不连(0)的方案数是1
    for(int i=1;i<=n;i++)
    {
        for(int idx=1;idx<=cnt[cur];idx++) h[cur][q[cur][idx]]<<=2;//换行操作,把上一行最后一列的状态的最高位的0移到最低位得到每一行第一列的状态
        
        for(int j=1;j<=m;j++)//阶段(一个格子就是一个阶段)+状态,一个网格一个网格枚举
        {
            //每一轮都是新的界限
            int last=cur;
            cur^=1,cnt[cur]=0;
            memset(h[cur],-1,sizeof h[cur]);
            
            for(int idx=1;idx<=cnt[last];idx++)
            {
                int state=h[last][q[last][idx]];
                LL w=v[last][q[last][idx]];
                int x=get(state,j-1),y=get(state,j);
                
                //分类讨论
                //除了轮廓线下标从0开始(0-m,状态压缩),其余都是从1开始
                //画图发现,当前枚举方块(i,j)是,拐弯形状的轮廓线拐点在(i,j)
                //(i,j)左侧边界编号应该是j-1,上边界编号是j
                if(!mapp[i][j])
                {
                    if(x==0 && y==0) insert(cur,state,w);
                    //如果没有插头,这是合法状态,加入哈希表中,权值不变

                    //else 不合法
                }
                else if(x==0 && y==0)
                {
                    //insert(cur,state,w);//如果不要求走完所有的格子,则加入这行代码
                    if(mapp[i+1][j] && mapp[i][j+1])
                    {
                        state+=sets(1,j-1)+sets(2,j);
                        insert(cur,state,w);
                    }
                    //一根线上靠左的(一个回路的左半边)是1,靠右的(一个回路的右半边)是2
                }
                else if(x==0 && y!=0)
                {
                    //注意下面的顺序不能颠倒
                    if(mapp[i][j+1]) insert(cur,state,w);
                    if(mapp[i+1][j])
                    {
                        state+=sets(y,j-1)-sets(y,j);
                        insert(cur,state,w);
                    }
                }
                else if(x!=0 && y==0)
                {
                    if(mapp[i+1][j]) insert(cur,state,w);
                    if(mapp[i][j+1])
                    {
                        state+=sets(x,j)-sets(x,j-1);
                        insert(cur,state,w);
                    }
                }
                else if(x==1 && y==1)
                {
                    for(int u=j+1,tot=1;;u++)
                    {
                        int z=get(state,u);
                        if(z==1) tot++;
                        else if(z==2)
                        {
                            tot--;
                            if(tot==0)
                            {
                                state+=-sets(x,j-1)-sets(y,j)-sets(1,u);
                                insert(cur,state,w);
                                break;
                            }
                        }
                    }
                }
                //向右找到Y对应的2,改成1
                //这种寻找方法就是括号匹配的寻找方法,可以过滤掉中间成对的括号
                else if(x==2 && y==2)
                {
                    for(int u=j-2,tot=1;;u--)
                    {
                        int z=get(state,u);
                        if(z==2) tot++;
                        else if(z==1)
                        {
                            tot--;
                            if(tot==0)
                            {
                                state+=-sets(x,j-1)-sets(y,j)+sets(1,u);
                                insert(cur,state,w);
                                break;
                            }
                        }
                    }
                }
                else if(x==2 && y==1)   //把它们连起来
                {
                    state+=-sets(x,j-1)-sets(y,j);
                    insert(cur,state,w);
                }
                
                else if(i==edx && j==edy) ans+=w;
                //else if(state==set(x,j-1)+set(y,j)) ans+=w;如果不要求走完所有的格子,即edx,edy是未定的,则只有当轮廓线的当前位置x=1,y=2且其他位置都是0时,把贡献计入答案                
            }
        }
    }
    
    printf("%lld\n",ans);
    
    return 0;
}

7.1.3.2.2.连通块

类似于回路。

  • state轮廓线状态的表示

    不同于回路,连通块的轮廓线是由格子组成的。

    最小表示法

    优点:适用范围广。

    缺点:要维护连通信息:每条进出的边属于哪个连通块。

    对于未标记的边标记一个最小的数字(同一个连通块的标记相同,没有边进出标记0)。

    \(e.g.\)若上面的图1属于一个连通块,2和3属于一个连通块,则最小表示法是0122。

    通常数据范围列数不会超过10,因此使用一个8进制数表示状态

为状态转移方便,转移过程中不要求状态是最小表示的,在插入状态时才调用update()函数把其转化为最小表示的。

注意非插入状态时不可轻易把state转化为最小表示的,否则x,y的真实值会改变

//在调用insert传入state时使用
//先合并连通块mi,ma(除了mi,ma编号为-1或0),然后返回state的最小表示
int update(int state,int mi,int ma)
{
    int idx=0,s=0;
    memset(vis,0,sizeof vis);
    for(int i=1;i<=n;i++)
    {
        int x=get(state,i);
        if(!x) continue;    //一定要放在第一位,防止mi=0的干扰
        if(x==mi) x=ma;
        if(!vis[x]) vis[x]=++idx;
        s+=sett(vis[x],i);
    }
    return s;
}

//返回state有多少个连通块
int count(int state)
{
    int idx=0;
    memset(vis,0,sizeof vis);
    for(int i=1;i<=n;i++)
    {
        int x=get(state,i);
        if(!x) continue;
        if(!vis[x]) vis[x]=++idx;
    }
    return idx;
}
  • 状态转移

    状态的转移实际上是上图的实线的轮廓线的状态转移到虚线的轮廓线的状态。

    由上图得:轮廓线一定是_|—形,我们设当前枚举的格子左边一个轮廓线(第一列的格子仍然视为左边有一个轮廓线。不同于回路,连通块不需要换行)的状态是x,上边是y:

    枚举完当前的格子后,y将会与外界“隔离”。

    1. 不选择当前的格子:y←0。

      注意,如果题目要求最终只能有一个连通块,那么只有 不选择当前格子后的轮廓线的连通块数 等于 之前的轮廓线的连通块数 时,才可以不选择当前的格子。

    2. 选择当前的格子

      1. 若xy0,当前的格子形成一个新的连通块。由于列数的数据范围通常会使得轮廓线的连通块数小于7,因此7是一个未使用的编号,暂时给当前的连通块编号为7:y←7。到后面插入状态求最小表示法update()再做调整。

      2. 若x0,y≠0或x≠0,y0,当前格子与原有的连通块联通。

        若x≠0,y≠0,当前格子与2个连通块联通,2个连通块合并为1个连通块。

        上面的转移可使用一行代码一起实现:insert(cur,update(state-sett(y,j)+sett(max(x,y),j),min(x,y),max(x,y)),w+a[i][j]);

    画图非常方便理解!

  • 例题的代码

int n,ans=-1e9;
int a[10][10];
int q[2][N],cnt[2];
int h[2][M],v[2][M];
int vis[8];

int get(int state,int k)
{
    return (state>>k*3)&7;
}

int sett(int v,int k)
{
    return v*(1<<k*3);
}

//在调用insert传入state时使用
//先合并连通块mi,ma(除了mi,ma编号为-1或0),然后返回state的最小表示
int update(int state,int mi,int ma)
{
    int idx=0,s=0;
    memset(vis,0,sizeof vis);
    for(int i=1;i<=n;i++)
    {
        int x=get(state,i);
        if(!x) continue;    //一定要放在第一位,防止mi=0的干扰
        if(x==mi) x=ma;
        if(!vis[x]) vis[x]=++idx;
        s+=sett(vis[x],i);
    }
    return s;
}

//返回state有多少个连通块
int count(int state)
{
    int idx=0;
    memset(vis,0,sizeof vis);
    for(int i=1;i<=n;i++)
    {
        int x=get(state,i);
        if(!x) continue;
        if(!vis[x]) vis[x]=++idx;
    }
    return idx;
}

int find(int cur,int state)
{
    int t=state%M;
    while(h[cur][t]!=-1 && h[cur][t]!=state)
    {
        t++;
        if(t==M) t=0;
    }
    return t;
}

void insert(int cur,int state,int w)
{
    int t=find(cur,state);
    if(h[cur][t]==-1)
    {
        q[cur][++cnt[cur]]=t;
        h[cur][t]=state;
        v[cur][t]=w;
    }
    else v[cur][t]=max(v[cur][t],w);
    if(count(state)==1) ans=max(ans,w);
    return ;
}

int main()
{
    scanf("%d",&n);
    for(int i=1;i<=n;i++)
        for(int j=1;j<=n;j++)
            scanf("%d",&a[i][j]);
    int cur=0;
    memset(h[cur],-1,sizeof h[cur]);
    insert(cur,0,0);
    for(int i=1;i<=n;i++)
        for(int j=1;j<=n;j++)
        {
            int last=cur;
            cur^=1,cnt[cur]=0;
            memset(h[cur],-1,sizeof h[cur]);
            for(int idx=1;idx<=cnt[last];idx++)
            {
                int state=h[last][q[last][idx]],w=v[last][q[last][idx]];
                int x=get(state,j-1),y=get(state,j);
                int res=count(state);
                state=state-sett(y,j);  //这里不可直接加update,否则x,y的真实值会改变
                if(count(state)==res) insert(cur,update(state,-1,-1),w);
                w=w+a[i][j];
                if(!x && !y) insert(cur,update(state+sett(7,j),-1,-1),w);
                else insert(cur,update(state+sett(max(x,y),j),min(x,y),max(x,y)),w);
            }
        }
    printf("%d\n",ans);
    return 0;
}

7.2.倍增优化dp

适用条件:阶段的递推形式。

  1. 预处理 dp

    \(f[k][...]\):...走了2^k步(一般是阶段)。

    先计算f[0][...];

    dpf[k][...]=f[k-1][...](先走2^k-1步)+f[k-1][...f[k-1][...]...](再走2^k-1步)

  2. 二进制拼凑(两种代码)

int p=i;
for(int k=30;k>=0;k--){
    if(p+f[p-1][k]<=N){
        p+=f[p-1][k];
    }
}
//要先预处理前缀和s
int p=1,k=0,sum=0;
while(p){
    if(sum+s[k+p]-s[k]<=M){
        sum+=s[k+p]-s[k];
        k+=p;
        if(k>N) break;
        p*=2;
    }
    else p/=2;
}

7.3.数位DP

给定若干个要求和一个正整数x,求小于等于x的正整数中满足题目要求的数的个数。

为数不多可以套模板无脑切题的dp……(当然有些题目还是不能套模板必须思考的)

7.3.1.记忆化搜索写法

模板

在多组测试数据中,数组a会改变,从而影响到限定limit。因此:

  • 要么在dp数组f中不加入limit并在dp过程中进行!limit的判断,时间复杂度是O(max(数位dp状态数转移的复杂度,T位数*转移的复杂度))

    “T位数转移的复杂度”:因为只有当一直i==up时limit为真,所以只有位数个状态是每组测试数据都要计算的。

  • 要么在dp数组f中加入limit并在每次调用calc()时初始化dp数组f,时间复杂度是O(T数位dp状态数转移的复杂度)

下面采用的写法是在dp数组f中不加入limit并在dp过程中进行!limit的判断。

int a[N],len;
ll f[N][/*可选参数*/][/*可选限定*/];   //f[pos][可选参数][可选限定]:在没有limit限定的情况下,当前从高位到低位考虑到第pos位且前面……且限定情况是……的方案数

ll dp(int pos,/*可选参数*/,bool limit,/*可选限定*/)
{
    if(pos==0) return /*合法条件*/ ? 1 : 0;
    if(!limit && f[pos][/*可选参数*/][/*可选限定*/]!=-1) return f[pos][/*可选参数*/][/*可选限定*/];
    ll res=0;
    int up=limit ? a[pos] : /*进制-1(无限制位上的可填的最大的数)*/;
    for(int i=0;i<=up;i++)
    {
        if(/*不合法条件*/) continue;
        res+=dp(pos-1,/*可选参数*/,limit && i==up,/*可选限定*/);
    }
    return !limit ? (f[pos][/*可选参数*/][/*可选限定*/]=res) : res;
}

ll calc(ll x)
{
    len=0;//!!!注意初始化!!!
    //无需初始化f
    while(x) a[++len]=x%/*进制*/,x/=/*进制*/;
    return dp(len,0,true,true);//调用,初始条件
}

memset(f,-1,sizeof f);//!!!注意在这里初始化!!!
int t;
cin>>t;
while(t--)
{
    ll x;
    cin>>x;
    cout<<calc(x)<<endl;
}

技巧

  1. 空间换时间:在dp数组f中加入limit,从而在dp过程中无需进行!limit的判断。注意在每次调用calc()时需要初始化dp数组f,因此不太适用于多组测试数据。
ll f[N][/*可选参数*/][2][/*可选限定*/];   //f[pos][可选参数][limit][可选限定]:当前从高位到低位考虑到第pos位且前面……且限定情况是……的方案数

ll dp(int pos,/*可选参数*/,bool limit,/*可选限定*/)
{
    //……
    ll &res=f[pos][/*可选参数*/][limit][/*可选限定*/];
    if(res!=-1) return res;
    res=0;
    //……
    return res;
}

ll calc(ll x)
{
    memset(f,-1,sizeof f);//!!!注意在每次调用calc()时需要初始化dp数组f!!!
    //……
}
  1. 减法借位

    \(e.g.\)给定n,求1~n中x满足条件1且n-x满足条件2的x的个数。

    x的上界就是n,而式子又出现减法,当枚举某一数位时\(n_{p_i}-x_{p_i}\)时可能为负数,需要向高位借位,因此从低向高枚举数位。记忆化搜索多传一个变量borrow:当前的数位有没有被上一个低数位借位。

    1. 最高位不能向它的高位借位。
    2. 枚举当前的数位时,若枚举的x>n则一定会向最高位的高位借位而被上面特判掉。因此这里不用考虑当前数位可以填数的限定
    3. 枚举当前的数位时,若已被低位借位,记得减1。
    4. 若枚举当前的数位的值为负数,向高位借位。
int f[N][2];    //f[pos][bor]:当前从低位到高位考虑到第pos位,当前的数位有没有被上一个低数位借位且满足条件的个数

int dp(int pos,bool bor)
{
    if(pos>len) return !bor; //最高位不能被借位
    int &res=f[pos][bor];
    if(res!=-1) return res;
    res=0;
    for(int i=0;i<=9;i++)    //x的上界就是n,若枚举的x>n则一定会向最高位的高位借位而被上面特判掉。因此这里不用考虑当前数位可以填数的限定
    {
        int u1=i,u2=n[pos]-i-bor;   //-bor:被低位借位
        bool b=false;
        if(u2<0) u2+=10,b=true;  //若为负数,则向高位借位
        
        //abaabaaba
        
        res+=dp(pos+1,b);
    }
    return res;
}

7.3.1.1.calc函数

作用:1.数→该数的每个数位的上界数组a[];2.返回1~x中有多少个满足要求的数。

len: 数位长度,一般根据这个来确定数组范围

\(a_i\): 每个数位的上界

在calc函数中实现。

$e.g.$123456→

数组下标 1 2 3 4 5 len=6
数位数字 6 5 4 3 2 1

调用记忆化搜索时从len开始,继续递归pos-1,这样可实现求原数时令val*10+i。

7.3.1.2.dp函数

变量 res 来记录答案,初始化一般为 0。
变量 up 表示当前位上的可填的最大的数。

7.3.1.2.1.必选参数与必选限定:

  • pos: 表示数字的位数

    根据题目的数字构造性质来选择 dp 顺序,一般选择从最高位到最低位的顺序。初始从 len 开始的话,边界条件应该是 pos=0,有限定的情况下当前的最高位可以填的最大的数应该是 a_{pos},往下递归时令 pos−1。

    这样可实现求原数时令val*10+i。

  • limit: 当前的最高位可以填数的限定(无限制的话(limit==false) 0∼9随便填,否则只能填到a[pos],因为大小不能超过给定的数)

    如果搜索到a1⋯apos⋯an,给定的数x的数位为a1⋯ak⋯an,那么我们必须对接下来搜索的数加以限制,也就是不能超过区间右端点 R,所以要引入limit这个参数:如果limit=1有限制,那么最高位数up≤apos+1,否则无限制,那么up=9(十进制下)这也就是确定搜索位数上界的语句limit ? a[pos] : 9;
    如果 limit=1 且已经取到了能取到的最高位时 (apos=ak),那么下一个 limit=1;
    如果 limit=1 且没有取到能取到的最高位时 (apos<ak),那么下一个 limit=0;
    如果 limit=0,那么下一个limit=0,因为前一位没有限制后一位必定没有限制。
    所以我们可以把这3种情况合成一个语句进行下一次搜索:limit && i == up
    (i为当前枚举的数字)

    在多组测试数据中,数组a会改变,从而影响到限定limit。因此要么在dp数组f中不加入limit并在dp过程中进行!limit的判断,时间复杂度是O(max(数位dp状态数转移的复杂度,Tlog W转移的复杂度));要么在dp数组f中加入limit并在每次调用calc()时初始化dp数组f,时间复杂度是O(T数位dp状态数*转移的复杂度)。

7.3.1.2.2.可选参数

根据题意选择。

dp数组每个维度包含pos和可选参数。

这里参数的最大值等于dp数组的大小上界,因此涉及到“原数...不是...的整数倍”递归时要记得参数取模。

pre和cnt大部分情况需要配合前导零限定lead

  • pre:表示上一个数是多少

    有些题目会用到前面的数

    1. 数字中不能含有4和62
int dfs(int pos,int pre,bool limit)
{
    if(pos==0) return 1;
    if(!limit && dp[pos][pre]!=-1) return dp[pos][pre];
    int res=0,up=limit ? a[pos] : 9;
    for(int i=0;i<=up;i++)
    {
        if(i==4 || (i==2 && pre==6)) continue;
        res+=dfs(pos-1,i,limit && i==up);
    }
    return limit ? res : (dp[pos][pre]=res);
}

return dfs(len,0,true);
  1. 任意相邻两个数位上的数字之差至少为 2

    必须填一个abs(pre - i) ≥ 2的数才能满足条件继续搜索下去,且初始条件参数 pre 必须填一个 ≤−2 的数来保证可以搜索下去。因此此题要限定前导零!

int dfs(int pos,int pre,bool lead,bool limit)
{
    if(!pos) return 1;
    if(!lead && !limit && dp[pos][pre]!=-1) return dp[pos][pre];
    int res=0,up=limit ? a[pos] : 9;
    for(int i=0;i<=up;i++)
    {
        if(abs(i-pre)<2) continue;
        if(lead && i==0) res+=dfs(pos-1,-2,lead && i==0,limit && i==up);
        else res+=dfs(pos-1,i,lead && i==0,limit && i==up);
    }
    return !lead && !limit ? (dp[pos][pre]=res) : res;
}

return dfs(len,-2,true,true);
  1. 数位从高到低非严格单调递增$e.g.$123、446

    前导零不影响,不需要lead。

int dfs(int pos,int pre,bool limit)
{
    if(!pos) return 1;
    if(!limit && dp[pos][pre]!=-1) return dp[pos][pre];
    int res=0,up=limit ? num[pos] : 9;
    for(int i=0;i<=up;i++)
    {
        if(i<pre) continue;
        res+=dfs(pos-1,i,limit && i==up);
    }
    return limit ? res : (dp[pos][pre]=res);
}

return dfs(len,0,true);
  • cnt:某个数字出现的次数

    有些题目会出现某个数字出现次数的条件。

    1. B进制下,数位上恰好出现K个1,其他均为0。

      若填某个数位为1前已经有k个1了,就continue

int dp[N][N];   //dp[pos][cnt]:第pos位且前面填了cnt个1的方案数

int dfs(int pos,int cnt,bool limit)
{
    if(!pos) return cnt==k;
    if(!limit && dp[pos][cnt]!=-1) return dp[pos][cnt];
    int res=0,up=limit ? a[pos] : b-1;
    for(int i=0;i<=min(up,1);i++)
    {
        if(i==1 && cnt==k) continue;
        res+=dfs(pos-1,cnt+(i==1),limit && i==up);
    }
    return limit ? res : (dp[pos][cnt]=res);
}

return dfs(len,0,true);

  • sum:搜索到当前所有数位上的数字之和

    有些题目会出现数位之和的条件。

    加和乘运算之间可以任意模一个相同的数。

    1. 数位之和不是x的整数倍
int dfs(int sum)
{
    if(pos==0) return sum!=0 ? 1 : 0;
    for(int i=0;i<=up;i++) res+=dfs((sum+i)%x);
}
  • val:搜索到当前的原数

    求原数时令val*10+i。

    1. 原数不是x的倍数
int dfs(int val)
{
    if(pos==0) return val!=0 ? 1 : 0;
    for(int i=0;i<=up;i++)  res+=dfs((val*10+i)%x);
}

  1. 十进制原数能够被它的数位之和整除

    由于模数(数位之和)在递归到达边界前不能确定,因此在calc函数中调用dfs前枚举模数(1~进制9*len)

int dfs(int pos,int val,int sum,bool limit)
{
    if(pos==0) return val==0 && sum==mod ? 1 : 0;
    if(!limit && dp[pos][val][sum]!=-1) return dp[pos][val][sum];
    int res=0,up=limit ? a[pos] : 9;
    for(int i=0;i<=up;i++)  res+=dfs(pos-1,(val*10+i)%mod,sum+i,limit && i==up);
    return limit ? res : (dp[pos][val][sum]=res);
}

int calc(int x)
{
    int res=0;
    len=0;
    while(x) a[++len]=x%10,x/=10;
    for(mod=1;mod<=9*len;mod++)
    {
        memset(dp,-1,sizeof dp);//!!!记得初始化!!
        res+=dfs(len,0,0,true);
    }
    return res;
}

  • key:布尔参数

    注:它也要作为dp数组的一个维度。

    1. 数位中但凡出现连续3个6。e.g.666、6663、6666。
LL dfs(LL pos,LL pre,bool key,bool limit)
{
    if(pos==0) return key;
    if(!limit && dp[pos][pre][key]!=-1) return dp[pos][pre][key];
    LL res=0,up=limit ? a[pos] : 9;
    for(int i=0;i<=up;i++)
    {
        if(i==6)
        {
            if(pre>=2) res+=dfs(pos-1,pre+1,true,limit && i==up);
            else res+=dfs(pos-1,pre+1,key,limit && i==up);
        }
        else res+=dfs(pos-1,0,key,limit && i==up);
    }
    return limit ? res : (dp[pos][pre][key]=res);
}

dfs(len,0,false,true);

  • 不体现在数组中的参数
    1. 数位中某一位不是x
int dfs()
{
    for(int i=0;i<=up;i++)
    {
        if(i==x) continue;
        res+=dfs();
    }
}

7.3.1.2.3.可选限定

根据题意选择。

  • lead:前导零是否存在,lead=true存在前导零,否则不存在

    一般来说有些题目不加限制前导零会影响数字结构(尤其是涉及到参数pre),所以 lead 是一个很重要的参数。
    如果 lead=1 且当前位为 0,那么说明当前位是前导 0,继续搜索 pos+1,其他条件不变。
    如果 lead=1 且当前位不为 0,那么说明当前位是最高位,继续搜索 pos+1,条件变动。
    如果 lead=0,则不需要操作。

7.3.1.3.主函数

根据题目要求写。

  • 求[l,r]中满足要求的数的个数

    calc()函数返回1~x中有多少个满足要求的数。用类似于前缀和的思想ans=calc(r)-calc(l-1)。

int main(){
    scanf("%d%d",&l,&r);
    printf("%d\n",calc(r)-calc(l-1));
    return 0;
}
  • 求[l,r]中满足要求的数的个数、和、平方和

    我们还是先用 数位DP-记忆化搜索 求解问题的方式去思考,如何在求解的过程中 记录/合并信息

    既然是 搜索,不妨先用 分治的思想,把 大问题集合 划分成多个 子问题集合,最后再进行 合并

    假设 我们当前已经搜出了 pos-1 层的信息,现在向上 回溯,如何把该信息合并到 大集合 中呢?

    考虑在 搜索 时,如何合并多条搜索分支 回溯 的 平方和信息

    \(∑_i(aA_i)^2=∑_i(a×10^{p−1}+A_i)^2=\)\((∑_i1)×(a×10 ^{p−1})^2+2×(a×10_{ p−1})×∑_iA_i+∑_iA_i^2\)
    根据上述 递推式 可知,我们枚举当前数位填 aa 以后下沉 搜索,然后 回溯时 需要传递的 信息 有:

    能接在 a 以后的数字 Ai 的个数 ∑i1
    能接在 a 以后的数字 Ai 的总和 ∑iAi
    能接在 a 以后的数字 Ai 的平方和 \(∑_iA_i^2\)

    于是我们可以把 记忆化搜索 的 属性值 开成 3维,分别记录这三个值: s0, s1, s2

    回溯的时候,分别对这三个值进行合并即可

    上述递推式给出了 s2 的合并, s1 的合并如下:

    \(∑_i(aAi)=∑_i(a×10^{p−1}+A_i)=\)\((∑_i1)×(a×10 ^{p−1})+∑_iA_i\)
    s0 的合并就是直接个数相加即可。

#include<bits/stdc++.h>
using namespace std;

typedef long long LL;
const LL N=20,MOD=1e9+7;

LL T,l,r;
LL pow_10[N];   //预处理10的幂次
LL a[N],len;
struct DP
{
    LL s0,s1,s2;
    void operator += (const DP &t)
    {
        s2=(s2+t.s2)%MOD;
        s1=(s1+t.s1)%MOD;
        s0=(s0+t.s0)%MOD;
    }
}dp[N][N][N];   //dp[pos][sum][val]

DP dfs(int pos,int sum,int val,bool limit)
{
    if(pos==0)
    {
        if(sum!=0 && val!=0) return (DP){1,0,0};
        else return (DP){0,0,0};
    }
    if(!limit && dp[pos][sum][val].s0!=-1) return dp[pos][sum][val];
    DP res={0,0,0};
    int up=limit ? a[pos] : 9;
    for(int i=0;i<=up;i++)
    {
        if(i==7) continue;
        DP t=dfs(pos-1,(sum+i)%7,(val*10+i)%7,limit && i==up);
        
        LL k=i*pow_10[pos-1]%MOD;
        t.s2=((t.s2+t.s1*2%MOD*k%MOD)%MOD+t.s0*k%MOD*k%MOD)%MOD;
        t.s1=(t.s1+t.s0*k%MOD)%MOD;
        res+=t;
    }
    return limit ? res : (dp[pos][sum][val]=res);
}

LL calc(LL x)
{
    memset(dp,-1,sizeof dp);
    len=0;
    while(x) a[++len]=x%10,x/=10;
    return dfs(len,0,0,true).s2;
}

int main()
{
    pow_10[0]=1;
    for(int i=1;i<20;i++)  pow_10[i]=pow_10[i-1]*10%MOD;
    
    scanf("%lld",&T);
    while(T--)
    {
        scanf("%lld%lld",&l,&r);
        printf("%lld\n",((calc(r)-calc(l-1))%MOD+MOD)%MOD); //!!!
    }
    
    return 0;
}
  • 求[l,r]中满足要求的数中某数码的出现次数

    在母题的基础上,选择可选参数 cnt(当前已有的某数码的个数),在 dp 函数中当 pos==0 且满足题目要求时返回 cnt。

  • 求第rk小的满足要求的数

    方法一

    一般从高位到低位考虑:

    对于每一位,从0到 进制-1 考虑填入:

    对于每一个考虑填入的数,借助数位dp(在参数限定下求正整数中有多少个满足题目要求的数,与模板基本相同。无需calc函数和limit限定。直接调用dp函数,每次调用不要初始化dp数组)计算在当前填入情况下(在调用dp函数时根据当前填入情况填写对应的参数限定)后面的方案数:
    
      若方案数小于rk,则令rk-=方案数,考虑下一个数。
    
      若方案数大于等于rk,则当前考虑的位填入当前考虑的数,考虑下一位。
    

    因为dp采用记忆化,所以在多组测试数据下,时间复杂度为O(max(数位dp状态数转移的复杂度,T位数*进制))。

//无需limit限定
int t;
ll rk,x;
ll f[N][/*可选参数*/][/*可选限定*/];    //f[pos][可选参数][可选限定]:在没有limit限定的情况下,当前从高位到低位考虑到第pos位且前面……且限定情况是……的方案数

ll dp(int pos,/*可选参数*/,/*可选限定*/)    //在参数限定下求正整数中有多少个满足题目要求的数,与模板基本相同
{
    if(pos==0) return /*合法条件*/ ? 1 : 0;
    ll &res=f[pos][/*可选参数*/][/*可选限定*/];
    if(res!=-1) return res;
    res=0;
    for(int i=0;i<=/*进制-1*/;i++)
    {
        if(/*不合法条件*/) continue;
        res+=dp(pos-1,/*可选参数*/,/*可选限定*/);
    }
    return res;
}

memset(f,-1,sizeof f);//注意在这里初始化
scanf("%d",&t);
while(t--)
{
    x=0;
    scanf("%lld",&rk);
    int pos=/*可能的最高位数*/,/*可选参数*/;bool /*可选限定*/;  //答案x的参数限定
    while(pos>=1)
    {
        x*=/*进制*/;
        for(int i=0;i<=/*进制-1*/;i++)
        {
            ll res=dp(pos-1,/*可选参数*/,/*可选限定*/); //根据当前填入情况填写对应的参数限定
            if(res<rk) rk-=res;
            else    //第pos位填入i
            {
                x+=i;
                /*根据第pos位填入i,更新答案x的参数限定
                ……
                */
                break;
            }
        }
    }
    printf("%lld\n",x);
}

方法二

二分查找。

l=1,r=MAX;calc(mid)≥rk;

在多组测试数据下,写法“dp数组f中不加入limit并在dp过程中进行!limit的判断”的时间复杂度是O(max(数位dp状态数转移的复杂度,Tlog MAX位数转移的复杂度)),写法“在dp数组f中加入limit并在每次调用calc()时初始化dp数组f”的时间复杂度是O(Tlog MAX数位dp状态数*转移的复杂度)。

scanf("%lld",&rk);
ll l=1,r=MAX;
while(l<r)
{
    ll mid=(l+r)>>1;
    if(calc(mid)>=rk) r=mid;
    else l=mid+1;
}
printf("%lld\n",l);
  • 求[l,r]中f(x)的最值

    下面的方法要求f(x)较小。下面以最大值为例。

    从f(x)的上界到下界枚举key,数位dp计算[l,r]中满足f(x)等于key的数的个数,如果大于等于1则直接结束,答案是这个key。

7.3.2.循环写法

模板

常用于二进制数计数。

时间复杂度O(T二进制位数转移的复杂度)。

for(int k=bit;k>=0;k--)
{
    if((x>>k)&1)//当前原数的最高位是1
    {
        /*当前最高位填0,后面的任意填,直接计算
        ……
        ans+=calc();
        */
        
        /*当前最高位填1,继续迭代
        ……
        */
    }
    else//当前原数的最高位是0,只能填0,继续迭代
    {
        /*
        ……
        */
    }
    
    //边界情况:填的数就是x
    if(k==0)
    {
        if(check(x)) ans++;
    }
}

推论dp

对于小于(大于)等于x的二进制数x',一种dp状态设计:f_k:在小于(大于)等于x的前提下,从高到低的第一位k满足在第k位下x为1并且x'为0(或者x为0并且x'为1),特别地当k=-1时表示x=x',……

意味着比k高的位与x相同,比k低的位没有小于(大于)等于x的限制。

7.3.3.卢卡斯拆位数位dp

适用条件:求解对于0≤i≤n,0≤j≤min(i,m)中\((\sum C_i^j) \mod p\)或有多少对(i,j)满足\(C_i^j\)是p的倍数,且p是一个小质数

在模p的意义下,由卢卡斯定理得:\(C_n^m=C_{\lfloor n/p \rfloor}^{\lfloor m/p \rfloor}*C_{n \mod p}^{m \mod p}=C_{\lfloor \lfloor n/p \rfloor /p \rfloor}^{\lfloor \lfloor m/p \rfloor /p \rfloor}*C_{\lfloor n/p \rfloor \mod p}^{\lfloor m/p \rfloor \mod p}*C_{n \mod p}^{m \mod p}=\prod C_{n_{p_i}}^{m_{p_i}}\)。其中\(n_{p_i}\)表示n在p进制下的第i位。各个数位之间是相乘关系。

  • 例1:给定n、m,求对于0≤i≤n,0≤j≤min(i,m)中有多少对(i,j)满足\(C_i^j\)是p的倍数,且p是一个小质数

    \(\Rightarrow\)在模p的意义下:\(C_i^j=\prod C_{i_{p_{i'}}}^{j_{p_{j'}}}=0\)。也就是求解有多少对(i,j)满足在p进制下至少有一个数位j>i(注意仍要满足原数j≤i!)。

int p;
int n[N],m[N],nidx,midx;
LL dp[N][2][2][2][2];   //dp[pos][ok][limit_n][limit_m][limit_nm]:第pos位、是否前面已经满足某一数位的组合数为0(C_i^j中j>i)、是否有n对当前i的最高位可以填数的限定、是否有m对当前j的最高位可以填数的限定、是否有i对当前j的最高位可以填数的限定(要求原数j<=i)

LL dfs(int pos,bool ok,bool limit_n,bool limit_m,bool limit_nm)
{
    if(pos==0) return ok;
    if(dp[pos][ok][limit_n][limit_m][limit_nm]!=-1) return dp[pos][ok][limit_n][limit_m][limit_nm];
    LL res=0;
    int up_n=limit_n ? n[pos] : p-1,up_m=limit_m ? m[pos] : p-1;
    for(int i=0;i<=up_n;i++)
        for(int j=0;(!limit_nm || j<=i) && j<=up_m;j++)
            res=(res+dfs(pos-1,ok || j>i/*满足某一数位的组合数为0(C_i^j中j>i)*/,limit_n && i==up_n,limit_m && j==up_m,limit_nm && j==i))%MOD;
    dp[pos][ok][limit_n][limit_m][limit_nm]=res;
    return dp[pos][ok][limit_n][limit_m][limit_nm];
}

LL calc(LL nn,LL mm)
{
    memset(dp,-1,sizeof dp);
    
    //p进制
    nidx=midx=0;
    while(nn) n[++nidx]=nn%p,nn/=p;
    while(mm) m[++midx]=mm%p,mm/=p;
    
    //给较小的数的数位补零
    for(int i=nidx+1;i<=midx;i++) n[i]=0;
    for(int i=midx+1;i<=nidx;i++) m[i]=0;
    
    return dfs(max(nidx,midx),false,true,true,true);
}

printf("%lld\n",calc(nn,mm));
  • 例2:给定n、m,求\(\sum\limits_{i=1}^{+\infty} (C_{m}^{i}*C_{m}^{n-i}) \mod p\),且p是一个小质数

    因为i的上界就是n(i>n时,\(C_{m}^{n-i}=0\)),而式子又出现减法,当枚举某一数位时\(n_{p_j}-i_{p_j}\)可能为负数,需要向高位借位,因此从低向高枚举数位。记忆化搜索多传一个变量borrow:当前的数位有没有被上一个低数位借位。

int dfs(int pos,bool bor)
{
    if(pos>max(nidx,midx)) return !bor; //最高位不能被借位
    int &res=f[pos][bor];
    if(res!=-1) return res;
    res=0;
    for(int i=0;i<p;i++)//i的上界就是n,若枚举的i>n则一定会向最高位的高位借位而被上面特判掉。因此这里不用考虑当前数位可以填数的限定
    {
        int u1=i,u2=n[pos]-i-bor;   //-bor:被低位借位
        bool b=false;
        if(u2<0) u2+=p,b=true;  //若为负数,则向高位借位
        res=(res+dfs(pos+1,b)*C(u1,m[pos])%p*C(u2,m[pos])%p)%p; //各个数位之间是相乘关系
    }
    return res;
}

int calc(int nn,int mm)
{
    //得到nn和mm的数位数组,初始化f……
    
    return dfs(1,0);
}

printf("%d\n",calc(nn,mm));

7.4.计数和计数类dp

  • 不重不漏。
  • 贡献法:当原问题计数困难时,尝试把角度转化为每个元素对答案的贡献。
    • 一个集合的所有子集的元素种类数之和\(=\sum\limits_i(2^{cnt_i}-1)2^{n-cnt_i}\)
  • 序列min/max 计数尝试排序后解决。
  • 题目的信息中有的确定而有的不确定,不确定的个数就是方案数。

7.4.1.计数与贡献

计数:\(f_i=\sum\limits_{\mathbb{C}_i} 1\)

贡献:\(g_i=\sum\limits_{\mathbb{C_i}} w(\mathbb{C_i})\)

借助计数进行贡献的状态转移:当从状态j转移到状态i时,

  1. \(g_i=g_i*f_j*calc(i,j)+g_j*f_i*calc(i,j)\)
  2. \(f_i=f_i*f_j*calc(i,j)\)

7.4.2.集合计数和序列计数

定序序列计数=集合计数。

无序序列计数=定序序列计数*排列。

7.4.2.1.有序序列计数

有序:\(e.g.\)拓扑序……

通常设\(f[i][j]\):i的排名是j的方案数/数值i位于前i个数的第j位。

确定形态的n个点的有根树的拓扑序个数

1~n的全排列个数=n!。

只考虑“节点u必须排在以u为根的子树中所有节点的前面”这一条件,只有n!/size[u]个排列是符合要求的。

每个节点的条件之间无关(条件只对当前节点有关)。

\(ANS=\frac{n!}{\prod size[u]}\)

7.4.3.区间贡献区间

适用条件:给定若干个贡献区间\([l_i,r_i]\),询问区间[L,R]在满足\(L≤l_i≤r_i≤R\)时,一个贡献区间\([l_i,r_i]\)会对其增加一个贡献\(p_i\)而不是\((r_i-l_i+1)*p_i\))。快速求出询问区间[L,R]的贡献总和。

离线做法

  1. 将贡献区间按左端点排序。

    一个端点相同且贡献相同的可以合并。下面以\([l_{i_1}\sim l_{i_2},r_i]\)为例子。(\([l_i,r_{i_1}\sim r_{i_2}]\)也是可以的。)

    对于多个贡献区间\([l_{i_1}\sim l_{i_2},r_i]\),每一个都产生一个贡献\(p_i\):在扫描到\(r_i\)时,给序列区间\([l_{i_1},l_{i_2}]\)每个点上增加贡献\(p_i\)

    也就是说扫描到一个单点时,要给其一个序列区间(不一定包含该单点)每个点上增加贡献。

  2. 从左往右扫描单点。只有扫描到单点i时,才给单点i所对应的序列区间每个点上增加贡献。(保证\(r_i≤R\)。包括上面区间合并成\([l_i,r_{i_1}\sim r_{i_2}]\)也是满足的,因为在序列区间询问到哪儿就是哪儿的贡献。)

  3. 对于一个询问区间[L,R],在扫描完点L-1的时候,令答案-=此时序列区间[L,R]所有单点的贡献和;在扫描完点R的时候,令答案+=此时序列区间[L,R]所有单点的贡献和。(保证\(L≤l_i\)。)

上述操作可以借助线段树实现。

7.4.4.匹配贡献

在dp设计中,需要在一对匹配结束后才能确定该对匹配的贡献。因此把贡献放在匹配的第二个元素计算

如果一对匹配是从2个序列中各选1个元素,则将2个序列的元素放在一起排序。

  • 如果一对匹配的贡献只与其中一个元素有关(\(e.g.\)min/max。),那么将元素排序,使得后面的元素与前面的元素匹配时,贡献只与后面的元素有关,这样dp就无后效性
  • 如果一对匹配的贡献与两个元素都有关,尝试转化角度,使得在后面的元素计算贡献时只利用后面的元素的信息
    • \(\sum_j a_i*b_j→a_i*sumb_j\)
    • cnt(前i个元素中有……个)有时是一个好帮手。
    • 直接计算→增量计算。
    • 二分图找环:右部节点负责新建交错路,左部节点负责合并交错路。

\(f[i][j]\):前i个元素中已经选好了j对匹配的……。

状态转移:

  • 当前元素与后面的元素匹配,该对匹配尚未结束,暂不计算贡献:f[i][j]←f[i-1][j]。
  • 当前元素与前面的元素匹配,在此时计算该对匹配的贡献:f[i][j]←calc(f[i-1][j-1])。

7.4.5.树上计数

7.4.5.1.树上点对贡献

在点对的lca处进行统计。

\(f_u\):当前已合并到以u为根的子树的节点的信息。

转移:在遍历到点u的一个儿子点v时,先把子树v的信息\(f_v\)\(f_u\)进行点对贡献统计,再把\(f_v\)合并到\(f_u\)。可以参考《图论7.1.树形dp求树的直径》

  1. \(ANS←f_u\oplus f_v\)
  2. \(f_u←f_v\)

在dp中,(i,j)与(j,i)视为相同,不会重复计算。根据题意(i,j)与(j,i)是否算不同的贡献,判断最终答案是否要乘2。

可能还会配上启发式合并。

7.4.5.2.树上链贡献与树上链对贡献

参考:《3.4.2.不相交链模型》《7.4.5.1.树上点对贡献》

7.4.5.3.树上连通块计数

7.4.5.3.1.树上连通块计数

\(\sum\limits_{\mathbb{V}}1\)

类似于《7.4.5.1.树上点对贡献》

在连通块中深度最小的点处进行统计。

下面简记“连通块中深度最小的点是点u的连通块”为“以点u为根的连通块”。

\(f_u\):当前已统计的以点u为根的连通块的个数。

转移:在遍历到点u的一个儿子点v时,情况分为删除边(u,v)和不删除边(u,v),\(f_u=f_u*1+f_u*f_v\)

边界:\(f_u=1\)。其他为0。

\(ANS=\sum\limits_uf_u\)

补充:另一种dp设计:\(f_{u,d}\):考虑了以u为根的子树,当前u所在连通块的根的深度为d,……。

7.4.5.3.2.树上连通块贡献

\(\sum\limits_{\mathbb{V}}w(\mathbb{V})\)

类似于《7.4.5.3.1.树上连通块计数》

所有连通块大小的乘积=在每个连通块内恰好选一个点的方案数。dp多开一维0/1表示当前连通块有/无选关键点。

7.4.5.3.3.树上连通块内点贡献

结合《7.4.1.计数与贡献》《7.4.5.3.2.树上连通块贡献》

\(\sum\limits_{\mathbb{V}}\sum\limits_{u\in\mathbb{V}}a_u\)

\(f_u\):当前已统计的以点u为根的连通块的个数。

\(g_u\):当前已统计的以点u为根的连通块内的点的贡献。

转移:在遍历到点u的一个儿子点v时,情况分为删除边(u,v)和不删除边(u,v),

  1. \(g_u=g_u*1+(g_u*f_v+g_v*f_u)\)
  2. \(f_u=f_u*1+f_u*f_v\)

边界:\(f_u=1,g_u=a_u\)。其他为0。

\(ANS=\sum\limits_ug_u\)

7.4.5.3.4.树上连通块内点对贡献

\(\sum\limits_{\mathbb{V}}\sum\limits_{u,v\in\mathbb{V}}(a_u\oplus a_v)\),其中⊕满足对加法的分配律。

异或:拆位,转化为1的个数与0的个数的乘积,即可满足对加法的分配律。

结合《7.4.5.1.树上点对贡献》《7.4.5.3.3.树上连通块内点贡献》。点对仍在点对的lca处进行统计,借助树上连通块的转移将点对贡献到其所属的所有连通块。

\(f_u\):当前已统计的以点u为根的连通块的个数。

\(g_u\):当前已统计的以点u为根的连通块内的点的贡献。

\(h_u\):当前已统计的以点u为根的连通块内的点对的贡献。

转移:在遍历到点u的一个儿子点v时,情况分为删除边(u,v)和不删除边(u,v),

  1. \(h_u=h_u*1+(h_u*f_v+h_v*f_u+g_u\oplus g_v)\)

    当不删除边(u,v)时,统计到含点u和点v的连通块,情况分为点对都在当前以点u为根的连通块(\(h_u*f_v\))、点对都在以点v为根的连通块(\(h_v*f_u\))和点对分别在当前以点u为根的连通块和以点v为根的连通块(\(g_u\oplus g_v\))。

  2. \(g_u=g_u*1+(g_u*f_v+g_v*f_u)\)

  3. \(f_u=f_u*1+f_u*f_v\)

边界:\(f_u=1,g_u=a_u,h_u=0\)。其他为0。

\(ANS=\sum\limits_uh_u\)

在dp中,(i,j)与(j,i)视为相同,不会重复计算。根据题意(i,j)与(j,i)是否算不同的贡献,判断最终答案是否要乘2。

7.4.5.3.5.树上连通块内点对贡献和合并子树信息模型

\(\sum\limits_{cnt_\mathbb{V}(\mathbb{G})=m+1}\sum\limits_{\mathbb{V}\in\mathbb{G}}\sum\limits_{u,v\in\mathbb{V}}(a_u\oplus a_v)\),其中⊕满足对加法的分配律。

\(f_{u,j}\):在以点u为根的子树内,当前已删除了j条边,已统计的以点u为根的连通块的个数。

\(g_{u,j}\):在以点u为根的子树内,当前已删除了j条边,已统计的以点u为根的连通块内的点的贡献。

\(h_{u,j}\):在以点u为根的子树内,当前已删除了j条边,已统计的所有连通块内的点对的贡献。

\(bf_j,bg_j,bh_j\)\(f_{u,j},g_{u,j},h_{u,j}\)的备份数组。备份后\(f_{u,j},g_{u,j},h_{u,j}\)清零。

转移:在遍历到点u的一个儿子点v时,

  • 删除边(u,v),
    • \(h_{u,j+k+1}←bh_j*f_{v,k}+h_{v,k}*bf_j\)
    • \(g_{u,j+k+1}←bg_j*f_{v,k}\)
    • \(f_{u,j+k+1}←bf_j*f_{v,k}\)
  • 不删除边(u,v),
    • $h_{u,j+k}←bh_jf_{v,k}+h_{v,k}bf_j+bg_j\oplus g_{v,k} $。
    • \(g_{u,j+k}←bg_j*f_{v,k}+g_{v,k}*bf_j\)
    • \(f_{u,j+k}←bf_j*f_{v,k}\)

边界:\(f_{u,0}=1,g_{u,0}=a_u,h_{u,0}=0\)。其他为0。

\(ANS=h_{1,m}\)

在dp中,(i,j)与(j,i)视为相同,不会重复计算。根据题意(i,j)与(j,i)是否算不同的贡献,判断最终答案是否要乘2。

7.5.随机化dp

适用条件:当满足“x≤y”时可做→复杂度较低,可以dp多次。且题目要求最值。

x是“n/m/k”中的一个,y是比原范围小(很多)的范围:1.题中的另一个范围;2.转化题意(\(e.g.\)二分图:范围是2;在长度为n的序列中选出长度≥n/2的子序列:子序列中相邻元素在原序列的下标的距离至多是2)。

  1. 直接把范围限下来(e.g.将x随机映射到大小为y的集合,集合内元素视为同种元素,每个集合内只能选一个元素),将某些不确定的因素确定下来,发现会很容易解决这个问题。

    注意必须保证随机化后得到的答案合法

  2. 在草稿纸上计算:dp次数=限定时间内运算次数/1次dp的复杂度。再计算概率=(映射的正确数量(答案恰好映射到不同的集合)/映射的总数量)^dp次数。

  3. 按照限定后的范围来dp。根据上面的计算来确定dp次数。在保证不超时的情况下,dp次数越多,正确率越高。答案对每次dp得到的值取最值。

int rand_cnt;

srand(time(NULL));
rand_cnt=;  //dp次数=限定时间内运算次数/1次dp的复杂度
while(rand_cnt--)
{
    get_rand(); //直接把范围限下来,将x随机映射到大小为y的集合,集合内元素视为同种元素,每个集合内只能选一个元素
    ans=min/max(ans,dp());   //按照限定后的范围来dp,答案对每次dp得到的值取最值
}
printf("%d\n",ans);

7.6.拉格朗日插值优化dp

适用条件:1.dp的内层状态涉及值域;2.或要对每个全局的限制都做一遍dp,而全局的限制的范围是值域;3.可以证明dp是一个多项式(边界是多项式,转移只涉及四则运算),最终只要求一个状态的值。

\(f_{i,j}\),其中j是值域为例:

\(f_{i,j}\)看作\(f_i(j)\),第i个关于j的多项式。设g(i)为\(f_i(j)\)的次数。只需对于每个\(j\in[1,g(n)+1]\)求出\(f_n(j)\),就可以利用拉格朗日插值\(O(g(n))\)\(O(g(n)^2)\)求出\(f_n(W)\)

求g(i)

  1. 简单的情况

    • 以转移方程是\(f_{i,j}=f_{i,j-1}+j*f_{i-1,j-1}\),边界是\(f_{0,j}=1\)为例

      \(\Rightarrow f_i(j)-f_i(j-1)=j*f_{i-1}(j-1)\)

      \(f_i(x)=\sum\limits_{k=0}^{g(i)}a_{i,k}x^k\)\(\Rightarrow \sum\limits_{k=0}^{g(i)}a_{i,k}j^k-\sum\limits_{k=0}^{g(i)}a_{i,k}(j-1)^k=j*\sum\limits_{k=0}^{g(i-1)}a_{i-1,k}(j-1)^k\)

      令k=g(i),可求出左边是g(i)-1次;令k=g(i-1),可求出右边是g(i-1)+1次。

      \(\Rightarrow g(i)-1=g(i-1)+1\),又因为g(0)=0(边界),所以g(i)=2*i。

  2. 复杂的情况

    \(f_i(j)\)的具体多项式是随j一段一段确定的:对于每一段单独求解分别拉插。

8.dp转移优化

若候选集合扩大对答案无影响,则可考虑将候选集合扩大到其的内容与循环变量无关,有时可以起到优化的作用。

优化dp决策的候选集合。

8.1.剪枝有效状态数

适用条件:发现有效状态数很少。

  • 注意题目特殊条件,有时转移只需要从前x个状态转移而来。
  • 若状态之间两两不同不需要合并,则用vector存该轮的有效状态,用该vector转移到下一轮。
    • 对于\(f_i=\max\limits_{j=0}^{i-1}/\min\limits_{j=0}^{i-1}(f_j+val(j,i))\),对于每个i,最多有\(O(M)\)种不同的\(f_j\)或者\(val(j,i)\)

      维护决策点j的最小集合(具有相同的\(f_j\)或者\(val(j,i)\)的j合并为一个决策点),从该集合里转移。复杂度为\(O(NM)\)

  • 若需要合并相同状态,则用map(或vector+sort)/unordered_map存该轮的有效状态,用该map/unordered_map转移到下一轮。

8.2.矩阵乘法优化dp

适用条件:

  1. 可以抽象为一个长度为n的一维向量,该向量在每个单位时间发生一次变化;
  2. 变化的形式是一个线性递推(只有若干个“加法”和“数乘”运算);
  3. 递推式每个时间可能作用于不同的数据上,但本身保持不变;
  4. 递推的轮数很大,但向量长度n不大。

《数学2.1.2.3.应用:加速递推》

数学

8.3.数据结构优化dp

维护dp决策的候选集合。

一般作用是快速插入元素、删除元素、查询最值。把朴素在取值范围中枚举决策的时间优化为维护数据结构的时间。

8.3.1.前缀和优化

适用条件:内层循环是连续的,且可以前缀和。从而省略内层循环。

有时可以像推式子那样调整枚举顺序,把连续的好求的变量放入内层循环,从而可以前缀和优化。

8.3.2.优化决策的候选集合

  • 决策的候选集合的上界只增大,下界不变——一般一个变量维护最值。

  • 决策的候选集合的上、下界均单调变化,每个决策在候选集合插入或删除最多一次——单调队列优化dp

    基本条件:f[i]=min/max{f[j]+val(i,j)} //L(i)<=j<=R(i)其中多项式val(i,j)中的每一项都只与i(如果是i-1,可直接把外层循环i看作定值)和j的其中一个有关。

    多维dp在执行内部循环时,把外部循环变量看作定值。单调队列维护决策候选集合。

    转移的复杂度均摊 \(O(1)\)

    “长度固定的最值”、“连续的”、“不能选择超过m个连续的”……:单调队列优化dp。

    “已知最值,求固定长度”:二分长度,check()函数中执行单调队列优化dp。

    “最优性”、“队头状态转移”、“可行性+插入i”的顺序要根据题意而定:是谁影响谁(\(e.g.\)滑动窗口:“可行性+插入i”对答案有影响,因此“可行性+插入i”要在“计算答案”的前面;单调队列优化dp:计算答案时不能算上i本身,因此“可行性+插入i”要在“计算答案”的后面)。

int hh=0,tt=-1;
//q[++tt]=0;//插入f[0]=0,根据题意判断f[0]是否合法、是否可以用于状态转移
//f[1]=...;q[++tt]=1;//若f[0]不合法,则先算出f[1],再把f[1]插入队列
for (int i=1;i<=n;i++)
{
    if(q[hh]<L(i)) hh++ ;//可行性,当队头不在i的决策范围内
    f[i]=f[q[hh]]+val(i,q[hh]);//状态转移
    while(hh<=tt && f[q[tt]]>=f[i]) tt--;//最优性
    q[++tt]=i;//插入下一个i+1的候选集合
}
  • 决策的候选集合的变化更复杂

    多维dp在执行内部循环时,把外部循环变量看作定值。状态转移取最优决策时,简单的限制条件用循环顺序处理,复杂的用数据结构维护。

    • 例题1:求多个区间覆盖区间\([l,r]\)的最小花费

      设n个按右端点排序的区间\([l_i,r_i]\)

      \(f[r_i]=\min\limits_{l_i-1≤x<r_i} \{ f[x] \}+c_i\)

      本质上是一个带有修改的区间最值操作。

      线段树维护。

    技巧:按照右端点递增排序。

    • 例题2:求序列\(A\)长度为\(M\)的严格递增子序列

      求序列\(A\)长度为\(M\)的严格递增子序列。

      \(f[i][j]\):前\(j\)个数以\(A_j\)结尾的数列,长度为\(i\)的严格递增子序列有多少个(\(i\)\(j\)均可作阶段)。

      特殊地,令\(A_0=-INF\)

      状态转移方程

const int INF=1<<30,MOD=1e9+7;

memset(f,0,sizeof f);
a[0]=-INF;

f[0][0]=1;
for(int i=1;i<=m;i++)
    for(int j=1;j<=n;j++)
        for(int k=0;k<j;k++)
            if(a[k]<a[j]) f[i][j]=(f[i][j]+f[i-1][k])%MOD;
            
int ans=0;
for(int i=1;i<=n;i++) ans=(ans+f[m][i])%MOD;
  #### 数据结构优化

  树状数组维护前缀和。

  在序列A(不包括$A_0$)中的数值的值域上建立树状数组。

  把外层循环i看作定值。当j增加1时,k的取值范围从0≤k<j变为0≤k<j+1,也就是多了一个k=j新决策。

  设一个决策$(A_k,f[i-1,k])$。

  1. 插入一个新决策。在j增加1前,把$(A_j,f[i-1,j])$插入集合:把$A_k$上的位置的值增加$f[i-1,k]$。
  2. 给定一个值$A_j$,查询满足$A_k<A_j$的二元组对应的$f[i-1,j]$的和:在树状数组计算$[1,A_j-1]$的前缀和。
//树状数组维护前缀和
#include<bits/stdc++.h>
using namespace std;

const int N=1005,MOD=1e9+7;
int t,n,m,ans;
int a[N],f[N][N];   //f[i][j]:前i个数以Aj结尾的数列,长度为j的严格递增子序列有多少个(i、j均可作阶段)
int nums[N],cnt;    //离散化
int tr[N];  //树状数组维护前缀和

inline int lowbit(int x){
    return x&-x;
}

void add(int x,int v){
    while(x<=cnt){
        tr[x]=(tr[x]+v)%MOD;
        x+=lowbit(x);
    }
    return ;
}

int sum(int x){
    int res=0;
    while(x>0){
        res=(res+tr[x])%MOD;
        x-=lowbit(x);
    }
    return res;
}

int main(){
    scanf("%d",&t);
    for(int C=1;C<=t;C++){
        ans=0;
        scanf("%d%d",&n,&m);
        cnt=0;
        for(int i=1;i<=n;i++){
            scanf("%d",&a[i]);
            nums[++cnt]=a[i];
        }

        //离散化
        sort(nums+1,nums+cnt+1);
        cnt=unique(nums+1,nums+cnt+1)-nums-1;   //注意这里要-1
        for(int i=1;i<=n;i++) a[i]=lower_bound(nums+1,nums+cnt+1,a[i])-nums+1;


        for(int i=1;i<=n;i++) f[i][1]=1;
        for(int j=2;j<=m;j++){
            for(int i=1;i<=cnt;i++) tr[i]=0;
            for(int i=1;i<=n;i++){
                f[i][j]=sum(a[i]-1);
                add(a[i],f[i][j-1]);
            }
        }

        for(int i=1;i<=n;i++) ans=(ans+f[i][m])%MOD;

        printf("Case #%d: %d\n",C,ans);
    }
    return 0;
}
例题3:用$\min\limits_{j<i,a[j]<a[i]}f_j$转移状态:相当于是二维偏序(j<i的要求已经在从小到大枚举i时自然满足)问题,使用以a[i]为下标的权值线段树。

8.3.3.bitset优化\(O(\frac{F(N)}{\omega}),\omega=64\)

下面以状态是\(f_{i,j}\)为例。

适用条件:1.\(f_{i,j}\)是布尔类型;2.转移形如\(f_{i,j}←f_{i-1,j+c}\),以便bitset轻松移位转移。3.数据档之间只相差几倍常数。

新建bitset<N> f[N]\(f_{i,j}\)的最内层维度j压入bitset。由于\(f_{i,j}\)是布尔类型,所以转移可以借助二进制运算,考虑转移方程中\(f_{i,j}←f_{i-1,j+c}\)的c来确定二进制运算和移位问题。

8.4.斜率优化dp

基本条件:f[i]=min/max{f[j]+val(i,j)} //0<=j<i其中多项式val(i,j)包含一个同时与i(而不是i-1。如果是i-1,可把外层循环i看作定值)和j都有关的部分(如乘积项)。

技巧

  1. 注意包含i或j的高次项是可能用单调队列或斜率优化的。

    \(e.g.\)\(f[i]=\min\limits_{j=0}^{i-1} \{ f[j]+(s[i]-s[j])^2 \}→f[j]+s[j]^2=2*s[i]*s[j]+f[i]-s[j]^2\)

  2. 分类讨论可以消灭成一个方程。

    \(e.g.\)\(f[i]=\begin{cases} \min\{f[j]+val(i,j)\} & j\%2==0 \\ \min\{f[j]+val(i,j)+c\} & j\%2==1 \end{cases}\)

    预处理\(g[i]=c*[j\%2==1]\)。则\(f[i]=\min\{ f[j]+val(i,j)+g[j] \}\),然后就可以斜率优化了。

分析斜率

下面以最值min为例:

只有f[i]、j是未知量,其余(如a[i])是已知量。

把min去掉,把关于j的值\(f[j]\pm a[j]\pm \cdots\)(视为y,仅与j有关的所有项)和a[i]a[j](或a[i]b[j]或i*a[j]……。其中a[i]视为斜率k、a[j]视为x,i和j的乘积项)看作变量,要求的f[i]看作参数,其余看作常数:\(f[j]\pm a[j]\pm \cdots\)\(=\)\(a[i]\)\(*\)\(a[j]\)$\pm $$f[i]$$\pm \cdots$。

f[i]最小 → 即找到一个点\((a[j],f[j]\pm a[j]\pm \cdots)\),使斜率为a[i]的直线过这个点的截距\(f[i]\pm \cdots\)最小 → 凸包问题。

取纵坐标函数get_y(int i){return f[i]+a[i]+...;}

求当前斜率(hh+1和hh、tt和tt-1、mid+1和mid两点构成的斜率)是否小于要求斜率a[i]:(get_y(q[hh+1])-get_y(q[hh]))/(a[q[hh+1]]-a[q[hh]])<=a[i]。为了防止误差(整除)还可以把分母乘过去。

8.4.1.斜率\(a[i]\)单调递增,所加的点的横坐标\(a[j]\)也单调递增 \(O(N)\)

用单调的队列(并非单调队列)维护下凸包,且在满足可行性后队头一定是答案。

由于所加的点的横坐标单调递增,所以可以用(get_y(q[tt])-get_y(q[tt-1]))*(a[i]-a[q[tt]])>=(get_y(i)-get_y(q[tt]))*(a[q[tt]]-a[q[tt-1]])判断点是否在凸包上。

  1. 在查询时,可以将队头小于当前斜率的点全部删掉;

    由于斜率单调递增,所以可以用队头算出f[i];(由于插入的判断式包含f[i],故要先用q[hh]算出f[i])

  2. 在插入时,将队尾所有不在凸包上的点全部删掉;

    插入i。(由于所加的点的横坐标单调递增,所以i一定满足插入的条件)

int get_y(int i)
{
    return f[i]+a[i]+...;
}

int hh=0,tt=-1;
//q[++tt]=0;//插入f[0]=0,根据题意判断f[0]是否合法、是否可以用于状态转移
//f[1]=...;q[++tt]=1;//若f[0]不合法,则先算出f[1],再把f[1]插入队列
for(int i=1;i<=n;i++){
    //注意hh<tt不取等号,因为要保证至少两个元素

    //队头元素斜率小于当前斜率就会被删除,为了防止误差(整除)把分母乘过去
    while(hh<tt && get_y(q[hh+1])-get_y(q[hh])<=a[i]*(a[q[hh+1]]-a[q[hh]])) hh++;
        
    //由于插入的判断式包含f[i],故要先用q[hh]算出f[i]
    int j=q[hh];
    f[i]=f[j]+val(i,j);

    //当队尾元素和它的前一个元素连成的直线的斜率大于i与它前一个连成的斜率就说明不是凸包下边界,删除队尾
    while(hh<tt && (get_y(q[tt])-get_y(q[tt-1]))*(a[i]-a[q[tt]])>=(get_y(i)-get_y(q[tt]))*(a[q[tt]]-a[q[tt-1]])) tt--;
    q[++tt]=i;//插入i
}
printf("%lld\n",f[n]);

8.4.2.斜率不具有单调性,但是所加的点的横坐标仍单调递增 \(O(NlogN)\)

用单调的队列(并非单调队列)维护下凸包:

由于所加的点的横坐标单调递增,所以可以用(get_y(q[tt])-get_y(q[tt-1]))*(a[i]-a[q[tt]])>=(get_y(i)-get_y(q[tt]))*(a[q[tt]]-a[q[tt-1]])判断点是否在凸包上。

  1. 在查询时,只能二分查找并算出f[i];(由于插入的判断式包含f[i],故要先用q[hh]算出f[i])

  2. 在插入时,将队尾所有不在凸包上的点全部删掉;

    插入i。(由于所加的点的横坐标单调递增,所以i一定满足插入的条件)

int search(int k,int l,int r){
    while(l<r){
        int mid=(l+r)>>1;
        if(get_y(q[mid+1])-get_y(q[mid])<=k*(a[q[mid+1]]-a[q[mid]])) l=mid+1;
        else r=mid;
    }
    return q[l];
}

int hh=0,tt=-1;
//q[++tt]=0;//插入f[0]=0,根据题意判断f[0]是否合法、是否可以用于状态转移
//f[1]=...;q[++tt]=1;//若f[0]不合法,则先算出f[1],再把f[1]插入队列
for(int i=1;i<=n;i++){
    //注意hh<tt不取等号,因为要保证至少两个元素

    //由于插入的判断式包含f[i],故要先二分查找并算出f[i]
    int j=search(a[i],hh,tt);
    f[i]=f[j]+val(i,j);

    //当队尾元素和它的前一个元素连成的直线的斜率大于i与它前一个连成的斜率就说明不是凸包下边界,删除队尾
    while(hh<tt && (get_y(q[tt])-get_y(q[tt-1]))*(a[i]-a[q[tt]])>=(get_y(i)-get_y(q[tt]))*(a[q[tt]]-a[q[tt-1]])) tt--;
    q[++tt]=i;//插入i
}
printf("%lld\n",f[n]);

8.4.3.任意情况

《数据结构‧数8.7.3.1.李超线段树优化斜率优化dp\(O(N \log N)\)

8.5.决策单调性优化dp

8.5.1.四边形不等式优化dp

基本条件:多项式val(i,j)注意是小的数在前)包含i与j的高次乘积,无法用单调队列或斜率优化。但是val(i,j)是一个二元函数,可以考虑判断其是否满足四边形不等式。

判定条件

\(w(x,y)\)是定义在整数集合上的二元函数,若对于定义域上的任意整数\(a <= b <= c <= d\),都有\(w(a,d) + w(b,c) >= w(a,c) + w(b,d)\),则函数\(w\)满足四边形不等式。

\(w(x,y)\)是定义在整数集合上的二元函数,若对于定义域上的任意整数\(a < b\),都有\(w(a,b+1) + w(a+1,b) >= w(a,b) + w(a+1,b+1)\),则函数\(w\)满足四边形不等式。

8.5.1.1.一维四边形不等式

基本条件:****f[i]=min{f[j]+val(j,i)}+c //L(i)<=j<i其中多项式val(j,i)满足四边形不等式,L(i)是常数或关于i的单调递增的一次函数。

具有决策单调性的判定(四边形不等式优化dp的前提)

在状态转移方程\(f[i]=min\{f[j]+val(j,i)\}+c,L(i)<=j<i\)中,若\(val\)满足四边形不等式,则\(f\)具有决策单调性。

证明\(val\)满足四边形不等式:讨论函数的增减性、求导、打表观察val。

决策单调性的定义

对于形如\(f[i]=min\{f[j]+val(j,i)\}+c,L(i)<=j<i\)的状态转移方程,记\(p[i]\)为令\(f[i]\)取到最小值的\(j\)的值,即\(p[i]\)\(f[i]\)的最优决策。若\(p\)\([1,N]\)上非严格单调递增,则称\(f\)具有决策单调性。

当f有决策单调性时的优化做法: \(O(N^2)\)优化到\(O(NlogN)\)

8.5.1.1.1.单调的队列写法

优点:可以在线。

利用决策单调性和一个单调的队列维护决策(三元组(i,l,r):决策i在f[l~r]上比其他任何决策都更优)集合。

根据L(i)求出R(j):状态j能合法转移到的最大的状态。

注意,只要问题的决策具有单调性,无论问题是不是dp,都可套用下面insert()函数。

void insert(int i)
{
    int pos=q[tt].r+1;
    
    //不断取出队尾(j,l,r)
    
    //若对于f[l]来说,i是比j更优的决策,则直接删除队尾,记pos=l
    while(hh<=tt && f[i]+val(i,q[tt].l)+c<=f[q[tt].j]+val(q[tt].j,q[tt].l)+c)
    {
        pos=q[tt].l;
        tt--;
    }
    
    //若对于f[r]来说,i是比j更优的决策,则二分查找求出pos:在pos之前,决策j更优;在pos及pos之后,决策i更优
    if(hh<=tt && f[i]+val(i,q[tt].r)+c<=f[q[tt].j]+val(q[tt].j,q[tt].r)+c)
    {
        int l=q[tt].l,r=q[tt].r;
        while(l<r)
        {
            int mid=(l+r)>>1;
            if(f[i]+val(i,mid)+c<=f[q[tt].j]+val(q[tt].j,mid)+c) r=mid;
            else l=mid+1;
        }
        q[tt].r=r-1;
        pos=r;
    }
    
    //把新的(i,pos,N)插入队尾
    if(pos<=R(i)) q[++tt]={i,pos,R(i)};
    
    return ;
}

hh=0,tt=-1;
/*
q[++tt]={0,1,R(0)};//插入f[0]=0,根据题意判断f[0]是否合法、是否可以用于状态转移
f[1]=...;q[++tt]={1,1,R(1)};//若f[0]不合法,则先算出f[1],再把f[1]插入队列
*/
for(int i=1/*若上面把f[1]插入队列,则这里i是从2开始*/;i<=n;i++)
{
    //可行性,检查队头
    if(q[hh].r<i) hh++;//不满足要求删除队头
    q[hh].l=i;//满足要求令l=i
    
    //利用队头计算出f[i]
    f[i]=f[q[hh].j]+val(q[hh].j,i)+c;
    
    //插入新决策i
    insert(i);
}

8.5.1.1.2.离线分治写法

优点:好写。当val(j,i)必须用类似莫队的方式求解时,仍然可以保证复杂度。缺点:必须离线。

对于每一层[l,r]都先在决策区间\([d_l,d_r]\)枚举决策点暴力计算\(f_{mid}\)并记录\(f_{mid}\)的最优决策点\(d_{mid}\),递归计算[l,mid-1]/[mid+1,r]时,他们的决策区间缩小为\([d_l,d_{mid}]/[d_{mid},d_r]\),下一层决策点的枚举量减半,因此总的时间复杂度是\(O(N\log N)\)

当val(j,i)必须用类似莫队的方式求解时,每一层指针移动的距离是\(O(N)\)的,分治一共有\(O(\log N)\)层,因此分治过程中计算val(j,i)的复杂度仍然可以保证。

int n;
int a[N];
LL res,L,R,cnt[N];
LL f[N];

//当val(j,i)必须用类似莫队的方式求解时
LL val(int l,int r)
{
    while(l<L) L--,add(a[L]);
    while(R<r) R++,add(a[R]);
    while(L<l) del(a[L]),L++;
    while(r<R) del(a[R]),R--;
    return res;
}

void divide(int l,int r,int dl,int dr)  //l、r:求解区间;dl、dr:决策区间
{
    if(l>r/*超过边界*/ || dl>dr/*没有合法的转移,只能无解*/) return ;
    int mid=(l+r)>>1,dmid;  //dmid:f[mid]的最优决策
    for(int i=dl;i<=dr;i++)
        if(f[mid]>f[i]+val(i,mid))
        {
            f[mid]=f[i]+val(i,mid);
            dmid=i;
        }
    divide(l,mid-1,dl,dmid);
    divide(mid+1,r,dmid,dr);
    return ;
}

scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
L=1,R=n;
for(int i=1;i<=n;i++) add(a[i]);
memset(f,0x3f,sizeof f);
f[0]=0;
divide(1,n,0,n-1);
printf("%lld\n",f[n]);

8.5.1.2.二维四边形不等式

基本条件:f[i][j]=min{f[i][k]+f[k+1][j]+val(j,i)} //i<=k<jf[i][j]=min{f[i][k]+f[k][j]+val(j,i)} //i<=k<=j

具有决策单调性的判定(四边形不等式优化dp的前提)

在状态转移方程\(f[i][j]=\min\limits_{i\le k<j}\{f[i][k]+f[k+1][j]+val(i,j)\}\)\(f[i][j]=\min\limits_{i\le k\le j}\{f[i][k]+f[k][j]+val(i,j)\}\)中,若:

  1. \(val\)是二元组函数,且满足四边形不等式;
  2. 对于任意的\(a <= b <= c <= d\),有\(val(a,d) >= val(b,c)\);
  3. \(f[i][i]=val(i,i)=0\)

\(f\)也满足四边形不等式。

决策单调性的定义

在状态转移方程\(f[i][j]=\min\limits_{i\le k<j}\{f[i][k]+f[k+1][j]+val(i,j)\}\)\(f[i][j]=\min\limits_{i\le k\le j}\{f[i][k]+f[k][j]+val(i,j)\}\) 中(特别地,\(f[i][i]=val(i,i)=0\)),记\(p[i][j]\)为令\(f[i][j]\)取到最小值的\(k\)的值。如果\(f\)满足四边形不等式,那么对于任意\(i < j\),有\(p[i][j-1] <= p[i][j] <= p[i+1][j]\)

由此可以缩小决策候选集合p。

由于要用到p[i][j-1]、p[i+1][j],所以倒序i,正序j

8.5.1.2.1. 应用——石子合并

石子合并
只能合并相邻的石子堆

合并果子(参见《数据结构‧数4.2.\(Huffman\)树》)
任意两堆果子均可合并

8.5.1.2.1.1.区间石子合并

参见《动态规划2.5.1.区间dp——区间石子合并》。

8.5.1.2.1.2.环形石子合并

参见《动态规划5.1.破环成链转化为线性dp》。

8.5.1.2.1.3.二维四边形不等式优化区间石子合并 \(O(N^3)\)优化到\(O(N^2)\)

  • 二维四边形不等式优化区间石子合并

    dp \(O(N^3)\)

    状态转移方程

for(int i=n-1;i>=1;i--)
        for(int j=i+1;j<=n;j++)
            for(int k=i;k<j;k++)
                f[i][j]=min(f[i][j],f[i][k]+f[k+1][j]+(sum[j]-sum[i-1]));

二维四边形优化 \(O(N^2)\)

sum[j]-sum[i-1]是区间和,满足四边形不等式\(w(i,j)\):取到等号。

根据上述两条定理,只需要在\(p[l][r-1] <= k <= p[l-1][r]\)的范围内对\(k\)进行枚举,求出\(f[l][r]\)\(p[l][r]\)

#include<bits/stdc++.h>
using namespace std;

const int N=5005;
int n;
int sum[N],f[N][N],p[N][N];

int main(){
    memset(f,0x3f,sizeof f);

    scanf("%d",&n);
    for(int i=1;i<=n;i++){
        scanf("%d",&sum[i]);
        sum[i]+=sum[i-1];
        f[i][i]=0;
        p[i][i]=i;
    }
    
    for(int i=n-1;i>=1;i--)
        for(int j=i+1;j<=n;j++)
            for(int k=p[i][j-1];k<=p[i+1][j];k++){
                int t=f[i][k]+f[k+1][j]+sum[j]-sum[i-1];
                if(f[i][j]>t){
                    f[i][j]=t;
                    p[i][j]=k;
                }
            }
    
    printf("%d\n",f[1][n]);

    return 0;
}

8.5.1.2.1.4.Garsia Wachs算法优化区间石子合并 \(O(N^3)\)优化到\(O(N\log N)\)

  • Garsia Wachs算法优化区间石子合并
    1. 找到满足\(a_{k-1} <= a_{k+1}\)的最小下标\(k\)
    2. 找到满足\(a_{j-1} > a_{k-1} + a_k\)\(j<k\)的最大的\(j\)
    3. 清除\(a_{k-1}\)\(a_k\)
    4. \(a_{j-1}\)后面插入\(a_{k-1}+a_k\)
    5. \(a_{-1}\)\(a_{n+1}\)可以定义为\(INF\)处理。
#include<bits/stdc++.h>
using namespace std;

const int N=5e4+5;
int n,ans,s_end;
int stone[N];

void merge(int x)
{
    int sum=stone[x]+stone[x-1],k,s_end_now;
    ans+=sum;   //合并
    
    for(k=x;k<s_end;k++) stone[k]=stone[k+1];   //清除stone[v]
    for(k=x-1;stone[k-1]<sum && k>1;k--) stone[k]=stone[k-1];   //清除stone[x-1]并找到x-1前面第一个stone大于sum的下标k-1
    stone[k]=sum;   //在k-1后面插入k
    
    //看能不能继续合并
    s_end_now=--s_end;
    while(k>2 && stone[k-2]<=stone[k])
    {
        merge(k-1);
        k-=s_end_now-s_end; //每递归调用merge一次,K会减少1,s_end_now-s_end就是调用次数
    }
    
    return ;
}

int main()
{
    while(scanf("%d",&n),n)
    {
        ans=s_end=0;
        
        for(int i=1;i<=n;i++)
        {
            int x;
            scanf("%d",&x);
            stone[++s_end]=x;
            
            //边输入,边进行可能的合并
            while(s_end>2 && stone[s_end-2]<=stone[s_end]) merge(s_end-1);
        }
        
        //剩余的都是从后合并,因为定义stone[s_end+1]=INF
        while(s_end>1) merge(s_end);
        
        printf("%d\n",ans);
    }
    return 0;
}

8.5.2.双指针优化

适用条件:答案随着编号有单调性。

8.6.凸单调性优化dp

8.6.1.凸包

适用条件:求最值、和斜率有关(本质上就是对斜率二分)、可以把题目转化为凸包形式、没有i、j的乘积项。

精度开大!

若给定的点无序先排序!

8.6.1.1.凸包优化转移

适用条件:形如\(f[i]/ans=\max\limits_{j=1}^{i-1} \frac{b_i-d_j}{a_i-c_j}\)

可以把max内的部分转化为\((a_i,b_i)\)\((c_j,d_j)\)两点连线的斜率。对于i时刻,\((a_i,b_i)\)是确定的,所以把\((c_j,d_j),1\le j\le i-1\)维护成一个凸包,在上面二分斜率。

根据题目性质(\(e.g.\)给定的点的横坐标和纵坐标都单调递增)以及在草稿纸上模拟,画出整个凸包,根据是求斜率最大值还是最小值判断维护上凸包还是下凸包。

插入当前点\((c_i,d_i)\)和二分斜率的顺序?判断当前查询是否可以考虑当前点。

下面以“给定的点的横坐标和纵坐标都单调递增,求斜率最大值”为例:

在草稿纸上画出整个凸包可知:维护下凸包。

  1. 若给定的点无序先排序,插入初始点。
  2. 枚举每一个i:
    1. 可行性:用点\((c_i,d_i)\)维护下凸包。
    2. 判断当前查询是否可以考虑当前点来决定插入当前点\((c_i,d_i)\)和二分斜率的顺序。
    3. 二分斜率后得到f[i]=max\((a_i,b_i)\)\((c_j,d_j)\)两点连线的斜率。
int top;
struct Slope
{
    double x,y;
    
    bool operator < (const Slope &qw) const
    {
        if(x==qw.x) return y>qw.y;
        return x<qw.x;
    }
    
    Slope operator - (const Slope &qw) const
    {
        return {x-qw.x,y-qw.y};
    }
}s[N];  //栈维护凸包

double slope(Slope a,Slope b)   //求斜率
{
    if(cmp(a.x,b.x)==0) return INF; //斜率无穷大
    return (a.y-b.y)/(a.x-b.x);
}

double cross(Slope a,Slope b)
{
    return a.x*b.y-a.y*b.x;
}

double area(Slope a,Slope b,Slope c)
{
    return cross(b-a,c-a);
}

sort(poi+1,poi+n+1);    //若给定的点无序先排序
s[0]={0,0}; //插入初始点
for(int i=1;i<=n;i++)
{
    Slope p1={c[i],d[i]},p2={a[i],b[i]};
    
    //可行性:用点(c[i],d[i])维护下凸包
    while(top && sign(cross(s[top-1],s[top],p1))<=0) top--;
    
    //判断当前查询是否可以考虑当前点来决定插入当前点i和二分斜率的顺序
    //插入当前点i
    s[++top]=p1;
    
    //最优性:二分斜率。斜率最大在下凸包的点
    int l=1,r=top,res=0;
    while(l<=r)
    {
        int mid=(l+r)>>1;
        if(sign(slope(s[mid-1],p2),slope(s[mid],p2))<0) l=mid+1,res=mid;
        else r=mid-1;
    }
    
    //二分斜率后得到f[i]=max(a[i],b[i])和(c[j],d[j])两点连线的斜率
    f[i]=slope(s[res],p2);
}

8.6.1.2.凸包优化状态

适用条件:给定n个点\((x_i,y_i)\)\(f_i(k)=calc(x_i,y_i,k)\),求出一个k,使得\(\max\limits_{i=1}^n f_i(k)\)最小,并输出最小值。

  1. \(f_i(k)\)转化为斜率为\(k_i\)且过点\((x_i,y_i)\)的直线的某一个值(\(e.g.\)斜率、横纵截距)。此时可推出对于当前的\(k_i\),该直线与凸包上点i的\(f_i(k_i)\)\(f_j(k_i),1≤j≤n\)
  2. 将问题转化为:在凸包上找一点i,使得一个斜率为\(k_i\)的直线与凸包相切于点i的\(f_i(k_i)\)最小(注意要满足前提:对于当前的\(k_i\),该直线与凸包上点i的\(f_i(k_i)\)\(f_j(k_i),1≤j≤n\))。
  3. 在草稿纸上通过计算求出\(h_i(k)\):直线斜率为k且经过点i时的\(f_i(k)\),显然k是有取值范围的:k必须在凸包上点i两边线段的斜率之间。
  4. 求出凸包。
  5. 枚举凸包上每一个点i。在k的取值范围内求出最小的\(h_i(k)\)更新答案ans。

8.6.2.slope tirck

适用条件:转移代价函数满足:1.连续;2.分段线性函数(斜率变化常是整数);3.凸函数。

第i轮循环的图像放在前i轮来看是一定正确的。

对于大部分的题目,形如下面的状态设计。

8.6.2.1.slope tirck

状态设计

对于大部分的题目,形如:\(f_{i,x}=\min\limits_{x-b_i\le x' \le x-a_i} \{f_{i-1,x'}\}+|x-c_i|\)。设\(f_{i,x}\)前i个且第i个是x(x的范围是值域大小)的最值,把\(f_{i,x}\)看成平面上的函数图像\(f_i(x)\)。且转移涉及到\(|x-c_i|\),看成关于x的绝对值函数。

大胆设计第二维为值域大小。

slope tirck状态转移

正确性证明:斜率相加、截距相加。

用两个堆(维护拐点的横坐标单调)来维护两个有序可重集来描述满足上面性质的函数。可重集的每个元素表示函数的一个拐点的横坐标,每种元素的个数表示函数在这个位置斜率变化了几(下凸函数就是表示增加了几)。一个大根堆维护斜率为0的段的左边的函数,一个小根堆维护右边。也就是说两个堆的堆顶就是斜率为0的段的两端。

  • \(b=\infty,a=1\)时:\(f_{i,x}=\min\limits_{x'<x} \{f_{i-1,x'}\}+|x-c_i|\)

    显然\(\min\limits_{x'<x}\{ f_{i-1,x'} \}\)\(f_{i-1,x}\)的前缀最小值。所以在图像上\(\min\limits_{x'<x}\{ f_{i-1,x'} \}\)左边是凸包,右边是水平的。所以我们可以只用一个左边的堆(维护左边和水平段)+一条右边的直线(k和b)来维护图像。

    1. 先考虑\(\min\limits_{x' < x}\{f_{i-1,x'}\}\)的转移,把\(f_{i-1,x}\)的图像变成\(f_{min}(x)=\min\limits_{x' < x}\{f_{i-1,x'}\}\)的图像。

      我们要把右边的直线打平:不断弹出左边的堆的堆顶直到右边的直线的斜率为0:每弹出一个堆顶我们可以利用目前的k和b得知其的纵坐标,然后我们更新k和b。

    2. 再考虑\(|x-c_i|\)的转移,把\(f_{min}(x)\)的图像变成\(f_{i,x}\)的图像。

      加入绝对值函数,分析可知在c处斜率的变化为2,所以向左边的堆中放入两个c,并更新k和b(截距相加,斜率相加)。

LL k,b;
priority_queue<LL> q;

//初始的函数是f=0\xRightarrow{+绝对值|x-c|}f_1
scanf("%lld",&c);
q.push(c),q.push(c);
k++,b-=c;

for(int i=2;i<=n;i++)
{
    scanf("%lld",&c);
    
    //f_{i-1,x}\xRightarrow{前缀最小值}f_{min}(x)
    while(k)
    {
        LL x=q.top(),y=k*x+b;
        
        q.pop();
        k--;
        b=y;
    }
    
    //f_{min}(x)\xRightarrow{+|x-c_i|}f_{i,x}
    q.push(c),q.push(c);
    k++,b-=c;
}
  • \(f_{i,x}=\min\limits_{x-b_i\le x' \le x-a_i} \{f_{i-1,x'}\}+|x-c_i|\)
    1. 先考虑\(\min\limits_{x-b_i\le x' \le x-a_i}\{f_{i-1,x'}\}\)的转移,把\(f_{i-1,x}\)的图像变成\(f_{min}(x)=\min\limits_{x-b_i\le x' \le x-a_i}\{f_{i-1,x'}\}\)的图像。

      对于下凸壳,在草稿纸上模拟可知:左边的部分会向右平移\(a_i\)个单位,右边的部分会向右平移\(b_i\)个单位。

      下面以维护右边的部分为例:也就是说,对于一个堆中的所有元素,都要加上一个相同的值\(b_i\)。因此这里用懒标记维护:tagr:右边的点的现在实际坐标=堆中的元素+tagr。(注意tagr与斜率为0的段无关!!!)

    2. 再考虑\(|x-c_i|\)的转移,把\(f_{min}(x)\)的图像变成\(f_{i,x}\)的图像。

      显然这个绝对值函数的分界点是\(x=c_i\)。分类讨论:当\(c_i\)<左边堆的堆顶,当\(c_i\)>右边堆的堆顶,\(c_i\)在中间。

      下面以\(c_i\)<左边堆的堆顶为例:\(c_i\)的左边斜率都减一,而右边都加一,等于在$ c_i\(的位置减少了2,所以朝左边的堆加入两个\) c_i$,加入左边的堆时记得减去懒标记tagl,因为点的现在实际坐标=堆中的元素+懒标记, L.push(x[i]-tagl);L.push(x[i]-tagl);。同时斜率为0的段的两端不再是之前两个堆的堆顶,而是左边的堆的堆顶和pop后的堆顶,所以要拿出左边的堆的堆顶并加入右边的堆中,从堆中拿出时记得加上懒标记tagl得到点的现在实际坐标,加入时同样要注意减去懒标记tagr,LL l=L.top()+tagl;L.pop();R.push(l-tagr);

      注意先pop()再push(c_i)。

int n;
LL c,a,b,ans;
priority_queue<LL> L;   //一个大根堆维护斜率为0的段的左边的函数
priority_queue<LL,vector<LL>,greater<LL> > R;   //一个小根堆维护右边
LL tagl,tagr;    //点的现在实际坐标=堆中的元素+懒标记(注意懒标记与斜率为0的段无关!!!)

scanf("%d",&n);
scanf("%lld",&c);
L.push(c),R.push(c);    //初始的函数是f=0\xRightarrow{+绝对值|x-c|}f_1
for(int i=2;i<=n;i++)
{
    scanf("%lld%lld%lld",&a,&b,&c);
    
    //f_{i-1}\xRightarrow{转移的范围}f_min
    tagl+=a,tagr+=b;
    
    //f_min\xRightarrow{+绝对值函数|x-c|}f_i
    //注意先pop()再push(c)
    int l=L.top()+tagl,r=R.top()+tagr;//!!!注意从堆中拿出时加上懒标记
    if(c<l) //只关心斜率变化,平移在上面已经处理好了,不属于加绝对值的部分
    {
        L.pop(),R.push(l-tagr);//!!!注意放入前要先减去懒标记
        L.push(c-tagl),L.push(c-tagl);
    }
    else if(c>r)
    {
        R.pop(),L.push(r-tagl);
        R.push(c-tagr),R.push(c-tagr);
    }
    else
    {
        L.push(c-tagl),R.push(c-tagr);
    }
}

技巧

  1. 图像的截取。

    使用时间:题目对最终的方案有限制。

    \(e.g.\)将一个序列x转化为序列y,要求之一是\(y_i\in[1,q]\)

    此时凸包的图像的定义域是不完整的。

    对凸包图像进行变换时是对之前已经不完整的凸包图像进行变换!并且得到的新的图像又要对[1,q]截取图像。

    \(e.g.\)定义域为[1,q]的图像向右平移delta单位并截取后的图像定义域为[1+delta,q]

    事实上不需要在堆中真的截取,只要在取出答案时在相应的定义域找最值即可。

  2. 图像变换:斜率为0的一段的左边向上平移,斜率为0的一段向右平移delta个单位,空缺处插入一段斜率为-1的线段。

    把斜率为0的一段的左端点x弹出,再插入x+delta即可。

    • 感性证明

      \(e.g.\)

求最终答案

因为我们设计的方程\(f_{i,x}\)表示考虑前i个点的最值,所以最终的答案是最终图像的最小函数值。

  • 定义域没有限制时,最终图像的最小函数值是斜率为0的一段的函数值。

    求斜率为0的一段的函数值:
    - 方法一:求出f(0)。

        适用条件:能求出f(0),且较难记录截距。
    
        求出f(0)。因为slope trick记录了拐点所以知道了每一段的斜率。所以可以由f(0)知道每个拐点的函数值。
    
        对于每一个拐点i,f0减去1~i*拐点前后的斜率之差。最终得到当前图像的最小函数值。
    
while(L.size())
{
    f0-=L.top();
    L.pop();
}
printf("%d\n",f0);

  - 方法二:记录斜率为0的一段的截距。

      适用条件:无法求出f(0)。

      - 对于$f_{i,x}=\min\limits_{x'<x} \{f_{i-1,x'}\}+|x-c_i|$:本身就已经记录了右边的直线。最后输出截距b即可。
      - 对于$f_{i,x}=\min\limits_{x-r\le x' \le x-l} \{f_{i-1,x'}\}+|x-c_i|$:计算每次转移斜率为0的一段的函数值的偏移量

          $f_{i,x}$斜率为0的一段的函数值相对于$f_{i-1,x}$的**函数值增加量=c与斜率为0的一段的横坐标距离**。

          - 证明

            这一段是由$f_{i-1,x}$的**斜率为-1**(c<左边堆的堆顶)或**1**(c>右边堆的堆顶)的一段+绝对值函数得到的。因此函数值的增加量=c与斜率为0的一段的横坐标距离,而不是图像的y。
if(c<l){ans+=l-c;}
else if(c>r){ans+=c-r;}
else{ans+=c-c;}
  • 定义域有限制时(\(e.g.\)截取),最终图像的最小函数值不一定是斜率为0的一段的函数值。

    此时需要得到方案,答案=calc(方案,原值)。

    得到方案见《动态规划8.5.2.3.输出方案》

8.6.2.2.树上slope trick

形如\(f_{u,x}=\sum\limits_{v\in son(u)}\min\limits_{x-b_i\le x' \le x-a_i} \{f_{v,x'}\}+|x-c_i|\)

  1. slope trick先求出\(f_{u,x}\)
  2. \(f_{u,x}\)转化为u为\(fa_u\)意义下的\(f'_{u,x}\)
  3. 使用可并堆(正确性证明:斜率相加、截距相加)把\(f'_{u,x}\)合并到\(f_{fa_u,x}\)

8.6.2.3.输出方案

注意:第i轮循环的图像放在前i轮来看是一定正确的(因为本身第i轮循环的图像就是从前面顺推过来的,归纳法证明。),放在全局来看是不一定正确的。

\(y_n\)在最终凸包上,则一定能构造一组解。

  • 若方案有自己的限制(\(e.g.\)\(y_i\in[1,q]\)):在维护凸包时满足限制(\(e.g.\)截取)。

  • 若方案有互相的限制(\(e.g.\)\(y_{i+1}-y_i\in[a,b]\)):

    初步构造\(y_i\)为第i轮循环的图像的最低点。然后从n-1开始倒推方案y[i]=min(y[i],y[i+1]-a);y[i]=max(y[i],y[i+1]-b);

    • 证明

      可行性:第n轮循环的图像放在前n轮看是一定正确的。所以倒推方案是一定存在一组解的。

      最优性:倒推方案时,若\(y_{i+1}-b\le y_i \le y_{i+1}-a\),即\(y_i\)能取到凸包最低点而且与后面不冲突的,根据贪心这是一定不劣的;若\(y_i<y_{i+1}-b\),由于一定存在一组解,根据贪心此刻令\(y_i=y_{i+1}-b\)一定是最优的,因为初步构造时\(y_i\)时凸包的最低点,而此刻\(y_i\)\(y_{i+1}-b\)最近,那么取这个端点一定最优;若\(y_{i+1}-a<y_i\),同理。

    Q:万一只有\(y_n=q\)时才有解呢?

    A:那么最终的图像一定是一个点\((n,q)\)

https://www.luogu.com.cn/blog/foreverlasting/solution-p4272

8.6.3.wqs二分

8.6.3.1.wqs二分\(O(N\log N)\)

适用条件: 求n 个物品恰好选 k 个的情况下的最值,函数图像(横坐标是选几个物品,纵坐标是最值)具有凸性

下面以求最小值为例:

  1. 难点。证明或打表发现函数图像具有下凸性。决定当多种方案的最值相等时,选择物品个数少的方案还是物品个数多的方案,以便二分。(下面以选择物品个数少的方案为例)

  2. 二分mid(具体怎么二分见第4步),选一个物品要额外花费mid的代价。

  3. 求出n个物品选若干个的情况下的最值res(表示最值处的纵坐标)和选择物品cnt(在“选择物品个数少的方案”的情况下,表示最值处平台的左端点横坐标)的个数。当多种方案的最值相等时,选择物品个数少的方案。

  4. 难点。在草稿纸上画出图像可知:

    1. 若cnt<k,应令选择物品多的最值更小,图像最低峰向右走,mid应更小,且由于“选择物品个数少的方案”,此时的mid有可能会使得\(y_k=y_{cnt}=res\) 取到最值处(图像意义:mid的图像的最值处平台的左端点横坐标cnt<横坐标k,此时k有可能在最值处平台上)成为答案,所以r=mid;
    2. 若cnt>k,同理,与cnt<k的情况区别在于此时的mid不可能成为答案,所以l=mid+1;
    3. 若cnt==k,此时mid的图像的最值处平台的左端点横坐标就是k,由于上文的分析r=mid;取等,因此该情况直接规约为r=mid;

    由于上文的分析r=mid;l=mid+1;,因此mid=(l+r)>>1;才可以使得二分正常进行。

  5. 二分结束后(l=r),k一定在l的图像的最值处平台。由于可能最后一次二分的mid≠l,所以此时应执行一次check(l,res,cnt)。最终答案是res-l*k(注意不是res-l*cntres-l*cnt错误的原因:虽然cnt和k都在l的图像的最值处平台,但是原图像cnt和k不一定在同一平台上。res-l*k正确的原因:\(y_k=y_{cnt}=res\)。)。

void calc(int mid,int &res,int &cnt)
{
    res=cnt=0;//不要忘记初始化
    //求出n个物品选若干个的情况下的最值res和选择物品cnt的个数
    //当多种方案的最值相等时,选择物品个数少的方案
    //选一个物品要额外花费mid的代价
}

int l=-UP,r=UP;
while(l<r)
{
    int mid=(l+r)>>1,res,cnt;
    calc(mid,res,cnt);
    if(cnt<=k) r=mid;
    else l=mid+1;
}
check(l,res,cnt);
printf("%lld\n",res-l*k);

8.6.3.2.wqs二分套wqs二分\(O(N \log^2 N)\)

当要求两种物品分别恰好选\(k_1\)个和\(k_2\)个的情况下的最值时,第一层二分mid1:每选第一种物品要额外花费mid1的代价;第二层二分mid2:每选第二种物品要额外花费mid2的代价;然后再check(mid1,mid2):求出两种物品分别选若干个的最值val、选第一种物品的个数cnt1以及选第二种物品的个数cnt2。其余的分析和wqs二分一样。

int k1,k2;
int ans;
struct Wqs
{
    int val,cnt1,cnt2;//val:两种物品分别选若干个的最值;cnt1:选第一种物品的个数;cnt2:选第二种物品的个数
};

Wqs check(int mid1,int mid2){}  //返回两种物品分别选若干个的最值、选第一种物品的个数以及选第二种物品的个数

scanf("%d%d",&k1,&k2);

//此处二分写法有问题,待修改
int l1=-UP,r1=UP;
while(l1<=r1)
{
    int mid1=(l1+r1)>>1;
    Wqs res1,res2;
    
    int l2=-UP,r2=UP,mid2;
    while(l2<=r2)
    {
        int mid=(l2+r2)>>1;
        res2=check(mid1,mid);
        if(res2.cnt2<=k2) r2=mid,res1=res2,mid2=mid;
        else l2=mid;
    }
    
    if(res1.cnt1<=k1) r1=mid1,ans=res1.val-k1*mid1-k2*mid2;
    else l1=mid1;
}

8.6.3.3.wqs二分优化dp\(O(dp*\log N)\)

  1. \(f[i][x]\):考虑前i个且恰好选了j个的最值\(\Rightarrow\)\(f[i]\):考虑前i个且选了若干个的最值。

    大大减少了复杂度。因此首先设的方程可以大胆设第二维。

    \(f[i]\)使用struct类型储存val:\(f[i]\)的值以及cnt:选了多少个物品。

    然后根据前者的方程或后者的方程进行状态转移。前者的状态转移\(\xRightarrow{省略第二维}\)后者的状态转移。

  2. 用其他优化方法优化\(f[i]\)的dp,降低复杂度\(O(dp)\)

  3. 套上wqs二分。

struct Wqs
{
    int val,cnt;
    
    Wqs operator + (const Wqs &qw) const
    {
        return {val+qw.val,cnt+qw.cnt};
    }
    bool operator < (const PLI &qw) const
    {
        if(val!=qw.val) return val<qw.val;
        return cnt<qw.cnt;
    }
}f[N];  //f[i]:考虑前i个且选了若干个的最值

//dp:考虑前i个且选了若干个的最值
void calc(int mid,int &cnt)

int l=-UP,r=UP;
int cnt;
while(l<r)
{
    int mid=(l+r)>>1;
    calc(mid,cnt);
    if(cnt<=k) r=mid;
    else l=mid+1;
}
calc(l,cnt);
printf("%lld\n",f[n].val-l*k);

8.7.dp套dp

适用条件:1.较难得知转移到哪个状态;2.根据当前的状态以及转移的方向求出可以转移到哪些状态也需要另一个dp求解;3.题目硬把\(dp_a\)套入\(dp_b\),而\(dp_b\)的转移必须根据\(dp_a\)的实际情况转移状态。

类似于自动机。

  1. 设计外层dp和内层dp。

    外层dp负责求解当前问题,内层dp负责根据当前的状态以及转移的方向求出可以转移到哪些状态

    外层dp:

    • 角度一:用足够多的维度信息表示当前的状态以供内层dp求出可以转移到哪些状态。
    • 角度二:内层dp的值域小:直接把内层dp的值设为状态。\(e.g.\)\(f_{i,v_0,v_1}\):位置i的\(g_{i,0},g_{i,1}\)的值分别为\(v_0,v_1\)时的……。
  2. 优化状态

    一定不能脑测状态数,以实际搜出来的结果为准。

    dp套dp的瓶颈一般在于状态数。

  3. 状态转移。一般采用由自己推别人。

    1. 内层dp根据当前的状态以及转移的方向求出可以转移到哪些状态。

      如果外层dp直接把内层dp的值设为状态,则状态直接套用内层dp的方程得到应转移到的状态。

    2. 外层dp从当前的状态转移到内层dp的求出的状态。

auto go[]={};    //转移的方向
LL f[N][K];    //f[][state]:外层dp
LL g[N];    //内层dp

int dp(int state,auto ne){} //内层dp:根据当前的状态state以及转移的方向ne求出可以转移到哪些状态

//自己推别人,枚举下一步转移的方向进行转移
for(int i=1;i<=n-1;i++)
    for(int state=0;state<(1<<k);state++)
        for(int j=0;j<m;j++)
        {
            int ne=dp(state,go[j]);
            //外层dp从当前的状态转移到内层dp的求出的状态
        }

模板题。

8.8.玄学优化

8.8.1.剪枝有效状态数

适用条件:发现有效状态数很少,但是不知道怎么排除无效状态。

用vector存该轮的有效状态,用该vector转移到下一轮。这样做可以做到接近正解。

e.g.\(f_{i,j}\):不超过j个的最值。对于每个i,map存\([j,f_{i,j}]\),即将到i+1时,把\(f_{i,j}\)劣于前缀最值的状态删除,不转移给i+1。

8.8.2.线段树剪枝

适用条件:优化\(O(N^2)\)暴力,且满足像斜率优化dp、决策单调性等最优决策点很少的情况。

\(f_i=\max\limits_{1≤j≤i-1\&\& a[j]≤a[i]}\{f_j+val(j,i)\}\)为例:正解做法是CDQ分治套斜率优化dp。

  1. 将原暴力套用线段树,复杂度多一个\(O(\log)\)

    线段树叶子节点维护dp下标。按照a[i]从小到大考虑i:依次遍历线段树叶子节点[1,i-1]求出\(f_i\),然后把\(f_i\)插入到线段树。

  2. 但是可以在线段树上剪枝。

    设计线段树节点的估价函数=子树最大的\(f_j\)+子树最大的val(k,i)。注意\(f_j\)与val(k,i)是相对独立的。

    剪枝1:遍历过程中,如果当前的\(f_i\)>即将遍历的节点的估价函数,不用往下遍历该节点。

    剪枝2:优先遍历估价函数大的儿子。这样就有更大概率不用遍历另一个儿子了。

注意:

  1. 当题目满足满足像斜率优化dp、决策单调性等最优决策点很少时,该做法相当于KD-Tree,不劣于\(O(N\sqrt N)\),实际表现\(O(N\log^2 N)\)
  2. 该技巧不局限于dp。
  3. 线段树遍历过程中,当j>=i时,val(j,i)的值要保证估价函数的正确性!!!
PLI a[N],best;  //best:求f_i的遍历过程中,当前最优的f_i
LL f[N];
struct Segmenttree
{
    int l,r;
    LL maxf;    //子树中最大的f
}tr[N*4];

void pushup(int u)
{
    tr[u].maxf=max(tr[u<<1].maxf,tr[u<<1|1].maxf);
    return ;
}

void build(int u,int l,int r)
{
    tr[u]={l,r,LL(-1e18)};
    if(l==r) return ;
    int mid=(l+r)>>1;
    build(u<<1,l,mid);
    build(u<<1|1,mid+1,r);
    return ;
}

void change(int u,int x,LL f)
{
    if(tr[u].l==tr[u].r)
    {
        tr[u].maxf=f;
        return ;
    }
    int mid=(tr[u].l+tr[u].r)>>1;
    if(x<=mid) change(u<<1,x,f);
    else change(u<<1|1,x,f);
    pushup(u);
    return ;
}

LL val(LL j,LL i)
{
    if(j>=i) return ;   //注意当j>=i时填写的返回值要保证估价函数的正确性!!!
    return ;
}

void solve(int u,int l,int r)
{
    if(tr[u].l==tr[u].r)
    {
        best.first=max(best.first,tr[u].maxf+val(tr[u].l,best.second));
        return ;
    }
    int mid=(tr[u].l+tr[u].r)>>1;
    LL fl=tr[u<<1].maxf+val(/*使得左子树取到最大的val(k,best.second)的k*/,best.second),fr=tr[u<<1|1].maxf+val(/*使得右子树取到最大的val(k,best.second)的k*/,best.second);
    if(fr>=fl)
    {
        if(r>mid && fr>best.first) solve(u<<1|1,l,r);
        if(l<=mid && fl>best.first) solve(u<<1,l,r);
    }
    else
    {
        if(l<=mid && fl>best.first) solve(u<<1,l,r);
        if(r>mid && fr>best.first) solve(u<<1|1,l,r);
    }
    return ;
}

for(int i=1;i<=n;i++)
{
    scanf("%lld",&a[i].first);
    a[i].second=i;
}
sort(a+1,a+n+1);
build(1,0,n);
change(1,0,f[0]);   //边界
for(int i=1;i<=n;i++)
{
    best={-1e18,a[i].second};
    solve(1,0,a[i].second-1);
    f[a[i].second]=best.first;
    change(1,a[i].second,f[a[i].second]);
}

9.数据结构化dp

9.1.区间询问——猫树优化dp

9.2.修改——动态dp

矩阵常数大,可以只维护矩阵中有用的信息。

本质是带修改的dp。当某一类dp的某一状态要求修改时,我们可以用数据结构优化转移,使修改后重新做一次dp的复杂度是\(O(c*\log N)\)

  1. 写出朴素dp方程。
  2. 根据方程选择转移方法:广义矩阵乘法或数据结构pushup

9.2.1.简单的方程——广义矩阵乘法中转移

适用条件:形如\(f_i=(f_{i-1}\otimes a) \oplus f_{i-1} \oplus c\oplus \cdots\),且\(\otimes\)\(\oplus\)存在分配律。一般是\(f_i=\max \{ f_{i-1} +\cdots \}\)。方程能转化成矩阵形式。

9.2.1.1.广义矩阵乘法

对于一个lm的矩阵A与一个mn的矩阵B,定义广义矩阵乘法AB=C的结果是一个ln的矩阵C,满足二元运算\(\oplus\)\(\otimes\)\(C_{i,k}=(A_{i,1} \otimes B_{1,k}) \oplus (A_{i,2} \otimes B_{2,k}) \oplus \cdots \oplus (A_{i,m} \otimes B_{m,k})=\bigoplus\limits_{j=1}^{m}(A_{i,j}\otimes B_{j,k})\)

\(\otimes\)\(\oplus\)存在分配律(即\((\bigoplus a) \otimes b = \bigoplus (a \otimes b)\))且\(\oplus\)满足交换律和结合律时,我们定义的广义矩阵乘法满足结合律。

  • 证明

    \(((A*B)*C)_{i,l}=\bigoplus\limits_{k=1}((A*B)_{i,k} \otimes C_{k,l})=\bigoplus\limits_{k=1}((\bigoplus\limits_{j=1}(A_{i,j}\otimes B_{j,k}))\otimes C_{k,l})\)

    \((A*(B*C))_{i,l}=\bigoplus\limits_{j=1}(A_{i,j}\otimes (B*C)_{j,l})=\bigoplus\limits_{j=1}(A_{i,j} \otimes (\bigoplus\limits_{k=1}(B_{j,k}\otimes C_{k,l})))\)

    当满足\((\bigoplus a) \otimes b = \bigoplus (a \otimes b)\)时:

    \(((A*B)*C)_{i,l}=\bigoplus\limits_{k=1}\bigoplus\limits_{j=1}(A_{i,j}\otimes B_{j,k}\otimes C_{k,l})=\bigoplus\limits_{j=1}\bigoplus\limits_{k=1}(A_{i,j} \otimes B_{j,k}\otimes C_{k,l})\)

    \((A*(B*C))_{i,l}=\bigoplus\limits_{j=1}\bigoplus\limits_{k=1}(A_{i,j} \otimes B_{j,k}\otimes C_{k,l})\)

    \(\therefore ((A*B)*C)_{i,l}=(A*(B*C))_{i,l}\)

同时,加法\(+\)对最值\(\min / \max\)存在分配律:\(\max / \min \{ a,b \}+c = \max / \min \{ a+c,b+c \}\)。因此我们可以定义广义矩阵乘法\(C_{i,k}=\max\limits_j / \min\limits_j \{ A_{i,j}+B_{j,k} \}\)。之前学过的\(Floyd\)算法就是这种广义矩阵乘法:\(f_{i,k}=\max\limits_j / \min\limits_j \{ f_{i,j}+f_{j,k} \}\)

补充:关于位运算的结合律的证明:

因为位运算是按位运算,所以只要一位满足结合律,则该位运算就满足结合律。

广义矩阵乘法具有结合律是实现动态dp的必要条件。

9.2.1.2.动态线性dp

下面以带修改的最大子段和为例:

参与中间运算的矩阵应memset(0)以不造成影响。

强制令矩阵中某一位置\(mat[x][y]\)不参与运算:mat[x][y]=-INF;

  1. 根据方程\(f\)设计转移矩阵\(G\)

    • 设计\(G\)

      有朴素方程:对于一个询问\([l,r]\)\(f_i\):以\(a_i\)结尾的\([l,i]\)的最大子段和,\(g_i\)\([l,i]\)的最大子段和,\(f_i=\max \{ f_{i−1}+a_i,a_i \},g_i=\max \{ g_{i−1},f_i \}\)

      \(\begin{bmatrix} f_{i-1} & g_{i-1} \end{bmatrix} * \begin{bmatrix} a_i & \\ & \end{bmatrix}=\begin{bmatrix} f_i & g_i \end{bmatrix}\)

      当发现没有空间写不下去的时候,不妨再来一维0:\(\begin{bmatrix} f_{i-1} & g_{i-1} & 0 \end{bmatrix}*\begin{bmatrix} a_i & & -\infty \\ -\infty & & -\infty \\ a_i & & 0 \end{bmatrix}=\begin{bmatrix} f_i & g_i & 0 \end{bmatrix}\)

      \(g_i=\max \{ g_{i−1},f_i \}\)不好处理,可以把\(f_i\)拆开:\(g_i=\max \{ g_{i−1},f_{i-1}+a_i,a_i \}\)\(\begin{bmatrix} f_{i-1} & g_{i-1} & 0 \end{bmatrix}*\begin{bmatrix} a_i & a_i & -\infty \\ -\infty & 0 & -\infty \\ a_i & a_i & 0 \end{bmatrix}=\begin{bmatrix} f_i & g_i & 0 \end{bmatrix}\)

    每一个i对应着自己的一个转移矩阵\(G_i\)

    1. 修改\(a_i\)。更新对应的\(G_i\)中的某些元素。
    2. 询问\([l,r]\)的最大子段和。求出\(G_l\)\(G_r\)的矩阵“乘积”。因为我们定义的广义矩阵乘法具有结合律,因此可以用线段树维护区间矩阵“乘积”
  2. 根据方程\(f\)和转移矩阵\(G\)设计初始向量\(F_0\),以在区间矩阵乘积中找到答案。

    • \(F_0\)的意义

      当我们对于一个询问\([l,r]\)求出区间矩阵乘积时,区间矩阵乘积的实际含义是(注意此时\(f_i\)对于一个询问\([l,r]\)表示以\(a_i\)结尾的\([l,i]\)的最大子段和,\(g_i\)同理)\(\begin{bmatrix} f_{l-1} & g_{l-1} & 0 \end{bmatrix}*\begin{bmatrix} a_i & a_i & -\infty \\ -\infty & 0 & -\infty \\ a_i & a_i & 0 \end{bmatrix}=\begin{bmatrix} f_r & g_r & 0 \end{bmatrix}\)。其中\(f_{l-1}\)\(g_{l-1}\)是没有意义的,既然初始向量\(F_0\)没有意义,那么转移矩阵\(G\)和答案向量\(F_{ans}\)的正确性也无法保证。

    实际上,只要设计出一个初始向量\(F_0\),对于任意的起点l,都有\(F_0*G_l=f_l\),那么动态dp的正确性即可达到保证。证明:广义矩阵乘法的结合律。

    • 设计\(F_0\)

      在草稿纸上简单模拟可设计出一个初始向量\(F_0\)\(\begin{bmatrix} 0 & -\infty & 0 \end{bmatrix}\)。因为对于任意的起点l,都有\(\begin{bmatrix} 0 & -\infty & 0 \end{bmatrix}*\begin{bmatrix} a_l & a_l & -\infty \\ -\infty & 0 & -\infty \\ a_l & a_l & 0 \end{bmatrix}=\begin{bmatrix} f_l & g_l & 0 \end{bmatrix}\)

      除此之外,\(\begin{bmatrix} -\infty & -\infty & 0 \end{bmatrix}\)也是合法的初始向量\(F\)。但是\(\begin{bmatrix} 0 & 0 & 0 \end{bmatrix}\)不是合法的初始向量\(F\),因为\(a_i\)可以为负数。

    \(F_0*G=F_{ans}\)得:\(\begin{bmatrix} 0 & -\infty & 0 \end{bmatrix}*\begin{bmatrix} a & b & c \\ d & e & f \\ g & h & i \end{bmatrix}=\begin{bmatrix} \max \{ a,g \} & \max \{ b,h \} & \max \{ c,i \} \end{bmatrix}\)

    又因为方程\(g_r\)是最终的答案,所以当我们得到区间矩阵乘积\(G\)后,\(ANS=\max \{ G[0][1],G[2][1] \}\)。(如果\(F_0=\begin{bmatrix} -\infty & -\infty & 0 \end{bmatrix}\)\(ANS=G[2][1]\) ,用广义矩阵乘法的结合律可证两个答案都是正确的)

  3. 线段树维护区间矩阵乘积。

//广义矩阵乘法
struct Matrix
{
    int mat[3][3];  //矩阵
    
    Matrix(){memset(mat,0,sizeof mat);} //当Matrix res;时,对res进行下面的初始化
    Matrix(int a)   //当res=Matrix(a);时,对res进行下面的初始化
    {
        mat[0][0]=mat[0][1]=mat[2][0]=mat[2][1]=a;
        mat[0][2]=mat[1][0]=mat[1][2]=-INF;
        mat[1][1]=mat[2][2]=0;
    }
    
    Matrix operator * (const Matrix &qw) const  //定义的广义矩阵乘法
    {
        Matrix res;
        memset(res.mat,-0x3f,sizeof res.mat);
        for(int k=0;k<3;k++)
            for(int i=0;i<3;i++)
                for(int j=0;j<3;j++)
                    res.mat[i][j]=max(res.mat[i][j],mat[i][k]+qw.mat[k][j]);
        return res;
    }
    int get_max()   //根据初始向量F0和区间转移矩阵乘积,在区间矩阵乘积中找答案
    {
        return max(mat[0][1],mat[2][1]);
    }
};

struct Tree
{
    int l,r;
    Matrix G;
}tr[N*4];   //线段树开4倍!!!

//当前节点的区间矩阵乘积=左儿子*右儿子
void pushup(int u)
{
    tr[u].G=tr[u<<1].G*tr[u<<1|1].G;
    return ;
}

void build(int u,int l,int r)
{
    tr[u]={l,r};
    if(l==r)
    {
        int a;
        scanf("%d",&a);
        tr[u].G=Matrix(a);
        return ;
    }
    int mid=(l+r)>>1;
    build(u<<1,l,mid);
    build(u<<1|1,mid+1,r);
    pushup(u);
    return ;
}

void change(int u,int x,int val)
{
    if(tr[u].l==tr[u].r)
    {
        tr[u].G=Matrix(val);  //单点修改时找到线段树对应的单点,修改矩阵的某些元素
        return ;
    }
    int mid=(tr[u].l+tr[u].r)>>1;
    if(x<=mid) change(u<<1,x,val);
    else change(u<<1|1,x,val);
    pushup(u);
    return ;
}

Matrix query(int u,int l,int r)
{
    if(l<=tr[u].l && tr[u].r<=r) return tr[u].G;
    int mid=(tr[u].l+tr[u].r)>>1;
    
    //注意下面的书写,因为矩阵乘法没有交换律!!!
    if(l<=mid && r>mid) return query(u<<1,l,r)*query(u<<1|1,l,r);
    else
    {
        if(l<=mid) return query(u<<1,l,r);
        else return query(u<<1|1,l,r);
    }
}

build(1,1,n);

//单点修改权值
scanf("%d%d",&x,&val);
change(1,x,val);

//区间询问最大子段和
scanf("%d%d",&l,&r);
Matrix res=query(1,l,r);
printf("%d\n",res.get_max());

9.2.1.3.动态树形dp

9.2.1.3.1.子结点的信息与当前节点是加和关系,具有可加减性

下面以带修改的树上最大点权独立集为例:

  1. 子树方程、答案。

    • 子树方程

      \(f[u][bool]\):以u为根的子树,u选不选时的最大点权独立集权值。

    动态dp需要维护序列的数据结构,动态树形dp自然需要把树剖分成重链转换为序列问题。因此还需要额外设计出与重链无关(也就是轻儿子)的信息的方程以方便后面维护和转移。这样当前节点=重儿子(与其在同一条重链上,转化为序列)+轻儿子。

  2. 设计方程分离出轻儿子的信息、转移。

    • 轻儿子方程

      \(g[u][bool]\):不包含u所在的重链部分(但是包含u)的以u为根的子树,u选不选时的最大点权独立集权值。

    注意求g时先把所有儿子的贡献算上,再减去重儿子求出g,注意是自顶向下的递推!否则会少减son[son[u]]或多减son[son[v]]!!!

  3. 状态转移:设\(son[u]\)为u的重儿子。用\(g[u]\)\(f[son[u]]\)写出\(f[u]\)的转移方程。

    • 状态转移

      \(g[u][0]=\sum\limits_{v ≠ son[u]} \max \{ f[v][0],f[v][1] \}\)\(g[u][1]=\sum\limits_{v ≠ son[u]} \{ f[v][0]+a[u] \}\)

      \(g[u]\)\(f[son[u]]\)写出\(f[u]\)的转移方程:\(f[u][0]=g[u][0]+\max \{ f[son[u]][0],f[son[u]][1] \}\)\(f[u][1]=g[u][1]+f[son[u]][0]\)

  4. 转换成矩阵形式。

    • 矩阵形式

      \(\begin{bmatrix} f[son[u]][0]&f[son[u]][1] \end{bmatrix} \times \begin{bmatrix} g[u][0]&g[u][1]\\ g[u][0]&-\infin \end{bmatrix} = \begin{bmatrix} f[u][0]&f[u][1] \end{bmatrix}\)

    但是,树上序列一般是以根节点在前,叶子节点在后,所以矩阵乘积自然是从根节点的矩阵连乘到叶子节点的矩阵\(G_{root}*...*G_u*G_{son}*F_{son}\)。因此我们需要利用矩阵的转置运算律\((AB)^T=B^TA^T\)来交换成正确的顺序:\(\begin{bmatrix} g[u][0] & g[u][0] \\ g[u][1] & -\infty \end{bmatrix} \times \begin{bmatrix} f[son[u]][0] \\ f[son[u]][1] \end{bmatrix} = \begin{bmatrix} f[u][0]\\ f[u][1] \end{bmatrix}\)

  5. 求解。

    初始向量\(F_0\):因为对于任意一个叶子节点u,有\(\begin{bmatrix}g[u][0]&g[u][0]\\ g[u][1]&-\infin\end{bmatrix}\times\begin{bmatrix}0\\ 0\end{bmatrix}=\begin{bmatrix}f[u][0]\\ f[u][1]\end{bmatrix}\)(注意此处的g是转移方程),所以初始向量是\(\begin{bmatrix}0\\0\end{bmatrix}\)

    答案向量\(F_{ans}\):由初始向量和转移矩阵\(**G**\)推得:\(f[u][0]=\max(g[0][0],g[0][1]),f[u][1]=\max(g[1][0],g[1][1])\)
    \(ans=\max(f[1][0],f[1][1])=\max \{ g[0][0],g[0][1],g[1][0],g[1][1] \}\)

总结

设计两个方程:子树方程和轻儿子方程。

答案向量\(F_{root}\)是根节点所在重链(简记“根重链”)的区间转移矩阵乘积\(G\)****乘初始矩阵,其他重链的链顶的矩阵\(**F_{top}**\)(其重链的区间矩阵乘积\(**G**\)乘初始矩阵)作为轻儿子更新在根重链上的父节点的****转移方程\(**g**\)

转移矩阵\(**G**\)由轻儿子转移方程\(**g**\)构成。区间矩阵乘积\(**G**\)是单点矩阵\(**poi**\)的连乘积,单点矩阵\(**poi**\)是由转移方程\(**g**\)构成的,当转移方程\(**g**\)被修改时,单点矩阵\(**poi**\)要及时更新poi[x]=Matrix(g[x][0],g[x][1]);,区间矩阵乘积\(**G**\)也要及时更新change(1,id[x]);(树剖线段树)或pushup(x);(全局平衡二叉树)。

modify_path(pos,val)(树剖线段树)和modify(pos,val)(全局平衡二叉树)中:首先在原树上单点修改posa[pos]=val。然后更新从u到根节点的信息:随着u=fa[top[u]]的过程中,对于每一轮循环:****1.记录线段树(或平衡树,下同)的原先信息:区间矩阵乘积;2.用原树的信息来更新线段树的信息;3.记录线段树的当前信息:区间矩阵乘积;4.用原先信息和当前信息(作为轻儿子区间矩阵乘积)更新原树(而不是线段树)的链顶父节点的信息。

9.2.1.3.1.1.重链剖分+线段树\(O(N \log^2 N)\)

类似于线段树维护动态dp和重链剖分板子。

由于要询问一整条重链上的矩阵乘积,因此还需要记录\(ed[i]\):重链的末尾的原编号。

需要注意的地方是modify_path(pos,val)

  1. 修改单点时要把其所在的重链(作为链顶的父节点的轻子树)到根节点所在的重链的信息全部更新。
  2. 修改单点时加偏移量而不是直接赋值,因为其还包含其他轻儿子的信息。
  3. 当转移方程\(g\)被修改时,单点矩阵\(poi\)要及时更新poi[x]=Matrix(g[x][0],g[x][1]);,区间矩阵乘积\(G\)也要及时更新change(1,id[x]);
  4. 向上跳一条重链时,因为更新了轻子树的区间乘积,所以要更新其父节点的转移方程\(g\)
int n,m;
int a[N];

int h[N],e[M],ne[M],idx;

int g[N][2];

struct Matrix
{
    int mat[2][2];
    Matrix(int gu0,int gu1)
    {
        mat[0][0]=mat[0][1]=gu0;
        mat[1][0]=gu1;
        mat[1][1]=-INF;
    }
    int get_max()
    {
        return max(max(mat[0][0],mat[0][1]),max(mat[1][0],mat[1][1]));
    }
}poi[N]; //point:单点矩阵

struct Tree
{
    int l,r;
    Matrix G;
}tr[N*4];//线段树开4倍!!!
int id[N],nid[N],cnt;    //id:节点的dfn序编号;nid[id[i]]:id[i]->i的映射
int dep[N],siz[N],top[N],ed[N],fa[N],son[N];  //ed[top]:重链的末尾的原编号;

//预处理树剖+g先把所有儿子的贡献算上,到dfs2再减去重儿子
void dfs1(int u,int father,int depth)
{
    dep[u]=depth,fa[u]=father,siz[u]=1;
    g[u][1]=a[u];
    for(int i=h[u];i!=0;i=ne[i])
    {
        int v=e[i];
        if(v==father) continue;
        dfs1(v,u,depth+1);
        siz[u]+=siz[v];
        if(siz[son[u]]<siz[v]) son[u]=v;
        g[u][1]+=g[v][0];
        g[u][0]+=max(g[v][0],g[v][1]);
    }
    //不能在这里直接减去重儿子求g,因为这里是自底向上的递推
    return ;
}

//做剖分(t是重链的顶点)
//减去重儿子求出g,注意是自顶向下的递推!否则会少减son[son[u]]或多减son[son[v]]!!!
void dfs2(int u,int t)
{
    id[u]=++cnt,nid[cnt]=u,top[u]=t,ed[t]=u;
    g[u][0]-=max(g[son[u]][0],g[son[u]][1]);
    g[u][1]-=g[son[u]][0];
    poi[u]=Matrix(g[u][0],g[u][1]);
    
    if(son[u]==0) return ;
    dfs2(son[u],t);
    
    for(int i=h[u];i!=0;i=ne[i])
    {
        int v=e[i];
        if(v==fa[u] || v==son[u]) continue;
        dfs2(v,v);
    }
    
    return ;
}

void build(int u,int l,int r)
{
    if(l==r)
    {
        tr[u].G=poi[nid[l]];    //把单点矩阵复制到线段树对应的单点
        return ;
    }
}

void change(int u,int x)
{
    if(tr[u].l==tr[u].r)
    {
        tr[u].G=poi[nid[x]];
        return ;
    }
}

void modify_path(int x,int y){
    //在原树上单点修改
    g[x][1]+=y-a[x];    //注意加偏移量而不是直接赋值y,因为g还包含其他轻儿子的信息
    poi[x].mat[1][0]+=y-a[x];   //g和poi要同时改!!!
    a[x]=y;
    
    Matrix bef,aft;
    while(x!=-1)//直到x到根节点
    {
        //记录线段树的原先信息:区间矩阵乘积
        bef=query(1,id[top[x]],id[ed[top[x]]]);
        
        //用原树的信息来更新线段树的信息
        change(1,id[x]);    //因为上一轮循环更新了单点矩阵poi,所以这里要更新区间矩阵乘积g
        
        //记录线段树的当前信息:区间矩阵乘积
        aft=query(1,id[top[x]],id[ed[top[x]]]);
        
        //用原先信息和当前信息(作为轻儿子区间矩阵乘积)更新原树(而不是线段树)的链顶父节点的信息
        x=fa[top[x]];   //跳到上面一条重链
        if(x==-1) break;    //因为是一直跳到根节点,fa[1]=-1
        //因为更新了轻子树的区间乘积,所以这里要更新其父节点的转移方程g
        g[x][0]+=max(max(aft.mat[0][0],aft.mat[0][1]),max(aft.mat[1][0],aft.mat[1][1]))-max(max(bef.mat[0][0],bef.mat[0][1]),max(bef.mat[1][0],bef.mat[1][1]));
        g[x][1]+=max(aft.mat[0][0],aft.mat[0][1])-max(bef.mat[0][0],bef.mat[0][1]);
        //因为更新了转移方程g,因此这里要更新单点矩阵poi
        poi[x]=Matrix(g[x][0],g[x][1]);
    }
    return ;
}

int main()
{
    n=read(),m=read();
    for(int i=1;i<=n;i++) a[i]=read();
    for(int i=1;i<n;i++)
    {
        int u=read(),v=read();
        add(u,v),add(v,u);
    }
    dfs1(1,-1,1);
    dfs2(1,1);
    build(1,1,n);
    while(m--)
    {
        int x=read(),y=read();
        modify_path(x,y);
        Matrix res;
        res=query(1,id[1],id[ed[1]]);
        printf("%d\n",res.get_max());
    }
    return 0;
}

9.2.1.3.1.2.全局平衡二叉树\(O(N \log N)\)

适用范围小,目前只出现在动态树形dp\(O(N \log N)\)

因为它与重链相关,因此它可以很好地配合动态树形dp。

  1. dfs_son(u,fa)求出重儿子,一开始就把轻重边划分好。顺便先预处理\(g\):先把所有儿子的贡献算上,到dfs再减去重儿子。
  2. dfs_g(u,fa)减去重儿子求出\(g\),注意是自顶向下的递推!
  3. build_subtree(u,fa)先把一条重链上的其他所有轻儿子子树往下递归建树build_subtree(light_son,u),顺便记录轻儿子是其所在重链的顶端。再对这条重链建二叉树build_chain(1,sidx)。返回重链的二叉树的根节点return build_chain(1,sidx);。因为我们把树剖分成重链,而根节点所在重链又会被建二叉树,因此树高是\(O(\log N)\)级别。
  4. build_chain(l,r)找重心保证复杂度,令重心为二叉树的根节点,递归二叉树左右儿子kid[u][0]=build_chain(l,i-1);kid[u][1]=build_chain(i+1,r);此时树形结构确定,pushup(u)并返回重链的二叉树的根节点。
  5. pushup(u)子树矩阵乘积=左子树矩阵乘积当前节点矩阵右子树矩阵乘积(注意顺序因为广义矩阵乘法没有交换律)
  6. modify(pos,val)先修改\(g[pos][1]\)(注意是加偏移量而不是直接赋值val,因为\(g\)还包含其他轻儿子的信息)并更新单点矩阵\(poi[pos]\),然后一级一级向上跳更新其父亲的子树矩阵乘积\(sub[fa]\)(因为建树时已保证树高是\(O(\log N)\)级别,因此修改复杂度也是\(O(\log N)\)。当到达一条重链的链顶时(同时也是其父节点的轻儿子),要更新其父节点的\(g[fa][0]\)\(g[fa][1]\)

modify(pos,val)的注意事项同重链剖分modify_path(pos,val)****一样。

int son[N],siz[N];

int seq[N],sidx; //记录重链辅助build_chain

struct Matrix{}poi[N],sub[N]; //point:单点矩阵;subtree:子树矩阵乘积
int root;
int fa[N],kid[N][2];    //全局平衡二叉树的父节点和2个重子结点(注意不包含轻子节点)
bool top[N];    //是否是重链的顶端(同时也是其父节点的轻子节点)。不必记录整棵树的根节点为true,因为用不到

//求出重儿子,一开始就把轻重边划分好。g先把所有儿子的贡献算上,到dfs_g再减去重儿子
void dfs_son(int u,int father)
{
    siz[u]=1;
    g[u][1]=a[u];
    for(int i=h[u];i!=0;i=ne[i])
    {
        int v=e[i];
        if(v==father) continue;
        dfs_son(v,u);
        siz[u]+=siz[v];
        if(siz[v]>siz[son[u]]) son[u]=v;
        g[u][1]+=g[v][0];
        g[u][0]+=max(g[v][0],g[v][1]);
    }
    //不能在这里直接减去重儿子求g,因为这里是自底向上的递推
    return ;
}

//减去重儿子求出g,注意是自顶向下的递推!否则会少减son[son[u]]或多减son[son[v]]!!!
void dfs_g(int u,int father)
{
    g[u][0]-=max(g[son[u]][0],g[son[u]][1]);
    g[u][1]-=g[son[u]][0];
    poi[u]=Matrix(g[u][0],g[u][1]);
    for(int i=h[u];i!=0;i=ne[i])
    {
        int v=e[i];
        if(v==father) continue;
        dfs_g(v,u);
    }
    return ;
}

//子树矩阵乘积=左子树矩阵乘积*当前节点矩阵*右子树矩阵乘积(注意顺序因为广义矩阵乘法没有交换律)
void pushup(int u)
{
    sub[u]=poi[u];
    if(kid[u][0]) sub[u]=sub[kid[u][0]]*sub[u];
    if(kid[u][1]) sub[u]=sub[u]*sub[kid[u][1]];
    return ;
}

//返回重链的二叉树的根节点
int build_chain(int l,int r)
{
    if(l>r) return 0;
    
    //预处理找重心
    int wtot=0,wi=0; //总权值
    for(int i=l;i<=r;i++)
    {
        int u=seq[i];
        wtot+=siz[u]-siz[son[u]];
    }
    
    for(int i=l;i<=r;i++)
    {
        int u=seq[i];
        wi+=siz[u]-siz[son[u]];  //当前点的权值
        if((wi<<1)>=wtot) //只要当前点的权值大于等于一半的总权值即可选其为重心,降低复杂度
        {
            kid[u][0]=build_chain(l,i-1);
            kid[u][1]=build_chain(i+1,r);
            fa[kid[u][0]]=fa[kid[u][1]]=u;
            pushup(u);
            return u;   //返回重链的二叉树的根节点,一定会有一个点返回
        }
    }
}

//返回重链的二叉树的根节点
int build_subtree(int u,int father)
{
    //不可以直接在这里top[u]=true,因为下面还要对这条重链做处理,当前的u不一定是链顶
    for(int uu=u;uu!=0;father=uu,uu=son[uu])
        for(int i=h[uu];i!=0;i=ne[i])
        {
            int v=e[i];
            if(v==father || v==son[uu]) continue;
            int light_son=build_subtree(v,uu);  //轻儿子同时也是下一条新重链的链顶
            top[light_son]=true;
            fa[light_son]=uu;
        }
    sidx=0;
    for(int uu=u;uu!=0;uu=son[uu]) seq[++sidx]=uu;
    return build_chain(1,sidx);
}

void modify(int x,int y)
{
    g[x][1]+=y-a[x];    //注意加偏移量而不是直接赋值y,因为g还包含其他轻儿子的信息
    poi[x]=Matrix(g[x][0],g[x][1]);//g和poi要同时改!!!
    a[x]=y;
    
    int befsub0,befsub1,aftsub0,aftsub1;
    while(x)//直到x到根节点
    {
        if(top[x])
        {
            //记录平衡树的原先信息:区间矩阵乘积
            befsub0=max(sub[x].mat[0][0],sub[x].mat[0][1]);
            befsub1=max(sub[x].mat[1][0],sub[x].mat[1][1]);
        }
        
        //用原树的信息来更新平衡树的信息
        pushup(x);//因为上一轮循环更新了单点矩阵poi,所以这里要更新区间矩阵乘积g
        
        if(top[x])
        {
            //记录平衡树的当前信息:区间矩阵乘积
            aftsub0=max(sub[x].mat[0][0],sub[x].mat[0][1]);
            aftsub1=max(sub[x].mat[1][0],sub[x].mat[1][1]);
            
            //用原先信息和当前信息(作为轻儿子区间矩阵乘积)更新原树(而不是平衡树)的父节点的信息
            //因为更新了轻子树的区间乘积,所以这里要更新其父节点的转移方程g
            g[fa[x]][0]+=max(aftsub0,aftsub1)-max(befsub0,befsub1);
            g[fa[x]][1]+=aftsub0-befsub0;
            //因为更新了转移方程g,因此这里要更新单点矩阵poi
            poi[fa[x]]=Matrix(g[fa[x]][0],g[fa[x]][1]);
        }
        
        x=fa[x];
    }
}

int main()
{
    n=read(),m=read();
    for(int i=1;i<=n;i++) a[i]=read();
    for(int i=1;i<n;i++)
    {
        int u=read(),v=read();
        add(u,v),add(v,u);
    }
    dfs_son(1,-1);
    dfs_g(1,-1);
    root=build_subtree(1,-1);
    int lastans=0;
    while(m--)
    {
        int x=read(),y=read();
        x^=lastans;
        modify(x,y);
        lastans=sub[root].get_max();
        printf("%d\n",lastans);
    }
    return 0;
}

9.2.1.3.2.子结点的信息与当前节点是最值关系

设计一个重链方程。

若有方程\(g_u\)表示一条重链上的最值,显然\(ANS=\max \{ 所有重链 \}\),则开一个全局multiset<int> ans来维护\(g_u\)的最大值得到答案。

若轻儿子在父节点矩阵中作为元素\(maxn\)\(smaxn\)(节点u所有的儿子v中最大或次大的\(f_v\)),则对于每一个节点开一个multiset<int> h来维护\(maxn\)\(smaxn\)

初始时,要把初值放进所有的multiset,因为后面会multiset.erase()

总结

设计一个方程:重链方程。

答案是所有重链的区间矩阵乘积的最大值,我们开一个全局multiset<int> ans来维护重链的区间矩阵乘积的最大值得到答案。一个重链的链顶的矩阵\(**F_{top}**\)(其重链的区间矩阵乘积\(**G**\)乘初始矩阵)作为轻儿子更新其父节点的multiset<int> h来维护父节点的单点矩阵中的元素\(maxn\)\(smaxn\)

转移矩阵\(**G**\)由轻儿子的最值\(maxn\)\(smaxn\)构成。区间矩阵乘积\(**G**\)是单点矩阵\(**poi**\)的连乘积,单点矩阵\(**poi**\)的组成成分之一是轻儿子的最值\(maxn\)\(smaxn\),也就是每个点的multiset<int> h。当轻儿子的最值\(maxn\)\(smaxn\)被修改时,每个点的multiset<int> h要及时更新h[u].erase(h[u].find(f_bef));h[u].insert(f_aft);,区间矩阵乘积\(**G**\)也要及时更新change(1,id[x]);(树剖线段树)或pushup(x);(全局平衡二叉树)。

modify_path(pos,val)(树剖线段树)和modify(pos,val)(全局平衡二叉树)中:首先在原树上单点修改posa[pos]=val。然后更新从u到根节点的信息:随着u=fa[top[u]]的过程中,对于每一轮循环:1.记录线段树(或平衡树,下同)的原先信息:区间矩阵乘积;2.用原树的信息来更新线段树的信息;3.记录线段树的当前信息:区间矩阵乘积;4.用原先信息和当前信息(作为轻儿子区间矩阵乘积)更新原树(而不是线段树)的链顶父节点的信息及全局ansans.erase(ans.find(gbe));ans.insert(gaf);****。

9.2.1.3.3.区间修改

9.2.1.3.3.1.区间的矩阵修改

一般题目的矩阵都有特殊性质:在两个单点矩阵的\(mat[x][y]\)都加上一个值后,它们的矩阵乘积也只有在\(mat[x][y]\)加上一倍的这个值。这样的话我们可以用懒标记维护它。

下面假设mat[x][y]的x=1,y=0。

struct Tree{LL val1;/*区间加val1懒标记*/}tr[N*4];

//给当前的节点u加上懒标记
void eval(int u,LL val1)
{
    tr[u].val1+=val1;
    tr[u].G.mat[1][0]+=val1;    //经过在草稿纸上手动模拟,发现懒标记只要加在矩阵的这些位置即可
    return ;
}

//其他部分基本等同于线段树的懒标记操作
//询问和其他修改也要记得pushdown
void change_val2(){pushdown(u);}
Matrix query(int u,int l,int r){pushdown(u);/*别忘记这里!!!*/}

9.2.1.3.3.2.树上的路径修改

对于“在路径(u,v)上的每一个点(或边)增加一个权值w”,采用树上差分的方法:设函数add_val1(u,w):把从u到根节点的路径上的所有点的val1加w。只需要int p=lca(u,v);add(u,w),add(v,w),add(p,-w),add(fa[p],-w)即可。

9.2.2.复杂的方程——数据结构pushup()中转移

当dp方程过于复杂或是矩阵维度过大,且dp的状态与区间长度无关、dp状态的值域较小时,采用数据结构优化,dp在pushup中执行。

设计dp时要注意dp的状态要与区间长度无关,常见设计的dp状态如下:

  • \(f[W]\):有W个……的……,其中W是较小的值域,则一次操作的复杂度是\(O(F(W)*\log N)\)

  • 选定一个子序列或划分序列的问题:\(f[l][r]\):区间dp,其中l,r≤W,W是较小的值域,则一次操作的复杂度是\(O(W^3*\log N)\)

    转移:

    线段树+选定一个子序列的问题:
    
      $f[l][r]$:当前线段树区间选定子序列[l,r]的……。$f[l][l]$在`build()`和`change()`中的叶子节点处理好。$f[l][r]$在`pushup()`中执行区间dp转移:
    
        $f[l][r]=\max(lson[l][r],rson[l][r])$
    
        $f[l][r]=\max\limits_{l≤k<r}\{lson[l][k]\oplus rson[k+1][r]\}$
    
    平衡树+划分序列的问题:
    
      $f_{point}[l][r]$:当前的节点在[l,r]的……。在新建节点时处理好。
    
      $f_{segment}[l][r]$:当前的整棵子树在[l,r]的……。在`pushup()`中执行区间dp转移:
    
        $res[l][r]=\max\limits_{l≤k≤r}\{lson_{f_{segment}}[l][k]\oplus f_{point}[k][r]\}$
    
        $f_{segment}[l][r]=\max\limits_{l≤k≤r}\{res[l][k]\oplus rson_{f_{segment}}[k][r]\}$
    

    区间询问:

    线段树:开一个全局ans[W][W]。在`query()`中的`if(l≤tr[u].l && tr[u].r≤r)`加入贡献:
    
      新建res[W][W]。防止重复加入贡献。
    
      $res[l][r]=\max(ans[l][r],f[l][r])$
    
      $res[l][r]=\max\limits_{l≤k<r}\{ans[l][k]\oplus f[k+1][r]\}$。因为递归一定是左儿子优先,所以可以从左到右加入贡献。
    
      ans←res
    

9.2.2.1.序列长度不变——线段树维护

可修改权值。

每个区间开一个dp\(f[]\),其中f[]省略了区间的这一维度(完整版是f[tr[u].l][tr[u].r][])。

  • pushup:
void pushup(int u)
{
    Tree &p=tr[u],&ls=tr[u<<1],&rs=tr[u<<1|1];
    memset(p.f,0,sizeof p.f);
    for(int i=0;i<=min(c,p.r-p.l+1);i++)//阶段:有i个人去A国
        for(int l=max(0,i-(rs.r-rs.l+1));l<=min(i,ls.r-ls.l+1);l++)
        {
            int r=i-l;//l、r:状态+决策:左右区间各去多少个人去A国
            p.f[i]=(p.f[i]+(ls.f[l]*rs.f[r])%MOD)%MOD;
        }
    return ;
}

9.2.2.2.序列长度改变——平衡树维护

可修改权值、插入删除。

每个点开一个dp\(f[]\),其中f[]代表整棵子树中序遍历的区间的信息。

  • pushup:

    \(fpoint[l][r]\):当前的节点在工作状态[l,r]的范围内的最大能量。

    区间dp转移:\(f_{point}[l][r]=\max\limits_{i≤k≤j}\{f_{point}[l][k]+f_{point}[k+1][r]\}\)

    \(fsegment[l][r]\):当前的整棵子树在工作状态[l,r]的范围内的最大能量。

    区间dp转移:

    \(res[l][r]=\max\limits_{l≤k≤r}\{lson_{f_{segment}}[l][k]+f_{point}[k][r]\}\)

    \(f_{segment}[l][r]=\max\limits_{l≤k≤r}\{res[l][k]+rson_{f_{segment}}[k][r]\}\)

void pushup(int p)
{
    if(ls) tr[p].lsiz=tr[ls].lsiz;
    else tr[p].lsiz=tr[p].cnt;
    
    tr[p].siz=tr[ls].siz+tr[rs].siz+tr[p].cnt;
    
    memset(tr[idx].f_p,0,sizeof tr[idx].f_p);
    tr[idx].f_p[0][0]=tr[idx].f_p[3][3]=tr[idx].a*tr[idx].cnt;
    tr[idx].f_p[1][1]=tr[idx].b*tr[idx].cnt;
    tr[idx].f_p[2][2]=tr[idx].c*tr[idx].cnt;
    for(int len=2;len<=4;len++)
        for(int l=0;l+len-1<4;l++)
        {
            int r=l+len-1;
            for(int k=l;k<r;k++) tr[idx].f_p[l][r]=max(tr[idx].f_p[l][r],max(tr[idx].f_p[l][k],tr[idx].f_p[k+1][r]));
        }
        
    LL res[4][4];
    memset(res,0,sizeof res);
    for(int len=1;len<=4;len++)
        for(int l=0;l+len-1<4;l++)
        {
            int r=l+len-1;
            for(int k=l;k<=r;k++) res[l][r]=max(res[l][r],tr[ls].f_s[l][k]+tr[p].f_p[k][r]);
        }
        
    memset(tr[p].f_s,0,sizeof tr[p].f_s);
    for(int len=1;len<=4;len++)
        for(int l=0;l+len-1<4;l++)
        {
            int r=l+len-1;
            for(int k=l;k<=r;k++) tr[p].f_s[l][r]=max(tr[p].f_s[l][r],res[l][k]+tr[rs].f_s[k][r]);
        }

例题

《实践.错题本2022.3.18.【带修改的dp——线段树优化】Travel、2022.5.3.【带修改的dp——平衡树优化】喷式水战》

错题本

posted @ 2025-10-14 00:55  Brilliance_Z  阅读(4)  评论(0)    收藏  举报