动态规划专题

参考:分享丨【算法题单】动态规划(入门/背包/划分/状态机/区间/状压/数位/树形/优化)- 讨论 - 力扣(LeetCode)

一、入门 DP

二、网格图DP

        对于一些二维 DP(例如背包、最长公共子序列),如果把 DP 矩阵画出来,其实状态转移可以视作在网格图上的移动。所以在学习相对更抽象的二维 DP 之前,做一些形象的网格图 DP 会让后续的学习更轻松(比如 0-1 背包的空间优化写法为什么要倒序遍历)。

2.1 基础

Leetcode 931. 下降路径最小和

思路:

        f(row, col)表示前row行,最终停留在(row, col)位置处的最小路径值,这个值是后续行的子问题。同样的,f(row - 1, col - 1)、f(row - 1, col)、f(row - 1, col + 1)也是f(row, col)的子问题。即状态转移公式为

         f(row, col) = min(f(row - 1, col - 1),f(row - 1, col),f(row - 1, col + 1)) + mat[row][col]。

        在实现的时候注意细节,也就是边界的问题,col-1有可能会越界,所以我们统一对col列进行+1的映射。所以此时的状态转移公式为:

          f(row, col+1)=min(f(row - 1, col),f(row - 1, col + 1),f(row - 1, col + 2))+mat[row][col]。

        初始化的细节,f[0][c+1] 初始化为mat[0][c],其余位置都设置为正无穷。

Code:

class Solution {
public:
    int minFallingPathSum(vector<vector<int>>& matrix) {
        int n = matrix.size(), f[n][n + 2];
        memset(f, 0x3f, sizeof f);
        for (int i = 0; i < n; i ++)   
            f[0][i + 1] = matrix[0][i];
        for (int r = 1; r < n; r ++) 
            for (int c = 0; c < n; c ++)
                f[r][c + 1] = min(min(f[r - 1][c], f[r - 1][c + 1]), f[r - 1][c + 2]) + matrix[r][c];
        return *min_element(f[n - 1] + 1, f[n - 1] + 1 + n);    // 最后一行的最小元素

    }
};

2.2 进阶

Leetcode 3363. 最多可收集的水果数目

思路:

        首先根据每个人恰好移动n-1次,可以发现中间的人只能走对角线。

        剩余两个人实际上是一个问题,可以通过翻转转化为同一个dp的解法,思路和Leetcode 931类似。只不过,需要注意,i和j的取值范围,简单来说就是下面的限制。

Code:

class Solution {
public:
    int maxCollectedFruits(vector<vector<int>>& fruits) {
        int n = fruits.size();
        auto dp = [&] () -> int {
            vector f(n - 1, vector<int> (n + 1));
            f[0][n - 1] = fruits[0][n - 1];
            for (int i = 1; i < n - 1; i ++) 
                for (int j = max(n - 1 - i, i + 1); j < n; j ++)
                    f[i][j] = max({f[i - 1][j - 1], f[i - 1][j], f[i - 1][j + 1]}) + fruits[i][j];
            return f[n - 2][n - 1];
        };
        int res = 0;
        // 从(0,0)出发的人->只能走对角线才能满足使用n-1步到达目标地点。
        for (int i = 0; i < n; i ++)    res += fruits[i][i];
        // 从(0,n-1)出发的人
        res += dp();
        // 从(n - 1, 0)的人(将下三角翻转到上三角中,可以复用dp函数)
        for (int i = 0; i < n; i ++)
            for (int j = 0; j < i; j ++)
                fruits[j][i] = fruits[i][j];
        return res + dp();
    }
};

三、背包

3.1 0-1背包

        任务限制:每个物品只能选一次。

0-1背包模板及推导:

const int N = 1010;
int v[N], w[N];
int f[N];

int main()
{
    int n, V; cin >> n >> V;
    for (int i = 1; i <= n ; i ++) cin >> v[i] >> w[i]; // 1 ~ n 读入
    for(int i = 1; i <= n ; i ++) 			// 1 ~ n
        for(int j = V; j >= v[i]; j --) 	// V(总体积) ~ v[i]  均为闭区间
            f[j]= max(f[j], f[j - v[i]] + w[i]); 
    cout << f[V] << endl;
    return 0;
}

Leetcode 416. 分割等和子集

示例 1:

输入:nums = [1,5,11,5] 输出: true   解释:数组可以分割成 [1, 5, 5] 和 [11] 

数据范围:1 <= nums.length <= 200;1 <= nums[i] <= 100        

思路:

        设 nums 的元素和为 s,如果 s 是奇数, s/2不是整数,直接返回 false。

        如果 s 是偶数,问题相当于:能否从 nums 中选出一个子序列,其元素和恰好等于  s/2?这可以用「恰好装满」型 0-1 背包解决。

        状态表示:定义 dfs(i,j) 表示能否从 nums[0] 到 nums[i] 中选出一个和恰好等于s/2的子序列。

记忆化搜索写法:

class Solution {
public:
    bool canPartition(vector<int>& nums) {  // 恰好型0-1背包
        int s = reduce(nums.begin(), nums.end());
        if (s % 2)  return false;
        int n = nums.size();
        vector<vector<int>> memo(n, vector<int> (s / 2 + 1, -1));    // -1表示没用过
        auto dfs = [&](this auto&& dfs, int i, int j)->bool{
            if (i < 0)  return j == 0;
            int& res = memo[i][j];  // 注意这里是引用
            if (res != -1)  return res;  
            return res = (j >= nums[i] && dfs(i - 1, j - nums[i]) || dfs(i - 1, j));
        };
        return dfs(n - 1, s / 2);
    }
};

