文章目录
一、动态规划
1.什么是动态规划
动态规划的核心是保存已计算过的历史数据来减少计算。用空间来换算时间。
动态规划是对一个任务的拆解,对有重叠子问题的处理。
2.动态规划四大步骤
步骤一:定义dp数组的含义
步骤二:定义状态转移方程
步骤三:初始化过程转移的初始值
步骤四:可优化点(可选)
3.动态规划示例
1) 案例一:打家劫舍I 「来自leetcode198」
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋
(金额 = 1)。偷窃到的最高金额 = 2 + 9 + 1 = 12 。
如果你对于动态规划还不是很了解,或者没怎么做过动态规划的题目的话,那么 House Robber (小偷问题)这道题是一个非常好的入门题目。本文会以 House Robber 题目为例子,讲解动态规划题目的四个基本步骤。
动态规划的的四个解题步骤是:
步骤一:定义dp数组的含义
步骤二:定义状态转移方程
步骤三:初始化过程转移的初始值
步骤四:可优化点(可选)
下面我们一步一步地进行讲解。
步骤一:定义dp数组的含义
DP 数组也可以叫”子问题数组”,因为 DP 数组中的每一个元素都对应一个子问题。dp数组存储的值一般代表截止目前的最优值,在该题目中,我们定义:
dp[i] 代表到达第 i 个房屋偷得的最高金额,也就是当前最大子序和
无论房屋有几间,最后我们取到dp数组的最后一个值
就求得小偷偷得的最高金额
步骤二:定义状态转移方程
动态规划解决的问题,一般来说就是解决最优子问题,“自顶向下” 的去不断的计算每一步骤的最优值。
也就是想要得到dp[i]的值,我们必须要知道dp[i-1],dp[i-2],dp[i-3] … 的每一步的最优值,在这个状态转移的过程中,我们必须要想清楚怎么去定义关系式。然而在每一步的计算中,都与前几项有关系,这个固定的关系就是我们要寻找的重叠子问题,也同样是接下来要详细定义的动态方程
该题目中,当小偷到达第 i 个屋子的时候
,他的选择有两种:一种是偷,另外一种是不偷, 然后选择价值较大者
a. 偷的情况计算:必然是dp[3] = nums[2] + dp[1],如果是偷取该屋子的话,相邻屋子是不能偷取的,因此,通项式子是:dp[i] = nums[i-1] + dp[i-2]
b.不偷的情况计算:必然是dp[3] = dp[2],如果是不偷取该屋子的话,相邻屋子就是其最优值,因此,通项式子是:dp[i] = dp[i-1]
动态方程: dp[i] = max(dp[i-1], nums[i-1]+dp[i-2])
步骤三:初始化数值设定
初始化: 没有房子时,dp一个位置,即:dp[0] 1 当size=0时,没有房子,dp[0]=0;2 当size=1时,有一间房子,偷即可:dp[1]=nums[0]
public static int max(int[] ints){
//数组长度
int len = ints.length;
//当长度为0时 返回 0
if( len == 0 ){
return 0;
}
//当长度为1时 返回第1个数据
if( len == 1 ){
return ints[0];
}
//定义dp数组
int[] db = new int[len];
//初始化值
db[0] = ints[0];
db[1] = Math.max(ints[0],ints[1]);
//循环其他值
for (int i = 2; i < len; i++) {
//往前第2个数 + 当前数 与 往前第1个数 进行比较取值
// (因为需要隔一间房子进行偷)
db[i] = Math.max(db[i-2] + ints[i],db[i-1]);
}
return db[len-1];
}
步骤四:优化
从dp[i] = max(dp[i-1], nums[i-1]+dp[i-2])
关系来看,每一次动态变化,都与前两次状态有关系(dp[i-1], dp[i-2]),而前面的一些值是没有必要留存的.
public static int max(int[] ints){
int len = ints.length;
if( len == 0 ){
return 0;
}
if( len == 1 ){
return ints[0];
}
int one = ints[0];
int two = Math.max(ints[0],ints[1]);
int temp;
for (int i = 2; i < len; i++) {
temp = Math.max(one + ints[i],two);
one = two;
two = temp;
}
return two;
}
所以,dp只需要定义两个变量就好,将空间复杂度降为O(1)
总结:以上是 打家劫舍I 的全部分析,代码并不是最优的,但是比较容易理解的。如果想看更优的代码,可以前往leetcode去查看 力扣198打家劫舍1
2) 案例二:爬楼梯 「来自leetcode70」
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
示例 1:
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
示例 2:
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
下面我们来分析这个题目,分析前我们将动态规划4步在重新回顾一下
动态规划的的四个解题步骤是:
步骤一:定义dp数组的含义
步骤二:定义状态转移方程
步骤三:初始化过程转移的初始值
步骤四:可优化点(可选)
步骤一:定义dp数组的含义
dp[i] 代表着爬到当前位置的方法数
步骤二:定义状态转移方程
我们需要得到dp[i] 的话,我们需要知道 dp[0] dp[1] … dp[i-1] 的各值
本题中:1次可以爬1个台阶或者2个台阶 ,那么他爬到 第 i 个台阶有多少种方法呢 ?
当只有1个台阶时: 1种方法: 1
当只有2个台阶时: 2种方法: 11 2
当只有3个台阶时: 3种方法:111 12 21 (1个台阶的每一种方法爬2个台阶可得到 + 2个台阶的所有方法爬1个台阶)
当只有4个台阶时:5种方法:112 22 1111 121 211 (2个台阶的每一种方法爬2个台阶可得到 + 3个台阶的所有方法爬1个台阶)
由此可以得到: d[4] = d[3] + d[2] 则
d[i] = d[i-1] + d[i-2]
动态方程: d[i] = d[i-1] + d[i-2]
步骤三:初始化过程转移初始值
初始化:当有一个台阶时 dp[0] = 1 当有2个台阶时 dp[1] = 2
public int climbStairs(int n) {
if( n == 0 ){
return 0;
}
//1、定义dp数组的含义
// dp[i] 代表着爬到当前位置的方法数
// 这里也可以不用n+1 但不用n+1需要考虑下面初始化dp[1]的值越界
int[] dp = new int[n+1];
//2、定义状态转移方程
//需要得到dp[i] 的话,我们需要知道 dp[0] dp[1] ... dp[i-1] 的各值
//本题中:1次可以爬1个台阶或者2个台阶 ,那么他爬到 第 i 个台阶有多少
//种方法呢 ?
// 当只有1个台阶时: 1种方法: 1
// 当只有2个台阶时: 2种方法: 11 2
// 当只有3个台阶时: 3种方法:111 12 21
// (1个台阶的每一种方法爬2个台阶可得到 + 2个台阶的所有方法爬1个台阶)
// 当只有4个台阶时:5种方法:112 22 1111 121 211
//由此可以得到: d[4] = d[3] + d[2] d[i] = d[i-1] + d[i-2]
//3、初始化过程转移初始值
dp[0] = 1;
dp[1] = 2;
for (int i = 2; i < n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n-1];
}
步骤四:优化
从 d[i] = d[i-1] + d[i-2]
这个转换来看,我们只需要知道 i-1和i-2的值就可以了。也就是不需要知道其他的值,那我们就可以将 dp[] 数组转换为 2个变量来解决。
public int climbStairs1(int n) {
if( n == 0 ){
return 0;
}
if( n == 1 ){
return 1;
}
//1、定义dp数组的含义
int one;
int two;
//2、定义状态转移方程
//需要得到dp[i] 的话,我们需要知道 dp[0] dp[1] ... dp[i-1] 的各值
//本题中:1次可以爬1个台阶或者2个台阶 ,那么他爬到 第 i 个台阶有多少
//种方法呢 ?
// 当只有1个台阶时: 1种方法: 1
// 当只有2个台阶时: 2种方法: 11 2
// 当只有3个台阶时: 3种方法:111 12 21
// (1个台阶的每一种方法爬2个台阶可得到 + 2个台阶的所有方法爬1个台阶)
// 当只有4个台阶时:5种方法:112 22 1111 121 211
//由此可以得到: d[4] = d[3] + d[2] d[i] = d[i-1] + d[i-2]
//3、初始化过程转移初始值
one = 1;
two = 2;
for (int i = 2; i < n; i++) {
int temp = two;
two = one + two;
one = temp;
}
return two;
}
总结:爬楼梯的核心是理解: dp[i] = dp[i-1] + dp[i-2] 及考虑好越界就好。【leetcode传送门 爬楼梯】
二、回溯算法
1、什么是回溯算法
回溯算法实际上是一个搜索尝试过程算法,
核心枚举尝试解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。
回溯算法最主要的点就是:将值加入到路径当中,然后递归尝试新的值,当值不满足时,删除路径当中的当前值,继续尝试新的值。
2、回溯算法示例
1) 案例一:给定一个没有重复数字的序列,返回其所有可能的全排列。
输入: [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
我们先将实现代码放出来,大家先看一下
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
traceBack(res,new ArrayList<>(),nums);
return res;
}
public void traceBack(List<List<Integer>> res,List<Integer> tempList,int[] nums){
//结束点
if( tempList.size() == nums.length ){
res.add(new ArrayList<>(tempList));
return;
}
for (int i = 0; i < nums.length; i++) {
//因为不能重复,所以如果存在,则去除
if( tempList.contains(nums[i]) ){
continue;
}
//添加
tempList.add(nums[i]);
//回溯递归
traceBack(res,tempList,nums);
//清除
tempList.remove(tempList.size() - 1);
}
}
代码的核心点是在于
//因为不能重复,所以如果存在,则去除
if( tempList.contains(nums[i]) ){
continue;
}
//添加
tempList.add(nums[i]);
//回溯递归
traceBack(res,tempList,nums);
//清除
tempList.remove(tempList.size() - 1);
流程分析:
第一次循环 1 2 3 ,当 i = 1时,会进行下方 1 这一行的所有操作。
对于remove(temp.size - 1) 这里是要保证递归函数返回后,状态可以恢复到递归前,以此达到真正回溯。