写在前面
经过上一篇博客对多重背包问题的解释与分析,相信大家都已对多重背包问题与其二进制优化方式有了基本的认识,在上一篇博客中我们提到若将多重背包问题的数据范围由100提升到2000,则朴素的三重暴力循环解法将很容易超出时间限制,需要考虑二进制优化做法;但如果我们继续把数据范围由2000扩大到20000呢?很容易发现二进制优化做法的优化程度仍然有限,这时候我们则需要使用更好的优化方案,即单调队列优化方案。
考虑到部分同学可能没有接触过单调队列的相关问题,我们本文将从经典的单调队列问题说起,以便大家能对多重背包问题的单调队列优化方案有更全面更深刻的理解。
滑动窗口最大值问题
我们先来看一道经典的单调队列问题,即LeetCode第239题:滑动窗口的最大值问题。
基本描述:
给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
在每一时刻请返回滑动窗口中的最大值 。
示例 1:输入:nums = [1,3,-1,-3,5,3,6,7], k = 3 输出:[3,3,5,5,6,7] 解释: 滑动窗口的位置 最大值 ----------------------------- ----- [1 3 -1] -3 5 3 6 7 3 1 [3 -1 -3] 5 3 6 7 3 1 3 [-1 -3 5] 3 6 7 5 1 3 -1 [-3 5 3] 6 7 5 1 3 -1 -3 [5 3 6] 7 6 1 3 -1 -3 5 [3 6 7] 7
。
示例 2:输入:nums = [1], k = 1 输出:[1]
思路点拨
本题的题意描述还是十分清晰的,相信大家读完题后基本都能明白题目的意思,即在一个有固定长度且不断滑动的窗口中返回其每个时刻的最大值。
这道题其实有不止一种的解法,但考虑到本题的关键在于对多重背包问题的单调队列优化,所以我们不再针对本次做一些无关紧要的讨论,有兴趣的同学可以通过链接去力扣研究。
对于本题,我们可以准备一个单调变化的双向队列,假定队列内的值从头到尾呈单调递减变化,我们让单调队列的头保存当前滑动窗口内的最大值,在每一时刻将单调队列的头返回即可。
而具体到每一步窗口的滑动,都应该有以下两种情形:
情况一:若在当前滑动窗口中存在新入元素大于等于单调队列队尾数据的情况,则将单调队列的队尾弹出,因为它在向右继续滑动的过程中再也没有成为最大值的机会。
情况二: 在滑动窗口已经形成后,若单调队列中的最大值下标在滑动窗口滑动的过程中小于滑动窗口的左边界,则将该最大值从单调队列的头部弹出,因为这个最大值已不在滑动窗口内,不再有讨论的必要。
我们以题中的示例一为例。在示例一中,滑动窗口的长度为3
,整数数组nums为1,3,-1,-3,5,3,6,7
,具体的变化过程如图所示:
图中我们可以清晰地看到,从第③步开始往后滑动窗口已经形成,这时单调队列中的首元素依次是3,3,5,5,6,7
,即是这道题所要求输出的值。
代码实现
C++代码实现如下(代码源自LeetCode):
带注释版:
class Solution
{
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k)
{
int n = nums.size();
deque<int> q; // 准备一个双向队列 q 作为单调队列
for (int i = 0; i < k; ++i) // 滑动窗口形成中,即上图中的前三步
{
while (!q.empty() && nums[i] >= nums[q.back()])
{
q.pop_back();
}
q.push_back(i);
}
// 滑动窗口形成完毕,准备开始滑动
vector<int> ans = { nums[q.front()] }; // 容器ans用于存放结果,这一步相当于对上图第三步进行记录
for (int i = k; i < n; ++i) // 滑动窗口开始滑动
{
while (!q.empty() && nums[i] >= nums[q.back()]) // 情况1,进行相应操作
{
q.pop_back();
}
q.push_back(i);
while (q.front() <= i - k) // 情况2,进行相应操作
{
q.pop_front();
}
ans.push_back(nums[q.front()]); // 将单调队列的队首元素放入 ans 中,也就是记录此时的最大值
}
return ans;
}
};
纯净版:
class Solution
{
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k)
{
int n = nums.size();
deque<int> q;
for (int i = 0; i < k; ++i)
{
while (!q.empty() && nums[i] >= nums[q.back()])
{
q.pop_back();
}
q.push_back(i);
}
vector<int> ans = { nums[q.front()] };
for (int i = k; i < n; ++i)
{
while (!q.empty() && nums[i] >= nums[q.back()])
{
q.pop_back();
}
q.push_back(i);
while (q.front() <= i - k)
{
q.pop_front();
}
ans.push_back(nums[q.front()]);
}
return ans;
}
};
多重背包问题的单调队列优化
原题链接:https://www.acwing.com/problem/content/6/
基本描述
有 N 种物品和一个容量是 V 的背包。
第 i 种物品最多有 si 件,每件体积是 vi ,价值是 wi 。
求解将哪些物品装入背包,可使物品体积总和不超过背包容量且价值总和最大,并将最大的价值输出。
输入格式第一行两个整数 N,V 用空格隔开,分别表示物品种数和背包容积。
接下来有 N 行,每行三个整数 vi , wi , si 用空格隔开,分别表示第 i 种物品的体积、价值和数量。
数据范围0 < N ≤ 1000
0 < V ≤ 20000
0 < vi , wi , si ≤ 20000
输入样例4 5 1 2 3 2 4 1 3 4 3 4 5 2
输出样例
10
优化的可能性
为了让现象更为直观,方便大家理解,我们不妨先假设一道多重背包问题的数据如下:
物品的数量为2
,背包的容量为10
物品的体积、价值以及数量依次如下:
体积 | 价值 | 数量 | |
---|---|---|---|
物品1 | 3 | 5 | 2 |
物品2 | 2 | 4 | 3 |
我们先来回顾一下多重背包问题的最朴素解法,即用三重循环暴力求解:
#include <iostream>
using namespace std;
const int N = 110;
int n, m;
int f[N];
int main()
{
cin >> n >> m;
for (int i = 1; i <= n; i++)
{
int v, w, s;
cin >> v >> w >> s;
for (int j = m; j >= v; j--)
{
for (int k = 1; k <= s && k * v <= j; k++)
{
f[j] = max(f[j], f[j - v * k] + w * k);
}
}
}
cout << f[m] << endl;
return 0;
}
我们以上面假定的数据为例,来看看具体的状态转移过程:
由于物品2的体积为2
、最大数量是3
,在图中,颜色各异的五角星状态由与其颜色相同的小圆圈转移过来。从图中我们可以清楚地看到,三种颜色地五角星所依赖地圆圈数量都为3
个(因为物品2
的数量最多只有3
个),而则三个圆圈的变化就可以看成是一个长度为3
的滑动窗口正在滑动,因此我们或许可以采用单调队列的思想,让每次更新都只需从单调队列中获取最大值,从而对时间复杂度进行优化
单调队列优化的具体实现
经过前文的分析后,我们可以得知多重背包问题可以采取单调队列的方式进行优化,我们不妨以当前物品的数量作为滑动窗口的大小,以物品的体积为标志将f
数组分成若干组,则对于每一组,我们都使用一个单调队列来求解其所能获取的最大值。
考虑到在滑动窗口变化的过程中,数组f
的值实际上是在不断更新变化的,因此为了保护原有的数据不受影响,我们可以另外使用一个数组g
来保存f数组中的原有值,让滑动窗口在g
数组上移动,而把最终的结果更新回数组f
中。
具体的实现我们先看代码:
C++代码实现如下:
带注释版:
#include <iostream>
using namespace std;
const int N = 20010; // 数组的大小,取值稍大于数据范围即可
int n, m;
int f[N], g[N], q[N]; // f[N]-原始的数组 g[N]-备份的数组 q[N]-单调队列数组
int main()
{
cin >> n >> m;
for (int i = 1; i <= n; i++)
{
int v, w, s;
cin >> v >> w >> s; // 循环录入物品的体积、价值和数量
memcpy(g, f, sizeof(f)); // 将 f 数组中的数据备份到 g 数组中
for (int j = 0; j < v; j++)
{
int h = 0, t = -1; // 设置 h 为单调队列的头,t 为单调队列的尾
for (int k = j; k <= m; k += v)
{
if (h <= t && q[h] < k - s * v) // 若 q[h] 已不在滑动窗口内,则其下表弹出单调队列
{
h++;
}
if (h <= t) // 使用单调队列的最大值更新 f 数组中的值
{
f[k] = max(f[k], g[q[h]] + (k - q[h]) / v * w);
// q 内存放的是元素下标
// g[q[h]]为队首元素的值
// (k - q[h]) / v * w 为还能获取的价值
// g[q[h]] + (k - q[h]) / v * w 即为最大价值
}
while (h <= t && g[k] >= g[q[t]] + (k - q[t]) / v * w) // 若当前值比队尾元素更有价值,则队尾元素出列
{
t--;
}
q[++t] = k; // 当前值的下标入队
}
}
}
cout << f[m] << endl;
return 0;
}
纯净版:
#include <iostream>
#include <cstring>
using namespace std;
const int N = 20010;
int n, m;
int f[N], g[N], q[N];
int main()
{
cin >> n >> m;
for (int i = 1; i <= n; i++)
{
int v, w, s;
cin >> v >> w >> s;
memcpy(g, f, sizeof(f));
for (int j = 0; j < v; j++)
{
int h = 0, t = -1;
for (int k = j; k <= m; k += v)
{
if (h <= t && q[h] < k - s * v)
{
h++;
}
if (h <= t)
{
f[k] = max(f[k], g[q[h]] + (k - q[h]) / v * w);
}
while (h <= t && g[k] >= g[q[t]] + (k - q[t]) / v * w)
{
t--;
}
q[++t] = k;
}
}
}
cout << f[m] << endl;
return 0;
}
参考资料
【1】崔添翼 . 背包问题九讲