空间优化递推写法:

 关注初始值的含义,f[i][j]表示是否能用前i个数字表示出数字j。这里面省略了第一维,相当于f[0][0]不用数字表示出数字0,这个是成立的,故初始化f[0] = true;

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        // 空间优化dp写法   
        int s = reduce(nums.begin(), nums.end());
        if (s % 2)  return false;
        int V = s / 2;  // 背包容量
        vector<bool> f(V + 1, false);
        f[0] = true;
        for (int x: nums)
            for (int j = V; j >= x; j --) 
                f[j] = f[j] || f[j - x];
        return f[V];
    }
};

Leetcode 494. 目标和

 思路:关键在于想到转化为背包问题(求方案数)

从统计的角度来看问题。

Code:(未优化空间版)

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int s = reduce(nums.begin(), nums.end()) - abs(target);
        if (s < 0 || s % 2) {
            return 0;
        }
        int m = s / 2; // 背包容量
        int n = nums.size();
        vector f(n + 1, vector<int>(m + 1));
        f[0][0] = 1;

        for (int i = 0; i < n; i++) {
            for (int c = 0; c <= m; c++) {
                if (c < nums[i]) {
                    f[i + 1][c] = f[i][c]; // 只能不选
                } else {
                    f[i + 1][c] = f[i][c] + f[i][c - nums[i]]; // 不选 + 选
                }
            }
        }
        return f[n][m];
    }
};

Code:(空间优化递推)

        注意这边的初始化,f[0]表示f[0][0],即用空间=0的背包装前0个东西的方案数,就是一个!!!

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int s = reduce(nums.begin(), nums.end()) - abs(target);
        if (s < 0 || s % 2) return 0;
        int m = s / 2;  // 背包容量
        vector<int> f(m + 1);
        f[0] = 1;       // 表示方案数(啥都不选就是)
        for (int x: nums)
            for (int c = m; c >= x; c --)
                f[c] += f[c - x];
        return f[m];
    }
};

Leetcode 2787. 将一个数字表示成幂的和的方案数

思路:

        把 n 看成背包容量,1^x,2^x,3^x,… 看成物品,本题是恰好装满型 0-1 背包的方案数。

        代码实现时,由于最坏情况 n=300,x=1 的答案没有超过 64 位整数的最大值,可以在计算结束后再取模。(求方案数-不选的话方案数是不会增加的,写法同494。)

Code:        同样注意这里的初始化-f[0]的含义!

class Solution {
public:
    int numberOfWays(int n, int x) {
        vector<long long> f(n + 1);
        f[0] = 1;
        // 数据范围小,pow的计算结果是准确
        for (int i = 1; pow(i, x) <= n; i ++) {
            int v = pow(i, x);
            for (int s = n; s >= v; s --)
                f[s] += f[s - v];
        }
        return f[n] % int(1e9 + 7);
    }
};

3.2 完全背包

3.3 多重背包(选做)

3.4 分组背包

四.经典线性DP

Leetcode 3177 求出最长好子序列 II

题型分析: 

 (1)记忆化搜索(能过小数据)时间复杂度为O(n²k)。母题来源于LIS

class Solution {
public:
    // 状态数是n·k,状态专题的时间复杂度是n,所以总的时间复杂度是O(n²·k)
    int maximumLength(vector<int>& nums, int k) {
        int n = nums.size();
        int f[n][k + 1];
        memset(f, -1, sizeof f);
        // dp(i, x)表示以nums[i]结尾的,有至多x对相邻元素不同 的 最长子序列的长度
        function<int(int, int)> dp = [&](int i, int x)->int{
            if(x < 0)   return 0;
            if(i == 0)  return 1;
            if(f[i][x] != -1)   return f[i][x];
            int res = INT_MIN;
            for(int j = 0; j < i; j ++){
                if(nums[j] == nums[i])
                    res = max(res, dp(j, x) + 1);
                else
                    res = max(res, dp(j, x - 1) + 1);
            }
            return f[i][x] = res;
        };
        int ans = 0;
        for(int i = 0; i < n; i ++)
            ans = max(ans, dp(i, k));
        return ans;
    }
};

 (2)优化:从值域角度考虑:时间复杂度为O(n·k)

class Solution {
public:
    int maximumLength(vector<int>& nums, int k) {
        // 按照出现的值进行优化, 而非下标(状态转移必然要O(n)),由于值域1e9,所以用hash
        unordered_map<int, vector<int>> fs; 
        vector<int> mx(k + 1, 0);  
        // 为了实现O(1)的状态转移,需要用O(1)的时间知道f[y][j - 1] for y in set的最大值,用mx维护
        for(int x: nums){
            auto& f = fs[x];
            f.resize(k + 1);
            for(int j = k; j >= 0; j --){
                // 这边j要倒序(思想类似于背包),防止污染上一轮的mx[j - 1]
                f[j] += 1;  // 必定会+1(把x放到x结尾的子序列的末尾)sit1:
                if(j > 0)   f[j] = max(f[j], mx[j - 1] + 1);    // sit2: 加到y结尾的子序列末尾(y!=x)
                // mx[j - 1]就表示了max(f[y][j] for y in set)这里y是可以等于x的,如果也是mx[j -1]的子序列就是与x相等的
                //那么mx[j-1]就变成了f[x][j - 1],相较于f[x][j]甚至条件更严格,所以只会比上面的f[x][j]小,不影响答案。
                mx[j] = max(mx[j], f[j]);   // 及时比较更新
            }
        }
        return mx[k];
    }
};

Leetcode 198. 打家劫舍

