线性 DP

内容

  • 线性 DP 的概念和典型的优化技巧
  • DP speedup

线性 DP 的概念和典型的优化技巧

多阶段决策问题

问题的解是通过逐步决策得到的。

  • 背包类型动态规划:每一步决定一个物品「选」或「不选」。

线性 DP

这一类问题常常可以用 DP 来解决。人们把这样的 DP 称为线性 DP。

  • 每个点表示一个状态集。
  • 每个箭头表示一次决策

这里说的“决策”和 DP 的术语“状态转移”可视作同义词。

每一步的决策依赖于(或针对)一个(或一些)资料。

  • 背包类型动态规划:一个物品

这些资料以及一些附加的规则决定了当前的哪些状态可以转移到下一阶段的哪些状态。

线性 DP 的难点

  • 如何划分阶段(识别出它是个线性 DP)
  • 如何定义子问题(需要什么状态)
  • 哪些状态转移是可行的或者说是需要考虑的
  • 如何快速地进行状态转移
  • 如何减少状态数
  • ……

例题:过河

河上有一座独木桥,一只青蛙想沿着独木桥从河的一侧跳到另一侧。桥的长度 和青蛙一次跳过的距离都是正整数,我们可以把独木桥上青蛙可能到达的点看成数轴上的一串整点:。坐标为 的点表示桥的起点,坐标为 的点表示桥的终点。

青蛙从桥的起点开始,不停的向终点方向跳跃。一次跳跃的距离是 到 之间的任意正整数(包括 )。当青蛙跳到或跳过坐标为 的点时,就算青蛙已经跳出了桥。

在桥上有 个石子,第 个石子的位置是 。
青蛙很讨厌踩在这些石子上。青蛙要想过河,最少需要踩到多少个石子?

我们可以只考虑下列关键状态

  • 在起点。
  • 首次到达 的位置,即首次到达或超过第一个石子。
  • 首次到达 的位置。
  • ……
  • 首次到达 的位置。

首次到达 的位置,有 个可能的位置 。

:首次到达 的位置是在 。。

:到达状态 最少要踩到前 个石子中的多少个?

情况 1:,此时要有 ,即二者表示同一状态。

情况 2:,此时考虑 。

  • 这里 且 。
  • 我们需要判断 是否可能。

例题:方格取数

有一个 的网格,第 行第 列的格子里有一个整数 。

有一只小熊想从图的左上角走到右下角。每一步只能向上、向下或向右走一格,并且不能重复经过已经走过的方格,也不能出界。

小熊会取走经过的方格中的整数,求它能取到的整数之和的最大值。

限制

子问题

  • :从 到 的路径上的数字之和的最大值。
  • 答案是 。
  • 递推式?

错误的递推式

从 到 或 的最优路径可能经过 。

状态转移

从第 行的某个格子 先向右一步走到 再向上或向下走到 。

分两步计算

把从 到 的路径分成两类:

  • 到 之后可以继续向下走
  • 到 之后可以继续向上走

分两步计算一列格子的 DP 值:

  1. 从上到下

  2. 从下到上

例题:摆渡车

有 个学生要乘摆渡车从车站前往学校。第 个人在第 分钟到车站等车。只有一辆摆渡车在工作,但摆渡车的容量可视为无限大。摆渡车从车站出发,把车上的人送到学校,再回到车站,这样往返一趟总共花费 分钟(人上下车的时间忽略不计)。

如果可以任意安排摆渡车的出发时间,那么这些人的等车时间之和最少是多少?

一个人的等待时间 = 他乘的车的出发时刻 - 他到车站的时刻

一个最优解应该具有下述性质

  • 假设某次摆渡车到站时已经有人在等车了。那么摆渡车要么立即出发,要么等到某个有人来的时刻出发。并且等待时间在 到 之间。

解释:如果摆渡车等了几分钟才出发,但是在等待期间没有新的学生来,那不如不等。如果等待了 分钟,不如先把车上的人送过去,再回来。

我们还可以给最优解加一个限制

  • 如果摆渡车到站后发现没人在等车,那它应该等到有人来再出发。

解释:空车跑和等 分钟再出发效果是一样的。

不妨设 。我们有下述结论

  • 第一趟车应该在 中某个有人来的时刻出发。
  • 以后的每一趟车,要么是上一趟到站后立即出发,要么是上一趟车到站后等一会儿,等到某个有人来的时刻出发。

解法

考虑 DP。

: 某趟车在 时刻出发。

: 某趟车在 时刻出发,前 个人的等车时间之和的最小值。

