算法思想
递归
递归指的是在函数的定义中使用函数自身的方法
递归需要注意的三个点:
1.明确递归终止条件;
2.给出递归终止时的处理办法;
3.提取重复的逻辑,缩小问题规模
楼梯问题
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
/**
*
* 假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
* 每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
* 注意:给定 n 是一个正整数。
*
* 示例 1:
* 输入: 2
* 输出: 2
* 解释: 有两种方法可以爬到楼顶。
* 1. 1 阶 + 1 阶
* 2. 2 阶
*
* 示例 2:
* 输入: 3
* 输出: 3
* 解释: 有三种方法可以爬到楼顶。
* 1. 1 阶 + 1 阶 + 1 阶
* 2. 1 阶 + 2 阶
* 3. 2 阶 + 1 阶
*
*/
public static int solution1(int stairs) {
if (stairs == 1) {
return 1;
} else if (stairs == NUM) {
return 2;
}
return solution1(stairs - 1) + solution1(stairs - NUM);
}
solution1最大的问题是大量的重复计算问题,优化一下。
public static int solution2(int stairs) {
if (stairs == 1) {
return 1;
} else if (stairs == NUM){
return NUM;
}
int res = 2;
int pre = 1;
int tmp;
for (int i = 3; i <= stairs; i++) {
tmp = res;
res = res + pre;
pre = tmp;
}
return res;
}
solution2并不是递归方式,最好的方法是将已经计算过的值缓存起来,后续需要就直接从缓存中读取即可
public static int[] cache = new int[1];
/**
* 记忆法
* @param stairs
* @return
*/
public static int solution3(int stairs) {
if (stairs == 1) {
return 1;
}
if (stairs == NUM) {
return 2;
}
if (cache.length < stairs) {
cache = new int[stairs + 1];
cache[1] = 1;
cache[2] = 2;
}
if (cache[stairs] != 0) {
return cache[stairs];
}
int result = solution3(stairs - 1) + solution3(stairs - 2);
cache[stairs] = result;
return result;
}
回溯法
枚举法的一种,但是,回溯法能够找到所有(或一部分)解的一般性算法,同时避免不必要的情况。一旦出现不正确的情况,就不再递归到下一层,而是回溯到上一层,是一种走不通就回退的方式。
78.子集Subsets
给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
示例:
输入: nums = [1,2,3]
输出:
[
[3],
[1],
[2],
[1,2,3],
[1,3],
[2,3],
[1,2],
[]
]
/**
*
* 回溯思想
* 参考labuladong
* 回溯算法的模板:
* result = []
* def backtrack(路径, 选择列表):
* if 满足结束条件:
* result.add(路径)
* return
* for 选择 in 选择列表:
* 做选择
* backtrack(路径, 选择列表)
* 撤销选择
* @param nums
* @return
*/
public static List<List<Integer>> solution3(List<Integer> nums) {
List<List<Integer>> res = new ArrayList<>();
backTrack(res, 0, nums, new ArrayList<>());
return res;
}
private static void backTrack(List<List<Integer>> res, int i, List<Integer> nums, List<Integer> track) {
res.add(new ArrayList<>(track));
for (int j = i; j < nums.size(); j++) {
track.add(nums.get(j));
backTrack(res, j + 1, nums, track);
track.remove(track.size() - 1);
}
}
动态规划
宝石挑选
一共有n块宝石,每块宝石有其对应的价值,由于,某些宝石质量过于差劲,因此存在只有店家倒贴钱,才愿意带走的宝石,即价值可为负数。
可以免费带走一个连续区间的宝石,比如区间[1,3]或者区间[2,4]中的宝石。
如何能带走最大价值的宝石?
问题分析
首先,思考最暴力的解法。
枚举所有区间,暴力累加区间中宝石的价值,最后选取价值最大的区间,时间复杂度O(n^3)
/**
* 一共有n块宝石,每块宝石有其对应的价值,由于,某些宝石质量过于差劲,因此存在只有店家倒贴钱,才愿意带走的宝石,即价值可为负数。
* 可以免费带走一个连续区间的宝石,比如区间[1,3]或者区间[2,4]中的宝石。
* 如何能带走最大价值的宝石?
*
* input: [1, -2, 4, -1, 6, 2]
* out: 11
*
* @return
*/
public static int solution1(int[] jewels) {
int max = 0;
for (int i = 1; i < jewels.length; i++) {
for (int j = 0; j < jewels.length; j++) {
int k = i;
int worth = 0;
while (k > 0) {
if (j + k <= jewels.length) {
worth += jewels[j + k - 1];
k--;
} else {
break;
}
}
if (worth > max) {
max = worth;
}
}
}
return max;
}
优化1.0
进行累加方式,固定从左到右计算区间中宝石的价值,可将时间复杂度优化到O(n^2)
public static int solution2(int[] jewels) {
int max = 0;
for (int i = jewels.length - 1; i > 0; i--) {
for (int j = 0; j < i; j++) {
int worth = Arrays.stream(jewels, j, i + 1).sum();
if (worth > max) {
max = worth;
}
}
}
return max;
}
优化2.0
观察O(n^2)的解法,发现用了O(n)的时间复杂度求出固定某个点为区间的右端点时的区间最大价值和,即O(n)的时间复杂度
「以 n 为区间右端点的区间最大和」,与「以 n - 1 为区间右端点的区间最大和」,这两者是否有关联呢?
为了描述方便,接下来我们用 f[i] 来代替「以 i 为区间右端点的区间最大和」,用 a[i] 来代替第 i 块宝石的价值。
不难发现,如果 f[n - 1] 为正数,则 f[n] 一定等于 f[n - 1] + a[n];如果 f[n - 1] 为负数,则 f[n] 一定等于 a[n]。因此我们可以推导出如下转移方程:
f[i] = max(f[i - 1] + a[i], a[i])
根据上述转移方程,我们可以在 O(n) 时间复杂度内求出最大的 f[i],即将此题时间复杂度优化到 O(n),而这个优化的过程就是「动态规划」的过程
在上述推导过程中,一共分为两步:
- 将整个问题划分为一个个子问题,并令 f[i] 为第 i 个子问题的答案
- 思考大规模的子问题如何从小规模的子问题推导而来,即如何由 f[i - 1] 推出 f[i]
这两个步骤便是「动态规划」解题思路的核心所在,即确定动态规划时的「状态」与「转移方程」。
/**
* @param jewels
* @return
*/
public static int solution3(int[] jewels) {
int max = 0;
int mid = 0;
for (int i = 0; i < jewels.length; i++) {
mid += jewels[i];
max = Math.max(mid, max);
mid = mid < 0 ? 0 : mid;
}
return max;
}
/**
* 获取对应区间
* @param jewels
* @return
*/
public static int[] solution4(int[] jewels) {
int[] dp = new int[2];
int max = 0;
int mid = 0;
for (int i = 0; i < jewels.length; i++) {
mid += jewels[i];
if (max < mid) {
max = mid;
dp[1] = i;
}
if (mid < 0) {
dp[0] = i + 1;
mid = 0;
}
}
return dp;
}
对比三种解法,可以看出,第一和第二种解法基于问题解决问题,而基于动态规划方式思考,可以在不同角度上思考问题,而不是双眼紧盯着问题不放。
上述的宝石问题其实是LeetCode的最大子序和问题的不同表述方式——Maximum Subarray
动态规划概述
动态规划解题思路
动态规划主要分为两个核心部分,一是确定「DP 状态」,二是确定「DP 转移方程」。
DP 状态
「DP 状态」的确定主要有两大原则:
1.最优子结构
2.无后效性
最优子结构
我们仍以「宝石挑选」例题来讲解这两大原则,首先是「最优子结构」。
什么是「最优子结构」?将原有问题化分为一个个子问题,即为子结构。而对于每一个子问题,其最优值均由「更小规模的子问题的最优值」推导而来,即为最优子结构。
因此「DP 状态」设置之前,需要将原有问题划分为一个个子问题,且需要确保子问题的最优值由「更小规模子问题的最优值」推出,此时子问题的最优值即为「DP 状态」的定义。
例如在「宝石挑选」例题中,原有问题是「最大连续区间和」,子问题是「以 i 为右端点的连续区间和」。并且「以 i 为右端点的最大连续区间和」由「以 i - 1 为右端点的最大连续区间和」推出,此时后者即为更小规模的子问题,因此满足「最优子结构」原则。
由此我们才定义 DP 状态 f[i] 表示子问题的最优值,即「以 i 为右端点的最大连续区间和」。
无后效性
而对于「无后效性」,顾名思义,就是我们只关心子问题的最优值,不关心子问题的最优值是怎么得到的。
仍以「宝石挑选」例题为例,我们令 DP 状态 f[i] 表示「以 i 为右端点的最大连续区间和」,我们只关心「以 i 为右端点的区间」这个子问题的最优值,并不关心这个子问题的最优值是从哪个其它子问题转移而来。
即无论 f[i] 所表示区间的左端点是什么,都不会影响后续 f[i + 1] 的取值。影响 f[i + 1] 取值的只有 f[i] 的数值大小。
那怎样的状态定义算「有后效性」呢?
我们对「宝石挑选」例题增加一个限制,即小 Q 只能挑选长度 <= k 的连续区间。此时若我们定义 f[i] 表示「以 i 为右端点的长度 <= k 的最大连续区间和」,则 f[i + 1] 的取值不仅取决于 f[i] 的数值,还取决于 f[i] 是如何得到的。
因为如果 f[i] 取得最优值时区间长度 = k,则 f[i + 1] 不能从 f[i] 转移得到,即 f[i] 的状态定义有后效性。
最后概括一下,「最优子结构」就是「DP 状态最优值由更小规模的 DP 状态最优值推出」,此处 DP 状态即为子问题。而「无后效性」就是「无论 DP 状态是如何得到的,都不会影响后续 DP 状态的取值」。
DP 转移方程
有了「DP 状态」之后,我们只需要用「分类讨论」的思想来枚举所有小状态向大状态转移的可能性即可推出「DP 转移方程」。
我们继续以「宝石挑选」问题为例。
在我们定义「DP 状态」f[i] 之后,我们考虑状态 f[i] 如何从 f[1] ~ f[i - 1] 这些更小规模的状态转移而来。
仔细思考可以发现,由于 f[i] 表示的是连续区间的和,因此其取值只与 f[i - 1] 有关,与 f[1] ~ f[i - 2] 均无关。
我们再进一步思考,f[i] 取值只有两种情况,一是向左延伸,包含 f[i - 1],二是不向左延伸,仅包含 a[i],由此我们可以得到下述「DP 转移方程」:
f[i] = max(f[i - 1] + a[i])
注意,i属于[1, n],且f[0] = 0
动态规划问题类别
讲述完 DP 问题的解题思路后,我们来大致列举一下 DP 问题的类别。
DP 问题主要分为两大类,第一大类是 DP 类型,第二大类是 DP 优化方法。
其中在 DP 类型部分,面试中最常考察的就是「线性 DP」,而在优化方法部分,最常见的是「RMQ 优化」,即使用线段树或其它数据结构查询区间最小值,来优化 DP 的转移过程
64 最小路径和-Minimum Path Sum
/**
*
* 给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,
* 使得路径上的数字总和为最小。
* 说明:每次只能向下或者向右移动一步。
*
* 示例:
* 输入:
* [
* [1,3,1],
* [1,5,1],
* [4,2,1]
* ]
* 输出: 7
* 解释: 因为路径 1→3→1→1→1 的总和最小。
*
* 每一个格子的路径来源只有自己的上方和左方,状态方程为
* f[i][j] = min(f[i - 1][j], f[i][j - 1])
*
*/
public static int solution1(int[][] nums) {
int[][] dp = new int[nums.length][nums[0].length];
dp[0][0] = nums[0][0];
for (int i = 1; i < nums.length; i++) {
dp[i][0] = nums[i][0] + dp[i - 1][0];
}
for (int i = 1; i < nums[0].length; i++) {
dp[0][i] = nums[0][i] + dp[0][i - 1];
}
for (int i = 1; i < nums.length; i++) {
for (int j = 1; j < nums[0].length; j++) {
dp[i][j] = nums[i][j] + Math.min(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[nums.length - 1][nums[0].length - 1];
}
152 乘积最大子数组-Maximum Product Subarray
/**
*
* 给定一个整数数组 nums ,找出一个序列中乘积最大的连续子序列(该序列至少包含一个数)。
*
* 示例 1:
* 输入: [2,3,-2,4]
* 输出: 6
* 解释: 子数组 [2,3] 有最大乘积 6。
*
* 示例 2:
* 输入: [-2,0,-1]
* 输出: 0
* 解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。
* 状态方程 Math.max(f(n - 1), nums[i]) * nums[i]
* 当然,乘法的原因,需要考虑其他情况
*
*/
public static int solution1(int[] nums) {
int max = nums[0];
int c = 1;
int m = 0;
for (int i = 0; i < nums.length; i++) {
if (nums[i] < 0 && m != 0) {
c = nums[i] * m;
} else {
c *= nums[i];
m *= nums[i];
}
max = Math.max(max, c);
m = c <= 0 ? c : m;
c = c > 0 ? c : 1;
}
return max;
}
public static void main(String[] args) {
// -1 , 2, 3, 0, -2, 4
// -2, 0, -1
// -2, 2, -3, 0, -3, -1
// 2, 3, -2, 4
int[] nums = {1 , 2, 3, 0, -2, 4};
System.out.println(solution1(nums));
}
最后总结一下 DP 问题的解题思路:
-
确定「DP 状态」
- 符合「最优子结构」原则:DP 状态最优值由更小规模的 DP 状态最优值推出
- 符合「无后效性」原则:状态的得到方式,不会影响后续其它 DP 状态取值
-
确定「DP 转移方程」
- 分类讨论,细心枚举
遗传算法
遗传算法是一种特殊的搜索技巧,适合处理多变量与非线性问题。
PS:后续会完善各种算法及其对应的LeetCode题型的讲解总结,一边刷题,一边总结还是挺慢的,暂时只是将拙劣的解法写下来,mark一下,后续更改和修补一番