算法刷题记录-DP(LeetCode)

*309. Best Time to Buy and Sell Stock with Cooldown

思路 DP

前言:

不要关注冷冻期!不要关注冷冻期!不要关注冷冻期! 只关注卖出的那一天!只关注卖出的那一天!只关注卖出的那一天!

题目中定义的“冷冻期”=卖出的那一天的后一天,题目设置冷冻期的意思是,如果昨天卖出了,今天不可买入,那么关键在于哪一天卖出,只要在今天想买入的时候判断一下前一天是不是刚卖出,即可,所以关键的一天其实是卖出的那一天,而不是卖出的后一天

正文:
因为当天卖出股票实际上也是属于“不持有”的状态,那么第i天如果不持有,那这个“不持有”就有了两种状态:

  1. 本来就不持有,指不是因为当天卖出了才不持有的;
  2. 第i天因为卖出了股票才变得不持有

而持有股票依旧只有一种状态

所以对于每一天i,都有可能是三种状态:

  1. 不持股且当天没卖出,定义其最大收益dp[i][0];
  2. 持股,定义其最大收益dp[i][1]
  3. 不持股且当天卖出了,定义其最大收益dp[i][2]

初始化:

dp[0][0]=0;//本来就不持有,啥也没干 
dp[0][1]=-1*prices[0];//第0天只买入
 dp[0][2]=0;//可以理解成第0天买入又卖出,那么第0天就是“不持股且当天卖出了”

这个状态下,其收益为0,所以初始化为0是合理的

重头戏:

一、第i不持股且没卖出的状态dp[i][0],也就是我没有股票,而且还不是因为我卖了它才没有的,那换句话说是从i-1天到第i天转移时,它压根就没给我股票!所以i-1天一定也是不持有,那就是不持有的两种可能:

  1. i-1天不持股且当天没有卖出dp[i-1][0]
  2. i-1天不持股但是当天卖出去了dp[i-1][2]

所以: d p [ i ] [ 0 ] = m a x ( d p [ i − 1 ] [ 0 ] , d p [ i − 1 ] [ 2 ] ) dp[i][0]=max(dp[i-1][0],dp[i-1][2]) dp[i][0]=max(dp[i1][0],dp[i1][2])

二、第i天持股dp[i][1]今天持股,来自两种可能:

  1. 要么是昨天我就持股,今天继承昨天的,也就是dp[i-1][1],这种可能很好理解;
  2. 要么:是昨天我不持股,今天我买入的,但前提是昨天我一定没卖!因为如果昨天我卖了,那么今天我不能交易!也就是题目中所谓“冷冻期”的含义,只有昨天是“不持股且当天没卖出”这个状态,我今天才能买入!所以是dp[i-1][0]-p[i]

所以: d p [ i ] [ 1 ] = m a x ( d p [ i − 1 ] [ 1 ] , d p [ i − 1 ] [ 0 ] − p [ i ] ) dp[i][1]=max(dp[i-1][1],dp[i-1][0]-p[i]) dp[i][1]=max(dp[i1][1],dp[i1][0]p[i])

三、i不持股且当天卖出了,这种就简单了,那就是说昨天我一定是持股的,要不然我今天拿什么卖啊,而持股只有一种状态,昨天持股的收益加上今天卖出得到的新收益,就是dp[i-1][1]+p[i]啦
所以: d p [ i ] [ 2 ] = d p [ i − 1 ] [ 1 ] + p [ i ] dp[i][2]=dp[i-1][1]+p[i] dp[i][2]=dp[i1][1]+p[i]

总结:最后一天的最大收益有两种可能,而且一定是“不持有”状态下的两种可能,把这两种“不持有”比较一下大小,返回即可

代码

    public int maxProfit(int[] prices) {
        int[][] cache = new int[prices.length ][3];
        cache[0][0]=0;
        cache[0][1]=-prices[0];
        cache[0][2]=0;
        for (int i = 1; i < prices.length; i++) {
            cache[i][1] = Math.max(cache[i - 1][0] - prices[i], cache[i - 1][1]);
            cache[i][0] = Math.max(cache[i - 1][0], cache[i - 1][2]);
            cache[i][2] = cache[i - 1][1] + prices[i];

        }
        return Math.max(cache[prices.length-1][0], cache[prices.length-1][2]);
    }

*337. House Robber III

思路 树上DP+DFS

我们换一种办法来定义此问题