法一:记忆化搜索(自顶向下)

时间与空间均为O(n)

class Solution {
public:
    int rob(vector<int>& nums) {
        int n = nums.size();
        vector<int> f(n, -1);
        function<int(int)> dp = [&](int i)-> int{
            if(i < 0)   return 0;
            if(f[i] != -1)  return f[i];
            int res = 0;
            res = max(dp(i - 1), dp(i - 2) + nums[i]);
            f[i] = res;
            return res;
        };
        return dp(n - 1);
    }
};

法二:动态规划&&空间优化

dp: 时间与空间均为O(n)

class Solution {
public:
    int rob(vector<int>& nums) {
        int n = nums.size();
        vector<int> f(n + 2, 0);
        for(int i = 0; i < n; i ++)
            f[i + 2] = max(f[i + 1], nums[i] + f[i]);
        return f[n + 1];
    }
};

空间优化(滚动数组)--空间复杂度优化到O(1)

class Solution {
public:
    int rob(vector<int>& nums) {
        int n = nums.size();
        int f1 = 0, f2 = 0, f3 = 0;
        for(int i = 0; i < n; i ++){
            f3 = max(f2, nums[i] + f1);
            int t = f2;
            f2 = f3;
            f1 = t;
        }
        return f2;
    }
};

Leetcode 213. 打家劫舍 II

Leetcode 337. 打家劫舍 III

Leetcode 2560. 打家劫舍 IV

Leetcode 2140. 解决智力问题

这题区别于打家劫舍198,“记忆化搜索”从左向右去搜。

class Solution {
public:
    long long mostPoints(vector<vector<int>>& questions) {
        int n = questions.size();
        vector<long long> f(n, -1);
        auto dp = [&](this auto&& dp, int i)->long long{
            if(i >= n)  return 0;
            if(f[i] != -1)  return f[i];
            return f[i] = max(dp(i + 1), dp(i + questions[i][1] + 1) + questions[i][0]);   
        };  // 注意:这里写function可能会被卡时间
        return dp(0);
    }
};

如果都需要写递归,如何确定自左向右还是自右向左?

Leetcode 121. 买卖股票的最佳时机

Leetcode 122. 买卖股票的最佳时机 II

Leetcode 123. 买卖股票的最佳时机 III

Leetcode 188. 买卖股票的最佳时机 IV

Leetcode 53. 最大子数组和

方法一:DP

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int n = nums.size();
        vector<int> f(n + 1, -2e9);
        if(nums[0] > -2e9) f[0] = nums[0];
        for(int i = 1; i < n; i ++){
            f[i] = max(nums[i], f[i - 1] + nums[i]);
        }
        int res = -2e9;
        for(int i = 0; i < n; i ++) res = max(res, f[i]);
        return res;
    }
};

方法二:前缀和

 转换为Leetcode 121. 买卖股票的最佳时机

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int n = nums.size(), res = -2e9;
        int min_pre_sum = 0;
        int pre_sum = 0;
        for(int i = 0; i < n; i ++){
            pre_sum += nums[i];     // 维护出前缀和
            res = max(res, pre_sum - min_pre_sum);      // 减去前缀和的最小值
            min_pre_sum = min(min_pre_sum, pre_sum);    // 维护前缀和的最小值
        }
        return res;
    }
};

Leetcode 3129. 找出所有稳定的二进制数组 I/II

方法一:记忆化搜索

class Solution {
public: 
    int f[1010][1010][2];
    int numberOfStableArrays(int zero, int one, int limit) {
        int MOD = 1e9 + 7;
        memset(f, -1, sizeof f);
        // dp(i,j,k)表示用i个0个和j个1构造合法方案数,其中第i+j的位置填k
        function<int(int, int, int)> dp = [&](int i, int j, int k) -> int{
            if(i < 0 || j < 0)  return 0;
            if(i == 0)  return k == 1 && j <= limit;
            if(j == 0)  return k == 0 && i <= limit;
            if(f[i][j][k] != -1)    return f[i][j][k];
            int res = 0;    // 方案数
            if(k == 0)      // +MOD保证答案非负
                res = ((long long)dp(i - 1, j, 0) + dp(i - 1, j, 1) - dp(i - limit - 1, j, 1) + MOD) % MOD;
            else    
                res = ((long long)dp(i, j - 1, 0) + dp(i, j - 1, 1) - dp(i, j - limit - 1, 0) + MOD) % MOD;
            f[i][j][k] = res;
            return res;
        };
        return (dp(zero, one, 1) + dp(zero, one, 0)) % MOD;
    }
};

8/7日每日一题,错因:当j>limit的时候显然是错误情况需要返回0。

在下面的dp方法中,体现在

方法二:DP

由记忆化搜索1:1转化来,主要注意:

        ①初始条件:分别进行初始化,zero和limit取min(不一定够用)

        ②i - limit - 1可能会越界,越界的部分都是0,这里用问号冒号表达式处理了

        ③内部计算可能会超出int范围,所有用longlong, +MOD )%MOD

class Solution {
public:
    int MOD = 1e9 + 7;
    int f[1010][1010][2] = {0};
    int numberOfStableArrays(int zero, int one, int limit) {
        // 初始化
        for(int i = 1; i <= min(zero, limit); i ++)
            f[i][0][0] = 1;
        for(int i = 1; i <= min(one, limit); i ++)
            f[0][i][1] = 1;
        // 递推
        for(int i = 1; i <= zero; i ++)
            for(int j = 1; j <= one; j ++){
                f[i][j][0] = ((long long)f[i - 1][j][0] + f[i - 1][j][1] - (i >= limit + 1? f[i - limit - 1][j][1]: 0) + MOD) % MOD;
                f[i][j][1] = ((long long)f[i][j - 1][0] + f[i][j - 1][1] - (j >= limit + 1? f[i][j - limit - 1][0]: 0) + MOD) % MOD;
            }
        return ((long long)f[zero][one][0] + f[zero][one][1]) % MOD;
    }
};

