单调队列优化dp

单调队列优化线性dp

对于单调队列,它可以维护一个窗口内的最大最小值,所以单调队可以优化的dp的类型一般是从某个定长窗口转移而来的dp问题。

由于用deque维护的单调队列的常数较大,所以下面来用数组模拟一下单调队列

稍微一想,对于单调队列维护窗口的操作,我们仅仅用到了deque的头删、尾删、尾插,这三个操作,也就是说在这个模型中,头尾指针是不会回退的,所以用双指针去维护这三个操作是很简单的

code:

#include <iostream>
#include <vector>

using namespace std;
const int N = 1e6 + 10;
int q[N], a[N], h, t;
int n, k;

int main()
{
    cin >> n >> k;
    for(int i = 1; i <= n; i++) cin >> a[i];
    h = 1, t = 0; // 清空操作
    for(int i = 1; i <= n; i++)
    {
        while(h <= t && a[q[t]] >= a[i]) t--;
        q[++t] = i;
        while(q[t] - q[h] + 1 > k) h++;
        if(i >= k) cout << a[q[h]] << ' ';
    }
    cout << endl;
    h = 1, t = 0;
    for(int i = 1; i <= n; i++)
    {
        while(h <= t && a[q[t]] <= a[i]) t--;
        q[++t] = i;
        while(q[t] - q[h] + 1 > k) h++;
        if(i >= k) cout << a[q[h]] << ' ';
    }
    cout << endl;
    return 0;
}

形如:\(dp_i=\max_{j=i-m}^{i-1}dp_j\)这样的转移方程可以用单调队列优化,不难发现这就是求的前面长度为m窗口的最值,可以在更新dp的同时去维护窗口。

虽然上述形式是一维的,但是如果在二维问题甚至更复杂的问题中,如果dp转移需要的是窗口的最值,并且需要的最值是前面更新过的dp值或者一些已经已知、预处理过的信息都可以用这个方式去优化。

#10176. 「一本通 5.5 例 2」最大连续和

状态表示:\(dp_i\)\(i\)位置为结尾的长度不超过\(m\)的最大的子序列和

转移方程:\(dp_i=s_i-\min_{j=i-m}^{i-1}s_j\)

code:

#include <iostream>

using namespace std;
const int N = 2e5 + 10;
typedef long long LL;
LL a[N], dp[N], q[N], s[N];
int h, t, n, m;

int main()
{
	cin >> n >> m;
	for(int i = 1; i <= n; i++) cin >> a[i];
	for(int i = 1; i <= n; i++) s[i] = s[i - 1] + a[i];
	h = 1, t = 0;
	LL ans = -1e18;
	for(int i = 1; i <= n; i++)
	{
		while(h <= t && s[q[t]] >= s[i - 1]) t--;
		q[++t] = i - 1;
		while(h <= t && q[h] < i - m) h++;
		dp[i] = s[i] - s[q[h]];
		ans = max(ans, dp[i]);
	}
	cout << ans << endl;
	return 0; 
}


#10180. 「一本通 5.5 练习 1」烽火传递

状态表示:\(dp_i\)\(i\)位置放置烽火,前\(i\)个位置满足连续\(m\)个位置必然有一个烽火的最小花费

  • 需要说明的是对于这种连续\(m\)个位置至少选择一个位置,或者连续未选择的位置不超过\(m\)个等等表述都是求解的一个问题,如果选择用dp来做的话,可以考虑去转移区间是否连续且需要最值。

状态转移:\(dp_i=a_i+\min_{j=i-m}^{i-1}\)

code:

#include <iostream>

using namespace std;
const int N = 2e5 + 10;
typedef long long LL;
LL a[N], dp[N], q[N];
int h, t, n, m;

int main()
{
	cin >> n >> m;
	for(int i = 1; i <= n; i++) cin >> a[i];
	h = 1, t = 0;
	for(int i = 1; i <= n; i++)
	{
		while(h <= t && dp[q[t]] >= dp[i - 1]) t--;
		q[++t] = i - 1;
		while(h <= t && q[h] < i - m) h++;
		dp[i] = dp[q[h]] + a[i];
	}
	LL ans = 1e18;
	for(int i = n - m + 1; i <= n; i++) 
		ans = min(ans, dp[i]);
	cout << ans << endl;
	return 0; 
}


