Solution -「简单 DP」zxy 讲课记实
阅读原文时间:2023年07月08日阅读:6

魔法题位面级乱杀。

「JOISC 2020 Day4」治疗计划

因为是不太聪明的 Joker,我就从头开始理思路了。中途也会说一些和 DP 算法本身有关的杂谈,给自己的冗长题解找借口。

首先,治疗方案不会重复使用。因为重复使用只会空加代价,而不会在特定时刻产生额外贡献。故而总决策方案应有 \(2^m\) 个,我们需要在这 \(2^m\) 个中找出最小可能花费。

DFS 是最显然的算法,但显然不可做,不过它枚举状态的思路很好地把我们引向了 DP

于是开始尝试设计 DP 状态。


DP 状态定义中,比较重要的一环是考虑「维度」。具体的讲,就是合理权衡一些重要信息是作为 DP 值转移还是作为状态放在下标处。

通常来看,信息的这两种记录方法会满足一些 features:

  • 下标:数量级不会太大,且便于判断合法性;
  • 值:通常可作为计算的核心中间量,并且其数量级往往不支持放在下标。
  • 当然有些「维度」可以在状态中舍弃而在转移中呈现。它们往往是明显的限制条件、两状态间的经过量、前两个剩下的失宠儿。

那么回归到这道题的「维度」:时间、治疗状态、方案使用造成的花费。

将时间加入下标中是我们很常见的处理方法,但本题时间在 \(10^9\) 级,肯定是不能作为下标的。花费的数量级更大,也不可能。而治疗状态是一个长度为 \(n\) 的二进制串,\(n\) 在 \(10^5\) 级,显然也不能直接放入下标。

但治疗状态与时间和花费有一点有区别:对于治疗状态我们希望它的末状态是一个 full 的「长相」。如果我们可以定义一个状态是能转移到 full 状态的,则多半可做。那就考虑部分 full 呗,就不难想到前缀了。

在漫长的逻辑链之后,我们选定了 DP,并定义了状态 \(dp_i\) 表示前 i 个人全部接受治疗的最小花费。

但是这里面并没有考虑时间。这是这道题很重要的一环,我们关心的是状态的「长相」和状态间的转移,而此题时间并不在这两部分中发挥决定性的作用。时间只作用于改变决策后的状态,而这样的改变到下一次决策时就停止了,所以时间只会成为两个状态间可以转移的判定条件。

那就不难写出转移方程式:\(dp_i = \min \space \{dp_j + c_i\} \space \mathrm {s.t.} |t_i - t_j| \leq r_j - l_i + 1\)。就是说时间产生的状态改变并不能将两个被治疗区间的重叠部分消耗完,那么在时间更靠后的那个方案完成后,这两个治疗区间仍可以进行合并但大小随时间流动有所缩小。

我们的初状态应是 \(dp_i = c_i \space \mathrm {s.t.} l_i = 1\),而末状态答案则是 \(\min \space \{dp_i\} \space \mathrm {s.t.} r_i = n\)。

我们不在状态中考虑时间产生了一个后果,即我们需要在新的状态转移中间接兼顾时间。所以线性的转移是不可取的,于是我们考虑在可以合并治疗区间的两个方案间连边,得到一张图,然后在图中进行转移。现在题目就变为在这张图上跑路径点权和最小的最短路了。

引入 Dijkstra 算法进行优化。注意式子,它提示我们每次只需要去找最小的 \(dp_j\)。换句话说,就是我们从最小的 \(dp_j\) 的 \(j\) 去更新可以更新的 \(dp_i\),这和 Dijkstra 是完全类似的。

并且由于每个 \(dp_i\) 只会被更新一次,所以 总时间复杂度均摊应该是 \(O(nk)\),其中 \(k\) 表示在图上从当前点查找下一个点的时间复杂度。

我们来看这个性质:\(|t_i - t_j| \leq r_j - l_i + 1\),可拆成:\(t_i \geq t_j, l_i + t_i \leq r_j + t_j + 1\) 和 \(t_i < t_j, l_i - t_i \leq r_j - t_j + 1\)。那么我们考虑以时间 \(t\) 为下标建立两棵线段树,节点上分别维护 \(\min \space \{ l_i + t_i \}\) 和 \(\min \space \{ l_i - t_i \}\)。这样每次查询就暴力查找到符合条件的叶子节点即可,在 \(dp_i\) 被更新后将 \(i\) 对应的线段树上节点更新为 \(\mathrm{inf}\),保证均摊复杂度 \(O(n \log n)\)。

