四边形不等式优化利用的是状态转移方程中的决策单调性。

基础知识

考虑最简单的情形,我们要解决如下一系列最优化问题。

这里假定成本函数 可以在 时间内计算。

约定

动态规划的状态转移方程经常可以写作一系列最优化问题的形式。以(1)式为例,这些问题含有参数 ,问题的目标函数和可行域都可以依赖于 。每一个问题都是在给定参数 时,选取某个可行解 来最小化目标函数的取值。为表述方便,下文将参数为 的最优化问题简称为「问题 」,该最优化问题的可行解 称为「决策 」,目标函数在最优解处取得的值则称为「状态 」。同时,记问题 对应的最小最优决策点为

在一般的情形下,这些问题总时间复杂度为 。这是由于对于问题 ,我们需要考虑所有可能的决策 。而在满足决策单调性时,可以有效缩小决策空间,优化总复杂度。

  • 决策单调性:对于任意 ,必然成立

最常见的判断决策单调性的方法是通过四边形不等式(quadrangle inequality)。

  • 四边形不等式:如果对于任意 均成立

则称函数 满足四边形不等式(简记为「交叉小于包含」)。若等号永远成立,则称函数 满足 四边形恒等式

如果没有特别说明,以下都会保证 。四边形不等式给出了一个决策单调性的充分不必要条件。

定理 1

满足四边形不等式,则问题 (1) 满足决策单调性。

四边形不等式可以理解在合理的定义域内, 的二阶混合差分 非正。

利用决策单调性,有两种常见算法可以将算法复杂度优化到

分治

要求解所有状态,只需要求解所有最优决策点。为了对所有 求解 ,首先计算 ,而后分别计算 上的 ,注意此时已知前半段的 必然位于 之间(含端点),而后半段的 必然位于 之间(含端点)。对于两个子区间,也类似处理,直至计算出每个问题的最优决策。在分治的过程中记录搜索的上下边界,就可以保证算法复杂度控制在 。递归树层数为 ,而每层中,单个决策点至多计算两次,所以总的计算次数是

核心代码

[list2tab]

  • C++

    int w(int j, int i);
     
    void DP(int l, int r, int k_l, int k_r) {
      int mid = (l + r) / 2, k = k_l;
      // 求状态f[mid]的最优决策点
      for (int j = k_l; j <= min(k_r, mid - 1); ++j)
        if (w(j, mid) < w(k, mid)) k = j;
      f[mid] = w(k, mid);
      // 根据决策单调性得出左右两部分的决策区间,递归处理
      if (l < mid) DP(l, mid - 1, k_l, k);
      if (r > mid) DP(mid + 1, r, k, k_r);
    }
  • Python

    def DP(l, r, k_l, k_r):
        mid = int((l + r) / 2)
        k = k_l  # 求状态f[mid]的最优决策点
        for i in range(k_l, min(k_r, mid - 1)):
            if w(i, mid) < w(k, mid):
                k = i
        f[mid] = w(k, mid)  # 根据决策单调性得出左右两部分的决策区间,递归处理
        if l < mid:
            DP(l, mid - 1, k_l, k)
        if r > mid:
            DP(mid + 1, r, k, k_r)

二分队列

注意到对于每个决策点 ,能使其成为最小最优决策点的问题 必然构成一个区间。可以通过单调队列记录到目前为止每个决策点可以解决的问题的区间,这样,问题的最优解自然可以通过队列中记录的决策点计算得到。算法大致如下。

核心代码

  • C++
    int val(int j, int i);
    int lt[N], rt[N], f[N];
    deque<int> dq;
    // 顺次考虑所有问题和决策,下标从 1 开始
    for (int j = 1; j <= n; ++j) {
      // 出队
      if (!dq.empty() && rt[dq.front()] < j) dq.pop_front();
      if (!dq.empty()) lt[dq.front()] = j;
      // 入队
      while (!dq.empty() && val(j, lt[dq.back()]) < val(dq.back(), lt[dq.back()])) {
        dq.pop_back();
      }
      if (dq.empty()) {
        lt[j] = j;
        rt[j] = n;
        dq.emplace_back(j);
      } else if (val(j, rt[dq.back()]) >= val(dq.back(), rt[dq.back()])) {
        if (rt[dq.back()] < n) {
          lt[j] = rt[dq.back()] + 1;
          rt[j] = n;
          dq.emplace_back(j);
        }
      } else {
        int ll = lt[dq.back()], rr = rt[dq.back()], i = rr;
        // 二分
        while (ll <= rr) {
          int mm = (ll + rr) / 2;
          if (val(j, mm) < val(dq.back(), mm)) {
            i = mm;
            rr = mm - 1;
          } else {
            ll = mm + 1;
          }
        }
        rt[dq.back()] = i - 1;
        lt[j] = i;
        rt[j] = n;
        dq.emplace_back(j);
      }
      // 计算
      f[j] = val(dq.front(), j);
    }

