跳转至

Slope Trick 优化

引入

对于一类二维 DP 问题,如果它的价值函数 对于每个固定的 都是 的凸函数,那么将函数 整体视为 处的状态,并维护它的差分(或斜率)

而非函数本身,往往能够起到优化转移的效果。这种优化 DP 的思想,就称为 Slope Trick。

「斜率」

因为大多数题目中涉及的函数都只在整点处取值,所以称它为差分和斜率没有本质区别,本文按照 Slope Trick 这个名词统一称呼它为斜率。

具体题目中,斜率的维护方式可能各不相同。如果斜率的取值有限,维护斜率变化的点(即拐点)更为方便;而如果函数中 的取值有限,维护斜率序列本身可能更为方便。更复杂的情形,可能需要同时维护每段斜率的大小和该段的长度。无论具体维护方式是什么,这类问题的本质都是利用状态转移中斜率序列变化有限这一点简化转移。因此,它们都可以称作 Slope Trick。

凸函数

在讨论具体的题目之前,有必要首先了解一下凸函数的基本性质,以及在对凸函数进行各种变换时,它的斜率会如何变化。

实轴上的凸函数

凸函数较为一般的定义是在 上给出的。

上的凸函数

如果函数 对于所有 都满足

就称函数 凸函数(convex function),其中 的运算法则规定为 乘以任何正实数或是加上任何实数都等于其自身,且对于任何实数 都有

当然,如果不等号换作 ,就相应地称它为凹函数1。因为对于凹函数 ,总有 为凸函数,所以本节只考虑凸函数。

当然,函数 往往并不会对所有实数都有定义。如果函数 的定义域仅是 的子集,那么可以将它拓展为 上的函数:

此时,称 是凸函数,当且仅当相应的 满足上述凸函数的定义。因此,如果没有特别指出,本文提到的凸函数的定义域均是实数集 。显然,凸函数 只能在一个区间(即 的凸子集)上取得有限值。

简单例子

常见的凸函数的例子包括:

  1. 常数函数:,其中
  2. 一次函数:,其中
  3. 绝对值函数:,其中
  4. 任何凸函数限制在某个区间上的结果,例如

当然,可以通过下文提到的保持凸性的变换组合出更为复杂的凸函数。

离散点集上的凸函数

算法竞赛中,很多函数仅在部分整数值处有定义。它们在一般情况下并不是(上文定义的)凸函数,因为它们的定义域不再是凸集。为了处理这种情形,需要单独定义离散点集上的函数的凸性。简单来说,需要首先对函数做线性插值,将其定义域拓展到区间,再判断它的凸性。

离散点集上的凸函数

为离散点集,即 对任意实数 都是有限集。对于函数 ,可以定义函数 使得:

  • 时,

  • 时,设 ,则

  • 时,

那么,如果 上的凸函数,就称 上的 凸函数

因为 上的凸函数处理起来更为方便,所以本文在提及凸函数时,若非特别说明,指的都是 上的凸函数。如果本文中某个函数仅给出了部分整数处的取值,那么它在其他实数处的取值应由定义中的 确定,也就相当于直接讨论对应的分段线性函数

整数集 上的凸函数有一个更为直观的等价定义:

上的凸函数的等价定义

函数 是凸的,当且仅当

对于所有 都成立。

也就是说,只要斜率(差分)单调不减,这个序列就可以看作是 上的凸函数。

凸函数的两种刻画

其实,用斜率刻画凸函数的方式也可以推广到一般情况。

凸函数的斜率刻画

或它的离散子集,则函数 为凸函数,当且仅当斜率

对于任何 都是 的弱增函数。

斜率单调不减,可以看作是凸函数的等价定义。正因为凸函数的斜率具有单调性,在维护斜率时,通常需要选择优先队列或平衡树等数据结构。

本文还会用到凸函数的另一种等价刻画。对于函数 ,可以考察平面内函数图像上方的区域,即

这个区域也称为函数 上境图(epigraph)。函数的凸性,等价于它的上境图的凸性:

凸函数的上境图刻画

函数 是凸函数,当且仅当 内的凸集。

稍后会看到,利用上境图,可以将凸函数的卷积下确界与凸集的 Minkowski 和联系起来。

凸函数的变换

紧接着,本文介绍一些 Slope Trick 中经常遇见的保持凸性的变换。

非负线性组合

对于凸函数 以及非负实数 ,函数 也是凸函数。而且,

因此,如果维护了凸函数 的斜率,要得到它们的非负线性组合 的斜率,只需要逐段计算即可。

在维护斜率的问题中,往往其中一个函数的形式比较简单,此时可以通过懒标记的方式降低修改复杂度。在维护拐点的问题中,要计算 的斜率拐点,只需要将 的斜率拐点合并即可。

卷积下确界(Minkowski 和)

