知识整理 单调队列 洛谷P1886 滑动窗口 /【模板】单调队列很容易想到这样一个做法: 随着窗口的移动, 每次都遍历整个窗口, 找到最小值和最大值.
但这样的时间复杂度是 , 效率十分低下.
其实我们能发现, 窗口每移动一格, 只有最左边的一个元素离开决策集合, 最右边新增一个元素进入决策集合, 只要思考最左边元素 的离开 和最右边新元素 的进入 对原有答案的影响, 不就可以了吗?
emmm, 那要怎么确定这个影响 呢?
以求最大值为例, 弄一个变量 记录原来的最大值, 然后用 和 更新 ?
比如,
如果 , 就把 更新为 . 如果 , 就不更新 . 如果 , 则不需要动 . 但如果 就是 怎么办? 再拿一个 来记录原来的次大值? 也不可行. 更改为 后, 要变成啥? 看来, 这不是拿一个变量就能搞定的事情.
但这个思路并没有问题. 其实我们并不关心次大值, 次次大值什么的, 我们只关心窗口内的最大值. 我们需要一个数据结构, 满足:
如果 , 可被新来的 更新, 如果 就是 , 它退出窗口后, 能有一个东西顺承它的位置, 成为新的最大值. 这个数据结构有哪些性质呢?
首先, 既然 退出后, 原来的次大值马上就能成为最大值, 它最好是能有线性 和单调性 . 的退出和 的加入要互不干扰, 那就固定在一头加入, 另一头退出 .这个数据结构就是单调队列 . 通常是一个单调的双端队列 . 以维护窗口最大值为例, 从队首到队尾递减 , 且 就是当前窗口 (决策集合) 内的最大值.
接下来详细讲讲怎么实现 的退出 和 的加入.
的退出.
如果 , 更严谨的说法是 还在窗口内, 则无需操作. 否则, 我们不断弹出队首, 直到 在窗口内. 的加入.
首先我们应该注意到, 如果 中的元素 比 小, 它不可能在 退出后成为新的最大值, 毕竟它比 更早进入队列, 也必然更早退出! 既然如此, 把 留在 里就完全没用, 它不可能再次进入决策集合了, 所以我们直接把 扔出 . 那么, 的加入操作就是, 不断弹出队尾, 直到 比 大, 然后把 加到队尾. 最大值是这样的, 最小值也一样. 单调队列的核心思想就是及时排除决策集合(队列)中一定不可能最优的元素 .
理解以后再来看模板题的AC Code:
#include <bits/stdc++.h> #define inf 1LL << 45 #define MAXN 1000005 #define int long long int n, k;int a[MAXN], min_v[MAXN], max_v[MAXN];std::deque<int > q_min, q_max; int read () { int x = 0 , f = 1 ; char ch = getchar (); for (;!isdigit (ch);ch = getchar ()) if (ch == '-' ) f = -1 ; for (;isdigit (ch);ch = getchar ()) x = (x << 3 ) + (x << 1 ) + (ch xor 48 ); return x*f; } inline int max (int a, int b) {return a > b? a : b;}inline int min (int a, int b) {return a < b? a : b;}signed main () { n = read (); k = read (); for (int i = 1 ; i <= n; ++i) a[i] = read (); for (int i = 1 ; i <= n; ++i){ while (!q_min.empty () and a[i] <= a[q_min.front ()]) q_min.pop_front (); while (!q_max.empty () and a[i] >= a[q_max.front ()]) q_max.pop_front (); q_min.push_front (i); q_max.push_front (i); while (q_min.back () < i - k + 1 ) q_min.pop_back (); while (q_max.back () < i - k + 1 ) q_max.pop_back (); min_v[i] = a[q_min.back ()]; max_v[i] = a[q_max.back ()]; } for (int i = k; i <= n; ++i) printf ("%lld " , min_v[i]); putchar ('\n' ); for (int i = k; i <= n; ++i) printf ("%lld " , max_v[i]); return 0 ; }
单调栈 洛谷P5788 【模板】单调栈题目很裸, 我形象化地改一下题目描述:
有 个人站成一排, 每个人都向右看, 求每个人看到的第一个比他自己高的人. 既然每个人都向右看, 我们来研究一下被看的人 , 也就是从右往左研究每个人.
对于相邻的两个被看的人 和 :
如果 , 那么 可能被左边身高小于 的人看到, 可能被身高大于等于 且小于 的人看到. 如果 , 那么 就被 挡住了, 永远无法被更前面的人看到了. 也就是说, 当 要加入被看者集合
时, 到 中小于等于 的都可以退出这个被看者集合
了.
由上可知这个被看者集合
是一个线性单调(递增) 的数据结构.
再切换到 的视角:
向右看时, 他会先看最右边的 吗? 肯定不会啊! 他肯定先尝试去看就在他旁边的 吧?也就是说, 虽然是最晚加入被看者集合
的, 但他又是第一个被尝试看者
.
由上可知这个被看者集合
是一个栈 .
综上, 这个被看者集合
就是一个单调栈 , 显然, 它可以维护一个数前/后 第一个 大于/小于 它的数 .
接下来是模板题的具体实现.
正如上面所说, 我们建立一个栈 来维护被看者集合
, 从右往左遍历每个人:
当扫描到 时:
若栈为空, 直接把 入栈. 否则不断弹出栈顶 , 直到 为空或 , 此时 就是被 看到的第一个人. 记录这个答案 (因为我们是逆序得到答案, 但又要顺序输出), 然后把 入栈. AC Code:
#include <stack> #include <cstdio> #include <ctype.h> #define N 3000006 int n;int a[N];int ans[N]; std::stack<int > S; int read () { int x = 0 , f = 1 ; char ch = getchar (); for (; !isdigit (ch); ch = getchar ()) f ^= (ch == '-' ); for (; isdigit (ch); ch = getchar ()) x = (x << 3 ) + (x << 1 ) + (ch xor 48 ); return f ? x : -x; } int main () { n = read (); for (int i = 1 ; i <= n; ++i) a[i] = read (); for (int i = n; i >= 1 ; --i){ while (S.size () && a[i] >= a[S.top ()]) S.pop (); if (S.empty ()) ans[i] = 0 ; else ans[i] = S.top (); S.push (i); } for (int i = 1 ; i <= n; ++i) printf ("%d " , ans[i]); return 0 ^(0 -0 )^0 ; }
不管是像左看还是向右看, 找第一个大于还是小于它的数, 做法都一样.
关键是弄清决策集合 , 也就是被看者集合
是什么, 用单调栈来维护它即可.
训练题 C - Patrik 音乐会的等待 洛谷P1823 [COI 2007] Patrik 音乐会的等待很容易想到这样一个做法: 维护一个单调递减栈(栈顶最小), 插入 时 弹出的元素数量+ 就是 向一方所能看到的人数 ( 非第一个元素).
但身高相同的情况要怎么处理?
把栈中元素加一维, 记录身高相同人数就可以了.
答案要用long long
存.
AC Code:
#include <stack> #include <cstdio> #include <ctype.h> #define N 500005 int n;int a[N];long long ans; int read () { int x = 0 , f = 1 ; char ch = getchar (); for (; !isdigit (ch); ch = getchar ()) f ^= (ch == '-' ); for (; isdigit (ch); ch = getchar ()) x = (x << 3 ) + (x << 1 ) + (ch xor 48 ); return f ? x : -x; } signed main () { n = read (); for (int i = 1 ; i <= n; ++i) a[i] = read (); std::stack<std::pair<int , int > > S; for (int i = n; i >= 1 ; --i){ int num = 1 ; while (S.size () and S.top ().first <= a[i]){ ans += S.top ().second; if (S.top ().first == a[i]) num += S.top ().second; S.pop (); } if (S.size ()) ++ans; S.push ({a[i], num}); } printf ("%lld\n" , ans); return 0 ^(0 -0 )^0 ; }
I - 合并果子 / [USACO06NOV] Fence Repair G 洛谷P1090 [NOIP2004 提高组] 合并果子很容易想到这样的贪心: 每次都取最小的两堆果子合并, 直到只剩下一堆 .
但基本没人会去证明这个贪心做法的正确性.
这里提供一个较为严谨的证明.
先举一个具体的例子: 假设有 堆果子, 大小分别为 .
很容易发现最佳合并方案图如下 (请在Light Mode下查看) :
graph TD;
A --> C[7];
C --> F[3];
F --> G[1]:::leaf;
F --> H[2]:::leaf;
A --> L[5]:::leaf;
C --> K[4]:::leaf;
BB[16] --> D[7]:::leaf;
BB --> J[9]:::leaf;
Root[28];
Root --> A[12];
Root --> BB;
classDef leaf fill:#a2d2ff,stroke:#023e8a,stroke-width:2px,color:#000000,font-style:italic; 总费用为 .
容易发现, 答案不就是一颗树除去叶子结点后的点权和吗? 我们要做的其实就是最小化这棵树的点权和 .
考虑每个点对答案的贡献. 容易发现, 叶子结点的深度 (设根节点的深度为 )就是它因合并而被计入答案的次数.
总费用还可以表示为: .
考虑问题的一般化, 也就是要构造一棵树 , 要求最小化:
为 在 中 的 深 度
我们要证明的贪心策略其实就是Huffman Tree 的构造策略:
初始化 : 将每个数视为一个节点.合并最小权重节点 : 合并权值最小的 个节点 和 作为它们的父亲, 并将新节点加入待合并节点集合中.重复合并 : 重复步骤 , 直到待合并节点集合中只剩下 个节点. 这样构造出来的树就是 Huffman Tree
.引理 1
设最优的合并树构成集合 . 那么, 存在 , 满足 中权值最小的 个节点 和 是兄弟节点.
证明
只需要证明, 对于 , 若 和 不是兄弟节点, 则必存在一种路径改变方式, 使它们成为兄弟节点后, 不会增大.
记操作Trans(a, b)
表示将b
的父亲更改为a
的父亲, 使b
成为a
的兄弟节点.
显然, 由于Trans(a, b)
只改变节点b
的位置, 因此只有节点b
对 的贡献可能改变.
设 的深度为 , 的深度为 ,
若 , Trans(i, j)
后 , 不变, 故 不变. 若 , Trans(i, j)
后 , , 故 减小. 若 , Trans(j, i)
后 , , 故 减小. 因此, 必然存在 棵最优树 , 使得最小的两个节点是兄弟节点. 引理 1
得证.
引理 2
引理 1
中的 和 必然是深度最深的节点.
结论过于trivial , 证明略.
最优子结构:
设上面的 合并 和 后得到 , 也就是移除 和 , 并令它们的父亲 . 则 与 的不同之处在于 和 的深度都减少 , 于是有:
显然有:
最 优 最 优
至此, 我们证明了Huffman Tree
具有最优子结构 .
用归纳法证明
是trivial 的.假设 时, Huffman Tree
的构造是最优的. 则 时, 设 和 是 中最小的 个节点, 由引理 1
知 和 可为兄弟节点, 由最优子结构性质
知最 优 最 优 . 因此Huffman Tree
是最优的策略.
只要会priority_queue
就肯定会写代码了.
AC Code:
#include <queue> #include <cstdio> #include <ctype.h> #define N 10005 int n, ans;std::priority_queue<int > q; int read () { int x = 0 , f = 1 ; char ch = getchar (); for (; !isdigit (ch); ch = getchar ()) f ^= (ch == '-' ); for (; isdigit (ch); ch = getchar ()) x = (x << 3 ) + (x << 1 ) + (ch xor 48 ); return f ? x : -x; } signed main () { n = read (); for (int i = 1 ; i <= n; ++i) q.push (-read ()); while (q.size () > 1 ){ int x = -q.top (); q.pop (); int y = -q.top (); q.pop (); ans += x + y; q.push (-(x + y)); } printf ("%d\n" , ans); return 0 ^(0 -0 )^0 ; }
K - 钓鱼 洛谷P1717 钓鱼只需要注意到 个显然的性质.
性质1: 在池塘 钓了 次, 向右走到 钓了 次 , 然后发现此时 的鱼最多了, 于是又回到 钓了一次鱼. 这个过程相当于在 钓了 次, 走到 , 在 钓 次. 也就是说, 我们可以随时”反悔”走回头路, 且反悔不需要付出额外代价 .
性质2: 在性质1
的基础上, 假设我们最终走到第 个池塘, 那么我们可以随时反悔, 走回 第 到第 间的任意池塘, 而不需要花费额外走路时间. 那么每次都贪心地选择 到 中鱼最多的池塘即可 .
定义 表示最终走到第 个池塘时, 最多能钓到的鱼数.
的求解非常简单. 首先用总时长 减去走路总时间 , 并把 压入大根堆 中, 每次选择堆顶来钓鱼, 直到时间耗尽或无鱼可钓 为止.
答案即为 .
AC Code:
#include <queue> #include <cstdio> #include <ctype.h> #define N 30 int n, h, ans;struct Pond { int f, d, t; bool operator < (const Pond& noob) const { return f < noob.f; } }a[N]; int read () { int x = 0 , f = 1 ; char ch = getchar (); for (; !isdigit (ch); ch = getchar ()) f ^= (ch == '-' ); for (; isdigit (ch); ch = getchar ()) x = (x << 3 ) + (x << 1 ) + (ch xor 48 ); return f ? x : -x; } int max (int a, int b) {return a > b? a : b;}int solve (int end) { int H = h, res = 0 ; std::priority_queue<Pond> q; for (int i = 1 ; i <= end; ++i) H -= a[i].t, q.push (a[i]); while (H > 0 and q.top ().f > 0 ){ Pond now = q.top (); q.pop (); res += now.f; now.f -= now.d; H--; q.push (now); } return res; } signed main () { n = read (), h = read () * 12 ; for (int i = 1 ; i <= n; ++i) a[i].f = read (); for (int i = 1 ; i <= n; ++i) a[i].d = read (); a[1 ].t = 0 ; for (int i = 2 ; i <= n; ++i) a[i].t = read (); std::priority_queue<Pond> q; for (int i = 1 ; i <= n; ++i) ans = max (ans, solve (i)); printf ("%d\n" , ans); return 0 ^(0 -0 )^0 ; }
L - 中位数 洛谷P1168 中位数这题做法非常多, 很多数据结构都能做.
比较简单的就是充分利用中位数性质的对顶堆 . 此外也可以直接当作求区间第 大 的板子, 比如树状数组 , 权值线段树 , 平衡树 等等.
见到的比较取巧的做法是vector + lower_bound , 比较阴间的做法是分块 , 这里我提供一个比较罕见的pb_ds库 的写法. 当然, tree 的本质是红黑树.
这段代码跑得还是比较快的.
AC Code:
#include <cstdio> #include <cctype> #include <cstring> #include <ext/pb_ds/tree_policy.hpp> #include <ext/pb_ds/assoc_container.hpp> #define N 1000005 int n;int a[N];__gnu_pbds::tree< double , __gnu_pbds::null_type, std::less<double >, __gnu_pbds::rb_tree_tag, __gnu_pbds::tree_order_statistics_node_update > t; int read () { int x = 0 , f = 1 ; char ch = getchar (); for (; !isdigit (ch); ch = getchar ()) f ^= (ch == '-' ); for (; isdigit (ch); ch = getchar ()) x = (x << 3 ) + (x << 1 ) + (ch xor 48 ); return f ? x : -x; } signed main () { n = read (); for (int i = 1 ; i <= n; ++i){ a[i] = read (); t.insert (a[i] + i * 1e-9 ); if (i & 1 ) printf ("%d\n" ,(int )*t.find_by_order ((1 + i) / 2 - 1 )); } return 0 ^(0 -0 )^0 ; }
M - 丑数 Humble Numbers 洛谷P2723 [USACO3.1] 丑数 Humble Numbers很容易想到这样一个做法: 把生成的数压到小根堆中, 每次都取出堆顶, 和 相乘, 得到 个数, 再把没有出现过的数压入堆中. 重复该过程, 直到取出 次堆顶. 此时堆顶就是答案.
于是写下了这样的代码:
#include <queue> #include <cstdio> #include <ctype.h> #include <unordered_map> int k, n, rank;long long p[105 ];bool v[1000005 ];std::priority_queue<int > q; std::unordered_map<long long , bool > mp; int read () { int x = 0 , f = 1 ; char ch = getchar (); for (; !isdigit (ch); ch = getchar ()) f ^= (ch == '-' ); for (; isdigit (ch); ch = getchar ()) x = (x << 3 ) + (x << 1 ) + (ch xor 48 ); return f ? x : -x; } bool valid (long long x) { if (x > 2147483647LL ) return 1 ; if (x <= 1000000 ) return v[x]; return mp[x]; } void insert (long long x) { if (x <= 1000000 ) v[x] = 1 ; else mp[x] = 1 ; } signed main () { k = read (), n = read (); for (int i = 1 ; i <= k; ++i) p[i] = read (), q.push (-p[i]); while (1 ){ ++rank; int x = q.top (); q.pop (); if (rank == n){ printf ("%d\n" , -x); return 0 ^(0 -0 )^0 ; } for (int i = 1 ; i <= k; ++i){ if (!valid (-x * p[i])){ insert (-x * p[i]); q.push (x * p[i]); } } } }
发现, 有一个测试点不是 TLE 就是 MLE .
非常暴躁, 改变判重
策略, 直接利用堆的性质来判重: 生成的每个合法的数都压进堆里, 在取堆顶的时候去重 .
然后这份时间复杂度玄学的代码过了…
AC Code:
#include <queue> #include <cstdio> #include <ctype.h> int k, n, rank;long long p[105 ];std::priority_queue<int > q; int read () { int x = 0 , f = 1 ; char ch = getchar (); for (; !isdigit (ch); ch = getchar ()) f ^= (ch == '-' ); for (; isdigit (ch); ch = getchar ()) x = (x << 3 ) + (x << 1 ) + (ch xor 48 ); return f ? x : -x; } signed main () { k = read (), n = read (); for (int i = 1 ; i <= k; ++i) p[i] = read (), q.push (-p[i]); while (1 ){ ++rank; int x = q.top (); q.pop (); while (q.size () && q.top () == x) q.pop (); if (rank == n){ printf ("%d\n" , -x); return 0 ^(0 -0 )^0 ; } for (int i = 1 ; i <= k; ++i){ if (-x * p[i] > 2147483647LL ) continue ; q.push (x * p[i]); } } }