掌握这一算法,需要理解如下要点:

  • 队列需要记录到目前为止每个可行的决策点 和能够解决的问题区间左右端点 构成的 三元组。对于给定区间 内的问题, 应该是到目前为止考虑过的决策点中最小最优的(以下简称最优决策)。每时每刻,队列中存储的决策未必是连续的,但是尚未解决的问题 应该是队列中存储的问题区间的不交并。
  • 初始时,队列是空的。类似于单调队列,每次考虑下一个决策 的时候,都需要进行出队和入队操作。
  • 出队:首先将上一个问题 从队列中移除。如果队首的决策能够解决的问题的右端点恰为 ,直接弹出队首;否则,将队首决策能够解决问题的左端点更新为
  • 入队:要对决策 进行入队时,首先比较它和队尾的决策
    • 如果对于问题 ,将要入队的决策 比已有的决策 严格更优,即 时,则弹出队尾的决策 。此操作持续到队列为空或队尾的决策 比起 对于问题 更优时为止。
    • 如果队列已空,入队 ,即认为决策 是尚未解决的所有问题的最优解。
    • 如果队尾决策 对于问题 同样不劣于将入队的决策 ,那么当 时,入队 ,表示 是问题 的最优决策;否则,不需要入队 ,因为它并不比已有的决策更优。
    • 最后的情形是,队尾决策 比起要入队的决策 对于问题 严格更优,而对于问题 严格更劣。这说明,存在问题 使得问题 的最优决策为 且问题 的最优决策为 。因而,需要通过 二分 找到最小的 使得 ,再将队尾的区间右端点 修改为 ,并入队
  • 处理完决策 后,就已经处理了所有到 为止的决策。此时,队首决策就是问题 的最优决策,可以记录相应的最优解。

类似于单调队列,每个决策点至多入队一次,出队一次。这里,出队是 的,而入队是 的(可能需要二分),所以总的时间复杂度是

给定一个长度为 的序列 ,要求对于每一个 ,找到最小的非负整数 满足

区间分拆问题

考虑将某个区间拆分成若干个子区间的问题。形式化地说,将给定区间 拆分成 ,其中,,以及 对任意 都成立。对于给定拆分,成本为 。问题要求最小化这一成本。可以列出如下的 1D1D 状态转移方程。

这里,。注意到,只要 满足四边形不等式, 必然满足四边形不等式,因为第一项并不包括 的交叉项,在混合差分时会消去。但是由于成本函数依赖于前面的子问题,这一转移只能够顺序计算,所以通常只适合应用二分队列算法。算法复杂度为

限制区间个数的情形

上述问题可以加强为限制区间个数的情形,即问题指定将区间拆分成 个子区间。此时需要将拆分后的区间个数作为转移状态的一维。相应地,有 2D1D 状态转移方程如下。

这里, 对任意 都成立。和上文同样的道理,这里的 必然满足四边形不等式。此时对于第 层的计算,并不再依赖于该层的结果,所以对于每一层,都可以通过分治或者二分队列的方法进行计算,此时算法复杂度为

对于这一问题,利用决策单调性,实际上还存在其他的优化算法。第二种优化思路依赖于如下结果。这种优化算法和下文详细描述的 Knuth 优化算法十分相似。

定理 2

满足四边形不等式,则对于问题 (2) 成立

利用这一结果,我们可以限制决策 的搜索范围。算法实现时,对 正向遍历,对 逆向遍历,在之前已确定的上下界范围内暴力搜索 就可以保证 的算法复杂度。

最后一种优化方法来源于如下的观察。

定理 3

满足四边形不等式,则问题 (2) 的最优解 是关于 的凸函数。

这一结论保证了可以通过 WQS 二分(国外称 Aliens Trick)的方法解决此问题。具体来说,考虑带参的成本函数 ,解决不限制区间个数的问题,求得其最优解为 。随着实数 递增,相应的最优区间的数目单调递减,故而可以通过二分的方法找到恰使得最优区间个数等于 的参数 ,则原题最优解为 。这里的实数 可以看作区间个数限制的 Lagrange 乘子。该算法的实现有很多细节,可以参考 WQS 二分 页面。这一算法的时间复杂度为 ,这里 为某一常数。

