区间类型动态规划

151 阅读5分钟

经典案例 石子合并

有 n(1n500)(1≤n≤500) 堆石子排成一排,第 i 堆石子有 ai(a_i(1≤aia_i≤500) 颗,每次我们可以选择相邻的两堆石子合并,代价是两堆石子数目的和,现在我们要一直合并这些石子,使得最后只剩下一堆石子,问总代价最少是多少?

[SDOI2008]石子合并

例如,初始我们有 3 堆石子,其中的石子数分别是 1, 2, 3,有两种合并的方法:

  • 先合并前两堆,代价是 1 + 2 = 3,合并完以后还剩两堆石子,其中的石子数分别是 3, 3,再把这两堆石子合并,代价是 6,总代价是 3 + 6 = 9;
  • 先合并后两堆,代价是 2 + 3 = 5,合并完以后还剩两堆石子,其中的石子数分别是 1, 5,再把这两堆石子合并,代价是 6,总代价是 5 + 6 = 11;

第一种方法代价较小,所以答案等于 9。

那么怎么来解决这个问题呢?

递归法

首先我们考虑用递归法来解决石子合并问题。

由于每次我们合并的都是相邻两堆石子,在合并的过程中每堆石子都是由初始时连续的一段石子合并而来。现在让我们考虑整个合并过程的最后一步,我们要把最后的两堆石子合并成一堆石子。

一定存在一个分界线 x,使得两堆石子中的一堆是初始第 1 堆到第 x 堆石子合并得到的结果,另一堆是初始第 x + 1 堆到第 n 堆石子合并得到的结果。比如说在例子的第一种方法中,x = 2,即最后一次合并的一堆石子是初始第 1 堆到第 2 堆石子合并得到的结果,另一堆是初始第 3 堆石子(也就是初始第 3 堆到第 3 堆石子合并得到的结果)。

当 x 确定的时候,总代价 = 合并初始第 1 堆到第 x 堆石子的最小代价 + 合并初始第 x + 1 堆到第 n 堆石子的最小代价 + 总石子数。但是我们不知道分界线 x 在哪里更优,所以我们选择枚举 x 的位置,并且在所有情况中选择代价最小的一个,即为最后的答案。

定义 f[i][j] 表示合并初始第 i 堆到第 j 堆石子的最小代价。现在问题从计算 f[1][n] 变成了计算所有的 f[1][x] 和 f[x + 1][n]。需要考虑的区间变小了,接下来是不是可以递归啦?

在递归的过程中,假如我们想求出 f[l][r],也就是合并初始第 l 到第 r 堆石子的最小代价。为了合并这些石子,也会存在一个分界线 m,使得最后一步合并的两堆石子中的一堆是初始第 l 堆到第 m 堆石子合并得到的结果,另一堆是初始第 m + 1 堆到第 r 堆石子合并得到的结果。当 m 确定的时候,总代价 = 合并初始第 l 到第 m 堆石子的最小代价 + 合并初始第 m + 1 到第 r 堆石子的最小代价 + 初始第 l 到第 r 堆石子的石子总数。同样的,我们要枚举分界线 m 的位置,并且从中选出代价最小的方案,有:

f[l][r]=minlm<r(f[l]][m]+f[m+1][r])+i=lraif[l][r] = min_{l \leq m < r}(f[l]][m] + f[m+1][r]) + \sum_{i=l}^r a_i
#include <bits/stdc++.h>

using namespace std;

int n, a[501], s[501];

int solve(int l, int r) {
    if (l == r)
        return 0;
    int ans = 1 << 30;
    for (int m = l; m < r; m++)
        ans = min(ans, solve(l, m) + solve(m + 1, r));
    return ans + s[r] - s[l - 1];
}

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++)
        scanf("%d", &a[i]);
    for (int i = 1; i <= n; i++)
        s[i] = s[i - 1] + a[i];
    printf("%d\n", solve(1, n));    
}

solve(l, r) 计算的是合并初始第 l 堆到第 r 堆石子的最小代价。

    if (l == r)
        return 0;

当 l = r,也就是只需要合并一堆石子时,不需要花费任何代价,返回 0。

    int ans = 1 << 30;
    for (int m = l; m < r; m++)
        ans = min(ans, solve(l, m) + solve(m + 1, r));

这段代码的作用是枚举分界线 m,找出 f[l][m] + f[m + 1][r] 的最小值。

    return ans + s[r] - s[l - 1];