#10177. 「一本通 5.5 例 3」修剪草坪

正难则反,没有连续超过\(k\)个奶牛,意味着\(k+1\)个奶牛中必然有一个不选,求这个最小值即可。

#include <iostream>

using namespace std;
const int N = 2e5 + 10;
typedef long long LL;
LL a[N], dp[N], q[N];
int h, t, n, m;

int main()
{
	LL sum = 0;
	cin >> n >> m;
	for(int i = 1; i <= n; i++) 
	{
		cin >> a[i];
		sum += a[i];
	}
	h = 1, t = 0;
	for(int i = 1; i <= n; i++)
	{
		while(h <= t && dp[q[t]] >= dp[i - 1]) t--;
		q[++t] = i - 1;
		while(h <= t && q[h] < i - m - 1) h++;
		dp[i] = dp[q[h]] + a[i];
	}
	LL mina = 2e18;
	for(int i = n - m; i <= n; i++) 
		mina = min(mina, dp[i]);
	cout << sum - mina << endl;
	return 0; 
}


#10181. 「一本通 5.5 练习 2」绿色通道

没什么好说的,二分答案+单调队列优化dp

#include <iostream>

using namespace std;
const int N = 5e4 + 10;
typedef long long LL;
LL a[N], dp[N], q[N];
int n, k;
int h, t;

bool check(int m)
{
	h = 1, t = 0;
	for (int i = 1; i <= n; i++)
		dp[i] = 0;
	for (int i = 1; i <= n; i++)
	{
		while (h <= t && dp[q[t]] >= dp[i - 1])
			t--;
		q[++t] = i - 1;
		while (h <= t && q[h] < i - m - 1)
			h++;
		dp[i] = dp[q[h]] + a[i];
	}
	// for (int i = n - m; i <= n; i++)
	// 	cout << dp[i] << " ";
	// cout << endl;
	for (int i = n - m; i <= n; i++)
		if (dp[i] <= k)
			return true;
	return false;
}

int main()
{
	cin >> n >> k;
	for (int i = 1; i <= n; i++)
		cin >> a[i];
	int l = 0, r = n;
	while(l < r)
	{
		int mid = (l + r) >> 1;
		if(check(mid)) r = mid;
		else l = mid + 1;
	}
	cout << l << endl;
	return 0;
}

P3800 Power 收集

\(i\)位置可以更新下一层的\([max(i-T,0),min(i+T,m)]\),意味着\(i\)可以由上一层\([max(j-T,0),min(j+T,m)]\)更新得来,求的是这个区间的最值,没什么好说的

code:

#include <iostream>

using namespace std;
const int N = 4e3 + 10;
typedef long long LL;
LL dp[N][N], val[N][N];
int n, m, t, T, h, q[N];

int main()
{
	cin >> n >> m >> t >> T;
	int x, y, v; 
	while(t--)
	{
		cin >> x >> y >> v;
		val[x][y] += v;
	}
	for(int j = 1; j <= m; j++) dp[1][j] = val[1][j];
	for(int i = 2; i <= n; i++)
	{
		// [j - T, j + T]
		h = 1, t = 0;
		int k = 1;
		for(int j = 1; j <= m; j++)
		{
            // 这里需要额外注意一定要把单次更新的所有元素全部入队列之后才能把大于窗口长度的h移出去
			while(k <= min(j + T, m)) 
			{
				while(h <= t && dp[i - 1][q[t]] <= dp[i - 1][k]) t--;
				q[++t] = k++;
			}
			while(h <= t && q[h] < j - T) h++; 
			dp[i][j] = dp[i - 1][q[h]] + val[i][j];
		}
	}
	LL ans = 0;
	for(int j = 1; j <= m; j++) 
		ans = max(ans, dp[n][j]);
	cout << ans << endl;
	return 0;
}


单调队列优化多重背包

多重背包:有\(n\)个物品,每个物品的体积为\(w_i\),价值为\(v_i\),共\(c_i\)个,背包的容量为\(m\),需要在所选物品体积不超过背包容量的前提的最大价值.

常规解法:

void solve()
{
    for(int i = 1; i <= n; i++)
        for(int j = 0; j <= m; j++)
            for(int k = 0; k <= c[i] && j - k * w[i] >= 0; k++)
                dp[i][j] = max(dp[i][j], dp[i - 1][j - k * w[i]] + k * v[i]);
}

时间复杂度:\(O(nmc)\),由于n,m,c往往同阶,记作\(O(n^3)\)

探寻优化方案:

状态转移方程:\(dp_{i,j}=\max_{k=0}^{min(c_i,\frac{j}{w_i})}(dp_{i-1,j-kw_i}+kv_i)\)

对于\(j\)这个位置,它依赖的位置为上一层的\(j-kw_i\),对于\(j-w_i\)这个位置,它依赖的位置为上一层的\(j-w_i-k*w_i\),我们发现其实状态的转移只发生在\(j-kw_i\)之间,也就是说,这些位置可以看做一组,而这一组的数的特点为\(j\%w_i\)为定值(因为\(j-kw_i\ge0\),他对\(w_i\)取模等价于\(j\)\(w_i\)取模)。

如果我们把状态转移方程中的\(kv_i\)去掉,就会发现对于同一组的dp值,转移变成了找到窗口内最大值,这个窗口上限大小为\(c_i\),这就变成了单调队列维护窗口最大的问题。

下面来举例形象理解一下:

\(w_i=3\)\(c_i=2\)\(v_i=10\), 下面是依赖关系

  • \(dp_{i,1}\)->\(dp_{i-1,1}\)
  • \(dp_{i,4}\)->\(dp_{i-1,1}\),\(dp_{i-1,4}\)
  • \(dp_{i,7}\)->\(dp_{i-1,1}\),\(dp_{i-1,4}\),\(dp_{i-1,7}\)
  • \(dp_{i,10}\)->\(dp_{i-1,4}\),\(dp_{i-1,7}\),\(dp_{i-1,10}\)
  • ...

问题到这里是很明了,对于上述问题就可以用单调队列维护,但如果把\(kv_i\)在加回来,单调队列里面维护的值就需要发生变化,比如:

  • \(dp_{i,7}\)->\(dp_{i-1,1}+2v_i\),\(dp_{i-1,4}+v_i\),\(dp_{i-1,7}\)
  • \(dp_{i,10}\)->\(dp_{i-1,4}+2v_i\),\(dp_{i-1,7}+v_i\),\(dp_{i-1,10}\)

及对于同一组内位置$k_1,k_2 $$k_1<k_2\(来讲,\)k_1\(所维护的单调队列需要统一加上若干倍的\)v_i\(,但是我们可以发现,这是一个区间统一加上某个数的操作,对于区间的最大值的位置并不影响,他们连续的位置对应数值的差距总为\)v_i\(,所以我们可以尝试只用单调队列去维护单调性,找到正确的转移位置,然后自己去修正它的答案,具体来讲就是单调队列里面维护的是\)dp_{i-1,j}+(tol-\frac{j}{w_i})v_i\(,\)tol=\frac{m}{w_i}\(然后把正确位置找到,假设是\)p\(,它们之间的在组内相隔的距离为\)\frac{j}{w_i}-\frac{p}{w_i}\(,\)dp_{i-1,p}\(这个状态在\)p\(位置加的是0,往后走\)\frac{j}{w_i}-\frac{p}{w_i}\(步到\)j\(之后应该加的是\)(\frac{j}{w_i}-\frac{p}{w_i})v_i\(,所以我们拿到\)p\(位置之后,\)dp_{i,j}=dp_{i-1,p}+(j/w_i-p/w_i)*v_i$

code:

#include <iostream>
#include <vector>

using namespace std;
const int N = 510, M = 1100;
int dp[N][M], q[N], h, t;
int n, m;
int w[N], v[N], c[N];