凸函数的另一种常见操作是卷积下确界。对于函数 ,函数

称为 卷积下确界2(infimal convolution)。如果 都是凸函数,它们的卷积下确界也是凸函数。

几何直观上, 就是 Minkowski 和。如果 都是分段线性函数,那么 同样是分段线性函数,且它的斜率段可以看作是 的斜率段合并(再排序)的结果。

在实际问题中,如果 其中一个的斜率段数较少,可以直接将较少的斜率段插入到较多的斜率段中;否则,可能需要利用 启发式合并可并堆 等方法,降低合并的整体复杂度,或者根据具体问题寻找相应的处理方式。

最值操作

两个凸函数的最大值仍然是凸函数,但是,两个凸函数的最小值未必仍然是凸函数。

很多常见的最小值操作可以转化为卷积下确界:

例子
  • 仍然是凸函数,因为它可以看作是卷积下确界:

  • 上的凸函数,只要 上的凸函数,且在有限集合 上定义的函数 也是该离散集合上的凸函数。这是因为延拓之后的函数 可以看作是卷积下确界:

    因此,延拓之前的函数 也是凸函数。

但并不是所有的最小值操作都保持凸性。

反例

是凸函数,函数 并不一定是凸函数。

在一些特殊的问题中,尽管动态规划的转移方程可以写作两个凸函数的最小值的形式,且难以转化为卷积下确界的形式,但是价值函数依然能够保持凸性。在实际处理时,通常需要结合打表和猜测找到这类问题的合理的斜率转移方式。

了解了凸函数及其常见变换后,就可以通过具体的问题理解 Slope Trick 优化 DP 的方法。本文的例题大致分为维护拐点和维护斜率两组,用于理解这两种维护方式的常见操作和实施细节。但是,正如前文所强调的那样,维护方式并不是 Slope Trick 的本质,应当根据具体的问题需要选取合适的斜率段维护方式。

维护拐点

这类问题通常出现在需要最小化若干个绝对值的和式的问题中。因为这类问题中,价值函数的斜率的绝对值并不大,因此维护斜率变化的拐点更为方便。

维护拐点是指维护分段线性函数中,斜率发生变化的点。相当于对于每个斜率为 的斜率段 ,只维护其端点信息,而斜率本身不需要格外维护;因此,这类问题斜率每次发生变化时,都应当只变化一个固定的量。比如,如果维护了拐点集 ,就相当于说:区间 内斜率为 ;向左每经过一个拐点,斜率减少一;向右每经过一个拐点,斜率增加一;故而,区间 内,斜率就是 ,区间 内,斜率就是 ,诸如此类。用形式语言表示,函数可以利用斜率拐点写作

它的最小值就是 ,且可以在区间 内任意位置取到。

例题:最小成本递增序列

[BalticOI 2004] Sequence 数字序列

给定长度为 的序列 ,求严格递增序列 使得 最小,输出最小值和任意一种最优方案

解答

首先, 严格递增,等价于 弱增。因此,可以对 求出差值最小的弱增序列 再恢复成序列 即可。

考虑朴素 DP 解法。设 是已经选取了序列 中前 个数字,且第 个数字不超过 时,已经选取的数字与 的前 个数字的最小差值:

容易得到状态转移方程为

初始状态为 ,最后要求的就是 。利用前文提到的凸函数的变换,从 ,需要经过两步变换:

  1. 首先,加上 ,这相当于对区间 内的所有斜率段都增加 ,对区间 内的所有斜率段都增加
  2. 对得到的函数取最小值,将 变为 。根据前文分析,这相当于对 做卷积下确界。因为后者的斜率段只有一段,斜率为 且向右延申至无限长,将其插入 的斜率段中,相当于删除其中所有正斜率段。

明晰了这些操作后,已经可以直接用平衡树维护所有斜率段了,但代码较复杂。注意到问题中斜率每次变化至多 ,故而所有斜率段的绝对值都不超过 。不直接维护斜率段,转而直接维护斜率拐点更为方便。

的拐点集为 。那么,上面的两步操作分别对应:

  1. 增加一个负斜率段的拐点 和一个正斜率段的拐点
  2. 弹出所有正斜率段的拐点

实际维护时,因为每次操作结束后都没有正斜率段的拐点,即斜率拐点具有形式 ,而且操作总发生在正负斜率段交界处,所以直接维护一个最大堆存储所有拐点即可。两步操作分别对应:

  1. 插入两次
  2. 弹出堆顶。

当然,每次结束后都需要维护当前函数的最小值。因为操作结束后,没有正斜率段,函数最小值就是它在最大堆堆顶处的取值。设每次操作之前堆顶为 ,最小值为 。因为弹出的堆顶是正斜率段的最小拐点,函数的最小值就等于该处函数的取值,所以直接计算弹出前堆顶处函数的取值即可,亦即

