# 区间 DP

LeetCode 上的 **【877. 石子游戏,难度为【中等】**。

典型的区间 DP 问题:大区间状态值依赖小区间状态值。

假设 f[x][y] 表示的是先手[x,y] 区间的总数差值,那么最后只需要 return f[1][n] > 0 即可。从一般性推导递推式, f[l][r] ,如果先手先拿左边的,那么之后就是后手变先手, f[l+1][r] 就是 f[l][r] 中后手的总数差值,所以先拿左边得到的差值就是 p[l]-f[l+1][r] 。同理,拿右边得到的差值就是 p[r]-f[l][r-1] 。因为两边都想赢,所以 f[l][r] = Math.max(p[l]-f[l+1][r],p[r]-f[l][r-1])

相关代码:

public boolean stoneGame(int[] p) {
    int n = p.length;
    int[][] f = new int[n+2][n+2];
    
    for(int len = 1; len <= n; len++) { // 枚举区间长度
        for(int l = 1; l + len -1 <= n; l++) { // 枚举左端点
            int r = l + len - 1; // 右端点
            f[l][r] = Math.max(p[l-1] - f[l+1][r], p[r-1],f[l][r-1]);
        }
    }
    return f[1][n] > 0;
}

总结:大区间依赖小区间,所以在 dp 时,可以先枚举区间长度。

当然,这道题,作为博弈问题,其实是先手必赢的局面。

扩展一下博弈思路,首先石子总和为奇数,总数为偶数,满足这么一个特性:均分两堆 AB,AB 堆各自石子总和必不可能相同。所以先手在拿的时候,可以将石子数组划分两堆:

  • 原数组:C C C C C C
  • 划分后:A B A B A B

那么 sum_A 和 sum_B 必定有一个更大,先手可以保证总是拿到 A 或者 B。如果 A 堆更到,那先手只需要每次都拿 A 即可(后手每次受 A 影响,只能拿 B)。

其他的区间 DP 练习题:

  • 最长回文子序列

参考:

  • 三叶姐:https://mp.weixin.qq.com/s/Se2Aw_q74vYlApF7H6OUVw

# 序列 DP

LeetCode 上的 **【139. 单词拆分,难度为【中等】**。

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。

注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

示例 1:

输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以由 "leet""code" 拼接成。

调整字符串 s 以及 dp 数组下标从 1 开始。

定义 f[i] 是前 i 个字符,能否使用 wordDict 拼凑: f[i]=true 代表 s[1...i] 能够使用 wordDict 所凑成,反之则不能。

数组转移: f[i] 需要考虑 s[1...i] 范围内的字符,如果 f[j-1]=trues[j..i] 能够被拼凑出来,就说明 f[i] = true 。所以我们需要做的就是枚举 1~i

class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        Set<String> setWord = new HashSet<>();
        for(String word : wordDict) setWord.add(word);
        int n = s.length();
        boolean[] f = new boolean[n+1];
        f[0] = true;
        for(int i = 1;i <= n;i++) {
            for(int j = 1 ; j <= i && !f[i] ; j++) {
                if(f[j-1] && setWord.contains(s.substring(j-1,i))) f[i] = true;
            }
        }
        return f[n];
    }
}

执行用时为 7ms,其实还可以更快,在底层循环中,我们的目的是在 1~i 中间插一块使其分为两个区间(i==j 时为一个区间),其实可以不用遍历取划分区间,假设给定单词数组里面,单词长度分别是 3,5,6。那么划分 s[i-1,i] 这样的区间是没有意义的,优化代码如下:

class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        Set<String> setWord = new HashSet<>();
        Set<Integer> setLen = new HashSet<>();
        for(String word : wordDict){
            setWord.add(word);// 实现快速查询
            setLen.add(word.length());
        } 
        int n = s.length();
        boolean[] f = new boolean[n+1];
        f[0] = true;
        for(int i = 1 ; i <= n ; i++) {
            for(Integer len : setLen) {
                // 将两个判断起来快一点的条件放前面
                if(i >= len && f[i-len] && setWord.contains(s.substring(i-len, i))) {
                    f[i] = true;break;
                }
            }
        }
        return f[n];
    }
}