#include <queue>
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;

typedef long long LL;
LL Abs (LL x) { return x < 0 ? -x : x; }
LL Max (LL x, LL y) { return x > y ? x : y; }
LL Min (LL x, LL y) { return x < y ? x : y; }

int Read () {
    int x = 0, k = 1;
    char s = getchar ();
    while (s < '0' || s > '9') {
        if (s == '-')
            k = -1;
        s = getchar ();
    }
    while ('0' <= s && s <= '9')
        x = (x << 3) + (x << 1) + (s ^ 48), s = getchar ();
    return x * k;
}

void Write (LL x) {
    if (x < 0)
        putchar ('-'), x = -x;
    if (x > 9)
        Write (x / 10);
    putchar (x % 10 + '0');
}

void Print (LL x, char s) { Write (x), putchar (s); }

const LL Inf = 1e15;
const int Maxn = 1e5 + 5;

vector <int> Ret;
struct Segment_Tree {
    #define Lson p << 1
    #define Rson p << 1 | 1

    int a[Maxn];
    LL Tr[Maxn * 4];

    void Pull (int p) { Tr[p] = Min (Tr[Lson], Tr[Rson]); }

    void Make_Tree (int p, int L, int R) {
        if (L == R) {
            Tr[p] = a[L];
            return ;
        }
        int Mid = (L + R) >> 1;
        Make_Tree (Lson, L, Mid), Make_Tree (Rson, Mid + 1, R);
        Pull (p);
    }

    void Delete (int p, int L, int R, int k) {
        if (L == R) {
            Tr[p] = Inf;
            return ;
        }
        int Mid = (L + R) >> 1;
        if (k <= Mid)
            Delete (Lson, L, Mid, k);
        else
            Delete (Rson, Mid + 1, R, k);
        Pull (p);
    }

    void Find (int p, int l, int r, int L, int R, int x) {
        if (L == R) {
            Ret.push_back (L);
            return ;
        }
        int Mid = (L + R) >> 1;
        if (Tr[Lson] <= x && l <= Mid)
            Find (Lson, l, r, L, Mid, x);
        if (Tr[Rson] <= x && r > Mid)
            Find (Rson, l, r, Mid + 1, R, x);
    }

    #undef Lson
    #undef Rson
} Tree[2];

struct Plan {
    int t, l, r, c;
    Plan () {}
    Plan (int T, int L, int R, int C) { t = T, l = L, r = R, c = C; }
} q[Maxn];

struct Node {
    int x;
    LL Val;
    Node () {}
    Node (int X, LL V) { x = X, Val = V; }
    friend bool operator < (Node a, Node b) { return a.Val > b.Val; }
};

LL Dp[Maxn];

int main () {
    int n = Read (), m = Read ();
    for (int i = 1; i <= m; i++)
        q[i].t = Read (), q[i].l = Read (), q[i].r = Read (), q[i].c = Read ();
    sort (q + 1, q + m + 1, [](const Plan &x, const Plan &y) { return x.t < y.t; });
    for (int i = 1; i <= m; i++)
        Tree[0].a[i] = q[i].l - q[i].t, Tree[1].a[i] = q[i].l + q[i].t;
    Tree[0].Make_Tree (1, 1, m), Tree[1].Make_Tree (1, 1, m);
    priority_queue <Node> Que;
    for (int i = 1; i <= m; i++)
        if (q[i].l == 1) {
            Que.push (Node (i, q[i].c)), Dp[i] = q[i].c;
            Tree[0].Delete (1, 1, m, i), Tree[1].Delete (1, 1, m, i);
        }
    while (!Que.empty ()) {
        int u = Que.top ().x; Que.pop ();
        if (q[u].r == n) {
            Print (Dp[u], '\n');
            return 0;
        }
        Ret.clear ();
        Tree[0].Find (1, 1, u - 1, 1, m, q[u].r - q[u].t + 1);
        Tree[1].Find (1, u + 1, m, 1, m, q[u].r + q[u].t + 1);
        for (int i = 0, v; i < Ret.size (); i++) {
            v = Ret[i], Dp[v] = Dp[u] + q[v].c;
            Que.push (Node (v, Dp[v]));
            Tree[0].Delete (1, 1, m, v), Tree[1].Delete (1, 1, m, v);
        }
    }
    Print (-1, '\n');
    return 0;
}

