洛谷 P2300 合并神犇

风格不统一 提交于 2020-02-11 18:57:56

\(\quad\) 看到这道题,觉得和P5665 划分很像,当时考场上我写的就是类似一些题解的 \(O(n^2)\) 算法,完美卡成 \(64\) 分。
\(\quad\) 我详细介绍一下 \(O(n)\) 的单调队列优化算法,也当作是自己学习单调队列的总结吧。
\(\quad\) 单调队列,顾名思义,具有单调性。它本质上是一个双端队列,队头代表当前的最优解(这一点很像优先队列)。

for(int i = 1; i <= n; ++i){
    while(!q.empty() && out_of_date(q.front()))
        q.pop_front();
    while(!q.empty() && notBetterThan(q.back(), i))
        q.pop_back();
    q.push_back(i);
    operate(q.front());
}

\(\quad\) 结合这一份样例代码分析。为了便于理解,这里使用 STL 中的 deque 充当载体。
\(\quad\) 第一个 while 语句每次将处于队列前端的一些元素弹出队列:这些元素虽然最优,然而已经“过期”了,因而必须被排除。由于每个元素的“过期时间”具有单调性,即处于前面的元素一定比后面的更早“过期”,这一操作可以保证所有“过期”元素都被排除。
\(\quad\) 第二个 while 语句每次将处于队列后端的一些元素弹出队列:这些元素本身不如将要加入的新元素 \(i\)“优”,而且还会比 \(i\) 更早过期,所以必须排除,以维护队列的单调性。
\(\quad\) operate 函数代表每次我们需要对当前最优元素进行的操作。
\(\quad\) 总结一下,即单调队列具有 \(2\) 个“单调性”:
\(\qquad\bullet\) 前面的元素比后面的元素“优”,元素的“优劣”单调递减,越靠前的元素越“优”,越靠后的元素越“差”。
\(\qquad\bullet\) 前面的元素比后面的元素“老”,元素的“年龄”单调递减,越靠前的元素越先“过期”,越靠后的元素越后“过期”。
\(\quad\) 为了维护这 \(2\) 个“单调性”,需要 \(2\) 个 while 语句。由于队列总共只进入过 \(n\) 个元素,所以弹出操作也最多只会执行 \(n\) 次,可以保证时间复杂度。
\(\quad\) 那么为什么当前范围内最“优”的元素就一定是队首呢?分析一下,满足“当前范围内最优”的元素具有以下性质:
\(\qquad\bullet\) 范围不存在任何元素更“优”,即在所有未“过期”元素中,它是最“优”的。
\(\qquad\bullet\) 在范围内,即未“过期”。
\(\quad\) 可以证明,以上两点是“当前范围内最优”的充要条件。而队列中不存在“过期”元素,具有单调性,且所有范围内元素都被加入过队列,所有弹出元素都不满足第 \(1\) 点或第 \(2\) 点,那么显然队首就是“当前范围内最优”的元素。
\(\quad\) 单调队列和动态规划又有什么关系呢?它不但可以优化背包 dp 和像这样的普通 dp,还可以优化斜率优化 dp。结合这道题,我们可以具体地感受单调队列的力量。
\(\quad\) 不难得到两种转移方程:
\(\qquad\) \(\mathrm {I.}\)
\(\qquad\quad\bullet\) \(dp_i\) 表示使 \([1,n]\) 区间满足要求的最少合并次数,\(a_i\) 表示对应的最后一个神犇的能力值,\(s\)是前缀和。
\(\qquad\quad\bullet\) \(dp_i = dp_j + i - j - 1, a_i = s_i - s_j(s_i - s_j \geq a_j)\)
\(\qquad\) \(\mathrm {II.}\)
\(\qquad\quad\bullet\) \(dp_i\) 表示使 \([1,n]\) 区间满足要求而保留的最多神犇数,\(a_i\) 表示对应的最后一个神犇的能力值,\(s\) 是前缀和。
\(\qquad\quad\bullet\) \(dp_i = dp_j + 1,a_i = s_i - s_j(s_i - s_j \geq a_j)\)
\(\quad\) 其实这两种方程本质上是完全一样的。这里我以 \(\mathrm {II}\) 为例简单讲解一下。
\(\quad\) 要维护这样的转移方程,需要对一般的单调队列做一些更改。

for(int i = 1; i <= n; ++i){
    while(q.size() > 1 && a[q[1]] + s[q[1]] <= s[i])
        q.pop_front();
    dp[i] = dp[q.front()] + 1, a[i] = s[i] - s[q.front()];
    while(!q.empty() && a[i] + s[i] <= a[q.back()] + s[q.back()])
        q.pop_back();
    q.push_back(i);
}