线性 dp 与序列 dp 的区别:

  • 线性 dp 强调【状态转移所依赖的前驱状态】是由给定数组所提供的,也就是 f[i][..] 依赖 f[i-1][...] 。限定了线性 dp 复杂度是简单由【状态数量】决定的。
  • 序列 dp 需要结合题意来寻找前驱状态,需要自身寻找拓扑序关系(如本题需要自己枚举的方式寻找左端点,从而找到可转移的前驱状态 f[j-1] 。限定了序列 dp 复杂度是由【状态数 + 找前驱】的复杂度决定的。

学习三叶姐的公众号:【常见题型总结】序列 DP 模板题(总结【线性 DP】和【序列 DP】本质区别)

# 数位 DP

LeetCode 上的 **【902. 最大为 N 的数字组合,难度为【困难】**。

给定一个按 非递减顺序 排列的数字数组 digits 。你可以用任意次数 来写的数字。例如,如果 ,我们可以写数字,如 '13' , '551' , 和 '1351315'

返回 可以生成的小于或等于给定整数 的正整数的个数 。

示例 1:

输入:digits = ["1","3","5","7"], n = 100
输出:20
解释:
可写出的 20 个数字是:
1, 3, 5, 7, 11, 13, 15, 17, 31, 33, 35, 37, 51, 53, 55, 57, 71, 73, 75, 77.

其实就是数组中组合起来小于 n 的数的个数。

class Solution {
    int[] nums;
    int dp(int x) {
        List<Integer> list = new ArrayList<>();
        while (x != 0) {
            list.add(x % 10);
            x /= 10;
        }
        int n = list.size(), m = nums.length, ans = 0;
        // 位数和 x 相同
        for (int i = n - 1, p = 1; i >= 0; i--, p++) {
            int cur = list.get(i);
            int l = 0, r = m - 1;
            while (l < r) {
                int mid = l + r + 1 >> 1;
                if (nums[mid] <= cur) l = mid;
                else r = mid - 1;
            }
            if (nums[r] > cur) {
                break;
            } else if (nums[r] == cur) {
                ans += r * (int) Math.pow(m, (n - p));
                if (i == 0) ans++;
            } else if (nums[r] < cur) {
                ans += (r + 1) * (int) Math.pow(m, (n - p));
                break;
            }
        }
        // 位数比 x 少的
        for (int i = 1, last = 1; i < n; i++) {
            int cur = last * m;
            ans += cur; last = cur;
        }
        return ans;
    }
    public int atMostNGivenDigitSet(String[] digits, int max) {
        int n = digits.length;
        nums = new int[n];
        for (int i = 0; i < n; i++) nums[i] = Integer.parseInt(digits[i]);
        return dp(max);
    }
}

# 状态机 DP

学习三叶姐的文章:【综合笔试题】难度 2.5/5,状态机序列 DP 运用题

LeetCode 上的【1218. 最长定差子序列】,难度为 **「中等」**。

给你一个整数数组 arr 和一个整数 difference ,请你找出并返回 arr 中最长等差子序列的长度,该子序列中相邻元素之间的差等于 difference

子序列 是指在不改变其余元素顺序的情况下,通过删除一些元素或不删除任何元素而从 arr 派生出来的序列。

本题的数据范围在 10^5 之内,朴素的序列 dp 会超时,所以需要使用点技巧,用 HashMap 存储上次 x 出现过的最近的位置:

class Solution {
    public int longestSubsequence(int[] arr, int difference) {
        int n = arr.length, max = 1;
        int[] f = new int[n];
        Map<Integer,Integer> map = new HashMap<>();
        Arrays.fill(f,1);
        // 朴素的序列 dp 会超时
        map.put(arr[0],0);
        for(int i = 1; i < n; i++) {
            int t = arr[i]-difference;
            if(map.containsKey(t)) f[i] = Math.max(f[i], f[map.get(t)]+1);
            max = Math.max(f[i], max);
            map.put(arr[i],i); // 贪心思想
        }
        return max;
    }
}

这和状态机有什么关系呢?

状态机这类问题往往是强调某一个阶段和上一个阶段之间的联系,且一个阶段里面有多种状态(比如说 “有” 和 “无”)。

是不是和序列 DP 很像,其实状态机 DP 就是很常见的 DP 问题,动态规划也就是根据前面有限的状态转移到当前状态,不要被名词吓住了。

这里说一下状态压缩:就是用二进制数来表示动态规划状态转移过程中的状态。因为二进制只有 0 和 1,所以表示的状态一般就是有 / 无,或者完成 / 未完成这种状态。状态压缩的题目,一般都会有非常明显的标志:如果你看到有一个参数的数值小于 20,同时这道题目中有涉及到是否选取、是否使用这样的二元状态,那么这道题目很可能就是一道状态压缩的题目。

状态压缩的解释出自:https://leetcode.cn/problems/stickers-to-spell-word/solution/zhuang-tai-ya-suo-dpji-you-hua-by-lucifer1004/

# LCS 模板

LCS 就是最长公共子序列,LeetCode 上的 **【1143. 最长公共子序列】,难度为【中等】**。

给定两个字符串,找出最长公共子序列。

LCS 这类题,都使用如下 **【状态定义】**:

f[i][j] 表示考虑 s1i 个字符和考虑 s2j 个字符,形成的最长公共子序列长度。有了【状态定义】,就可以得到【转移方程】:

  • 如果 s1[i] == s2[j]f[i][j] = f[i-1][j-1]+1

  • 如果 s1[i] != s2[j]f[i][j] = Math.max(f[i-1][j],f[i][j-1])

编码细节:可以在两个字符串前面加上一个空格,这样就可以将 f[i][0]f[0][j] 初始化为 1。

public int fun(String s1, String s2) {
    int n = s1.length(), m = s2.length();
    s1 = " " + s1;s2 = " " + s2;
    
    for(int i = 0 ; i <= n ; i++) f[i][0] = 1;
    for(int j = 0 ; j <= m ; j++) f[0][j] = 1;
    
    for(int i = 1 ; i <= n ;i++) {
        for(int j = 1 ; j <= m ; j++) {
            if(s1[i] == s2[j]) f[i][j] = f[i-1][j-1] + 1;
            else f[i][j] = Math.max(f[i-1][j], f[i][j-1]);
        }
    }
    return f[i][j] - 1;
}

如何通过 f 数组构造出最长公共子序列:

StringBuffer sb = new StringBuffer();
int i = n , j = m;
while(i > 0 && j > 0) {
    if(s1[i] == s2[j]) {
        sb.append(s1[i]);
        i--;j--;
    } else if(f[i][j] == f[i-1][j]) {
        //s1 [i] 没有在 s1 (0,i),s2 (0,j) 的最长公共子序列中
        i--;
    } else j--;
}
return sb.reverse().toString();

类似题还有 **【1092. 最短公共超序列】**。

学习三叶姐的公众号:【面试高频题】难度 1.5/5,LCS 模板题

# 经典题目复现

# 最长递增子序列

该系列题在牛客分别是三种要求:

  • 找最长上升子序列,时间复杂度 O (n^2)
  • 找最长上升子序列,时间复杂度 O (nlogn)
  • 最长上升子序列可能有多个,请返回字典序最小的那个子序列,时间复杂度 O (nlogn)

第三种比二种多的其实就是保存状态,第二种最重要的就是二分查找,我们先看一下第二种的代码

/* 有些是要求给出最长的子序列,有些要求给出长度即可 */
public int LIS(int[] arr) {
    int n = arr.lenght, l = 0, r = 0, riht = 0, m = 0, res = 1;
    int[] ends = new int[n];
    ends[0] = arr[0];
    
    for(int i = 1; i < n; i++) {
        l = 0; r = right; // 开始二分
        while(l <= r) {
            m = (l + r) >> 1;
            if(arr[i] > ends[m]) l = m + 1; // 肯定不能覆盖比自己小的
            else r = m - 1; // 考虑 1,3 插入 2 的情况,尽管 3 暂时被 m-1 了,但是 l 还会加回来
        }
        //l 是可以直接覆盖的
    	right = Math.max(right, l);
        res = Math.max(res, l+1);
        ends[l] = arr[i];
    }
    return res;
}

再来看第三种,主要就是保存每次更新的状态

public int[] LIS(int[] arr) {
    int n = arr.length, l = 0, r = 0, right = 0, m = 0;
    int[] ends = new int[n];
    List<Integer>[] status = new List[n];
    
    ends[0] = arr[0];
    status[0] = new ArrayList<>();
    status[0].add(arr[0]);
    
    for(int i = 1; i < n; i++) {
        l = 0; r = right;
        
        while(l <= r) {
            m = (l + r) >> 1;
            if(ends[m] < arr[i]) l = m+1;
            else r = m-1;
        }
        //l 是绝对可以覆盖的
        if(l == 0) {
            status[0].set(0, arr[i]);
        } else {
            ArrayList<Integer> t = new ArrayList<>(status[l-1]);
            t.add(arr[i]);
            right = Math.max(right, l);
            status[l] = t;
        }
        ends[l] = arr[i];
    }
    
    int[] res = new int[right+1];
    for(int i = 0; i <= right; i++) {
        res[i] = status[right].get(i);
    }
    return res;
}