「CF1476F」Lanterns

“要么向左,要么向右,判定方案是否合法” 这道题的引入和前一道题几乎一模一样,直接考虑 DP 也就很合理了。

对于「维度」,此题则要稍微简单一些:灯笼被照亮状态、灯笼朝向状态、合法性(?

合法性我们不太熟悉,但显然不能放在状态下标处。灯笼朝向状态可以很容易鉴定为在转移时才会用到,毕竟这里没有时间维度,灯笼间没有顺序,所以可以直接线性转移,对于每个灯笼考虑两种朝向。

而灯笼被照亮状态具有我们刚见过的性质:我们期望它的末状态是 full。故而此题也可以用前缀定义 DP

灯笼朝向状态应用于转移,灯笼被照亮状态放入下标,剩下的不熟悉的合法性感觉不好处理。不妨将合法性判定问题转化为我们更熟悉的最值问题,将原题转化为使用 \(n\) 个灯笼最多能照亮多长的前缀,如果这个最大长度大于等于 \(n\),显然就是合法状态。这样就巧妙的

依据以上两个想法我们就可以写出状态了 \(dp_i\) 表示使用前 \(i\) 个灯笼最多可以照亮多长的前缀。

假设决策到第 \(i\) 个灯笼,考虑利用朝向状态进行转移:

  • 若第 \(i\) 个灯笼朝向右,则再细分两种情况。

    • 若前 \(i - 1\) 个灯笼可以照亮 \(i\),即 \(dp_{i - 1} \geq i\),则 \(i\) 可以放心往右照,即 \(dp_i = \max \space (dp_{i - 1}, i + p_i)\)。
    • 若前 \(i - 1\) 个灯笼不能照亮 \(i\),即 \(dp_{i - 1} < i\),则 \(i\) 虽然向右照了,但前缀却在自己这里断了,故 \(dp_i = dp_{i - 1}\)。
  • 若第 \(i\) 个灯笼照向左,则会发现这样的决策有可能会给 \([i - p_i, i)\) 这些灯笼向右照的机会,会影响到 \(dp_i\)。为了处理这样的影响,我们考虑枚举 \(j \space \mathrm {s.t.} dp_j \geq i - p_i - 1\)。也就是说到第 \(j\) 个灯笼,我们 \(i - p_i\) 前的灯笼全部都处理了,那 \((j, i)\) 的灯笼就可以放心向右照了,即 \(dp_i = \max \space (i - 1, \max \space \{ dp_k \space \mathrm {s.t.} k \in (j, i) \})\)。

    考虑优化。不难发现 \(dp_i\) 是具有单调性的,因为灯笼越多,能照亮的最大前缀一定是不降的。所以我们需要转移中只需要找满足条件的最小的 \(j\) 即可,这可以使用二分查找。

    再看 \((j, i)\) 中最值的查询,因为是一个已知量组成的序列,故直接使用 ST 表即可。

当然这道题还要构造方案,这个记录状态是由哪个前驱转移过来的递归查找每个灯笼的状态即可。

总复杂度 \(O(n\log n)\),瓶颈在于灯笼照向左的转移。

#include <cmath>
#include <cstdio>
#include <algorithm>
using namespace std;

int Abs (int x) { return x < 0 ? -x : x; }
int Max (int x, int y) { return x > y ? x : y; }
int Min (int x, int y) { return x < y ? x : y; }

int Read () {
    int x = 0, k = 1;
    char s = getchar ();
    while (s < '0' || s > '9') {
        if (s == '-')
            k = -1;
        s = getchar ();
    }
    while ('0' <= s && s <= '9')
        x = (x << 3) + (x << 1) + (s ^ 48), s = getchar ();
    return x * k;
}

void Write (int x) {
    if (x < 0)
        putchar ('-'), x = -x;
    if (x > 9)
        Write (x / 10);
    putchar (x % 10 + '0');
}

void Print (int x, char s) { Write (x), putchar (s); }

const int Maxn = 3e5 + 5;

bool Flag[Maxn];
int Dp[Maxn], Pre[Maxn], p[Maxn], St[Maxn][32];

int Solve (int l, int r) {
    if (l > r)
        return 0;
    int k = log (r - l + 1) / log (2);
    return Max (St[l][k], St[r - (1 << k) + 1][k]);
}

void Plan (int x) {
    if (!x)
        return ;
    Plan (Pre[x]);
    if (Flag[x])
        putchar ('R');
    else {
        for (int i = Pre[x] + 1; i < x; i++)
            putchar ('R');
        putchar ('L');
    }
}

int main () {
    int t = Read ();
    while (t--) {
        int n = Read ();
        for (int i = 1; i <= n; i++)
            p[i] = Read (), St[i][0] = i + p[i];
        for (int j = 1; j <= (int)(log (n) / log (2)); j++)
            for (int i = 1; i + (1 << j) - 1 <= n; i++)
                St[i][j] = Max (St[i][j - 1], St[i + (1 << (j - 1))][j - 1]);
        Dp[1] = 0, Flag[1] = 1;
        for (int i = 2, Pos; i <= n; i++) {
            if (Dp[i - 1] >= i)
                Dp[i] = Max (Dp[i - 1], i + p[i]);
            else
                Dp[i] = Dp[i - 1];
            Pre[i] = i - 1, Flag[i] = 1;
            Pos = lower_bound (Dp, Dp + i, i - p[i] - 1) - Dp;
            if (Pos < i && Max (i - 1, Solve (Pos + 1, i - 1)) >= Dp[i])
                Dp[i] = Max (i - 1, Solve (Pos + 1, i - 1)), Pre[i] = Pos, Flag[i] = 0;
        }
        if (Dp[n] >= n)
            printf ("YES\n"), Plan (n), putchar ('\n');
        else
            printf ("NO\n");
    }
    return 0;
}

「CF1523F」Favorite Game

优先看题,\(n \leq 14\),很明显在提示状压之类的算法了对吧。

玩家的每一步决策都直接影响到后续的决策,这里是有个很明显会对后续产生一定影响的。而玩家每一步的决策是可以根据已知待完成任务状态来确定的,故而引入 DP

考虑「维度」:当前位置的状态(传送门或任务点)、当前位置在哪里、当前传送门的开启状态、已完成任务数、时间。

乍一看 \(5\) 个维度,第一个限制最弱,所以我们考虑从限制强的入手。(doge

当前位置在哪里数量级较小,放入状态。传送门的开启状态我们需要一个长度为 \(n\) 的二进制串去记录,放入 dp 值中的话,值会和下标脱节难以转移,故放在下标。已完成任务数因为数量级较小可以接受放入下标。时间数量级很大,考虑加入到 dp 值中。而限制最弱的那个,显然可以在转移的时候通过讨论不同情况来处理。

其实从这道题就可以看出,「维度」合理分配的位置有些时候是很模糊的。我是站在一个上帝视角分析放入什么位置,实际在做题的时候还是应该根据自己的经验、题感去尝试,再做出决策。

回到此题。我们能摸到一个基础状态 \(dp_{s, i, j}\) 表示在传送门状态 \(s\),完成任务 \(i\) 件,当前位置为 \(j\) 的最短时间。转移就是枚举状态然后枚举下一个位置,讨论传送门和任务点。但这个思路没有必要细化,因为到这里就已经是 \(O(2^nm^3)\) 的时间复杂度,肯定起飞。

不过既然算了时间复杂度,不难感觉到真实的时间复杂度应该和 \(O(2^nm^2)\) 长得比较像,那么我们就考虑优化掉一维。

注意到一个很显然的性质:当我们站在传送门时,其实不关心具体位置,因为传送门可以瞬移;当我们在任务点时,其实不关心具体时间,因为一定是任务需要的时间点。

也就是说我们分开将两类当前位置状态优化了一维,那么剩下的就很简单嘛。我们分开定义两种状态:设 \(f_{s, i}\) 表示传送门状态 \(s\) ,完成 \(i\) 件任务,现在仍在传送门的最小时间;设 \(g_{s, i}\) 表示传送门状态为 \(s\) ,现在在任务点 \(i\) 的最大任务完成数。

那么我们就需要考虑转移了。先设定一个中间变量 \(w_{s, i}\) 表示在传送门状态 \(s\) 下,从任意位置到达 \(i\) 位置的最短用时。因为我们是分开定义的状态,所以要考虑用来状态更新的状态和目标状态的共 \(4\) 种位置情况。

  • 从一个传送门到新的传送门,考虑向传送门状态中新加入一个传送门,然后求出到该传送门最短时间即可,有 \(f_{s \cup {j},i} = f_{s, i} + w_{s, j}\)。
  • 从一个传送门到任务点做任务,有限定条件:当前传送门状态支持在任务限定时间到达任务点。即满足条件 \(f_{s, i} + w_{s, j} \leq t_j\) 时,有 \(g_{s, j} = i + 1\)。
  • 从任务点做完任务到新的传送门,直接计算时间即可,注意需要累加当前时间,即任务限定时间。故 \(f_{s \cup j, x} = t_i + \min \space (w_{s, j}, \mathrm{dis} \space (i, j))\)。其中 \(x = g_{s, i}\)。
  • 从任务点到下一个任务点,需要计算是否能在下一个任务限定时间内到达下一个任务点。即满足条件 \(t_i + \min \space (w_{s, j}, \mathrm{dis} \space (i, j)) \leq t_j\) 时,\(g_{s, j} = g_{s, i} + 1\)。

实际实现中是先枚举传送门状态,然后分别转移 \(f\) 和 \(s\) ,或许其实谁先谁后都可以?

#include <cstdio>
#include <algorithm>
using namespace std;

int Abs (int x) { return x < 0 ? -x : x; }
int Max (int x, int y) { return x > y ? x : y; }
int Min (int x, int y) { return x < y ? x : y; }

int Read () {
    int x = 0, k = 1;
    char s = getchar ();
    while (s < '0' || s > '9') {
        if (s == '-')
            k = -1;
        s = getchar ();
    }
    while ('0' <= s && s <= '9')
        x = (x << 3) + (x << 1) + (s ^ 48), s = getchar ();
    return x * k;
}

void Write (int x) {
    if (x < 0)
        putchar ('-'), x = -x;
    if (x > 9)
        Write (x / 10);
    putchar (x % 10 + '0');
}

void Print (int x, char s) { Write (x), putchar (s); }

const int Inf = 1e9;
const int Maxm = 2e2 + 5;
const int Maxn = 2e4 + 5;

struct Node {
    int x, y, t;
    Node () {}
    Node (int X, int Y, int T) { x = X, y = Y, t = T; }
    friend bool operator < (Node a, Node b) { return a.t < b.t; }
} q[Maxm];

int w[Maxn][Maxm], f[Maxn][Maxm], g[Maxn][Maxm];

int Dist (Node a, Node b) { return Abs (a.x - b.x) + Abs (a.y - b.y); }

int main () {
    int n = Read (), m = Read ();
    for (int i = 0; i < n; i++)
        q[i].x = Read (), q[i].y = Read ();
    for (int i = n; i < n + m; i++)
        q[i].x = Read (), q[i].y = Read (), q[i].t = Read ();
    sort (q + n, q + n + m);
    for (int S = 0; S < (1 << n); S++)
        for (int i = 0; i < n + m; i++) {
            f[S][i] = Inf, g[S][i] = -Inf, w[S][i] = Inf;
            for (int j = 0; j < n; j++)
                if ((S >> j) & 1)
                    w[S][i] = Min (w[S][i], Dist (q[i], q[j]));
        }
    for(int i = 0; i < n; i++)
        f[1 << i][0] = 0;
    for(int i = 0; i < m; i++)
        g[0][i] = 1;
    int Res = 0;
    for (int S = 0; S < (1 << n); S++) {
        for (int i = 0; i < m; i++)
            if (f[S][i] < Inf) {
                for (int j = 0; j < n; j++)
                    if (!((S >> j) & 1))
                        f[S | (1 << j)][i] = Min (f[S | (1 << j)][i], f[S][i] + w[S][j]);
                for (int j = 0; j < m; j++)
                    if (f[S][i] + w[S][j + n] <= q[j + n].t)
                        g[S][j] = Max (g[S][j], i + 1);
            }
        for (int i = 0; i < m; i++)
            if (g[S][i] >= 0) {
                for (int j = 0; j < n; j++)
                    if (!((S >> j) & 1))
                        f[S | (1 << j)][g[S][i]] = Min (f[S | (1 << j)][g[S][i]], q[i + n].t + Min (w[S][j], Dist (q[i + n], q[j])));
                for (int j = i + 1; j < m; j++)
                    if (Min (w[S][j + n], Dist (q[i + n], q[j + n])) + q[i + n].t <= q[j + n].t)
                        g[S][j] = Max (g[S][j], g[S][i] + 1);
                Res = Max (Res, g[S][i]);
            }
    }
    Print (Res, '\n');
    return 0;
}