每个节点可选择偷或者不偷两种状态,根据题目意思,相连节点不能一起偷

  1. 当前节点选择偷时,那么两个孩子节点就不能选择偷了

  2. 当前节点选择不偷时,两个孩子节点只需要拿最多的钱出来就行(两个孩子节点偷不偷没关系)
    我们使用一个大小为 2 的数组来表示 i n t [ ] r e s = n e w i n t [ 2 ] int[] res = new int[2] int[]res=newint[2] 0 代表不偷,1 代表偷 任何一个节点能偷到的最大钱的状态可以定义为

    • 当前节点选择不偷:当前节点能偷到的最大钱数 = 左孩子能偷到的钱 + 右孩子能偷到的钱
    • 当前节点选择偷:当前节点能偷到的最大钱数 = 左孩子选择自己不偷时能得到的钱 + 右孩子选择不偷时能得到的钱 + 当前节点的钱数

表示为公式如下

root[0] = Math.max(rob(root.left)[0], rob(root.left)[1]) + Math.max(rob(root.right)[0], rob(root.right)[1])
root[1] = rob(root.left)[0] + rob(root.right)[0] + root.val;

代码

class Solution {
public:
    int rob(TreeNode* root) {
        auto res= rob_interval(root);
        return max(res.first,res.second);

    }
    pair<int,int> rob_interval(TreeNode* root){
        if (!root){
            return {0,0};
        }
        auto left_sum= rob_interval(root->left);
        auto right_sum= rob_interval(root->right);
        return {root->val+left_sum.second+right_sum.second,max(left_sum.first,left_sum.second) +max(right_sum.first,right_sum.second) };
    }
};

714. Best Time to Buy and Sell Stock with Transaction Fee

思路 DP

参照没有Fee的DP转移状态,在每次计算price时加上fee即可

代码

    public int maxProfit(int[] prices, int fee) {
        int[][] cache=new int[prices.length][2];
        cache[0][0]=0;
        cache[0][1]=-fee-prices[0];
        for (int i = 1; i < prices.length; i++) {
            cache[i][0]=Math.max(cache[i-1][1]+prices[i],cache[i-1][0]);
            cache[i][1]=Math.max(cache[i-1][1],cache[i-1][0]-fee-prices[i]);
        }
        return cache[prices.length-1][0];
    }

746. Min Cost Climbing Stairs

代码

    int minCostClimbingStairs(vector<int>& cost) {
        if (cost.size()<2){
            return 0;
        }
        int cache[cost.size()+1];
        cache[0]=0;
        cache[1]=0;
        for (int i = 2; i <= cost.size(); ++i) {
                cache[i]= min(cache[i-2]+cost[i-2],cache[i-1]+cost[i-1]);
        }
        return cache[cost.size()];
    }

*873. Length of Longest Fibonacci Subsequence

思路

定义 f [ i ] [ j ] f[i][j] f[i][j]为使用 a r r [ i ] arr[i] arr[i] 为斐波那契数列的最后一位,使用 a r r [ j ] arr[j] arr[j] 为倒数第二位(即 a r r [ i ] arr[i] arr[i] 的前一位)时的最长数列长度。

不失一般性考虑 f [ i ] [ j ] f[i][j] f[i][j]该如何计算,首先根据斐波那契数列的定义,我们可以直接算得 a r r [ j ] arr[j] arr[j] 前一位的值为 a r r [ i ] − a r r [ j ] arr[i]−arr[j] arr[i]arr[j],而快速得知 a r r [ i ] − a r r [ j ] arr[i]−arr[j] arr[i]arr[j]值的坐标 t t t,可以利用 arr 的严格单调递增性质,使用「哈希表」对坐标进行转存,若坐标 t t t存在,并且符合 t < j t<j t<j,说明此时至少凑成了长度为 333 的斐波那契数列,同时结合状态定义,可以使用 f [ j ] [ t ] f[j][t] f[j][t]来更新 f [ i ] [ j ] f[i][j] f[i][j],即有状态转移方程:
f [ i ] [ j ] = m a x ⁡ ( 3 , f [ j ] [ t ] + 1 ) f [ i ] [ j ] = max ⁡ ( 3 , f [ j ] [ t ] + 1 ) f[i][j]=max⁡(3,f[j][t]+1)f[i][j] = \max(3, f[j][t] + 1) f[i][j]=max(3,f[j][t]+1)f[i][j]=max(3,f[j][t]+1)
同时,当我们「从小到大」枚举 iii,并且「从大到小」枚举 j j j 时,我们可以进行如下的剪枝操作:

  • 可行性剪枝:当出现 a r r [ i ] − a r r [ j ] > = a r r [ j ] arr[i]−arr[j]>=arr[j] arr[i]arr[j]>=arr[j],说明即使存在值为 a r r [ i ] − a r r [ j ] arr[i]−arr[j] arr[i]arr[j]的下标 t t t,根据 arr 单调递增性质,也不满足 t < j < i t<j<i t<j<i 的要求,且继续枚举更小的 j j j ,仍然有 a r r [ i ] − a r r [ j ] > = a r r [ j ] arr[i]−arr[j]>=arr[j] arr[i]arr[j]>=arr[j],仍不合法,直接 break 掉当前枚举 j j j的搜索分支;
  • 最优性剪枝:假设当前最大长度为 ans,只有当 j + 2 > a n s j+2>ans j+2>ans,我们才有必要往下搜索, j + 2 j+2 j+2 的含义为以 a r r [ j ] arr[j] arr[j] 为斐波那契数列倒数第二个数时的理论最大长度。

