热身一、数据结构
操作分析:
- 插入操作:可以用一个栈模拟,一直往栈顶放元素
- 删除操作:就是正常的弹栈操作
- 左移操作:弹栈操作,可弹出的元素需要保留,可以放到第二个栈里面
- 右移操作:讲第二个栈顶元素,移动到第一个栈里面
- 询问操作:维护一个数组F,每次元素更新,都需要维护数组F
问题分析:
- 关键就是新造一个数据结构,结构定义 + 结构操作
- 模拟光标的功能,做移动,右移动,插入,删除,用对顶栈来模拟
- 实现对顶栈,用数组模拟,或者用链表模拟
- 题目中的BUG:Query K中 K 可能大于当前位置
#include<iostream>
#include <stack>
#include <vector>
#include <stdio.h>
using namespace std;
#define ll long long
class NewStruct {
public:
NewStruct() {
sum[0] = 0;
ans[0] = -2147483647;
}
void insert(ll x) {
s1.push(x);
int ind = s1.size();
ll val = x + sum[ind - 1];
ll val1 = max(ans[ind - 1], val);
sum[ind] = val;
ans[ind] = val1;
return ;
}
void del() {
if (s1.empty()) return ;
s1.pop();
return ;
}
void move_left() {
if (s1.empty()) return ;
s2.push(s1.top());
del();
return ;
}
void move_right() {
if (s2.empty()) return ;
insert(s2.top());
s2.pop();
return ;
}
int query(ll k) {
return ans[k];
}
private:
stack<ll> s1, s2;
ll sum[1005];//sum 存从第一个位置到当前位置的和
ll ans[1005];//ans存放当前位置的字段和最大的
};
int main() {
ll n;
cin >> n;
string op;
ll val;
NewStruct s;
for (int i = 0; i < n; i++) {
cin >> op;
switch (op[0]) {
case 'I': cin >> val; s.insert(val); break;
case 'D': s.del(); break;
case 'L': s.move_left(); break;
case 'R': s.move_right(); break;
case 'Q': {
cin >> val;
cout << s.query(val) << endl;
} break;
}
}
return 0;
}
热身二:火车进站
有 n 列火车按 1 到 n 的顺序从东方左转进站,这个车站是南北方向的,它虽然无限长,只可惜是一个死胡同,而且站台只有一条股道,火车只能倒着从西方出去,而且每列火车必须进站,先进后出。
现在请你按字典序输出前 20 种可能的出栈方案。
题目分析:
-
当前进栈的最大数字是x, 序列中当前待出栈的数字是y
-
y < x y \lt x y<x,说明y 一定是栈顶元素
-
y > x y > x y>x,将 x + 1 , y {x + 1 , y} x+1,y入栈,此时栈顶元素一定是y
-
通过使用next_permutation来枚举出所有全排序的方案
-
然后模拟栈的进出,来判断该情况是否合理
#include<iostream>
#include <stack>
#include <algorithm>
using namespace std;
int a[30];
int s[30], top;
bool is_valid(int *a, int n) {
int j = 0;//j 记录当前入栈的最大值
top = 0;//把栈清空
for (int i = 1; i <= n; i++) {
while(j < a[i]) { s[++top] = (++j); }//小于这个数的都入栈,不能入栈看能否匹配
if (top == 0 || s[top] - a[i]) return false;
--top;
}
return true;
}
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) a[i] = i;
int cnt = 0;
do {
if (!is_valid(a, n)) continue;
for (int i = 1; i <= n; i++) {
cout << a[i];
}
cout << endl;
cnt++;
if (cnt == 20) break;
} while(next_permutation(a + 1, a + n + 1));
return 0;
}
单调队列
- 本质问题是:固定查询结尾的RMQ问题,例如RMQ ( x , 7 ) (x, 7) (x,7)
- 问题性质:维护滑动窗口最值问题
问题引入: RMQ(X, Y)就是询问数组[x, y]区间内部的最小值 例如RMQ(0, 3) = 1, RMQ(3 , 7) = 2
现在,固定询问区间的尾部,例如:RMQ(x, 7) 请思考,如下序列中最少记录几个元素,就可以满足RMQ(x, 7)的任何需求
答:4个
0–7:1
1–7:1
2–7:2
3–7:2
4–7:2
5–7:8:
6–7:8
7–7:12
单调队列所维护的区间最小值一定是在队首 所以需要建立的是一个单调递增的序列
插入元素以后,所有大于该元素的值,都将被移出队列
入队操作:
队尾入队,会把之前破坏单调性的元素,都从队尾移出(维护单调性)
出队操作:
如果队首元素超出区间范围,就将元素从队首出队
元素性质:
1. 队首元素,永远是当前维护区间的(最大/最小值)
2. 序列中的每一个元素,在依次入队的过程中,每个元素都【蓝】过
3. 单调队列维护的是滑动窗口内部的区间最值
实际案例: 把数值代表人的能力,窗口代表时间范围, 如果一个人年龄比你小,能力比你强,自己就会被出队。。
维护最小值,则维护单调递增队列
维护最大值,则维护单调递减队列
题目一:滑动窗口:单调队列的模板题
思考:单调队列中是记录值还是记录下标的问题
结论:记录下标,因为有了下标可以索引到值,记录值反向不可查
实现单调队列的套路,先入队,再把过期元素出队
#include<iostream>
using namespace std;
#define MAX_N 300000
int q[MAX_N + 5], head = 0, tail = 0;//单调队列存储的是原数列的下标,有下标可以索引到值
int val[MAX_N + 5];
int main() {
int n, k;
cin >> n >> k;
for (int i = 1; i <= n; i++) {
cin >> val[i];
}
for (int i = 1; i <= n; i++) {
while(tail - head && val[q[tail - 1]] > val[i]) --tail;//当队尾元素大于该元素,队尾元素出队
q[tail++] = i;
if (q[head] <= i - k) head++;//当第一个元素超过滑动窗口范围,第一个元素出队
if (i >= k) {//当队列中的值满足滑动窗口的值
i > k && cout << " ";
cout << val[q[head]];
}
}
cout << endl;//单调递减s队列
head = tail = 0;//清空队列
for (int i = 1; i <= n; i++) {
while(tail - head && val[q[tail - 1]] < val[i]) --tail;
q[tail++] = i;
if (q[head] <= i - k) head++;
if (i >= k) {
i > k && cout << " ";
cout << val[q[head]];
}
}
cout << endl;
return 0;
}
单调栈
对于单调队列,尾部入队,把违反单调性的队列从尾部出队,把过期的元素,从头部出队
若把单调队列的一头堵死,从尾部入,从尾部出,就构成了单调栈
单调栈不过滤过期元素
单调栈的性质
一个数字入栈以后,他前面的数字一定是离他最近小于/大于他的值
擅长维护最近(大于/小于)关系
- 单调栈保留了单调队列的入队操作
- 单调栈依然是维护了一种单调性
- 问题性质:最近(大于/小于)关系
- 入栈之前,符合单调性的栈顶元素,就是我们要找的最近(大于/小于)关系
- 均摊时间复杂度: O ( 1 ) O(1) O(1)
题目二:最大矩形面积:单调栈的模板题
1. 分析最优解性质
1. 最大矩形的性质 : 矩形高度 = 区域最矮模板的高度
2. 以每一块模板作为矩形高度,求能得到的最大矩形面积,最后在所有面积中,取得最大值
2. 需要求解:每一块木板最近得高度小于当前木板得位置。所以需要用单调栈
分别从左面和右面找到小于该高度的值,距离相减求得宽度,然后以当前为高求得面积
#include<iostream>
using namespace std;
#define MAX_N 100000
#define ll long long
ll s[MAX_N + 5], top;
ll height[MAX_N + 5];
ll l[MAX_N + 5], r[MAX_N + 5];//分别找到左面小于当前和右面小于当前的值
ll solve(ll n) {
height[0] = height[n - 1] = -1;
top = -1;
s[++top] = 0;
for (int i = 1; i <= n; i++) {
while( top != -1 && height[s[top]] >= height[i] ) --top;//大于等于是因为对于和当前相同的值的时候,小于当前的临界值,显然不是等于该值的,下面的同理
l[i] = s[top];
s[++top] = i;
}
top = -1;
s[++top] = 0;
for (int i = n; i >= 1; i--) {
while( top != -1 && height[s[top]] >= height[i] ) --top;
r[i] = s[top];
s[++top] = i;
}
ll ans = 0;
for (int i = 1; i <= n; i++) {
ans = max(ans, height[i] * (r[i] - l[i] - 1));
}
return ans;
}
int main() {
ll n, ans;
cin >> n;
for (int i = 1; i <= n; i++) cin >> height[i];
cout << solve(n) << endl;
return 0;
}
动态规划与单调栈/队列
题一:矩形
能组成矩形就是看有没有左上点和该点可以匹配成一个矩形 找矩形,就以该列得高度为最矮得高度,然后在左面找第一个小于该点的高度
以第三行为例
每个矩形得高度为2、3、1、3、3、3 以第五行为例:小于该列的高度为第3列,高度为1,所以能确定在第三列右侧,
S 总 矩 形 面 积 为 = h 第 五 列 的 高 度 × ( 第 五 列 − 第 三 列 ) 宽 度 S_{总矩形面积为} =h_{第五列的高度} \times (第五列 - 第三列)_{宽度} S总矩形面积为=h第五列的高度×(第五列−第三列)宽度
而能构成多大的矩形,就说明有多少个点可以匹配右下角的点构成一个三角形, 对于第三行以及左面,以第三行为右下角点,构成多少矩形,则为 f ( 3 ) f(3) f(3)
所以 f ( 5 ) = h 5 ∗ ( 5 − 3 ) + f ( 3 ) f(5) = h_5 * (5 - 3) + f(3) f(5)=h5∗(5−3)+f(3)
推出动态转移方程为 f ( n ) = h n ∗ ( n − m ) + f ( m ) ∣ m : 左 面 第 一 个 小 于 该 列 高 度 的 列 f(n) = h_n * (n - m)+ f(m) | m:左面第一个小于该列高度的列 f(n)=hn∗(n−m)+f(m)∣m:左面第一个小于该列高度的列
解题思路:
- 左上角坐标和右下角坐标可以唯一确定一个子矩形
- 确定一行,将问题转换成子问题,右下角坐标落在固定的一行上,求每一点能构成的子矩形数量
- 通过观察,将问题变成两个子问题
- 首先找到左侧离x点最近的,小于x点的位置i
- f ( x ) f(x) f(x) 代表以x做为右下角坐标所能构成的合法子矩形数量
- 首先找到左侧离x点最近的,小于x点的位置i
- f ( x ) = h x × ( x − i ) + f ( i ) f(x) = h_x \times (x - i) + f(i) f(x)=hx×(x−i)+f(i)
- 因为需要求解离x最近的小于x的位置,所以要用单调栈
#include<iostream>
using namespace std;
#define MAX_N 1000
#define MOD_NUM 100007
int s[MAX_N + 5], top;
int c[MAX_N + 5][MAX_N + 5];//每一个点向上数白色格子数量
int f[MAX_N];
int n, m;
void read() {
cin >> n >> m;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
cin >> c[i][j];
if (c[i][j]) c[i][j] += c[i - 1][j];
}
}
return ;
}
long long solve() {
long long ans = 0;
for (int i = 1; i<= n; i++) {
top = -1;
s[++top] = 0;
c[i][0] = -1;
f[0] = 0;
for (int j = 1; j <= m; j++) {
while (top != -1 && c[i][s[top]] >= c[i][j]) --top;
f[j] = c[i][j] * (j - s[top]) + f[s[top]];
s[++top] = j;
f[j] %= MOD_NUM;
ans += f[j];
ans %= MOD_NUM;
}
}
return ans;
}
int main() {
read();
cout << solve() << endl;
return 0;
}
题二:古老的打字机:斜率优化
题目分析: 设 dp[i] 为打印 i个字符的最小消耗\ C k C_k Ck:为从 k k k 到 i i i 的所有和
则 d p [ i ] = m i n ( d p [ j ] + ∑ k = j + 1 k = i C k 2 + M ) dp[i] = min(dp[j] + \sum_{k = j + 1}^{k = i}C_k^2 + M) dp[i]=min(dp[j]+∑k=j+1k=iCk2+M):从i之前找到一个区间 使得 单次打印磨损值 与该区间之前的磨损值之和 最小的
该转移方程的时间复杂度为 O ( n 2 ) O(n^2) O(n2) 但是因为题目中 n n n 值为1e6,会导致超时的结果从而进行优化
设s[i] 为从0到i的加和
d
p
[
i
]
=
m
i
n
(
d
p
[
j
]
+
∑
k
=
j
+
1
k
=
i
C
k
2
+
M
)
=
m
i
n
(
d
p
[
j
]
+
(
s
i
−
s
j
)
2
+
M
)
dp[i] = min(dp[j] + \sum_{k = j + 1}^{k = i}C_k^2 + M) = min(dp[j] + (s_i - s_j)^2 +M)
dp[i]=min(dp[j]+∑k=j+1k=iCk2+M)=min(dp[j]+(si−sj)2+M)
=
m
i
n
(
d
p
[
j
]
+
s
i
2
−
s
j
2
−
2
∗
s
i
∗
s
j
+
M
)
= min(dp[j] + s_i^2 - s_j^2 - 2 * s_i * s_j + M)
=min(dp[j]+si2−sj2−2∗si∗sj+M)
在上述式子中:
s
i
2
+
M
s_i^2 + M
si2+M : 定值项
d
p
[
j
]
+
s
j
2
dp[j] + s_j^2
dp[j]+sj2: 转移项
2
∗
s
i
∗
s
j
2 * s_i * s_j
2∗si∗sj:混合项
设
g
(
i
,
j
)
=
f
(
i
)
−
f
(
j
)
s
i
−
s
j
g(i, j) = \frac{f(i) - f(j)}{s_i - s_j}
g(i,j)=si−sjf(i)−f(j)
设 g ( j , k ) < g ( k , l ) g(j, k) < g(k, l) g(j,k)<g(k,l) 若 2 ∗ s i < g ( j , k ) < g ( k , l ) 2*s_i<g(j, k) < g(k, l) 2∗si<g(j,k)<g(k,l) : 则 j j j 不如 k k k, k k k, 不如 l l l,则最优解为 l l l
若 g ( j , k ) < 2 ∗ s i < g ( k , l ) g(j, k) < 2*s_i< g(k, l) g(j,k)<2∗si<g(k,l):则 k k k 不如 l l l , j j j 比 k k k 好,最优解在 j j j 和 l l l 中取
若 g ( j , k ) < g ( k , l ) 2 ∗ s i g(j, k) < g(k, l) 2*s_i g(j,k)<g(k,l)2∗si:则 k k k 比 l l l, j j j 比 k k k 好, 删除 k k k, 最优解为 j j j
综上:若出现背脊形状,则背脊处为无用点
优化之后:时间复杂度为
O
(
n
)
O(n)
O(n)
解题思路:
- 状态定义
d p [ i ] dp[i] dp[i] 代表打印i个字符,最小消耗值 - 状态转移方程
定义: s i = ∑ k = 1 i C k s_i = \sum_{k = 1}^iC_k si=∑k=1iCk
d p [ i ] = m i n ( d p [ j ] + ( s i − s j ) 2 + M ) dp[i] = min(dp[j] + (s_i - s_j)^2 + M) dp[i]=min(dp[j]+(si−sj)2+M)
时间复杂度为 O ( n 2 ) O(n^2) O(n2)
斜率优化
假设从j转移要优于从k转移
d p [ j ] + ( s i − s j ) 2 + M < d p [ k ] + ( s i − s k ) 2 + M dp[j] + (s_i - s_j)^2 + M < dp[k] + (s_i - s_k)^2 + M dp[j]+(si−sj)2+M<dp[k]+(si−sk)2+M
d p [ j ] + s j 2 − 2 s i s j < d p [ k ] + s k 2 − 2 s i s k dp[j] + s_j^2 - 2s_is_j < dp[k] + s_k^2 - 2s_is_k dp[j]+sj2−2sisj<dp[k]+sk2−2sisk
( d p [ j ] + s j 2 ) − ( d p [ k ] + s k 2 ) < 2 s i ( s j − s k ) (dp[j] + s_j^2) - (dp[k] + s_k^2) < 2s_i(s_j - s_k) (dp[j]+sj2)−(dp[k]+sk2)<2si(sj−sk)
( d p [ j ] + s j 2 ) − ( d p [ k ] + s k 2 ) s j − s k < 2 s i \frac {(dp[j] + s_j^2) - (dp[k] + s_k^2) }{s_j - s_k}<2s_i sj−sk(dp[j]+sj2)−(dp[k]+sk2)<2si
设: f ( i ) = d p [ i ] + s i 2 f(i) = dp[i] + s_i^2 f(i)=dp[i]+si2
f ( j ) − f ( k ) s j − s k < 2 s i \frac {f(j) - f(k)}{s_j - s_k} < 2s_i sj−skf(j)−f(k)<2si,这东西就是一个人斜率
经过斜率优化以后,时间复杂度优化成了:
O
(
n
)
O(n)
O(n)
代码演示:
#include<iostream>
using namespace std;
#define MAX_N 1000000
#define S(a) ((a) * (a))
#define ll long long
ll dp[MAX_N + 5];
ll c[MAX_N + 5], n, M, s[MAX_N + 5];//s:sum 从0 到i
ll f[MAX_N + 5]; //dp[i] + si^2
ll q[MAX_N + 5], head, tail;
double slope(ll i, ll j){
return 1.0 * (f[i] - f[j]) / (s[i] - s[j]);
}
void read() {
cin >> n >> M;
for (int i = 1; i <= n; i++) {
cin >> c[i];
s[i] = s[i - 1] + c[i];
}
return ;
}
void set_dp(int i, int j) {
dp[i] = dp[j] + S(s[i] - s[j]) + M;
f[i] = dp[i] + S(s[i]);
return ;
}
ll solve() {
head = tail = 0;
q[tail++] = 0;
for (int i = 1; i <= n; i++) {
while ((tail - head) >= 2 && slope(q[head + 1], q[head]) < (2 * s[i])) ++head;//队列中多于两个元素,并且最优解为q[head + 1]
set_dp(i, q[head]);//得出当前位置的dp值
while (tail - head >= 2 && slope(i, q[tail - 1]) < slope(q[tail - 2], q[tail - 1])) --tail;//要放入元素的时候,先看看有没有背脊状
q[tail++] = i;
}
return dp[n];
}
int main() {
read();
cout << solve() << endl;
return 0;
}
题三:双生序列:单调队列
本题确定队尾p,然后比较下标,实际比较队列中的数量即可
解题思路
- 因为两个序列的每个区间的RMQ值都相等,等价于两个序列的单调队列长得一样
- 将两个序列,依次插入到单调队列中,过程中判断单调队列是否一样,如果不一样就退出
#include<iostream>
using namespace std;
#define MAX_N 500000
int a[MAX_N + 5], b[MAX_N + 5];
class Queue {
public :
Queue(int *arr) :arr(arr){}
void push(int i) {
while (tail - head && arr[q[tail - 1]] > arr[i]) --tail;
q[tail++] = i;
return ;
}
void pop() {++head; }
int size() {
return tail - head;
}
private:
int *arr;
int q[MAX_N + 5], head, tail;
};
int n;
Queue q1(a), q2(b);
void read() {
cin >> n;
for (int i = 0; i < n; i++) cin >> a[i];
for (int i = 0; i < n; i++) cin >> b[i];
return ;
}
int main() {
read();
int p;
for (p = 0; p < n; p++) {
q1.push(p);
q2.push(p);
if (q1.size() != q2.size()) break;
}
cout << p << endl;
return 0;
}
题四:最大子序和
- 有个限制条件:子序列的长度不超过M
- 转换成前缀和数组上的问题,就是 S i − s j S_i - s_j Si−sj, 其中 i − j < = M i - j <= M i−j<=M
- 在前缀和数组上,维护一个大小为M的滑动窗口中的最小值
- 所以,采用单调队列
#include<iostream>
#include <climits>
using namespace std;
#define MAX_N 300000
int s[MAX_N + 5], n, m;
int q[MAX_N + 5], head, tail;
void read() {
cin >> n >> m;
for (int i = 1; i <= n; i++) {
cin >> s[i];
s[i] += s[i - 1];
}
return ;
}
int solve() {
int ans = INT_MIN;
head = tail = 0;
q[tail++] = 0;
for (int i = 1; i <= n; i++) {
if (i - q[head] > m) head++; //队首元素过期
ans = max(ans, s[i] - s[q[head]]);
while (tail - head && s[q[tail - 1]] > s[i]) tail--;//找区间内最小的前缀和
q[tail++] = i;
}
return ans;
}
int main() {
read();
cout << solve() << endl;
return 0;
}