合并初始第 l 堆到第 r 堆石子的最小代价 ,我们用前缀和的思想计算初始第 l 堆到第 r 堆石子中的石子总数。

    for (int i = 1; i <= n; i++)
        s[i] = s[i - 1] + a[i];

在这里我们引入 s 数组,s[i] 等于编号小于等于 i 的堆中的石子总数(初始第 1 堆到第 i 堆石子的石子总数)。那么初始第 l 到第 r 堆中有多少石子怎么用 s 数组表示呢?是不是就是 s[r] - s[l - 1] 呀(初始第 1 堆到第 r 堆石子的石子总数 - 初始第 1 堆到第 l - 1 堆石子的石子总数,剩下的就是初始第 l 到第 r 堆石子的石子总数)!

    printf("%d\n", solve(1, n));   

solve(1, n) 即为最后的答案!

记忆化搜索

递归的解法跑得很慢,n = 500 早就彻底歇菜了,为什么呢?在递归的过程当中,同一个区间 [l, r] (指的是合并初始第 l 堆到第 r 堆的情况)会被重复计算很多次。比如说,对于区间 [3, 4] 来说,计算区间 [1, 4], [2, 4], [3, 5], [3, 6], ...., [3, n] 时都需要被算一次,这显然就太慢了!那怎么办呢?每次计算的时候,合并初始第 l 堆到第 r 堆石子的最小代价都是一样的,于是我们可以用一个数组 f 把算过的情况都记下来,f[l][r] 中记录了合并初始第 l 堆到第 r 堆石子的最小代价。每次递归到区间 [l, r] 的时候,我们首先判断一下之前这个情况有没有出现过。如果这是第一次出现,我们继续往下递归,并用算出来的值更新 f[l][r];否则,我们直接返回 f[l][r] 的值就可以啦!

#include <bits/stdc++.h>

using namespace std;

int n, a[501], s[501], f[501][501];

int solve(int l, int r) {
    if (f[l][r] != -1)
        return f[l][r];
    if (l == r)
        return f[l][r] = 0;
    int ans = 1 << 30;
    for (int m = l; m < r; m++)
        ans = min(ans, solve(l, m) + solve(m + 1, r));
    return f[l][r] = ans + s[r] - s[l - 1];
}

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++)
        scanf("%d", &a[i]);
    for (int i = 1; i <= n; i++)
        s[i] = s[i - 1] + a[i];
    memset(f, 255, sizeof(f));
    printf("%d\n", solve(1, n));
}

和刚刚的递归解法不一样的是,我们引入了数组 f,把算过的情况都记下来。

    memset(f, 255, sizeof(f));

初始 f 中所有元素的值都赋值成 -1。如果 f[l][r] = -1,表示这个情况没有被计算过。

    if (f[l][r] != -1)
        return f[l][r];

在计算 solve(l, r) ,也就是合并初始第 l 堆到第 r 堆石子的情况的过程中,如果这个情况之前被计算过了,则直接返回 f[l][r]。

    if (l == r)
        return f[l][r] = 0;
    int ans = 1 << 30;
    for (int m = l; m < r; m++)
        ans = min(ans, solve(l, m) + solve(m + 1, r));
    return f[l][r] = ans + s[r] - s[l - 1];

否则,我们只要在 return 之前把算出来的值存入 f[l][r] 就好啦!

我们再来分析下这个代码的时间复杂度。长度为 n 的序列总共有 O(n2)O(n^2)(实际是 Cn+12C^2_{n+1}),每个区间最多被计算到一次,每次计算时分界线最多有  O(n)O(n) 个,所以总的时间复杂度是O(n3)O(n^3)

这就是传说中的记忆化搜索啦!顾名思义,记忆化搜索指的是我们在搜索(递归)的过程中,把碰到过的情况都记录下来。再次碰到同一个情况的时候,我们不必从头再计算一次,取而代之的是我们可以直接从记忆中把结果读取出来。记忆化搜索和动态规划秉承着相似的逻辑,核心问题都是以子问题的解推出母问题的解。 不一样的是,在动态规划中拆分问题的过程是在人的头脑中进行的,而记忆化搜索拆分问题是在程序中进行的,它的执行模式是在递归的过程中先自上而下把母问题拆分成子问题,一直拆分直到不能继续拆分为止,然后再自下而上把每个子问题的解一一推导出来。作为对比,动态规划的程序里只有自下而上求解每个子问题的解这一个过程。在实战当中,记忆化搜索的思维难度相较动态规划来说一般更低,在读者早期对动态规划还不甚理解的时候,不妨先熟练掌握使用记忆化搜索的技巧,已经可以解决绝大部分的动态规划类问题。

