DP动态规划–区间DP

这是我参与更文挑战的第 12 天,活动详情查看: 更文挑战

区间 DP

有一类 DP 题目,看起来像是一个线性的数组,但是如果仔细分析,就会发现要处理的却是一个一个的子区间。下面我们依然通过一道例题,使用 6 步破题法进行求解。

例子:扰乱字符串

题目

【题目】使用下面描述的算法可以扰乱字符串 s 得到字符串 t 。

Step 1. 如果字符串的长度为 1 ,算法停止。

Step 2. 如果字符串的长度 > 1 ,执行下述步骤:

在一个随机下标处将字符串分割成两个非空的子字符串。即如果已知字符串 s ,则可以将其分成两个子字符串 x 和 y ,且满足 s = x + y 。

随机决定是“交换两个子字符串”还是“保持这两个子字符串的顺序不变”。即在执行这一步骤之后,s 可能是 s = x + y 或者 s = y + x 。

Step 3. 在 x 和 y 这两个子字符串上继续从 Step 1 开始递归执行此算法。

有两个长度相等的字符串 s1 和 s2,判断 s2 是否是 s1 的扰乱字符串。如果是,返回 true ;否则,返回 false 。

输入:s1 = “great”, s2 = “rgeat”

输出:true

解释:经过如下操作即可从 s1 得到 s2:

分析

【分析】当拿到这个题目的时候,我们看到 true/false,可以联想到这应该是个 DP 题的信号。因为题目也没有要求输出具体怎么操作。

因此,你可以由此想到使用 6 步破题法,而不是立马跟着题意开始切分字符串。

1. 最后一步

首先看最后一步,题目中给定两个字符串 s1,s2,那么 s1 在最后一步操作之后是否可以得到 s2 呢?(假设 s1 的长度为 N,下标从 0 开始)

我们分析一下,s1 在操作的时候,可以有以下步骤:

1)在位置 p 处进行切分,将 s1 切分为 x = [0, … p] 和 y = [p + 1, N);

2)然后再分别处理 s1 = x + y 和 s1 = y + x 能否拼成 s2。

其中 p 的取值范围是 [0 ~ N-2],所以一共有 N-1 种选项。

但是这样操作并不能降低处理的数据规模,无法将一个大问题切分为更多的小问题,也就是说我们只是找到了原始问题的一个等价问题。

这说明我们最后一步找得不准!需要重新思考。如果 s1 是 s2 的扰乱字符串,那么在最后一步的时候,存在以下 2 种情况(判断的时候,只需要判断对应颜色相同的部分):

Case 1. 找到某个位置,将 s1, s2 都切成两半,其中 s1 = x + y,而 s2 = c + d,那么我们只需要保证 x 是 c 的扰乱字符串,y 是 d 的扰乱字符串。

Case 2. 找到某个位置,切分后,使得 s1 = x + y,s2 = c + d,并且 x 是 d 的扰乱字符串,而 y 是 c 的扰乱字符串。

我们发现,找准最后一步之后,原始问题的规模减小了很多。

2. 子问题

起初要求解的问题是:

判断 s1 是不是 s2 的扰乱字符串。

经过最后一步的处理之后,需要处理的问题被拆分为两种情况:

Case 1. s1 = x + y, s2 = c + d,判断 <x, c> 和 <y,d> 是不是扰乱字符串;

Case 2. s1 = x + y, s2 = c + d,判断 <x, d> 和 < y, c> 是不是扰乱字符串。

到这里,我们可以将原问题表示如下:

f(s1, s2) = true,表示 s1 是 s2 的扰乱字符串,false 则表示不是。

那么最后一步就可以表示为:

f(s1, s2) = f(x, c) && f(y, d) || f(x, d) && f(y, c)
其中 s1 = x + y, s2 = c + d

3. 递推关系式

我们可以用伪代码表示一下这个递推关系式,代码如下:

boolean isScramble(s1, s2) {
  N = len(s1)
  f(s1, s2) = false;
  for cutPos in range(0, N) {
    // 第一种切分方式
    // s1 = x + y
    x = s1[0:cutPos]
    y = s1[cutPos+1, N)
    // s2 = c + d
    c = s2[0:cutPos]
    d = s2[cutPos+1,N)
    // 看第一种是否满足条件
    f(s1,s2) = f(x,c) && f(y,d);
    if (f(s1,s2)) break;
    // 第二种切分方式
    c = s2[0:N-cutPos-1]
    d = s2[N-cutPos:N)
    // 看第二种是否满足条件 
    f(s1, s2) = f(x,d) && f(y, c);
    if (f(s1,s2)) break;
  }
}
复制代码

4. f(x) 的表示

接下来,我们需要看一下 f(s1, s2) 的表示。