\(\quad\) 可以得到,\(dp_i\) 具有单调性,即 \(\forall \, i > j\),有 \(dp_i \geq dp_j\)。由于 \(dp_i = dp_j + 1\),为了能保留尽量多的神犇,\(j = max\left\{ j \, | \, s_i - s_j \geq a_j \right\}\),即在所有满足限制的 \(j\) 中选取最大的那一个转移。
\(\quad\) 对限制条件 \(s_i - s_j \geq a_j\) 稍作变换,可以得到 \(a_j + s_j \leq s_i\),由于 \(s_i\) 仅与 \(i\) 本身有关且单调不减,即 \(s_i\) 具有单调性,那么 \(a_j + s_j\) 越小,\(j\) 就越后“过期”。
\(\quad\) 事实上,\(j\) 越大越“优”,且 \(a_j + s_j\) 越大也越容易“过期”。这样的 \(j\),具有单调性,可以用单调队列来维护。
\(\quad\) 大多数单调队列以第一个 while 去除“过期”元素,以第二个 while 去除不够“优”的元素,而这道题的单调队列却以第一个 while 去除不够“优”的元素,以第二个 while 去除“更容易‘过期’”的元素。即,这道题的单调队列是“反”的。
\(\quad\) 如以上代码中的第一个 while,既然 \(q_1\)\(q_0\) 更晚入队,说明 \(q_1 > q_0\)\(q_1\)\(q_0\)“优”。而 \(a_{q_1} + s_{q_1} \leq s_i\),说明 \(q_1\) 也没有“过期”,那为什么不选取 \(q_1\) 转移呢?因此要弹出 \(q_1\)
\(\quad\) 在第二个 while 中,更容易“过期”的元素被弹出了。既然 \(a_i + s_i \leq a_{q_{back}} + s_{q_{back}}\),说明 \(i\)\(q_{back}\) 更晚“过期”,且 \(i > j\),即 \(i\)\(j\) 优。那么为什么不把既容易“过期”,又不“优”的 \(q_{back}\) 弹出呢?
\(\quad\) 这样,这道题就用单调队列以 \(O(n)\) 的复杂度完全解决了。
\(\quad\) 为了避免 STL 的一些可能出现的问题,常用数组模拟 deque。具体的模拟方法可以用如下的类清晰地表示:

template <class Tp>
class Deque{
    private:
        Tp q[MAXN];
        int head, tail;
    public:
        Deque(){
            head = 0, tail = -1;
        }
        Tp& operator[](const int& x){
            return q[x + head];
        }
        Tp& front(void){
            return q[head];
        }
        Tp& back(void){
            return q[tail];
        }
        void push_back(const Tp& x){
            q[++tail] = x;
        }
        void pop_front(void){
            ++head;
        }
        void pop_back(void){
            --tail;
        }
        int size(void){
            return tail - head + 1;
        }
        bool empty(void){
            return tail < head;
        }
};

\(\quad\) 使用数组优化后,得到整题代码:

#include <stdio.h>

const int MAXN = 2e5 + 19;

int n, dp[MAXN], q[MAXN], head, tail;
long long s[MAXN], a[MAXN];

int main(){
    scanf("%d", &n);
    for(int i = 1; i <= n; ++i)
        scanf("%lld", s + i), s[i] += s[i - 1];
    for(int i = 1; i <= n; ++i){
        while(head < tail && a[q[head + 1]] + s[q[head + 1]] <= s[i])
            ++head;
        dp[i] = dp[q[head]] + 1, a[i] = s[i] - s[q[head]];
        while(head <= tail && a[i] + s[i] <= a[q[tail]] + s[q[tail]])
            --tail;
        q[++tail] = i;
    }
    printf("%d\n", n - dp[n]);
    return 0;
}

\(\quad\) 如果采用 \(\mathrm I\) 的做法,代码就应当是这样的:

#include <stdio.h>

const int MAXN = 2e5 + 19;

int n, dp[MAXN], q[MAXN], head, tail;
long long s[MAXN], a[MAXN];

int main(){
    scanf("%d", &n);
    for(int i = 1; i <= n; ++i)
        scanf("%lld", s + i), s[i] += s[i - 1];
    for(int i = 1; i <= n; ++i){
        while(head < tail && a[q[head + 1]] + s[q[head + 1]] <= s[i])
            ++head;
        dp[i] = dp[q[head]] + i - q[head] - 1, a[i] = s[i] - s[q[head]];
        while(head <= tail && a[i] + s[i] <= a[q[tail]] + s[q[tail]])
            --tail;
        q[++tail] = i;
    }
    printf("%d\n", dp[n]);
    return 0;
}

\(\quad\) 可以发现,它们的写法几乎是一样的,只有两行不同。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!