代码

    public int lenLongestFibSubseq(int[] arr) {
        int ans=0;
        HashMap<Integer,Integer> finder=new HashMap<>();
        for (int i = 0; i < arr.length; i++) {
            finder.put(arr[i],i);
        }
        int[][] cache=new int[arr.length][arr.length];
        for (int i = 2; i < arr.length; i++) {
            for (int j = i-1; j >= 1; j--) {
                int residual=arr[i]-arr[j];
                if (residual>=arr[j]){
                    break;
                }
                if (finder.containsKey(arr[i]-arr[j])){
                    cache[i][j]=Math.max(3,cache[j][finder.get(residual)]+1);
                    ans=Math.max(ans,cache[i][j]);
                }
            }
        }
        return ans;
    }

*877. Stone Game

数学解法

事实上,这还是一道很经典的博弈论问题,也是最简单的一类博弈论问题。

为了方便,我们称「石子序列」为石子在原排序中的编号,下标从 1开始。

由于石子的堆数为偶数,且只能从两端取石子。因此先手后手所能选择的石子序列,完全取决于先手每一次决定

证明如下:

由于石子的堆数为偶数,对于先手而言:每一次的决策局面,都能「自由地」选择奇数还是偶数的序列,从而限制后手下一次「只能」奇数还是偶数石子

具体的,对于本题,由于石子堆数为偶数,因此先手的最开始局面必然是[奇数, 偶数],即必然是「奇偶性不同的局面」;当先手决策完之后,交到给后手的要么是[奇数,奇数] 或者 [偶数,偶数],即必然是「奇偶性相同的局面」;后手决策完后,又恢复「奇偶性不同的局面」交回到先手 …

不难归纳推理,这个边界是可以应用到每一个回合。

因此先手只需要在进行第一次操作前计算原序列中「奇数总和」和「偶数总和」哪个大,然后每一次决策都「限制」对方只能选择「最优奇偶性序列」的对立面即可

同时又由于所有石子总和为奇数,堆数为偶数,即没有平局,所以先手必胜。

    bool stoneGame(vector<int>& piles) {
        return true;
    }

动态规划

定义 f[l][r] 为考虑区间 [l,r],在双方都做最好选择的情况下,先手与后手的最大得分差值为多少。

那么 f[1][n] 为考虑所有石子,先手与后手的得分差值:

  • f [ 1 ] [ n ] > 0 f[1][n]>0 f[1][n]>0,则先手必胜,返回 True
  • f [ 1 ] [ n ] < 0 f[1][n]<0 f[1][n]<0,则先手必败,返回 False

不失一般性的考虑 f [ l ] [ r ] f[l][r] f[l][r]如何转移。根据题意,只能从两端取石子(令 piles 下标从 1 开始),共两种情况:

  • 从左端取石子,价值为piles[l - 1];取完石子后,原来的后手变为先手,从 [l+1,r] 区间做最优决策,所得价值为 f[l+1][r]。因此本次先手从左端点取石子的话,双方差值为:
    p i l e s [ l − 1 ] − f [ l + 1 ] [ r ] piles[l−1]−f[l+1][r] piles[l1]f[l+1][r]
  • 从右端取石子,价值为 piles[r−1];取完石子后,原来的后手变为先手,从[l,r−1] 区间做最优决策,所得价值为 f[l][r−1]。因此本次先手从右端点取石子的话,双方差值为:
    p i l e s [ r − 1 ] − f [ l ] [ r − 1 ] piles[r−1]−f[l][r−1] piles[r1]f[l][r1]

双方都想赢,都会做最优决策(即使自己与对方分差最大)。因此 f [ l ] [ r ] f[l][r] f[l][r] 为上述两种情况中的最大值。