Leetcode 1035. 不相交的线

思考过程&题解:

class Solution {
public:
    int f[510][510] = {0};
    int maxUncrossedLines(vector<int>& nums1, vector<int>& nums2) {
        int n = nums1.size(), m = nums2.size();
        memset(f, -1, sizeof f);
        function<int(int, int)> dp = [&](int i, int j) -> int{
            if(i < 0 || j < 0)    return 0;   // 有一端没有数字就连接不了
            if(f[i][j] != -1)   return f[i][j];
            int res = 0;
            if(nums1[i] == nums2[j]) res = dp(i - 1, j - 1) + 1;
            else    res = max(dp(i - 1, j), dp(i, j - 1));
            f[i][j] = res;
            return res;
        };
        return dp(n - 1, m - 1);
    }
};

Leetcode 552. 学生出勤记录 II

思路:

代码:

class Solution {
public:
    const static int MOD = 1e9 + 7;
    int checkRecord(int n) {
        int f[n + 2][3][5];
        memset(f, -1, sizeof f);
        auto dp = [&](auto&& dp, int i, int j, int k) -> long long{
            if(j > 1 || k >= 3)    return 0;    // 注意顺序, 有可能是i == 0且j和k非法,那应该算是非法状态返回0,如果与下面这行互换,则会返回1
            if(i == 0)  return 1;               // 其他解决办法: 特判当j == 0的时候才加dp(i - 1, j + 1, 0),k同理
            if(f[i][j][k] != -1)    return f[i][j][k];
            f[i][j][k] = ((long long)dp(dp, i - 1, j, 0) + dp(dp, i - 1, j + 1, 0) + dp(dp, i - 1, j, k + 1)) % MOD;
            return f[i][j][k];
        };
        return dp(dp, n, 0, 0) % MOD;
    }
};

Leetcode 3154. 到达第 K 级台阶的方案数

dp思路(核心在于寻找限制/子问题)

class Solution {
public:
    int waysToReachStair(int k) {
        unordered_map<long long, int> memo;
        auto dfs = [&](auto&& dfs, int i, int j, bool pre_down) -> int {
            if (i > k + 1) { // 无法到达终点 k
                return 0;
            }
            // 把状态 (i, j, pre_down) 压缩成一个 long long
            long long mask = (long long) i << 32 | j << 1 | pre_down;
            if (memo.contains(mask)) { // 之前算过了
                return memo[mask];
            }
            int res = i == k;
            res += dfs(dfs, i + (1 << j), j + 1, false); // 操作二
            if (!pre_down) {
                res += dfs(dfs, i - 1, j, true); // 操作一
            }
            return memo[mask] = res; // 记忆化
        };
        return dfs(dfs, 1, 0, false);
    }
};

组合数学方法见数学专题。

Leetcode 2708. 一个小组的最大实力值

class Solution {
public:
    // maxx = max({maxx, nums[i], maxx * nums[i], minn * nums[i]});
    // minn = min({minn, nums[i], minn * nums[i], maxx * nums[i]});     两个式子要同时计算
    long long maxStrength(vector<int>& nums) {
        long long maxx = nums[0], minn = nums[0];
        for(int i = 1; i < nums.size(); i ++){
            long long tmp = maxx;
            long long x = nums[i];
            maxx = max({maxx, x, maxx * x, minn * x});
            minn = min({minn, x, minn * x, tmp * x});
        }
        return maxx;
    }
};

如果将子序列修改为子数组,那么变为下面152题。

Leetcode 152. 乘积最大子数组

 思路分析:

 关注这里面在维护当前最大值和当前最小值的时候,nums[i]是一定会在的(而非选or不选)。

class Solution {
public:
    int maxProduct(vector<int>& nums) {
        int res = INT_MIN, curmax = 1, curmin = 1;
        for(int i = 0; i < nums.size(); i ++){
            if(nums[i] < 0) swap(curmax, curmin);
            curmax = max(nums[i], curmax * nums[i]);
            curmin = min(nums[i], curmin * nums[i]);
            res = max(res, curmax);
        }
        return res;
    }
};

X.1 最长公共子序列(LCS)

X.2 最长递归子序列(LIS)

Leetcode 368. 最大整除子集

排个序,相邻元素间的限制关系。

class Solution {
public:
    vector<int> largestDivisibleSubset(vector<int>& nums) {
        sort(nums.begin(), nums.end()); //两个条件,不好处理。可以把nums排序(从小到大)
        // 从(排序后的)nums 中选一个子序列,在子序列中,任意相邻的两个数,右边的数一定是左边的数的倍数。
        int n = nums.size();
        vector<int> memo(n), from_(n, -1);
        // from 用于生成具体的生成方案
        auto dfs = [&](this auto&& dfs, int i) -> int{
            int& res = memo[i]; // 注意是引用
            if (res)    return res;
            for (int j = 0; j < i; j ++){
                if (nums[i] % nums[j])  continue;
                int f = dfs(j);
                if (f > res)
                    res = f, from_[i] = j;   // 记录最佳转移来源
            }
            res ++;    // 加上nums[i]自己
            return res;
        };
        int max_f = 0, max_i = 0;
        for (int i = 0; i < n; i ++) {
            int f = dfs(i);
            if (f > max_f)
                max_f = f, max_i = i;   // 最长合法子序列的最后一个数的下标
        }
        vector<int> path;
        for (int i = max_i; i >= 0; i = from_[i]) 
            path.push_back(nums[i]);
        return path;    // 任意顺序返回均可
    }
};