int main()
{
    cin >> n >> m;
    for(int i = 1; i <= n; i++)
        cin >> w[i] >> v[i] >> c[i];
    for(int i = 1; i <= n; i++)
    {
        for(int k = 0; k < w[i]; k++) // 枚举模数
        {
            h = 1, t = 0;
            int tol = m / w[i];
            // 模值为k的一组j
            // if(i == 2)cout << k << endl;
            for(int j = k; j <= m; j += w[i])
            {
                while(h <= t && dp[i - 1][q[t]] + (tol - q[t] / w[i]) * v[i] <= dp[i - 1][j] + (tol - j / w[i]) * v[i]) t--;
                q[++t] = j;
                while(q[h] < j - c[i] * w[i]) h++;
                int p = q[h];
                dp[i][j] = dp[i - 1][p] + (j / w[i] - p / w[i]) * v[i]; 
                // if(i == 2) cout << p << " " << j << ' ' << dp[i][j] << endl;
            } 
        }
    }                                   
    // for(int j = 0; j <= m; j++) cout << dp[2][j] << ' ';
    // cout << endl;
    cout << dp[n][m] << endl;
    return 0;
}

上述代码已经可以解决问题了,但是并不是网络上流行的方式,下面来介绍一下大家熟知的优化方式

单调队列无法直接维护的原因是当\(j\)往后移动的时候,单调队列中的\(kv_i\)会发生改变,所以自然会想到让\(j\)这一维的值固定,及令\(j-kw_i=t\),得\(k=\frac{j-t}{w_i}\),这样转移的形式\(dp_{i,j}=\max_{k=0}^{min(c_i,\frac{j}{w_i})}(dp_{i-1,j-kw_i}+kv_i)\)

变成\(dp_{i,j}=\max_{k=0}^{min(c_i,\frac{j}{w_i})}(dp_{i-1,t}-\frac{tv_i}{w_i}+\frac{jv_i}{w_i})\)\(dp_{i-1,t}-\frac{tv_i}{w_i}\)并不会随着\(j\)变化了,或者说的形象一点我们把变化屏蔽掉了,之前\(k\)的差异需要\(j\)去做偏移屏蔽,现在我们把\(j\)固定直接对\(k\)做偏移,偏移量就是\(\frac{j}{w_i}\),最后更新答案的时候加上\(\frac{jv_i}{w_i}\)即可。

code:

    for(int i = 1; i <= n; i++)
    {
        for(int k = 0; k < w[i]; k++)
        {
            h = 1, t = 0;
            for(int j = k; j <= m; j += w[i])
            {
                while(h <= t && dp[i - 1][q[t]] - q[t] * v[i] / w[i] <= dp[i - 1][j] - j * v[i] / w[i]) t--;
                q[++t] = j;
                while(h <= t && q[h] < j - c[i] * w[i]) h++;
                dp[i][j] = dp[i - 1][q[h]] - q[h] * v[i] / w[i] + j * v[i] / w[i];
            }
        }
    }    

其实不难发现,上述两种思考方式都指向了同一种结果,那就是对\(k\)做偏移,因为都是维护的最值的位置,也就是说我们仅仅关系它们的大小关系,所以在比较\(dp_{i,q_t}+(tol-\frac{q_t}{w_i})v_i\)\(dp_{i,j}+(tol-\frac{j}{w_i})v_i\)时可以把\(tol\)统一去掉,这样单调队列中大小的比较就变成了下面的那种。

下面来做一下空间优化,由于窗口的移动方向已经固定了,所以没法直接用一个表去做优化(直接一个表需要逆序遍历),所以我们再加一张表\(g_j\)表示\(i-1\)行的状态,每次更新前拷贝\(f_i\)->\(g_i\),然后把需要\(dp_{i-1}\)的信息的位置全部换成\(g_j\)即可。

code:

    for(int i = 1; i <= n; i++)
    {
        for(int k = 0; k < w[i]; k++)
        {
            h = 1, t = 0;
            memcpy(g, dp, sizeof dp);
            for(int j = k; j <= m; j += w[i])
            {
                while(h <= t && g[q[t]] - q[t] * v[i] / w[i] <= g[j] - j * v[i] / w[i]) t--;
                q[++t] = j;
                while(h <= t && q[h] < j - c[i] * w[i]) h++;
                dp[j] = g[q[h]] - q[h] * v[i] / w[i] + j * v[i] / w[i];
            }
        }
    }   

下面给一下板子的链接

P1776 宝物筛选

posted on 2026-06-29 09:49  我不爱吃汉堡  阅读(2)  评论(0)    收藏  举报

导航