摆渡车在状态 之后有两种情况:

  • 每次到站就立即出发,直到把所有人都送到学校。

  • 连续运行几次之后,下一次等到时刻 出发。这里 。也就是从状态 转移到状态 。令 ,那么,对于 ,

    • 若 ,则第 个人是在 时刻出发的,等待时间是 。
    • 否则第 个人的出发时刻是 。

为了使第一种情况也对应到一个状态,我们补充一个 ;这样,第一种情况就是从状态 转移到状态 。这个问题的答案就是 。

int main() {
    int n, m; cin >> n >> m;
    vector<int> t(n);
    for (int i = 0; i < n; i++) cin >> t[i];
    sort(t.begin(), t.end());
    t.push_back(t.back() + 2 * m);

    vector<int> sum(n + 1);
    for (int i = 0; i < n; i++) sum[i + 1] = t[i] + sum[i];
    vector<int> dp(n + 1, INT_MAX);
    for (int i = 0; i < n; i++) {
        dp[i] = min(dp[i], i * t[i] - sum[i]); // 摆渡车第一次出发是在 t[i] 时刻
        int wait = 0; //乘坐连续运行的车的那些人的等待时间之和
        for (int j = i + 1, ptr = i + 1; j <= n; j++) { // 双指针
            if (t[j] >= t[i] + m) {//在t[i]时刻出发,连续运行若干次,下一次在 t[j] 时刻出发。
                int r = (t[j] - t[i]) % m;
                int last = t[j] - r - m; // 上一趟车的出发时刻
                while (t[ptr] <= last) {
                    int s = (t[ptr] - t[i]) % m;
                    if (s)
                        wait += m - s;
                    ptr++;
                }
                int wait_2 = t[j] * (j - ptr) - (sum[j] - sum[ptr]);
                dp[j] = min(dp[j], dp[i] + wait + wait_2);
            }
        }
    }
    cout << dp[n] << '\n';
}

例题:接龙

个人玩接龙游戏,第 个人有一个整数序列 作为他的词库。

一个游戏有若干轮,每一轮规则如下:

  • 选择某个人 进行接龙。若这不是第一轮,那么这一轮进行接龙的人不能与上一轮相同,但可以与更早的轮相同。
  • 人 选择一个长度在 的 的子串 作为这一轮的接龙序列,其中 是给定的常数。若这是第一轮,那么 必须以 开头,否则 必须以上一轮接龙序列的最后一个元素开头。

处理 个询问。第 个询问给你两个整数 ,问能否进行 轮接龙且最后一轮的接龙序列以 结尾。

限制

接龙游戏是一个典型的多阶段决策过程。

如何找到恰当的子问题?

每一次决策需要哪些信息?

如何应对「这一轮接龙的人不能与上一轮相同」这个限制?

:接龙进行到第 轮且第 轮的接龙序列以整数 结尾

:能否到达状态 ?
若能,最后一轮上场的人是否唯一?
若唯一,是谁?

的值

  • 表示不能到达状态 。
  • 表示能到达状态 ,且第 轮上场的人不唯一。
  • ()表示能到达状态 ,并且最后一轮上场的人只能是 。

如果我们能算出全部 (,),就能在 时间回答一个询问。

下一步怎么办?

思路一:对每个 ,考虑如何计算 ,或者说 等于什么。

思路二:对每个 ,考虑 能转移到哪些 。

思路三:对每个人 ,考虑如果第 轮派 上场,哪些状态转移是可行的。

如果第 轮派第 个人上场,且选择 作为接龙序列,
那么考虑从状态 到状态 的转移。

如果存在一个 使得

  • 或 且 ,

那么状态 是可达到的,并且最后一轮上场的人可以是 。

滑动窗口

对于 的每个长度为 的子串(或长度小于 的前缀)

  • 决定了要转移到的状态是
  • ()这些值决定了能不能转移过去。

例题:染色

有 个球从左到右排成一行,编号 到 。
给你一个长为 的正整数序列 。
你要将每个球染成红色或蓝色,然后按如下方式计算每个球 的得分 :

  • 如果球 左侧没有与其同色的球,则 。
  • 否则,记其左侧与其最靠近的同色球为球 ,若 ,则令 ,否则令 。

你的最终得分为 。求你的最终得分可能达到的最大值。
你需要解决 组测试。

  • 。

一个球是什么颜色不重要,重要的是两个球的颜色是否相同。

考虑 DP。

:对前 个球染色的最大得分。

边界条件:。


对于 ,考虑下列两种情况

  • 球 得分是 。
    此时前 个球的最大得分为 。

  • 球 得分是 。
    设球 左边第一个跟它同色的球是 ,那么 。
    再细分为两种情况

    • ,此时最大得分是
    • ,此时情形如右图。最大得分是下列三项之和
      • 对前 个球染色,球 和球 异色,最大总得分。
      • 球 到球 的总得分。