其中,第一项相等是因为 没有正斜率段。因此,每次只需要在最小值上不断累加 即可。

本题还要求输出一种最优方案。因为最后操作结束时,最优解就是堆顶,所以 的取值可以直接确定。如果已经知道了第 个最优解 ,要求解 满足 的最优解,只需要注意到因为 是凸的,所以越接近它的全局最小值点,解就越优,故而只要记录 的全局最小值点,并将它与 取最小值,就可以得到最优的

时间复杂度为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
#include <queue>
#include <vector>

int main() {
  int n;
  std::cin >> n;
  std::vector<int> a(n), b(n);
  for (int& x : a) std::cin >> x;
  for (int i = 0; i < n; ++i) a[i] -= i;
  long long res = 0;
  std::priority_queue<int> max_heap;
  for (int i = 0; i < n; ++i) {
    max_heap.emplace(a[i]);
    max_heap.emplace(a[i]);
    res += max_heap.top() - a[i];
    max_heap.pop();
    b[i] = max_heap.top();
  }
  std::cout << res << '\n';
  for (int i = n - 2; i >= 0; --i) b[i] = std::min(b[i], b[i + 1]);
  for (int i = 0; i < n; ++i)
    std::cout << (b[i] + i) << (i == n - 1 ? '\n' : ' ');
  return 0;
}

模板题:

例题:转移带限制的情形

[NOISG 2018 Finals] Safety

给定长度为 的序列 ,求序列 使其满足 对所有 都成立,并使得 最小,输出最小值。

解答

内容大致与上一个题目相仿,只是序列 的限制发生了变化。同样地,设 为第 个数字取 时,前 个数字的差值的最小值:

由此,有状态转移方程为

起始条件为 。最后要求的仍然是

状态转移拆解为对凸函数的操作,分两步:

  1. 首先对 取最值,变为 ,这相当于 的卷积下确界;
  2. 再将得到的函数与 相加。

同样因为斜率每次只变化一,可以考虑维护拐点。这样,这两步操作就可以描述为:

  1. 将所有负斜率段向左移动 ,将所有正斜率段向右移动
  2. 插入两次

显然,对于本题,将正负斜率段分别维护较为方便。因为操作主要集中在零斜率段附近,因此考虑使用 对顶堆,即分别用最大堆和最小堆维护负斜率段和正斜率段的拐点。拐点的整体平移操作用懒标记完成。因为第二步操作需要分别对两个堆插入一个 ,而且,插入完成后,未必最大堆的堆顶仍然小于等于最小堆的堆顶。此时,交换两堆顶,直到堆顶的大小关系得到满足即可。

最后,考虑操作过程中如何更新最小值。因为第一步平移操作并不会改变最小值,所以只要考虑交换堆顶的操作即可。设 ,将堆顶 交换时,函数由

变为

过程中,函数形状不变,只是向下平移了 。因此,要使得交换堆顶前后函数保持不变,只需要将最小值累加 即可。

算法的时间复杂度仍为 ,因为每次添加元素后,交换堆顶的操作至多执行一次。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <queue>
#include <vector>

int main() {
  int n;
  long long h;
  std::cin >> n >> h;
  std::priority_queue<long long> max_heap;
  std::priority_queue<long long, std::vector<long long>, std::greater<>>
      min_heap;
  long long lt = 0, rt = 0;
  long long res = 0;
  for (; n; --n) {
    long long x;
    std::cin >> x;
    lt += h;
    rt += h;
    max_heap.emplace(x + lt);
    min_heap.emplace(x - rt);
    auto l = max_heap.top() - lt;
    auto r = min_heap.top() + rt;
    while (l > r) {
      max_heap.pop();
      min_heap.pop();
      res += l - r;
      max_heap.emplace(r + lt);
      min_heap.emplace(l - rt);
      l = max_heap.top() - lt;
      r = min_heap.top() + rt;
    }
  }
  std::cout << res << std::endl;
  return 0;
}

模板题:

维护斜率

还有一些问题,维护斜率更为方便。这类问题通常也可以使用 反悔贪心 或模拟费用流的思想解决。费用流模型中,最小费用往往是流量的凸函数,这就为使用 Slope Trick 提供了基础。

例题:股票交易问题

Codeforces 865 D. Buy Low Sell High

给定 天股票价格序列 (均为正数),初始持股为 ,每天可买入一股、卖出一股或不交易,求 天后最大利润。

解答

首先考虑朴素 DP 解法。设 为第 天结束时持有股票数量为 的最大利润,则

初始状态为 ,且对所有 ,有 。问题的答案就是

需要经过两步变换:

  1. 与函数

    对应的分段线性函数 (显然是凹函数)做卷积上确界;

  2. 因为这样会导致函数在区间 内具有有限值,这与 的要求矛盾,故而需要截取函数在 内的部分。

