目录
动态规划(Dynamic Programming)
动态规划之所以比暴力算法(回溯算法等)快,是因为动态规划技巧消除了重叠子问题。
动态规划是分治思想的延伸,通俗一点来说就是一种大事化小,小事化无的艺术。在将大问题化解为小问题的分治过程中,保存对这些小问题已经处理好的结果,并供后面处理大问题时直接使用这些结果。
动态规划 需要用最优解来填充一张表 (一维表 or 二维表)
动态规划具备了以下三个特点
- 把原来的问题分解成了几个相似的子问题。
- 所有的子问题都只需要解决一次。
- 储存子问题的解。
动态规划的三要素:重叠子问题、最优子结构、状态转移方程
动态规划的本质是对问题状态的定义和状态转移方程的定义(状态以及状态之间的递推关系)。
动态规划问题的求解步骤:
- 状态定义
- 状态间的转移方程定义
- 状态的初始化
- 返回值
注意:
状态定义的要求:定义的状态一定要形成递推关系。
适用场景:
最大值/最小值, 可不可行, 是不是,方案个数
Ⅰ 斐波那契数列
大家都知道斐波那契数列,现在要求输入一个整数n,请你输出斐波那契数列的第n项(从0开始,第0项为0,第1项是1)。注意:n<=39
链接:斐波那契数列
(1)传统递归解法:
public class Solution {
public int Fibonacci(int n) {
if(n<=0){
return 0;
}
if(n==1||n==2){
return 1;
}
return Fibonacci(n-1)+Fibonacci(n-2);
}
}
缺点:递归的方法时间复杂度为O(2^n)
,随着n的增大呈现指数增长,效率低下,当输入n比较大时,可能导致栈溢出,且在递归过程中有大量的冗余计算。
这些冗余的计算就可以理解为是重叠子问题,动态规划就是要规避这些重叠子问题。
注意:
当遇到递归问题时,都可以尝试去画 递归树,便于分析算法的时间复杂度。
递归算法的时间复杂度计算:子问题的个数(每个节点)*子问题的时间复杂度
子问题的个数即为二叉树的节点总数O(2n),解决一个子问题的时间为O(1), 本题中没有循环操作只有一个加法操作。所以本题的时间复杂度为O(2n)。
带备忘录的递归解法:
即然耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录(数组、哈希表等容器)」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不用再耗时去计算了。
public class Solution {
public int Fibonacci(int n) {
if(n<1){
return 0;
}
int[] table = new int[n+1];
return dps(table,n);
}
public int dps(int[] table,int n){
if(n==1 || n==2){
return 1;
}
if(table[n]!=0){
return table[n];
}
table[n] = dps(table,n-1)+dps(table,n-2);
return table[n];
}
}
带备忘录的递归算法,相当于将冗余的子问题进行了剪枝。剪枝之后的节点个数相当于为fib(1) fib(2)…fib(n-1) fib(n)共n个节点,时间复杂度也就变为O(n).
注意:
带备忘录的递归算法是一个 自顶向下 的求解过程,即总是从大问题如fib(6),向下逐层分解,直到触底fib(1),fib(2),然后再逐层返回答案。
动态规划是一种 自底向上 的算法,即总是从小问题着手,从fib(1),fib(2),逐层向上递推,直到得到我们想要的答案。这也是为啥,动态规划用循环就能完成的原因。
在备忘录上完成自底向上的动态规划算法:
public int Fibonacci(int n) {
if(n<1){
return 0;
}
if(n==1 || n==2){
return 1;
}
int[] table = new int[n+1];
table[1] = 1;
table[2] = 1;
for(int i=3;i<=n;i++){
table[i] = table[i-1]+table[i-2];
}
return table[n];
}
本题中,当前状态之和前面的两个状态有关,没必要整这么长的备忘录来存储状态,所以我们只需要保存前面两个状态就好了,将空间复杂度置为O(1),下面是优化;
(3)动态规划解法:
状态:F(i)
状态递推:F(i)=F(i-1)+F(i-2)
初始值:F(1)=F(2)=1
返回结果:F(N)
F(n)只与它相邻的前两项有关,所以只需要保存两个子问题的解就可以了,通过不断递推就可以得到最终的解。
public class Solution {
public int Fibonacci(int n) {
if(n<=0){
return 0;
}
if(n==1||n==2){
return 1;
}
int fn=0;
int f1=1,f2=1;
for(int i=3;i<=n;i++){
fn = f1 +f2;
f1 =f2;
f2 = fn;
}
return fn;
}
}
2 零钱兑换
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1
输入: coins = [2], amount = 3
输出: -1
来源:力扣(LeetCode)
我们通过这道题来看最优子结构。对于本题的最优子结构可以这么理解,如果知道凑出10的最小硬币数,那么凑出11的的最小硬币数就为凑出10的最小硬币数+1.
状态:dp[i]
凑出总值为i
的最小硬币数
转移方程:dp[i] = min{dp[i],dp[i-j]};
初始值:dp[i]=i;
class Solution {
public int coinChange(int[] coins, int amount) {
if(amount<0){
return -1;
}
int[] dp = new int[amount+1];
for(int i=1;i<dp.length;i++){
dp[i]=amount+1;
//初始化为amount+1,相当于初始化为Integer.MAX_VALUE-1
}
dp[0] = 0;
for(int i=1;i<=amount;i++){
for(int j=0;j<coins.length;j++){
if(i-coins[j]<0){
continue;
}
dp[i] = Math.min(dp[i],dp[i-coins[j]]+1);
}
}
return (dp[amount]==amount+1)? -1:dp[amount];
}
}
3 青蛙跳台阶
(1)疯狂青蛙(可以随意跳)
一只青蛙一次可以跳上1级台阶,也可以跳上2级……它也可以跳上n级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
链接:疯狂青蛙
通过上面的简单举例,可知通过第0层直接跳到第三层,通过第一层可以直接跳到第三层,通过第二层也可以直接跳到第三层。
F(i) = F(i-1)+F(i-2)+F(i-3)+…+F(1)+F(0);
F(i-1)= F(i-2)+F(i-3)+…+F(1)+F(0);
得:F(i) = 2*F(i-1)
状态:F(i)跳上i级台阶的方法个数
状态递推:F(i) = 2*F(i-1)
初始值:F(1)=1
返回值:F(n)
public class Solution {
public int JumpFloorII(int target) {
if(target==1){
return 1;
}
int f1=1;
int fn=0;
for(int i =2;i<=target;i++){
fn=2*f1;
f1=fn;
}
return fn;
}
}
(2)普通青蛙(只能跳1级或2级)
若一次只能跳1级或者2级,那么问题求解就变成斐波那契问题。
链接:普通青蛙
状态:F(i)跳上i级台阶的方法个数
状态递推:F(i) = F(i-1)+F(i-2)
初始值:F(1)=1, F(2)=2
返回值:F(n)
public class Solution {
public int JumpFloor(int target) {
if(target==1){
return 1;
}
if(target==2){
return 2;
}
int fn=0;
int f1=1,f2=2;
for(int i=3;i<=target;i++){
fn=f1+f2;
f1=f2;
f2 = fn;
}
return fn;
}
}
4 矩形覆盖问题
我们可以用2*1
的小矩形横着或者竖着去覆盖更大的矩形。请问用n个2*1
的小矩形无重叠地覆盖一个2*n
的大矩形,总共有多少种方法?
比如n=3时,2*3的矩形块有3种覆盖方法:
链接:矩形覆盖问题
矩形的最后部分有两种放法,一种是竖着放一个的情况,此时前面还有i-1个小正方形;一种是横着放的情况,此时前面还有i-2个小矩形。所以i个小矩形可以总结为i-1个小矩形的覆盖方法数和i-2个小矩形的覆盖方法数之和。
状态F(i):用i个2*1
无重复覆盖2*i
的大矩形的方法个数
状态递推:F(i) = F(i-1)+F(i-2)
初始值:F(1)=1, F(2)=2
返回值:F(n)
public class Solution {
public int RectCover(int target) {
if(target==1){
return 1;
}
if(target == 2){
return 2;
}
int f1 = 1, f2 = 2;
int fn=0;
for(int i=3;i<=target;i++){
fn = f1+f2;
f1 = f2;
f2 = fn;
}
return fn;
}
}
5 最大连续子数组和
在古老的一维模式识别中,常常需要计算连续子向量的最大和,当向量全为正数的时候,问题很好解决。但是,如果向量中包含负数,是否应该包含某个负数,并期望旁边的正数会弥补它呢?例如:{6,-3,-2,7,-15,1,2,2},连续子向量的最大和为8(从第0个开始,到第3个为止)。给一个数组,返回它的最大连续子序列的和(子向量的长度至少是1)。
链接:最大连续子数组和
得到多个以第i个元素结尾的最大值F(0),F(1),F(2)…F(i),然后从F(i)中选出一个最大的。
状态:以第i个元素结尾的最大连续和
状态递归:F(i)=max(F(i-1)+a[i],a[i])
初始值:F(0)=a[0]
返回值:max(F(i))——返回F(i)中的最大值
public class Solution {
public int FindGreatestSumOfSubArray(int[] array) {
int maxNum=array[0];
int curNum = array[0];
for(int i=1;i<array.length;i++){
curNum = Math.max(curNum+array[i],array[i]);
if(maxNum<curNum){
maxNum = curNum;
}
}
return maxNum;
}
}
6 字符串分割——单词拆分Ⅰ
给定一个字符串s和一组单词dict,判断s是否可以用空格分割成一个单词序列,使得单词序列中所有的单词都是dict中的单词(序列可以包含一个或多个单词)。
例如:
给定s=“leetcode”;
dict=[“leet”, “code”].
返回true,因为"leetcode"可以被分割成"leet code".
链接:字符串分割
F(2)中F(1)代表以F(1)为界限进行分割,因为F(1)=false,所以F(1)为界限分割的F(2)肯定为false,除了考虑在F(1)的基础上的分割情况,还要考虑F(2)的整体情况。
F(4)为true,所以可以考虑除去F(4),剩余字符的匹配情况。
状态F(i):前i个字符能否被分割
状态递推:F(i): j <i && F(j) && substr[j+1,i],在j小于i中,只要能找到一个F(j)为true,并且从j+1到i之间的字符能在词典中找到,则F(i)为true
初始值:辅助状态F(0):true
返回值:F(n)
注意:
- 对于初始值无法确定的,可以引入一个不代表实际意义的空状态,作为状态的起始。
- 空状态的值需要保证状态递推可以正确且顺利的进行。
import java.util.*;
public class Solution {
public boolean wordBreak(String s, Set<String> dict) {
boolean[] canBreak = new boolean[s.length()+1];
// 初始化F(0) = true
canBreak[0]=true;
for(int i=1;i<=s.length();i++){
for(int j=0;j<=i-1;j++){
// 第j+1个字符的索引为j
if(canBreak[j]&&dict.contains(s.substring(j,i))){
canBreak[i] = true;
break;
}
}
}
//最后一个索引
return canBreak[s.length()];
}
}
注意:
这里需要注意一下索引,一个是canBreak的索引(下标0为引入的辅助状态),一个是字符串s的索引(从小标0开始)。if(canBreak[j]&&dict.contains(s.substring(j,i)))
。
j==0时表示匹配整个字符。
7 三角矩阵
给出一个三角形,计算从三角形顶部到底部的最小路径和,每一步都可以移动到下面一行相邻的数字。
例如,给出的三角形如下:
[ [2],
[3 , 4],
[6, 5, 7],
[4, 1, 8, 3]
]
最小的从顶部到底部的路径和是2 + 3 + 5 + 1 = 11。
注意:
如果你能只用O(N)的额外的空间来完成这项工作的话,就可以得到附加分,其中N是三角形中的行总数。
链接:三角矩阵
方法一:从上到下递推
初始值为第一行,从上到下递推,求出每一行每一个值对应的最短路径。
状态F(i,j):从(0,0)到(i,j)的最短路径和
状态递推:
第一列:F(i,0) = F(i-1,0)+a[i][0]
中间位置:F(i,j)=min(F(i-1,j-1),F(i-1,j))+a[i][j]
最后一列:F(i,i)=F(i-1,i-1)+a[i][i]
初始值:F(0,0)=a[0][0]
返回值:在最后一行找一个最小值min(F(n-1,j))
import java.util.List;
import java.util.ArrayList;
public class Solution {
public int minimumTotal(ArrayList<ArrayList<Integer>> triangle) {
if(triangle.isEmpty()){
return 0;
}
//保存每一行,每个数值的最短路径
List<List<Integer>> minPathSum = new ArrayList<>();
for(int i = 0; i < triangle.size(); ++i) {
minPathSum.add(new ArrayList<>());
}
// F[0][0]初始化,将第0行第0列(第一个数)放入minPathSum中
minPathSum.get(0).add(triangle.get(0).get(0));
for(int i = 1; i < triangle.size(); ++i) {
int curSum = 0;
for(int j = 0; j <= i; ++j) {
// 处理左边界和右边界
if(j == 0) {
curSum = minPathSum.get(i - 1).get(0);
}
else if(j == i){
curSum = minPathSum.get(i - 1).get(j - 1);
}
else{
curSum = Math.min(minPathSum.get(i - 1).get(j),
minPathSum.get(i - 1).get(j - 1));
}
// F(i,j) = min( F(i-1, j-1), F(i-1, j)) + triangle[i][j]
minPathSum.get(i).add(triangle.get(i).get(j) + curSum);
}
}
int size = triangle.size();
// min(F(n-1, i)),将最后一行的第一个数作为最小值
int allMin = minPathSum.get(size - 1).get(0);
for(int i = 1; i < size; ++i)
{ //在第一行中选择最小值
allMin = Math.min(allMin,minPathSum.get(size - 1).get(i));
}
return allMin;
}
}
定义一个同样的三角形List集合,用来保存每个值对应的最短路径和。在List集合中的最后一行寻找全局最小路径和。
方法二:从下到上递推
初始值为最后一行,从下到上递推,求出每一行每一个值对应的最短路径,使用这种方法不用考虑边界值,也不需要在最后寻找最小值,直接返回F(0,0)即可。
状态F(i,j):从(i,j)到最后一行的最短路径和
状态递推:F(i,j) = min(F(i+1,j),F(i+1,j+1))+a[i][j]
初始值:F(n-1,0) = a[n-1][0], F(n-1,1) =a[n-1][1] ,…, F(n-1,n-1) = a[n-1][n-1]
返回值:F(0,0)
import java.util.*;
public class Solution {
public int minimumTotal(ArrayList<ArrayList<Integer>> triangle) {
int rows = triangle.size();
//i从最后一行开始
for(int i = rows-1;i>0;i--){
//当前计算从倒数第二行开始
ArrayList<Integer> list = triangle.get(i-1);
//当前计算的下一行
ArrayList<Integer> last = triangle.get(i);
//当前行的大小
int size = triangle.get(i-1).size();
for(int j=0;j<size;j++){
int min = Math.min(last.get(j),last.get(j+1));
triangle.get(i-1).set(j,min+list.get(j));
}
}
return triangle.get(0).get(0);
}
}
8 路径总数
一个机器人在m×n大小的地图的左上角(起点,下图中的标记“start"的位置)。
机器人每次向下或向右移动。机器人要到达地图的右下角。(终点,下图中的标记“Finish"的位置)。可以有多少种不同的路径从起点走到终点?
上图是3×7大小的地图,有多少不同的路径?
备注:m和n小于等于100
链接:路径总数
状态F(i,j):从(0,0)到达F(i,j)的路径数
状态递推:F(i,j) = F(i-1,j)+F(i,j-1)
初始化:F(0,i)=1 F(i,0)=1 第0行,第0列的特殊情况
返回值:F(m-1,n-1)
import java.util.*;
public class Solution {
public int uniquePaths (int m, int n) {
// write code here
//初始化
int[][] steps = new int[m][n];
for(int i=0;i<n;i++){
steps[0][i]=1;
}
for(int i=1;i<m;i++){
steps[i][0]=1;
}
//从第二行开始遍历
for(int i=1;i<m;i++){
for(int j=1;j<n;j++){
steps[i][j] = steps[i-1][j]+steps[i][j-1];
}
}
return steps[m-1][n-1];
}
}
9 加入障碍的路径总数
依旧是向右向下走,如果在图中加入了一些障碍,有多少不同的路径?分别用0和1代表空区域和障碍。
例如
下图表示有一个障碍在3*3
的图中央。
[
[0,0,0],
[0,1,0],
[0,0,0]
]
有2条不同的路径
备注:m和n不超过100.
链接:加入障碍的路径总数
状态F(i,j):从(0,0)到达F(i,j)的路径数
状态递推:if(a[i][j]==1){
F(i,j)=0
}else {
F(i,j)=F(i-1,j)+F(i,j-1)
}
初始值:
第一行if(a[0][j]==1) k>=j -> F(0,k)=0 else F(0,k)=1
第一列:if(a[i][0]==1) k>=i -> F(k,0) = 0 else F(k,0)=1
返回值:F(m-1,n-1)
import java.util.*;
public class Solution {
public int uniquePathsWithObstacles (int[][] obstacleGrid) {
//初始化
int rows = obstacleGrid.length;
int cols = obstacleGrid[0].length;
int[][] steps = new int[rows][cols];
//第一行
for(int i=0;i<cols;i++){
if(obstacleGrid[0][i]==0){
steps[0][i]=1;
}else{
while(i<cols){
steps[0][i]=0;
i++;
}
}
}
//第一列
for(int i=0;i<rows;i++){
if(obstacleGrid[i][0]==0){
steps[i][0]=1;
}else{
while(i<rows){
steps[i][0]=0;
i++;
}
}
}
//从第二行开始遍历
for(int i=1;i<rows;i++){
for(int j=1;j<cols;j++){
if(obstacleGrid[i][j]!=1){
steps[i][j] = steps[i-1][j]+steps[i][j-1];
}else{
steps[i][j]=0;
}
}
}
return steps[rows-1][cols-1];
}
}
10 最小路径和
给定一个由非负整数填充的m x n的二维数组,现在要从二维数组的左上角走到右下角,请找出路径上的所有数字之和最小的路径。
注意:每次只能向下或向右移动。
链接: 最小路径和
状态F(i,j):从(0,0)到(i,j)最短路径和
状态递归:F(i,j)=min(F(i-1,j),F(i,j-1))+a[i][j]
初始值:
第一行:F(0,j) = F(0,j-1)+a[0][j]
第一列:F(i,0) = F(i-1,0)+a[i][0]
返回值:F(m-1,n-1)
import java.util.*;
public class Solution {
public int minPathSum (int[][] grid) {
// write code here
//初始化
int rows = grid.length;
int cols = grid[0].length;
//行初始化
for(int i=1;i<cols;i++){
grid[0][i]=grid[0][i-1]+grid[0][i];
}
//列初始化
for(int i=1;i<rows;i++){
grid[i][0]=grid[i-1][0]+grid[i][0];
}
for(int i=1;i<rows;i++){
for(int j=1;j<cols;j++){
int min = Math.min(grid[i][j-1],grid[i-1][j]);
grid[i][j]=grid[i][j]+min;
}
}
return grid[rows-1][cols-1];
}
}
11 背包问题
有n 个物品和一个大小为m 的背包. 给定数组A 表示每个物品的大小和数组V 表示每个物品的价值. 问最多能装入背包的总价值是多大?
链接:背包问题
状态F(i,j):从前i个物品中选择包的大小为j时的最大值
递推状态:
如果第i个商品的大小大于j,则第i个商品不能放进去F(i,j)=F(i-1,j)
如果第i个商品的大小小于j,则第i个商品可以放进去:
(1)如果选择不放第i个商品F1(i,j) = F(i-1,j)
(2)如果选择放第i个商品F2(i,j) = F(i-1,j-A[i-1])+V[i-1]
,则最大值为放上当前物品后,剩余空间所能达到的最大值 加上 当前值。
在两种选择中选取总价值最大的:
F(i,j) = max(F1,F2)
初始值:
第一行:F(0,j) = 0 空包的价值
第一列:F(i,0) =0 没有包的价值
返回值:F(m-1,n-1)
注意:
第i个物品大小A[i-1]);第i个商品的价值V[i - 1]
以蓝圈举例:当空间为3时,以求得放2个时的最大价值4,当放第三个时若选择放则最大价值为5,不放最大价值为4,所以结果为5.
以红圈为例:当空间为4时,已求得放2个时的最大价值为5,当不放第3个时,最大价值就是5,当放第三个时,此时还有1个空余空间,1个空余空间可以容纳的最大价值为1,故结果为6.
public class Solution {
public int backPackII(int m, int[] A, int[] V) {
// write your code here
int num = A.length; //物品的数量
int[][] maxValue = new int[num+1][m+1];//增加一行用来辅助,增加一列,让空间从1开始增加
for(int i=1;i<=num;i++){
for(int j=1;j<=m;j++){
//放不下时
if(A[i-1]>j){
maxValue[i][j]=maxValue[i-1][j];
}else{
//如果放
int newValue = maxValue[i-1][j-A[i-1]]+V[i-1];
maxValue[i][j] = Math.max(newValue,maxValue[i-1][j]);
}
}
}
return maxValue[num][m];
}
}
- 如果放不下, maxValue(i,j)的最大价值和 maxValue(i-1,j)相同
- 如果可以装下,分两种情况:
1)不装,为maxValue(i-1,j)
2)装:即maxValue(i-1, j - A[i-1]) + 第i个商品的价值V[i - 1]
最后在装与不装中选出最大的价值
maxValue(i-1, j - A[i-1])
表示在除去第i个物品后,剩余空间下能装的最大值。
12 神奇的口袋 (背包问题)
有一个神奇的口袋,总的容积是40,用这个口袋可以变出一些物品,这些物品的总体积必须是40。John现在有n个想要得到的物品,每个物品的体积分别是a1,a2……an。John可以从这些物品中选择一些,如果选出的物体的总体积是40,那么利用这个神奇的口袋,John就可以得到这些物品。现在的问题是,John有多少种不同的选择物品的方式。
输入描述:
输入的第一行是正整数n (1 <= n <= 20),表示不同的物品的数目。接下来的n行,每行有一个1到40之间的正整数,分别给出a1,a2……an的值。
输出描述:
输出不同的选择物品的方式的数目。
输入
3
20
20
20
输出
3
链接:神奇的口袋
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
while(sc.hasNextInt()){
int n = sc.nextInt();
int[] arr = new int[n+1];
//该数组第一个元素为0,其余元素为要输入的元素
arr[0] = 0;
for(int i = 1;i<=n;i++){
arr[i] = sc.nextInt();
}
System.out.println(nNumAddSumCount(arr,40));
}
}
public static long nNumAddSumCount(int[] arr,int sum){
int n = arr.length;
//定义二维数组,用来表示方法数
long[][] dp = new long[n+1][sum+1];
//对第一列全部赋1,空间为0时的方案数
for(int i = 0;i<=n;i++){
dp[i][0] = 1;
}
//对第一行除第一个位置外其余赋0
for(int i = 1;i<=sum;i++){
dp[0][i] = 0;
}
for(int i = 1;i<=n;i++){
for(int j = 1;j<=sum;j++){
//如果arr[i]>j,此时(i,j)位置的值为(i-1,j)位置的值
if(arr[i-1]>j){//当前物品放不下
dp[i][j] = dp[i-1][j];
}else{ //不放当前物品时的方案数+放当前物品时的方案数
dp[i][j] = dp[i-1][j]+dp[i-1][j-arr[i-1]];
}
}
}
return dp[n][sum];
}
13 回文串分割
给出一个字符串s,分割s使得分割出的每一个子串都是回文串
计算将字符串s分割成回文分割结果的最小切割数。
例如:给定字符串s=“aab”,
返回1,因为回文分割结果[“aa”,“b”]是切割一次生成的。
链接:回文串分割
public class Solution {
public int minCut(String s) {
int len = s.length();
if(len == 0)
return 0;
int[] minCut = new int[len + 1];
// F(i)初始化
for(int i = 0; i <= len; ++i){
minCut[i] = i - 1;
}
for(int i = 1; i <= len; ++i){
for(int j = 0; j < i; ++j){
// 从最长串判断,如果从第j+1到i为回文字符串
// 则再加一次分割,从1到j,j+1到i的字符就全部分成了回文字符串
if(isPal(s, j, i - 1)){
minCut[i] = Math.min(minCut[i], minCut[j] + 1);
}
}
}
return minCut[len];
}
}
//判断是否回文串
public boolean isPal(String s, int start, int end){
while(start < end){
if(s.charAt(start) != s.charAt(end))
return false;
++start;
--end;
}
return true;
}
注意:
F(0)= -1
是必要项,如果没有这一项,对于重叠字符串“aaaaa”会产生错误的结果。
14 回文串分割(回文串判断优化)
我们在对回文串判断进行优化时,如果从前向后分割,因为第i处
需要用到第i+1处
的信息,所以应该从字符串末尾遍历分割。
回文串优化后,我们可以通过下标,i,j
来直接判断i到j
是否为回文串。
链接:回文串分割
状态F(i,j):区间[i,j]是否为回文串
状态递推:F(i,j) : s[i]==s[j] && F(i+1,j-1)
上式表示如果字符区间首尾字符相同且在去掉区间首尾字符后字符区间仍为回文串, 则原字符区间为回文串。
初始化:F(i,i)=true 单字符串为回文串
返回值:矩阵F(n,n)
public class Solution {
public int minCut(String s) {
int len = s.length();
if(len == 0)
return 0;
//提前将回文串判断做好了,不用每次都进行回文串的循环判断了
boolean[][] Mat = getMat(s);
int[] minCut = new int[len + 1];
// F(i)初始化
for(int i = 0; i <= len; ++i){
minCut[i] = i - 1;
}
for(int i = 1; i <= len; ++i){
for(int j = 0; j < i; ++j){
//回文串判断进行优化
if(Mat[j][i - 1]){
//注意:这里的j为起始点,i为终点。
minCut[i] = Math.min(minCut[i], minCut[j] + 1);
}
}
}
return minCut[len];
}
//字符串中的回文串判断
public boolean[][] getMat(String s){
int len = s.length();
boolean[][] mat = new boolean[len][len];
for(int i=len-1;i>=0;i--){
for(int j=i;j<len;j++){
if(i==j){ //单个字符
mat[i][j]=true;
}else if(j==i+1){ //相邻字符
if(s.charAt(i)==s.charAt(j)){
mat[i][j] = true;
}//else 默认为false,不用加了
}else if(s.charAt(i)==s.charAt(j)&&mat[i+1][j-1]){
mat[i][j] = true;
}
}
}
return mat;
}
}
15 编辑距离
给定两个单词word1和word2,请计算将word1转换为word2至少需要多少步操作。
你可以对一个单词执行以下3种操作:
a)在单词中插入一个字符
b)删除单词中的一个字符
c)替换单词中的一个字符
链接:编辑距离
问题举例理解: “ab” 到 “abc” 的最小步数
所有子问题:
""
到"a"
【及 到 “ab” 及 到 “abc”】的最优解- “a” 到 “a” 【及 到 “ab” 及 到 “abc”】的最优解
- “ab” 到 “a” 【及 到 “ab” 及 到 “abc”】的最优解
用这些最优解填充一张二维表,表的右下角为整个问题的"ab" 到 "abc"的最优解。
二维表:#代表空字符串
为方便形成递推关系,我们引入了空字符串。在完成初始化之后,从第二行开始遍历,每个最优解都对应如下的关系:
递推公式:
F(i,j)
代表word1的前i -1个字符组成的字符串到 word2的前j -1个字符组成的字符串的最优解
例:F(2, 3) 代表word1的前1个字符组成的字符串到 word2的前2个字符组成的字符串的最优解。
- 若
i 索引对应值 == j 索引对应值,
则意味着不需额外操作,则F(i,j) 等于 F(i - 1,j - 1)
- 若
i 索引对应值 != j 索引对应值
,则需要增加1步操作来转换:
以 “ab” 到 "abc"为例,该最优解为:min{"a" 到 "abc"的最优解, "ab" 到 "ab"的最优解,"a" 到 "ab" 的最优解 } + 1
所以该情况递推公式为:
F(i,j) = min{F(i - 1, j), F(i, j - 1),F(i - 1, j - 1) } + 1
import java.util.*;
public class Solution {
public int minDistance (String word1, String word2) {
// write code here
int len1 = word1.length();
int len2 = word2.length();
int[][] dp = new int[len1+1][len2+1];
//行初始化
for(int i=0;i<=len2;i++){
dp[0][i] = i; //空串->word2,相当于插入操作
}
//列初始化
for(int i=0;i<=len1;i++){
dp[i][0] = i;//word1->空串,相当于删除操作
}
//初始化完成开始遍历
for(int i=1;i<=len1;i++){
for(int j=1;j<=len2;j++){
//找三个操作的最小操作数
//进行替换操作时,下个字符相等则不需要进行替换
if(word1.charAt(i-1)==word2.charAt(j-1)){
dp[i][j] = dp[i-1][j-1];
}else{
dp[i][j] = dp[i-1][j-1]+1;
}
int temp = Math.min(dp[i - 1][j]+1 , dp[i][j - 1]+1);
dp[i][j] = Math.min(dp[i][j],temp);
}
}
return dp[len1][len2];
}
}
注意:
字符串类的动态规划,可引入空串进行初始化。
16 不同子序列
给定一个字符串S和一个字符串T,计算S中的T的不同子序列的个数。
字符串的子序列是由原来的字符串删除一些字符(也可以不删除)在不改变相对位置的情况下的剩余字符(例如,"ACE"is a subsequence of"ABCDE"但是"AEC"不是)。
例如:
S =“rabbbit”, T =“rabbit”
返回3
链接:不同子序列
母串rabbbit—子串rabbit :
问题的拆解方式:用dp,将问题抽取为各个长度的子串(0-m)
在各个母串区间区间(0-n)
上匹配的数目。
用一个二维表格,行是子串的各个字母,列是母串的各个字母。每个元素的行i
代表此次迭代所用字串为0-i
的这一整段(不是一个字母而是将开始到i
字母的这一段视为整体)。每个元素的列j
代表当前是在迭代从0到j
这一整段的母串视为一个整体,元素dp[i][j]
代表子串0-i
对于母串0-j
有多少种匹配方式。
举个例子:
通过举例可以得到递推关系式:
状态F(i,j):母串S[1:i]
中的子串与 T[1:j]
相同的个数
状态递推:F(i,j):
if(s[i-1]==t[j-1]):第i个字符和第j个字符相同
F(i,j) = F(i-1,j-1)+F(i-1,j)
else
F(i,j) = F(i-1,j)
初始值:
引入空串进行初始化,
F(i,0) = 1 —> S的子串与空串相同的个数,只有空串与空串相同
返回值:F(m,n)
注意:
要引入初始值,得以递推。
import java.util.*;
public class Solution {
public int numDistinct (String S, String T) {
// write code here
int rows = T.length();
int cols = S.length();
int[][] dp = new int[rows+1][cols+1];
//行初始化
for(int i=0;i<=cols;i++){
dp[0][i] = 1;//字串是空串时
}
//列初始化
for(int j=1;j<=rows;j++){ //除了空串和空串匹配为1,其余字串与空串匹配数都为0
dp[j][0] = 0;
}
//根据递推公式遍历
for(int i=1;i<=rows;i++){//字串
for(int j=1;j<=cols;j++){//母串
if(S.charAt(j-1)==T.charAt(i-1)){
dp[i][j] = dp[i-1][j-1]+dp[i][j-1];
}else{
dp[i][j] = dp[i][j-1];
}
}
}
return dp[rows][cols];
}
}
17 餐桌
某餐馆有n张桌子,每张桌子有一个参数:a 可容纳的最大人数; 有m批客人,每批客人有两个参数:b人数,c预计消费金额。 在不允许拼桌的情况下,请实现一个算法选择其中一部分客人,使得总预计消费金额最大
输入描述:
输入包括m+2行。 第一行两个整数n(1 <= n <= 50000),m(1 <= m <= 50000) 第二行为n个参数a,即每个桌子可容纳的最大人数,以空格分隔,范围均在32位int范围内。 接下来m行,每行两个参数b,c。分别表示第i批客人的人数和预计消费金额,以空格分隔,范围均在32位int范围内。
输出描述:
输出一个整数,表示最大的总预计消费金额
输入
3 5 2 4 2 1 3 3 5 3 7 5 9 1 10
输出
20
链接:餐桌
本题考察贪心算法和二分查找的结合。(也有点背包问题在里面)
import java.util.*;
public class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int[] table = new int[n];
int[] verify = new int[n];//负责记录桌子是否被用
for(int i=0;i<n;i++){
table[i] = sc.nextInt();
}
Arrays.sort(table);
int[][] persons = new int[m][2];
for(int i=0;i<m;i++){
int num = sc.nextInt();
int money = sc.nextInt();
persons[i][0]=num;
persons[i][1]=money;
}
Arrays.sort(persons,(int[] a,int[] b)->{return b[1]-a[1];});
long max=0;
for(int i=0;i<m;i++){
if(persons[i][0]>table[n-1]){
continue;
}
int nums = persons[i][0];
int cost = persons[i][1];
//找到尽量能容纳下这批人的桌位
int index = findTable(table,nums);
//能容纳下的桌位,可能已经满人了,所以可能需要向后查找空位
while(index<n&&verify[index]==1){
index++;
}
if(index<n){
max+=cost;
verify[index]=1;
}
}
System.out.println(max);
}
//二分查找
public static int findTable(int[] table,int nums){
int start = 0;
int end = table.length-1;
while(start<=end){
int mid = (start+end)/2;
if(table[mid]>=nums){
end = mid-1;
}else{
start=mid+1;
}
}
return start;
}
}
注意:
桌子可容纳的最大人数可能会是一样的,例如:1 2 2 2 3 3 3 5
所以在二分的时候,我们要找到相同数字的最左边,,如果二分没找到的桌子满了,就需要向后查找,找到一个更大的桌子。
二维数组按第二列元素从大到小排序。
Arrays.sort(persons,(int[] a,int[] b)->{return b[1]-a[1];});
这里可以这样理解,二元数组里面的元素相当于是一个个一元数组构成的,所以int[] a,int[] b
相当于二元数组里面的元素。b[1]-a[1]
相当于取该元素的第二列来处理。