动态规划
实际相当于每次遇到的情况处理都相同,那么处理n次和处理1次过程都是一样的,重心就是找到每个题目下的状态转移方程,依据方程进行逻辑编写即可。
打家劫舍 II
相邻房子不能都打劫,且所有房子环形建设。
public int rob(int[] nums) {
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 temp = 0;
int cur = 0;
int pre = 0;
for (int num : nums) {
temp = cur;
cur = Math.max(pre + num, cur);
pre = temp;
}
return cur;
}
首先用普通打家劫舍思路,遇到新房子要么打劫(dp[n-1]+num)要么跳过(dp[n]),所以状态转移方程为 dp[n+1]=max(dp[n],dp[n−1]+num)
。
在此基础上,环形带来的问题是头尾两个房子不能同时打劫,那么可以将两个情况分别讨论取大值,即Math.max(myRob(Arrays.copyOfRange(nums, 0, nums.length - 1)),myRob(Arrays.copyOfRange(nums, 1, nums.length)))
跳跃游戏
题目给定数组,数组元素代表该格可以跳的距离,判断是否可以跳完全部格。
public boolean canJump(int[] nums) {
int length = 0;
for (int i = 0; i <= length; i++) {
int temp = i + nums[i];
length = Math.max(length, temp);
if (length >= nums.length - 1) {
return true;
}
}
return false;
}
从数组开头开始,依据每格可以跳过的格数length内一格一格判断,其跳过的距离为i+nums[i],那么在length格内,找到跳得最远的那一格即可。
状态转移方程:dp[i]当前能跳到的最远距离 = Math.max(dp[i - 1], i + nums[i]当前格跳到的距离)
跳跃游戏 II
相比于上面,该题需要得到最短跳跃次数。
public int jump(int[] nums) {
int res = 0;
int rightMax = 0;
int maxJump = 0;
for (int i = 0; i < nums.length - 1; i++) {
maxJump = Math.max(i + nums[i], maxJump);
if (i == rightMax) {
rightMax = maxJump;
res++;
}
}
return res;
}
要最短跳跃次数,肯定在跳跃点有更加严格的要求,相比于跳跃游戏I,其使用的边界更新条件是每到一个新的格子就进行判断,用最大跳跃距离代替边界;而此题因为要记录跳跃次数,可以想每次跳跃标记条件应该是到达边界,到达边界就意味着当前标记的就是这一段内,可以跳到的最远距离,此时进行最远跳跃,再在新的范围内进行再次判断。
状态转移方程:dp[i] = 可以跳到i格的多个格中步数最少的dp[j] + 1
不同路径
求从矩阵左上角到矩阵右下角有多少种走法。
public int uniquePaths(int m, int n) {
int[][] res = new int[m][n];
for (int i = 0; i < m; i++) {
res[i][0] = 1;
}
for (int i = 0; i < n; i++) {
res[0][i] = 1;
}
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
res[i][j] = res[i - 1][j] + res[i][j - 1];
}
}
return res[m - 1][n - 1];
}
由于题目要求只能向右、向下走,所以第一行、第一列结果一定都是1,只能走直线到。
接下来倒着思考,每一格的可能性就是其上面格和左面格的路径结果,即状态转移方程为:dp[m][n] = dp[m - 1][n] + dp[m][n - 1]
通过整体遍历生成结果数组后,返回最右下角结果即可。
状态转移方程:dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
最长回文子串
public String longestPalindrome(String s) {
int max = 1;
int begin = 0;
boolean[][] dp = new boolean[s.length()][s.length()];
for (int i = 0; i < s.length(); i++) {
dp[i][i] = true;
}
for (int i = 1; i < s.length(); i++) {
for (int j = 0; j < i; j++) {
if (s.charAt(i) != s.charAt(j)) {
dp[j][i] = false;
} else {
if (i - j < 3) {
dp[j][i] = true;
} else {
dp[j][i] = dp[j + 1][i - 1];
}
}
if (dp[j][i] && i - j + 1 > max) {
max = i - j + 1;
begin = j;
}
}
}
return s.substring(begin, begin + max);
}
二维数组代表从i到j数组内容是否是回文字符串,由于单字必然是回文,初始化可将其对应数组内容设置为true,接着二重遍历,遍历i,再将0~i所有子字符串取出进行判断,判断方式并不是传统回文判断,而是依据头尾两字符,若两字符不相同,则必然不是回文字符串;若相同,分两种情况,长度为2时,必然是回文子串,则改变dp数组值,长度大于2时,其判断应依据去头去尾的子字符串的结果即dp[j + 1][i - 1]
,判断结束后再将其与最长长度进行判断即可。
状态转移方程:dp[i][j] = dp[i + 1][j - 1] && i和j字符是否相同
等差数列划分
求长度3以上的等差数列个数
public int numberOfArithmeticSlices(int[] nums) {
if (nums.length == 1) {
return 0;
}
int d = nums[0] - nums[1];
int temp = 0;
int res = 0;
for (int i = 2; i < nums.length; i++) {
if (nums[i - 1] - nums[i] == d) {
temp++;
} else {
d = nums[i - 1] - nums[i];
temp = 0;
}
res += temp;
}
return res;
}
记录两元素之间的方差,每次进行方差值判断,只要两次方差d值相同,就满足3元素的等差数列,temp就可加一,temp变量就存储当前子数组拥有的新组合数,每次判断结束给res+temp即可。
状态转移方程:dp[i] = 满足等差数列 ? dp[i - 1] + 1 : 0
单词拆分
给定一个目标单词,一个单词数组,判断能否用数组内的单词组合成目标单词
public boolean wordBreak(String s, List<String> wordDict) {
int maxWordLenth = 0;
Set<String> wordSet = new HashSet<>(wordDict.size());
for (String word : wordDict) {
wordSet.add(word);
maxWordLenth = Math.max(word.length(), maxWordLenth);
}
boolean[] dp = new boolean[s.length() + 1];
dp[0] = true;
for (int i = 1; i < dp.length; i++) {
for (int j = (i - maxWordLenth < 0 ? 0 : i - maxWordLenth); j < i; j++) {
if (dp[j] && wordSet.contains(s.substring(j, i))) {
dp[i] = true;
break;
}
}
}
return dp[dp.length - 1];
}
逻辑一定包含在数组中查找是否有对应单词的任务,在Java中可以先将单词一个一个放入Set,方便后续判断。
接下来进行遍历,内外层遍历,将其包含的字符串作为单词进行判断
左边界起始条件:当所处位置大于最长单词长度时,说明左侧不可能只有一个单词,就将左边界不从0开始,而是去除最长单词后开始
判断条件:首先要满足条件,其左侧字符串必然要满足单词条件,即dp[左边界]为true,然后再进行单词判断
此处用的dp长度和遍历i从1开始,只是为了边界(j为0)也能套入状态转移情况。
由于单词是可以重新使用的,所以在dp[i]=true设置完后,说明长度为i的字字符串已经能够由单词拼接完成,可以break出去即不用再对i长度子字符串进行尝试拼接。
状态转移方程:dp[i] = dp[左边界] && 左边界到i字符串符合单词条件
解码方法
给定数组,每一个或两个数字可以组成一个字母作为解码,判断有多少种可能
public int numDecodings(String s) {
int a = 0;
int b = 1;
int c = 0;
for (int i = 1; i <= s.length(); i++) {
c = 0;
if (s.charAt(i - 1) != '0') {
c +=b;
}
if (i > 1 && s.charAt(i - 2) != '0' && ((s.charAt(i - 2) - '0') * 10 + (s.charAt(i - 1) - '0') <= 26)) {
c += a;
}
a = b;
b = c;
}
return c;
}
在对字符判断时,每个字母判断情况都相同,即当前是作为单个数字变字母,还是两个数字变字母,不同情况所加的数值不同。
c作为当前结果数量,b为i-1能组成的组合数即当前数字自身作为一个字母,a为i-2能组成的组合数即当前数字和前一个数组一起组成字母。
类似青蛙跳格,到当前格是一步到还是两步到的处理。
状态转移方程:dp[i] = dp[i - 1](i作为单个字母) + dp[i-2](i和i-1组合成为一个字母)
最长递增子序列
给定数组,找出其中最长的递增子序列,不要求连续。
public int lengthOfLIS(int[] nums) {
int[] dp = new int[nums.length];
dp[0] = 1;
int max = 1;
Arrays.fill(dp, 1);
for (int i = 1; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[j] + 1, dp[i]);
}
}
max = Math.max(max, dp[i]);
}
return max;
}
查长度,依旧双重遍历,外部右边界,内部左边界,dp代表每个格左侧最长子序列,则状态转移方程为dp[i] = Math.max(dp[j] + 1, dp[i])
dp[i]为右边界以左最长子序列,dp[j]为左边界以左最长子序列,额外多了一格j,所以要加一。实际就是包含左边界在内以左的最长序列和右边界以左已记录最长子序列求大值。
最长递增子序列的个数
给定数组,在上一题查找最长递增子序列的长度基础上,额外记录符合改长度的子序列个数。
public int findNumberOfLIS(int[] nums) {
int max = 1;
int[] f = new int[nums.length];
int[] g = new int[nums.length];
Arrays.fill(f, 1);
Arrays.fill(g, 1);
for (int i = 0; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
if (f[i] < f[j] + 1) {
f[i] = f[j] + 1;
g[i] = g[j];
} else if (f[i] == f[j] + 1) {
g[i] += g[j];
}
}
}
max = Math.max(max, f[i]);
}
int res = 0;
for (int i = 0; i < f.length; i++) {
if (f[i] == max) {
res += g[i];
}
}
return res;
}
f数组和上一题dp用途一样,为当前格以左已记录的最长子序列长度,所以处理也和上一题相同,边遍历边找大值。
g数组记录当前格以左的最长递增数列个数,对其处理就是当f数组值改变时即最长递增子序列长度变化,g数组记录的个数随之改变,分三种情况:
①dp[i] < dp[j] + 1:说明左边界的长度较长,所以g[右边界]应该赋值为g[左边界]
②dp[i] == dp[j] + 1:说明左右边界包含的长度相同,找到了一个新的符合条件的边界,g[右边界]应累加上g[左边界]
③dp[i] > dp[j] + 1:最长长度不变,所以对应计数也不用变更
f数组状态转移方程同上题dp:dp[i] = Math.max(dp[j] + 1, dp[i])
g数组状态转移方程由上述三种情况得出
最长公共子序列
给定字符串,查找最长子序列(可以不连续)
public int longestCommonSubsequence(String text1, String text2) {
int[][] dp = new int[text1.length() + 1][text2.length() + 1];
for (int i = 1; i <= text1.length(); i++) {
for (int j = 1; j <= text2.length(); j++) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]);
}
}
}
return dp[text1.length()][text2.length()];
}
双指针包裹遍历两个字符串加dp数组,状态转移方程:
两字符串找到相同字符:dp[i][j] = dp[i - 1][j - 1] + 1 即将当前相同字进行叠加
字符不相同:dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]) 因为要找最长,延续记录最长序列长度即可
两个字符串的删除操作
给两个字符串,找最少删除几次可以将两字符串变为相同
思路一:按上一题思路,找到最长子序列后,多出的部分就是要删掉的
思路二:
public int minDistance(String word1, String word2) {
int[][] dp = new int[word1.length() + 1][word2.length() + 1];
for (int i = 1; i < dp.length; i++) {
dp[i][0] = i;
}
for (int i = 1; i < dp[0].length; i++) {
dp[0][i] = i;
}
for (int i = 1; i <= word1.length(); i++) {
for (int j = 1; j <= word2.length(); j++) {
if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + 1;
}
}
}
return dp[word1.length()][word2.length()];
}
和最长公共子序列思路一样,额外多数组的初始化操作,按最差情况即全部删除来初始化。
状态转移方程:
两字符相同:dp[i][j] = dp[i - 1][j - 1] 即延续记录
字符不同:dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + 1 因为要查找最少删除次数,因为字符不同,需要在先前两记录较少数多加一次
零钱兑换
给零钱数组和目标钱数,查找使用最少数量凑齐钱数。
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
for (int j = 0; j < coins.length; j++) {
if (coins[j] <= i) {
dp[i] = Math.min(dp[i - coins[j]] + 1, dp[i]);
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
需要有一个思路,在基于现有钱数,最优办法是dp[总钱数-当前钱数]叠加出来的。
双重遍历,外层从1开始逐步组合到amount目标钱数,内层遍历零钱数组进行组合。
当零钱数小于目标钱数,说明可以利用,布置状态转移方程:dp[目标钱数i]=Math.min(dp[余下钱数i - coins[j] + 1, dp[目标钱数i])
即要么用自己已有的金钱计划,要么基于余下钱数的最优解+1
整数拆分
给定数字,求其拆分数(按和拆分)乘积最大的组合。
public int integerBreak(int n) {
int[] dp = new int[n + 1];
for (int i = 2; i <= n; i++) {
int temp = 0;
for (int j = 1; j < i; j++) {
temp = Math.max(temp, Math.max(j * (i - j), j * dp[i - j]));
}
dp[i] = temp;
}
return dp[n];
}
双重遍历,外层从2(最小2能分成两个数)遍历到n逐层增加拆分的目标数,内部遍历将其进行拆分并更新状态转移方程:
当前目标数现有最大乘积、拆分成两个数乘积j*(i-j)、在dp基础上额外多乘一个数j*dp[i-j],三者取最大用于更新方程。
编辑距离
两字符串ab,可以对a进行替换、删除、增加使其和b相同,求最少步骤数。
public int minDistance(String word1, String word2) {
int[][] dp = new int[word1.length() + 1][word2.length() + 1];
for (int i = 0; i <= word1.length(); i++) {
dp[i][0] = i;
}
for (int i = 0; i <= word2.length(); i++) {
dp[0][i] = i;
}
for (int i = 1; i <= word1.length(); i++) {
for (int j = 1; j <= word2.length(); j++) {
if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = Math.min(dp[i - 1][j - 1], Math.min(dp[i][j - 1], dp[i - 1][j])) + 1;
}
}
}
return dp[word1.length()][word2.length()];
}
和两个字符串的删除操作思路完全一样,先初始化dp数组,按最差情况处理。
双指针分别遍历判断字符,同时更新状态转移方程:
两字符相同时:dp[i][j] = dp[i - 1][j - 1] 无需操作,延续记录即可
两字符不同时:dp[i][j] = Math.min(dp[i - 1][j - 1], Math.min(dp[i][j - 1], dp[i - 1][j])) + 1; 需要进行替换dp[i - 1][j - 1]、删除dp[i - 1][j](删除字符串a的一个字,相当于将其指针推前一位进行判断)、添加dp[i][j - 1](给字符串a添加一个符合要求的字符,相当于把b指针推前一位进行判断)三种操作,取其操作数最小的进行记录。