如果简单一点,我们当然可以使用 HashMap<String,HashMap<String, Boolean>> 双层哈希函数来处理(难办一点,可以用 s1+”#”+s2 作为 key,然后只使用一层哈希函数)。

但是就这道题而言,我们还有更好的表达方式。观察题目可以发现:子问题里面的 <x, y> 和 <c, d> 都是原字符串的子串。

    // s1 = x + y
    x = s1[0:cutPos]
    y = s1[cutPos+1, N)
    // s2 = c + d
    c = s2[0:cutPos]
    d = s2[cutPos+1,N)
复制代码

那么可以用起始位置与终止位置来表示:

f(s1, s2) = f([s1_start, s1_end], [s2_start, s2_end])
复制代码

到这里,我们可以认为信息已经变成了 f(s1_start, s1_end, s2_start, s2_end)。针对这个 4 维的信息,可以通过建立一个 4 维数组来处理。比如:dp[s1_start][s1_end][s2_start][s2_end]。

但是,如果 s1 字符串要是 s2 字符串的扰动字符串,那么这两者的长度应该是相等的。因此,应该满足如下的关系:

s1_end – s1_start = s2_end – s2_start

也就是说,两个字符串的长度总是相等的,那么我们可以把 4 维的信息压缩为 3 维:

s1_start

s2_start

length
复制代码

因为 s1_end = s1_start + length,而 s2_end = s2_start + length。也就是说,3 维的信息与 4 维的信息完全是等价的。那么,我们可以把原来 4 维的数组 dp,更改为 3 维的数组 dp[s1_start][s2_start][length]。

5. 初始条件与边界

虽然有了第 3 步中的递推关系,但是我们很快可以发现,有那么几项需要提前处理,否则无法计算结果。

都是空字符串:s1 = empty,s2 = empty。(就本题而言,题目已给定了不会出现空字符串)。

两个字符串长度都是 1:len(s1) = 1, len(s2) = 1。

还有一些可以提前处理的操作,比如 s1 与 s2 的字符串长度不相等,这种直接可以返回 False,因为扰动字符串需要两个字符串长度相等。

6. 计算顺序

我们在初始条件中,已经处理了长度为 0(空字符串),长度为 1 的子串的情况。如果再接着走两步,那么应该再去计算长度为 2 的任意子串是不是相互为扰动字符串。然后再计算长度为 3,长度为 4,直到长度为 N 的子串。

可以肯定,当计算到长度为 N 的时候,我们就能得到最终解。

完整代码

【分析】经过了前面的 DP 分析 6 步法,现在应该可以写出如下代码了(解析在注释里):

boolean isScramble(String s1, String s2) {
  final int s1len = s1 == null ? 0 : s1.length();
  final int s2len = s2 == null ? 0 : s2.length();
  if (s1len != slen2) {
    return false;
  }
  // N表示后面字符串的长度
  final int N = s1len;
  boolean[][][] dp = new boolean[N + 1][N + 1][N + 1];
  // 初始条件是长度为1的情况
  for (int s1start = 0; s1start < N; s1start++) {
    for (int s2start = 0; s2start < N; s2start++) {
      dp[s1start][s2start][1] = s1.charAt(s1start) == s2.charAt(s2start);
    }
  }
  // 那么再通过递推公式计算其他长度的情况
  // 子串的截取,这里我们采用开闭原则[start, end)
  // 也就是说,end是可以取到N的。
  for (int len = 2; len <= N; len++) {
    for (int s1start = 0; s1start + len <= N; s1start++) {
      for (int s2start = 0; s2start + len <= N; s2start++) {
        // 现在我们需要计算两个子串
        // X = s1[s1start, s1start+len)
        // Y = s2[s2start, s2start+len)
        // 这两个子串是否是扰动字符串
        // 那么根据递推公式,我们需要找切分点
        // 切分子串的时候
        // X 切分为 X = a + b, 分为左右两半
        // Y 切分为 Y = c + d,同样分为左右两半
        // 左边的长度为leftLen, 右边的长度就是len - leftLen
        for (int leftLen = 1; leftLen < len && !dp[s1start][s2start][len];
             leftLen++) {
          // 第一种切分,case 1
          // X = a + b, Y = c + d
          // [s1start, s1start + leftLen) <- a
          // [s2start, s2start + leftLen) <- c
          // [s1start + leftLen, s1start + len) <- b
          // [s2start + leftLen, s2start + len) <- d
          boolean c1 =
            // <a, c>
            dp[s1start][s2start][leftLen] &&
            // <b, d>
            dp[s1start + leftLen][s2start + leftLen][len - leftLen];
          // 第2种切分
          // X = a + b, Y = c + d
          // [s1start, s1start + leftLen) <- a
          // [s2start + len - leftLen, s2start + len) <- d
          // [s1start + leftLen, s1start + len) <- b
          // [s2start, s2start + len - leftLen) <- c
          boolean c2 =
            // <a, d>
            dp[s1start][s2start + len - leftLen][leftLen] &&
            // <b, c>
            dp[s1start + leftLen][s2start][len - leftLen];
          dp[s1start][s2start][len] = c1 || c2;
        }
      }
    }
  }
  return dp[0][0][N];
}
复制代码

复杂度分析:时间复杂度 O(N4),空间复杂度O(N3)。

小结

除了前面的 DP 6 步破题法之外,这道题的重点考点在于:

最后一步的正确理解与选择——要保证最后一步得到的子问题是收缩的;

哈希函数空间的优化——我们一步一步推导了从哈希函数到 4 维数组,最后到 3 维数组。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享