根据动态规划的状态转移方程,计算 dp [ i ] [ j ] \textit{dp}[i][j] dp[i][j] 需要使用 dp[i+1][j]dp[i][j−1]的值,即区间 [i+1,j][i,j−1]的状态值需要在区间[i,j] 的状态值之前计算,因此计算 dp[i][j] 的顺序可以是以下两种。

从小到大遍历每个区间长度,对于每个区间长度依次计算每个区间的状态值。

从大到小遍历每个区间开始下标 i i i,对于每个区间开始下标 i i i 从小到大遍历每个区间结束下标 jjj,依次计算每个区间 [i, j] 的状态值。

计算得到 dp[0][n−1]即为 Alice 与 Bob 的石子数量之差最大值。如果 d p [ 0 ] [ n − 1 ] > 0 dp[0][n−1]>0 dp[0][n1]>0,则 Alice 赢得游戏,返回true,否则 Bob 赢得游戏,返回 false。

class Solution {
    public boolean stoneGame(int[] ps) {
        int n = ps.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; // 计算右端点
                int a = ps[l - 1] - f[l + 1][r];
                int b = ps[r - 1] - f[l][r - 1];
                f[l][r] = Math.max(a, b);
            }
        }
        return f[1][n] > 0;
    }
}

*915. Partition Array into Disjoint Intervals

思路

根据题意,我们知道本质是求分割点,使得分割点的「左边的子数组的最大值」小于等于「右边的子数组的最小值」

我们可以先通过一次遍历(从后往前)统计出所有后缀的最小值 min,其中 min[i] = x 含义为下标范围在
[ i , n − 1 ] [i,n−1] [i,n1] n u m s [ i ] nums[i] nums[i]的最小值为 x,然后再通过第二次遍历(从前往后)统计每个前缀的最大值(使用单变量进行维护),找到第一个符合条件的分割点即是答案。

代码

    public int partitionDisjoint(int[] nums) {
        int[] min=new int[nums.length];
        int[] max=new int[nums.length];
        min[nums.length-1]=nums[nums.length-1];
        for (int i = nums.length-2; i >=0 ; i--) {
            min[i]=Math.min(min[i+1],nums[i]);
        }
        max[0]=nums[0];
        for (int i = 1; i < nums.length; i++) {
            max[i]=Math.max(nums[i],max[i-1]);
        }
        int ans=0;
        for (int i = nums.length-2; i >=0; i--) {
            if (max[i]<=min[i+1]){
                ans=i;
            }
        }
        return ans+1;
    }

926. Flip String to Monotone Increasing

思路

根据题意可知,字符有0和1两种状态,所以我们维护一个二维的cache数组来记录每个字符的状况。
cache[i][0]表示第i个字符是0的变换次数,cache[i][1]表示第i个字符是1的变换次数。
根据单调性: 若 s [ i − 1 ] = = 0 s[i-1] == 0 s[i1]==0,s[i]是0或者1都可以保持单调性。
s [ i − 1 ] = = 1 s[i-1] == 1 s[i1]==1,s[i]则必须为1才可以保持单调性(必须满足i-1是1)。
所以

cache[i][0] = cache[i-1][0] + (s[i] == '1' ? 1 : 0);//(自己是0,则前边都是0) 
cache[i][1] = Math.min(cache[i-1][0],cache[i-1][1]) + (s[i] == '0' ? 1 : 0);//(自己是1,前边0或者1都可以) 

最后result = Math.min(cache[i][0],cache[i][1])

代码

    public int minFlipsMonoIncr(String s) {
        int cache[][]=new int[s.length()][2];
        char[] arr=s.toCharArray();
        cache[0][0]=arr[0]=='0'?0:1;
        cache[0][1]=1-cache[0][0];
        for (int i = 1; i < arr.length; i++) {
            cache[i][0]=cache[i-1][0]+(arr[i]=='0'?0:1);
            cache[i][1]=Math.min(cache[i-1][0],cache[i-1][1])+(arr[i]=='1'?0:1);
        }
        return Math.min(cache[s.length()-1][0],cache[s.length()-1][1]);
    }

**940. Distinct Subsequences II

思路

根据题目描述,要找出一个字符串中所有不同的子序列。那么我们就需要找出这种子序列组合的规律。为了排除其他干扰,我们假设字符串中素有的字符都是不重复的。如下图所示, s = “ a b c d ” s=“abcd” s=abcd,那么我们可以看到如下规律:

遍历第1个字符‘a’:子序列总数 = 1(字符‘a’本身)= 1
遍历第2个字符‘b’:子序列总数 =【字符’a’的子序列总数】+ 1(字符‘b’本身)= 1 + 1 = 2;
遍历第3个字符‘c’:子序列总数 =【字符’a’的子序列总数】+ 【字符’b’的子序列总数】+ 1(字符‘c’本身)= 1 + 2 + 1 = 4;
遍历第4个字符‘d’:子序列总数 =【字符’a’的子序列总数】+【字符’b’的子序列总数】+【字符’c’的子序列总数】+ 1(字符‘d’本身)= 1 + 2 + 4 + 1 = 8; 【总结果】 = 1 + 2 + 4 + 8 = 15


但是,题目中并没有限制字符不能重复,所以,我们这时候在考虑如果字符串中出现重复字符,对总结果的影响是怎样的?请见下图,我们以s=“abcb”为例,我们发现,里面有字符‘b’发生了重复,我们发现如下规律:

在第1次遍历到字符‘b’的时候:子序列为“ab”、“b”;
在第2次遍历到字符‘b’的时候:子序列为“ab”、“b”、“abb”、“bb”、“acb”、“abcb”、“bcb”、“cb”;
【结论】我们发现第2次遍历字符’b’的时候,已经包含了第1次遍历字符’b’的子序列了。所以,在统计最终结果的时候,我们需要把“上一次”相同字符子序列总数减去才可以。

代码

    int distinctSubseqII(string s) {
        long res=0;
        long letter[26] = {0}; // 记录26个字符每个字符的子序列总数
        int mod=1e9+7;
        for (char ch:s) {
            long pre=letter[ch-'a']; // 获得字符sc前一次统计的子序列数
            letter[ch-'a']=(res+1)%mod;// 计算当前字符sc的子序列数
            res=(res+letter[ch-'a']-pre+mod)%mod;
        }
        return res;
    }

978. Longest Turbulent Subarray

思路

考虑缓存数组cache[n][2]。其中cache[i][0]表示当前的方向为"<“的连续子数组数,cache[i][0]表示当前的方向为”>“的连续子数组数。只要按照”<><>…"的顺序向右递推即可。

代码

    int maxTurbulenceSize(vector<int> &arr) {
        if (arr.size() == 1) {
            return 1;
        }
        int cache[arr.size()][2];
        cache[0][0] = 1;
        cache[0][1] = 1;
        int ans = 1;
        for (int i = 1; i < arr.size(); ++i) {
            if (arr[i] > arr[i - 1]) {
                cache[i][1] = cache[i - 1][0] + 1;
                cache[i][0] = 1;
            } else if(arr[i]<arr[i-1]) {
                cache[i][0] = cache[i - 1][1] + 1;
                cache[i][1] = 1;
            }
            else{
                cache[i][0]=1;
                cache[i][1]=1;
            }
            ans = max(max(cache[i][0], cache[i][1]), ans);
        }
        return ans;
    }

*983. Minimum Cost For Tickets

思路 逆序DP

如果今天不需要出门,不用买票。
如果今天如果要出门,需要买几天?

  1. 看往后几天(最多 30 天内)要不要出门
  2. 30 天内都没有要出行的,那只买今天就好
  3. 有要出门的(不同决策)
    • 这次 和 后面几次 分开买更省
    • 这次 和 后面几次 一起买更省

细化思路
上述思路显而易见,最关键在于:「今天买多少,得看后几天怎么安排」,即「前面依赖后面」——从后向前来买。

如图所示,例 d a y s = [ 1 , 4 , 6 , 7 , 8 , 20 ] days = [1,4,6,7,8,20] days=[1,4,6,7,8,20]

  1. 第 21 及以后的日子都不需要出门,不用买票
  2. 第 20 需要出门,需要买几天?
    • 不考虑 20 之前要不要出门,否则与思路相违背
    • 第 20 之后没有出门日,故买「一天」的 costs[0] 最省钱

  1. 第 9 - 19 不需要出门,则不用买

  1. 第 8 需要出门,需要买几天?

    • 往后(只需看往后 30 天)有出门的需求
      • 决策 1:买一天期,后面的不包
      • 决策 2:买七天期,包到第 8 + 7 - 1 天,第 8 + 7 天往后的不包
      • 决策 3:买三十天期,包到第 8 + 30 - 1 天,第 8 + 30 天往后的不包
        下图展示了三种决策所包含的日期跨度(黄色区域画多了一天…)、所花费用
        可见,决策 3 包三十天期的话,第 20 可不用花钱
  2. 抽象,定义状态,确定从后向前的递推公式
    将上述结果换个说法:「result 为第 8 天开始,所需最小费用 累计」
    抽象,定义状态: 「dp[i] 为第 i 天开始,所需最小费用 累计」
    则:

dp[i] = min(决策1, 决策2, 决策3);
      = min(c[0] + 1天后不包, c[1] + 7天后不包, c[2] + 30天不包);
      = min(c[0] + dp[i + 1], c[1] + dp[i + 7], c[2] + dp[i + 30]);

代码

    int mincostTickets(vector<int>& days, vector<int>& costs) {
        int cache[400]={0};
        int n=days.size();
        cache[days[n-1]]=min(costs[0], min(costs[1],costs[2]));
        unordered_set<int> set1;
        set1.insert(days.begin(), days.end());
        for (int i = days[n-1]-1; i >=0 ; i--) {
            if (set1.contains(i)){
                auto cost_day=cache[i+1]+costs[0];
                auto cost_week=cache[i+7]+costs[1];
                auto cost_month=cache[i+30]+costs[2];
                cache[i]=min(cost_day,min(cost_week,cost_month));
            }
            else{
                cache[i]=cache[i+1];
            }
        }
        return cache[0];
    }

*1014. Best Sightseeing Pair

思路

已知题目要求 r e s = A [ i ] + A [ j ] + i − j ( i < j ) res = A[i] + A[j] + i - j (i < j) res=A[i]+A[j]+ij(i<j) 的最大值,
而对于输入中的每一个 A[j] 来说, 它的值 A[j] 和它的下标 j 都是固定的,
所以 A[j] - j 的值也是固定的。
因此,对于每个 A[j] 而言, 想要求 res 的最大值,也就是要求 A[i] + i (i < j) 的最大值,

所以不妨用一个变量 pre_max 记录当前元素 A[j] 之前的 A[i] + i 的最大值,

这样对于每个 A[j] 来说,都有 最大得分 = p r e m a x + A [ j ] − j 最大得分 = pre_max + A[j] - j 最大得分=premax+A[j]j

再从所有 A[j] 的最大得分里挑出最大值返回即可。

代码

class Solution {
    public int maxScoreSightseeingPair(int[] values) {
        int pre_max= values[0];
        int res=0;
        for (int i = 1; i < values.length; i++) {
            res=Math.max(res,pre_max+values[i]-i);
            pre_max=Math.max(pre_max,values[i]+i);
        }
        return res;
    }
}

*1027. Longest Arithmetic Subsequence

思路1 双Hash

首先建立最外层的HashMap,key为每一个位置i,value为一个HashMap。
对于每一个位置i,建立HashMap,key为nums[j]-nums[i],value为位置j
对于每一个位置遍历,直到不存在内层key为当前的差的key为止即可。

思路2 DP 更优

对于动态规划问题,通常可以从「选或不选」和「枚举选哪个」这两个角度入手。

看到子序列,你可能想到了「选或不选」这个思路,但是本题要寻找的是等差子序列,假设我们确定了等差子序列的末项和公差,那么其它数也就确定了,所以寻找等差子序列更像是一件「枚举选哪个」的事情了。

为方便描述,下文将nums 简记为 a,将最长等差子序列称作 LAS

例如 a = [ 9 , 4 , 7 , 2 , 10 ] a=[9,4,7,2,10] a=[9,4,7,2,10]。假设 a [ 4 ] = 10 a[4]=10 a[4]=10 是 LAS 的最后一项,公差 d = 3 d=3 d=3,那么倒数第二项就是 10 − 3 = 7 10−3=7 103=7,我们需要在前面找到 7的位置,如果有多个 7,则应该贪心取最靠右的,从而更有机会找到更长的 LAS。这样,问题就变成以 7 结尾的公差为 3LAS 的长度。由于有很多相似的子问题,可以用递归解决。

先来试试定义成dfs(i,d),表示以 a[i] 结尾的公差为 d LAS 的长度。那么需要在前面找到 a [ j ] = a [ i ] − d a[j]=a[i]-d a[j]=a[i]d,然后继续递归 dfs(j,d)

如何找到 a [ i ] − d a[i]-d a[i]d

暴力枚举:需要花费 O ( n ) O(n) O(n) 的时间。
预处理相同元素的位置列表,然后在列表中二分查找:预处理 O ( n ) O(n) O(n),二分查找 O ( log ⁡ n ) O(\log n) O(logn)
无论如何,总是有多余的时间浪费在查找元素上了。