将它们转化为斜率段的变化,就是如下两步:

  1. 插入长度为 、斜率为 的斜率段;
  2. 删除斜率有限的斜率段中,斜率最大且长度为 的一段。

因为斜率段的长度总是自然数,所以不妨维护若干个长度为一的斜率段,从而只需要记录每段的斜率即可。因为只需要插入和访问最大值操作,所以只需要一个最大堆。操作分两步:

  1. 插入两次
  2. 弹出堆顶。

还需要维护 的值。因为第一步操作得到的函数在 处的取值就是 ,所以它在 处的取值就是该值加上马上要弹出的堆顶——它就是函数在区间 上的斜率。因为截断不改变函数在 处的取值,所以这就是

对比该算法实现与上文 最小成本递增序列 的代码可知,该算法等价于求将股票价格变为弱递减序列的最小成本。

时间复杂度为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <queue>
#include <vector>

int main() {
  int n;
  std::cin >> n;
  std::vector<int> a(n);
  for (int& x : a) std::cin >> x;
  long long res = 0;
  std::priority_queue<int> max_heap;
  for (int x : a) {
    max_heap.emplace(-x);
    max_heap.emplace(-x);
    res += x + max_heap.top();
    max_heap.pop();
  }
  std::cout << res << '\n';
  return 0;
}

模板题:

例题:搬运土石问题

[USACO16OPEN] Landscaping P

给定长度为 的序列 ,分别表示第 个花园已经有的泥土数量和需要的泥土数量(不能多也不能少)。购买一单位泥土放入任意花园价格为 ,从任意花园运走一单位泥土价格为 ,从花园 向花园 运送一单位泥土价格为 。求满足所有花园需求的最小成本。(

解答

考虑朴素 DP 解法。设 为满足前 个花园需求且净剩余 单位泥土运到后面的花园时的最小代价。如果 ,就相当于净亏空 单位泥土需要从后面的花园运送过来。那么,可以写成状态转移方程为

其中,函数 表示当前花园的泥土净购买量为 时的成本,即

它显然是凸函数。该状态转移方程的含义为

  • 之前 个花园净剩余泥土数量为 时,最小成本为
  • 将净剩余(亏空)的泥土数量在 之间运送的成本为
  • 通过买卖,将第 个花园的泥土数量从 调整为 ,并将净剩余泥土数量从 调整到 ,最小成本为

初始状态为 ,且对所有 ,有 。问题的答案就是

将函数 变换为 可以分为三步:

  1. 首先,加上 ,得到
  2. 然后,与 做卷积下确界,得到
  3. 最后,将函数向左平移 个单位。

转化为对斜率段的操作,同样分为三步:

  1. 将原点左侧斜率段全体加上 ,将原点右侧斜率段全体加上
  2. 将所有小于 的斜率段全部替换为 ,将所有大于 的斜率段全部替换为
  3. 将所有斜率段向左平移 个单位。

原题中 很小,因此只需要维护若干个长度为 的斜率段即可。虽然斜率段有无穷多个,但是有上界 和下界 ,且严格位于两者之间的斜率段数目并不多。因为不涉及插入操作,所以可以用两个栈维护原点两侧的斜率段,区间加和区间最值操作全部打懒标记完成。上述三步操作分别对应:

  1. 对左右两个栈分别打懒标记,左侧加 ,右侧加
  2. 每次栈内弹出元素时,都对 取最大值,对 取最小值。如果左栈为空,则弹出 。如果右栈为空,则弹出
  3. 将左栈顶部的 个元素弹出,插入右栈;当然, 时,就反过来。

在交换栈顶时,更新答案,向左移动就减去当前斜率,向右移动就加上当前斜率。

算法复杂度为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
#include <stack>

int main() {
  int n;
  long long x, y, z;
  std::cin >> n >> x >> y >> z;
  std::stack<long long> neg, pos;
  long long lt = 0, rt = 0;
  long long res = 0;
  for (; n; --n) {
    int a, b;
    std::cin >> a >> b;
    lt -= z;
    rt += z;
    for (; b < a; ++b) {
      auto l = -y;
      if (!neg.empty()) {
        l = std::max(l, neg.top() + lt);
        neg.pop();
      }
      pos.emplace(l - rt);
      res -= l;
    }
    for (; b > a; --b) {
      auto r = x;
      if (!pos.empty()) {
        r = std::min(r, pos.top() + rt);
        pos.pop();
      }
      neg.emplace(r - lt);
      res += r;
    }
  }
  std::cout << res << std::endl;
  return 0;
}

模板题:

习题

本文的最后,提供一些各类算法竞赛中出现过的且可以使用 Slope Trick 解决的问题,以供练习。

参考文献与注释


  1. 不同教材对于凸函数的称呼可能不同。 

  2. 也常称为 卷积、 卷积或者 卷积。