五.划分型DP

5.1 判定能否划分

5.2 计算划分最优值

5.3 约束划分个数

(固定右,枚举左)

Leetcode 3117. 划分数组得到最小的值之和

寻找子问题

class Solution {
public:
    
    int minimumValueSum(vector<int>& nums, vector<int>& andValues) {
        const int N = INT_MAX / 2;      // 防止下面+nums[i]溢出(func:让非法状态作废)
        unordered_map<long long, int> f;
        int n = nums.size(), m = andValues.size();
        // 维度i:访问数组的第i个元素 维度j:andValues数组的第j个元素 维度and_当前组前面已经&的结果(单调不升)
        auto dp = [&](auto&& dp, int i, int j, int and_) -> int {
            if(n - i < m - j)   return N;   // 不够划分的
            if(j == m)  return i == n ? 0: N;
            
            int ad = and_ & nums[i];    // 先算新的and值,才有state!!!
            long long st = (long long) i << 36 | (long long) j << 32 | ad;
            if(f.count(st)) return f[st];
            if(ad < andValues[j])   return N;           // 剪枝, 往后再运算不可能得到andValues[j]了
            int res = dp(dp, i + 1, j, ad);             // 不选(即继续往当前子数组添加数,而非重新开一个子数组)
            if(ad == andValues[j])                      // and值重置为-1为下一各子数组的情况做准备
                res = min(res, dp(dp, i + 1, j + 1, -1) + nums[i]);    // 选择nums[i]作为子数组的最后一个元素
            return f[st] = res;                         // 记忆化
        };
        int ans = dp(dp, 0, 0, -1);
        return ans < N ? ans: -1;
    }
};

5.4 不相交区间

六、状态机 DP

七、其他线性 DP

7.1 一维DP

Leetcode 2901. 最长相邻不相等子序列 II

 思路:类似于LIS,只不过为了输出最后的路径,选用后缀的写法。

Code:

class Solution {
    bool ok(string&s , string& t) {
        if (s.size() != t.size())   return false;
        bool diff = false;
        for (int i = 0; i < s.size(); i ++) {
            if (s[i] != t[i]) {
                if (diff)   return false;   // 汉明距离>1
                diff = true;
            }
        }return diff;
    }
public:
    vector<string> getWordsInLongestSubsequence(vector<string>& words, vector<int>& groups) {
        int n = words.size();
        vector<int> f(n), from(n);
        int max_i = n - 1;
        for (int i = n - 1; i >= 0; i --) { // 为什么定义成后缀?方便后面输出具体方案。
            for (int j = i + 1; j < n; j ++) {
                if (f[j] > f[i] && groups[j] != groups[i] && ok(words[i], words[j]))
                    f[i] = f[j], from[i] = j;
            }f[i] ++;   // +1写在这
            if (f[i] > f[max_i])    max_i = i;
        }
        int i = max_i, m = f[i];
        vector<string> res(m);
        for (int k = 0; k < m; k ++) 
            res[k] = words[i], i = from[i];
        return res;
    }
};

7.2 不相交区间

Leetcode 2008. 出租车的最大盈利

 

思路:

        寻找子问题,递归拆解。假设答案n = 9,那么答案是dfs(9)。在地点i=9,可以选择由i-1的状态直接转移过来(不接人),或者说不能接。当然也可以从 i - len的地点刚好送过来,构成转移,然而刚好送到终点i=9可能有很多个ride区间,所以需要进行分组统计以end为key的start和报酬对。(这里类似于1353题,)

Code:

class Solution {
public:
    long long maxTaxiEarnings(int n, vector<vector<int>>& rides) {
        vector<vector<pair<int, int>>> groups(n + 1);
        for (auto& r: rides) {
            int start = r[0], end = r[1], tip = r[2];
            groups[end].push_back({start, end - start + tip});
        }
        vector<long long> f(n + 1);     // f[1] = 0 初始状态
        for (int i = 2; i <= n; i ++) {
            f[i] = f[i - 1];            //  不选当前end=i的ride或者没有此ride
            for (auto& [s, t]: groups[i])     // 如果要选,从子问题曲线救国
                f[i] = max(f[i], f[s] + t);
        } return f[n];
    }
};

Leetcode 1235. 规划兼职工作

思考:“动态规划 + 二分查找”

         不同于2008题,这题最大的end时间在1e9,但开不了这么大的空间,所以引出2008题的第二种解法“动态规划+二分”。

        还是从选还是不选的角度来考虑。f(i)表示使用前1-i个区间所得到的最大利益。如果不选择第i个区间,直接从f(i - 1)转移来;如果选择第i个区间,则转移前驱区间j的end_time需要小于第i个区间的start_time,此时f(i) = f(j) + profit(i)。

        此处的区间 j 可以通过二分查找找到小于等于区间i的start_time的最大endtime区间。

Code:

class Solution {
public:
    int jobScheduling(vector<int>& startTime, vector<int>& endTime, vector<int>& profit) {
        int n = startTime.size();
        vector<array<int, 3>> jobs(n);
        for (int i = 0; i < n; i ++) 
            jobs[i] = {endTime[i], startTime[i], profit[i]};
        // 按照end时间从小到大排序。
        sort(jobs.begin(), jobs.end(), [](auto& a, auto& b){return a[0] < b[0];});
        vector<int> f(n + 1);
        for (int i = 0; i < n; i ++) {
            // 默认比较第一个元素,为显示声明的元素为0
            int j = upper_bound(jobs.begin(), jobs.begin() + i, array<int, 3>{jobs[i][1], INT_MAX}) - jobs.begin();
            f[i + 1] = max(f[i], f[j] + jobs[i][2]);    
            // 状态转移中,为什么是 j 不是 j+1:上面算的是 > st,-1 后得到 <= st,但由于还要 +1,抵消了
            // 即f(i) = max(f(i - 1), f(j - 1) + jobs[i - 1][2]); 
        }return f[n];
    }
};

Leetcode 1751. 最多可以参加的会议数目 II

7.3 子数组DP

7.4 合法子序列DP

Leetcode 3202. 找出有效子序列的最大长度 II

思路:可以从k=2开始思考,乍一看有点像LIS(需要找到一个比前面的数更大的,去接上去)。这边实际上只要维护前面同余的两个数的值即可完成转移。具体分析如下:

①问题转化:

②本题转移公式:

Code:

class Solution {
public:
    int maximumLength(vector<int>& nums, int k) {
        int res = 0;
        vector f(k, vector<int>(k));    // 注意二维数组的写法
        for (int x: nums) {
            x %= k;
            for (int y = 0; y < k; y ++) {
                f[y][x] = f[x][y] + 1;
                res = max(res, f[y][x]);
            }
        }return res;
    }
};

7.5 子矩阵DP

7.6 多维DP

Leetcode 1900. 最佳运动员的比拼回合

思考:

         我们可以通过翻转等操作,保证a的左侧人数比b右侧人更少。(题目保证a的位置在b左侧)。

剩下只需要给b的位置分类:①b在中轴线左侧②右侧。

Code:

class Solution {
public:
    vector<int> earliestAndLatest(int n, int firstPlayer, int secondPlayer) {
        vector memo(n + 1, vector(n + 1, vector<pair<int, int>>(n + 1)));
        auto dfs = [&] (this auto&& dfs, int n, int first, int second) -> pair<int, int> {
            // a和b相遇的时候
            if (first + second == n + 1)    return {1, 1};
            // 保证a左边人数比b右边人数少,题目已经保证first < second
            if (first + second > n + 1)
                tie(first,second) = pair(n + 1 - second, n + 1 -first); // 翻转一下
            auto& res = memo[n][first][second];
            if (res.first)  return res;
            
            int m = (n + 1) / 2;    // 下一回合的人数
            // ab之间保留 [mid_mid, max_mid]人
            int min_mid = second <= m ? 0: second - n / 2 - 1;
            int max_mid = second <= m ? second - first: m - first;
            int earliest = INT_MAX;
            int latest = 0;

            for (int left = 0; left < first; left ++) { // 枚举A左侧保留left个人
                for (int mid = min_mid; mid < max_mid; mid ++) { // 枚举ab之间保留mid个人
                    auto [e, l] = dfs(m, left + 1, left + mid + 2);
                    earliest = min(earliest, e);
                    latest = max(latest, l);
                }
            }
            return res = {earliest + 1, latest + 1};    // 加上当前的回合。
        };
        auto [earliest, latest] = dfs(n, firstPlayer, secondPlayer);
        return {earliest, latest};
    }
};

八、区间 DP

九、状态压缩 DP(状压 DP)

十.数位DP

下面的题目使用 数位DP V1.0 板子

只支持计算 [0, n]的数位dp的结果。若要计算[low, high]之间的数位dp的结果,那就要用类似前缀和的计算方式:f(high) - f(low - 1)。如果high和low超过int范围,还得用字符串表示

Leetcode 2376. 统计特殊整数(模板) 

class Solution {
public:
    int countSpecialNumbers(int n) {
        string s = to_string(n);
        int m = s.length();
        int f[m][1 << 10];          // 备忘录
        memset(f, -1, sizeof f);    // 初始化为-1,-1即表示没有搜索
        function<int(int, int, bool, bool)> dp = [&] (int i, int mask, bool is_lim, bool is_num)->int{
            if(i == m)  return is_num;      // is_num为true则代表搜索到了合法的数字
            if(!is_lim && is_num && f[i][mask] != -1)    return f[i][mask]; // 一定注意!is_lim这个限制

            int res = 0;
            // sit 1: 前面都没有填数字,那么这一位也可以不填数字
            if(!is_num) 
                res = dp(i + 1, mask, false, false);
            // sit 2: 枚举每一种可能填写的位数
            int upbound = is_lim ? s[i] - '0' : 9;
            for(int j = 1 - is_num; j <= upbound; j ++)
                if ((mask >> j & 1) == 0)   // 要填的这个数字不在mask(集合)中
                    res += dp(i + 1, mask | (1 << j), is_lim && j == upbound, true);
            // 将答案记忆化
            if(!is_lim && is_num)   // 最大值和最小值只会计算一次,无需记忆化
                f[i][mask] = res;
            return res;
        };
        return dp(0, 0, true, false);
    }
};

Leetcode 902. 最大为 N 的数字组合

 直接修改一下板子就可以。

class Solution {
public:
    int atMostNGivenDigitSet(vector<string>& digits, int n) {
        string s = to_string(n);
        int m = s.length(), f[m];
        memset(f, -1, sizeof f);

        function<int(int, bool, bool)>  dp = [&](int i, bool is_lim, bool is_num)->int{
            if (i == m) return is_num;
            if (!is_lim && is_num && f[i] != -1)  return f[i];

            int res = 0;
            // sit 1:
            if (!is_num)    res = dp(i + 1, false, false);    
            // sit 2:
            char up = is_lim ? s[i] : '9';
            for (int j = 0; j < digits.size() && digits[j][0] <= up; j ++)
                res += dp(i + 1, digits[j][0] == s[i] && is_lim, true);
            if (!is_lim && is_num)  f[i] = res;
            return res;
        };
        return dp(0, true, false);  // true表示得按照限制去执行
    }
};

