浅谈斜率优化 DP

算法详解

例题引入

我们来看一道题目:玩具装箱

\(n\) 个玩具,第 \(i\) 个玩具有长度 \(c_i\)。要求将这 \(n\) 个玩具按顺序排成一排并分成若干个段。一个 \([l,r]\) 段的代价为 \((r-l+\sum_{i=l}^{r} c_i-L)^2\),求分段的最小代价。

\(1 \le n \le 5 \times 10^4\)\(1 \le c_i,L \le 10^7\)

朴素 DP 做法

首先容易想到一个朴素的 DP 做法。

\(dp_i\) 表示将前 \(i\) 个物品分段后的最小代价,最终答案即为 \(dp_n\)

先预处理前缀和 \(s_k = \sum_{i=1}^{k} c_i\),易得状态转移方程 \(dp_i = \min_{j<i} \{ dp_j + (i-j-1+s_i-s_j)^2 \}\)

做法是可行的,但是在该题 \(5 \times 10^4\) 的数据范围下,这种 \(O(n^2)\) 的暴力是跑不过去的。

斜率优化 DP

考虑简化上面的转移式,令 \(g_i = s_i + i\)\(L' = L + 1\),有 \(dp_i = \min_{j<i} \{ dp_j + (g_i - g_j - L')^2 \}\)

将与 \(j\) 无关的移到外边,可得 \(dp_i - (g_i - L')^2 = \min_{j<i} \{ dp_j + {g_j}^2 + 2s_j(L'-s_i) \}\)

一次函数斜截式为 \(y = kx+b\),移项得 \(b = y - kx\),将所有只与 \(j\) 有关的信息表示成 \(y\),同时与 \(i\)\(j\) 都有关的表示成 \(kx\),把需要最小化的量即只与 \(i\) 有关的信息表示成 \(b\),则有 \(x_j = g_j\)\(y_j = dp_j + {g_j}^2\)\(k_i = -2(L'-g_i)\)\(b_i = f_i - (s_i - L')^2\)

那么现在转移式就可以直接写成 \(b_i = \min_{j<i} \{y_j - k_i x_j\}\)。把 \((x_j,y_j)\) 视作二维平面上的点,那么 \(k_i\) 就是斜率,\(b_i\) 是截距。问题转化为选择最合适的 \(j\) 以最小化直线的截距。

盗用借用 OI-wiki 上一张图:

不难发现,可以将斜率 \(k\) 的直线(图中红色线)从下往上平移,直到有一个点 \((x_p,y_p)\) 出现在直线上,此时 \(b_i = y_p - k_i x_p\)\(b_i\) 取到最小值,算完 \(dp_i\) 再把 \((x_i,y_i)\) 也加入点集。

思路是不错的,但是我们该如何维护点集呢?注意到可能让 \(b_i\) 取到最小值的点一定在下凸壳上,于是在寻找 \(p\) 的时候我们便不需要维护整个点集,只维护凸包上的点即可。本题中斜率 \(k_i\) 随着 \(i\) 的增加而增加,因而可以单调队列维护。

具体过程是简单的。每次只需用一条和 \(i\) 相关的直线去切维护的凸包,找到最优解来更新 \(dp_i\)。之后往单调队列里塞进状态 \(dp_i\),此时还得先剔除所有加入新点后不再是凸包上的点的点。

代码实现参考

注意涉及到分数比大小时最好十字相乘转化为整数来比,避免精度误差。

#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define i128 __int128
using namespace std;
const int N = 5e4+5;
struct line{LL k,b;};
LL n,L,c[N],s[N],dp[N];
deque<line> q;
LL read(){
    LL su=0,pp=1;char ch=getchar();
    while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
    while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
    return su*pp;
}
LL cntY(line ln,LL x){return ln.k*x+ln.b;}
bool Bad(line l1,line l2,line l3){
    i128 Lx=(i128)(l2.b-l1.b)*(l2.k-l3.k);
    i128 Rx=(i128)(l3.b-l2.b)*(l1.k-l2.k);
    return (Lx>=Rx);
}
int main(){
    n=read(),L=read()+1;
    for(int i=1;i<=n;i++)
        c[i]=read(),s[i]=s[i-1]+c[i]+1;
    q.pb({0,0});
    for(int i=1;i<=n;i++){
        LL x=s[i]-L;
        while(q.size()>1&&cntY(q[0],x)>=cntY(q[1],x))q.pop_front();
        LL Bnow=cntY(q.front(),x);dp[i]=Bnow+x*x;
        line newln={-2*s[i],dp[i]+s[i]*s[i]};
        while(q.size()>1&&Bad(q[q.size()-2],q.back(),newln))q.pop_back();
        q.pb(newln);
    }cout<<dp[n]<<"\n";
    return 0;
}

例题讲解

P2365 任务安排

给定 \(n\) 个按一定顺序排列的任务,要求将这 \(n\) 个任务分成若干批,每批包含相邻的若干任务。这些任务从 \(0\) 时刻开始分批加工,每个任务单独加工的时间为 \(t_i\),且每批任务开始前有机器启动时间 \(s\),而完成这批任务所需的时间是各个任务需要时间的总和(同一批任务将在同一时刻完成)。每个任务的费用是它的完成时刻乘以一个费用系数 \(f_i\),请构造一个分组方案使总费用最小。

\(1\le n \le 5000\)\(0 \le s \le 50\)\(1\le t_i,f_i \le 100\)

首先维护数组 \(st\)\(sf\) 分别表示 \(t\)\(f\) 这两个数组的前缀和,方便后续转移。

定义 \(dp_i\) 表示将前 \(i\) 个物品分成若干批的最小费用,但是你发现转移的时候就不知道机器启动过几次了。但是可以发现,如果要从 \(dp_j\) 转移到 \(dp_i\),由于编号为 \(j+1 \sim i\) 的任务都是在同一批内完成的,因此只需要把 \(s\) 对这一段的额外影响补充进费用里即可。

posted @ 2026-01-30 15:08  嘎嘎喵  阅读(9)  评论(0)    收藏  举报