center

:对前 个球染色,第 个球和第 个球异色,最大总分数。

边界条件:。

球 到球 的总得分可以表示为两个前缀和之差。

: 中满足 的 的和。

那么,球 到球 的总得分是 。

于是前 个球的最大总得分可表为

我们要计算

为此我们维护序列 ()

随着 的增大,我们更新序列 。

DP speedup

(比朴素的方法)更快地计算 DP。

例题:最长上升子序列

例题:Yet Another Knapsack Problem

有 种物品。第 种物品有 个,重量是 ,价值是 。

我们要从一共 个物品中选一些,所选物品的总重量不超过 。

对每个 ,求选 个物品,总价值可能达到的最大值。
在本题的限制条件下,对每个 ,一定能选出 个物品,总重量不超过 .

限制

朴素的 DP

子问题

:从前 种物品中选 个,总重不超过 ,所选物品的总价值的最大值。

递推式

时间:。

用前面提到的维护滑动窗口最大值的套路,能够把时间降到

但是这还不够。

能不能更快?

问题在于有 这个条件在,大部分子问题 都是需要计算的。

具体地说就是

这已经有 个子问题了。

就算用滚动数组的技巧,也只是把 变成 ,仍然有 个子问题。

我们还有一条路可走:重新定义子问题。

优先考虑重的物品

子问题

:从第 到第 种物品中选 个物品,总重量不超过 ,所选物品的最大总价值。

递推式

这样做有什么好处?

需要考虑的状态变少了

从第 到第 种物品中选 个,所选物品的总重量至少是 。

因此只考虑满足 的 就够了。

状态数被削减到 。

至于转移,仍可以用滑动窗口最大值的套路,在均摊 的时间内转移。

例题:价值衰减的背包问题

有 种物品。第 种物品的重量是 价值是 。每种物品有 个。

高桥要选一些物品放进一个容量是 的背包。他想让所选物品的价值之和最大又不想选太多个同种物品。于是他定义选 个第 种物品的满意度是 。

高桥想要选一些物品放进背包使得所有种类的总满意度最大而总重量不超过 。求可以达到的最大总满意度。

限制

子问题

:从前 种物品中选一些,所选物品的总重量不超过 ,总满意度的最大值。

最多能选 个第 种物品。有递推式

改写递推式

令 ,,那么 ,把上式改写成

整理成

把 记作 ,求 归结为求

一些一次函数的最大值

对 ,我们这样看:

  • 有 个一次函数,第 个()是 。
    求 。

求一些一次函数(直线)在某一点处的函数值的最大值。

例子:

  • 有些直线始终取不到最大值。
  • 可能取到最大值的直线,在其上取到最大值的 值是一个区间。直线的斜率越大,这个区间越靠右。

为什么是这样

考虑两个一次函数 ,,。

存在 使得

  • 当 时,
  • 当 时,

概要

  • 求 归结为求 。
  • 把 看作一次函数 在 处的值。
  • 把问题归结为求函数 在 处的值。
  • 用函数图像来研究函数 。

比较

  • 求 归结为求 。

  • 求 归结为求 。

考虑按 的顺序计算每个 。

和 相比

  • 多了一条直线 。它的斜率比 都更大。
  • 求值点右移。(从 变成 )

维护一列直线

假设我们维护了一列对求值点 有用的直线,按斜率从小到大排列。
那么 处函数值的最大值就是在这个序列中的第一个排列上取到的。

现在直线 来了,此后的求值点 满足 。

关键性质

设 。若 ,此后的最大值可以不在 上取到。因此我们可以把 扔掉。

struct convex_hull_trick { // 每次加入的直线的斜率严格递增
    struct line {
        long long k, b;
    };
    vector<line> lines; // 直线序列
    vector<long long> r; // 交点序列
    void add(long long k, long long b) {
        while (!lines.empty()) {
            // 计算直线 (k, b) 和直线 lines.back() 的交点
            auto [k2, b2] = lines.back();
            assert(k2 < k);
            // k * x + b >= k2 * x + b2  ==>  x >= (b2 - b) / (k - k2)
            long long x = (b2 - b) / (k - k2);
            if (x * k + b < x * k2 + b2)
                x++;
            if (r.size() && x <= r.back()) {
                r.pop_back();
                lines.pop_back();
            } else {
                r.push_back(x);
                break;
            }
        }
        lines.push_back({k, b});
    }
    long long query(long long x) {
        assert(!lines.empty());
        auto pos = upper_bound(r.begin(), r.end(), x) - r.begin();
        return x * lines[pos].k + lines[pos].b;
    }
};

# [abc373_f](https://atcoder.jp/contests/abc373/tasks/abc373_f) Knapsack with Diminishing Values