动态规划

让我们换种思路,用动态规划的想法来解决这个问题,分析过程是和前面的做法类似的。首先,我们来看看这个题的最优子结构、无后效性、状态以及转移。

最优子结构:为了计算合并区间 [i, j] 的最小代价,我们需要先计算合并所有满足

ikji\leq k \leq j的区间 [i, k],[k + 1, j] 的最小代价;有了后者的值,我们就可以算出前者的值;

无后效性:我们只关心合并区间 [i, j] 的最小代价,不关心具体是怎么合并的;

状态:用 f[i][j] 表示合并区间 [i, j] 的最小代价;

转移:

f[i][j]=minik<j(f[i][k]+f[k+1][j]+k=ijak)f[i][j] = min_{i \leq k <j}(f[i][k] + f[k+1][j] + \sum_{k=i}^{j} a_k)

分别对应了分界线 k = i, k = i + 1, ...., k = j - 1 的情况,我们从这 j - i 种情况中选出代价最小的方案,即可以更新 f[i][j] 的值;

上回也说了在动态规划中,在算一个问题的解之前,必须事先计算这个问题的所有子问题的解。这一点在这里如何保证呢?让我们思索一下,问题 [i, j] 的子问题 [i, k] 和 [k + 1, j] 有什么共同点呢?对了,他们的长度是不是都比 [i, j] 小!如果我们把区间按长度从小到大的顺序排好序进行计算,那么一个问题的子问题是不是都会先于它被计算到呀?

话不多少,让我们来看看代码!

#include <bits/stdc++.h>

using namespace std;

int n, a[501], s[501], f[501][501];

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++)
        scanf("%d", &a[i]);
    for (int i = 1; i <= n; i++)
        s[i] = s[i - 1] + a[i];
    memset(f, 127, sizeof(f));
    for (int i = 1; i <= n; i++)
        f[i][i] = 0;
    for (int l = 1; l < n; l++)
        for (int i = 1; i <= n - l; i++) {
            int j = i + l;
            for (int k = i; k < j; k++)
                f[i][j] = min(f[i][j], f[i][k] + f[k + 1][j] + s[j] - s[i - 1]);
        }
    printf("%d\n", f[1][n]);
}

我们具体的实现方法是先算所有长度为 1 的区间,再算所有长度为 2 的区间,...,最后算所有长度为 n - 1 的区间。对于同样长度的区间,谁先算谁后算是不关键的(它们中的任意一个不可能成为另一个的子问题)。

    memset(f, 127, sizeof(f));
    for (int i = 1; i <= n; i++)
        f[i][i] = 0;

一开始我们进行初始化操作。因为最后要求最小代价,我们把所有状态的值赋成无穷大,之后出现更小的值,把它们替换掉就可以了。接着,对于所有的区间 [i, i],只有一堆石子,所以不需要合并,因而不需要付出任何代价,把这些 f[i][i] 赋值成 0。

    for (int l = 1; l < n; l++)
        for (int i = 1; i <= n - l; i++) {
            int j = i + l;

接着从小到大枚举当前考虑的区间长度 l 以及考虑的区间的左端点 i,对应的右端点 j 等于 i + l。

            for (int k = i; k < j; k++)
                f[i][j] = min(f[i][j], f[i][k] + f[k + 1][j] + s[j] - s[i - 1]);

然后枚举分界线 k 的位置,即可以更新出 f[i][j] 的值。

    printf("%d\n", f[1][n]);

f[1][n] 就是答案!

最后让我们来分析下这个做法的时间复杂度,我们需要枚举区间长度 l、左端点 i 以及分界线位置 k,每个都是 O(n) 级别的,所以总的时间复杂度是 O(n3)O(n^3)和记忆化搜索是一样的。

石子合并代表了一类涉及到区间的动态规划问题,其核心思想是按长度从小到大的顺序计算每个区间代表的状态的值。解决这类问题的一般思路就是按上面所说的依次枚举区间长度 l、左端点 i 以及分界线位置 k,然后进行状态的更新和转移。

练习