*309. Best Time to Buy and Sell Stock with Cooldown
思路 DP
前言:
不要关注冷冻期!不要关注冷冻期!不要关注冷冻期! 只关注卖出的那一天!只关注卖出的那一天!只关注卖出的那一天!
题目中定义的“冷冻期”=卖出的那一天的后一天,题目设置冷冻期的意思是,如果昨天卖出了,今天不可买入,那么关键在于哪一天卖出,只要在今天想买入的时候判断一下前一天是不是刚卖出,即可,所以关键的一天其实是卖出的那一天,而不是卖出的后一天
正文:
因为当天卖出股票实际上也是属于“不持有”的状态,那么第i天如果不持有,那这个“不持有”就有了两种状态:
- 本来就不持有,指不是因为当天卖出了才不持有的;
- 第i天因为卖出了股票才变得不持有
而持有股票依旧只有一种状态
所以对于每一天i
,都有可能是三种状态:
- 不持股且当天没卖出,定义其最大收益
dp[i][0]
; - 持股,定义其最大收益
dp[i][1]
; - 不持股且当天卖出了,定义其最大收益
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
天一定也是不持有,那就是不持有的两种可能:
- i-1天不持股且当天没有卖出
dp[i-1][0]
; - 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[i−1][0],dp[i−1][2])
二、第i天持股dp[i][1]
,今天持股,来自两种可能:
- 要么是昨天我就持股,今天继承昨天的,也就是dp[i-1][1],这种可能很好理解;
- 要么:是昨天我不持股,今天我买入的,但前提是昨天我一定没卖!因为如果昨天我卖了,那么今天我不能交易!也就是题目中所谓“冷冻期”的含义,只有昨天是“不持股且当天没卖出”这个状态,我今天才能买入!所以是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[i−1][1],dp[i−1][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[i−1][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
我们换一种办法来定义此问题
每个节点可选择偷或者不偷两种状态,根据题目意思,相连节点不能一起偷
-
当前节点选择偷时,那么两个孩子节点就不能选择偷了
-
当前节点选择不偷时,两个孩子节点只需要拿最多的钱出来就行(两个孩子节点偷不偷没关系)
我们使用一个大小为 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[l−1]−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[r−1]−f[l][r−1]
双方都想赢,都会做最优决策(即使自己与对方分差最大)。因此 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][n−1]>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,n−1] 的
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[i−1]==0,s[i]是0或者1都可以保持单调性。
若
s
[
i
−
1
]
=
=
1
s[i-1] == 1
s[i−1]==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
如果今天不需要出门,不用买票。
如果今天如果要出门,需要买几天?
- 看往后几天(最多 30 天内)要不要出门
- 30 天内都没有要出行的,那只买今天就好
- 有要出门的(不同决策)
- 这次 和 后面几次 分开买更省
- 这次 和 后面几次 一起买更省
细化思路
上述思路显而易见,最关键在于:「今天买多少,得看后几天怎么安排」,即「前面依赖后面」——从后向前来买。
如图所示,例 d a y s = [ 1 , 4 , 6 , 7 , 8 , 20 ] days = [1,4,6,7,8,20] days=[1,4,6,7,8,20]
- 第 21 及以后的日子都不需要出门,不用买票
- 第 20 需要出门,需要买几天?
- 不考虑 20 之前要不要出门,否则与思路相违背
- 第 20 之后没有出门日,故买「一天」的 costs[0] 最省钱
- 第 9 - 19 不需要出门,则不用买
-
第 8 需要出门,需要买几天?
- 往后(只需看往后 30 天)有出门的需求
- 决策 1:买一天期,后面的不包
- 决策 2:买七天期,包到第 8 + 7 - 1 天,第 8 + 7 天往后的不包
- 决策 3:买三十天期,包到第 8 + 30 - 1 天,第 8 + 30 天往后的不包
下图展示了三种决策所包含的日期跨度(黄色区域画多了一天…)、所花费用
可见,决策 3 包三十天期的话,第 20 可不用花钱
- 往后(只需看往后 30 天)有出门的需求
-
抽象,定义状态,确定从后向前的递推公式
将上述结果换个说法:「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]+i−j(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
10−3=7,我们需要在前面找到 7
的位置,如果有多个 7
,则应该贪心取最靠右的,从而更有机会找到更长的 LAS
。这样,问题就变成以 7
结尾的公差为 3
的 LAS
的长度。由于有很多相似的子问题,可以用递归解决。
先来试试定义成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
7−9=−2 和
7
−
4
=
3
7-4=3
7−4=3的 LAS
,长度均为 2
。对于
a
[
4
]
=
10
a[4]=10
a[4]=10,它与
a
[
2
]
=
7
a[2]=7
a[2]=7 形成子序列时,由于已经知道以 a[2]
结尾的公差为 3
的 LAS
的长度为 2
,所以立刻得出以 a[4]
结尾的公差为 3
的 LAS
的长度为
2
+
1
=
3
2+1=3
2+1=3。
那么把所有以 a[i]
结尾的(至少有两个元素的)LAS
的公差及其长度都算出来,存到一个哈希表中,a[i]
右边的数x
就可以直接去哈希表中查找公差
d
=
x
−
a
[
i
]
d=x-a[i]
d=x−a[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
的序列的数目?由于相同字母不作区分,先考虑 2
个 C
如何放置。
这等价于在 5
个位置中选 2
个位置放 C
,其余位置放 AAB
。这 2
个C
有
(
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[i−1][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[i−1][j−k]⋅(kj)
这里 k ≤ m i n ( j , c n t ) k≤min(j,cnt) k≤min(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=0∑min(j,cnt)f[i−1][j−k]⋅(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=1∑nf[m][j]
,其中 m
为 tiles
中的字母种数。
代码实现时,组合数可以用如下恒等式预处理
(
n
k
)
=
(
n
−
1
k
−
1
)
+
(
n
−
1
k
)
\binom n k = \binom {n-1} {k-1} + \binom {n-1} k
(kn)=(k−1n−1)+(kn−1)
这个式子本质是考虑第 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;
}
}