对于限制区间个数的区间分拆问题的三种算法,在不同的数据范围时表现各有优劣,需要结合具体的题目选择合适的算法。

高速公路旁边有一些村庄。高速公路表示为整数轴,每个村庄的位置用单个整数坐标标识。没有两个在同样地方的村庄。两个位置之间的距离是其整数坐标差的绝对值。

邮局将建在一些,但不一定是所有的村庄中。为了建立邮局,应选择他们建造的位置,使每个村庄与其最近的邮局之间的距离总和最小。

你要编写一个程序,已知村庄的位置和邮局的数量,计算每个村庄和最近的邮局之间所有距离的最小可能的总和。

区间合并问题

另一类可以通过四边形不等式优化的动态规划问题是区间合并问题,即要将 个长度为一的区间 两两合并起来,直到得到区间 。每次合并 时都需要支付成本 。问题要求找到成本最低的合并方式。对于此类问题,有如下 2D1D 状态转移方程。

这里给定任意初始成本 。暴力算法的总复杂度为 ,而当存在决策单调性时,可以优化至 的算法复杂度。这一算法最早由 Knuth 在解决最优二叉搜索树问题时提出,并由姚储枫进一步研究总结,在国外称为 Knuth’s optimization 或 Knuth-Yao speedup。

除了四边形不等式以外,区间合并问题的决策单调性还要求成本函数满足区间包含单调性。

  • 区间包含单调性:如果对于任意 均成立

则称函数 对于区间包含关系具有单调性。

这实质是成本函数的一阶条件,即 关于 递减,关于 递增。

引理 1

满足区间包含单调性和四边形不等式,则状态 满足四边形不等式。

定理 4

满足区间包含单调性和四边形不等式,则问题 (3) 中最小最优决策 满足

利用这一结论,同样可以限制决策点 的搜索范围。在这里,正序遍历区间长度 ,再遍历具有同样长度的所有区间 ,暴力搜索 之间的所有 求得最优解 并记录最小最优决策 。对于同样长度的所有区间,此算法中决策空间总长度是 的,而可能的区间长度的数目同样是 的,故而总的算法复杂度为 的。

核心代码

[list2tab]

  • C++

    for (int len = 2; len <= n; ++len)  // 枚举区间长度
      for (int j = 1, i = len; i <= n; ++j, ++i) {
        // 枚举长度为len的所有区间
        f[j][i] = INF;
        for (int k = opt[j][i - 1]; k <= opt[j + 1][i]; ++k)
          if (f[j][i] > f[j][k] + f[k + 1][i] + w(j, i)) {
            f[j][i] = f[j][k] + f[k + 1][i] + w(j, i);  // 更新状态值
            opt[j][i] = k;  // 更新(最小)最优决策点
          }
      }
  • Python

    for len in range(2, n + 1):  # 枚举区间长度
        for i in range(len, n + 1):
            # 枚举长度为len的所有区间
            j = i - len + 1
            f[j][i] = INF
            for k in range(opt[j][i - 1], opt[j + 1][i] + 1):
                if f[j][i] > f[j][k] + f[k + 1][i] + w(j, i):
                    f[j][i] = f[j][k] + f[k + 1][i] + w(j, i)  # 更新状态值
                    opt[j][i] = k  # 更新(最小)最优决策点

满足四边形不等式的函数类

为了更方便地证明一个函数满足四边形不等式,我们有以下几条性质:

性质 1:若函数 均满足四边形不等式(或区间包含单调性),则对于任意 ,函数 也满足四边形不等式(或区间包含单调性)。

性质 2:若存在函数 使得 ,则函数 满足四边形恒等式。当函数 单调增加时,函数 还满足区间包含单调性。

性质 3:设 是一个单调增加的凸函数,若函数 满足四边形不等式并且对区间包含关系具有单调性,则复合函数 也满足四边形不等式和区间包含单调性。

性质 4:设 是一个凸函数,若函数 满足四边形恒等式并且对区间包含关系具有单调性,则复合函数 也满足四边形不等式。

首先需要澄清一点,凸函数(Convex Function)的定义在国内教材中有分歧,此处的凸函数指的是下凸函数,即(可微时)一阶导数单调增加的函数。

习题

参考资料