再来观察 a = [ 9 , 4 , 7 , 2 , 10 ] a=[9,4,7,2,10] a=[9,4,7,2,10]。对于 a[2]=7 来说,它和前面的元素形成了公差分别为 7 − 9 = − 2 7−9=−2 79=2 7 − 4 = 3 7-4=3 74=3LAS,长度均为 2。对于 a [ 4 ] = 10 a[4]=10 a[4]=10,它与 a [ 2 ] = 7 a[2]=7 a[2]=7 形成子序列时,由于已经知道以 a[2]结尾的公差为 3LAS 的长度为 2,所以立刻得出以 a[4] 结尾的公差为 3LAS 的长度为 2 + 1 = 3 2+1=3 2+1=3

那么把所有以 a[i] 结尾的(至少有两个元素的)LAS 的公差及其长度都算出来,存到一个哈希表中,a[i] 右边的数x 就可以直接去哈希表中查找公差 d = x − a [ i ] d=x-a[i] d=xa[i] 对应的 LAS 长度了。

因此我们换个角度,定义成 dfs(i),它返回上面说的哈希表。由于 a[i] 和前面的元素至多形成 i 个公差不同的 LAS,所以哈希表的大小至多为 i

具体来说,对于 dfs(i),维护一个哈希表 maxLen,枚举所有 j < i j<i j<i,设公差 d = a [ i ] − a [ j ] d=a[i]−a[j] d=a[i]a[j],则更新

m a x L e n [ d ] = m a x ⁡ ( m a x L e n [ d ] , d f s ( j ) [ d ] + 1 ) maxLen[d]=max⁡(maxLen[d],dfs(j)[d]+1) maxLen[d]=max(maxLen[d],dfs(j)[d]+1)

注:考虑到 j 越大 dfs(j)[d] 也越大,所以代码实现时可以倒序遍历j,对 maxLen[d]只更新一次。这样执行用时更短。

代码1 双Hash

class Solution {
    public int longestArithSeqLength(int[] nums) {
        int ans=0;
        if (nums.length<=2){
            return nums.length;
        }
        HashMap<Integer,HashMap<Integer,Integer>> map =new HashMap<>();
        for (int i = 0; i < nums.length; i++) {
            map.put(i,new HashMap<>());
            for (int j = i+1; j < nums.length; j++) {
                var curr=map.get(i);
                if (!curr.containsKey(nums[j]-nums[i])){
                    curr.put(nums[j]-nums[i],j);
                }
            }
        }
        for (int i = 0; i < nums.length-1; i++) {
            if (ans>nums.length-i){
                return ans;
            }
            for(int diff:map.get(i).keySet()){
                int cnt=2;
                int next=map.get(i).get(diff);
                while (map.get(next).containsKey(diff)){
                    cnt++;
                    next=map.get(next).get(diff);
                }
                ans=Math.max(cnt,ans);
            }
        }
        return ans;
    }
}

代码2 DP 更优

    public int longestArithSeqLength(int[] a) {
        int ans = 0, n = a.length;
        Map<Integer, Integer>[] f = new HashMap[n];
        Arrays.setAll(f, e -> new HashMap<>());
        for (int i = 1; i < n; ++i)
            for (int j = i - 1; j >= 0; --j) {
                int d = a[i] - a[j]; // 公差
                if (!f[i].containsKey(d)) {
                    f[i].put(d, f[j].getOrDefault(d, 1) + 1);
                    ans = Math.max(ans, f[i].get(d));
                }
            }
        return ans;
    }

**1031. Maximum Sum of Two Non-Overlapping Subarrays

思路


对于有两个变量的题目,通常可以枚举其中一个变量,把它视作常量,从而转化成只有一个变量的问题。

对于本题来说,就是枚举 b,把问题转化成计算 a 的最大元素和。

其实这个技巧在 1. 两数之和 中就体现了:枚举第二个数,去左边找第一个数。(用哈希表优化找第一个数的过程。)

代码 DP

class Solution {
    int[] prefix_sum;
    public int maxSumTwoNoOverlap(int[] nums, int firstLen, int secondLen) {
        prefix_sum=new int[nums.length+1];
        for (int i = 1; i <= nums.length; i++) {
            prefix_sum[i]=prefix_sum[i-1]+nums[i-1];
        }
        return Math.max(maxSum(firstLen,secondLen),maxSum(secondLen,firstLen));
    }
    public int maxSum(int firstLen,int secondLen){
        int res=0;
        int a_sum=0;
        for (int i = firstLen+secondLen; i <prefix_sum.length ; i++) {
            a_sum=Math.max(a_sum,prefix_sum[i-secondLen]-prefix_sum[i-firstLen-secondLen]);
            res=Math.max(res,a_sum+prefix_sum[i]-prefix_sum[i-secondLen]);
        }
        return res;
    }
}