Leetcode 1012. 至少有 1 位重复的数字

“至少”这种问题-->正难则反,转换成求无重复数字的个数。答案等于 n 减去无重复数字的个数。(leetcode 2376)

class Solution {
public:
    int numDupDigitsAtMostN(int n) {
        string s = to_string(n);
        int m = s.length(), f[m][1 << 10];
        memset(f, -1, sizeof(f));
        function<int(int, int, bool, bool)> dp = [&](int i, int mask, bool is_lim, bool is_num)->int{
            if (i == m) return is_num;
            if (!is_lim && is_num && f[i][mask] != -1)  return f[i][mask];

            int res = 0;
            if (!is_num)    res = dp(i + 1, mask, false, false); // 本位不选
            int up = is_lim ? s[i] - '0' : 9;
            for(int j = 1 - is_num; j <= up; j ++){
                if (((mask >> j) & 1) == 0)
                    res += dp(i + 1, mask | (1 << j), j == up && is_lim, true);
            }
            if (!is_lim && is_num)  f[i][mask] = res;
            return res;
        };
        return n - dp(0, 0, true, false);   // is_lim设置为true时表示第一个位需要受到s[0]的限制
    }
};

Leetcode 600. 不含连续1的非负整数

        前几题是直接在位置上填十进制数,这边实际上就是改成填写二进制数即可。

class Solution {
public:
    // 多少个整数的二进制表示中不存在连续的1?
    int findIntegers(int n) {
        int m = __lg(n);        // 二进制表示最高位的位数在哪(从0开始)
        int f[m + 1][2];        // 第二维的状态只有2个, 第一维代表二进制空位上的下标(m有可能为0,所以要+1),第二维是该下标对应的数字是多少
        memset(f, -1, sizeof f);

        // 为什么不用 bool is_num:因为本题是连续的1,所以有前导0也无所谓
        function<int(int, int, bool)> dp = [&](int i, int pre, bool is_lim)->int{
            if (i < 0)     return 1;   // 能运行到这个条件的,一定是正确情况
            if (!is_lim && f[i][pre] != -1)  return f[i][pre];

            int res = 0;
            int up = is_lim ? n >> i & 1 : 1;
            // sit 1: 本位填0无所谓,一定不会涉及到连续1的条件
            res += dp(i - 1, 0, up == 0 && is_lim);
            // sit 2: 如果本位要填写1,一定要检查
            if(pre != 1 && up == 1)    res += dp(i - 1, 1, is_lim);
            if(!is_lim)     f[i][pre] = res;
            return res;
        };
        return dp(m, 0, true);
    }
};

Leetcode 233. 数字 1 的个数

class Solution {
public:
    int countDigitOne(int n) {
        string s = to_string(n);
        int m = s.length();
        int f[m][m];
        memset(f, -1, sizeof f);
        function<int(int, int, bool, bool)> dp = [&](int i, int k, bool is_lim, bool is_num)->int{
            if(i == m)  return k;
            if(!is_lim && is_num && f[i][k] != -1) return f[i][k];
            int res = 0;
            if(!is_num) res = dp(i + 1, 0, false, false);
            int up = is_lim ? s[i] - '0' : 9;
            for(int j = 1 - is_num; j <= up; j ++){
                res += dp(i + 1, k + (j == 1), is_lim && j == up, true);
            }
            if(!is_lim && is_num)   f[i][k] = res;
            return res;
        };
        return dp(0, 0, true, false);
    }
};

 Leetcode 1399. 统计最大组的数目

如果 n=10^18,方法一就超时了,怎么办?

class Solution {
public:
    int countLargestGroup(int n) {
        string s = to_string(n);
        int m = s.size();
        vector memo(m, vector<int> (m * 9 + 1, -1));
        auto dfs = [&](this auto&& dfs, int i, int left, bool limit_high) -> int{
            if (i == m) return left == 0;
            if (!limit_high && memo[i][left] != -1) return memo[i][left];
            int hi = limit_high? s[i] - '0': 9; 
            int res = 0;
            for (int d = 0; d <= min(hi, left); d ++)   // 枚举当前数位填d
                res += dfs(i + 1, left - d, limit_high && d == hi);
            if (!limit_high)    memo[i][left] = res;
            return res;
        };
        int max_cnt = 0, ans = 0;
        for (int target = 1; target <= m * 9; target ++) {  // 枚举目标数位和
            int cnt = dfs(0, target, true);
            if (cnt > max_cnt)  max_cnt = cnt, ans = 1;
            else if (cnt == max_cnt)    ans ++;
        }return ans;
    }
};

着重关注状态使用的left(距离target的大小),而非已累计的数位和。

下面使用数位DP V2.0板子

支持[low, high]的数位DP求解。

Leetcode 2999. 统计强大整数的数目

思路:

代码(不考虑前导零)的写法:

class Solution {
public:
    long long numberOfPowerfulInt(long long start, long long finish, int limit, string s) {
        string low = to_string(start);
        string high = to_string(finish);
        int n = high.size();
        low = string(n - low.size(), '0') + low;    // 补上前导零,和high对其
        int diff = n - s.size();
        vector<long long> memo(n, -1);
        auto dfs = [&](this auto&& dfs, int i, bool limit_low, bool limit_high) -> long long {
            if (i == high.size())    return 1;   
            if(!limit_low && !limit_high && memo[i] != -1)
                return memo[i];

            int lo = limit_low ? low[i] - '0': 0;
            int hi = limit_high ? high[i] - '0': 9;
            // 错误hi = min(hi, limit_high) -->反例:limithigh=5, 63566,会导致枚举错误5xxxx(第二位受限于limithigh标志,只能填0~3,但实际上是可以填上54xxxxx的)
            long long res = 0;
            if (i < diff){  // 枚举当前数位填什么
                for (int d = lo; d <= min(hi, limit); d ++)
                    res += dfs(i + 1, limit_low && d == lo, limit_high && d == hi); // 统计不同的方案之和
            }else{
                int x = s[i - diff] - '0';  // 这个数位只能填s[i-diff]
                if (lo <= x && x <= hi) // 其实题目已经保证 x < limit了
                    res = dfs(i + 1, limit_low && x == lo, limit_high && x == hi);  // 成为固定的方案了,而非res += xxx了
            }
            if(!limit_low && !limit_high)
                memo[i] = res;  // 记忆化i, false, false
            return res;
        };
        return dfs(0, true, true);
    }
};

考虑前导零的板子:

 十一、优化 DP

十二、树形 DP

十三、图 DP

十四、博弈 DP

十五、概率/期望 DP

Leetcode 808. 分汤

思考:

       ①从递归的角度开始思考:

        模拟过程:定义目标事件-汤 A 先于 B 耗尽,或者两种汤在同一回合耗尽(此时只计一半的权重)。比如 n=200:

        从汤 A 取 100 毫升,从汤 B 取 0 毫升。问题变成两种汤的剩余量分别为 100 毫升和 200 毫升时,目标事件发生的概率。
        从汤 A 取 75 毫升,从汤 B 取 25 毫升。问题变成两种汤的剩余量分别为 125 毫升和 175 毫升时,目标事件发生的概率。
        从汤 A 取 50 毫升,从汤 B 取 50 毫升。问题变成两种汤的剩余量分别为 150 毫升和 150 毫升时,目标事件发生的概率。
        从汤 A 取 25 毫升,从汤 B 取 75 毫升。问题变成两种汤的剩余量分别为 175 毫升和 125 毫升时,目标事件发生的概率。
        这些问题都是和原问题相似的、规模更小的子问题,可以用递归解决。

        ②定义 dfs(a,b) 表示两种汤的剩余量分别为 a 毫升和 b 毫升时,目标事件发生的概率。

在当前回合,我们等概率地选择以下四种操作中的一种执行:

        从汤 A 取 100 毫升,从汤 B 取 0 毫升。问题变成两种汤的剩余量分别为 a−100 毫升和 b 毫升时,目标事件发生的概率,即 dfs(a−100,b)。
        从汤 A 取 75 毫升,从汤 B 取 25 毫升。问题变成两种汤的剩余量分别为 a−75 毫升和 b−25 毫升时,目标事件发生的概率,即 dfs(a−75,b−25)。
        从汤 A 取 50 毫升,从汤 B 取 50 毫升。问题变成两种汤的剩余量分别为 a−50 毫升和 b−50 毫升时,目标事件发生的概率,即 dfs(a−50,b−50)。
        从汤 A 取 25 毫升,从汤 B 取 75 毫升。问题变成两种汤的剩余量分别为 a−25 毫升和 b−75 毫升时,目标事件发生的概率,即 dfs(a−25,b−75)。
        在当前回合,上述四种操作互斥且等概率地被选择,因此目标事件发生的概率为四个后续状态的平均值,   

            dfs(a,b)= 0.25 * [dfs(a−100,b)+dfs(a−75,b−25)+dfs(a−50,b−50)+dfs(a−25,b−75)]

        执行一次操作 2 和一次操作 4,会让 a 和 b 均减少 100;执行两次操作 3,也会让 a 和 b 均减少 100。这两种方式都会递归到 dfs(a−100,b−100),这会导致我们重复计算同一个状态。考虑到整个递归过程中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化

        ③边界:

  • 如果 a≤0 且 b≤0,根据题目要求,返回 0.5。
  • 否则如果 a≤0,返回 1。
  • 否则如果 b≤0,返回 0。

 递归入口:dfs(n,n),即答案。

        ④优化空间:

        ⑤进一步的剪枝:

        题目说n的范围是1e9,但除了25之后也是不能开到这么大空间的数组,阅读题干,我们知道

且返回值在正确答案1e-5的范围内将被认为是正确的,所以如果正确答案>=1e-5,直接返回1就可以。不断的实验n的取值,发现n >=4451的时候正确答案>=1e-5,此时直接返回1。

Code:

class Solution {
public:
    double soupServings(int n) {
        if (n >= 4451)  return 1;
        vector memo(n + 1, vector<double>(n + 1));
        n = (n + 24) / 25;
        auto dfs = [&](this auto&& dfs, int a, int b) -> double{
            if (a <= 0 && b <= 0)   return 0.5;     // 同时被耗尽
            if (a <= 0)             return 1.0;     // a 先被耗尽
            if (b <= 0)             return 0;       // b 先被耗尽
            double& res = memo[a][b];               // 注意这里是引用
            if (res == 0)                           // res首先没被计算过(答案不可能取到0的,所以这里用0来判断)
                res = (dfs(a - 4, b) + dfs(a - 3, b - 1) + dfs(a - 2, b - 2) + dfs(a - 1, b - 3)) / 4;  // 等概率发生
            return res;
        };
        return dfs(n, n);
    }
};

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

_Ocean__

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值