动态规划的思想
首先进行暴力尝试,然后建立记忆搜索表进行缓存,最后建立严格表结构。
直接使用暴力递归 ,会将所有情况都进行尝试,这样时间复杂度较高,有很多之前尝试过的答案会在其他分支仍然进行尝试。因此可以在暴力递归的基础上建立记忆搜索表(缓存表),将已经尝试过的答案缓存到表中,这样在其他分支就不需要再次尝试了,可以直接从基友搜索表里获取结果,从而节省了大量的时间,该方法又被称为记忆化递归法 。最后可以根据暴力尝试的推导公式建立严格表结构,从而省去递归操作,在严格表中通过递推快速得到结果。
暴力递归就是尝试:
把问题转化为规模缩小了的同类问题的子问题
有明确的不需要继续进行递归的条件(base case)
有当得到了子问题的结果之后的决策过程
不记录每一个子问题的解
暴力递归的常用技巧:
在递归过程中不保存结果,而是在调用到 base case(栈底)时才执行保存,因为到 base case 时说明当前这条路是走得通的,那么走到底时就可以保存结果了,如果某条路走不通(例如八皇后问题中可能某些路就走不通)则不会到 base case,从而不会保存。
在递归过程中通过修改 char[] 或 int[] 里的值来节省空间,在调用子问题前先修改原数组(要注意暂存起来修改前的值),并在子问题执行完毕后将其原始值保存回去,从而做到保持原数组的正确性,不影响其他的递归栈
上述技巧使用举例:例如要保存一个字符串的所有子序列:
递归过程中在修改 char[] 数组当前位置的值为空 ''
,代表不考虑当前字符的情况,修改后进入子问题,得到不考虑当前位置字符时的结果;然后该子问题的方法栈调用完毕后,再将原始值赋值回去,再调用一次子问题,代表考虑当前字符的情况,又可以得到一种结果
递归到底时,再保存当前 char[] 中的内容到结果列表里。
动态规划常见题目
背包问题
给定两个长度都为N的数组weights和values,weights[i]
和values[i]
分别代表i号物品的重量和价值。给定一个正数bag,表示一个载重bag的袋子,你装的物品不能超过这个重量。返回你能装下最多的价值是多少?
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 package com.zhao;public class Knapsack { public static int maxValue (int [] weights, int [] values, int bag) { return process(0 , weights, values, 0 , 0 , bag); } public static int process (int i, int [] weights, int [] values, int alreadyWeight, int alreadyValue, int bag) { if (alreadyWeight > bag) { return alreadyValue - values[i - 1 ]; } if (i == weights.length) { return alreadyValue; } return Math.max( process(i + 1 , weights, values, alreadyWeight + weights[i], alreadyValue + values[i], bag), process(i + 1 , weights, values, alreadyWeight, alreadyValue, bag) ); } public static int maxValue2 (int [] weights, int [] values, int bag) { int [][] dp = new int [weights.length + 1 ][bag + 1 ]; for (int i = weights.length - 1 ; i >= 0 ; i--) { for (int j = bag; j >= 0 ; j--) { dp[i][j] = dp[i + 1 ][j]; if (j + weights[i] <= bag) { dp[i][j] = Math.max(dp[i][j], values[i] + dp[i + 1 ][j + weights[i]]); } } } return dp[0 ][0 ]; } public static void main (String[] args) { int [] weights = { 4 , 7 , 6 , 8 }; int [] values = { 5 , 6 , 3 , 19 }; int bag = 11 ; System.out.println(maxValue(weights, values, bag)); System.out.println(maxValue2(weights, values, bag)); } }
背包问题 II
牛牛准备参加学校组织的春游,出发前牛牛准备往背包里装入一些零食,牛牛的背包容量为w。
牛牛家里一共有n袋零食,第i袋零食体积为v[i]。牛牛想知道在总体积不超过背包容量的情况下,他一共有多少种零食放法(总体积为0也算一种放法)。
纸牌游戏
给定一个整型数组arr,代表数值不同的纸牌排成一条线。玩家A和玩家B依次拿走每张纸牌,规定玩家A先拿,玩家B后拿,但是每个玩家每次只能拿走最左或最右的纸牌,玩家A和玩家B都绝顶聪明。请返回最后获胜者的分数。
思路:将整个抽牌过程拆成先手和后手两种策略。
己方先作为先手抽牌 ,有两种情况,取这两种情况的最大 点数情况
抽最左侧的牌,然后己方作为后手等待对方抽一张
抽最右侧的牌,然后己方作为后手等待对方抽一张
己方作为后手时是不能抽牌的 ,要等对方抽完一张后己方再作为先手抽牌 ,此时流程又回到了己方作为先手抽牌的情况。对方有两种抽法,己方要取两种先手情况的最小值 。因为对方肯定会抽掉某张牌,使得己方再作为先手抽剩下的牌时得分最少 ,所以是取最小值
代码中将不断递归地在先手方法里调用后手方法,在后手方法里递归地调用先手方法,直到就剩一张牌,此时:
己方若为先手,则抽到这张牌,返回该值,递归栈开始返回
己方若为后手,则不能抽到这张牌,返回0,递归栈开始返回
需要注意的是,己方作为后手时是不能抽牌的,要直接返回去掉两侧牌后的两种先手情况的最小值。
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 public class CardsInLine { public static int maxScore01 (int [] nums) { if (nums == null || nums.length == 0 ) { return 0 ; } return Math.max(first(nums, 0 , nums.length - 1 ), second(nums, 0 , nums.length - 1 )); } public static int first (int [] nums, int i, int j) { if (i == j) { return nums[i]; } return Math.max(nums[i] + second(nums, i + 1 , j), nums[j] + second(nums, i, j - 1 )); } public static int second (int [] nums, int i, int j) { if (i == j) { return 0 ; } return Math.min(first(nums, i + 1 , j), first(nums, i, j - 1 )); } public static int win2 (int [] arr) { if (arr == null || arr.length == 0 ) { return 0 ; } int [][] f = new int [arr.length][arr.length]; int [][] s = new int [arr.length][arr.length]; for (int j = 0 ; j < arr.length; j++) { f[j][j] = arr[j]; for (int i = j - 1 ; i >= 0 ; i--) { f[i][j] = Math.max(arr[i] + s[i + 1 ][j], arr[j] + s[i][j - 1 ]); s[i][j] = Math.min(f[i + 1 ][j], f[i][j - 1 ]); } } return Math.max(f[0 ][arr.length - 1 ], s[0 ][arr.length - 1 ]); } public static void main (String[] args) { int [] arr = { 1 , 9 , 1 }; System.out.println(maxScore01(arr)); System.out.println(win2(arr)); } }
青草游戏
牛牛和羊羊都很喜欢青草,今天他们决定玩青草游戏。最初有一个装有n份青草的箱子,牛牛和羊羊依次进行,牛牛先开始。在每个回合中,每个玩家必须吃一些箱子中的青草,所吃的青草份数必须是4的x次幂,比如1,4,16,64等等。不能在箱子中吃到有效份数青草的玩家落败。假定牛牛和羊羊都是按照最佳方法进行游戏,请输出胜利者的名字。
思路:
先枚举 n <= 4 时的胜负情况。
然后先手第一次选择吃 1 份,判断此时剩余的草让对方成为先手开始吃胜负属于谁;
如果自己胜利则直接返回,否则先手第一次选择吃 4 份,继续判断此时剩余的草让对方成为先手开始吃胜负属于谁;
如果仍然是对方胜利,则先手第一次选择吃 16 份,继续判断此时剩余的草让对方成为先手开始吃胜负属于谁;
重复以上过程,如果先手第一次无论吃多少份,赢得都是后手,代表先手不可能获胜,答案就是后手。如果中途有一次能让先手获胜,则答案就是先手。
母过程的先手和子过程里的后手是同一个人 ,如果子过程的后手赢了,就代表母过程的先手赢了
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 public class EatGlass { public static String winner01 (int n) { if (n >= 0 && n <= 4 ) { return (n == 0 || n == 2 ) ? "后手" : "先手" ; } int base = 1 ; while (base <= n) { if ("后手" .equals(winner01(n - base))) { return "先手" ; } if (base > n / 4 ) { break ; } base *= 4 ; } return "后手" ; } public static String winner02 (int n) { if (n % 5 == 0 || n % 5 == 2 ) { return "先手" ; } else { return "后手" ; } } public static void main (String[] args) { for (int i = 0 ; i <= 50 ; i++) { System.out.println(i + " " + winner01(i)); } } }
机器人的运动范围
剑指 Offer 13. 机器人的运动范围 :地上有一个m行n列的方格,从坐标 [0,0] 到坐标 [m-1,n-1] 。一个机器人从坐标 [0, 0] 的格子开始移动,它每次可以向左、右、上、下移动一格(不能移动到方格外),也不能进入行坐标和列坐标的数位之和大于k的格子。例如,当k为18时,机器人能够进入方格 [35, 37] ,因为3+5+3+7=18。但它不能进入方格 [35, 38],因为3+5+3+8=19。请问该机器人能够到达多少个格子?
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 public int movingCount (int m, int n, int k) { int [][] record = new int [m][n]; movingCountHelper(0 , 0 , m, n, k, record); return count; } public int count = 0 ;public void movingCountHelper (int i, int j, int m, int n, int k, int [][] record) { if (i < 0 || i >= m || j < 0 || j >= n) { return ; } if (!isValid(i, j, k)) { return ; } if (record[i][j] == 1 ) { return ; } record[i][j] = 1 ; count++; movingCountHelper(i - 1 , j, m, n, k, record); movingCountHelper(i, j - 1 , m, n, k, record); movingCountHelper(i + 1 , j, m, n, k, record); movingCountHelper(i, j + 1 , m, n, k, record); } public boolean isValid (int i, int j, int k) { int sum = 0 ; while (i != 0 ) { sum += i % 10 ; i /= 10 ; } while (j != 0 ) { sum += j % 10 ; j /= 10 ; } return sum <= k; }
构建乘积数组
剑指 Offer 66. 构建乘积数组 :给定一个数组 A[0,1,…,n-1],请构建一个数组 B[0,1,…,n-1],其中 B[i] 的值是数组 A 中除了下标 i 以外的元素的积, 即 B[i]=A[0]×A[1]×…×A[i-1]×A[i+1]×…×A[n-1]
。不能使用除法。
示例:
1 2 输入: [1,2,3,4,5] 输出: [120,60,40,30,24]
根据表格的主对角线(全为 11 ),可将表格分为 上三角 和 下三角 两部分。分别迭代计算下三角和上三角两部分的乘积,即可 不使用除法 就获得结果。
算法流程:
初始化:数组 B,其中 B[0] = 1;辅助变量 tmp = 1;
计算 B[i] 的 下三角 各元素的乘积,直接乘入 B[i];
计算 B[i] 的 上三角 各元素的乘积,记为 tmp,并乘入 B[i];
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 class Solution { public int [] constructArr(int [] a) { if (a == null || a.length == 0 ) { return new int [0 ]; } int [] b = new int [a.length]; b[0 ] = 1 ; for (int i = 1 ; i < a.length; i++) { b[i] = b[i - 1 ] * a[i - 1 ]; } int [] res = new int [a.length]; int tmp = 1 ; for (int i = a.length - 1 ; i >= 0 ; i--) { res[i] = b[i] * tmp; tmp = tmp * a[i]; } return res; } }
戳气球
312. 戳气球 :有 n
个气球,编号为0
到 n - 1
,每个气球上都标有一个数字,这些数字存在数组 nums
中。现在要求你戳破所有的气球。戳破第 i
个气球,你可以获得 nums[i - 1] * nums[i] * nums[i + 1]
枚硬币。这里的 i - 1
和 i + 1
代表和 i
相邻的两个气球的序号。如果 i - 1
或 i + 1
超出了数组的边界,那么就当它是一个数字为 1
的气球。求所能获得硬币的最大数量。
示例 1:
1 2 3 4 5 输入:nums = [3,1,5,8] 输出:167 解释: nums = [3,1,5,8] --> [3,5,8] --> [3,8] --> [8] --> [] coins = 3*1*5 + 3*5*8 + 1*3*8 + 1*8*1 = 167
思路:尝试的依据:当前位置必须是最后一个戳 的情况下,整体获得的硬币数量。那么只需要先递归地计算左侧区域的硬币数(代表先戳破左边),再递归地计算右侧区域的硬币数(代表再戳破右边),最后只剩下当前一个气球,将其戳破,即可得到当前位置最后一个戳破整体可以得到的硬币数量。在戳左侧区域时,右边界就是当前位置;在戳右侧区域时,左边界就是当前位置。
其中,需要先构造辅助数组 ,在数组的左右边界额外补充两个1 ,这样在某个数组中的气球最后戳的时候也能顺利通过 L - 1 和 R + 1 得到当前戳得到的硬币:1 * nums[i] * 1
,否则还得考虑边界条件,非常麻烦。
递归方法里需要将当前区间 [L, R] 内的位置一一尝试进行计算,会有大量重复计算,因此构建缓存表避免重复计算。
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 class Solution { public int maxCoins (int [] nums) { int [] help = new int [nums.length + 2 ]; help[0 ] = 1 ; help[nums.length + 1 ] = 1 ; for (int i = 0 ; i < nums.length; i++) { help[i + 1 ] = nums[i]; } int [][] record = new int [help.length][help.length]; return maxCoinsRecur(help, 1 , nums.length, record); } public int maxCoinsRecur (int [] nums, int L, int R, int [][] record) { if (L >= R) { return nums[L - 1 ] * nums[L] * nums[R + 1 ]; } if (record[L][R] != 0 ) { return record[L][R]; } int res = 0 ; int coins = 0 ; int coins1 = maxCoinsRecur(nums, L + 1 , R, record) + nums[L - 1 ] * nums[L] * nums[R + 1 ]; int coins2 = maxCoinsRecur(nums, L, R - 1 , record) + nums[L - 1 ] * nums[R] * nums[R + 1 ]; res = Math.max(coins1, coins2); for (int i = L + 1 ; i <= R - 1 ; i++) { coins = maxCoinsRecur(nums, L, i - 1 , record) + maxCoinsRecur(nums, i + 1 , R, record) + nums[L - 1 ] * nums[i] * nums[R + 1 ]; res = Math.max(res, coins); } record[L][R] = res; return res; } }
矩阵的最小路径之和
剑指 Offer II 099. 最小路径之和 :给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。说明:一个机器人每次只能向下或者向右移动一步。
思路:本题是典型的动态规划问题,当前位置 [i][j]
的路径值 grid[i][j]
,要么选择加上左侧位置 [i][j - 1]
,要么选择加上右侧位置 [i - 1][j]
,因此最简单的想法就是从左到右,从上到下,遍历整个二维矩阵,不断更新当前位置的值:
1 dp[i][j] = Math.min(dp[i - 1 ][j], dp[i][j - 1 ]) + m[i][j];
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public static int minPathSum1 (int [][] m) { if (m == null || m.length == 0 || m[0 ] == null || m[0 ].length == 0 ) { return 0 ; } int row = m.length; int col = m[0 ].length; int [][] dp = new int [row][col]; dp[0 ][0 ] = m[0 ][0 ]; for (int i = 1 ; i < row; i++) { dp[i][0 ] = dp[i - 1 ][0 ] + m[i][0 ]; } for (int j = 1 ; j < col; j++) { dp[0 ][j] = dp[0 ][j - 1 ] + m[0 ][j]; } for (int i = 1 ; i < row; i++) { for (int j = 1 ; j < col; j++) { dp[i][j] = Math.min(dp[i - 1 ][j], dp[i][j - 1 ]) + m[i][j]; } } return dp[row - 1 ][col - 1 ]; }
但其实可以对该方法进行空间压缩优化 :只使用一个一维数组 ,不断在该数组中更新路径和:从 arr[j - 1]
(本行更新后的值) 和 arr[j]
(上一行的旧值)中选出最小的更新到 arr[j] 里。
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public int minPathSum (int [][] grid) { if (grid == null || grid.length < 1 || grid[0 ] == null || grid[0 ].length < 1 ) { return 0 ; } int [] arr = new int [grid[0 ].length]; arr[0 ] = grid[0 ][0 ]; for (int j = 1 ; j < grid[0 ].length; j++) { arr[j] = grid[0 ][j] + arr[j - 1 ]; } for (int i = 1 ; i < grid.length; i++) { arr[0 ] = grid[i][0 ] + arr[0 ]; for (int j = 1 ; j < grid[0 ].length; j++) { arr[j] = grid[i][j] + Math.min(arr[j], arr[j - 1 ]); } } return arr[grid[0 ].length - 1 ]; }
第
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public static int minPathSum2 (int [][] m) { if (m == null || m.length == 0 || m[0 ] == null || m[0 ].length == 0 ) { return 0 ; } int more = Math.max(m.length, m[0 ].length); int less = Math.min(m.length, m[0 ].length); boolean rowmore = more == m.length; int [] arr = new int [less]; arr[0 ] = m[0 ][0 ]; for (int i = 1 ; i < less; i++) { arr[i] = arr[i - 1 ] + (rowmore ? m[0 ][i] : m[i][0 ]); } for (int i = 1 ; i < more; i++) { arr[0 ] = arr[0 ] + (rowmore ? m[i][0 ] : m[0 ][i]); for (int j = 1 ; j < less; j++) { arr[j] = Math.min(arr[j - 1 ], arr[j]) + (rowmore ? m[i][j] : m[j][i]); } } return arr[less - 1 ]; }
动态规划空间压缩技巧
如果某个动态规划问题的抽象网格模型中,当前位置 (i, j)
的更新只依赖于相邻的行和列的值,那么通常可以仅使用一个一维数组 存储每一行或每一列更新的值。将更新二维表的过程修改为更新一维数组的过程,从而大大节省时间。
具体为存储行还是存储列取决于问题中的二维矩阵是行的尺寸比较大还是列的尺寸比较大。
当前位置依赖于左侧和上侧:
当前位置取决于上侧、左上侧和左左上侧(此时需要从右往左遍历更新):
当前位置取决于左侧、上侧和左上侧(此时需要一个临时变量 t 先保存左上角的值,添加完再更新为当前位置的值):
空间压缩技巧例题:剑指 Offer 47. 礼物的最大价值
在一个 m*n 的棋盘的每一格都放有一个礼物,每个礼物都有一定的价值(价值大于 0)。你可以从棋盘的左上角开始拿格子里的礼物,并每次向右或者向下移动一格、直到到达棋盘的右下角。给定一个棋盘及其上面的礼物的价值,请计算你最多能拿到多少价值的礼物?
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 public static int minPathSum1 (int [][] m) { if (m == null || m.length == 0 || m[0 ] == null || m[0 ].length == 0 ) { return 0 ; } int row = m.length; int col = m[0 ].length; int [][] dp = new int [row][col]; dp[0 ][0 ] = m[0 ][0 ]; for (int i = 1 ; i < row; i++) { dp[i][0 ] = dp[i - 1 ][0 ] + m[i][0 ]; } for (int j = 1 ; j < col; j++) { dp[0 ][j] = dp[0 ][j - 1 ] + m[0 ][j]; } for (int i = 1 ; i < row; i++) { for (int j = 1 ; j < col; j++) { dp[i][j] = Math.min(dp[i - 1 ][j], dp[i][j - 1 ]) + m[i][j]; } } return dp[row - 1 ][col - 1 ]; } public static int minPathSum2 (int [][] m) { if (m == null || m.length == 0 || m[0 ] == null || m[0 ].length == 0 ) { return 0 ; } int more = Math.max(m.length, m[0 ].length); int less = Math.min(m.length, m[0 ].length); boolean rowmore = more == m.length; int [] arr = new int [less]; arr[0 ] = m[0 ][0 ]; for (int i = 1 ; i < less; i++) { arr[i] = arr[i - 1 ] + (rowmore ? m[0 ][i] : m[i][0 ]); } for (int i = 1 ; i < more; i++) { arr[0 ] = arr[0 ] + (rowmore ? m[i][0 ] : m[0 ][i]); for (int j = 1 ; j < less; j++) { arr[j] = Math.min(arr[j - 1 ], arr[j]) + (rowmore ? m[i][j] : m[j][i]); } } return arr[less - 1 ]; }
斐波那契系列问题
斐波那契数列
剑指 Offer 10- I. 斐波那契数列 :写一个函数,输入 n
,求斐波那契(Fibonacci)数列的第 n
项(即 F(N)
)。斐波那契数列的定义如下:
1 2 F(0) = 0, F(1) = 1 F(N) = F(N - 1) + F(N - 2), 其中 N > 1.
斐波那契数列由 0 和 1 开始,之后的斐波那契数就是由之前的两数相加而得出。
版本一:暴力递归
直接暴力递归时间复杂度过高,因为重复计算了大量的值:
1 2 3 4 5 6 7 8 9 10 public static int f1 (int n) { if (n < 1 ) { return 0 ; } if (n == 1 || n == 2 ) { return 1 ; } return f1(n - 1 ) + f1(n - 2 ); }
版本二:动态规划
可以使用滚动数组版本的动态规划解决该问题,但是其时间复杂度为 O(N):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public static int f2 (int n) { if (n < 1 ) { return 0 ; } if (n == 1 || n == 2 ) { return 1 ; } int p = 0 ; int q = 1 ; int res = 0 ; for (int i = 2 ; i <= n; i++) { res = p + q; p = q; q = res; } return res; }
版本三:快速幂
可以使用矩阵的快速幂方法,其时间复杂度为 O(logN)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 public static int f3 (int n) { if (n < 1 ) { return 0 ; } if (n == 1 || n == 2 ) { return 1 ; } int [][] base = new int [][]{{1 , 1 }, {1 , 0 }}; int [][] res = matrixPower(base, n - 2 ); return 1 * res[0 ][0 ] + 1 * res[1 ][0 ]; } public static int [][] matrixPower(int [][] m, int p) { int [][] res = new int [m.length][m[0 ].length]; for (int i = 0 ; i < m.length; i++) { res[i][i] = 1 ; } int [][] tmp = m; for (; p > 0 ; p >>= 1 ) { if ((p & 1 ) == 1 ) { res = matrixMulti(res, tmp); } tmp = matrixMulti(tmp, tmp); } return res; } private static int [][] matrixMulti(int [][] a, int [][] b) { int [][] res = new int [a.length][b[0 ].length]; for (int i = 0 ; i < a.length; i++) { for (int j = 0 ; j < b[0 ].length; j++) { for (int k = 0 ; k < a[0 ].length; k++) { res[i][j] += a[i][k] * b[k][j]; } } } return res; }
总结
快速幂的套路同样适合于严格递推关系 的动态规划问题,比如本题的 f(n) = f(n - 1) + f(n - 2)
,是一个严格递推关系,可以通过该关系枚举前几项构成方程组 从而推算出 M 矩阵的具体值以及矩阵公式。西面再介绍几个可以使用该套路的题目。
递推过程:先枚举出前几项,构造方程组求解 a b c d 的值,即可求得 M:
青蛙跳台阶
剑指 Offer 10- II. 青蛙跳台阶问题 :一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n
级的台阶总共有多少种跳法。
该问题与斐波那契数列非常相似,只是边界值不同而已:
青蛙跳台阶问题: f(0)=1, f(1)=1, f(2)=2
斐波那契数列问题: f(0)=0, f(1)=1, f(2)=1
该问题同样可以使用上面介绍的套路进行快速幂的计算,并且 M 矩阵也是相同的,唯一区别在于边界条件的值不相等。
母牛问题
一只母牛在出生三年后,每一年都可以开始生小牛,生出来的小牛在三年后也可以生小牛。假设母牛出生后永远不会死亡,则 n 年后总共有多少头牛?
该问题同样符合严格递推公式:f(n) = f(n - 1) + f(n - 3)
。
其中 f(n - 1) 代表前一年存活的牛肯定也会活到第 n 年;f(n - 3) 代表三年前存活的母牛在第 n 年肯定都可以生育新的小牛,所以也要加上
该问题若想采用快速幂进行求解时,可以构造成:f(n) = 1 * f(n - 1) + 0 * f(n - 2) + 1 * f(n - 3)
。此时要构造的就是一个 3*3 的矩阵,有三次项:
如果题目设定母牛会在 10 年后死亡,则该递推公式将改为:f(n) = f(n - 1) + f(n - 3) - f(n - 10)
把数字翻译成字符串
剑指 Offer 46. 把数字翻译成字符串 :规定1和A对应、2和B对应、3和C对应…那么一个数字字符串比如"111",就可以转化为"AAA"、“KA"和"AK”。给定一个只有数字字符组成的字符串str,返回有多少种转化结果。
思路:归纳出翻译的规则,字符串的第 i 位置:
可以单独作为一位来翻译
如果第 i - 1 位和第 i 位组成的数字在 10 到 25 之间,可以把这两位连起来翻译
记忆化递归尝试版本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 public static List<String> convert (String str) { char [] arr = str.toCharArray(); char [] tmp = new char [arr.length]; List<String> res = new ArrayList<>(); process(0 , arr, tmp, res); return res; } public static void process (int i, char [] arr, char [] tmp, List<String> res) { if (i == arr.length) { res.add(new String(tmp)); return ; } if (arr[i] == '0' ) { return ; } if (arr[i] >= '3' ) { tmp[i] = (char )((int )'A' + (arr[i] - '1' )); process(i + 1 , arr, tmp, res); clearCache(tmp, i+1 ); return ; } if (arr[i] == '1' ) { tmp[i] = 'A' ; process(i + 1 , arr, tmp, res); clearCache(tmp, i+1 ); if (i + 1 < arr.length) { tmp[i] = (char )((int )'A' + (10 + arr[i + 1 ] - '1' )); process(i + 2 , arr, tmp, res); clearCache(tmp, i+1 ); } return ; } if (arr[i] == '2' ) { tmp[i] = 'B' ; process(i + 1 , arr, tmp, res); clearCache(tmp, i+1 ); if (i + 1 < arr.length && arr[i + 1 ] <= '6' ) { tmp[i] = (char ) ((int ) 'A' + (20 + arr[i + 1 ] - '1' )); process(i + 2 , arr, tmp, res); clearCache(tmp, i+1 ); } } } public static void clearCache (char [] tmp, int count) { for (int i = count; i < tmp.length; i++) { tmp[i] = '0' ; } } public static void main (String[] args) { System.out.println(convert("123" )); }
简化版问题:统计出可行的方案
当问题只需要统计出可行的方案时(不要求实际转换),条件 3 和 4 可以进行合并:如果当前位的数字和下一位的数字组成的数字处于 10 和 27 之间,则统一进行新一轮的递归。
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public static int process (char [] str, int index) { if (index == str.length) { return 1 ; } if (str[index] == '0' ) { return 0 ; } int res = process(str, index + 1 ); if (index == str.length - 1 ) { return res; } if (((str[index] - '0' ) * 10 + str[index + 1 ] - '0' ) < 27 ) { res += process(str, index + 2 ); } return res; }
动态规划版本
题解:https://leetcode-cn.com/problems/ba-shu-zi-fan-yi-cheng-zi-fu-chuan-lcof/solution/ba-shu-zi-fan-yi-cheng-zi-fu-chuan-by-leetcode-sol/
我们可以用 f(i) 表示以第 i 位结尾的前缀串翻译的方案数,考虑第 i 位单独翻译和与前一位连接起来再翻译对 f(i) 的贡献。
单独翻译对 f(i) 的贡献为 f(i−1);
如果第 i - 1 位存在,并且第 i−1 位和第 i 位形成的数字 x 满足 10 ≤ x ≤ 25,那么就可以把第 i - 1 位和第 i 位连起来一起翻译,对 (i) 的贡献为 f(i−2),否则为 0。
我们可以列出这样的动态规划转移方程:f (i )=f (i −1)+f (i −2)[i −1≥0,10≤x ≤25]。边界条件是 f(-1) = 0,f(0) = 1。方程中 [c] 的意思是 c 为真的时候 [c] = 1,否则 [c] = 0。
本质上是青蛙跳台阶问题
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public int translateNum (int num) { if (num < 0 ) { return 0 ; } String str = String.valueOf(num); int p = 1 , q = 1 , r = 1 ; String tmp = null ; for (int i = 1 ; i < str.length(); i++) { r = p; tmp = str.substring(i - 1 , i + 1 ); if (tmp.compareTo("10" ) >= 0 && tmp.compareTo("25" ) <= 0 ) { r += q; } q = p; p = r; } return r; }
达标的字符串
给定一个数 N,想象只由 0 和 1 两种字符组成的所有长度为 N 的字符串。如果某个字符串任何 0 字符的左边都有 1 紧挨着,则认为这个字符串达标。返回有多少达标的字符串。
例如:
达标的字符串:11101011101110
不达标的字符串:001001000(只要有某个 0 的左边不是 1 就不达标)
思路:该问题同样是一道动态规划问题。可以进行以下尝试:
如果当前位置设置为 1,则其没有额外限制,截至目前为止的种类数为 f(n) = f(n - 1)
如果当前位置设置为 0,则其左边必须为 1,截至目前为止的种类数为 f(n) = f(n - 2)。因为 n-1 位置必须为 1,需要考虑前 n-2 的种类数
因此当前位置的总种类数为:f(n) = f(n - 1) + f(n - 2)
。
该问题与“把数字翻译成字符串”问题类似,本质上都是青蛙跳台阶问题
注意 :要从 1 开始遍历,因为 0 位置必须为 “1”
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 public static int getNum1 (int n) { if (n < 1 ) { return 0 ; } if (n == 1 ) { return 1 ; } int pre = 1 ; int cur = 1 ; int tmp = 0 ; for (int i = 2 ; i <= n; i++) { tmp = cur; cur += pre; pre = tmp; } return cur; } public static int getNum2 (int n) { if (n < 1 ) { return 0 ; } return process(1 , n); } public static int process (int i, int n) { if (i == n - 1 ) { return 2 ; } if (i == n) { return 1 ; } return process(i + 1 , n) + process(i + 2 , n); } public static int getNum3 (int n) { if (n < 1 ) { return 0 ; } if (n == 1 || n == 2 ) { return n; } int [][] base = { { 1 , 1 }, { 1 , 0 } }; int [][] res = matrixPower(base, n - 2 ); return 2 * res[0 ][0 ] + res[1 ][0 ]; } public static int [][] matrixPower(int [][] m, int p) { int [][] res = new int [m.length][m[0 ].length]; for (int i = 0 ; i < res.length; i++) { res[i][i] = 1 ; } int [][] tmp = m; for (; p != 0 ; p >>= 1 ) { if ((p & 1 ) != 0 ) { res = muliMatrix(res, tmp); } tmp = muliMatrix(tmp, tmp); } return res; } public static int [][] muliMatrix(int [][] m1, int [][] m2) { int [][] res = new int [m1.length][m2[0 ].length]; for (int i = 0 ; i < m1.length; i++) { for (int j = 0 ; j < m2[0 ].length; j++) { for (int k = 0 ; k < m2.length; k++) { res[i][j] += m1[i][k] * m2[k][j]; } } } return res; }
铺瓷砖
假设有一个 2*N 面积的地面,我们有足够多数量的瓷砖(尺寸为 1 * 2 或 2 * 1),则完全铺满该地面有多少种铺法。
思路:该问题的本质同样是青蛙跳台阶 。当前位置的尝试方法:
如果上一个瓷砖是 1 * 2 尺寸,则当前位置也必须是 1 * 2 尺寸。此时的铺法为 f(n - 2)。因为1 * 2 的尺寸占用了两个单元,所以要减二
如果上一个瓷砖是 2 * 1 尺寸,则当前位置也必须是 2 * 1 尺寸。此时的铺法为 f(n - 1)。因为2 * 1 的尺寸占用了一个单元,所以要减一
所以总的铺法就是f(n) = f(n - 1) + f(n - 2)
。
打家劫舍系列问题
打家劫舍 I
198. 打家劫舍 :你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
方法一:记忆化搜索版本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public int rob (int [] nums) { if (nums == null || nums.length == 0 ) { return 0 ; } int [] record = new int [nums.length]; for (int i = 0 ; i < record.length; i++) { record[i] = -1 ; } return recur(nums, 0 , record); } public int recur (int [] nums, int i, int [] record) { if (i == nums.length - 1 ) { return nums[nums.length - 1 ]; } if (i == nums.length) { return 0 ; } if (record[i] != -1 ) { return record[i]; } record[i] = Math.max( recur(nums, i + 1 , record), recur(nums, i + 2 , record) + nums[i] ); return record[i]; }
方法二:动态规划版本,使用三个变量 p
,q
,r
(注意p的初始值是二者的最大值 ,而不是nums[1]
)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public int rob (int [] nums) { if (nums == null ) { return 0 ; } if (nums.length == 1 ) { return nums[0 ]; } else if (nums.length == 2 ) { return Math.max(nums[0 ], nums[1 ]); } int p = nums[0 ]; int q = Math.max(nums[1 ], nums[0 ]); int r = 0 ; for (int i = 2 ; i < nums.length; i++) { r = Math.max(p + nums[i], q); p = q; q = r; } return r; }
方法三:动态规划版本,使用两个变量 p
,q
,r
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public int rob (int [] nums) { if (nums == null ) { return 0 ; } if (nums.length == 1 ) { return nums[0 ]; } int p = nums[0 ]; int q = Math.max(nums[1 ], nums[0 ]); for (int i = 2 ; i < nums.length; i++) { int tmp = q; q = Math.max(p + nums[i], q); p = tmp; } return q; }
打家劫舍 II
213. 打家劫舍 II :你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
思路:环状排列 意味着第一个房子和最后一个房子中只能选择一个偷窃 ,因此可以把此环状排列房间 问题约化为两个单排排列房间 子问题:
偷第一家,则计算偷 [0, n -1] 家的最大金额
不偷第一家(代表可能偷最后一家),然后计算偷 [1, n] 家的最大金额
最后求二者的最大值即可。
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public int rob (int [] nums) { if (nums.length == 0 ) return 0 ; if (nums.length == 1 ) return nums[0 ]; return Math.max(myRob(Arrays.copyOfRange(nums, 0 , nums.length - 1 )), myRob(Arrays.copyOfRange(nums, 1 , nums.length))); } private int myRob (int [] nums) { int pre = 0 , cur = 0 , tmp; for (int num : nums) { tmp = cur; cur = Math.max(pre + num, cur); pre = tmp; } return cur; }
打家劫舍 III
股票系列问题
买卖股票的最佳时机
假设把某股票的价格按照时间先后顺序存储在数组中,请问买卖该股票一次可能获得的最大利润是多少?
思想:遍历每个元素,规定必须在当前元素卖出 的情况下,在其前面哪个地方买 ,收益最大
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public int maxProfit (int [] prices) { if (prices == null || prices.length < 1 ) { return 0 ; } int ans = 0 ; int min = prices[0 ]; for (int i = 1 ; i < prices.length; i++) { min = Math.min(prices[i], min); ans = Math.max(prices[i] - min, ans); } return ans; }
买卖股票的最佳时机 II
给定一个数组 prices ,其中 prices[i] 是一支给定股票第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
思路:任意次数的购买,最佳策略就是记录所有上升的区间 的收益。将整体进行离散化 考虑,对每个上升的子区间都进行累加收益 ,代表只要发现有收益,就进行一笔买卖,累加收益。
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public int maxProfit (int [] prices) { int ans = 0 ; for (int i = 1 ; i < prices.length; i++) { if (prices[i] > prices[i - 1 ]) { ans += prices[i] - prices[i - 1 ]; } } return ans; }
买卖股票的最佳时机 III
给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成两笔 交易。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
整体思想:将在当前区间内买两次拆成两个步骤:
先在当前区间内买卖一次 ,并必须在当前区间内买一笔 ,并且令二者的总收益最大,这样后面再在当前点卖就肯定能获得最大收益;
第二次遍历所有位置,在每个当前位置卖出,即可得到在当前区间内买卖两笔 的总收益
那么遍历完所有位置,肯定能得到全局最大的收益值。
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 public int maxProfit (int [] prices) { if (prices == null || prices.length < 2 ) { return 0 ; } int [] doneOnceMax = new int [prices.length]; doneOnceMax[0 ] = 0 ; int min = prices[0 ]; for (int i = 1 ; i < prices.length; i++) { min = Math.min(min, prices[i]); doneOnceMax[i] = Math.max(prices[i] - min, doneOnceMax[i - 1 ]); } int [] doneOnceMinusOneBuyMax = new int [prices.length]; doneOnceMinusOneBuyMax[0 ] = -1 * prices[0 ]; for (int i = 1 ; i < prices.length; i++) { doneOnceMinusOneBuyMax[i] = Math.max(doneOnceMinusOneBuyMax[i - 1 ], doneOnceMax[i] - prices[i]); } int ans = 0 ; for (int i = 0 ; i < prices.length; i++) { ans = Math.max(doneOnceMinusOneBuyMax[i] + prices[i], ans); } return ans; }
左右括号系列问题
合法括号匹配序列的深度
一个合法的括号匹配序列有以下定义:
空串 “” 是一个合法的括号匹配序列
如果 “X” 和 “Y” 都是合法的括号匹配序列,“XY” 也是一个合法的括号匹配序列
如果 “X” 是一个合法的括号匹配序列,那么 “(X)” 也是一个合法的括号匹配序列
每个合法的括号序列都可以由以上规则生成。
例如:"","()","()()","((()))" 都是合法的括号序列。对于一个合法的括号序列我们又有以下定义它的深度:
空串 “” 的深度是0
如果字符串 “X” 的深度是 x,字符串 “Y” 的深度是y,那么字符串 “XY” 的深度为 max(x,y)
如果 “X” 的深度是 x,那么字符串 “(X)” 的深度是 x+1
例如:"()()()" 的深度是 1,"((()))" 的深度是 3。现在给你一个合法的括号序列,需要你计算出其深度。
思路:定义变量 count,遍历字符串:
当遇到左括号时,count++,代表当前子序列的深度加一;
当 count 等于 0 时,说明已经经过了某一个合法的括号子序列,记录此时的 count 到结果中(取最大值)
遍历完整个字符串后,即可得到最大的深度
代码:
括号序列合法化
给定义一个可能不合法的括号序列,求至少需要添加多少个括号才能令该括号序列变得合法。
思路:定义变量 count 记录左右括号出现的情况,定义 ans 记录需要添加的括号数量,遍历字符串:
如果遇到左括号,则 count++
如果遇到右括号,则先判断 count 此时是否是 0
如果是,则代表当前的右括号在左侧没有匹配的左括号,需要添加一个左括号,ans++
如果不是,则还不需要添加左括号,count–
遍历完毕后,count 的数量就是没有匹配的左括号的数量,ans 的数量就是没有匹配的右括号的数量,二者相加即是答案。
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public class NeedParentheses { public static int needParentheses (String s) { int count = 0 ; int ans = 0 ; for (int i = 0 ; i < s.length(); i++) { if (s.charAt(i) == '(' ) { count++; } else if (s.charAt(i) == ')' ) { if (count == 0 ) { ans++; } else { count--; } } } return ans + count; } public static void main (String[] args) { System.out.println(needParentheses("Sadadsd)dsd(((dsds))" )); } }
最长有效括号
32. 最长有效括号 :给你一个只包含 '('
和 ')'
的字符串,找出最长有效(格式正确且连续)括号子串的长度。
方法一:双指针 + 双向遍历
本质上是一种贪心策略
在此方法中,我们利用两个计数器 left 和 right。首先,我们从左到右遍历字符串,对于遇到的每个 左括号,我们增加 left 计数器,对于遇到的每个右括号,我们增加 right 计数器。每当 left 计数器与 right 计数器相等时,我们计算当前有效字符串的长度,并且记录目前为止找到的最长子字符串。当 right 计数器比 left 计数器大时,我们将 left 和 right 计数器同时变回 0 。
这样的做法贪心地考虑了以当前字符下标结尾的有效括号长度,每次当右括号数量多于左括号数量的时候之前的字符我们都扔掉不再考虑 ,重新从下一个字符开始计算 ,但这样会漏掉一种情况,就是遍历的时候左括号的数量始终大于右括号的数量,即 (()
,这种时候最长有效括号是求不出来的。
解决的方法也很简单,我们只需要从右往左遍历用类似的方法计算即可,只是这个时候判断条件反了过来:
当 left 计数器比 right 计数器大时,我们将 left 和 right 计数器同时变回 0
当 left 计数器与 right 计数器相等时,我们计算当前有效字符串的长度,并且记录目前为止找到的最长子字符串
思路总结:
一旦相等,就找到了一个局部的有效子串,更新长度,然后遍历(注意此时可不能令 left 和 right 归零,因为其后面可能还连着更长的有效子串)
如果一直是右括号(体现在 right > left),则不可能是有效的,那么算法就会一直令 left 和 right 清零,代表这些右括号开头的子串不可能有效
如果先是左括号,则 left > right,不会清空,继续遍历,直到 right == left,代表之前的左括号都找到了对应的匹配右括号,那么就进行更新,然后继续遍历。
直到 right > left,代表当前有效子串后面多加了一个右括号,导致当前子串不是有效的了,那么就二者一起清零,开始判断当前位置的下一个位置往后还有没有有效的(这里很重要的思想 就是:一旦 right > left,那么 [left, right] 区间内任意位置开头的子串都不可能再和后面的子串拼接起来了(因为当前位置的右括号始终补不上),所以才会直接令 left 和 right 清零,表示不再考虑刚才的子串了,重新开始,这也就避免了重复遍历数组,使得时间复杂度为 O(N))
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 public int longestValidParentheses02 (String s) { int left = 0 , right = 0 , maxlength = 0 ; for (int i = 0 ; i < s.length(); i++) { if (s.charAt(i) == '(' ) { left++; } else { right++; } if (left == right) { maxlength = Math.max(maxlength, 2 * right); } else if (right > left) { left = right = 0 ; } } left = right = 0 ; for (int i = s.length() - 1 ; i >= 0 ; i--) { if (s.charAt(i) == '(' ) { left++; } else { right++; } if (left == right) { maxlength = Math.max(maxlength, 2 * left); } else if (left > right) { left = right = 0 ; } } return maxlength; }
方法二:动态规划
https://www.bilibili.com/video/BV13g41157hK?p=21
思想:计算出以每个位置作为有效字符串的结尾 时,当前子字符串的长度,取最大值即可得到答案。当前位置能作为有效字符串的结尾的前提 是:当前位置必须是左括号 ,如果是右括号,肯定不能作为结尾。
令 pre 指向 dp[i-1],代表的合格子串的前一个位置 (见下图的黄色块)
判断 pre 位置的符号是否为 (:
如果是,则恰好能和当前 i 的 ) 组成一个 ( (...dp[i-1]...) )
合格子串(见下图的黄色块 + 绿色块 + 红色块)
如果 pre-1 位置 dp[pre - 1] 不为 0,则将其也纳入当前的子串(见下图的蓝色块),否则长度就是 dp[i-1] 的长度 + 2
如果是 ),则不能和当前的 )构成合格子串,则当前位置的 dp[i] 值为 0
示意图:
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 class Solution { public int longestValidParentheses (String s) { if (s == null || s.length() < 1 ) { return 0 ; } return longestValidParentheses03(s); } public int longestValidParentheses03 (String s) { int [] dp = new int [s.length()]; char [] str = s.toCharArray(); int pre = 0 ; int res = 0 ; for (int i = 1 ; i < s.length(); i++) { if (str[i] == '(' ) { dp[i] = 0 ; continue ; } else { pre = i - dp[i - 1 ] - 1 ; if (pre >= 0 && str[pre] == '(' ) { dp[i] = pre - 1 >=0 ? dp[i - 1 ] + 2 + dp[pre - 1 ] : dp[i - 1 ] + 2 ; } else { dp[i] = 0 ; } } res = Math.max(res, dp[i]); } return res; } }