*1079. Letter Tile Possibilities

思路

寻找子问题
t i l e s = A A B C C tiles=AABCC tiles=AABCC为例。先来思考,如何计算长为 5 的序列的数目?由于相同字母不作区分,先考虑 2C 如何放置。

这等价于在 5个位置中选 2 个位置放 C,其余位置放 AAB。这 2C ( 5 2 ) = 10 \dbinom 5 2=10 (25)=10 种放法。剩余要解决的问题为,用AAB 构造长为 3 的序列的数目。这是一个与原问题相似,且规模更小的子问题。
状态定义与转移
根据上面的讨论,定义f[i][j] 表示用前 i 种字符构造长为j 的序列的方案数。

设第 i 种字符有 cnt 个:

  • 如果一个也不选,那么 f [ i ] [ j ] = f [ i − 1 ] [ j ] f[i][j]=f[i−1][j] f[i][j]=f[i1][j]
  • 如果选k 个,那么需要从 j个位置中选k 个放第 i 种字符,其余位置就是用前 i−1 种字符构造长为j−k 的序列的方案数,所以有 f [ i ] [ j ] = f [ i − 1 ] [ j − k ] ⋅ ( j k ) f[i][j] =f[i-1][j-k]\cdot \dbinom j k f[i][j]=f[i1][jk](kj)
    这里 k ≤ m i n ⁡ ( j , c n t ) k≤min⁡(j,cnt) kmin(j,cnt)。特别地,一个也不选相当于 k = 0 k=0 k=0 的情况。

所以,枚举 k = 0 , 1 , ⋯   , m i n ⁡ ( j , c n t ) k=0,1,⋯ ,min⁡(j,cnt) k=0,1,,min(j,cnt),把所有方案数相加,就得到了f[i][j],对应的状态转移方程为

f [ i ] [ j ] = ∑ k = 0 min ⁡ ( j , cnt ) f [ i − 1 ] [ j − k ] ⋅ ( j k ) f[i][j] = \sum_{k=0}^{\min(j,\textit{cnt})} f[i-1][j-k]\cdot \binom j k f[i][j]=k=0min(j,cnt)f[i1][jk](kj)
初始值: f [ 0 ] [ 0 ] = 1 f[0][0]=1 f[0][0]=1,构造空序列的方案数为 1

答案: ∑ j = 1 n f [ m ] [ j ] \sum\limits_{j=1}^{n}f[m][j] j=1nf[m][j]
,其中 mtiles 中的字母种数。

代码实现时,组合数可以用如下恒等式预处理

( n k ) = ( n − 1 k − 1 ) + ( n − 1 k ) \binom n k = \binom {n-1} {k-1} + \binom {n-1} k (kn)=(k1n1)+(kn1)
这个式子本质是考虑第 n 个数「选或不选」。如果选,那么问题变成从 n−1 个数中选 k−1 个数的方案数;如果不选,那么问题变成从 n−1 个数中选k 个数的方案数。二者相加即为从 n 个数中选 k 个数的方案数。

代码

class Solution {
    static int[][] combinations = initCombinations();

    public static int[][] initCombinations() {
        int[][] combinations = new int[8][8];
        for (int i = 0; i <= 7; i++) {
            combinations[i][0] = combinations[i][i] = 1;
            for (int j = 1; j < i; j++) {
                combinations[i][j] = combinations[i - 1][j - 1] + combinations[i - 1][j];
            }
        }
        return combinations;
    }

    public int numTilePossibilities(String tiles) {
        HashMap<Character, Integer> counter = new HashMap<>();
        for (char ch : tiles.toCharArray()) {
            counter.merge(ch, 1, Integer::sum);
        }
        int m = counter.size();
        int n = tiles.length();
        var cache = new int[m + 1][n + 1];
        cache[0][0] = 1;
        int i = 1;
        for (var cnt : counter.values()) {// 枚举字母i
            for (int j = 0; j <= n; j++) {// 枚举序列长度 j
                for (int k = 0; k <= j && k <= cnt; k++) {//枚举字母选了 k 个
                    cache[i][j] += cache[i - 1][j - k] * combinations[j][k];
                }
            }
            i++;
        }
        int ans = 0;
        for (int j = 1; j <= n; j++) {
            ans += cache[m][j];
        }
        return ans;
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值