算法合集
算法合集
前导
算法讲解019【必备】算法笔试中处理输入和输出_哔哩哔哩_bilibili
这是很必要的
简单来说用BufferReader可以把所有文件用内存来托管
然后用StreamTokenizer可以忽略所有的空格和换行把一个个数字读出来
按行读(BufferReader\PrintWriter)
不推荐用动态
推荐使用全局静态空间
IO模板规定数据量的题性型
1 | import java.io.*; |
按行读数的题型
不告诉你每组数据的规模,这里就不能用StreamTokenizer了因为不知道什么时候结束了。
那就把每行读出来之后在自己切分
1 | public class Main{ |
数组排序方法
1 | int[] nums = {2,-1,2,4,5,6}; |
我们一步步拆解:
🔹 1. int[] nums = {2, -1, 2, 4, 5, 6};
定义一个基本类型 int
的数组,初始值为:[2, -1, 2, 4, 5, 6]
🔹 2. IntStream.of(nums)
IntStream.of(nums)
:将int[]
数组包装成一个IntStream
(Java 8 的流,专门处理int
类型)。- 它是流式操作的起点。
此时你有一个 IntStream
,里面的数据是:2, -1, 2, 4, 5, 6
🔹 3. .boxed()
- 将
IntStream
中的每个int
装箱(boxing)为对应的Integer
对象。 - 结果是一个
Stream<Integer>
。
✅ 为什么需要这一步?
因为接下来我们要使用 .sorted()
并传入一个 Comparator
,而 IntStream
本身不支持自定义比较器排序(除非是基本排序),所以我们需要转成对象流 Stream<Integer>
才能使用 (o1, o2) -> ...
这种比较方式。
👉 现在流变成了:Stream<Integer>
,包含 [2, -1, 2, 4, 5, 6]
🔹 4. .sorted((o1, o2) -> Math.abs(o2) - Math.abs(o1))
这是排序的核心逻辑。
参数说明:
o1
,o2
:流中的两个Integer
对象Math.abs(o1)
:o1
的绝对值Math.abs(o2)
:o2
的绝对值
比较逻辑:
1 | Math.abs(o2) - Math.abs(o1) |
- 如果结果 > 0:
o2
的绝对值更大,o2
应该排在前面(降序) - 如果结果 < 0:
o1
的绝对值更大,o1
应该排在前面 - 如果结果 = 0:相等,顺序不变(稳定排序)
👉 所以这个 Comparator
实现的是:按绝对值从大到小排序(降序)
🔹 5. .mapToInt(Integer::intValue)
mapToInt
:将Stream<Integer>
转换回IntStream
Integer::intValue
:方法引用,等价于x -> x.intValue()
,把Integer
对象“拆箱”回int
值
✅ 为什么需要这一步?
因为 .toArray()
在对象流上会返回 Integer[]
,但我们最终想要的是 int[]
,所以必须先转回 IntStream
,再调用 .toArray()
得到 int[]
🔹 6. .toArray()
- 对
IntStream
调用.toArray()
,生成一个新的int[]
数组 - 内容是排序后的
int
值
除法运算
1 | // 计算并返回 double 类型,保留2位小数(四舍五入) |
二维数组的排序
1 | Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0])); |
两个整数相加后除以2保证不会溢出
1 | int avg = a + (b - a) / 2; |
操作字符串!!!
首先String是不可变类,一旦创建就不能修改
字符串存储在字符串常量池中
1 | //2、字符访问 |
类型 | 转换方式 |
---|---|
int → String |
String.valueOf(123) 或 123 + "" |
String → int |
Integer.parseInt("123") |
double → String |
String.valueOf(3.14) |
String → double |
Double.parseDouble("3.14") |
操作栈
Deque其实是双端队列
1 | Deque<Integer> st = new ArrayDeque<>(); |
Deque
提供了丰富的操作方法,通常每种操作都有两种形式:
方法类型 | 抛异常 | 返回特殊值(null 或 false) |
---|---|---|
插入 | addFirst(e) , addLast(e) |
offerFirst(e) , offerLast(e) |
删除 | removeFirst() , removeLast() |
pollFirst() , pollLast() |
查看 | getFirst() , getLast() |
peekFirst() , peekLast() |
操作 | Deque (推荐) |
Stack (不推荐) |
---|---|---|
创建 | new ArrayDeque<>() |
new Stack<>() |
入栈 | push(e) 或 addFirst(e) |
push(e) |
出栈 | pop() 或 removeFirst() |
pop() |
查看栈顶 | peek() 或 peekFirst() |
peek() |
性能 | ⚡ 快(ArrayDeque 基于数组) |
🐢 慢(Stack 继承 Vector ,加锁) |
线程安全 | 否(更高效) | 是(带同步,性能差) |
推荐程度 | ✅✅✅ 强烈推荐 | ❌ 避免使用 |
操作图
使用HashMap
方法 | 说明 |
---|---|
put(K key, V value) |
插入或更新键值对 |
get(Object key) |
根据键获取值,不存在返回 null |
remove(Object key) |
删除键值对 |
containsKey(Object key) |
是否包含某个键 |
containsValue(Object value) |
是否包含某个值(较慢) |
size() |
返回键值对数量 |
isEmpty() |
是否为空 |
clear() |
清空所有数据 |
keySet() |
返回所有键的集合 |
values() |
返回所有值的集合 |
entrySet() |
返回所有键值对(Map.Entry)的集合 |
第一章 数组
1、数组理论基础
数组是存放在连续内存空间上的相同类型的集合
- 数组的下标都是从0开始
- 数组内存空间的地址是连续的
正式因为数组 在内存空间的地址是连续的,所以我们在删除或者添加元素的时候,难免要移动其他元素的地址
!!!数组的元素是不能删的,只能覆盖!!!
二维数组的话第一个索引是行,第二个索引是列
C++中二维数组在地址空间上是连续的,Java的二维数组在地址空间上的地址就不连续了,而是经历过几次跳转。
2、二分查找
题目建议: 大家今天能把 704.二分查找 彻底掌握就可以,至于 35.搜索插入位置 和 34. 在排序数组中查找元素的第一个和最后一个位置 ,如果有时间就去看一下,没时间可以先不看,二刷的时候在看。
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
示例 1:
1 | 输入: nums = [-1,0,3,5,9,12], target = 9 |
示例 2:
1 | 输入: nums = [-1,0,3,5,9,12], target = 2 |
提示:
- 你可以假设 nums 中的所有元素是不重复的。
- n 将在 [1, 10000]之间。
- nums 的每个元素都将在 [-9999, 9999]之间。
思路
这道题目的前提是数组是有序数组,同时题目还强调 数组中没有重复元素,因为一旦有重复元素,使用二分查找法返回的元素下标可能不是唯一的,这都是使用二分法的前提条件。
难点是边界条件的控制
区间的定义就是不变量在二分查找的过程中,保持不变量,就是在while寻找中每一次边界的处理都要坚持根据区间的定义 来操作,这就是循环不变量规则。
写二分法,区间的定义一般为两种,左闭右闭即[left, right],或者左闭右开即[left, right)。
思路一:区间左闭右闭[left,right]
第一种写法,我们定义target式在一个左闭右闭的区间里,也就是[left,right]。
因为定义了这个区间,所以
- while(left <= right) 要使用<=,因为left==right是有意义的,所以使用<=
- if(nums[mid] > target)right要赋值为middle-1,因为当前这个nums[mid]一定不是target,那么接下来要查找的做区间结束下标就是right = mid-1
1 | class Solution { |
思路二:区间左闭右开[left,right)
- while (left < right),这里使用 < ,因为left == right在区间[left, right)是没有意义的
- if (nums[middle] > target) right 更新为 middle,因为当前nums[middle]不等于target,去左区间继续寻找,而寻找区间是左闭右开区间,所以right更新为middle,即:下一个查询区间不会去比较nums[middle]
1 | class Solution { |
其他题目
- 35.搜索插入位置(opens new window)
- 34.在排序数组中查找元素的第一个和最后一个位置(opens new window)
- 69.x 的平方根(opens new window)
- 367.有效的完全平方数(opens new window)
3、移出元素
题目建议: 暴力的解法,可以锻炼一下我们的代码实现能力,建议先把暴力写法写一遍。 双指针法 是本题的精髓,今日需要掌握,至于拓展题目可以先不看。
给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素,并返回移除后数组的新长度。
不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并原地修改输入数组。
元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。
示例 1: 给定 nums = [3,2,2,3], val = 3, 函数应该返回新的长度 2, 并且 nums 中的前两个元素均为 2。 你不需要考虑数组中超出新长度后面的元素。
示例 2: 给定 nums = [0,1,2,2,3,0,4,2], val = 2, 函数应该返回新的长度 5, 并且 nums 中的前五个元素为 0, 1, 3, 0, 4。
你不需要考虑数组中超出新长度后面的元素。
!!!数组中的元素不能删除,只能覆盖!!!
思路
思路一:暴力破解
首先外面用一个size,另一个类似于指针
两层for循环,一个for循环遍历数组元素 ,第二个for循环更新数组。
一个是外面的size控制数组的长度,里面那层类似于指针从0开始到size结束,如果指针找到对应的val那么就用一个for循环把后面的数向前移动一格,移动完之后,指针到下一个地址。
1 | class Solution { |
思路二:快慢指针
一个快指针,一个慢指针,快指针在前面探查新元素是否匹配规则,慢指针负责更新数据。
当快指针超过数组长度结束,一开始快指针和慢指针指向第一个数,如果第一个数不等于val那么,快指针和慢指针就会一起向后移动一格,如果快指针指向的数等于val,那么快指针就会移动到新的元素,然后慢指针不动,如果快指针的下一个元素还是等于那么快指针向下移动一格,慢指针还是不动,如果快指针的下一个不等于val了,那么快指针指向的数就会和慢指针指向的数交换并且慢指针和快指针都向后移动一格
1 | class Solution { |
其他题目推荐
- 26.删除排序数组中的重复项(opens new window)
- 283.移动零(opens new window)
- 844.比较含退格的字符串(opens new window)
- 977.有序数组的平方(opens new window)
4、有序数组的平方
给你一个按 非递减顺序 排序的整数数组 nums,返回 每个数字的平方 组成的新数组,要求也按 非递减顺序 排序。
示例 1:
- 输入:nums = [-4,-1,0,3,10]
- 输出:[0,1,9,16,100]
- 解释:平方后,数组变为 [16,1,0,9,100],排序后,数组变为 [0,1,9,16,100]
示例 2:
输入:nums = [-7,-3,2,3,11]
输出:[4,9,9,49,121]
思路
思路一:每个数平方然后排个序
1 | class Solution { |
思路二:利用双指针
一开始是非递减也就是递增的数组,所以最大的数只能在左右两边,左边或者右边,不可能是中间,所以先用两个指针指在数组的左右两边,一开始新建一个结果数组然后,比较两个数左右两边谁大,如果右边大,那么右边向左移动一格,然后结果数组的最后一位放右边的数,如果左边大,那么左边向右移动一格,然后数组的最后一位放左边的数,之后左边向右移动一格,如果一样大,那么左边向右边移动一格,放左边的数,直到左边大于右边指针地址(等于的时候放最后一个数)。
1 | class Solution { |
5、长度最小的子数组
题目建议: 本题关键在于理解滑动窗口
给定一个含有 n 个正整数的数组和一个正整数 s ,找出该数组中满足其和 ≥ s 的长度最小的 连续 子数组,并返回其长度。如果不存在符合条件的子数组,返回 0。
示例:
- 输入:s = 7, nums = [2,3,1,2,4,3]
- 输出:2
- 解释:子数组 [4,3] 是该条件下的长度最小的子数组。
提示:
- 1 <= target <= 10^9
- 1 <= nums.length <= 10^5
- 1 <= nums[i] <= 10^5
思路
思路一:暴力破解
这道题目暴力解法当然是 两个for循环,然后不断的寻找符合条件的子序列,时间复杂度很明显是O(n^2)。
一个变量存储答案数组的长度,一个存储和,一个存储子数组长度,然后两个for循环,如果和大于或者等于目标值就结束,然后如果子数组长度小于答案数组长度就更新答案数组的长度。
1 | class Solution { |
思路二:滑动窗口
接下来就开始介绍数组操作中另一个重要的方法:滑动窗口。
所谓滑动窗口,就是不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。
在暴力解法中,是一个for循环滑动窗口的起始位置,一个for循环为滑动窗口的终止位置,用两个for循环 完成了一个不断搜索区间的过程。
那么滑动窗口如何用一个for循环来完成这个操作呢。
首先要思考 如果用一个for循环,那么应该表示 滑动窗口的起始位置,还是终止位置。
如果只用一个for循环来表示 滑动窗口的起始位置,那么如何遍历剩下的终止位置?
此时难免再次陷入 暴力解法的怪圈。
所以 只用一个for循环,那么这个循环的索引,一定是表示 滑动窗口的终止位置。
然后思考滑动窗口的起始位置怎么移动?
首先有两个指针一个指针用于表示起始位置,一个指针表示向后探索的位置,一个sum记录指针从起始位置到另一个指针的和,如果和小于目标值,那么另一个指针向后移动一格,再次计算sum,如果大于等于就更新长度如果比最小长度小的话,如果不是就不用更新,然后起始位置的指针向后移动一格,后面的指针也向后移动一格,如果后面的指针超过数组长度直接结束,返回最小长度。
1 | class Solution { |
滑动窗口“逐步累加”的高效特性
相关题目
6、螺旋矩阵
给定一个正整数 n,生成一个包含 1 到 n^2 所有元素,且元素按顺时针顺序螺旋排列的正方形矩阵。
示例:
输入: 3 输出: [ [ 1, 2, 3 ], [ 8, 9, 4 ], [ 7, 6, 5 ] ]
思路
这道题目可以说在面试中出现频率较高的题目,本题并不涉及到什么算法,就是模拟过程,但却十分考察对代码的掌控能力。
要如何画出这个螺旋排列的正方形矩阵呢?
相信很多同学刚开始做这种题目的时候,上来就是一波判断猛如虎。
结果运行的时候各种问题,然后开始各种修修补补,最后发现改了这里那里有问题,改了那里这里又跑不起来了。
大家还记得我们在这篇文章数组:每次遇到二分法,都是一看就会,一写就废 (opens new window)中讲解了二分法,提到如果要写出正确的二分法一定要坚持循环不变量原则。
而求解本题依然是要坚持循环不变量原则。
模拟顺时针画矩阵的过程:
- 填充上行从左到右
- 填充右列从上到下
- 填充下行从右到左
- 填充左列从下到上
由外向内一圈一圈这么画下去。
可以发现这里的边界条件非常多,在一个循环中,如此多的边界条件,如果不按照固定规则来遍历,那就是一进循环深似海,从此offer是路人。
这里一圈下来,我们要画每四条边,这四条边怎么画,每画一条边都要坚持一致的左闭右开,或者左开右闭的原则,这样这一圈才能按照统一的规则画下来。
那么我按照左闭右开的原则,来画一圈,大家看一下:
这里每一种颜色,代表一条边,我们遍历的长度,可以看出每一个拐角处的处理规则,拐角处让给新的一条边来继续画。
这也是坚持了每条边左闭右开的原则。
一些同学做这道题目之所以一直写不好,代码越写越乱。
就是因为在画每一条边的时候,一会左开右闭,一会左闭右闭,一会又来左闭右开,岂能不乱。
代码如下,已经详细注释了每一步的目的,可以看出while循环里判断的情况是很多的,代码里处理的原则也是统一的左闭右开。
1 | class Solution { |
7、区间和
题目描述
给定一个整数数组 Array,请计算该数组在每个指定区间内元素的总和。
输入描述
第一行输入为整数数组 Array 的长度 n,接下来 n 行,每行一个整数,表示数组的元素。随后的输入为需要计算总和的区间下标:a,b (b > = a),直至文件结束。
输出描述
输出每个指定区间内元素的总和。
输入示例
1 | 5 |
输出示例
1 | 3 |
提示信息
数据范围:
0 < n <= 100000
思路
本题我们来讲解 数组 上常用的解题技巧:前缀和
首先来看本题,我们最直观的想法是什么?
那就是给一个区间,然后 把这个区间的和都累加一遍不就得了,是一道简单不能再简单的题目。
代码如下:
1 |
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
代码一提交,发现超时了…..
我在制作本题的时候,特别制作了大数据量查询,卡的就是这种暴力解法。
来举一个极端的例子,如果我查询m次,每次查询的范围都是从0 到 n - 1
那么该算法的时间复杂度是 O(n * m) m 是查询的次数
如果查询次数非常大的话,这个时间复杂度也是非常大的。
接下来我们来引入前缀和,看看前缀和如何解决这个问题。
前缀和的思想是重复利用计算过的子数组之和,从而降低区间查询需要累加计算的次数。
前缀和 在涉及计算区间和的问题时非常有用!
前缀和的思路其实很简单,我给大家举个例子很容易就懂了。
例如,我们要统计 vec[i] 这个数组上的区间和。
我们先做累加,即 p[i] 表示 下标 0 到 i 的 vec[i] 累加 之和。
如图:
如果,我们想统计,在vec数组上 下标 2 到下标 5 之间的累加和,那是不是就用 p[5] - p[1] 就可以了。
为什么呢?
1 | p[1] = vec[0] + vec[1]; |
这不就是我们要求的 下标 2 到下标 5 之间的累加和吗。
如图所示:
p[5] - p[1]
就是 红色部分的区间和。
而 p 数组是我们之前就计算好的累加和,所以后面每次求区间和的之后 我们只需要 O(1) 的操作。
特别注意: 在使用前缀和求解的时候,要特别注意 求解区间。
如上图,如果我们要求 区间下标 [2, 5] 的区间和,那么应该是 p[5] - p[1],而不是 p[5] - p[2]。
1 | import java.util.Scanner; |
8、 开发商购买土地
本题为代码随想录后续扩充题目,还没有视频讲解,顺便让大家练习一下ACM输入输出模式(笔试面试必备)
【题目描述】
在一个城市区域内,被划分成了n * m个连续的区块,每个区块都拥有不同的权值,代表着其土地价值。目前,有两家开发公司,A 公司和 B 公司,希望购买这个城市区域的土地。
现在,需要将这个城市区域的所有区块分配给 A 公司和 B 公司。
然而,由于城市规划的限制,只允许将区域按横向或纵向划分成两个子区域,而且每个子区域都必须包含一个或多个区块。
为了确保公平竞争,你需要找到一种分配方式,使得 A 公司和 B 公司各自的子区域内的土地总价值之差最小。
注意:区块不可再分。
【输入描述】
第一行输入两个正整数,代表 n 和 m。
接下来的 n 行,每行输出 m 个正整数。
输出描述
请输出一个整数,代表两个子区域内土地总价值之间的最小差距。
【输入示例】
3 3 1 2 3 2 1 3 1 2 3
【输出示例】
0
【提示信息】
如果将区域按照如下方式划分:
1 2 | 3 2 1 | 3 1 2 | 3
两个子区域内土地总价值之间的最小差距可以达到 0。
【数据范围】:
- 1 <= n, m <= 100;
- n 和 m 不同时为 1。
思路
看到本题,大家如果想暴力求解,应该是 n^3 的时间复杂度,
一个 for 枚举分割线, 嵌套 两个for 去累加区间里的和。
如果本题要求 任何两个行(或者列)之间的数值总和,大家在0058.区间和 的基础上 应该知道怎么求。
就是前缀和的思路,先统计好,前n行的和 q[n],如果要求矩阵 a行 到 b行 之间的总和,那么就 q[b] - q[a - 1]就好。
至于为什么是 a - 1,大家去看 0058.区间和 的分析,使用 前缀和 要注意 区间左右边的开闭情况。
本题也可以使用 前缀和的思路来求解,先将 行方向,和 列方向的和求出来,这样可以方便知道 划分的两个区间的和。
前缀和
1 | import java.util.Scanner; |
优化暴力
1 | import java.util.Scanner; |
第二章 链表
1、链表理论基础
链表理论基础
什么是链表,链表是一种通过指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域指向null(空指针的意思)。
链表的入口节点称为链表的头结点也就是head。
如图所示:
链表的类型
接下来说一下链表的几种类型:
单链表
刚刚说的就是单链表。
双链表
单链表中的指针域只能指向节点的下一个节点。
双链表:每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点。
双链表 既可以向前查询也可以向后查询。
如图所示:
循环链表
循环链表,顾名思义,就是链表首尾相连。
循环链表可以用来解决约瑟夫环问题。
链表的存储方式
了解完链表的类型,再来说一说链表在内存中的存储方式。
数组是在内存中是连续分布的,但是链表在内存中可不是连续分布的。
链表是通过指针域的指针链接在内存中各个节点。
所以链表中的节点在内存中不是连续分布的 ,而是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理。
如图所示:
这个链表起始节点为2, 终止节点为7, 各个节点分布在内存的不同地址空间上,通过指针串联在一起。
链表的定义
接下来说一说链表的定义。
链表节点的定义,很多同学在面试的时候都写不好。
这是因为平时在刷leetcode的时候,链表的节点都默认定义好了,直接用就行了,所以同学们都没有注意到链表的节点是如何定义的。
而在面试的时候,一旦要自己手写链表,就写的错漏百出。
1 | public class ListNode { |
所以如果不定义构造函数使用默认构造函数的话,在初始化的时候就不能直接给变量赋值!
链表的操作
删除节点
删除D节点,如图所示:
只要将C节点的next指针 指向E节点就可以了。
那有同学说了,D节点不是依然存留在内存里么?只不过是没有在这个链表里而已。
是这样的,所以在C++里最好是再手动释放这个D节点,释放这块内存。
其他语言例如Java、Python,就有自己的内存回收机制,就不用自己手动释放了。
添加节点
如图所示:
可以看出链表的增添和删除都是O(1)操作,也不会影响到其他节点。
但是要注意,要是删除第五个节点,需要从头节点查找到第四个节点通过next指针进行删除操作,查找的时间复杂度是O(n)。
性能分析
再把链表的特性和数组的特性进行一个对比,如图所示:
数组在定义的时候,长度就是固定的,如果想改动数组的长度,就需要重新定义一个新的数组。
链表的长度可以是不固定的,并且可以动态增删, 适合数据量不固定,频繁增删,较少查询的场景。
2、移除链表元素
题意:删除链表中等于给定值 val 的所有节点。
示例 1: 输入:head = [1,2,6,3,4,5,6], val = 6 输出:[1,2,3,4,5]
示例 2: 输入:head = [], val = 1 输出:[]
示例 3: 输入:head = [7,7,7,7], val = 7 输出:[]
思路
这里以链表 1 4 2 4 来举例,移除元素4。
当然如果使用java ,python的话就不用手动管理内存了。
这种情况下的移除操作,就是让节点next指针直接指向下下一个节点就可以了,
那么因为单链表的特殊性,只能指向下一个节点,刚刚删除的是链表的中第二个,和第四个节点,那么如果删除的是头结点又该怎么办呢?
这里就涉及如下链表操作的两种方式:
- 直接使用原来的链表来进行删除操作。
- 设置一个虚拟头结点在进行删除操作。
1 | class Solution { |
3、设计链表
在链表类中实现这些功能:
- get(index):获取链表中第 index 个节点的值。如果索引无效,则返回-1。
- addAtHead(val):在链表的第一个元素之前添加一个值为 val 的节点。插入后,新节点将成为链表的第一个节点。
- addAtTail(val):将值为 val 的节点追加到链表的最后一个元素。
- addAtIndex(index,val):在链表中的第 index 个节点之前添加值为 val 的节点。如果 index 等于链表的长度,则该节点将附加到链表的末尾。如果 index 大于链表长度,则不会插入节点。如果index小于0,则在头部插入节点。
- deleteAtIndex(index):如果索引 index 有效,则删除链表中的第 index 个节点。
删除链表节点:
添加链表节点:
这道题目设计链表的五个接口:
- 获取链表第index个节点的数值
- 在链表的最前面插入一个节点
- 在链表的最后面插入一个节点
- 在链表第index个节点前面插入一个节点
- 删除链表的第index个节点
可以说这五个接口,已经覆盖了链表的常见操作,是练习链表操作非常好的一道题目
链表操作的两种方式:
- 直接使用原来的链表来进行操作。
- 设置一个虚拟头结点在进行操作。
单链表
1 | //单链表 |
双链表
1 | //双链表 |
第三章 哈希表
1、哈希表理论基础
首先什么是哈希表,哈希表(英文名字为Hash table,国内也有一些算法书籍翻译为散列表,大家看到这两个名称知道都是指hash table就可以了)。
哈希表是根据关键码的值而直接进行访问的数据结构。
这么官方的解释可能有点懵,其实直白来讲其实数组就是一张哈希表。
哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素,如下图所示:
那么哈希表能解决什么问题呢,一般哈希表都是用来快速判断一个元素是否出现集合里。
例如要查询一个名字是否在这所学校里。
要枚举的话时间复杂度是O(n),但如果使用哈希表的话, 只需要O(1)就可以做到。
我们只需要初始化把这所学校里学生的名字都存在哈希表里,在查询的时候通过索引直接就可以知道这位同学在不在这所学校里了。
将学生姓名映射到哈希表上就涉及到了hash function ,也就是哈希函数。
哈希函数
哈希函数,把学生的姓名直接映射为哈希表上的索引,然后就可以通过查询索引下标快速知道这位同学是否在这所学校里了。
哈希函数如下图所示,通过hashCode把名字转化为数值,一般hashcode是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把学生名字映射为哈希表上的索引数字了。
如果hashCode得到的数值大于 哈希表的大小了,也就是大于tableSize了,怎么办呢?
此时为了保证映射出来的索引数值都落在哈希表上,我们会在再次对数值做一个取模的操作,这样我们就保证了学生姓名一定可以映射到哈希表上了。
此时问题又来了,哈希表我们刚刚说过,就是一个数组。
如果学生的数量大于哈希表的大小怎么办,此时就算哈希函数计算的再均匀,也避免不了会有几位学生的名字同时映射到哈希表 同一个索引下标的位置。
接下来哈希碰撞登场
哈希碰撞
如图所示,小李和小王都映射到了索引下标 1 的位置,这一现象叫做哈希碰撞。
一般哈希碰撞有两种解决方法, 拉链法和线性探测法。
拉链法
刚刚小李和小王在索引1的位置发生了冲突,发生冲突的元素都被存储在链表中。 这样我们就可以通过索引找到小李和小王了
(数据规模是dataSize, 哈希表的大小为tableSize)
其实拉链法就是要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
线性探测法
使用线性探测法,一定要保证tableSize大于dataSize。 我们需要依靠哈希表中的空位来解决碰撞问题。
例如冲突的位置,放了小李,那么就向下找一个空位放置小王的信息。所以要求tableSize一定要大于dataSize ,要不然哈希表上就没有空置的位置来存放 冲突的数据了。如图所示:
其实关于哈希碰撞还有非常多的细节,感兴趣的同学可以再好好研究一下,这里我就不再赘述了。
常见的三种哈希结构
当我们想使用哈希法来解决问题的时候,我们一般会选择如下三种数据结构。
- 数组
- set (集合)
- map(映射)
这里数组就没啥可说的了,我们来看一下set。
在C++中,set 和 map 分别提供以下三种数据结构,其底层实现以及优劣如下表所示:
集合 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
std::set | 红黑树 | 有序 | 否 | 否 | O(log n) | O(log n) |
std::multiset | 红黑树 | 有序 | 是 | 否 | O(logn) | O(logn) |
std::unordered_set | 哈希表 | 无序 | 否 | 否 | O(1) | O(1) |
std::unordered_set底层实现为哈希表,std::set 和std::multiset 的底层实现是红黑树,红黑树是一种平衡二叉搜索树,所以key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加。
映射 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
std::map | 红黑树 | key有序 | key不可重复 | key不可修改 | O(logn) | O(logn) |
std::multimap | 红黑树 | key有序 | key可重复 | key不可修改 | O(log n) | O(log n) |
std::unordered_map | 哈希表 | key无序 | key不可重复 | key不可修改 | O(1) | O(1) |
std::unordered_map 底层实现为哈希表,std::map 和std::multimap 的底层实现是红黑树。同理,std::map 和std::multimap 的key也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解)。
当我们要使用集合来解决哈希问题的时候,优先使用unordered_set,因为它的查询和增删效率是最优的,如果需要集合是有序的,那么就用set,如果要求不仅有序还要有重复数据的话,那么就用multiset。
那么再来看一下map ,在map 是一个key value 的数据结构,map中,对key是有限制,对value没有限制的,因为key的存储方式使用红黑树实现的。
其他语言例如:java里的HashMap ,TreeMap 都是一样的原理。可以灵活贯通。
虽然std::set和std::multiset 的底层实现基于红黑树而非哈希表,它们通过红黑树来索引和存储数据。不过给我们的使用方式,还是哈希法的使用方式,即依靠键(key)来访问值(value)。所以使用这些数据结构来解决映射问题的方法,我们依然称之为哈希法。std::map也是一样的道理。
这里在说一下,一些C++的经典书籍上 例如STL源码剖析,说到了hash_set hash_map,这个与unordered_set,unordered_map又有什么关系呢?
实际上功能都是一样一样的, 但是unordered_set在C++11的时候被引入标准库了,而hash_set并没有,所以建议还是使用unordered_set比较好,这就好比一个是官方认证的,hash_set,hash_map 是C++11标准之前民间高手自发造的轮子。
总结
总结一下,当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。
但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找。
如果在做面试题目的时候遇到需要判断一个元素是否出现过的场景也应该第一时间想到哈希法!
2、有效的字母异位词
给定两个字符串 s
和 t
,编写一个函数来判断 t
是否是 s
的 字母异位词(字母个数和类型不变只是位置变了)
示例 1:
1 | 输入: s = "anagram", t = "nagaram" |
示例 2:
1 | 输入: s = "rat", t = "car" |
提示:
1 <= s.length, t.length <= 5 * 104
s
和t
仅包含小写字母
思路一(暴力)
两层for循环,同时记录字符是否重复出现。
思路二
数组其实就是一个简单的哈希表,而且这道题目中字符串只有小写字符,那么就可以定义一个数组,来记录字符串s里字符出现的次数。
需要定义一个多大的数组呢,定一个数组叫做record,大小为26 就可以了,初始化为0,因为字符a到字符z的ASCII也是26个连续的数值。
为了方便举例,判断一下字符串s= “aee”, t = “eae”。
这里aee遍历完之后的数组是这样的。
需要把字符映射到数组也就是哈希表的索引下标上,因为字符a到字符z的ASCII是26个连续的数值,所以字符a映射为下标0,相应的字符z映射为下标25。
再遍历 字符串s的时候,只需要将 s[i] - ‘a’ 所在的元素做+1 操作即可,并不需要记住字符a的ASCII,只要求出一个相对数值就可以了。 这样就将字符串s中字符出现的次数,统计出来了。
那看一下如何检查字符串t中是否出现了这些字符,同样在遍历字符串t的时候,对t中出现的字符映射哈希表索引上的数值再做-1的操作。
那么最后检查一下,record数组如果有的元素不为零0,说明字符串s和t一定是谁多了字符或者谁少了字符,return false。
最后如果record数组所有元素都为零0,说明字符串s和t是字母异位词,return true。
时间复杂度为O(n),空间上因为定义是的一个常量大小的辅助数组,所以空间复杂度为O(1)。
1 | class Solution { |
相关题目
3、两个数组的交集
给定两个数组 nums1
和 nums2
,返回 它们的 交集 。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序 。
示例 1:
1 | 输入:nums1 = [1,2,2,1], nums2 = [2,2] |
示例 2:
1 | 输入:nums1 = [4,9,5], nums2 = [9,4,9,8,4] |
提示:
1 <= nums1.length, nums2.length <= 1000
0 <= nums1[i], nums2[i] <= 1000
思路一(暴力)
思路二(利用哈希中的set)
这道题目,主要要学会使用一种哈希数据结构:unordered_set,这个数据结构可以解决很多类似的问题。
注意题目特意说明:输出结果中的每个元素一定是唯一的,也就是说输出的结果的去重的, 同时可以不考虑输出结果的顺序
这道题用暴力的解法时间复杂度是O(n^2),那来看看使用哈希法进一步优化。
那么用数组来做哈希表也是不错的选择,例如242. 有效的字母异位词(opens new window)
但是要注意,使用数组来做哈希的题目,是因为题目都限制了数值的大小。
而这道题目没有限制数值的大小,就无法使用数组来做哈希表了。
而且如果哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。
此时就要使用另一种结构体了,set
思路如图所示:
拓展
那有同学可能问了,遇到哈希问题我直接都用set不就得了,用什么数组啊。
直接使用set 不仅占用空间比数组大,而且速度要比数组慢,set把数值映射到key上都要做hash计算的。
不要小瞧 这个耗时,在数据量大的情况,差距是很明显的。
后记
本题后面 力扣改了 题目描述 和 后台测试数据,增添了 数值范围:
- 1 <= nums1.length, nums2.length <= 1000
- 0 <= nums1[i], nums2[i] <= 1000
所以就可以 使用数组来做哈希表了, 因为数组都是 1000以内的。
版本一:使用HashSet
1 | class Solution { |
版本二:使用Hash数组
1 | class Solution { |
相关题目
4、快乐数
编写一个算法来判断一个数 n 是不是快乐数。
「快乐数」定义为:对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和,然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。如果 可以变为 1,那么这个数就是快乐数。
如果 n 是快乐数就返回 True ;不是,则返回 False 。
示例:
输入:19
输出:true
解释:
1^2 + 9^2 = 82
8^2 + 2^2 = 68
6^2 + 8^2 = 100
1^2 + 0^2 + 0^2 = 1
思路
这道题目看上去貌似一道数学问题,其实并不是!
题目中说了会 无限循环,那么也就是说求和的过程中,sum会重复出现,这对解题很重要!
所以这道题目使用哈希法,来判断这个sum是否重复出现,如果重复了就是return false, 否则一直找到sum为1为止。
1 | class Solution { |
5、两数之和
给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那 两个 整数,并返回他们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍。
示例:
给定 nums = [2, 7, 11, 15], target = 9
因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]
思路
首先我再强调一下 什么时候使用哈希法,当我们需要查询一个元素是否出现过,或者一个元素是否在集合里的时候,就要第一时间想到哈希法。
本题呢,我就需要一个集合来存放我们遍历过的元素,然后在遍历数组的时候去询问这个集合,某元素是否遍历过,也就是 是否出现在这个集合。
那么我们就应该想到使用哈希法了。
因为本题,我们不仅要知道元素有没有遍历过,还要知道这个元素对应的下标,需要使用 key value结构来存放,key来存元素,value来存下标,那么使用map正合适。
再来看一下使用数组和set来做哈希法的局限。
- 数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
- set是一个集合,里面放的元素只能是一个key,而两数之和这道题目,不仅要判断y是否存在而且还要记录y的下标位置,因为要返回x 和 y的下标。所以set 也不能用。
此时就要选择另一种数据结构:map ,map是一种key value的存储结构,可以用key保存数值,用value再保存数值所在的下标。
接下来需要明确两点:
- map用来做什么
- map中key和value分别表示什么
map目的用来存放我们访问过的元素,因为遍历数组的时候,需要记录我们之前遍历过哪些元素和对应的下标,这样才能找到与当前元素相匹配的(也就是相加等于target)
接下来是map中key和value分别表示什么。
这道题 我们需要 给出一个元素,判断这个元素是否出现过,如果出现过,返回这个元素的下标。
那么判断元素是否出现,这个元素就要作为key,所以数组中的元素作为key,有key对应的就是value,value用来存下标。
所以 map中的存储结构为 {key:数据元素,value:数组元素对应的下标}。
在遍历数组的时候,只需要向map去查询是否有和目前遍历元素匹配的数值,如果有,就找到的匹配对,如果没有,就把目前遍历的元素放进map中,因为map存放的就是我们访问过的元素。
过程如下:
1 | class Solution { |
6、四数相加二
给定四个包含整数的数组列表 A , B , C , D ,计算有多少个元组 (i, j, k, l) ,使得 A[i] + B[j] + C[k] + D[l] = 0。
为了使问题简单化,所有的 A, B, C, D 具有相同的长度 N,且 0 ≤ N ≤ 500 。所有整数的范围在 -2^28 到 2^28 - 1 之间,最终结果不会超过 2^31 - 1 。
例如:
输入:
- A = [ 1, 2]
- B = [-2,-1]
- C = [-1, 2]
- D = [ 0, 2]
输出:
2
解释:
两个元组如下:
- (0, 0, 0, 1) -> A[0] + B[0] + C[0] + D[1] = 1 + (-2) + (-1) + 2 = 0
- (1, 1, 0, 0) -> A[1] + B[1] + C[0] + D[0] = 2 + (-1) + (-1) + 0 = 0
这道题目是四个独立的数组,只要找到A[i] + B[j] + C[k] + D[l] = 0就可以,不用考虑有重复的四个元素相加等于0的情况,所以相对于题目18. 四数之和,题目15.三数之和,还是简单了不少!
如果本题想难度升级:就是给出一个数组(而不是四个数组),在这里找出四个元素相加等于0,答案中不可以包含重复的四元组,大家可以思考一下,后续的文章我也会讲到的。
本题解题步骤:
- 首先定义 一个map,key放a和b两数之和,value 放a和b两数之和出现的次数。
- 遍历大A和大B数组,统计两个数组元素之和,和出现的次数,放到map中。
- 定义int变量count,用来统计 a+b+c+d = 0 出现的次数。
- 再遍历大C和大D数组,找到如果 0-(c+d) 在map中出现过的话,就用count把map中key对应的value也就是出现次数统计出来。
- 最后返回统计值 count 就可以了
1 | class Solution { |
7、赎金信
给定一个赎金信 (ransom) 字符串和一个杂志(magazine)字符串,判断第一个字符串 ransom 能不能由第二个字符串 magazines 里面的字符构成。如果可以构成,返回 true ;否则返回 false。
(题目说明:为了不暴露赎金信字迹,要从杂志上搜索各个需要的字母,组成单词来表达意思。杂志字符串中的每个字符只能在赎金信字符串中使用一次。)
注意:
你可以假设两个字符串均只含有小写字母。
canConstruct(“a”, “b”) -> false
canConstruct(“aa”, “ab”) -> false
canConstruct(“aa”, “aab”) -> true
思路
思路一:暴力循环枚举
思路二:哈希解法
因为题目说只有小写字母,那可以采用空间换取时间的哈希策略,用一个长度为26的数组来记录magazine里字母出现的次数。
然后再用ransomNote去验证这个数组是否包含了ransomNote所需要的所有字母。
依然是数组在哈希法中的应用。
一些同学可能想,用数组干啥,都用map完事了,其实在本题的情况下,使用map的空间消耗要比数组大一些的,因为map要维护红黑树或者哈希表,而且还要做哈希函数,是费时的!数据量大的话就能体现出来差别了。 所以数组更加简单直接有效!
1 | class Solution { |
8、三数之和
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有满足条件且不重复的三元组。
注意: 答案中不可以包含重复的三元组。
示例:
给定数组 nums = [-1, 0, 1, 2, -1, -4],
满足要求的三元组集合为: [ [-1, 0, 1], [-1, -1, 2] ]
思路
思路一:双指针法
先让数组排序,然后左指针指向循环的下一个,右指针指向数组尾,然后等到左指针和右指针相遇一个遍历结束。
1 |
|
为什么要去重?
题目要求返回 所有不重复的三元组,例如:如果数组是 [-1, -1, 0, 1],那么 [ -1, 0, 1 ] 出现两次是不允许的。即使它们顺序不同,但元素相同,也视为重复。所以我们在遍历过程中必须避免生成重复的三元组。
去重的三大关键点
外层循环中跳过相同的第一个数(nums[i])
1 | if (i > 0 && nums[i] == nums[i - 1]) continue; |
解释:当前值 nums[i] 和上一个值 nums[i - 1] 相同,说明如果继续计算下去会得到重复的三元组。
所以我们跳过当前这个值。
示例:数组排序后为:[-1, -1, 0, 1]
第一次 i = 0 时处理了 [-1, 0, 1]
i = 1 时发现 nums[1] == nums[0],直接跳过,避免重复
在找到一个解后,跳过相同的 left 值
1 | while (left < right && nums[left] == nums[left + 1]) left++; |
解释:此时找到了一个三元组满足条件(和为0),但可能还有多个 left 指向相同的值。
为了避免后续再次选到同样的组合,要跳过这些重复值。
同样地,跳过相同的 right 值
1 | while (left < right && nums[right] == nums[right - 1]) right--; |
解释:类似上面的逻辑,跳过右边重复的值,防止产生重复的三元组。
思路二:哈希法
思路一样只是实现方式变成了用哈希表
1 | class Solution { |
9、四数之和
给你一个由 n
个整数组成的数组 nums
,和一个目标值 target
。请你找出并返回满足下述全部条件且不重复的四元组 [nums[a], nums[b], nums[c], nums[d]]
(若两个四元组元素一一对应,则认为两个四元组重复):
0 <= a, b, c, d < n
a
、b
、c
和d
互不相同nums[a] + nums[b] + nums[c] + nums[d] == target
你可以按 任意顺序 返回答案 。
示例 1:
1 | 输入:nums = [1,0,-1,0,-2,2], target = 0 |
示例 2:
1 | 输入:nums = [2,2,2,2,2], target = 8 |
提示:
1 <= nums.length <= 200
-109 <= nums[i] <= 109
-109 <= target <= 109
这种问题还是使用双指针法,思路跟之前一样重要的是剪枝操作和加了个循环
1 | public class Solution { |
第四章 字符串
1、反转字符串
编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s
的形式给出。
不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
示例 1:
1 | 输入:s = ["h","e","l","l","o"] |
示例 2:
1 | 输入:s = ["H","a","n","n","a","h"] |
提示:
1 <= s.length <= 105
s[i]
都是 ASCII 码表中的可打印字符
思路一:双指针结束了
没啥好讲的双指针交换元素就结束了。
1 | class Solution { |
2、反转字符串2
给定一个字符串 s
和一个整数 k
,从字符串开头算起,每计数至 2k
个字符,就反转这 2k
字符中的前 k
个字符。
- 如果剩余字符少于
k
个,则将剩余字符全部反转。 - 如果剩余字符小于
2k
但大于或等于k
个,则反转前k
个字符,其余字符保持原样。
示例 1:
1 | 输入:s = "abcdefg", k = 2 |
示例 2:
1 | 输入:s = "abcd", k = 2 |
提示:
1 <= s.length <= 104
s
仅由小写英文组成1 <= k <= 104
思路
这道题目其实也是模拟,实现题目中规定的反转规则就可以了。
1 | class Solution { |
1 |
|
3、替换数字
题目描述
给定一个字符串 s,它包含小写字母和数字字符,请编写一个函数,将字符串中的字母字符保持不变,而将每个数字字符替换为number。 例如,对于输入字符串 “a1b2c3”,函数应该将其转换为 “anumberbnumbercnumber”。
输入描述
输入一个字符串 s,s 仅包含小写字母和数字字符。
输出描述
打印一个新的字符串,其中每个数字字符都被替换为了number
输入示例
1 | a1b2c3 |
输出示例
1 | anumberbnumbercnumber |
提示信息
数据范围:
1 <= s.length < 10000。
思路
首先扩充数组到每个数字字符替换成 “number” 之后的大小。
例如 字符串 “a5b” 的长度为3,那么 将 数字字符变成字符串 “number” 之后的字符串为 “anumberb” 长度为 8。
如图:
然后从后向前替换数字字符,也就是双指针法,过程如下:i指向新长度的末尾,j指向旧长度的末尾。
1 | import java.util.*; |
扩展
- 27.移除元素(opens new window)
- 15.三数之和(opens new window)
- 18.四数之和(opens new window)
- 206.翻转链表(opens new window)
- 142.环形链表II(opens new window)
- 344.反转字符串(opens new window)
4、翻转字符串里的单词
给你一个字符串 s
,请你反转字符串中 单词 的顺序。
单词 是由非空格字符组成的字符串。s
中使用至少一个空格将字符串中的 单词 分隔开。
返回 单词 顺序颠倒且 单词 之间用单个空格连接的结果字符串。
注意:输入字符串 s
中可能会存在前导空格、尾随空格或者单词间的多个空格。返回的结果字符串中,单词间应当仅用单个空格分隔,且不包含任何额外的空格。
示例 1:
1 | 输入:s = "the sky is blue" |
示例 2:
1 | 输入:s = " hello world " |
示例 3:
1 | 输入:s = "a good example" |
提示:
1 <= s.length <= 104
s
包含英文大小写字母、数字和空格' '
s
中 至少存在一个 单词
思路
所以这里我还是提高一下本题的难度:不要使用辅助空间,空间复杂度要求为O(1)。
想一下,我们将整个字符串都反转过来,那么单词的顺序指定是倒序了,只不过单词本身也倒序了,那么再把单词反转一下,单词不就正过来了。
所以解题思路如下:
- 移除多余空格
- 将整个字符串反转
- 将每个单词反转
举个例子,源字符串为:”the sky is blue “
- 移除多余空格 : “the sky is blue”
- 字符串反转:”eulb si yks eht”
- 单词反转:”blue is sky the”
这样我们就完成了翻转字符串里的单词。
思路一:使用trim
1 | class Solution { |
思路二:利用思路中的想法
1 | class Solution { |
5、右旋转字符串
题目描述
字符串的右旋转操作是把字符串尾部的若干个字符转移到字符串的前面。给定一个字符串 s 和一个正整数 k,请编写一个函数,将字符串中的后面 k 个字符移到字符串的前面,实现字符串的右旋转操作。
例如,对于输入字符串 “abcdefg” 和整数 2,函数应该将其转换为 “fgabcde”。
输入描述
输入共包含两行,第一行为一个正整数 k,代表右旋转的位数。第二行为字符串 s,代表需要旋转的字符串。
输出描述
输出共一行,为进行了右旋转操作后的字符串。
输入示例
1 | 2 |
输出示例
1 | fgabcde |
提示信
数据范围:
1 <= k < 10000,
1 <= s.length < 10000;
思路
思路就是 通过 整体倒叙,把两段子串顺序颠倒,两个段子串里的的字符在倒叙一把,负负得正,这样就不影响子串里面字符的顺序了。
1 | import java.util.*; |
6、实现strStr()
28. 找出字符串中第一个匹配项的下标 - 力扣(LeetCode)
给你两个字符串 haystack
和 needle
,请你在 haystack
字符串中找出 needle
字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle
不是 haystack
的一部分,则返回 -1
。
示例 1:
1 | 输入:haystack = "sadbutsad", needle = "sad" |
示例 2:
1 | 输入:haystack = "leetcode", needle = "leeto" |
提示:
1 <= haystack.length, needle.length <= 104
haystack
和needle
仅由小写英文字符组成
思路
小白学KMP算法理论部分(自用)_哔哩哔哩_bilibili
代码思路
1、初始化
2、处理不相等的情况
3、处理相等的情况
4、更新next
1 | class Solution { |
7、重复的子字符串
给定一个非空的字符串 s
,检查是否可以通过由它的一个子串重复多次构成。
示例 1:
1 | 输入: s = "abab" |
示例 2:
1 | 输入: s = "aba" |
示例 3:
1 | 输入: s = "abcabcabcabc" |
提示:
1 <= s.length <= 104
s
由小写英文字母组成
思路
思路一:利用KMP方法
1 | class Solution { |
第五章 栈与队列
1、理论基础
我想栈和队列的原理大家应该很熟悉了,队列是先进先出,栈是先进后出。
如图所示:
那么我这里再列出四个关于栈的问题,大家可以思考一下。以下是以C++为例,使用其他编程语言的同学也对应思考一下,自己使用的编程语言里栈和队列是什么样的。
- C++中stack 是容器么?
- 我们使用的stack是属于哪个版本的STL?
- 我们使用的STL中stack是如何实现的?
- stack 提供迭代器来遍历stack空间么?
对于java来说这里的栈和队列需要了解一下
2、用栈实现队列
请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作(push
、pop
、peek
、empty
):
实现 MyQueue
类:
void push(int x)
将元素 x 推到队列的末尾int pop()
从队列的开头移除并返回元素int peek()
返回队列开头的元素boolean empty()
如果队列为空,返回true
;否则,返回false
说明:
- 你 只能 使用标准的栈操作 —— 也就是只有
push to top
,peek/pop from top
,size
, 和is empty
操作是合法的。 - 你所使用的语言也许不支持栈。你可以使用 list 或者 deque(双端队列)来模拟一个栈,只要是标准的栈操作即可。
示例 1:
1 | 输入: |
提示:
1 <= x <= 9
- 最多调用
100
次push
、pop
、peek
和empty
- 假设所有操作都是有效的 (例如,一个空的队列不会调用
pop
或者peek
操作)
思路
思路一:用两个栈一个负责出入库,一个负责存储
单纯的模拟题,不涉及算法。
1 | import java.util.Stack; |
3、用队列实现栈
请你仅使用两个队列实现一个后入先出(LIFO)的栈,并支持普通栈的全部四种操作(push
、top
、pop
和 empty
)。
实现 MyStack
类:
void push(int x)
将元素 x 压入栈顶。int pop()
移除并返回栈顶元素。int top()
返回栈顶元素。boolean empty()
如果栈是空的,返回true
;否则,返回false
。
注意:
- 你只能使用队列的标准操作 —— 也就是
push to back
、peek/pop from front
、size
和is empty
这些操作。 - 你所使用的语言也许不支持队列。 你可以使用 list (列表)或者 deque(双端队列)来模拟一个队列 , 只要是标准的队列操作即可。
示例:
1 | 输入: |
提示:
1 <= x <= 9
- 最多调用
100
次push
、pop
、top
和empty
- 每次调用
pop
和top
都保证栈不为空
进阶:你能否仅用一个队列来实现栈。
思路一:用两个队列实现栈
(这里要强调是单向队列)
有的同学可能疑惑这种题目有什么实际工程意义,其实很多算法题目主要是对知识点的考察和教学意义远大于其工程实践的意义,所以面试题也是这样!
刚刚做过用栈实现队列的可能依然想着用一个输入队列,一个输出队列,就可以模拟栈的功能,仔细想一下还真不行!
队列模拟栈,其实一个队列就够了,那么我们先说一说两个队列来实现栈的思路。
队列是先进先出的规则,把一个队列中的数据导入另一个队列中,数据的顺序并没有变,并没有变成先进后出的顺序。
所以用栈实现队列, 和用队列实现栈的思路还是不一样的,这取决于这两个数据结构的性质。
但是依然还是要用两个队列来模拟栈,只不过没有输入和输出的关系,而是另一个队列完全用来备份的!
如下面动画所示,用两个队列que1和que2实现队列的功能,que2其实完全就是一个备份的作用,把que1最后面的元素以外的元素都备份到que2,然后弹出最后面的元素,再把其他元素从que2导回que1。
1 | class MyStack { |
思路二:用一个队列实现栈
其实这道题目就是用一个队列就够了。
一个队列在模拟栈弹出元素的时候只要将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部,此时再去弹出元素就是栈的顺序了。
简单来说就是每次加入队列的元素,都先poll一次然后再offer这个元素就可以了,但是需要用size记录要操作多少次。
1 | class MyStack { |
4、有效的括号
给定一个只包括 '('
,')'
,'{'
,'}'
,'['
,']'
的字符串 s
,判断字符串是否有效。
有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
- 每个右括号都有一个对应的相同类型的左括号。
示例 1:
输入:s = “()”
输出:true
示例 2:
输入:s = “()[]{}”
输出:true
示例 3:
输入:s = “(]”
输出:false
示例 4:
输入:s = “([])”
输出:true
提示:
1 <= s.length <= 104
s
仅由括号'()[]{}'
组成
思路
括号匹配是使用栈解决的经典问题。
题意其实就像我们在写代码的过程中,要求括号的顺序是一样的,有左括号,相应的位置必须要有右括号。
如果还记得编译原理的话,编译器在 词法分析的过程中处理括号、花括号等这个符号的逻辑,也是使用了栈这种数据结构。
再举个例子,linux系统中,cd这个进入目录的命令我们应该再熟悉不过了。
1 | cd a/b/c/../../ |
这个命令最后进入a目录,系统是如何知道进入了a目录呢 ,这就是栈的应用(其实可以出一道相应的面试题了)
所以栈在计算机领域中应用是非常广泛的。
有的同学经常会想学的这些数据结构有什么用,也开发不了什么软件,大多数同学说的软件应该都是可视化的软件例如APP、网站之类的,那都是非常上层的应用了,底层很多功能的实现都是基础的数据结构和算法。
所以数据结构与算法的应用往往隐藏在我们看不到的地方!
这里我就不过多展开了,先来看题。
由于栈结构的特殊性,非常适合做对称匹配类的题目。
首先要弄清楚,字符串里的括号不匹配有几种情况。
一些同学,在面试中看到这种题目上来就开始写代码,然后就越写越乱。
建议在写代码之前要分析好有哪几种不匹配的情况,如果不在动手之前分析好,写出的代码也会有很多问题。
先来分析一下 这里有三种不匹配的情况,
- 第一种情况,字符串里左方向的括号多余了 ,所以不匹配。
- 第二种情况,括号没有多余,但是 括号的类型没有匹配上。
- 第三种情况,字符串里右方向的括号多余了,所以不匹配。
我们的代码只要覆盖了这三种不匹配的情况,就不会出问题,可以看出 动手之前分析好题目的重要性。
1 | import java.util.Stack; |
5、删除字符串中的所有相邻的重复项
1047. 删除字符串中的所有相邻重复项 - 力扣(LeetCode)
给出由小写字母组成的字符串 s
,重复项删除操作会选择两个相邻且相同的字母,并删除它们。
在 s
上反复执行重复项删除操作,直到无法继续删除。
在完成所有重复项删除操作后返回最终的字符串。答案保证唯一。
示例:
1 | 输入:"abbaca" |
提示:
1 <= s.length <= 105
s
仅由小写英文字母组成。
思路
本题要删除相邻相同元素,相对于20. 有效的括号 (opens new window)来说其实也是匹配问题,20. 有效的括号 是匹配左右括号,本题是匹配相邻元素,最后都是做消除的操作。
本题也是用栈来解决的经典题目。
那么栈里应该放的是什么元素呢?
我们在删除相邻重复项的时候,其实就是要知道当前遍历的这个元素,我们在前一位是不是遍历过一样数值的元素,那么如何记录前面遍历过的元素呢?
所以就是用栈来存放,那么栈的目的,就是存放遍历过的元素,当遍历当前的这个元素的时候,去栈里看一下我们是不是遍历过相同数值的相邻元素。
1 | import java.util.Stack; |
6、逆波兰表达式求值
给你一个字符串数组 tokens
,表示一个根据 逆波兰表示法 表示的算术表达式。
请你计算该表达式。返回一个表示表达式值的整数。
注意:
- 有效的算符为
'+'
、'-'
、'*'
和'/'
。 - 每个操作数(运算对象)都可以是一个整数或者另一个表达式。
- 两个整数之间的除法总是 向零截断 。
- 表达式中不含除零运算。
- 输入是一个根据逆波兰表示法表示的算术表达式。
- 答案及所有中间计算结果可以用 32 位 整数表示。
示例 1:
1 | 输入:tokens = ["2","1","+","3","*"] |
示例 2:
1 | 输入:tokens = ["4","13","5","/","+"] |
示例 3:
1 | 输入:tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"] |
思路
其实只是一个模拟运算的过程,看效果就很快会想到使用栈来模拟。
1 | class Solution { |
7、滑动窗口最大值
给你一个整数数组 nums
,有一个大小为 k
的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k
个数字。滑动窗口每次只向右移动一位。
返回 滑动窗口中的最大值 。
示例 1:
1 | 输入:nums = [1,3,-1,-3,5,3,6,7], k = 3 |
示例 2:
1 | 输入:nums = [1], k = 1 |
提示:
1 <= nums.length <= 105
-104 <= nums[i] <= 104
1 <= k <= nums.length
思路一:使用单调队列
这是使用单调队列的经典题目。
难点是如何求一个区间里的最大值?
1、暴力方法,遍历一遍的过程中每次从窗口中再找到最大的数值,这样就是O(n x k)的算法。
2、思考现在我们需要一个队列,这个队列,放进去窗口的元素,然后随着窗口移动,队列也一进一出。,每次移动后,队列会告诉我们里面的最大值是什么。
这种队列怎么实现了?
然后再思考一下,队列里的元素一定是要排序的,而且要最大值放在出对口,要不然怎么知道最大值呢?
但如果把窗口里的元素都放进队列里,窗口移动的时候,队列需要弹出元素。
那么问题是,已经排序之后的队列 怎么能把窗口要移出的元素(这个元素不一定是最大值)弹出呢?
其实队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。
那么这个维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。
不要以为实现的单调队列就是 对窗口里面的数进行排序,如果排序的话,那和优先级队列又有什么区别了呢。
单调队列是如何维护队列里的元素?
1 | class MyQueue{ |
8、前k个高频元素
给你一个整数数组 nums
和一个整数 k
,请你返回其中出现频率前 k
高的元素。你可以按 任意顺序 返回答案。
示例 1:
1 | 输入: nums = [1,1,1,2,2,3], k = 2 |
示例 2:
1 | 输入: nums = [1], k = 1 |
提示:
1 <= nums.length <= 105
k
的取值范围是[1, 数组中不相同的元素的个数]
- 题目数据保证答案唯一,换句话说,数组中前
k
个高频元素的集合是唯一的
进阶:你所设计算法的时间复杂度 必须 优于 O(n log n)
,其中 n
是数组大小。
思路
这道题目主要涉及到如下三块内容:
- 要统计元素出现频率
- 对频率排序
- 找出前K个高频元素
首先统计元素出现的频率,这一类的问题可以使用map来进行统计。
然后是对频率进行排序,这里我们可以使用一种 容器适配器就是优先级队列。
什么是优先级队列呢?
其实就是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。
而且优先级队列内部元素是自动依照元素的权值排列。那么它是如何有序排列的呢?
缺省情况下priority_queue利用max-heap(大顶堆)完成对元素的排序,这个大顶堆是以vector为表现形式的complete binary tree(完全二叉树)。
什么是堆呢?
【从堆的定义到优先队列、堆排序】 10分钟看懂必考的数据结构——堆_哔哩哔哩_bilibili
堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。
所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。
本题我们就要使用优先级队列来对部分频率进行排序。
为什么不用快排呢, 使用快排要将map转换为数组的结构,然后对整个数组进行排序, 而这种场景下,我们其实只需要维护k个有序的序列就可以了,所以使用优先级队列是最优的。(当然前面那个也可以但是没那么快)
此时要思考一下,是使用小顶堆呢,还是大顶堆?
有的同学一想,题目要求前 K 个高频元素,那么果断用大顶堆啊。
那么问题来了,定义一个大小为k的大顶堆,在每次移动更新大顶堆的时候,每次弹出都把最大的元素弹出去了,那么怎么保留下来前K个高频元素呢。
而且使用大顶堆就要把所有元素都进行排序,那能不能只排序k个元素呢?
所以我们要用小顶堆,因为要统计最大前k个元素,只有小顶堆每次将最小的元素弹出,最后小顶堆里积累的才是前k个最大元素。
寻找前k个最大元素流程如图所示:(图中的频率只有三个,所以正好构成一个大小为3的小顶堆,如果频率更多一些,则用这个小顶堆进行扫描)
1 | import java.util.*; |
第六章 二叉树
1、理论基础
在我们解题过程中二叉树有两种主要的形式:满二叉树和完全二叉树。
二叉树种类
满二叉树
满二叉树:如果一棵二叉树只有度为0的结点和度为2的结点,并且度为0的结点在同一层上,则这棵二叉树为满二叉树。
度就是所拥有的子节点个数,如果是0就称为叶子结点
如图所示:
这棵二叉树为满二叉树,也可以说深度为k,有2^k-1个节点的二叉树。
完全二叉树
什么是完全二叉树?
完全二叉树的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层(h从1开始),则该层包含 1~ 2^(h-1) 个节点。
大家要自己看完全二叉树的定义,很多同学对完全二叉树其实不是真正的懂了。
我来举一个典型的例子如题:
之前我们刚刚讲过优先级队列其实是一个堆,堆就是一棵完全二叉树,同时保证父子节点的顺序关系。
二叉搜索树
前面介绍的树,都没有数值的,而二叉搜索树是有数值的了,二叉搜索树是一个有序树。
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 它的左、右子树也分别为二叉排序树
下面这两棵树都是搜索树
左小右大
平衡二叉搜索树
平衡二叉搜索树:又被称为AVL(Adelson-Velsky and Landis)树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
C++ 类型 | Java 类型 | 底层数据结构 | 是否支持重复键 | 是否有序 |
---|---|---|---|---|
map |
TreeMap |
红黑树 | 否 | 是 |
set |
TreeSet |
红黑树(基于 TreeMap) | 否 | 是 |
multimap |
TreeMap |
红黑树 | 是 | 是 |
multiset |
自定义或 Guava | 红黑树 | 是 | 是 |
所以大家使用自己熟悉的编程语言写算法,一定要知道常用的容器底层都是如何实现的,最基本的就是map、set等等,否则自己写的代码,自己对其性能分析都分析不清楚!
二叉树的存储方式
二叉树可以链式存储,也可以顺序存储。
那么链式存储方式就用指针, 顺序存储的方式就是用数组。
顾名思义就是顺序存储的元素在内存是连续分布的,而链式存储则是通过指针把分布在各个地址的节点串联一起。
链式存储如图:
链式存储是大家很熟悉的一种方式,那么我们来看看如何顺序存储呢?
其实就是用数组来存储二叉树,顺序存储的方式如图:
用数组来存储二叉树如何遍历的呢?
如果父节点的数组下标是 i,那么它的左孩子就是 i * 2 + 1,右孩子就是 i * 2 + 2。
但是用链式表示的二叉树,更有利于我们理解,所以一般我们都是用链式存储二叉树。
所以大家要了解,用数组依然可以表示二叉树。
二叉树的遍历方式
关于二叉树的遍历方式,要知道二叉树遍历的基本方式都有哪些。
一些同学用做了很多二叉树的题目了,可能知道前中后序遍历,可能知道层序遍历,但是却没有框架。
我这里把二叉树的几种遍历方式列出来,大家就可以一一串起来了。
二叉树主要有两种遍历方式:
- 深度优先遍历:先往深走,遇到叶子节点再往回走。
- 广度优先遍历:一层一层的去遍历。
这两种遍历是图论中最基本的两种遍历方式,后面在介绍图论的时候 还会介绍到。
那么从深度优先遍历和广度优先遍历进一步拓展,才有如下遍历方式:
- 深度优先遍历
- 前序遍历(递归法,迭代法)
- 中序遍历(递归法,迭代法)
- 后序遍历(递归法,迭代法)
- 广度优先遍历
- 层次遍历(迭代法)
在深度优先遍历中:有三个顺序,前中后序遍历, 有同学总分不清这三个顺序,经常搞混,我这里教大家一个技巧。
这里前中后,其实指的就是中间节点的遍历顺序,只要大家记住 前中后序指的就是中间节点的位置就可以了。
看如下中间节点的顺序,就可以发现,中间节点的顺序就是所谓的遍历方式
- 前序遍历:中左右
- 中序遍历:左中右
- 后序遍历:左右中
大家可以对着如下图,看看自己理解的前后中序有没有问题。
最后再说一说二叉树中深度优先和广度优先遍历实现方式,我们做二叉树相关题目,经常会使用递归的方式来实现深度优先遍历,也就是实现前中后序遍历,使用递归是比较方便的。
之前我们讲栈与队列的时候,就说过栈其实就是递归的一种实现结构,也就说前中后序遍历的逻辑其实都是可以借助栈使用递归的方式来实现的。
而广度优先遍历的实现一般使用队列来实现,这也是队列先进先出的特点所决定的,因为需要先进先出的结构,才能一层一层的来遍历二叉树。
这里其实我们又了解了栈与队列的一个应用场景了。
具体的实现我们后面都会讲的,这里大家先要清楚这些理论基础。
二叉树的定义
刚刚我们说过了二叉树有两种存储方式顺序存储,和链式存储,顺序存储就是用数组来存,这个定义没啥可说的,我们来看看链式存储的二叉树节点的定义方式。
1 | public class TreeNode { |
大家会发现二叉树的定义 和链表是差不多的,相对于链表 ,二叉树的节点里多了一个指针, 有两个指针,指向左右孩子。
这里要提醒大家要注意二叉树节点定义的书写方式。
在现场面试的时候 面试官可能要求手写代码,所以数据结构的定义以及简单逻辑的代码一定要锻炼白纸写出来。
因为我们在刷leetcode的时候,节点的定义默认都定义好了,真到面试的时候,需要自己写节点定义的时候,有时候会一脸懵逼!
2、二叉树的递归遍历
思路
这次我们要好好谈一谈递归,为什么很多同学看递归算法都是“一看就会,一写就废”。
主要是对递归不成体系,没有方法论,每次写递归算法 ,都是靠玄学来写代码,代码能不能编过都靠运气。
本篇将介绍前后中序的递归写法,一些同学可能会感觉很简单,其实不然,我们要通过简单题目把方法论确定下来,有了方法论,后面才能应付复杂的递归。
这里帮助大家确定下来递归算法的三个要素。每次写递归,都按照这三要素来写,可以保证大家写出正确的递归算法!
- 确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数, 并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型。
- 确定终止条件: 写完了递归算法, 运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。
- 确定单层递归的逻辑: 确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。
好了,我们确认了递归的三要素,接下来就来练练手:
以下以前序遍历为例:
1 | // 前序遍历·递归·LC144_二叉树的前序遍历 |
3、二叉树的迭代遍历
思路
为什么可以用迭代法(非递归的方式)来实现二叉树的前后中序遍历呢?
匹配问题都是栈的强项,
递归的实现就是:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。
此时大家应该知道我们用栈也可以是实现二叉树的前后中序遍历了。
前序遍历(迭代法)
我们先看一下前序遍历。
前序遍历是中左右,每次先处理的是中间节点,那么先将根节点放入栈中,然后将右孩子加入栈,再加入左孩子。
为什么要先加入 右孩子,再加入左孩子呢? 因为这样出栈的时候才是中左右的顺序
不难写出下面的代码
思路就是遍历到叶子结点再出,第一个节点先出
1 | // 前序遍历顺序:中-左-右,入栈顺序:中-右-左 |
此时会发现貌似使用迭代法写出前序遍历并不难,确实不难。
此时是不是想改一点前序遍历代码顺序就把中序遍历搞出来了?
其实还真不行!
但接下来,再用迭代法写中序遍历的时候,会发现套路又不一样了,目前的前序遍历的逻辑无法直接应用到中序遍历上。
中序遍历(迭代法)
为了解释清楚,我说明一下 刚刚在迭代的过程中,其实我们有两个操作:
- 处理:将元素放进result数组中
- 访问:遍历节点
分析一下为什么刚刚写的前序遍历的代码,不能和中序遍历通用呢,因为前序遍历的顺序是中左右,先访问的元素是中间节点,要处理的元素也是中间节点,所以刚刚才能写出相对简洁的代码,因为要访问的元素和要处理的元素顺序是一致的,都是中间节点。
那么再看看中序遍历,中序遍历是左中右,先访问的是二叉树顶部的节点,然后一层一层向下访问,直到到达树左面的最底部,再开始处理节点(也就是在把节点的数值放进result数组中),这就造成了处理顺序和访问顺序是不一致的。
那么在使用迭代法写中序遍历,就需要借用指针的遍历来帮助访问节点,栈则用来处理节点上的元素。
1 | // 中序遍历顺序: 左-中-右 入栈顺序: 左-右 |
后序遍历(迭代法)
再来看后序遍历,先序遍历是中左右,后序遍历是左右中,那么我们只需要调整一下先序遍历的代码顺序,就变成中右左的遍历顺序,然后在反转result数组,输出的结果顺序就是左右中了,如下图:
1 | // 后序遍历顺序 左-右-中 入栈顺序:中-左-右 出栈顺序:中-右-左, 最后翻转结果 |
所以后序遍历只需要前序遍历的代码稍作修改就可以了,代码如下:
总结
此时我们用迭代法写出了二叉树的前后中序遍历,大家可以看出前序和中序是完全两种代码风格,并不像递归写法那样代码稍做调整,就可以实现前后中序。
这是因为前序遍历中访问节点(遍历节点)和处理节点(将元素放进result数组中)可以同步处理,但是中序就无法做到同步!
上面这句话,可能一些同学不太理解,建议自己亲手用迭代法,先写出来前序,再试试能不能写出中序,就能理解了。
那么问题又来了,难道二叉树前后中序遍历的迭代法实现,就不能风格统一么(即前序遍历改变代码顺序就可以实现中序 和 后序)?
当然可以,这种写法,还不是很好理解,我们将在下一篇文章里重点讲解。
4、二叉树的统一迭代法
思路
我们发现迭代法实现的先中后序,其实风格也不是那么统一,除了先序和后序,有关联,中序完全就是另一个风格了
实践过的同学,也会发现使用迭代法实现先中后序遍历,很难写出统一的代码,不像是递归法,实现了其中的一种遍历方式,其他两种只要稍稍改一下节点顺序就可以了。
其实针对三种遍历方式,使用迭代法是可以写出统一风格的代码!
重头戏来了,接下来介绍一下统一写法。
我们以中序遍历为例,
如何标记呢?
- 访问(Visited):当一个节点第一次被压入栈时,它还没有被“访问”(即它的左子树还没有被处理)。
- 处理(Processed):当一个节点被弹出栈时,如果它不是
null
,说明它是第一次被访问;如果它是null
,说明它的左子树已经处理完毕,接下来需要处理它自己(将它的值加入结果列表)。
1 | class Solution { |
5、二叉树的层序遍历
给你一个二叉树,请你返回其按 层序遍历 得到的节点值。 (即逐层地,从左到右访问所有节点)。
思路
层序遍历一个二叉树。就是从左到右一层一层的去遍历二叉树。这种遍历的方式和我们之前讲过的都不太一样。
需要借用一个辅助数据结构即队列来实现,队列先进先出,符合一层一层遍历的逻辑,而用栈先进后出适合模拟深度优先遍历也就是递归的逻辑。
而这种层序遍历方式就是图论中的广度优先遍历,只不过我们应用在二叉树上。
使用队列实现二叉树广度优先遍历
方法一是递归的方式,deep记录的是层级
1 | /** |
1 | // 102.二叉树的层序遍历 |
我们以这棵树为例:
1 | 3 |
初始化阶段
1 | Queue<TreeNode> que = new LinkedList<>(); |
此时队列内容:[3]
que.size() = 1
→ len = 1
第一次外层循环(处理第 1 层)
1 | while (!que.isEmpty()) { |
内层循环(处理第 1 层)
len = 1
- 弹出节点
3
,加入itemList = [3]
- 把
3.left = 9
,3.right = 20
加入队列
队列现在:[9, 20]
len--
→len = 0
,退出内层循环resList.add([3])
第二次外层循环(处理第 2 层)
- 队列:
[9, 20]
len = que.size() = 2
内层循环(处理第 2 层)
len = 2
- 弹出
9
→itemList = [9]
9
没有子节点,不加入队列len-- = 1
- 弹出
20
→itemList = [9, 20]
20.left = 15
,20.right = 7
加入队列len-- = 0
,退出内层循环
队列现在:[15, 7]
resList.add([9, 20])
第三次外层循环(处理第 3 层)
- 队列:
[15, 7]
len = 2
内层循环(处理第 3 层)
- 弹出
15
,加入itemList = [15]
15
没有子节点len-- = 1
- 弹出
7
,加入itemList = [15, 7]
7
没有子节点len-- = 0
,退出内层循环
队列现在为空
resList.add([15, 7])
6、二叉树的层次遍历2
107. 二叉树的层序遍历 II - 力扣(LeetCode)
给定一个二叉树,返回其节点值自底向上的层次遍历。 (即按从叶子节点所在层到根节点所在的层,逐层从左向右遍历)
思路
相当于把普通二叉树的res的数组反转一下就好了
1 | /** |
7、二叉树的右视图
思路
给定一棵二叉树,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。
层序遍历的时候,判断是否遍历到单元层的最后面的元素,如果是,就放进res数组中,随后返回res就可以了
1 | /** |
8、二叉树的层平均值
给定一个非空二叉树, 返回一个由每层节点平均值组成的数组。
思路
就是在放入res的时候求平均值
1 | /** |
9、N叉树的层序遍历
给定一个 N 叉树,返回其节点值的层序遍历。 (即从左到右,逐层遍历)。
例如,给定一个 3叉树 :
返回其层序遍历:
[ [1], [3,2,4], [5,6] ]
思路
1 | class Solution { |
10、在每个树行中找最大值
515. 在每个树行中找最大值 - 力扣(LeetCode)
您需要在二叉树的每一行中找到最大的值。
思路
1 | /** |
11、填充每个节点的下一个右侧节点指针
116. 填充每个节点的下一个右侧节点指针 - 力扣(LeetCode)
给定一个完美二叉树,其所有叶子节点都在同一层,每个父节点都有两个子节点。二叉树定义如下:
1 | struct Node { |
填充它的每个 next 指针,让这个指针指向其下一个右侧节点。如果找不到下一个右侧节点,则将 next 指针设置为 NULL。
初始状态下,所有 next 指针都被设置为 NULL。
示例 1:
1 | 输入:root = [1,2,3,4,5,6,7] |
示例 2:
1 | 输入:root = [] |
提示:
- 树中节点的数量在
[0, 212 - 1]
范围内 -1000 <= node.val <= 1000
1 | class Solution { |
12、填充每个节点的下一个右侧节点指针2
117. 填充每个节点的下一个右侧节点指针 II - 力扣(LeetCode)
给定一个二叉树:
1 | struct Node { |
填充它的每个 next 指针,让这个指针指向其下一个右侧节点。如果找不到下一个右侧节点,则将 next 指针设置为 NULL
。
初始状态下,所有 next 指针都被设置为 NULL
。
示例 1:
1 | 输入:root = [1,2,3,4,5,null,7] |
示例 2:
1 | 输入:root = [] |
提示:
- 树中的节点数在范围
[0, 6000]
内 -100 <= Node.val <= 100
1 | class Solution { |
13、二叉树的最大深度
层序遍历
层数就是最大深度返回层数就行
1 | /** |
递归法
二叉树中“高度”与“深度” 的问题,很多初学者在学习二叉树时都会对这两个概念产生混淆。
一、定义对比
概念 | 定义 | 说明 |
---|---|---|
深度(Depth) | 从根节点到当前节点的路径长度(边数或节点数) | 从上往下看,根节点深度为0或1(取决于定义) |
高度(Height) | 从当前节点到其最远叶子节点的路径长度(边数或节点数) | 从下往上看,叶子节点高度为0或1 |
二、举个例子说明
我们来看一棵简单的二叉树:
1 | A |
假设:节点数从1开始计数,边数从0开始计数
节点 | 深度(Depth) | 高度(Height) |
---|---|---|
A(根) | 0(根节点到自己的距离) | 2(最长路径 A → B → C 或 D) |
B | 1(根到B) | 1(最长路径 B → C 或 D) |
C | 2(根到C) | 0(C是叶子) |
D | 2(根到D) | 0(D是叶子) |
本题可以使用前序(中左右),也可以使用后序遍历(左右中),使用前序求的就是深度,使用后序求的是高度。
- 二叉树节点的深度:指从根节点到该节点的最长简单路径边的条数或者节点数(取决于深度从0开始还是从1开始)
- 二叉树节点的高度:指从该节点到叶子节点的最长简单路径边的条数或者节点数(取决于高度从0开始还是从1开始)
而根节点的高度就是二叉树的最大深度,所以本题中我们通过后序求的根节点高度来求的二叉树最大深度。
这一点其实是很多同学没有想清楚的,很多题解同样没有讲清楚。
我先用后序遍历(左右中)来计算树的高度。
1、确定递归函数的参数和返回值:参数就是传入数的根节点,返回就是返回这棵树的深度,所以返回的是int
1 | int getdepth(TreeNode node) |
2、确定终止条件:如果为空节点的话,就返回0,表示高度为0
1 | if(node == null) return 0 |
3、确定单层递归的逻辑:先求左子树的深度,再求右子树的深度,最后取左右深度最大的数值再+1(加1是因为算上当前中间节点)就是目前节点为根节点的树的深度。
1 | int leftdepth = getdepth(node.left);//左 |
最后的代码如下:
1 | /** |
14、二叉树的最小深度
给定一个二叉树,找出其最小深度。
最小深度是从根节点到最近叶子节点的最短路径上的节点数量。
说明:叶子节点是指没有子节点的节点。
示例 1:
1 | 输入:root = [3,9,20,null,null,15,7] |
示例 2:
1 | 输入:root = [2,null,3,null,4,null,5,null,6] |
提示:
- 树中节点数的范围在
[0, 105]
内 -1000 <= Node.val <= 1000
层序遍历
1 | /** |
递归法
这里是求最小深度
前序遍历求的是深度,后序遍历求的事高度。
那么使用后续遍历,其实求的事根节点到叶子节点的最小距离,求的事高度的过程,不过这个最小距离也同样是最小深度。
递归三部曲
1、确定递归函数的参数和返回值
参数为要传入的二叉树根节点,返回的是int的深度
1 | int getDepth(TreeNode node) |
2、确定终止条件
终止条件也是遇到空节点返回0,表示当前节点的高度为0
1 | if(node == null) return 0; |
3、确定单层递归的逻辑
这里很重要
因为如果左子树为空的话算作0的话,没有左孩子的分支会被算作最短深度。
如果左子树为空,右子树不为空,那么最小深度是1+右子树的深度
如果右子树为空,左子树不为空,最小深度是1+左子树的深度。
最后如果左右子树都不为空或者都为空,返回左右子树深度最小值+1.
我们用的是后序遍历
1 | int leftDepth = getDepth(node.left);//左 |
最后的代码
1 | /** |
15、反转二叉树
给你一棵二叉树的根节点 root
,翻转这棵二叉树,并返回其根节点。
示例 1:
1 | 输入:root = [4,2,7,1,3,6,9] |
示例 2:
1 | 输入:root = [2,1,3] |
示例 3:
1 | 输入:root = [] |
提示:
- 树中节点数目范围在
[0, 100]
内 -100 <= Node.val <= 100
思路
自己看一下想要反转二叉树,其实就是把每一个节点的左右孩子交换一下就行了。
那么关键在于遍历顺序,前中后序应该选择哪一种遍历顺序?
这道题使用前序比那里和后续遍历都是可以的,唯独中序遍历不方便,因为中序遍历会把某些节点的左右孩子反转两次。画个图就懂了求求你一定要画
层序遍历也是可以的,只要把每一个节点的左右孩子反转一次的遍历方式就可以了。
递归法
我们以前序遍历为例子,看一下翻转过程。
递归的三部曲:
1、确定递归函数的参数和返回值
参数就是要传入节点的指针,不需要其他参数,通常此时定下来主要参数,如果在写递归的逻辑中发现还需要其他参数的时候,随时补充。
返回值的话其实也不需要,但是题目中给出的要返回root节点的指针,可以直接使用题目定义好的函数,所以函数的返回类型可以为TreeNode.
2、确定终止条件
当前节点为空的时候就返回
3、确定单层递归的逻辑
因为是前序遍历,所以先进性交换左右孩子节点,然后翻转左子树,翻转右子树。
1 | class Solution { |
迭代法(深度优先)
为什么这个中序就是可以的呢,因为这是用栈来遍历,而不是靠指针来遍历,避免了递归法中翻转了两次的情况,大家可以画图理解一下
1 | /** |
层序遍历
1 | /** |
16、对称二叉树
给定一个二叉树,检查它是否是镜像对称的。
思路
首先想清楚,判断对称二叉树要比较的是哪两个节点,要比较的可不是左右节点!
对于二叉树是否对称,要比较的是根节点的左子树与右子树是不是相互翻转的,理解这一点就知道了其实我们要比较的是两个树(这两个树是根节点的左右子树),所以在递归遍历的过程中,也是要同时遍历两棵树。
那么遍历的顺序应该是什么样的呢?
本题遍历只能是“后序遍历”,因为我们要通过递归函数的返回值来判断两个子树的内侧节点和外侧节点是否相等。
正是因为要遍历两棵树而且要比较内侧和外侧节点,所以准确的来说是一个树的遍历顺序是左右中,一个树的遍历顺序是右左中。
但都可以理解算是后序遍历,尽管已经不是严格上在一个树上进行遍历的后序遍历了。
其实后序也可以理解为是一种回溯,当然这是题外话,讲回溯的时候会重点讲的。
说到这大家可能感觉我有点啰嗦,哪有这么多道理,上来就干就完事了。别急,我说的这些在下面的代码讲解中都有身影。
那么我们先来看看递归法的代码应该怎么写。
递归法
递归三部曲
1、确定递归函数的参数和返回值
因为我们要比较的是根节点的两个子树是否是相互翻转的,进而判断这个树是不是对称树,所以要比较的是两个树,参数自然也是左子树节点和右子树节点。
返回值自然是bool类型。
1 | bool compare(TreeNode left,TreeNode right) |
2、确定终止条件
要比较两个节点数值相不相同,首先要把两个节点为空的情况弄清楚!否则后面比较数值的时候就会操作空指针了。
节点为空的情况有:(注意我们比较的其实不是左孩子和右孩子,所以如下我称之为左节点右节点)
- 左节点为空,右节点不为空,不对称,return false
- 左不为空,右为空,不对称 return false
- 左右都为空,对称,返回true
此时已经排除掉了节点为空的情况,那么剩下的就是左右节点不为空:
- 左右都不为空,比较节点数值,不相同就return false
此时左右节点不为空,且数值也不相同的情况我们也处理了。
1 | if(left == null && right != null) return false; |
注意上面最后一种情况,我没有使用else,而是else if, 因为我们把以上情况都排除之后,剩下的就是 左右节点都不为空,且数值相同的情况。
3、确定单层递归的逻辑
此时才进入单层递归的逻辑,单层递归的逻辑就是处理 左右节点都不为空,且数值相同的情况。
- 比较二叉树外侧是否对称:传入的是左节点的左孩子,右节点的右孩子。
- 比较内侧是否对称,传入左节点的右孩子,右节点的左孩子。
- 如果左右都对称就返回true ,有一侧不对称就返回false 。
1 | boolean outside = compare(left.left, right.right); // 左子树:左、 右子树:右 |
我给出的代码并不简洁,但是把每一步判断的逻辑都清楚的描绘出来了。
1 | /** |
如果上来就看网上各种简洁的代码,看起来真的很简单,但是很多逻辑都掩盖掉了,而题解可能也没有把掩盖掉的逻辑说清楚。
盲目的照着抄,结果就是:发现这是一道“简单题”,稀里糊涂的就过了,但是真正的每一步判断逻辑未必想到清楚。
迭代法
这道题目我们也可以使用迭代法,但要注意,这里的迭代法可不是前中后序的迭代写法,因为本题的本质是判断两个树是否是相互翻转的,其实已经不是所谓二叉树遍历的前中后序的关系了。
这里我们可以使用队列来比较两个树(根节点的左右子树)是否相互翻转,(注意这不是层序遍历)
1 | /** |
17、完全二叉树的节点个数
222. 完全二叉树的节点个数 - 力扣(LeetCode)
给你一棵 完全二叉树 的根节点 root
,求出该树的节点个数。
完全二叉树 的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h
层(从第 0 层开始),则该层包含 1~ 2h
个节点。
示例 1:
1 | 输入:root = [1,2,3,4,5,6] |
示例 2:
1 | 输入:root = [] |
示例 3:
1 | 输入:root = [1] |
提示:
- 树中节点的数目范围是
[0, 5 * 104]
0 <= Node.val <= 5 * 104
- 题目数据保证输入的树是 完全二叉树
思路
递归
1、确定函数的参数和返回值,返回个数,传入节点
1 | int getNodeNum(TreeNode node); |
2、确定终止条件,如果是空节点的话,返回0
1 | if(node == null) return 0; |
3、确定单层递归逻辑,这边用先求左子树的节点数量,再求右子树的节点数量,最后总数为左子树+右子树+1
1 | int leftNum = getNodeNum(node.left); |
最终的代码
1 | /** |
迭代法也是层序遍历
1 | /** |
针对完全二叉树的解法
在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大层,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第h层,则该层包含1 - 2的(h-1)次方个节点
所以完全二叉树只有两种情况
情况一:满二叉树
情况二:最后一层的叶子节点没有满
情况一可以直接用2的深度次方-1
情况二,分别递归左孩子和右孩子,递归到某一深度一定会有左孩子或右孩子为满二叉树,然后依旧可以用情况1来计算
这里关键在于如何判断一个左子树或者右子树是不是满二叉树
在完全二叉树中,如果递归向左遍历的深度等于递归向右遍历,那就说明是满二叉树,如果递归向左遍历的深度不等于递归向右遍历的深度,就不是满二叉树。
那么如果是满二叉树就利用公式计算节点数量,如果不是就继续递归其左子树和右子树+1(加上自己的节点)
1 | class Solution { |
18、平衡二叉树
平衡二叉树就是树的所有左右子节点高度差不为1
示例 1:
1 | 输入:root = [3,9,20,null,null,15,7] |
示例 2:
1 | 输入:root = [1,2,2,3,3,null,null,4,4] |
示例 3:
1 | 输入:root = [] |
提示:
- 树中的节点数在范围
[0, 5000]
内 -104 <= Node.val <= 104
但leetcode中强调的深度和高度很明显是按照节点来计算的,如图:
关于根节点的深度究竟是1 还是 0,不同的地方有不一样的标准,leetcode的题目中都是以节点为一度,即根节点深度是1。但维基百科上定义用边为一度,即根节点的深度是0,我们暂时以leetcode为准(毕竟要在这上面刷题)。
因为求深度可以从上到下去查 所以需要前序遍历(中左右),而高度只能从下到上去查,所以只能后序遍历(左右中)
思路
递归
这里要求比较高度,必然是用后序遍历
递归的三部曲
1、确定递归函数的参数值和返回值
参数:当前的节点。返回值:以当前传入节点为根节点的树的高度
1 | int getHeight(TreeNode node); |
如果当前传入节点为根节点的二叉树已经不是二叉平衡树了,还返回高度的话就没有意义了。
所以如果已经不是二叉平衡树了,可以返回-1来标记已经不符合平衡树的规则了。
2、明确终止条件
递归的过程中依然是遇到了空节点了为终止,返回0,表示当前节点为根节点的树高度为0
1 | if(node == null){ |
3、明确单层递归的逻辑
如何判断当前传入节点为根节点的二叉树是否是平衡二叉树呢?当然是其左子树高度和其右子树高度的差值。
分别求出其左右子树的高度,然后如果差值小于等于1,则返回当前二叉树的高度,否则返回-1,表示已经不是平衡二叉树了。
1 | int leftHeight = getHeight(node.left);//左 |
最终的代码
1 | class Solution { |
迭代
我们可以用层序遍历来求深度,但是不能直接使用层序遍历来求高度。
这题的迭代方式,可以先定义一个函数,专门用来求高度。
这个函数可以通过栈模拟的后序遍历找到每一个节点的高度(其实是通过求传入节点为根节点的最大深度求高度)
1 | int getDepth(TreeNode cur){ |
然后再用栈来模拟后序遍历,遍历每一个节点的时候,再去判断左右孩子的高度是否符合
1 | boolean isBalanced(TreeNode root){ |
当然此题用迭代法,其实效率很低,因为没有很好的模拟回溯的过程,所以迭代法有很多重复的计算。
虽然理论上所有的递归都可以用迭代来实现,但是有的场景难度可能比较大。
1 | class Solution { |
例如:都知道回溯法其实就是递归,但是很少人用迭代的方式去实现回溯算法!
因为对于回溯算法已经是非常复杂的递归了,如果再用迭代的话,就是自己给自己找麻烦,效率也并不一定高。
19、二叉树的所有路径
257. 二叉树的所有路径 - 力扣(LeetCode)257. 二叉树的所有路径 - 力扣(LeetCode)
给你一个二叉树的根节点 root
,按 任意顺序 ,返回所有从根节点到叶子节点的路径。
叶子节点 是指没有子节点的节点。
示例 1:
1 | 输入:root = [1,2,3,null,5] |
示例 2:
1 | 输入:root = [1] |
提示:
- 树中节点的数目在范围
[1, 100]
内 -100 <= Node.val <= 100
思路
这道题目要求从根节点到叶子的路径,所以需要前序遍历,这样才方便让父节点指向孩子节点,找到对应的路径。
在这道题目中将第一次涉及到回溯,因为我们要把路径记录下来,需要回溯来回退一个路径再进入另一个路径。
前序遍历以及回溯的过程如图:
我们先使用递归的方式,来做前序遍历。因为前序遍历很方便,如果不是前序遍历还需要考虑之前的固定模版或者说修改一下递归的逻辑很麻烦,回溯也很麻烦,所以还是用前序最好。
要知道递归和回溯就是一家的,本题也需要回溯。
递归
1、递归函数参数以及返回值
要传入根节点,记录每一条路径的path,和存放结果集的result,这里递归不需要返回值
1 | void traversal(TreeNode cur,List<Integer> path,List<String> res); |
2、确定递归终止条件
本题的终止条件,是要找到叶子节点,就开始结束的处理逻辑条件了(把路径放到res里面)
那什么时候算是找到了叶子节点?是当cur不为空,并且左右孩子都为空的时候,就找到了叶子节点。
所以终止条件是
1 | if(cur.left == null && cur.right == null){ |
为什么没有判断cur是否为空呢,因为下面的逻辑可以控制节点不入循环。
再来看一下终止处理逻辑。
这里使用List
那为什么用List
那么终止处理逻辑如下
1 | if (root.left == null && root.right == null) { |
那么整体代码如下
1 | //方式一 |
20、左叶子之和
计算给定二叉树的所有左叶子之和。
示例:
思路
首先要注意是判断左叶子,不是二叉树左侧的节点。
左叶子:节点A的左孩子不为空,并且做孩子的左右孩子都为空,说明是叶子节点,那么A节点的左孩子为左叶子节点
大家思考一下如下图中二叉树,左叶子之和究竟是多少?
其实是0,因为这棵树根本没有左叶子!
但看这个图的左叶子之和是多少?
相信通过这两个图,大家对最左叶子的定义有明确理解了。
那么判断当前节点是不是左叶子是无法判断的,必须要通过节点的父节点来判断其左孩子是不是左叶子。
如果该节点的左节点不为空,该节点的左节点的左节点为空,该节点的左节点的右节点为空,则找到了一个左叶子,判断代码如下:
1 | if(node.left != null && node.left.left == null && node.left.right == null){ |
递归法
递归的遍历顺序为后序遍历(左右中),是因为要通过递归函数的返回值来累加求取左叶子数值之和。
我的理解就是因为是左叶子,左先会比较方便。
递归三部曲:
1、确定递归函数的参数和返回值
判断一个树的左叶子节点之和,那么一定要传入树的根节点,递归函数的返回值为数值之和,所以int
使用题目中给出的函数就可以了
2、确定终止条件
如果遍历到空节点,那么左叶子值肯定是0
1 | if(root = null) return 0; |
注意,只有当前遍历的节点是父节点,才能判断其子节点是不是左叶子。所以如果当前遍历的节点是叶子节点,那么其左叶子也必定是0
所以终止条件为
1 | if(root == null) return 0; |
3、确定单层递归的逻辑
当遇到左叶子节点的时候,记录数值,然后通过递归求取左子树左叶子之和,和右子树左叶子之和,相加鞭尸整个树的左叶子之和。
1 | int leftValue = sumOfLeftLeaves(root.left);//左 |
最终代码如下
1 | /** |
迭代法
本题迭代法使用前中后序都是可以的,只要把做叶子节点统计出来,就可以了,可以写一个前序遍历的迭代方法
1 | /** |
层序遍历也可以
1 | // 层序遍历迭代法 |
21、找树左下角的值
给定一个二叉树的 根节点 root
,请找出该二叉树的 最底层 最左边 节点的值。
假设二叉树中至少有一个节点。
示例 1:
1 | 输入: root = [2,1,3] |
示例 2:
1 | 输入: [1,2,3,4,null,5,6,null,null,7] |
提示:
- 二叉树的节点个数的范围是
[1,104]
-231 <= Node.val <= 231 - 1
思路
这里用层序遍历是非常简单了,反而用递归还比较难一点
层序遍历(迭代法)
1 | import java.util.LinkedList; |
递归
一看这道题目用递归的话就一直向左遍历,最后一个就是答案?
并不是,一直遍历到最左到最后一个,但是并不是最后一行。
所以首先要到最后一行,然后是最左边的值。
如果是使用递归的方法,如何判断是最后一行?其实就是深度最大的叶子节点一定是最后一行。
所以要找到深度最大的叶子节点
因为求深度可以从上到下去查 所以需要前序遍历(中左右),而高度只能从下到上去查,所以只能后序遍历(左右中)
那么怎么找到最 左边?可以使用前序遍历(当然中序,后序,都可以,因为本题没有中间节点的处理逻辑,只要左优先就行),保证优先左边搜索,然后记录深度最大的叶子节点,此时就是树的最后一行最左边的值。
递归三部曲:
1、确定递归函数的参数和返回值
参数必须有要遍历的树的根节点,还有就是一个int类型的变量用来记录最长深度。这里就不需要返回值了,所以递归函数的返回类型为void。
本体还需要两个全局变量,maxDepth来记录最大深度,res记录最大深度最左节点的数值
1 | int maxDepth = -1; |
2、确定终止条件
当遇到叶子节点的时候,就需要统计一下最大的深度了,所以幻遇到叶子节点来更新最大深度
1 | if(root.left == null && root.right == null){ |
3、确定单层递归的逻辑
在找最大深度的时候,递归的过程中依然要使用回溯
1 | //中 |
完整代码
1 | // 递归法 |
22、路径总和
给你二叉树的根节点 root
和一个表示目标和的整数 targetSum
。判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和 targetSum
。如果存在,返回 true
;否则,返回 false
。
叶子节点 是指没有子节点的节点。
示例 1:
1 | 输入:root = [5,4,8,11,null,13,4,7,2,null,null,null,1], targetSum = 22 |
示例 2:
1 | 输入:root = [1,2,3], targetSum = 5 |
示例 3:
1 | 输入:root = [], targetSum = 0 |
提示:
- 树中节点的数目在范围
[0, 5000]
内 -1000 <= Node.val <= 1000
-1000 <= targetSum <= 1000
思路
递归
可以使用深度优先比那里的方式(本题前中后序都可以)
三部曲
1、确定递归函数的参数和返回类型
参数:需要二叉树的根节点,还需要一个计数器,这个计数器用来计算二叉树的一条边之和是否正好是目标和,计数器为int型。
再看看返回值,递归函数什么时候需要返回值?什么时候不需要返回值?
- 如果需要搜索整个二叉树且不用处理递归返回值,递归函数就不要返回值。(路径总和2)
- 如果需要搜索整颗二叉树且需要处理递归返回值,递归函数就需要返回值、
- 如果要搜索其中一条符合条件的路径,那么递归一定需要返回值,因为遇到符合条件的路径了就要及时返回。(路径总和)
而本题我们要找一条符合条件的路径,所以递归函数需要返回值,及时返回,那么返回类型是什么?
如图所示:
图中可以看出,遍历的路线,并不要遍历整棵树,所以递归函数需要返回值,可以用bool类型表示。
1 | boolean traversal(TreeNode cur, int count)//注意函数的返回类型 |
2、确定终止条件
首先计数器如何统计这一条路径的和呢?
不要去累加然后判断是否等于目标和,那么代码比较麻烦,可以用递减,让计数器count初始为目标和,然后每次减去遍历路径节点上的数值。
如果最后count==0,同时到了叶子节点的话,说明找到了目标和。
如果遍历到了叶子节点,count不为0,就是没有找到。
递归终止条件代码如下
1 | if(cur.left == null && cur.right == null && count == 0) return true; |
3、确定单层递归的逻辑
因为终止条件是判断叶子节点,所以递归的过程中久不要让空节点进入递归了。
递归函数是有返回值的,如果递归函数返回true,说明找到了合适的路径,应该立刻返回。
1 | if(cur.left != null){ |
以上代码中是包含着回溯的,没有回溯,如何后撤重新找到另一条路径呢
回溯隐藏在traversal(cur.left,count - cur.left.val),因为count-cur.left.val直接作为参数传进去,函数结束,count的数值没有改变。
为了把回溯体现出来,可以改为
1 | if(cur.left != null){ |
整体代码如下
1 | /** |
迭代
如果使用栈模拟递归,如何回溯?
此时栈里面一个元素不仅要记录节点指针,还要记录从头节点到该节点的路径数值总和。
我们可以用两个栈,一个栈控制访问节点,一个栈控制sum
1 | /** |
23、路径总和2
给你二叉树的根节点 root
和一个整数目标和 targetSum
,找出所有 从根节点到叶子节点 路径总和等于给定目标和的路径。
叶子节点 是指没有子节点的节点。
示例 1:
1 | 输入:root = [5,4,8,11,null,13,4,7,2,null,null,5,1], targetSum = 22 |
示例 2:
1 | 输入:root = [1,2,3], targetSum = 5 |
示例 3:
1 | 输入:root = [1,2], targetSum = 0 |
提示:
- 树中节点总数在范围
[0, 5000]
内 -1000 <= Node.val <= 1000
-1000 <= targetSum <= 1000
思路
要遍历整个树,找到所有路径,所以递归函数不要返回值!
如图:
递归三部曲
1、确定递归函数的返回值和参数
返回值是void,参数需要1个根节点、一个目标和,一个存储结果的数组,一个存储路径的path数组 这里用前序方便点,当然也可以使用统一的中序和后序
1 | public void preOrderDfs(TreeNode root, int targetSum,List<List<Integer>> res,List<Integer> path); |
2、确定终止条件
1 | if(root.left == null && root.right == null){ |
3、确定单层递归逻辑
1 | if(root.left != null){ |
整体代码如下
1 | /** |
24、从中序与后序遍历序列构造二叉树
106. 从中序与后序遍历序列构造二叉树 - 力扣(LeetCode)
给定两个整数数组 inorder
和 postorder
,其中 inorder
是二叉树的中序遍历, postorder
是同一棵树的后序遍历,请你构造并返回这颗 二叉树 。
示例 1:
1 | 输入:inorder = [9,3,15,20,7], postorder = [9,15,7,20,3] |
示例 2:
1 | 输入:inorder = [-1], postorder = [-1] |
提示:
1 <= inorder.length <= 3000
postorder.length == inorder.length
-3000 <= inorder[i], postorder[i] <= 3000
inorder
和postorder
都由 不同 的值组成postorder
中每一个值都在inorder
中inorder
保证是树的中序遍历postorder
保证是树的后序遍历
思路
首先要回忆一下怎么根据两个顺序构造一个唯一的二叉树。
先自己画一画找找感觉
以后序数组的最后一个元素为切割点,先切中序数组,根据中序数组,反过来再切后序数组。一层一层切下去,每次后序数组最后一个元素就是节点元素。
如果让我们肉眼看两个序列,画一棵二叉树的话,应该分分钟都可以画出来。
流程如图:
那么代码应该怎么写呢?
说到一层一层切割,就应该想到了递归。
来看一下一共分几步:
- 第一步:如果数组大小为零的话,说明是空节点了。
- 第二步:如果不为空,那么取后序数组最后一个元素作为节点元素。
- 第三步:找到后序数组最后一个元素在中序数组的位置,作为切割点
- 第四步:切割中序数组,切成中序左数组和中序右数组 (顺序别搞反了,一定是先切中序数组)
- 第五步:切割后序数组,切成后序左数组和后序右数组
- 第六步:递归处理左区间和右区间
不难写出下面代码(先把框架写出来)
1 | TreeNode traversal(List<Integer> inorder,List<Integer> postorder){ |
难点在于,如何切割,以及边界值找不好很容易乱套
此时应该注意确定切割的标准,是左闭右开,还是左开右闭,还是左闭右闭,这个就是不变量,要在递归中保持这个不变量。
在切割的过程中会产生四个区间,把握不好不变量的话,一会左闭右开,一会左闭右闭,必然乱套!
首先要切割中序数组,为什么先切割中序数组呢?
切割点在后续数组的最后一个元素,就是这个元素来切割中序数组的,所以必要先切割中序师数组。
中序数组相对比较好切,找到切割点(后序数组的最后一个元素)在中序数组的位置,然后切割,下面的代码我坚持左闭右开的原则
1 | //找到中序遍历的切割点 |
接下来要切割后序数组了。
首先后序数组的最后一个元素制定不能要了,这是切割点 也是 当前二叉树中间节点的元素,已经用了。
后序数组的切割点怎么找?
后序数组没有明确的元素来进行左右切割,不想中序数组有明确的切割点,切割点左右分开就可以了。
此时有一个很重要的点,就是中序数组大小一定是和后续数组的大小相同的
中序数组我们都切成了左中序数组和右中序数组了,那么后续数组就可以按照左中序数组的大小来切割,切成左后序数组和右后序数组。
1 | //postorder舍弃末尾元素,因为这个元素是中间节点,已经用过了 |
此时已经切好了,可以递归了
1 | root.left = traversal(leftInorder,leftPostorder); |
整体代码如下
1 | class Solution { |
25、从前序与中序遍历序列构造二叉树
105. 从前序与中序遍历序列构造二叉树 - 力扣(LeetCode)
根据一棵树的前序遍历与中序遍历构造二叉树。
注意: 你可以假设树中没有重复的元素。
例如,给出
前序遍历 preorder = [3,9,20,15,7] 中序遍历 inorder = [9,3,15,20,7] 返回如下的二叉树:
思路
首先先想一下怎么样做,画画图回忆一下,首先找到前序遍历的第一个元素,然后在中序遍历找到对应的元素,然后切割中序遍历数组,这样就变成了左中序数组,右中序数组,那么这时候记住
前序遍历数组大小始终会和中序遍历的大小一致,那么时候就利用左中序数组和右中序数组来分隔前序数组变成左前序数组和右前序数组,然后一层一层的递归
这时候递归三部曲
1、确定函数的参数和返回值
返回值返回根节点,前序数组,前序数组开始点,前序数组结束点,中序数组,中序数组开始点,中序数组结束点。
1 | public TreeNode buildHelper(List<Integer> preorder,int preStart,int preEnd,List<Integer> inorder,int inStart,int inEnd); |
2、确定结束条件
当数组为0也就是遍历结束了
1 | if(preStart == preEnd) return null; |
3、确定单层遍历条件
- 找到前序数组的第一个数也就是preStart当做根节点
- 找到之后遍历中序数组,然后找到然后更新左中序数组的开始和结束,右中序数组的开始和结束
- 然后再更新前序的做前序的开始和结束,右前序数组的开始和结束就分隔好前序数组了
- 最后递归
整体代码如下
1 | /** |
26、最大二叉树
给定一个不重复的整数数组 nums
。 最大二叉树 可以用下面的算法从 nums
递归地构建:
- 创建一个根节点,其值为
nums
中的最大值。 - 递归地在最大值 左边 的 子数组前缀上 构建左子树。
- 递归地在最大值 右边 的 子数组后缀上 构建右子树。
返回 nums
构建的 最大二叉树\ 。
示例 1:
1 | 输入:nums = [3,2,1,6,0,5] |
示例 2:
1 | 输入:nums = [3,2,1] |
提示:
1 <= nums.length <= 1000
0 <= nums[i] <= 1000
nums
中的所有整数 互不相同
思路
其实这个思路跟前面两题的思路很像,只不过是具体的逻辑细微不同
接下来想递归的三部曲
1、递归的参数和返回值
参数:
返回值肯定是根节点
1 | public TreeNode (int[] nums,int numsStart,int numsEnd); |
2、终止条件
因为我们是要分隔数组的所以到最后肯定有一左一右,肯定有数组长度为1,当只剩最后一个的时候,把节点值一赋值,就结束了
1 | if(numsStart == numsEnd){ |
3、确定单层递归的逻辑
- 先找到数组中的最大值和他的下标,用最大的下标当做根节点,,下标用来分隔数组
1 | int max = 0; |
- 分隔数组
1 | int leftStart = numsStart; |
整体代码如下
1 | /** |
27、合并二叉树
给你两棵二叉树: root1
和 root2
。
想象一下,当你将其中一棵覆盖到另一棵之上时,两棵树上的一些节点将会重叠(而另一些不会)。你需要将这两棵树合并成一棵新二叉树。合并的规则是:如果两个节点重叠,那么将这两个节点的值相加作为合并后节点的新值;否则,不为 null 的节点将直接作为新二叉树的节点。
返回合并后的二叉树。
注意: 合并过程必须从两个树的根节点开始。
示例 1:
1 | 输入:root1 = [1,3,2,5], root2 = [2,1,3,null,4,null,7] |
示例 2:
1 | 输入:root1 = [1], root2 = [1,2] |
提示:
- 两棵树中的节点数目在范围
[0, 2000]
内 -104 <= Node.val <= 104
思路
其实和遍历一个树的逻辑是一样的,只不过传入两个树的节点,同时操作
递归
二叉树使用递归,哪种遍历都是可以的
那么我们用前序递归方便点
递归三部曲
1、确定递归函数的参数和返回值
首先要合入两个二叉树,那么参数至少要传入二叉树的根节点,返回值就是合并之后二叉树的根节点
1 | TreeNode mergerTree(TreeNode t1, TreeNode t2); |
2、确定终止条件
因为是传入了两个树,那么就有两个树遍历的节点t1和t2,如果t1==null了,两个树合并就是t2了(如果t2是null也无所谓,合并之后就是null)
反过来如果t2==null,那么两个数合并就是t1
1 | if(t1 == null) return t2; |
3、确定单层递归的逻辑
单层递归的逻辑就比较好写了,这里我们重复利用一下这个t1这个树,t1就是合并之后树的根节点
那么单层递归中,就是把两个树的元素加到一起
1 | t1.val += t2.val; |
接下来t1的左子树是:合并t1左子树t2左子树之后的左子树。
t1的右子树:是 合并t1右子树t2右子树之后的右子树
最终t1就是合并之后的根节点
1 | t1.left = mergeTree(t1.left,t2.left); |
此时前序遍历,完整代码就出来了
1 | class Solution { |
迭代法
1 | class Solution { |
迭代法层序遍历
1 | class Solution { |
28、二叉搜索树中的搜索
给定二叉搜索树(BST)的根节点 root
和一个整数值 val
。
你需要在 BST 中找到节点值等于 val
的节点。 返回以该节点为根的子树。 如果节点不存在,则返回 null
。
示例 1:
1 | 输入:root = [4,2,7,1,3], val = 2 |
示例 2:
1 | 输入:root = [4,2,7,1,3], val = 5 |
提示:
- 树中节点数在
[1, 5000]
范围内 1 <= Node.val <= 107
root
是二叉搜索树1 <= val <= 107
思路
就遍历二叉搜索树,用值来比较如果比节点大在右边,否则在左边,如果相等返回节点
1 | /** |
或者用迭代,这里不用栈了,因为可以一路找下去,利用二叉搜索树的性质
1 | class Solution { |
29、二叉搜索树的最小绝对差
530. 二叉搜索树的最小绝对差 - 力扣(LeetCode)
给你一个二叉搜索树的根节点 root
,返回 树中任意两不同节点值之间的最小差值 。
差值是一个正数,其数值等于两值之差的绝对值。
示例 1:
1 | 输入:root = [4,2,6,1,3] |
示例 2:
1 | 输入:root = [1,0,48,null,null,12,49] |
提示:
- 树中节点的数目范围是
[2, 104]
0 <= Node.val <= 105
思路
递归法
二叉搜索树采用中序遍历,其实就是一个有序数组。
在一个有序数组上求两个树最小差值,这不就是一道送分题了
最直观的就是把二叉搜索树转换成有序数组,然后遍历一遍数组,就统计出来最小值了。
1 |
|
但是其实在二叉搜索树中序遍历的过程中,我们就可以直接计算了
需要用一个pre节点记录一下cur节点的前一个节点。
如图:
1 | class Solution { |
迭代法
统一迭代法来中序遍历
1 | class Solution { |
30、二叉搜索树中的众数
给你一个含重复值的二叉搜索树(BST)的根节点 root
,找出并返回 BST 中的所有 众数(即,出现频率最高的元素)。
如果树中有不止一个众数,可以按 任意顺序 返回。
假定 BST 满足如下定义:
- 结点左子树中所含节点的值 小于等于 当前节点的值
- 结点右子树中所含节点的值 大于等于 当前节点的值
- 左子树和右子树都是二叉搜索树
示例 1:
1 | 输入:root = [1,null,2,2] |
示例 2:
1 | 输入:root = [0] |
提示:
- 树中节点的数目在范围
[1, 104]
内 -105 <= Node.val <= 105
思路
这道题目呢,递归法我从两个维度来讲。
首先如果不是二叉搜索树的话,应该怎么解题,是二叉搜索树,又应该如何解题,两种方式做一个比较,可以加深大家对二叉树的理解。
递归法暴力法
如果不是二叉搜索树,最直观的方法就是把这个树都便利了,用map统计频率然后把频率排序,最后取最高频率的集合
1 | /** |
是二叉搜索树,那么他的中序遍历就是有序的,那么这时候把他变成有序数组,然后遍历有序数组,取出频率最高的。
遍历一次数组然后把他们放到map里面
1 | import java.util.*; |
天才递归法
那么怎么在树上直接操作呢?
在搜索树的最小绝对差的时候我们用了pre指针和cur指针的技巧,这里也能用,弄一个指针指向前一个节点,这样每次cur才能和pre作比较,而且初始化的时候pre==null,这样当pre为null的时候,我们就知道这是比较的第一个元素。
1 | if(pre == null){//第一个节点 |
此时又有问题了,因为要求最大频率的元素集合 注意是集合,不是一个元素,可以有多个众数,如果是数组上的话。
应该是遍历一遍数组,找到最大频率,然后再重新遍历一遍数组把出现频率为maxCount的元素放进集合。(因为众数有多个)
这种方式就遍历了两遍数组
那么我们遍历两遍二叉树,把众数集合算出来也是可以的。
但这里其实只需要遍历一次就可以找到所有的众数。
那么如何只遍历一遍呢?
如果 频率count等于maxCount(最大频率),当然要把这个元素加入到结果集中
1 | if(count == maxCount){ |
此时又有问题了,res怎么能轻易就把元素放进去了呢?万一,这个maxCount此时还不是真正最大频率呢?
所以下面要做如下操作
频率count大于maxCount的时候,不仅要更新maxCount,而且要清空结果集,因为结果集之前的元素都失效了
下面是天才的代码
1 | class Solution { |
31、二叉树的最近公共祖先
236. 二叉树的最近公共祖先 - 力扣(LeetCode)
给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。
百度百科中最近公共祖先的定义为:“对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”
示例 1:
1 | 输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1 |
示例 2:
1 | 输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4 |
示例 3:
1 | 输入:root = [1,2], p = 1, q = 2 |
提示:
- 树中节点数目在范围
[2, 105]
内。 -109 <= Node.val <= 109
- 所有
Node.val
互不相同
。 p != q
p
和q
均存在于给定的二叉树中。
思路
遇到这个题目首先想的事要是能自底向上查找就好了,这样就可以找到公共祖先了,那么二叉树如何可以自底向上查找呢?
回溯,二叉树回溯的过程就是自底向上。
后序遍历(左右中)就是天然的回溯过程,可以根据左右子树的返回值,来处理中节点的逻辑。因为从上往下看能看到自己多深,所以是前序遍历,从下往上看才能看到自己多高,所以是后序遍历,所以自底向上是后序遍历
接下来就看如何判断一个节点是节点q和节点p的公共祖先呢?
最容易想到的一个情况:如果找到一个节点,发现左子树出现节点p,右子树出现节点q,或者左子树出现节点q,右子树出现p,那么该节点就是节点p和q的最近公共祖先。
这里是出现哦!!!有刚好在左叶子节点,有的在左子树,都符合的,由于是自底向上的,所以可以保证是最近的公共祖先。
这个是情况一:
判断逻辑是如果递归遍历遇到q,就将q返回,遇到p就将p返回,那么如果左右子树的返回值都不为空,说明此时的中节点,一定是q和p的最近祖先。
但是很多人忽略的一个情况就是节点本身p(q),他自己拥有一个子孙节点情况二:
其实情况一和情况二代码实现过程都是一样的,也可以说,实现情况一的逻辑,顺便包含了情况二。
因为遇到q或者p返回,这样也包含了q或者p本身就是公共祖先的情况。
递归三部曲
1、确定递归函数返回值以及参数
需要递归函数返回值,来告诉我们是否找到节点q或者p,那么返回值为boolean类型就好了。
但我们还是要返回最近公共节点,可以利用上题目中的返回值是TreeNode,那么如果遇到了p或者q,就吧把p和q返回,返回值不为空,就说明找到了p或者q。
1 | TreeNode lowestCommonAncestor(TreeNode root,TreeNode p ,TreeNode q) |
2、确定终止条件
遇到空的话,返回空
那么如果root == p 或者root == q,说明找到p q,就将其返回,这个返回值,后米娜在中节点的处理过程中会用到
1 | if(root == q || root == p || root == null) return root |
3、确定单层递归逻辑
值得注意的是本体函数有返回值,是因为回溯的过程需要递归函数的返回值做判断,但本体我们依然要遍历树的所有节点。
小结一下:递归函数没有返回值肯定是遍历整棵树,有返回值可能是找一条边找到了就立刻返回,也有可能是遍历整棵树,找到了然后还需要后续处理。
所以如果递归函数有返回值,如何区分要搜索一条边还是搜索整个树?
搜索一条边的写法
1 | if(递归函数(root.left)) return; |
搜索整个树的写法
1 | left = 递归函数(root.left); |
在递归函数有返回值的情况下,
如果要搜索一条边,递归函数返回值不为空的时候,立即返回。
如果搜索整个树,直接用一个变量left、right接住这个返回值,这个left、right后续还要逻辑处理的需要,也就是后续遍历中处理中间节点的逻辑(也就是回溯)
那么为什么要遍历整棵树呢?直观上来看,找到最近公共祖先,直接一路返回就可以了。
如图:
就像图中一样直接返回7。
但事实上还要遍历根节点右子树(即使此时已经找到了目标节点了),也就是图中的节点4、15、20。
因为在如下代码的后序遍历中,如果想利用left和right做逻辑处理, 不能立刻返回,而是要等left与right逻辑处理完之后才能返回。
所以我们的代码如下
1 | TreeNode left = lowestCommonAncestor(root.left,p,q); |
如果left和right都不为空,说明此时root就是最近公共节点。这个比较好理解
如果left为空,right不为空,就返回right,说明目标节点在右子树通过right返回,反之依然
这里有的同学就理解不了了,为什么left为空,right不为空,目标节点通过right返回呢?
如图:
图中节点10的左子树返回null,右子树返回目标值7,那么此时节点10的处理逻辑就是把右子树的返回值(最近公共祖先7)返回上去!
这里也很重要,可能刷过这道题目的同学,都不清楚结果究竟是如何从底层一层一层传到头结点的。
那么如果left和right都为空,则返回left或者right都是可以的,也就是返回空。
代码如下:
1 | if(left == null && right != null) return right; |
那么寻找最小公共祖先,完整流程图如下:
从图中,大家可以看到,我们是如何回溯遍历整棵二叉树,将结果返回给头结点的!
整体代码如下:
1 | class Solution { |
32、二叉搜索树的最近公共祖先
235. 二叉搜索树的最近公共祖先 - 力扣(LeetCode)
给定一个二叉搜索树, 找到该树中两个指定节点的最近公共祖先。
百度百科中最近公共祖先的定义为:“对于有根树 T 的两个结点 p、q,最近公共祖先表示为一个结点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”
例如,给定如下二叉搜索树: root = [6,2,8,0,4,7,9,null,null,3,5]
示例 1:
1 | 输入: root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 8 |
示例 2:
1 | 输入: root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 4 |
思路
那么本题是二叉搜索树,二叉搜索树是有序的,那得好好利用一下这个特点。
在有序树里,如果判断一个节点的左子树里有p,右子树里有q呢?
因为是有序树,所以 如果 中间节点是 q 和 p 的公共祖先,那么 中节点的数组 一定是在 [p, q]区间的。即 中节点 > p && 中节点 < q 或者 中节点 > q && 中节点 < p。
那么只要从上到下去遍历,遇到 cur节点是数值在[p, q]区间中则一定可以说明该节点cur就是p 和 q的公共祖先。 那问题来了,一定是最近公共祖先吗?
如图,我们从根节点搜索,第一次遇到 cur节点是数值在[q, p]区间中,即 节点5,此时可以说明 q 和 p 一定分别存在于 节点 5的左子树,和右子树中。
此时节点5是不是最近公共祖先? 如果 从节点5继续向左遍历,那么将错过成为p的祖先, 如果从节点5继续向右遍历则错过成为q的祖先。
所以当我们从上向下去递归遍历,第一次遇到 cur节点是数值在[q, p]区间中,那么cur就是 q和p的最近公共祖先。
理解这一点,本题就很好解了。
而递归遍历顺序,本题就不涉及到 前中后序了(这里没有中节点的处理逻辑,遍历顺序无所谓了)。
如图所示:p为节点6,q为节点9
可以看出直接按照指定的方向,就可以找到节点8,为最近公共祖先,而且不需要遍历整棵树,找到结果直接返回!
所以在写递归的时候再思考一下,这是找到结果直接返回,那么这个肯定是需要返回值的。
递归三部曲
1、确定参数和返回值
1 | public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) |
2、确定终止条件
1 | if(root == null) return null |
3、确定单层逻辑
1 | if(root.val > p.val && root.val < q.val || root.val > q.val && root.val < p.val){ |
最终代码如下
1 | class Solution { |
迭代法如下
1 | class Solution { |
33、二叉搜索树中的插入操作
701. 二叉搜索树中的插入操作 - 力扣(LeetCode)
给定二叉搜索树(BST)的根节点 root
和要插入树中的值 value
,将值插入二叉搜索树。 返回插入后二叉搜索树的根节点。 输入数据 保证 ,新值和原始二叉搜索树中的任意节点值都不同。
注意,可能存在多种有效的插入方式,只要树在插入后仍保持为二叉搜索树即可。 你可以返回 任意有效的结果 。
示例 1:
1 | 输入:root = [4,2,7,1,3], val = 5 |
示例 2:
1 | 输入:root = [40,20,60,10,30,50,70], val = 25 |
示例 3:
1 | 输入:root = [4,2,7,1,3,null,null,null,null,null,null], val = 5 |
提示:
- 树中的节点数将在
[0, 104]
的范围内。 -108 <= Node.val <= 108
- 所有值
Node.val
是 独一无二 的。 -108 <= val <= 108
- 保证
val
在原始BST中不存在。
这道题目其实是一道简单题目,但是题目中的提示:有多种有效的插入方式,还可以重构二叉搜索树,一下子吓退了不少人,瞬间感觉题目复杂了很多。
其实可以不考虑题目中提示所说的改变树的结构的插入方式。
如下演示视频中可以看出:只要按照二叉搜索树的规则去遍历,遇到空节点就插入节点就可以了。
例如插入元素10 ,需要找到末尾节点插入便可,一样的道理来插入元素15,插入元素0,插入元素6,需要调整二叉树的结构么? 并不需要。
只要遍历二叉搜索树,找到空节点 插入元素就可以了,那么这道题其实就简单了。
接下来就是遍历二叉搜索树的过程了。
递归三部曲
1、确定参数和返回值
利用题目的函数即可
1 | public TreeNode insertIntoBST(TreeNode root, int val) |
2、确定结束条件
1 | if(root == null){ |
3、确定单层递归的逻辑
此时要明确,需要遍历整棵树么?
别忘了这是搜索树,遍历整棵搜索树简直是对搜索树的侮辱。
搜索树是有方向了,可以根据插入元素的数值,决定递归方向。
1 | /** |
到这里,大家应该能感受到,如何通过递归函数返回值完成了新加入节点的父子关系赋值操作了,下一层将加入节点返回,本层用root->left或者root->right将其接住。
迭代法
1 | class Solution { |
34、删除二叉搜索树中的节点
450. 删除二叉搜索树中的节点 - 力扣(LeetCode)
给定一个二叉搜索树的根节点 root 和一个值 key,删除二叉搜索树中的 key 对应的节点,并保证二叉搜索树的性质不变。返回二叉搜索树(有可能被更新)的根节点的引用。
一般来说,删除节点可分为两个步骤:
- 首先找到需要删除的节点;
- 如果找到了,删除它。
示例 1:
1 | 输入:root = [5,3,6,2,4,null,7], key = 3 |
示例 2:
1 | 输入: root = [5,3,6,2,4,null,7], key = 0 |
示例 3:
1 | 输入: root = [], key = 0 |
提示:
- 节点数的范围
[0, 104]
. -105 <= Node.val <= 105
- 节点值唯一
root
是合法的二叉搜索树-105 <= key <= 105
进阶: 要求算法时间复杂度为 O(h),h 为树的高度。
思路
搜索树的节点删除要比节点增加复杂的多,有很多情况需要考虑,做好心理准备。
递归
递归三部曲
1、确定递归函数参数及其返回值
说到递归函数的返回值,在插入操作中通过递归返回值来加入新节点,这里也可以通过递归返回值删除节点。
1 | TreeNode deleteNode(TreeNode root,int key); |
2、确定终止条件
遇到空返回,其实这也说明没找到删除的节点,遍历到空节点直接返回了
1 | if(root == null) return root; |
3、确定单层递归的逻辑
这里就是把二叉搜索树中删除节点遇到的情况都搞清楚。
有以下五种情况:
- 第一种:没有找到删除的节点,遍历到空节点直接返回了。
- 找到了删除的节点如何操作
- 第二种情况:左右孩子都为空(叶子节点),直接删除节点,返回null为根节点。
- 第三种情况:删除节点的左孩子为空,右孩子不为空,删除节点,右孩子补位,返回右孩子为根节点。
- 第四种情况:删除节点的右孩子为空,左孩子不为空,删除节点,左孩子补位,返回左孩子为根节点
- 第五种情况:左右孩子节点都不为空,则将删除节点的左子树头结点放到删除节点的右子树的最左面节点的左孩子上,返回删除节点右孩子为新的根节点。
1 | if(root.val == key){ |
这样就做完了
整体代码如下
1 | /** |
普通二叉树的删除方式
这里没有使用搜索树的特性,遍历整棵树,用交换值的操作来删除目标节点。
代码中目标节点(要删除的节点)被操作了两次。
- 第一次是和目标节点的右子树最左面节点交换
- 第二次直接被null覆盖了
1 | class Solution{ |
递归法
删除节点的迭代法还是复杂一些的,但其本质我在递归法里都介绍了,最关键就是删除节点的操作(动画模拟的过程)
1 | class Solution { |
35、修剪二叉树
给你二叉搜索树的根节点 root
,同时给定最小边界low
和最大边界 high
。通过修剪二叉搜索树,使得所有节点的值在[low, high]
中。修剪树 不应该 改变保留在树中的元素的相对结构 (即,如果没有被移除,原有的父代子代关系都应当保留)。 可以证明,存在 唯一的答案 。
所以结果应当返回修剪好的二叉搜索树的新的根节点。注意,根节点可能会根据给定的边界发生改变。
示例 1:
1 | 输入:root = [1,0,2], low = 1, high = 2 |
示例 2:
1 | 输入:root = [3,0,4,null,2,null,null,1], low = 1, high = 3 |
提示:
- 树中节点数在范围
[1, 104]
内 0 <= Node.val <= 104
- 树中每个节点的值都是 唯一 的
- 题目数据保证输入是一棵有效的二叉搜索树
0 <= low <= high <= 104
思路
这道题不简单!!!
递归法
直接想法就是:递归处理,然后遇到
1 | root.val < low || root.val > hight |
的时候直接return null,一波修改,干净利落。
但是这样就会有一个严重的问题,即使当前节点不在范围内,它的子树仍然可能有合法节点!而你直接返回null,相当于 整棵子树都被丢弃了 这是错误的。
所以上面这个想法是不可行的
但是也不需要重构那么复杂。
在例子中我们发现节点0并不符合区间要求,那么将节点0和右孩子 节点2直接赋给节点3的左孩子就可以了(就是把节点0从二叉树中移出)如图:
理解了最关键部分,我们再递归三部曲
1、确定递归函数的参数和返回值
这里我们为什么需要返回值呢?
因为要遍历整棵树,做修改,那么遍历整棵树有返回值,没有返回值都可以。
但是有返回值,更方便,可以通过递归函数的返回值来移除节点。
1 | TreeNode trimBST(TreeNode root,int lov,int high) |
2、确定终止条件
修剪的操作并不是在终止条件进行的,所以就是遇到空节点返回就可以了。
1 | if(root == null) return null; |
3、确定单层递归的逻辑
如果root(当前节点)的元素小于low的数值,那么应该递归右子树,并返回右子树符合条件的头节点。
1 | if(root.val < low){ |
如果root(当前节点)的元素大于high的,那么应该递归左子树,并返回左子树符合条件的头结点。
1 | if(root.val > high){ |
接下来要将下一层处理完左子树的结果赋给root.left,处理完右子树的结果赋给root.right。最后返回root节点
1 | root.left = trimBST(root.left,low,high);//root.left接入符合条件的左孩子 |
此时大家是不是还没发现这多余的节点究竟是如何从二叉树中移除的呢?
在回顾一下上面的代码,针对下图中二叉树的情况:
如下代码相当于把节点0的右孩子(节点2)返回给上一层
1 | if(root.val < low){ |
然后如下代码相当于用节点3的左孩子 把下一层返回的 节点0的右孩子(节点2) 接住。
1 | root.left = trimBST(root.left,low,high); |
此时节点3的左孩子就变成了节点2,将节点0从二叉树中移除了。
只看代码,其实不太好理解节点是如何移除的,这一块大家可以自己再模拟模拟!
递归法
1 | class Solution { |
迭代法
因为二叉搜索树的有序性,不需要使用栈模拟递归的过程
在剪枝的时候,可以分为三步:
- 将root移动到[L, R] 范围内,注意是左闭右闭区间
- 剪枝左子树
- 剪枝右子树
1 | class Solution { |
36、将有序数组转换为二叉搜索树
108. 将有序数组转换为二叉搜索树 - 力扣(LeetCode)
给你一个整数数组 nums
,其中元素已经按 升序 排列,请你将其转换为一棵 平衡 二叉搜索树。
示例 1:
1 | 输入:nums = [-10,-3,0,5,9] |
示例 2:
1 | 输入:nums = [1,3] |
提示:
1 <= nums.length <= 104
-104 <= nums[i] <= 104
nums
按 严格递增 顺序排列
有序数组转 BST,不是“从中间往两边串链”,而是“每次取中点,递归建左右子树”。
题目中说要转换为一棵高度平衡二叉搜索树。为什么强调要平衡呢?
因为只要给我们一个有序数组,如果不强调平衡,都可以以线性结构来构造二叉搜索树。
例如 有序数组[-10,-3,0,5,9] 就可以构造成这样的二叉搜索树,如图。
上图中,是符合二叉搜索树的特性吧,如果要这么做的话,是不是本题意义就不大了,所以才强调是平衡二叉搜索树。
其实数组构造二叉树,构成平衡树是自然而然的事情,因为大家默认都是从数组中间位置取值作为节点元素,一般不会随机取。所以想构成不平衡的二叉树是自找麻烦。
在之前构造二叉树中讲过,如果根据数组构造一棵二叉树本质就是寻找分割点,分割点作为当前节点然后递归做区间和右区间。
由于是二叉搜索树,所以分割点就是数组中间位置的节点。
那么问题来了,如果数组长度为偶数,中间节点有两个,取哪一个?
取哪一个都可以,只不过构成了不同的平衡二叉树。
递归三部曲
1、确定递归函数的返回值以及参数
删除二叉树节点,增加二叉树节点,都是用递归函数的返回值来完成,这样是比较方便的。
再来看参数,首先是传入数组,然后就是左下标left和右下标right,我们在构造二叉树的时候尽量不要重新定义左右区间数组,而是用下标来操作原数组。
1 | TreeNode fun(int[] nums,int left,int right) |
注意这里我们定义的是左闭右闭区间,在不断的分隔过程中,我们也要坚持左闭右闭区间,这涉及到我们讲过的循环不变量
2、确定递归终止条件
这里定义左闭右闭区间,所以当区间left > right的时候,就是空节点了。
1 | if(left > right) return null; |
3、确定单层递归逻辑
首先取中间元素的位置,不难写出
1 | int mid = (left + right ) / 2 |
这样容易数组越界
所以我们
1 | int mid = left + ((right - left) / 2); |
这么写。
取了这个中间位置所以构造中间位置的元素构造节点
1 | TreeNoe rott = new TreeNode(nums[mid]); |
接着划分区间,root的左孩子接住下一层做区间的构造节点,右孩子接住下一层有区间构造的节点
1 | int mid = left + ((right - left) / 2); |
37、把二叉搜索树转换为累加树
538. 把二叉搜索树转换为累加树 - 力扣(LeetCode)
给出二叉 搜索 树的根节点,该树的节点值各不相同,请你将其转换为累加树(Greater Sum Tree),使每个节点 node
的新值等于原树中大于或等于 node.val
的值之和。
提醒一下,二叉搜索树满足下列约束条件:
- 节点的左子树仅包含键 小于 节点键的节点。
- 节点的右子树仅包含键 大于 节点键的节点。
- 左右子树也必须是二叉搜索树。
注意:本题和 1038: https://leetcode-cn.com/problems/binary-search-tree-to-greater-sum-tree/ 相同
示例 1:
1 | 输入:[4,1,6,0,2,5,7,null,null,null,3,null,null,null,8] |
示例 2:
1 | 输入:root = [0,null,1] |
示例 3:
1 | 输入:root = [1,0,2] |
示例 4:
1 | 输入:root = [3,2,4,1] |
提示:
- 树中的节点数介于
0
和104
之间。 - 每个节点的值介于
-104
和104
之间。 - 树中的所有值 互不相同 。
- 给定的树为二叉搜索树。
思路
一看到累加树,相信很多人都会疑惑,如何累加?遇到一个节点,然后再遍历其他节点累加?这么一想很麻烦
然后发现这是一颗二叉搜索树,想到了有序数组,求从后到前的累加数组,这样是不是觉得简单了
因为数组都知道怎么遍历,从后向前,挨个累加就完事了,这换成了二叉搜索树,看起来就别扭了一些是不是。
那么知道如何遍历这个二叉树,也就迎刃而解了,从树中可以看出累加的顺序是右中左,所以我们需要反中序遍历这个二叉树,然后顺序累加就可以了。
递归
遍历顺序右中左来一个反中序遍历。
本体依然需要一个pre指针记录当前遍历节点cur的前一个节点,这样才方便做累加!!!!!!!!!!!!!!
递归三部曲
1、递归函数参数以及返回值
不需要递归函数的返回值做什么操作了,要遍历整棵树
代码如下
1 | int pre = 0;//记录前一个节点的数值 |
2、确定终止条件
遇到空就停止
1 | if(cur == null){ |
3、确定单层递归的逻辑
注意要右中左来遍历二叉树,中间节点的处理逻辑就是让cur的数值加上前一个节点的数值
1 | fun(cur.right); |
整体代码如下
1 | /** |
第七章 回溯算法
1、理论基础
什么是回溯法
回溯法也可以叫回溯搜索法,这是一种搜索的方式
在二叉树系列中,我们不止一次的用到了回溯
回溯是递归的副产品,只要有递归就会有回溯。
所以下面的讲解中,回溯函数也就是递归函数,指的都是一个函数。
回溯法的效率
回溯法的性能如何呢,虽然回溯法很难,很不好理解,但是回溯法并不是什么高效的算法
因为回溯的本质就是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改变不了回溯法就是穷举的本质。
那么既然回溯法不高效为什么还要用呢?
因为没得选,一些问题能暴力搜索出来就不错了,撑死再剪枝一下,还没有更高效的解法。
那很么问题这么牛逼只能暴力搜索。
回溯法解决的问题
回溯法一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里面有多少种符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等.
相信大家看着会发现每个问题都不简单,还有人分不清什么是组合什么是排列
组合式不强调元素顺序的,排列是强调元素顺序。
例如:{1, 2} 和 {2, 1} 在组合上,就是一个集合,因为不强调顺序,而要是排列的话,{1, 2} 和 {2, 1} 就是两个集合了。
记住组合无序,排列有序,就可以了。
如何理解回溯法
回溯法解决的问题都可以抽象为树形结构,所有回溯法的问题都可以抽象为树形结构!!!!!
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度
递归就要有终止条件,所以必然是一棵高度有限的树(N叉数)
回溯法模板
之前递归中说了递归三部曲,现在讲回溯三部曲
1、回溯算法中函数的返回值一般为void
再看一下参数,因为回溯算法需要的参数不像二叉树递归的时候那么一次性确定下来,所以一般是先写逻辑,然后需要什么参数就填什么参数。
但是后面为了方便理解,就把参数确定了下来
1 | void backtracking(参数) |
2、回溯终止条件
既然是树形结构,那么在递归的时候,就知道遍历树形结构一定要有终止条件。
所以回溯也要有终止条件
什么时候达到终止条件,树种可以看出,一般来说搜到了叶子结点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
所以回溯函数的终止条件
1 | if(终止条件){ |
3、回溯搜索的遍历过程
在上面我们提到了,回溯法一般是在结合中递归搜索,集合的大小构成了树的宽度,递归的深度构成了树的深度。
如图:
注意图中,特意距离集合大小和孩子的数量是相等的!!!!!
所以回溯函数遍历过程伪代码如下
1 | for(选择:本层集合中元素(树种节点孩子的数量就是集合的大小)){ |
for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。
backtracking这里自己调用自己,实现递归。
大家可以从图中的for循环可以理解是横向遍历,backtracking(递归)是纵向遍历,这样就把这个树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
分析完过程,回溯法模板框架如下
1 | void backtracking(参数){ |
2、组合
给定两个整数 n
和 k
,返回范围 [1, n]
中所有可能的 k
个数的组合。
你可以按 任何顺序 返回答案。
示例 1:
1 | 输入:n = 4, k = 2 |
示例 2:
1 | 输入:n = 1, k = 1 |
提示:
1 <= n <= 20
1 <= k <= n
思路
回溯法的经典题目。
直接的解法当然是使用for循环,例如实例中k为2,很容易想到用两个for循环,这样就可以输出和案例中一样的结果,如果输入n=100,k=3那么就是三层for循环。
那么这时候发现虽然是想暴力搜索,但是用for循环嵌套连暴力都写不出来
回溯搜索法就来了,虽然回溯法也是暴力,但是至少能写出来。
那么回溯法怎么暴力搜索呢?
上面我们说了要解决 n为100,k为50的情况,暴力写法需要嵌套50层for循环,那么回溯法就用递归来解决嵌套层数的问题。
递归来做层叠嵌套(可以理解是开k层for循环),每一次的递归中嵌套一个for循环,那么递归就可以用于解决多层嵌套循环的问题了。
此时递归的层数知道了,例如N为100,k为50的情况下就是递归50层。
如果用脑洞模拟回溯搜索的过程,会非常难,所以需要抽象图形结构来进一步理解。
用树形结构来理解回溯就容易多了。
那么我把组合问题抽象为如下树形结构:
可以看出这棵树,一开始集合是1,2,3,4,从左向右取数,取过的数,不再重复取。
第一次取1,集合变为2,3,4,因为k为2,我们只需要再取一个数就可以了,分别取2,3,4,得到集合,以此类推。
每次从集合中选取元素,可选择的返回随着选择的进行而收缩,调整可选择的范围。
图中可以发现n相当于树的宽度,k相当于树的深度.
图中每次搜索到了叶子节点,我们就找到了一个结果。
相当于只需要把达到叶子节点的结果收集起来,就可以求得n个数中k个数的组合集合。
回溯三部曲
1、递归函数的返回值以及参数
在这里要定义两个全局变量,一个用来存放符合条件单一结果,一个用来存放符合条件结果的集合。
1 | List<List<Integer>> res;//用来存放符合条件结果的集合 |
其实不定义这两个全局变量也是可以的,把这两个变量放进递归函数的参数里,但函数里参数太多影响可读性,所以定义为全局变量了。
函数里一定有两个参数,既然是集合n里面取k个数,那么n和k是两个int类型的参数。
然后还需要一个参数,为int变量startIndex,这个参数用来记录本层递归中,集合从哪里开始遍历,集合就是1到N。
为什么要有这个startIndex呢?
startIndex的作用是防止出现重复的组合。
从下图中红线部分可以看出,在集合[1,2,3,4]取1之后,下一层递归,就要在[2,3,4]中取数了,那么下一层递归如何知道从[2,3,4]中取数呢,靠的就是startIndex。
所以需要startIndex来记录下一层递归,搜索的起始位置。
1 | List<List<Integer>> res; |
2、确定回溯函数的终止条件
什么时候达到所谓的叶子节点呢?
path这个数组的大小如果达到k,说明我们找到了一个子集大小为k的组合了,在图中path存的就是一根节点到叶子节点的路径。
如图红色部分:
此时用result二维数组,把path保存起来,并终止本层递归。
所以终止的代码如下
1 | if(path.size() == k){ |
3、单层搜索的过程
回溯法的搜索过程就是一个属性结果的遍历过程,在 下图中,可以看出for循环用来横向遍历,递归的过程就是纵向遍历
如此我们才遍历完图中的这棵树。
for循环每次从startIndex开始遍历,然后用path保存取到的节点i。
代码如下:
1 | for(int i = startIndex ; i <= n ; i++){ |
最终的整体代码如下
1 | class Solution{ |
剪枝优化
回溯法虽然是暴力搜索,但也有时候可以有点剪枝优化一下的。
在遍历过程中有如下代码
1 | for(int i = startIndex ; i <= n ; i++){ |
这个遍历的返回是可以剪枝优化的,怎么优化呢?
来举一个例子,n=4,k=4的话,那么第一层for循环的时候,从元素2开始的遍历都没有意义了,在第二层for循环,从元素3开始没有意义了,这么说有点抽象。
如图所示:
图中每一个节点(图中为矩形),就代表本层的一个for循环,那么每一层的for循环从第二个数开始遍历的话,都没有意义,都是无效遍历。
所以,可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。
如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
注意代码中for循环里面选择其实的位置
1 | for(int i = startIndex ; i <= n ; i++) |
这里可以优化一下
1、已经选择的元素个数path.size();
2、开需要的元素的个数为 k - path.size();
3、在集合n中至多要从该起始位置 n - (k - path.size()) +1的位置开始遍历,为什么有个+1呢,因为包括起始位置,我们需要是一个左闭的集合。
这里其实可以 自己举例子尝试边界。
那么优化之后的循环是
1 | for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置 |
那么整体优化后的代码如下
1 | class Solution { |
3、组合总和3
找出所有相加之和为 n
的 k
个数的组合,且满足下列条件:
- 只使用数字1到9
- 每个数字 最多使用一次
返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。
示例 1:
1 | 输入: k = 3, n = 7 |
示例 2:
1 | 输入: k = 3, n = 9 |
示例 3:
1 | 输入: k = 4, n = 1 |
思路
本题在1~9这个集合里面找到和为n的K个数的组合。
相对于组合,无非就是多了一个限制,本题就是找到和为n的k个数的组合,而这个组合已经固定了1~9,
相当于k深度,而9(因为整个集合就是9个数)就是树的宽度
例如 k = 2,n = 4的话,就是在集合[1,2,3,4,5,6,7,8,9]中求 k(个数) = 2, n(和) = 4的组合。
选取过程如图:
图中,可以看出,只有最后取到集合(1,3)和为4 符合条件。
回溯三部曲
1、确定递归函数参数
和组合一样
1 | List<List<Integer>> res; |
但是还需要如下参数
targetSum 目标和,也就是题目中的n
k就是k个数的集合
sum目前已经收集的元素的总和
startIndex为下一层for循环搜索的起始位置
```java
void backtracking(int targetSum,int k , int sum , int startIndex)1
2
3
4
5
6
7
8
9
10
11
12
13
14
2、确定终止条件
什么时候终止?
这里已经知道了树的深度了,因为就取k个元素
所以
```java
if(path.size() == k){
if(sum == targetSum) res.add(new ArrayList<>(path));
return;
}
3、单层搜索的过程
如图:
处理过程就是 path收集每次选取的元素,相当于树型结构里的边,sum来统计path里元素的总和
代码如下
1 | class Solution { |
剪枝也很容易想到,如果元素总和已经大于n,直接减掉。
1 | class Solution { |
4、电话号码的字母组合
给定一个仅包含数字 2-9
的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
示例 1:
1 | 输入:digits = "23" |
示例 2:
1 | 输入:digits = "" |
示例 3:
1 | 输入:digits = "2" |
思路
理解本题后要解决三个问题
1、数字和字母如何映射
2、输入1或者其他奇怪按键等等异常情况
数字和字母如何映射以及处理奇怪情况
用数组,下标代表数字
1 | //初始对应所有的数字,为了直接对应2-9,新增了两个无效的字符串"" |
那么初始化好了就开始回溯法解决组合问题了
回溯三部曲
1、递归函数的参数
需要一个存放答案的数组,以及由于需要大量字符串操作所以用StringBuilder
1 | List<String> res = new ArrayList<>(); |
参数的话,思考一下,按的数字对应的集合总长度决定了宽度,然后数字的长度决定了深度,比如按了23,那么深度为2
所以需要的参数有输入的digits,数字对应的字符numString,已经到哪个数字了的index
2、结束条件
结束条件就是深度达到了
1 | if(num == digits.length()){ |
3、确定单层递归逻辑
1 | //str 表示当前num对应的字符串 |
整体代码如下
1 | class Solution { |
5、组合总和
给你一个 无重复元素 的整数数组 candidates
和一个目标整数 target
,找出 candidates
中可以使数字和为目标数 target
的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates
中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target
的不同组合数少于 150
个。
示例 1:
1 | 输入:candidates = [2,3,6,7], target = 7 |
示例 2:
1 | 输入: candidates = [2,3,5], target = 8 |
示例 3:
1 | 输入: candidates = [2], target = 1 |
提示:
1 <= candidates.length <= 30
2 <= candidates[i] <= 40
candidates
的所有元素 互不相同1 <= target <= 40
思路
组合问题肯定是想到暴力,那么想到暴力就想到回溯法,那么开始分析怎么使用回溯法。
首先这个跟前面的不同,前面是找一个元素就长度变短,这里的元素是可以多次使用的,所以限制深度的只有target了。
回溯三部曲
1、确定参数
首先要有一个存放答案的List和存入答案的List
1 | List<List<Integer>> res = new ArrayList<>();//表示结果 |
那么要传入的参数有,一个是candidates数组,一个数目标target,还有一个sum,由于每次都可以从第一个开始就不用跟之前一样传开始位置了?并不,还是需要的,为什么呢?如果不用起始位置,会导致组合的重复什么意思比如要求target为7,那么组合里面可以取2,2,3,那么这时候2,3,2和3,2,2都是符合条件的,但是这三个都算是一个组合。
那么什么时候需要startIndex?
如果是一个集合求组合,就需要startIndex
如果是多个集合求组合,各个集合之间相互不影响就不用startIndex,比如电话号码。
注意上面只是求组合,如果是排列,又是另一个分析思路。
1 | void backtracking(int[] candidates,int target,int sum,int startIndex) |
2、结束条件
这里的结束条件是如果sum大于或者等于target就结束,如果等于target就存入ans里。
1 | if(sum >= target){ |
3、确定单层递归的函数
1 | for(int i = startIndex ; i < candidates.length ; i++){ |
整体代码如下
1 | class Solution { |
看完代码后仔细思考一下,startIndex是怎么控制不会重复的?
看完这个图就知道了:
剪枝
以及上面的版本一的代码大家可以看到,对于sum已经大于target的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。
其实如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。
那么可以在for循环的搜索范围上做做文章了。
对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。
如图:
for循环剪枝代码如下:
1 | for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) |
整体代码如下:
1 | // 剪枝优化 |
6、组合总和2
给定一个候选人编号的集合 candidates
和一个目标数 target
,找出 candidates
中所有可以使数字和为 target
的组合。
candidates
中的每个数字在每个组合中只能使用 一次 。
注意:解集不能包含重复的组合。
示例 1:
1 | 输入: candidates = [10,1,2,7,6,1,5], target = 8, |
示例 2:
1 | 输入: candidates = [2,5,2,1,2], target = 5, |
提示:
1 <= candidates.length <= 100
1 <= candidates[i] <= 50
1 <= target <= 30
思路
这题和前面还是不同的,首先这里每个组合中的元素只能使用一次,并且元素里面是有重复的
首先是组合问题肯定用回溯法
回溯三部曲
首先candidates的长度决定了树的宽度和深度
1、确定参数
首先要有一个存放答案和最终答案的List
1 | List<List<Integer>> res = new ArrayList<>(); |
然后参数要传candidates,target,还有现在的sum,还有startIndex
1 | void backtracking(int[] candidates,int target,int sum,int startIndex) |
2、确定结束条件
1 | if(sum >= target){ |
3、确定单层递归逻辑
1 | for(int i = startIndex ; i < candidates.length ; i++){ |
最终代码如下
1 | class Solution { |
但是写完之后发现有问题,因为由于组合中元素是重复的可能造成的结果就是
假设有个组合1,2,1,然后target是3,那么就会造成1,2和2,1两种组合,但是这样是不对的,所以这题跟前面是不一样的,那么需要怎么修改呢?
所以需要排序+去重
提前将candidates排序好,然后递归的时候如果前一个数和当前数相等直接continue
最终代码如下
1 | class Solution { |
7、分隔回文串
给你一个字符串 s
,请你将 s
分割成一些 子串,使每个子串都是 回文串 。返回 s
所有可能的分割方案。
示例 1:
1 | 输入:s = "aab" |
示例 2:
1 | 输入:s = "a" |
提示:
1 <= s.length <= 16
s
仅由小写英文字母组成
思路
这里有两个关键问题
1、切割问题,有不同的切割方式
2、判断回文
首先这里肯定是回溯了,因为组合
那回溯法怎么切割字符串呢?
仔细分析一下切割其实就是组合
例如对于字符串abcdef:
- 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个…..。
- 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段…..。
感受出来了不?
所以切割问题,也可以抽象为一棵树形结构,如图:
递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。
此时可以发现,切割问题的回溯搜索的过程和组合问题的回溯搜索的过程是差不多的。
从树形结构的图中可以看出:切割线切到了字符串最后面,说明找到了一种切割方法,此时就是本层递归的终止条件。
那么在代码里什么是切割线呢?
在处理组合问题的时候,递归参数需要传入startIndex,表示下一轮递归遍历的起始位置,这个startIndex就是切割线。
想通了之后开始回溯三部曲
1、确定回溯参数
1 | List<List<String>> res = new ArrayList<>(); |
2、确定结束条件
1 | if(startIndex >= s.size()){ |
3、确定单层递归逻辑
来看看在递归循环中如何截取子串呢?
在for (int i = startIndex; i < s.size(); i++)
循环中,我们 定义了起始位置startIndex,那么 [startIndex, i] 就是要截取的子串。
首先判断这个子串是不是回文,如果是回文,就加入在ans
中,ans用来记录切割过的回文子串。
首先需要一个方法来判断是否回文,这个可以用双指针。
那么最终代码如下
1 | class Solution { |
看完代码再思考一下,怎么能分隔aa出来呢,这里可以看看但单层逻辑那里,有一个sb.append这里第一次是加了a然后开始回溯也就判断a是否是回文,然后第二次继续append这样就代表第一次分隔了aa出来了。后面也是一样的。
8、复原IP地址
有效 IP 地址 正好由四个整数(每个整数位于 0
到 255
之间组成,且不能含有前导 0
),整数之间用 '.'
分隔。
- 例如:
"0.1.2.201"
和"192.168.1.1"
是 有效 IP 地址,但是"0.011.255.245"
、"192.168.1.312"
和"192.168@1.1"
是 无效 IP 地址。
给定一个只包含数字的字符串 s
,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s
中插入 '.'
来形成。你 不能 重新排序或删除 s
中的任何数字。你可以按 任何 顺序返回答案。
示例 1:
1 | 输入:s = "25525511135" |
示例 2:
1 | 输入:s = "0000" |
示例 3:
1 | 输入:s = "101023" |
提示:
1 <= s.length <= 20
s
仅由数字组成
思路
这题跟之前很像也是分隔问题,但是有区别的是这里的分隔是有限制的分隔,因为这是0~255的分隔,所以不能分隔成2333四长度的子串
切割问题可以抽象为树型结构,如图:
回溯三部曲
1、递归参数
首先需要一个List存放结果,然后startIndex是肯定需要的,因为不能重复分隔,并且由于分隔数量是有限制的所以还需要传递添加逗点的数量。
1 | void backtracking(String s,int startIndex, int pointNum) |
2、递归的终止条件
终止条件这里很重要了,这题明确要求只会分成4段,所以用分隔的段数作为终止条件,如果pointNum为3说明分成4段了。
然后验证第四段是否合法,如果合法就加入到结果集里。
1 | if(pointNum == 3){ |
3、单层递归的逻辑
在for (int i = startIndex; i < s.size(); i++)
循环中 [startIndex, i] 这个区间就是截取的子串,需要判断这个子串是否合法。
如果合法就在字符串后面加上符号.
表示已经分割。
如果不合法就结束本层循环,如图中剪掉的分支:
然后就是递归和回溯的过程:
递归调用时,下一层递归的startIndex要从i+2开始(因为需要在字符串中加入了分隔符.
),同时记录分割符的数量pointNum 要 +1。
回溯的时候,就将刚刚加入的分隔符.
删掉就可以了,pointNum也要-1。
判断子串是否合法
在写一个判断位是否是有效段位了。
1、段位以0开头不合法
2、段位里有非正整数字符不合法
3、段位如果大于255不合法
1 | boolean isValid(StringBuilder s , int start,int end){ |
整体代码如下
1 | class Solution { |
9、子集
给你一个整数数组 nums
,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
示例 1:
1 | 输入:nums = [1,2,3] |
示例 2:
1 | 输入:nums = [0] |
提示:
1 <= nums.length <= 10
-10 <= nums[i] <= 10
nums
中的所有元素 互不相同
思路
这个跟组合问题还是有区别的,如果不明白回去看看题目描述就知道了
如果把子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分隔问题都是收集树的叶子结点,而自己问题是找树的所有节点。
其实自己也是一种组合问题,因为他的集合是无序的,那么既然是无序的,那么去过的元素不会重复取,写回溯的时候,for就要从startIndex开始,而不是从0开始
那什么时候从0开始,求排列问题的时候,因为1,2和2,1是不同的。
以示例中nums = [1,2,3]为例把求子集抽象为树型结构,如下:
从图中红线部分,可以看出遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合。
回溯三部曲
1、递归函数的参数
用一个List存放最终结果,一个List存放子集组合然后需要startIndex
1 | List<List<Integer>> res = new ArrayList<>(); |
2、递归终止条件
从图中可以看出:
剩余集合为空的时候,就是叶子节点。
那么什么时候剩余集合为空呢?
就是startIndex已经大于数组的长度了,就终止了,因为没有元素可取了,代码如下:
1 | if (startIndex >= nums.length) { |
其实可以不需要加终止条件,因为startIndex >= nums.size(),本层for循环本来也结束了。
3、单层搜索逻辑
求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。
那么单层递归逻辑代码如下:
1 | for (int i = startIndex; i < nums.length; i++) { |
最终代码如下
1 |
|
10、子集2
给你一个整数数组 nums
,其中可能包含重复元素,请你返回该数组所有可能的 子集(幂集)。
解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。
示例 1:
1 | 输入:nums = [1,2,2] |
示例 2:
1 | 输入:nums = [0] |
提示:
1 <= nums.length <= 10
-10 <= nums[i] <= 10
思路
这题跟上题的区别就是有重复元素
之前组合问题的时候是排序+去重解决的了。
这里总结一下,“使用过”在树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。
下图是组合问题的去重逻辑
那么这题的去重逻辑是
用示例中的[1, 2, 2] 来举例,如图所示: (注意去重需要先对集合排序)
1 | class Solution { |
11、递增子序列
给你一个整数数组 nums
,找出并返回所有该数组中不同的递增子序列,递增子序列中 至少有两个元素 。你可以按 任意顺序 返回答案。
数组中可能含有重复元素,如出现两个整数相等,也可以视作递增序列的一种特殊情况。
示例 1:
1 | 输入:nums = [4,6,7,7] |
示例 2:
1 | 输入:nums = [4,4,3,2,1] |
提示:
1 <= nums.length <= 15
-100 <= nums[i] <= 100
思路
这个递增子序列比较像是取有序的子集。而且本题也要求不能有相同的递增子序列。
这又是子集,又是去重,是不是不由自主的想起了刚刚的子集2
就是因为太像了,更要注意差别所在,要不就掉坑里了!
本题求自增子序列,是不能对原数组进行排序的,排完序的数组都是自增子序列了。
所以不能使用之前的去重逻辑!
回溯三部曲
1、递归函数参数
本题求子序列,很明显一个元素不能重复使用,所以需要startIndex,调整下一层递归的起始位置
1 | List<List<Integer>> res = new ArrayList<>(); |
2、终止条件
本题其实类似求子集问题,也是要遍历树形结构找每一个节点,所以跟子集一样,可以不加终止条件
但是本题收集结果有所不同,题目要求递增子序列大小至少为2,所以代码如下:
1 | if(ans.size() > 1){ |
3、单层搜索逻辑
在图中可以看出,同一父节点下的同层上使用过的元素就不能再使用了,这里可以用used数组来判断,也可以用hashset来去重,或者用map去重。
这里需要注意的是去重逻辑,怎么样去重呢?
1 | if(path.size() >= 2) |
HashSet<Integer> hs
的作用:同一层去重
hs
是在 每一层递归中新建的(在for
循环前声明)- 它记录了当前层已经使用过的数字
- 如果当前数字
nums[i]
已经在hs
中出现过,就跳过 → 避免在同一层选择相同的数字
举例
数组 [4, 6, 7, 7]
,当前层可选 [6,7,7]
i=1
: 选6
,hs.add(6)
i=2
: 选第一个7
,hs.add(7)
i=3
:nums[3]==7
,hs.contains(7)==true
→ 跳过第二个7
这样就避免了在同一层产生两个
[...,7]
的分支,防止重复子序列。
1 | class Solution { |
12、全排列
给定一个不含重复数字的数组 nums
,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
1 | 输入:nums = [1,2,3] |
示例 2:
1 | 输入:nums = [0,1] |
示例 3:
1 | 输入:nums = [1] |
提示:
1 <= nums.length <= 6
-10 <= nums[i] <= 10
nums
中的所有整数 互不相同
思路
回溯三部曲
1、确定参数,这里由于1,2和2,1是两个集合所以不需要startIndex了,但是需要used数组标记已经用过的树
1 | List<List<Integer>> res = new ArrayList<>(); |
2、确定结束条件
1 | if(ans.size() == nums.length){ |
3、确定单层递归逻辑
1 | //首先这是全排列,也就是每次都要从第一个开始所以是0 |
整体代码如下
1 | class Solution { |
13、全排列2
给定一个可包含重复数字的序列 nums
,按任意顺序 返回所有不重复的全排列。
示例 1:
1 | 输入:nums = [1,1,2] |
示例 2:
1 | 输入:nums = [1,2,3] |
提示:
1 <= nums.length <= 8
-10 <= nums[i] <= 10
思路
这题跟之前的又有区别就是这里有重复的数字
所以需要再上一个全排列的基础上增加排序+去重
排序简单,然后思考是去重的维度
同一层去重,还是同一树枝去重,这次是同一层
那么重要的是去重逻辑,用一个used数组标注是否使用过,那么同一层的话用hashset来标注,
1 | class Solution { |
后面这三题太难了,看完图论再过来看比较好
14、重新安排行程
给你一份航线列表 tickets
,其中 tickets[i] = [fromi, toi]
表示飞机出发和降落的机场地点。请你对该行程进行重新规划排序。
所有这些机票都属于一个从 JFK
(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK
开始。如果存在多种有效的行程,请你按字典排序返回最小的行程组合。
- 例如,行程
["JFK", "LGA"]
与["JFK", "LGB"]
相比就更小,排序更靠前。
假定所有机票至少存在一种合理的行程。且所有的机票 必须都用一次 且 只能用一次。
示例 1:
1 | 输入:tickets = [["MUC","LHR"],["JFK","MUC"],["SFO","SJC"],["LHR","SFO"]] |
示例 2:
1 | 输入:tickets = [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]] |
提示:
1 <= tickets.length <= 300
tickets[i].length == 2
fromi.length == 3
toi.length == 3
fromi
和toi
由大写英文字母组成fromi != toi
思路
直觉上来看 这道题和回溯法没有什么关系,更像是图论中的深度优先搜索。
实际上确实是深搜,但这是深搜中使用了回溯的例子,在查找路径的时候,如果不回溯,怎么能查到目标路径呢。
所以我倾向于说本题应该使用回溯法,那么我也用回溯法的思路来讲解本题,其实深搜一般都使用了回溯法的思路,在图论系列中我会再详细讲解深搜。
这里就是先给大家拓展一下,原来回溯法还可以这么玩!
这道题目有几个难点:
- 一个行程中,如果航班处理不好容易变成一个圈,成为死循环
- 有多种解法,字母序靠前排在前面,让很多同学望而退步,如何该记录映射关系呢 ?
- 使用回溯法(也可以说深搜) 的话,那么终止条件是什么呢?
- 搜索的过程中,如何遍历一个机场所对应的所有机场。
针对以上问题我来逐一解答!
如何理解死循环
对于死循环,我来举一个有重复机场的例子:
为什么要举这个例子呢,就是告诉大家,出发机场和到达机场也会重复的,如果在解题的过程中没有对集合元素处理好,就会死循环。
记录映射关系
Map<String, Integer>
会自动按 key(机场名)字母序排序(如果是TreeMap
)HashMap
不排序,TreeMap
排序
所以我们要用 TreeMap
来保证目的地按字母序排列。
1 | import java.util.*; |
15、N皇后
16、解数独
第八章 贪心算法
1、贪心算法理论基础
什么是贪心,贪心的本质就是选择的每一阶段都是局部最优,从而达到全局最优
这么说有一点抽象,举个例子:例如有一堆钞票,你可以拿走十张,如果想达到最大的金额,你要怎么拿?
指定每次拿最大的,最终的结果就是拿走最大数额的钱。
每次拿最大的就是局部最有,最后拿走最大数额的钱就是推出全局最优。
再聚一个例子,如果有一堆盒子,你有一个背包体积为n,如何把背包尽可能装满,如果每次还选最大的盒子,就不行了。这时候需要动态规划。
贪心的套路:什么时候使用贪心
这个就是靠常识性判断,或者想反例,如果想不出什么反例,可以试一试贪心。一般大部分不需要数学推导。
贪心的一般解题步骤
贪心算法一般分为4步
1、将问题分解为若干个子问题
2、找出适合的贪心策略
3、求解每一个子问题的最优解
4、将局部最优解堆叠成全局最优解
实际上做题的时候,只要想清楚局部最优是什么,如果推导出全局最有,其实就够了
所以说,贪心没有套路,常识性推导加上举反例
2、分发饼干
假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。
对每个孩子 i
,都有一个胃口值 g[i]
,这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j
,都有一个尺寸 s[j]
。如果 s[j] >= g[i]
,我们可以将这个饼干 j
分配给孩子 i
,这个孩子会得到满足。你的目标是满足尽可能多的孩子,并输出这个最大数值。
示例 1:
1 | 输入: g = [1,2,3], s = [1,1] |
示例 2:
1 | 输入: g = [1,2], s = [1,2,3] |
提示:
1 <= g.length <= 3 * 104
0 <= s.length <= 3 * 104
1 <= g[i], s[j] <= 231 - 1
思路
为了满足更多的小孩,就不要造成饼干尺寸的浪费。
大尺寸的饼干既可以满足胃口大的孩子也可以满足胃口小的孩子,那么就应该优先满足胃口大的。
这里的局部最优就是大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩。
可以尝试使用贪心策略,先将饼干数组和小孩数组排序。
然后从后向前遍历小孩数组,用大饼干优先满足胃口大的,并统计满足小孩数量。
如图:
这个例子可以看出饼干 9 只有喂给胃口为 7 的小孩,这样才是整体最优解,并想不出反例,那么就可以撸代码了。
1 | class Solution { |
ACM模式
1 | import java.io.*; |
3、摆动序列
如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为 摆动序列 。第一个差(如果存在的话)可能是正数或负数。仅有一个元素或者含两个不等元素的序列也视作摆动序列。
- 例如,
[1, 7, 4, 9, 2, 5]
是一个 摆动序列 ,因为差值(6, -3, 5, -7, 3)
是正负交替出现的。 - 相反,
[1, 4, 7, 2, 5]
和[1, 7, 4, 5, 5]
不是摆动序列,第一个序列是因为它的前两个差值都是正数,第二个序列是因为它的最后一个差值为零。
子序列 可以通过从原始序列中删除一些(也可以不删除)元素来获得,剩下的元素保持其原始顺序。
给你一个整数数组 nums
,返回 nums
中作为 摆动序列 的 最长子序列的长度 。
示例 1:
1 | 输入:nums = [1,7,4,9,2,5] |
示例 2:
1 | 输入:nums = [1,17,5,10,13,15,10,5,16,8] |
示例 3:
1 | 输入:nums = [1,2,3,4,5,6,7,8,9] |
提示:
1 <= nums.length <= 1000
0 <= nums[i] <= 1000
思路
本题要求通过从原始序列中删除一些(也可以不删除)元素来获得子序列,剩下的元素保持其原始顺序。
相信这么一说吓退不少同学,这要求最大摆动序列又可以修改数组,这得如何修改呢?
来分析一下,要求删除元素使其达到最大摆动序列,应该删除什么元素呢?
用示例二来举例,如图所示:
局部最优:删除单调坡度上的节点(不包括单调坡度两端的节点),那么这个坡度就可以有两个局部峰值。
整体最优:整个序列有最多的局部峰值,从而达到最长摆动序列。
局部最优推出全局最优,并举不出反例,那么试试贪心!
(为方便表述,以下说的峰值都是指局部峰值)
实际操作上,其实连删除的操作都不用做,因为题目要求的是最长摆动子序列的长度,所以只需要统计数组的峰值数量就可以了(相当于是删除单一坡度上的节点,然后统计长度)
这就是贪心所贪的地方,让峰值尽可能的保持峰值,然后删除单一坡度上的节点
在计算是否有峰值的时候,大家知道遍历的下标 i ,计算 prediff(nums[i] - nums[i-1]) 和 curdiff(nums[i+1] - nums[i]),如果prediff < 0 && curdiff > 0
或者 prediff > 0 && curdiff < 0
此时就有波动就需要统计。
这是我们思考本题的一个大体思路,但本题要考虑三种情况:
- 情况一:上下坡中有平坡
- 情况二:数组首尾两端
- 情况三:单调坡中有平坡
情况一:上下坡中有平坡
例如 [1,2,2,2,2,1]这样的数组,如图:
它的摇摆序列长度是多少呢? 其实是长度是 3,也就是我们在删除的时候 要不删除左面的三个 2,要不就删除右边的三个 2。
如图,可以统一规则,删除左边的三个 2:
在图中,当 i 指向第一个 2 的时候,prediff > 0 && curdiff = 0
,当 i 指向最后一个 2 的时候 prediff = 0 && curdiff < 0
。
如果我们采用,删左面三个 2 的规则,那么 当 prediff = 0 && curdiff < 0
也要记录一个峰值,因为他是把之前相同的元素都删掉留下的峰值。
所以我们记录峰值的条件应该是: (preDiff <= 0 && curDiff > 0) || (preDiff >= 0 && curDiff < 0)
,为什么这里允许 prediff == 0 ,就是为了 上面我说的这种情况。
2、情况二:数组首尾两端
所以本题统计峰值的时候,数组最左面和最右面如何统计呢?
题目中说了,如果只有两个不同的元素,那摆动序列也是 2。
例如序列[2,5],如果靠统计差值来计算峰值个数就需要考虑数组最左面和最右面的特殊情况。
因为我们在计算 prediff(nums[i] - nums[i-1]) 和 curdiff(nums[i+1] - nums[i])的时候,至少需要三个数字才能计算,而数组只有两个数字。
这里我们可以写死,就是 如果只有两个元素,且元素不同,那么结果为 2。
不写死的话,如何和我们的判断规则结合在一起呢?
可以假设,数组最前面还有一个数字,那这个数字应该是什么呢?
之前我们在 讨论 情况一:相同数字连续 的时候, prediff = 0 ,curdiff < 0 或者 >0 也记为波谷。
那么为了规则统一,针对序列[2,5],可以假设为[2,2,5],这样它就有坡度了即 preDiff = 0,如图:
针对以上情形,result 初始为 1(默认最右面有一个峰值),此时 curDiff > 0 && preDiff <= 0,那么 result++(计算了左面的峰值),最后得到的 result 就是 2(峰值个数为 2 即摆动序列长度为 2)
经过以上分析后,我们可以写出如下代码:
1 | // 版本一 |
- 时间复杂度:O(n)
- 空间复杂度:O(1)
此时大家是不是发现 以上代码提交也不能通过本题?
所以此时我们要讨论情况三!
情况三:单调坡度有平坡
在版本一中,我们忽略了一种情况,即 如果在一个单调坡度上有平坡,例如[1,2,2,2,3,4],如图:
图中,我们可以看出,版本一的代码在三个地方记录峰值,但其实结果因为是 2,因为 单调中的平坡 不能算峰值(即摆动)。
之所以版本一会出问题,是因为我们实时更新了 prediff。
那么我们应该什么时候更新 prediff 呢?
我们只需要在 这个坡度 摆动变化的时候,更新 prediff 就行,这样 prediff 在 单调区间有平坡的时候 就不会发生变化,造成我们的误判
最终代码如下
1 | class Solution { |
4、最大子序和
给你一个整数数组 nums
,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组是数组中的一个连续部分。
示例 1:
1 | 输入:nums = [-2,1,-3,4,-1,2,1,-5,4] |
示例 2:
1 | 输入:nums = [1] |
示例 3:
1 | 输入:nums = [5,4,-1,7,8] |
提示:
1 <= nums.length <= 105
-104 <= nums[i] <= 104
进阶:如果你已经实现复杂度为 O(n)
的解法,尝试使用更为精妙的 分治法 求解。
思路
贪心解法
这里哪里可以贪呢
如果-2 1在一起,计算起点的时候,一定是从1开始计算,因为负数会拉低综总和。
局部最优:当前“连续和”为负数的时候立刻放弃,从下一个元素重新计算“连续和”,因为负数加上下一个元素“连续和”只会越来越小。
全局最优:选取最大“连续和”
局部最优的情况下,并记录最大的连续和,可以推出全局最优。
从代码角度上思考:遍历nums,从头开始用count累计,如果count一旦加上nums[i]变为负数,那么就应该从nums[i + 1]开始从0开始累计count了,因为已经变为了负数的count,只会拖累总和。
这相当于是暴力解法中的不断调整最大子序和区间的起始位置
那就有学问了,区间终止位置不用调整吗?如何才能找到最大连续和?
区间的终止位置,就是如果count取到最大值了,及时记录下来
1 | if(count > result) result = count; |
这样相当于是用result记录最大子序的区间和(变相的算是调整了终止位置)
力扣题解
1 | class Solution { |
ACM题解
1 | import java.util.*; |
5、买股票的最佳时间
122. 买卖股票的最佳时机 II - 力扣(LeetCode)
给你一个整数数组 prices
,其中 prices[i]
表示某支股票第 i
天的价格。
在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售。
返回 你能获得的 最大 利润 。
示例 1:
1 | 输入:prices = [7,1,5,3,6,4] |
示例 2:
1 | 输入:prices = [1,2,3,4,5] |
示例 3:
1 | 输入:prices = [7,6,4,3,1] |
提示:
1 <= prices.length <= 3 * 104
0 <= prices[i] <= 104
1 | class Solution { |
6、跳跃游戏
给你一个非负整数数组 nums
,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标,如果可以,返回 true
;否则,返回 false
。
示例 1:
1 | 输入:nums = [2,3,1,1,4] |
示例 2:
1 | 输入:nums = [3,2,1,0,4] |
提示:
1 <= nums.length <= 104
0 <= nums[i] <= 105
1 | class Solution { |
7、跳跃游戏2
给定一个长度为 n
的 0 索引整数数组 nums
。初始位置为 nums[0]
。
每个元素 nums[i]
表示从索引 i
向后跳转的最大长度。换句话说,如果你在 nums[i]
处,你可以跳转到任意 nums[i + j]
处:
0 <= j <= nums[i]
i + j < n
返回到达 nums[n - 1]
的最小跳跃次数。生成的测试用例可以到达 nums[n - 1]
。
示例 1:
1 | 输入: nums = [2,3,1,1,4] |
示例 2:
1 | 输入: nums = [2,3,0,1,4] |
提示:
1 <= nums.length <= 104
0 <= nums[i] <= 1000
- 题目保证可以到达
nums[n-1]
思路
本题要计算最少步数,那么就要想清楚什么时候步数才一定要加一呢?
贪心的思路,局部最优:当前可移动距离尽可能多走,如果还没到终点,步数再加一。整体最优:一步尽可能多走,从而达到最少步数。
思路虽然是这样,但在写代码的时候还不能真的能跳多远就跳多远,那样就不知道下一步最远能跳到哪里了。
所以真正解题的时候,要从覆盖范围出发,不管怎么跳,覆盖范围内一定是可以跳到的,以最小的步数增加覆盖范围,覆盖范围一旦覆盖了终点,得到的就是最少步数!
这里需要统计两个覆盖范围,当前这一步的最大覆盖和下一步最大覆盖。
如果移动下标达到了当前这一步的最大覆盖最远距离了,还没有到终点的话,那么就必须再走一步来增加覆盖范围,直到覆盖范围覆盖了终点。
如图:
图中覆盖范围的意义在于,只要红色的区域,最多两步一定可以到!(不用管具体怎么跳,反正一定可以跳到)
1 | // 版本一 |
为什么“等到 i == curDistance
跳”?
因为这是贪心策略的关键:延迟决策,直到必须跳时才跳
- 在
[0, curDistance]
这个范围内行走时,不断观察:“从这些位置出发,下一步最远能跳到哪?” - 不需要在
i=0
就决定跳到哪,而是走到curDistance
边界时,才做决定 - 这样可以收集到“当前跳跃范围内所有位置的信息”,从而做出最优决策
8、K次取反后最大化的数组和
1005. K 次取反后最大化的数组和 - 力扣(LeetCode)
给你一个整数数组 nums
和一个整数 k
,按以下方法修改该数组:
- 选择某个下标
i
并将nums[i]
替换为-nums[i]
。
重复这个过程恰好 k
次。可以多次选择同一个下标 i
。
以这种方式修改数组后,返回数组 可能的最大和 。
示例 1:
1 | 输入:nums = [4,2,3], k = 1 |
示例 2:
1 | 输入:nums = [3,-1,0,2], k = 3 |
示例 3:
1 | 输入:nums = [2,-3,-1,5,-4], k = 2 |
提示:
1 <= nums.length <= 104
-100 <= nums[i] <= 100
1 <= k <= 104
思路
贪心的思路,局部最优,让绝对值大的负数变为正数,当前数值达到最大,整体最优:整个数组和达到最大。
局部最优可以推出全局最优。
那么如果将负数都转变为正数了,k依然大于0,此时的问题是一个有序的正整数序列,如何转变k次正负,让 数组 和达到最大
那么又是一个贪心:局部最优:只找数值最小的正整数进行反转,当前数值和可以达到最大,全局最优:整个数组和达到最大
那么本题的解题步骤为:
- 第一步:将数组按照绝对值大小从大到小排序,注意要按照绝对值的大小
- 第二步:从前向后遍历,遇到负数将其变为正数,同时K—
- 第三步:如果K还大于0,那么反复转变数值最小的元素,将K用完
- 第四步:求和
1 | import java.util.stream.IntStream; |
9、加油站
在一条环路上有 n
个加油站,其中第 i
个加油站有汽油 gas[i]
升。
你有一辆油箱容量无限的的汽车,从第 i
个加油站开往第 i+1
个加油站需要消耗汽油 cost[i]
升。你从其中的一个加油站出发,开始时油箱为空。
给定两个整数数组 gas
和 cost
,如果你可以按顺序绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1
。如果存在解,则 保证 它是 唯一 的。
示例 1:
1 | 输入: gas = [1,2,3,4,5], cost = [3,4,5,1,2] |
示例 2:
1 | 输入: gas = [2,3,4], cost = [3,4,3] |
提示:
n == gas.length == cost.length
1 <= n <= 105
0 <= gas[i], cost[i] <= 104
- 输入保证答案唯一。
思路
用贪心的思想,局部最优推出全局最优。
- 如果总加油量 < 总消耗量 → 肯定无法绕一圈 → 返回
-1
- 如果总加油量 ≥ 总消耗量 → 一定存在一个合法起点(题目保证唯一解)
- 我们可以从
0
开始尝试,记录当前剩余油量,一旦油量不够到下一站,就说明前面这一段都不能作为起点。(因为剩余油量加上当前油量都过不了,说明这前面的起点都不行)
1 | class Solution { |
10、分发糖果
n
个孩子站成一排。给你一个整数数组 ratings
表示每个孩子的评分。
你需要按照以下要求,给这些孩子分发糖果:
- 每个孩子至少分配到
1
个糖果。 - 相邻两个孩子中,评分更高的那个会获得更多的糖果。
请你给每个孩子分发糖果,计算并返回需要准备的 最少糖果数目 。
示例 1:
1 | 输入:ratings = [1,0,2] |
示例 2:
1 | 输入:ratings = [1,2,2] |
提示:
n == ratings.length
1 <= n <= 2 * 104
0 <= ratings[i] <= 2 * 104
思路
这道题目一定是要确定一边之后,再确定另一边,例如比较每一个孩子的左边,然后再比较右边,如果两边一起考虑一定会顾此失彼。
先确定右边评分大于左边的情况(也就是从前向后遍历)
此时局部最优:只要右边评分比左边大,右边的孩子就多一个糖果,全局最优:相邻的孩子中,评分高的右孩子获得比左边孩子更多的糖果
局部最优可以推出全局最优。
也就是说一定要从后向前遍历来确定左孩子大于右孩子的情况,然后从前往后遍历来确定右孩子大于左孩子的情况。
直到这个约束之后就能解决问题了,然后之鱼先从后往前还是从前往后都可以的。
那么为什么会有这个从后往前来确定左孩子大于右孩子的约束呢?
因为我们要满足两个规则,如果右>左,那么糖果右大于左
如果左>右,那么糖果左大于右
我们要同时满足这两个约束。
那么如果右大于左,那么右边的糖果=左边糖果+1,那么我们要先知道左边糖果的值,那么左边糖果的值要先处理了才能知道左边糖果的值,所以从前往后处理,然后右>左。
最终整体代码如下
1 | class Solution { |
11、柠檬水找零
在柠檬水摊上,每一杯柠檬水的售价为 5
美元。顾客排队购买你的产品,(按账单 bills
支付的顺序)一次购买一杯。
每位顾客只买一杯柠檬水,然后向你付 5
美元、10
美元或 20
美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5
美元。
注意,一开始你手头没有任何零钱。
给你一个整数数组 bills
,其中 bills[i]
是第 i
位顾客付的账。如果你能给每位顾客正确找零,返回 true
,否则返回 false
。
示例 1:
1 | 输入:bills = [5,5,5,10,20] |
示例 2:
1 | 输入:bills = [5,5,10,10,20] |
提示:
1 <= bills.length <= 105
bills[i]
不是5
就是10
或是20
思路
仔细一琢磨就会发现,可供我们做判断的空间非常少!
只需要维护三种金额的数量,5,10和20。
有如下三种情况:
- 情况一:账单是5,直接收下。
- 情况二:账单是10,消耗一个5,增加一个10
- 情况三:账单是20,优先消耗一个10和一个5,如果不够,再消耗三个5
此时大家就发现 情况一,情况二,都是固定策略,都不用我们来做分析了,而唯一不确定的其实在情况三。
而情况三逻辑也不复杂甚至感觉纯模拟就可以了,其实情况三这里是有贪心的。
账单是20的情况,为什么要优先消耗一个10和一个5呢?
因为美元10只能给账单20找零,而美元5可以给账单10和账单20找零,美元5更万能!
所以局部最优:遇到账单20,优先消耗美元10,完成本次找零。全局最优:完成全部账单的找零。
局部最优可以推出全局最优,并找不出反例,那么就试试贪心算法!
1 | class Solution { |
12、根据身高重建队列(未解决没读懂题)
假设有打乱顺序的一群人站成一个队列,数组 people
表示队列中一些人的属性(不一定按顺序)。每个 people[i] = [hi, ki]
表示第 i
个人的身高为 hi
,前面 正好 有 ki
个身高大于或等于 hi
的人。
请你重新构造并返回输入数组 people
所表示的队列。返回的队列应该格式化为数组 queue
,其中 queue[j] = [hj, kj]
是队列中第 j
个人的属性(queue[0]
是排在队列前面的人)。
示例 1:
1 | 输入:people = [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]] |
示例 2:
1 | 输入:people = [[6,0],[5,0],[4,0],[3,2],[2,2],[1,4]] |
提示:
1 <= people.length <= 2000
0 <= hi <= 106
0 <= ki < people.length
- 题目数据确保队列可以被重建
13、用最少数量的箭引爆气球
452. 用最少数量的箭引爆气球 - 力扣(LeetCode)
有一些球形气球贴在一堵用 XY 平面表示的墙面上。墙面上的气球记录在整数数组 points
,其中points[i] = [xstart, xend]
表示水平直径在 xstart
和 xend
之间的气球。你不知道气球的确切 y 坐标。
一支弓箭可以沿着 x 轴从不同点 完全垂直 地射出。在坐标 x
处射出一支箭,若有一个气球的直径的开始和结束坐标为 x``start
,x``end
, 且满足 xstart ≤ x ≤ x``end
,则该气球会被 引爆 。可以射出的弓箭的数量 没有限制 。 弓箭一旦被射出之后,可以无限地前进。
给你一个数组 points
,返回引爆所有气球所必须射出的 最小 弓箭数 。
示例 1:
1 | 输入:points = [[10,16],[2,8],[1,6],[7,12]] |
示例 2:
1 | 输入:points = [[1,2],[3,4],[5,6],[7,8]] |
示例 3:
1 | 输入:points = [[1,2],[2,3],[3,4],[4,5]] |
提示:
1 <= points.length <= 105
points[i].length == 2
-231 <= xstart < xend <= 231 - 1
思路
首先得理解题目意思,首先points里面记录的是气球的直径的x坐标,然后弓箭是能水平y轴射出,所以也就是说,只要两个气球直径有重合一支箭就能把重合的气球一箭射爆。
那么我们就思考这题是不是找重合坐标了。
算法确定下来了,那么如何模拟气球射爆的过程?是在数组中移除元素还是标记?
如果真实的模拟过程,应该射一个,气球数组就remove一个元素,这样最直观。
但是仔细思考一下:如果把气球排序之后,从前到后遍历气球,被蛇果的气球仅仅跳过就行了,没必要让气球数组remove气球,只要记录一下箭的数量就可以了。
为了让气球尽可能的重叠,需要对数组进行排序。
那么按照气球起始位置排序,还是按照气球终止位置排序呢?
其实都可以!只不过对应的遍历顺序不同,我就按照气球的起始位置排序了。
既然按照起始位置排序,那么就从前向后遍历气球数组,靠左尽可能让气球重复。
从前向后遍历遇到重叠的气球了怎么办?
如果气球重叠了,重叠气球中右边边界的最小值 之前的区间一定需要一个弓箭。
以题目示例: [[10,16],[2,8],[1,6],[7,12]]为例,如图:(方便起见,已经排序)
可以看出首先第一组重叠气球,一定是需要一个箭,气球3,的左边界大于了 第一组重叠气球的最小右边界,所以再需要一支箭来射气球3了。
整体代码如下
1 | class Solution { |
14、无重叠区间
给定一个区间的集合 intervals
,其中 intervals[i] = [starti, endi]
。返回 需要移除区间的最小数量,使剩余区间互不重叠 。
注意 只在一点上接触的区间是 不重叠的。例如 [1, 2]
和 [2, 3]
是不重叠的。
示例 1:
1 | 输入: intervals = [[1,2],[2,3],[3,4],[1,3]] |
示例 2:
1 | 输入: intervals = [ [1,2], [1,2], [1,2] ] |
示例 3:
1 | 输入: intervals = [ [1,2], [2,3] ] |
提示:
1 <= intervals.length <= 105
intervals[i].length == 2
-5 * 104 <= starti < endi <= 5 * 104
思路
相信很多同学看到这道题目都冥冥之中感觉要排序,但是究竟是按照右边界排序,还是按照左边界排序呢?
其实都可以。主要就是为了让区间尽可能的重叠。
我来按照右边界排序,从左向右记录非交叉区间的个数。最后用区间总数减去非交叉区间的个数就是需要移除的区间个数了。
此时问题就是要求非交叉区间的最大个数。
这里记录非交叉区间的个数还是有技巧的,如图:
区间,1,2,3,4,5,6都按照右边界排好序。
当确定区间 1 和 区间2 重叠后,如何确定是否与 区间3 也重贴呢?
就是取 区间1 和 区间2 右边界的最小值,因为这个最小值之前的部分一定是 区间1 和区间2 的重合部分,如果这个最小值也触达到区间3,那么说明 区间 1,2,3都是重合的。
接下来就是找大于区间1结束位置的区间,是从区间4开始。那有同学问了为什么不从区间5开始?别忘了已经是按照右边界排序的了。
区间4结束之后,再找到区间6,所以一共记录非交叉区间的个数是三个。
总共区间个数为6,减去非交叉区间的个数3。移除区间的最小数量就是3。
1 | class Solution { |
15、划分字母区间
给你一个字符串 s
。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。例如,字符串 "ababcc"
能够被分为 ["abab", "cc"]
,但类似 ["aba", "bcc"]
或 ["ab", "ab", "cc"]
的划分是非法的。
注意,划分结果需要满足:将所有划分结果按顺序连接,得到的字符串仍然是 s
。
返回一个表示每个字符串片段的长度的列表。
示例 1:
1 | 输入:s = "ababcbacadefegdehijhklij" |
示例 2:
1 | 输入:s = "eccbbbbdec" |
提示:
1 <= s.length <= 500
s
仅由小写英文字母组成
思路
一想到分割字符串就想到了回溯,但本题其实不用回溯去暴力搜索。
题目要求同一字母最多出现在一个片段中,那么如何把同一个字母的都圈在同一个区间里呢?
如果没有接触过这种题目的话,还挺有难度的。
在遍历的过程中相当于是要找每一个字母的边界,如果找到之前遍历过的所有字母的最远边界,说明这个边界就是分割点了。此时前面出现过所有字母,最远也就到这个边界了。
可以分为如下两步:
- 统计每一个字符最后出现的位置
- 从头遍历字符,并更新字符的最远出现下标,如果找到字符最远出现位置下标和当前下标相等了,则找到了分割点
如图:
1 | class Solution { |
16、合并区间
以数组 intervals
表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi]
。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。
示例 1:
1 | 输入:intervals = [[1,3],[2,6],[8,10],[15,18]] |
示例 2:
1 | 输入:intervals = [[1,4],[4,5]] |
提示:
1 <= intervals.length <= 104
intervals[i].length == 2
0 <= starti <= endi <= 104
思路
这个题目跟前面的题目类似,可以先尝试一下。这里的难点是二维数组该怎么合并?
1 | class Solution { |
17、单调递增的数字
给定一个非负整数 N,找出小于或等于 N 的最大的整数,同时这个整数需要满足其各个位数上的数字是单调递增。
(当且仅当每个相邻位数上的数字 x 和 y 满足 x <= y 时,我们称这个整数是单调递增的。)
示例 1:
- 输入: N = 10
- 输出: 9
示例 2:
- 输入: N = 1234
- 输出: 1234
示例 3:
- 输入: N = 332
- 输出: 299
说明: N 是在 [0, 10^9] 范围内的一个整数。
思路
暴力方法是超时的这里就不说了,但是一看到这个题目还有例子会很容易想到后面都是9前面小一位就行了。
例如:98,一旦出现strNum[i - 1] > strNum[i]的情况()非单调递增的),首先就是想到让strNum[i - 1]–,然后后面给9就找到了89就找到了小于98的最大的单调递增整数。
那么这时候思考?我是从前向后还是从后向前遍历?
从前向后如果遇到前面大于后面的情况让前面-1,但是如果前面-1可能又小于前面的前面那个数。
那么从后向前遍历,就可以重复利用上次比较的结果了。
难点在于操作数字,把数字变成string数组再从string数组变成int
1 | class Solution { |
18、监控二叉树
给定一个二叉树,我们在树的节点上安装摄像头。
节点上的每个摄影头都可以监视其父对象、自身及其直接子对象。
计算监控树的所有节点所需的最小摄像头数量。
示例 1:
1 | 输入:[0,0,null,0,0] |
示例 2:
1 | 输入:[0,0,null,0,null,0,null,null,0] |
提示:
- 给定树的节点数的范围是
[1, 1000]
。 - 每个节点的值都是 0。
思路
首先要思考,如何放置,才能让摄像头最小的呢?
从题目中示例,其实可以得到启发,我们发现题目示例中的摄像头都没有放在叶子节点上!
这是很重要的一个线索,摄像头可以覆盖上中下三层,如果把摄像头放在叶子节点上,就浪费的一层的覆盖。
所以把摄像头放在叶子节点的父节点位置,才能充分利用摄像头的覆盖面积。
那么有同学可能问了,为什么不从头结点开始看起呢,为啥要从叶子节点看呢?
因为头结点放不放摄像头也就省下一个摄像头, 叶子节点放不放摄像头省下了的摄像头数量是指数阶别的。
所以我们要从下往上看,局部最优:让叶子节点的父节点安摄像头,所用摄像头最少,整体最优:全部摄像头数量所用最少!
局部最优推出全局最优,找不出反例,那么就按照贪心来!
此时,大体思路就是从低到上,先给叶子节点父节点放个摄像头,然后隔两个节点放一个摄像头,直至到二叉树头结点。
此时这道题目还有两个难点:
- 二叉树的遍历
- 如何隔两个节点放一个摄像头
首先遍历顺序肯定是后序遍历也就是左右中的顺序
如何隔两个节点放一个摄像头
此时需要状态转移的公式,大家不要和动态的状态转移公式混到一起,本题状态转移没有择优的过程,就是单纯的状态转移!
来看看这个状态应该如何转移,先来看看每个节点可能有几种状态:
有如下三种:
- 该节点无覆盖
- 本节点有摄像头
- 本节点有覆盖
我们分别有三个数字来表示:
- 0:该节点无覆盖
- 1:本节点有摄像头
- 2:本节点有覆盖
大家应该找不出第四个节点的状态了。
一些同学可能会想有没有第四种状态:本节点无摄像头,其实无摄像头就是 无覆盖 或者 有覆盖的状态,所以一共还是三个状态。
因为在遍历树的过程中,就会遇到空节点,那么问题来了,空节点究竟是哪一种状态呢? 空节点表示无覆盖? 表示有摄像头?还是有覆盖呢?
回归本质,为了让摄像头数量最少,我们要尽量让叶子节点的父节点安装摄像头,这样才能摄像头的数量最少。
那么空节点不能是无覆盖的状态,这样叶子节点就要放摄像头了,空节点也不能是有摄像头的状态,这样叶子节点的父节点就没有必要放摄像头了,而是可以把摄像头放在叶子节点的爷爷节点上。
所以空节点的状态只能是有覆盖,这样就可以在叶子节点的父节点放摄像头了
接下来就是递推关系。
那么递归的终止条件应该是遇到了空节点,此时应该返回2(有覆盖),原因上面已经解释过了。
1 | // 空节点,该节点有覆盖 |
递归的函数,以及终止条件已经确定了,再来看单层逻辑处理。
主要有如下四类情况:
- 情况1:左右节点都有覆盖
左孩子有覆盖,右孩子有覆盖,那么此时中间节点应该就是无覆盖的状态了。
如图:
代码如下:
1 | // 左右节点都有覆盖 |
- 情况2:左右节点至少有一个无覆盖的情况
如果是以下情况,则中间节点(父节点)应该放摄像头:
- left == 0 && right == 0 左右节点无覆盖
- left == 1 && right == 0 左节点有摄像头,右节点无覆盖
- left == 0 && right == 1 左节点有无覆盖,右节点摄像头
- left == 0 && right == 2 左节点无覆盖,右节点覆盖
- left == 2 && right == 0 左节点覆盖,右节点无覆盖
这个不难理解,毕竟有一个孩子没有覆盖,父节点就应该放摄像头。
此时摄像头的数量要加一,并且return 1,代表中间节点放摄像头。
代码如下:
1 | if (left == 0 || right == 0) { |
- 情况3:左右节点至少有一个有摄像头
如果是以下情况,其实就是 左右孩子节点有一个有摄像头了,那么其父节点就应该是2(覆盖的状态)
- left == 1 && right == 2 左节点有摄像头,右节点有覆盖
- left == 2 && right == 1 左节点有覆盖,右节点有摄像头
- left == 1 && right == 1 左右节点都有摄像头
代码如下:
1 | if (left == 1 || right == 1) return 2; |
从这个代码中,可以看出,如果left == 1, right == 0 怎么办?其实这种条件在情况2中已经判断过了,如图:
这种情况也是大多数同学容易迷惑的情况。
- 情况4:头结点没有覆盖
以上都处理完了,递归结束之后,可能头结点 还有一个无覆盖的情况,如图:
所以递归结束之后,还要判断根节点,如果没有覆盖,result++,代码如下:
1 | int minCameraCover(TreeNode* root) { |
以上四种情况我们分析完了,代码也差不多了,整体代码如下:
(以下我的代码注释很详细,为了把情况说清楚,特别把每种情况列出来。)
1 | class Solution { |
第九章 动态规划
1、理论基础
什么是动态规划,简称DP,如果某一个问题有很多重叠子问题,那么使用动态规划是最有效的。
所以动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的。
在贪心算法中,举了一个背包问题的例子,例如:有N件物品和一个最多能背重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
动态规划中dp[j]是由dp[j-weight[i]]推导出来的,然后取max(dp[j], dp[j - weight[i]] + value[i])。
但如果是贪心呢,每次拿物品选一个最大的或者最小的就完事了,和上一个状态没有关系。
所以贪心解决不了动态规划的问题。
其实大家也不用死扣动规和贪心的理论区别,后面做做题目自然就知道了。
而且很多讲解动态规划的文章都会讲最优子结构啊和重叠子问题啊这些,这些东西都是教科书的上定义,晦涩难懂而且不实用。
大家知道动规是由前一个状态推导出来的,而贪心是局部直接选最优的,对于刷题来说就够用了。
上述提到的背包问题,后序会详细讲解。
动态规划的解题步骤
做动规题目的时候,很多同学会陷入一个误区,就是以为把状态转移公式背下来,照葫芦画瓢改改,就开始写代码,甚至把题目AC之后,都不太清楚dp[i]表示的是什么。
这就是一种朦胧的状态,然后就把题给过了,遇到稍稍难一点的,可能直接就不会了,然后看题解,然后继续照葫芦画瓢陷入这种恶性循环中。
状态转移公式(递推公式)是很重要,但动规不仅仅只有递推公式。
对于动态规划问题,我将拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了!
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
一些同学可能想为什么要先确定递推公式,然后在考虑初始化呢?
因为一些情况是递推公式决定了dp数组要如何初始化!
后面的讲解中我都是围绕着这五点来进行讲解。
可能刷过动态规划题目的同学可能都知道递推公式的重要性,感觉确定了递推公式这道题目就解出来了。
其实 确定递推公式 仅仅是解题里的一步而已!
一些同学知道递推公式,但搞不清楚dp数组应该如何初始化,或者正确的遍历顺序,以至于记下来公式,但写的程序怎么改都通过不了。
后序的讲解的大家就会慢慢感受到这五步的重要性了。
动态规划应该如何debug
相信动规的题目,很大部分同学都是这样做的。
看一下题解,感觉看懂了,然后照葫芦画瓢,如果能正好画对了,万事大吉,一旦要是没通过,就怎么改都通过不了,对 dp数组的初始化,递推公式,遍历顺序,处于一种黑盒的理解状态。
写动规题目,代码出问题很正常!
找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的!
一些同学对于dp的学习是黑盒的状态,就是不清楚dp数组的含义,不懂为什么这么初始化,递推公式背下来了,遍历顺序靠习惯就是这么写的,然后一鼓作气写出代码,如果代码能通过万事大吉,通过不了的话就凭感觉改一改。
这是一个很不好的习惯!
做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果。
然后再写代码,如果代码没通过就打印dp数组,看看是不是和自己预先推导的哪里不一样。
如果打印出来和自己预先模拟推导是一样的,那么就是自己的递归公式、初始化或者遍历顺序有问题了。
如果和自己预先模拟推导的不一样,那么就是代码实现细节有问题。
这样才是一个完整的思考过程,而不是一旦代码出问题,就毫无头绪的东改改西改改,最后过不了,或者说是稀里糊涂的过了。
这也是我为什么在动规五步曲里强调推导dp数组的重要性。
可以自己先思考这三个问题:
- 这道题目我举例推导状态转移公式了么?
- 我打印dp数组的日志了么?
- 打印出来了dp数组和我想的一样么?
如果这灵魂三问自己都做到了,基本上这道题目也就解决了,或者更清晰的知道自己究竟是哪一点不明白,是状态转移不明白,还是实现代码不知道该怎么写,还是不理解遍历dp数组的顺序。
然后再问问题,目的性就很强了
2、斐波那契数
斐波那契数,通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是: F(0) = 0,F(1) = 1 F(n) = F(n - 1) + F(n - 2),其中 n > 1 给你n ,请计算 F(n) 。
示例 1:
- 输入:2
- 输出:1
- 解释:F(2) = F(1) + F(0) = 1 + 0 = 1
示例 2:
- 输入:3
- 输出:2
- 解释:F(3) = F(2) + F(1) = 1 + 1 = 2
示例 3:
- 输入:4
- 输出:3
- 解释:F(4) = F(3) + F(2) = 2 + 1 = 3
提示:
- 0 <= n <= 30
思路
先动态规划五部曲
1、确定dp数组以及下标的含义
dp[i]的定义为:第i个数的斐波那契数值是dp[i]
2、确定递推公式
题目已经把递推公式直接给我们了,dp[i] = dp[i - 1] + dp[i - 2]
3、dp数组如何初始化
题目中也把如何初始化给我们了
1 | dp[0] = 0; |
4、确定遍历顺序
从递归公式中可以看出dp[i]是依赖前一个dp和前一个的前一个来决定的,那么遍历顺序一定是从前到后遍历的。
5、举例子推导dp数组
按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导一下,当N为10的时候,dp数组应该是如下的数列:
0 1 1 2 3 5 8 13 21 34 55
如果代码写出来,发现结果不对,就把dp数组打印出来看看和我们推导的数列是不是一致的。
整体代码如下
1 | class Solution { |
3、爬楼梯
假设你正在爬楼梯。需要 n
阶你才能到达楼顶。
每次你可以爬 1
或 2
个台阶。你有多少种不同的方法可以爬到楼顶呢?
示例 1:
1 | 输入:n = 2 |
示例 2:
1 | 输入:n = 3 |
提示:
1 <= n <= 45
思路
爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。
那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层。
所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了。
我们来分析一下,动规五部曲:
定义一个一维数组来记录不同楼层的状态
- 确定dp数组以及下标的含义
dp[i]: 爬到第i层楼梯,有dp[i]种方法
- 确定递推公式
如何可以推出dp[i]呢?
从dp[i]的定义可以看出,dp[i] 可以有两个方向推出来。
首先是dp[i - 1],上i-1层楼梯,有dp[i - 1]种方法,那么再一步跳一个台阶不就是dp[i]了么。
还有就是dp[i - 2],上i-2层楼梯,有dp[i - 2]种方法,那么再一步跳两个台阶不就是dp[i]了么。
那么dp[i]就是 dp[i - 1]与dp[i - 2]之和!
这里有人会问,之前的路不会重复吗?不会因为路径不同就是不同的路径。
所以dp[i] = dp[i - 1] + dp[i - 2] .
在推导dp[i]的时候,一定要时刻想着dp[i]的定义,否则容易跑偏。
这体现出确定dp数组以及下标的含义的重要性!
- dp数组如何初始化
再回顾一下dp[i]的定义:爬到第i层楼梯,有dp[i]种方法。
那么i为0,dp[i]应该是多少呢,这个可以有很多解释,但基本都是直接奔着答案去解释的。
例如强行安慰自己爬到第0层,也有一种方法,什么都不做也就是一种方法即:dp[0] = 1,相当于直接站在楼顶。
但总有点牵强的成分。
那还这么理解呢:我就认为跑到第0层,方法就是0啊,一步只能走一个台阶或者两个台阶,然而楼层是0,直接站楼顶上了,就是不用方法,dp[0]就应该是0.
其实这么争论下去没有意义,大部分解释说dp[0]应该为1的理由其实是因为dp[0]=1的话在递推的过程中i从2开始遍历本题就能过,然后就往结果上靠去解释dp[0] = 1。
从dp数组定义的角度上来说,dp[0] = 0 也能说得通。
需要注意的是:题目中说了n是一个正整数,题目根本就没说n有为0的情况。
所以本题其实就不应该讨论dp[0]的初始化!
我相信dp[1] = 1,dp[2] = 2,这个初始化大家应该都没有争议的。
所以我的原则是:不考虑dp[0]如何初始化,只初始化dp[1] = 1,dp[2] = 2,然后从i = 3开始递推,这样才符合dp[i]的定义。
- 确定遍历顺序
从递推公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,遍历顺序一定是从前向后遍历的
- 举例推导dp数组
举例当n为5的时候,dp table(dp数组)应该是这样的
如果代码出问题了,就把dp table 打印出来,看看究竟是不是和自己推导的一样。
此时大家应该发现了,这不就是斐波那契数列么!
唯一的区别是,没有讨论dp[0]应该是什么,因为dp[0]在本题没有意义!
1 | // 常规方式 |
4、使用最小花费爬楼梯
给你一个整数数组 cost
,其中 cost[i]
是从楼梯第 i
个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
你可以选择从下标为 0
或下标为 1
的台阶开始爬楼梯。
请你计算并返回达到楼梯顶部的最低花费。
示例 1:
1 | 输入:cost = [10,15,20] |
示例 2:
1 | 输入:cost = [1,100,1,1,1,100,1,1,100,1] |
提示:
2 <= cost.length <= 1000
0 <= cost[i] <= 999
思路
1、确定dp数组以及下标的含义
使用动态规划,就要有一个数组来记录状态,本题只需要一个一维数组dp[i]就可以了。
dp[i]的定义:到达第i台阶所花费的最少体力为dp[i]
2、确定递推公式
可以有两个途径可以得到dp[i],一个是dp[i-1]一个是dp[i-2]
dp[i - 1] 跳到 dp[i] 需要花费 dp[i - 1] + cost[i - 1]。
dp[i - 2] 跳到 dp[i] 需要花费 dp[i - 2] + cost[i - 2]。
那么究竟是选从dp[i - 1]跳还是从dp[i - 2]跳呢?
一定是选最小的,所以dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
3、dp数组如何初始化
看一下递归公式,dp[i]由dp[i - 1],dp[i - 2]推出,既然初始化所有的dp[i]是不可能的,那么只初始化dp[0]和dp[1]就够了,其他的最终都是dp[0]dp[1]推出。
那么 dp[0] 应该是多少呢? 根据dp数组的定义,到达第0台阶所花费的最小体力为dp[0],那么有同学可能想,那dp[0] 应该是 cost[0],例如 cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] 的话,dp[0] 就是 cost[0] 应该是1。
这里就要说明本题力扣为什么改题意,而且修改题意之后 就清晰很多的原因了。
新题目描述中明确说了 “你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。” 也就是说 到达 第 0 个台阶是不花费的,但从 第0 个台阶 往上跳的话,需要花费 cost[0]。
所以初始化 dp[0] = 0,dp[1] = 0;
4、确定遍历顺序
最后一步,递归公式有了,初始化有了,如何遍历呢?
本题的遍历顺序其实比较简单,简单到很多同学都忽略了思考这一步直接就把代码写出来了。
因为是模拟台阶,而且dp[i]由dp[i-1]dp[i-2]推出,所以是从前到后遍历cost数组就可以了。
5、举例子推导dp数组
拿示例2:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] ,来模拟一下dp数组的状态变化,如下:
如果大家代码写出来有问题,就把dp数组打印出来,看看和如上推导的是不是一样的。
1 | class Solution { |
5、不同路径
一个机器人位于一个 m x n
网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
示例 1:
1 | 输入:m = 3, n = 7 |
示例 2:
1 | 输入:m = 3, n = 2 |
示例 3:
1 | 输入:m = 7, n = 3 |
示例 4:
1 | 输入:m = 3, n = 3 |
思路
机器人从(0,0)位置出发,到(m - 1, n - 1)终点。
按照动规五部曲来分析:
- 确定dp数组(dp table)以及下标的含义
dp[i][j] :表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径。
- 确定递推公式
想要求dp[i]j,只能有两个方向来推导出来,即dp[i - 1]j 和 dp[i]j - 1。
此时在回顾一下 dp[i - 1]j 表示啥,是从(0, 0)的位置到(i - 1, j)有几条路径,dp[i]j - 1同理。
那么很自然,dp[i]j = dp[i - 1]j + dp[i]j - 1,因为dp[i]j只有这两个方向过来。
- dp数组的初始化
如何初始化呢,首先dp[i]0一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0]j也同理。
所以初始化代码为:
1 | for (int i = 0; i < m; i++) dp[i][0] = 1; |
- 确定遍历顺序
这里要看一下递推公式dp[i]j = dp[i - 1]j + dp[i]j - 1,dp[i]j都是从其上方和左方推导而来,那么从左到右一层一层遍历就可以了。
这样就可以保证推导dp[i]j的时候,dp[i - 1]j 和 dp[i]j - 1一定是有数值的。
- 举例推导dp数组
如图所示:
1 | class Solution { |
6、不同路径2
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1 和 0 来表示。
示例 1:
- 输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
- 输出:2 解释:
- 3x3 网格的正中间有一个障碍物。
- 从左上角到右下角一共有 2 条不同的路径:
- 向右 -> 向右 -> 向下 -> 向下
- 向下 -> 向下 -> 向右 -> 向右
示例 2:
- 输入:obstacleGrid = [[0,1],[0,0]]
- 输出:1
提示:
- m == obstacleGrid.length
- n == obstacleGrid[i].length
- 1 <= m, n <= 100
- obstacleGrid[i][j] 为 0 或 1
思路
第一次接触这种题目的同学可能会有点懵,这有障碍了,应该怎么算呢?有障碍的话,其实就是标记对应的dp table(dp数组)保持初始值(0)就可以了。
动态规划五部曲
1、确定dp数组以及下标的含义
dpij表示从00出发,到ij有dpij条不同的路径
2、确定递推公式
dpij = dp i-1 j + dp i j-1
因为有了障碍所以ij如果是障碍的话就保持初始状态0
1 | if (obstacleGrid[i][j] == 0) { // 当(i, j)没有障碍的时候,再推导dp[i][j] |
3、dp数组如何初始化
之前行和列都初始化为1,但是现在有了障碍之后,障碍之前都是1,障碍之后都是0
如图:
4、确定遍历顺序
从递归公式dp[i]j = dp[i - 1]j + dp[i]j - 1 中可以看出,一定是从左到右一层一层遍历,这样保证推导dp[i][j]的时候,dp[i - 1]j 和 dp[i]j - 1一定是有数值。
代码如下:
1 | for (int i = 1; i < m; i++) { |
- 举例推导dp数组
拿示例1来举例如题:
对应的dp table 如图:
代码如下
1 | class Solution { |
7、整数拆分
给定一个正整数 n
,将其拆分为 k
个 正整数 的和( k >= 2
),并使这些整数的乘积最大化。
返回 你可以获得的最大乘积 。
示例 1:
1 | 输入: n = 2 |
示例 2:
1 | 输入: n = 10 |
提示:
2 <= n <= 58
思路
首先看这种题目,第一次的拆分会影响下一次拆分的决定,那么这时候会想到dp
那么来dp五部曲
- 确定dp数组(dp table)以及下标的含义
dp[i]:分拆数字i,可以得到的最大乘积为dp[i]。
dp[i]的定义将贯彻整个解题过程,下面哪一步想不懂了,就想想dp[i]究竟表示的是啥!
- 确定递推公式
可以想 dp[i]最大乘积是怎么得到的呢?
其实可以从1遍历j,然后有两种渠道得到dp[i].
一个是j * (i - j) 直接相乘。
一个是j * dp[i - j],相当于是拆分(i - j),对这个拆分不理解的话,可以回想dp数组的定义。
那有同学问了,j怎么就不拆分呢?
j是从1开始遍历,拆分j的情况,在遍历j的过程中其实都计算过了。那么从1遍历j,比较(i - j) j和dp[i - j] j 取最大的。递推公式:dp[i] = max(dp[i], max((i - j) j, dp[i - j] j));
也可以这么理解,j (i - j) 是单纯的把整数拆分为两个数相乘,而j dp[i - j]是拆分成两个以及两个以上的个数相乘。
如果定义dp[i - j] * dp[j] 也是默认将一个数强制拆成4份以及4份以上了。
所以递推公式:dp[i] = max({dp[i], (i - j) j, dp[i - j] j});
那么在取最大值的时候,为什么还要比较dp[i]呢?
因为在递推公式推导的过程中,每次计算dp[i],取最大的而已。
这里的 dp[i]
并不是“最终结果”,而是当前已知的最佳值。我们在 j
的循环中不断尝试不同的拆分方式,每试一种就更新一次 dp[i]
。
- dp的初始化
不少同学应该疑惑,dp[0] dp[1]应该初始化多少呢?
有的题解里会给出dp[0] = 1,dp[1] = 1的初始化,但解释比较牵强,主要还是因为这么初始化可以把题目过了。
严格从dp[i]的定义来说,dp[0] dp[1] 就不应该初始化,也就是没有意义的数值。
拆分0和拆分1的最大乘积是多少?
这是无解的。
这里我只初始化dp[2] = 1,从dp[i]的定义来说,拆分数字2,得到的最大乘积是1,这个没有任何异议!
- 确定遍历顺序
确定遍历顺序,先来看看递归公式:dp[i] = max(dp[i], max((i - j) j, dp[i - j] j));
dp[i] 是依靠 dp[i - j]的状态,所以遍历i一定是从前向后遍历,先有dp[i - j]再有dp[i]。
所以遍历顺序为:
1 | for (int i = 3; i <= n ; i++) { |
注意 枚举j的时候,是从1开始的。从0开始的话,那么让拆分一个数拆个0,求最大乘积就没有意义了。
j的结束条件是 j < i - 1 ,其实 j < i 也是可以的,不过可以节省一步,例如让j = i - 1,的话,其实在 j = 1的时候,这一步就已经拆出来了,重复计算,所以 j < i - 1
至于 i是从3开始,这样dp[i - j]就是dp[2]正好可以通过我们初始化的数值求出来。
更优化一步,可以这样:
1 | for (int i = 3; i <= n ; i++) { |
因为拆分一个数n 使之乘积最大,那么一定是拆分成m个近似相同的子数相乘才是最大的。
例如 6 拆成 3 3, 10 拆成 3 3 * 4。 100的话 也是拆成m个近似数组的子数 相乘才是最大的。
只不过我们不知道m究竟是多少而已,但可以明确的是m一定大于等于2,既然m大于等于2,也就是 最差也应该是拆成两个相同的 可能是最大值。
那么 j 遍历,只需要遍历到 n/2 就可以,后面就没有必要遍历了,一定不是最大值。
至于 “拆分一个数n 使之乘积最大,那么一定是拆分成m个近似相同的子数相乘才是最大的” 这个我就不去做数学证明了,感兴趣的同学,可以自己证明。
- 举例推导dp数组
举例当n为10 的时候,dp数组里的数值,如下:
整体代码如下
1 | class Solution { |
8、不同的二叉搜索树
给你一个整数 n
,求恰由 n
个节点组成且节点值从 1
到 n
互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。
示例 1:
1 | 输入:n = 3 |
示例 2:
1 | 输入:n = 1 |
提示:
1 <= n <= 19
思路
我们应该先举几个例子,画画图,看看有没有什么规律,如图:
n为1的时候有一棵树,n为2有两棵树,这个是很直观的。
来看看n为3的时候,有哪几种情况。
当1为头结点的时候,其右子树有两个节点,看这两个节点的布局,是不是和 n 为2的时候两棵树的布局是一样的啊!
(可能有同学问了,这布局不一样啊,节点数值都不一样。别忘了我们就是求不同树的数量,并不用把搜索树都列出来,所以不用关心其具体数值的差异)
当3为头结点的时候,其左子树有两个节点,看这两个节点的布局,是不是和n为2的时候两棵树的布局也是一样的啊!
当2为头结点的时候,其左右子树都只有一个节点,布局是不是和n为1的时候只有一棵树的布局也是一样的啊!
发现到这里,其实我们就找到了重叠子问题了,其实也就是发现可以通过dp[1] 和 dp[2] 来推导出来dp[3]的某种方式。
思考到这里,这道题目就有眉目了。
dp[3],就是 元素1为头结点搜索树的数量 + 元素2为头结点搜索树的数量 + 元素3为头结点搜索树的数量
元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量
元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量
元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量
有2个元素的搜索树数量就是dp[2]。
有1个元素的搜索树数量就是dp[1]。
有0个元素的搜索树数量就是dp[0]。
所以dp[3] = dp[2] dp[0] + dp[1] dp[1] + dp[0] * dp[2]
如图所示:
此时我们已经找到递推关系了,那么可以用动规五部曲再系统分析一遍。
- 确定dp数组(dp table)以及下标的含义
dp[i] : 1到i为节点组成的二叉搜索树的个数为dp[i]。
也可以理解是i个不同元素节点组成的二叉搜索树的个数为dp[i] ,都是一样的。
以下分析如果想不清楚,就来回想一下dp[i]的定义
- 确定递推公式
在上面的分析中,其实已经看出其递推关系, dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量]
j相当于是头结点的元素,从1遍历到i为止。
所以递推公式:dp[i] += dp[j - 1] * dp[i - j]; ,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量
- dp数组如何初始化
初始化,只需要初始化dp[0]就可以了,推导的基础,都是dp[0]。
那么dp[0]应该是多少呢?
从定义上来讲,空节点也是一棵二叉树,也是一棵二叉搜索树,这是可以说得通的。
从递归公式上来讲,dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量] 中以j为头结点左子树节点数量为0,也需要dp[以j为头结点左子树节点数量] = 1, 否则乘法的结果就都变成0了。
所以初始化dp[0] = 1
- 确定遍历顺序
首先一定是遍历节点数,从递归公式:dp[i] += dp[j - 1] * dp[i - j]可以看出,节点数为i的状态是依靠 i之前节点数的状态。
那么遍历i里面每一个数作为头结点的状态,用j来遍历。
1 | for (int i = 1; i <= n; i++) { |
- 举例推导dp数组
n为5时候的dp数组状态如图:
当然如果自己画图举例的话,基本举例到n为3就可以了,n为4的时候,画图已经比较麻烦了。
我这里列到了n为5的情况,是为了方便大家 debug代码的时候,把dp数组打出来,看看哪里有问题。
整体代码如下
1 | class Solution { |
9、动态规划:01背包理论基础
题目描述
小明是一位科学家,他需要参加一场重要的国际科学大会,以展示自己的最新研究成果。他需要带一些研究材料,但是他的行李箱空间有限。这些研究材料包括实验设备、文献资料和实验样本等等,它们各自占据不同的空间,并且具有不同的价值。
小明的行李空间为 N,问小明应该如何抉择,才能携带最大价值的研究材料,每种研究材料只能选择一次,并且只有选与不选两种选择,不能进行切割。
输入描述
第一行包含两个正整数,第一个整数 M 代表研究材料的种类,第二个正整数 N,代表小明的行李空间。
第二行包含 M 个正整数,代表每种研究材料的所占空间。
第三行包含 M 个正整数,代表每种研究材料的价值。
输出描述
输出一个整数,代表小明能够携带的研究材料的最大价值。
输入示
1 | 6 1 |
输出示例
1 | 5 |
提示信息
小明能够携带 6 种研究材料,但是行李空间只有 1,而占用空间为 1 的研究材料价值为 5,所以最终答案输出 5。
数据范围:
1 <= N <= 5000
1 <= M <= 5000
研究材料占用空间和价值都小于等于 1000
思路
正式开始讲解背包问题!
对于面试的话,其实掌握01背包和完全背包,就够用了,最多可以再来一个多重背包。
如果这几种背包,分不清,我这里画了一个图,如下:
除此以外其他类型的背包,面试几乎不会问,都是竞赛级别的了,leetcode上连多重背包的题目都没有,所以题库也告诉我们,01背包和完全背包就够用了。
而完全背包又是也是01背包稍作变化而来,即:完全背包的物品数量是无限的。
所以背包问题的理论基础重中之重是01背包,一定要理解透!
leetcode上没有纯01背包的问题,都是01背包应用方面的题目,也就是需要转化为01背包问题。
所以我先通过纯01背包问题,把01背包原理讲清楚,后续再讲解leetcode题目的时候,重点就是讲解如何转化为01背包问题了。
01背包
有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
这是标准的背包问题,以至于很多同学看了这个自然就会想到背包,甚至都不知道暴力的解法应该怎么解了。
这样其实是没有从底向上去思考,而是习惯性想到了背包,那么暴力的解法应该是怎么样的呢?
每一件物品其实只有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是O(2^n),这里的n表示物品数量。
所以暴力的解法是指数级别的时间复杂度。进而才需要动态规划的解法来进行优化!
在下面的讲解中,我举一个例子:
背包最大重量为4。
物品为:
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
问背包能背的物品最大价值是多少?
以下讲解和图示中出现的数字都是以这个例子为例。
二维dp数组01背包
依然动规五部曲分析一波。
1、确定dp数组以及下标的含义
我们需要使用二维数组,为什么呢?
因为有两个维度需要分别表示:物品 和 背包容量
如图,二维数组为 dp[i]j。
那么这里 i 、j、dp[i]j 分别表示什么呢?
i 来表示物品、j表示背包容量。
我们来尝试把上面的 二维表格填写一下。
动态规划的思路是根据子问题的求解推导出整体的最优解。
我们先看把物品0 放入背包的情况:
背包容量为0,放不下物品0,此时背包里的价值为0。
背包容量为1,可以放下物品0,此时背包里的价值为15.
背包容量为2,依然可以放下物品0 (注意 01背包里物品只有一个),此时背包里的价值为15。
背包容量为 3,上一行同一状态,背包只能放物品0,这次也可以选择物品1了,背包可以放物品1 或者 物品0,物品1价值更大,背包里的价值为20。
背包容量为 4,上一行同一状态,背包只能放物品0,这次也可以选择物品1了,背包可以放下物品0 和 物品1,背包价值为35。
以上举例,是比较容易看懂,我主要是通过这个例子,来帮助大家明确dp数组的含义。
上图中,我们看 dp[1][4] 表示什么意思呢。
任取 物品0,物品1 放进容量为4的背包里,最大价值是 dp[1][4]。
通过这个举例,我们来进一步明确dp数组的含义。
即dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
要时刻记着这个dp数组的含义,下面的一些步骤都围绕这dp数组的含义进行的,如果哪里看懵了,就来回顾一下i代表什么,j又代表什么。
2、确定递推公式
这里在把基本信息给出来:
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
对于递推公式,首先我们要明确有哪些方向可以推导出 dp[i][j]。
这里我们dp[1]4的状态来举例:
求取 dp[1]4 有两种情况:
- 放物品1
- 还是不放物品1
如果不放物品1, 那么背包的价值应该是 dp[0][4] 即 容量为4的背包,只放物品0的情况。
推导方向如图:
如果放物品1, 那么背包要先留出物品1的容量,目前容量是4,物品1 的容量(就是物品1的重量)为3,此时背包剩下容量为1。
容量为1,只考虑放物品0 的最大价值是 dp[0][1],这个值我们之前就计算过。
所以 放物品1 的情况 = dp[0][1] + 物品1 的价值,推导方向如图:
两种情况,分别是放物品1 和 不放物品1,我们要取最大值(毕竟求的是最大价值)
1 | dp[1][4] = max(dp[0][4], dp[0][1] + 物品1 的价值) |
以上过程,抽象化如下:
- 不放物品i:背包容量为j,里面不放物品i的最大价值是dp[i - 1][j]。
- 放物品i:背包空出物品i的容量后,背包容量为j - weight[i],dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]且不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值
递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3、dp数组如何初始化
关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。
首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。如图:
在看其他情况。
状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。
dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。
那么很明显当 j < weight[0]
的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。
当j >= weight[0]
时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。
1 | for (int i = 1; i < weight.size(); i++) { // 当然这一步,如果把dp数组预先初始化为0了,这一步就可以省略,但很多同学应该没有想清楚这一点。 |
此时dp数组初始化情况如图所示:
dp[0][j] 和 dp[i][0] 都已经初始化了,那么其他下标应该初始化多少呢?
其实从递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出dp[i][j] 是由左上方数值推导出来了,那么 其他下标初始为什么数值都可以,因为都会被覆盖。
初始-1,初始-2,初始100,都可以!
但只不过一开始就统一把dp数组统一初始为0,更方便一些。
如图:
4、确定遍历顺序
在如下图中,可以看出,有两个遍历的维度:物品与背包重量
那么问题来了,先遍历 物品还是先遍历背包重量呢?
其实都可以!! 但是先遍历物品更好理解。
那么我先给出先遍历物品,然后遍历背包重量的代码。
1 | // weight数组的大小 就是物品个数 |
选择 1:不选第 i
个物品
- 最大价值是:
dp[i - 1][j]
选择 2:选择第 i
个物品
- 先腾出
weight[i]
的空间 → 剩余容量是j - weight[i]
- 在前
i-1
个物品中,用这个剩余容量能获得的最大价值是:dp[i - 1][j - weight[i]]
- 加上当前物品的价值:
+ value[i]
- 所以总价值是:
dp[i - 1][j - weight[i]] + value[i]
5、举例推导dp数组
来看一下对应的dp数组的数值,如图:
最终结果就是dp[2][4]。
建议大家此时自己在纸上推导一遍,看看dp数组里每一个数值是不是这样的。
做动态规划的题目,最好的过程就是自己在纸上举一个例子把对应的dp数组的数值推导一下,然后在动手写代码!
很多同学做dp题目,遇到各种问题,然后凭感觉东改改西改改,怎么改都不对,或者稀里糊涂就改过了。
主要就是自己没有动手推导一下dp数组的演变过程,如果推导明白了,代码写出来就算有问题,只要把dp数组打印出来,对比一下和自己推导的有什么差异,很快就可以发现问题了。
1 | import java.util.*; |
滚动数组
今天我们就来说一说滚动数组,其实在前面的题目中我们已经用到过滚动数组了,就是把二维dp降为一维dp,一些录友当时还表示比较困惑。
那么我们通过01背包,来彻底讲一讲滚动数组!
接下来还是用如下这个例子来进行讲解
背包最大重量为4。
物品为:
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
问背包能背的物品最大价值是多少?
用一维dp数组(滚动数组)
对于背包问题其实状态都是可以压缩的。
在使用二维数组的时候,递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
其实可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);
与其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。
这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。
读到这里估计大家都忘了 dp[i][j]里的i和j表达的是什么了,i是物品,j是背包容量。
dp[i]j 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
一定要时刻记住这里i和j的含义,要不然很容易看懵了。
动规五部曲分析如下:
1、确定dp数组的含义
在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。
2、一维dp数组的递推公式
二维dp数组的递推公式为: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
一维dp数组,其实就上上一层 dp[i-1] 这一层 拷贝的 dp[i]来。
所以在 上面递推公式的基础上,去掉i这个维度就好。
递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
以下为分析:
dp[j]为 容量为j的背包所背的最大价值。
dp[j]可以通过dp[j - weight[i]]推导出来,dp[j - weight[i]]表示容量为j - weight[i]的背包所背的最大价值。
dp[j - weight[i]] + value[i]
表示 容量为 [j - 物品i重量] 的背包 加上 物品i的价值。(也就是容量为j的背包,放入物品i了之后的价值即:dp[j])
此时dp[j]有两个选择,一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1]j,即不放物品i,一个是取dp[j - weight[i]] + value[i]
,即放物品i,指定是取最大的,毕竟是求最大价值,
所以递归公式为:
1 | dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); |
可以看出相对于二维dp数组的写法,就是把dp[i][j]中i的维度去掉了
3、关于一维dp数组如何初始化
关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。
dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。
那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢?
看一下递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。
这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。
那么我假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。
4、一维dp数组遍历顺序
1 | for(int i = 0; i < weight.size(); i++) { // 遍历物品 |
这里大家发现和二维dp的写法中,遍历背包的顺序是不一样的!
二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。
为什么呢?
倒序遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!
举一个例子:物品0的重量weight[0] = 1,价值value[0] = 15
如果正序遍历
dp[1] = dp[1 - weight[0]] + value[0] = 15
dp[2] = dp[2 - weight[0]] + value[0] = 30
此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。
为什么倒序遍历,就可以保证物品只放入一次呢?
倒序就是先算dp[2]
dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)
dp[1] = dp[1 - weight[0]] + value[0] = 15
所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。
那么问题又来了,为什么二维dp数组遍历的时候不用倒序呢?
因为对于二维dp,dp[i][j]都是通过上一层即dp[i - 1]j计算而来,本层的dp[i][j]并不会被覆盖!
(如何这里读不懂,大家就要动手试一试了,空想还是不靠谱的,实践出真知!)
再来看看两个嵌套for循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?
不可以!
因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。
所以一维dp数组的背包在遍历顺序上和二维其实是有很大差异的!,这一点大家一定要注意。
- 举例推导dp数组
一维dp,分别用物品0,物品1,物品2 来遍历背包,最终得到结果如下:
1 | import java.util.*; |
10、分割等和子集
给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
注意: 每个数组中的元素不会超过 100 数组的大小不会超过 200
示例 1:
- 输入: [1, 5, 11, 5]
- 输出: true
- 解释: 数组可以分割成 [1, 5, 5] 和 [11].
示例 2:
- 输入: [1, 2, 3, 5]
- 输出: false
- 解释: 数组不能分割成两个元素和相等的子集.
提示:
- 1 <= nums.length <= 200
- 1 <= nums[i] <= 100
思路
这道题目初步看,和如下两题几乎是一样的,大家可以用回溯法,解决如下两题
- 698.划分为k个相等的子集
- 473.火柴拼正方形
这道题目是要找是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
那么只要找到集合里能够出现 sum / 2 的子集总和,就算是可以分割成两个相同元素和子集了。
本题是可以用回溯暴力搜索出所有答案的,但最后超时了,也不想再优化了,放弃回溯。
是否有其他解法可以解决此题。
本题的本质是,能否把容量为 sum / 2的背包装满。
dp五部曲
1、确定dp数组以及下标的含义
dp[j] 表示容量为j的背包,所背的物品价值最大可以为dp[j]。
如果背包所载重量为target, dp[target]就是装满 背包之后的总价值,因为 本题中每一个元素的数值既是重量,也是价值,所以,当 dp[target] == target 的时候,背包就装满了。
2、确定递推公式
01背包的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
本题,相当于背包里放入数值,那么物品i的重量是nums[i],其价值也是nums[i]。
所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
3、dp数组如何初始化
在01背包,一维dp如何初始化,已经讲过,
从dp[j]的定义来看,首先dp[0]一定是0。
如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。
这样才能让dp数组在递推的过程中取得最大的价值,而不是被初始值覆盖了。
本题题目中 只包含正整数的非空数组,所以非0下标的元素初始化为0就可以了。
4、确定遍历顺序
如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历!
1 | // 开始 01背包 |
5、举例推导dp数组
dp[j]的数值一定是小于等于j的。
如果dp[j] == j 说明,集合中的子集总和正好可以凑成总和j,理解这一点很重要。
用例1,输入[1,5,11,5] 为例,如图:
最后dp[11] == 11,说明可以将这个数组分割成两个子集,使得两个子集的元素和相等。
1 | class Solution { |
11、最后一块石头的重量2
1049. 最后一块石头的重量 II - 力扣(LeetCode)
有一堆石头,用整数数组 stones
表示。其中 stones[i]
表示第 i
块石头的重量。
每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x
和 y
,且 x <= y
。那么粉碎的可能结果如下:
- 如果
x == y
,那么两块石头都会被完全粉碎; - 如果
x != y
,那么重量为x
的石头将会完全粉碎,而重量为y
的石头新重量为y-x
。
最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0
。
示例 1:
1 | 输入:stones = [2,7,4,1,8,1] |
示例 2:
1 | 输入:stones = [31,26,33,21,40] |
提示:
1 <= stones.length <= 30
1 <= stones[i] <= 100
思路
首先这个也是dp问题,因为选择石头的过程是有上一次选择的结果推出来的。
首先这道题目就是分石头,将石头分成两部分尽可能相等,然后给出最后的差值就可以了,那么跟上一题很相似。
dp五部曲
1、确定dp数组以及下标的含义
dp[j]表示容量为j的背包最多能装下dp[j] 大的石头
2、递推公式
之前的01背包公式推导一下,
1 | dp[j] = Math.max(dp[j] , dp[j - stones[i]] + stones[i]) |
stones[i]表示石头的大小和价值
3、dp如何初始化
1 | dp[o] = 0; |
4、确定遍历顺序,第一层遍历物品,第二层倒序遍历价格
1 | for(int i = 0 ; i < stones.length ; i++){ |
整体代码如下
1 | class Solution { |
12、目标和
给你一个非负整数数组 nums
和一个整数 target
。
向数组中的每个整数前添加 '+'
或 '-'
,然后串联起所有整数,可以构造一个 表达式 :
- 例如,
nums = [2, 1]
,可以在2
之前添加'+'
,在1
之前添加'-'
,然后串联起来得到表达式"+2-1"
。
返回可以通过上述方法构造的、运算结果等于 target
的不同 表达式 的数目。
示例 1:
1 | 输入:nums = [1,1,1,1,1], target = 3 |
示例 2:
1 | 输入:nums = [1], target = 1 |
提示:
1 <= nums.length <= 20
0 <= nums[i] <= 1000
0 <= sum(nums[i]) <= 1000
-1000 <= target <= 1000
思路
首先这种题想到dp,因为第一次的选择会影响下一次的选择并且每次问题都是一样的。
既然为target,那么就一定有 left组合 - right组合 = target。
left + right = sum,而sum是固定的。right = sum - left
left - (sum - left) = target 推导出 left = (target + sum)/2 。
target是固定的,sum是固定的,left就可以求出来。
此时问题就是在集合nums中找出和为left的组合。
动态规划(二维dp数组)
假设加法的总和为x,那么减法对应的总和就是sum - x。
所以我们要求的是 x - (sum - x) = target
x = (target + sum) / 2
此时问题就转化为,用nums装满容量为x的背包,有几种方法。
这里的x,就是bagSize,也就是我们后面要求的背包容量。
大家看到(target + sum) / 2
应该担心计算的过程中向下取整有没有影响。
这么担心就对了,例如sum是5,target是2 的话其实就是无解的,所以:
1 | if ((target + sum) % 2 == 1) return 0; // 此时没有方案 |
同时如果target 的绝对值已经大于sum,那么也是没有方案的。
1 | if (abs(target) > sum) return 0; // 此时没有方案 |
因为每个物品(题目中的1)只用一次!
这次和之前遇到的背包问题不一样了,之前都是求容量为j的背包,最多能装多少。
本题则是装满有几种方法。其实这就是一个组合问题了。
1、确定dp数组以及下标的含义
先用 二维 dp数组求解本题,dp[i][j] :使用 下标为[0, i]的nums[i]能够凑满j(包括j)这么大容量的包,有dp[i][j]种方法。
再详细解释一下
假设你有一个数组:nums = [1, 1, 1, 1, 1]
,目标是凑出 target = 3
。
你可以把每个数前面加 +
或 -
,比如:
+1 +1 +1 -1 -1 = 1
+1 +1 -1 +1 -1 = 1
+1 +1 +1 +1 -1 = 3
✅
我们要找的是:有多少种方式能让最终结果等于 target
。
这个问题可以转化为一个 0-1 背包问题:
我们要从数组中选出一部分数字作为正数(比如加法部分),剩下的作为负数(减法部分)。
设:
- 所有正数之和为
x
- 所有负数之和为
sum - x
- 那么
x - (sum - x) = target
→x = (target + sum) / 2
所以问题变成:从数组中选出一些数,使它们的和等于 x
,有多少种选法?
这就是一个 “装满背包有几种方法” 的组合问题。
拆解 dp[i][j]
的
dp[i][j]
表示:
使用下标从0
到i
的这些数字(即前i+1
个数),
能够恰好凑出总和为j
的方案数。
我们来用一个具体例子说明:
示例:
1 | nums = [1, 2, 3]`,我们想凑出 `j = 3 |
我们定义 dp[i][j]
为:用 nums[0]
到 nums[i]
这些数,凑出和为 j
的方法数。
dp[2][3]
是什么意思?
用
nums[0], nums[1], nums[2]
(也就是 1, 2, 3)这三个数,凑出和为 3,有多少种方法?
答案是:
3
(直接用 3)1 + 2
(用前两个数)
所以 dp[2][3] = 2
dp[1][3]
呢
用
nums[0], nums[1]
(也就是 1 和 2),凑出和为 3
只有 1 种方法:1 + 2 = 3
→ dp[1][3] = 1
dp[0][1]
呢?
用
nums[0] = 1
,凑出和为 1
只有 1 种方法:选它 → dp[0][1] = 1
2、开始推导递推公式
我们先手动推导一下,这个二维数组里面的数值。
先只考虑物品0,如图:
(这里的所有物品,都是题目中的数字1)。
装满背包容量为0 的方法个数是1,即 放0件物品。
装满背包容量为1 的方法个数是1,即 放物品0。
装满背包容量为2 的方法个数是0,目前没有办法能装满容量为2的背包。
接下来 考虑 物品0 和 物品1,如图:
装满背包容量为0 的方法个数是1,即 放0件物品。
装满背包容量为1 的方法个数是2,即 放物品0 或者 放物品1。
装满背包容量为2 的方法个数是1,即 放物品0 和 放物品1。
其他容量都不能装满,所以方法是0。
接下来 考虑 物品0 、物品1 和 物品2 ,如图:
装满背包容量为0 的方法个数是1,即 放0件物品。
装满背包容量为1 的方法个数是3,即 放物品0 或者 放物品1 或者 放物品2。
装满背包容量为2 的方法个数是3,即 放物品0 和 放物品1、放物品0 和 物品2、放物品1 和 物品2。
装满背包容量为3的方法个数是1,即 放物品0 和 物品1 和 物品2。
通过以上举例,我们来看 dp[2][2] 可以有哪些方向推出来。
如图红色部分:
dp[2][2] = 3,即 放物品0 和 放物品1、放物品0 和 物品 2、放物品1 和 物品2, 如图所示,三种方法:
容量为2 的背包,如果不放 物品2 有几种方法呢?
有 dp[1][2] 种方法,即 背包容量为2,只考虑物品0 和 物品1 ,有 dp[1][2] 种方法,如图:
容量为2 的背包, 如果放 物品2 有几种方法呢?
首先 要在背包里 先把物品2的容量空出来, 装满 刨除物品2容量 的背包 有几种方法呢?
刨除物品2容量后的背包容量为 1。
此时装满背包容量为1 有 dp[1][1] 种方法,即: 不放物品2,背包容量为1,只考虑物品 0 和 物品 1,有 dp[1][1] 种方法。
如图:
有录友可能疑惑,这里计算的是放满 容量为2的背包 有几种方法,那物品2去哪了?
在上面图中,你把物品2补上就好,同样是两种方法。
dp[2][2] = 容量为2的背包不放物品2有几种方法 + 容量为2的背包放物品2有几种方法
所以 dp[2][2] = dp[1][2] + dp[1][1] ,如图:
以上过程,抽象化如下:
不放物品i:即背包容量为j,里面不放物品i,装满有dp[i - 1][j]中方法。
放物品i: 即:先空出物品i的容量,背包容量为(j - 物品i容量),放满背包有 dp[i - 1][j - 物品i容量] 种方法。
本题中,物品i的容量是nums[i],价值也是nums[i]。
递推公式:dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]];
考到这个递推公式,我们应该注意到,j - nums[i]
作为数组下标,如果 j - nums[i]
小于零呢?
说明背包容量装不下 物品i,所以此时装满背包的方法值 等于 不放物品i的装满背包的方法,即:dp[i][j] = dp[i - 1][j];
所以递推公式:
1 | if (nums[i] > j) dp[i][j] = dp[i - 1][j]; |
3、dp数组如何初始化
先明确递推的方向,如图,求解 dp[2][2] 是由 上方和左上方推出。
那么二维数组的最上行 和 最左列一定要初始化,这是递推公式推导的基础,如图红色部分:
关于dp[0][0]的值,在上面的递推公式讲解中已经讲过,装满背包容量为0 的方法数量是1,即 放0件物品。
那么最上行dp[0][j] 如何初始化呢?
dp[0][j]:只放物品0, 把容量为j的背包填满有几种方法。
只有背包容量为 物品0 的容量的时候,方法为1,正好装满。
其他情况下,要不是装不满,要不是装不下。
所以初始化:dp[0][nums[0]] = 1 ,其他均为0 。
表格最左列也要初始化,dp[i][0] : 背包容量为0, 放物品0 到 物品i,装满有几种方法。
都是有一种方法,就是放0件物品。
即 dp[i][0] = 1
但这里有例外,就是如果 物品数值就是0呢?
如果有两个物品,物品0为0, 物品1为0,装满背包容量为0的方法有几种。
- 放0件物品
- 放物品0
- 放物品1
- 放物品0 和 物品1
此时是有4种方法。
其实就是算数组里有t个0,然后按照组合数量求,即 2^t 。
初始化如下:
1 | int numZero = 0; |
- 确定遍历顺序
在明确递推方向时,我们知道 当前值 是由上方和左上方推出。
那么我们的遍历顺序一定是 从上到下,从左到右。
因为只有这样,我们才能基于之前的数值做推导。
例如下图,如果上方没数值,左上方没数值,就无法推出 dp[2][2]。
那么是先 从上到下 ,再从左到右遍历,例如这样:
1 | for (int i = 1; i < nums.size(); i++) { // 行,遍历物品 |
举例推导dp数组
输入:nums: [1, 1, 1, 1, 1], target: 3
bagSize = (target + sum) / 2 = (3 + 5) / 2 = 4
dp数组状态变化如下:
这么大的矩阵,我们是可以自己手动模拟出来的。
在模拟的过程中,既可以帮我们寻找规律,也可以帮我们验证 递推公式加遍历顺序是不是按照我们想象的结果推进的。
整体代码如下
1 | class Solution { |
动态规划(一维dp数组)
将二维dp数组压缩成一维dp数组,讲过滚动数组,原理是一样的,即重复利用每一行的数值。
既然是重复利用每一行,就是将二维数组压缩成一行。
dp[i][j] 去掉 行的维度,即 dp[j],表示:填满j(包括j)这么大容积的包,有dp[j]种方法。
2、确定递推公式
二维DP数组递推公式: dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]];
去掉维度i 之后,递推公式:dp[j] = dp[j] + dp[j - nums[i]]
,即:dp[j] += dp[j - nums[i]]
这个公式在后面在讲解背包解决排列组合问题的时候还会用到!
3、dp数组如何初始化
在上面 二维dp数组中,我们讲解过 dp[0][0] 初始为1,这里dp[0] 同样初始为1 ,即装满背包为0的方法有一种,放0件物品。
4、确定遍历顺序
遍历物品放在外循环,遍历背包在内循环,且内循环倒序(为了保证物品只使用一次)。
5、举例推导dp数组
输入:nums: [1, 1, 1, 1, 1], target: 3
bagSize = (target + sum) / 2 = (3 + 5) / 2 = 4
dp数组状态变化如下:
总结这个二维数组的意思就是用物品0到n装满背包有几种方法
注意两个排除:总数小于目标数排除,目标数+总数不为偶数也要排除,因为公式推导的过程中要除以2,如果不为偶数就会除不尽也就是说装不满背包,总会多一个或者少一个。
1 | class Solution { |
13、一和零
给你一个二进制字符串数组 strs
和两个整数 m
和 n
。
请你找出并返回 strs
的最大子集的长度,该子集中 最多 有 m
个 0
和 n
个 1
。
如果 x
的所有元素也是 y
的元素,集合 x
是集合 y
的 子集 。
示例 1:
1 | 输入:strs = ["10", "0001", "111001", "1", "0"], m = 5, n = 3 |
示例 2:
1 | 输入:strs = ["10", "0", "1"], m = 1, n = 1 |
提示:
1 <= strs.length <= 600
1 <= strs[i].length <= 100
strs[i]
仅由'0'
和'1'
组成1 <= m, n <= 100
思路
首先这种题目要么动态规划,要么贪心,要么回溯,回溯会超时,贪心没想到什么能局部推到全局,每次选最大的?可能0或者1会超,每次选0和1少的,可能不是最大的,不能推所以贪心不行了,那么思考dp、
首先分析这是什么背包问题
01背包:物品只有一个
完全背包:物品无限多个
多重背包:物品数量不一样
分析题目,strs是物品,里面的子集都是一个,那么背包容量是多少呢?现在有m个0和n个1的背包大量小
分析完之后可以知道这是01背包了。
但是背包容量不同,维度不同。
开始dp五部曲
1、确定dp数组以及下标的含义
dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]
2、确定递推公式
dp[i][j]可以由前一个strs里的字符串推导出来,str里的字符串有zeroNum个0,oneNum个1
dp[i][j]就可以是dp[i-zeroNum][j - oneNum] + 1
对比一下就会发现,字符串的zeroNum和oneNum相当于物品的重量(weight[i]),字符串本身的个数相当于物品的价值(value[i])。
这就是一个典型的01背包! 只不过物品的重量有了两个维度而已。
3、dp数组初始化
dp的行是0的个数,列是1的个数,也就是dp[m][n]
dp[0][0]是0
4、确定遍历顺序
这里的维度其实可以是三维数组,但是压缩成二维了,所以背包容量要从后往前。
那么本题也是,物品就是strs里的字符串,背包容量就是题目描述中的m和n。
代码如下
1 | for(String str : strs){//遍历物品 |
有同学可能想,那个遍历背包容量的两层for循环先后循序有没有什么讲究?
没讲究,都是物品重量的一个维度,先遍历哪个都行!
5、举例推导dp数组
以输入:[“10”,”0001”,”111001”,”1”,”0”],m = 3,n = 3为例
最后dp数组的状态如下所示:
1 | class Solution { |
番外总结
此时我们讲解了0-1背包的多种应用,
- 纯 0 - 1 背包 (opens new window)是求 给定背包容量 装满背包 的最大价值是多少。
- 416. 分割等和子集 (opens new window)是求 给定背包容量,能不能装满这个背包。
- 1049. 最后一块石头的重量 II (opens new window)是求 给定背包容量,尽可能装,最多能装多少
- 494. 目标和 (opens new window)是求 给定背包容量,装满背包有多少种方法。
- 本题是求 给定背包容量,装满背包最多有多少个物品。
14、完全背包理论基础
这个跟01背包的区别就是每件物品都有无限个,也就是可以放入背包多次,求将哪些物品放入背包,价值最大
同样leetcode上没有纯完全背包问题,都是需要完全背包的各种应用,需要转化成完全背包问题,所以我这里还是以纯完全背包问题进行讲解理论和原理。
用个例题讲解一下
背包最大重量为4,物品为:
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
每件商品都有无限个!
问背包能背的物品最大价值是多少?
开始动态规划五部曲
1、确定dp数组以及下标的含义
dp[i][j] 表示从下标为[0-i]的物品,每个物品可以取无限次,放进容量为j的背包,价值总和最大是多少
。
2、确定递推公式
这里在把基本信息给出来:
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
对于递推公式,首先我们要明确有哪些方向可以推导出 dp[i][j]。
这里依然拿dp[1][4]的状态来举例
求取 dp[1][4]
有两种情况:
- 放物品1
- 还是不放物品1
如果不放物品1, 那么背包的价值应该是 dp[0][4] 即 容量为4的背包,只放物品0的情况。
推导方向如图:
如果放物品1, 那么背包要先留出物品1的容量,目前容量是4,物品1 的容量(就是物品1的重量)为3,此时背包剩下容量为1。
容量为1,只考虑放物品0 和物品1 的最大价值是 dp[1][1]
, 注意 这里和01背包就不同了
01背包,背包先空流出物品1的容量,此时容量为1,只考虑放物品0的最大价值是dp[0][1]
,因为01背包每个物品只有一个,既然空出物品1,那么背包中也不会再有物品1。
简单来说就是,用dp[i][j] 和 dp[i-1][j - weight[i]] + value[i]
1 | for (int i = 0; i < n; i++) { |
但是在完全背包中,物品是可以放无限个,所以即使空出物品1空间容量,那背包中也可能还有物品1,所以此时我们依然考虑放物品1和物品0的最大价值dp[1][1]
而不是dp[0][1]
所以放物品1 的时候 = dp[1][1] + 物品1的价值
两种情况,分别是放物品1 和 不放物品1,我们要取最大值(毕竟求的是最大价值)
1 | dp[1][4] = max(dp[0][4], dp[1][1] + 物品1 的价值) |
以上过程,抽象化如下:
- 不放物品i:背包容量为j,里面不放物品i的最大价值是dp[i - 1][j]。
- 放物品i:背包空出物品i的容量后,背包容量为j - weight[i],dp[i][j - weight[i]] 为背包容量为j - weight[i]且不放物品i的最大价值,那么dp[i][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值
递推公式: dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]);
(注意,完全背包二维dp数组 和 01背包二维dp数组 递推公式的区别,01背包中是 dp[i - 1][j - weight[i]] + value[i])
)
3、dp数组如何初始化
关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。
首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。如图:
在看其他情况。
状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]);
可以看出有一个方向 i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。
dp[0][j],即:存放编号0的物品的时候,各个容量的背包所能存放的最大价值。
那么很明显当 j < weight[0]
的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。
当j >= weight[0]
时,dp[0][j] 如果能放下weight[0]的话,就一直装,每一种物品有无限个。
代码初始化如下:
1 | for (int i = 1; i < weight.size(); i++) { // 当然这一步,如果把dp数组预先初始化为0了,这一步就可以省略,但很多同学应该没有想清楚这一点。 |
4、确定遍历顺序
这里习惯性的先遍历物品,再遍历背包容量
1 | for (int i = 1; i < n; i++) { // 遍历物品 |
5、举例推导dp数组
以本篇举例数据为例,填满了dp二维数组如图:
因为 物品0 的性价比是最高的,而且 在完全背包中,每一类物品都有无限个,所以有无限个物品0,既然物品0 性价比最高,当然是优先放物品0。
1 | import java.util.Scanner; |
15、零钱兑换2
给你一个整数数组 coins
表示不同面额的硬币,另给一个整数 amount
表示总金额。
请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0
。
假设每一种面额的硬币有无限个。
题目数据保证结果符合 32 位带符号整数。
示例 1:
1 | 输入:amount = 5, coins = [1, 2, 5] |
示例 2:
1 | 输入:amount = 3, coins = [2] |
示例 3:
1 | 输入:amount = 10, coins = [10] |
提示:
1 <= coins.length <= 300
1 <= coins[i] <= 5000
coins
中的所有值 互不相同0 <= amount <= 5000
思路
自己的思路,首先这里的amount=5相当于背包容量,然后coins相当于物品的重量,然后之前是返回最大价值,那么这里是返回最大方式,该怎么办呢?
本题和纯完全背包不一样,纯完全背包是凑成背包最大价值是多少,而本题是要求凑成总金额的物品组合个数!
注意题目描述中是凑成总金额的硬币组合数,为什么强调是组合数呢?
例如示例一:
5 = 2 + 2 + 1
5 = 2 + 1 + 2
这是一种组合,都是 2 2 1。
如果问的是排列数,那么上面就是两种排列了。
组合不强调元素之间的顺序,排列强调元素之间的顺序。
dp五部曲
1、确定dp下标和含义
dp[i][j] i表示物品,j表示背包容量,dp[i][j]表示容量j的背包装[0...i]的物品的有dp[i][j]种方法
2、确定递推公式
首先二维dp数组的递推公式为
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
然后完全背包的dp是
dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i])
可以发现是后面那部分一个是i-1一个是i,主要原因就是完全背包物品有无限个。
那么这题求排列组合其实跟目标和问题是一样的,唯一的区别就是之前是01背包,现在是完全背包。
在目标和中推出的递推公式:dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]]
所以本题递推公式:dp[i][j] = dp[i - 1][j] + dp[i][j - nums[i]]
,区别依然是 dp[i - 1][j - nums[i]]
和 dp[i][j - nums[i]]
这个所以省略了很多,具体的推导过程看目标和
3、dp数组的初始化
那么二维数组的最上行 和 最左列一定要初始化,这是递推公式推导的基础,如图红色部分:
这里首先要关注的就是 dp[0][0] 应该是多少?
背包空间为0,装满「物品0」 的组合数有多少呢?
应该是 0 个, 但如果 「物品0」 的 数值就是0呢? 岂不是可以有无限个0 组合 和为0!
题目描述中说了1 <= coins.length <= 300
,所以不用考虑 物品数值为0的情况。·那么最上行dp[0][j] 如何初始化呢?
dp[0][j]的含义
:用「物品0」(即coins[0]) 装满 背包容量为j的背包,有几种组合方法。
如果 j 可以整除 物品0,那么装满背包就有1种组合方法。
初始化代码:
1 | for (int j = 0; j <= bagSize; j++) { |
最左列如何初始化呢?
dp[i][0] 的含义:用物品i(即coins[i]) 装满容量为0的背包 有几种组合方法。
都有一种方法,即不装。
所以 dp[i][0] 都初始化为1
4、确定遍历顺序
维DP数组的完全背包的两个for循环先后顺序是无所谓的。
先遍历背包,还是先遍历物品都是可以的。
5、举例打印dp数组
以amount为5,coins为:[2,3,5] 为例:
dp数组应该是这样的:
1 | 1 0 1 0 1 0 |
整体代码如下
1 | class Solution { |
换成一维dp的话也是跟之前一样
1、确定dp数组以及下标的含义
dp[j]:凑成总金额j的货币组合数为dp[j]
2、确定递推公式
将二维dp的递推公式:dp[i][j] = dp[i - 1][j] + dp[i][j-coins[i]]
压缩成一维
dp[j] += dp[j - coins[i]]
3、dp数组如何初始化
装满背包容量为0 的方法是1,即不放任何物品,dp[0] = 1
4、确定遍历顺序
本题中我们是外层for循环遍历物品(钱币),内层for遍历背包(金钱总额),还是外层for遍历背包(金钱总额),内层for循环遍历物品(钱币)呢?
之前我们是外层遍历背包,内层遍历物品并且内层要调转顺序
但是本题就不行了
因为纯完全背包求得装满背包的最大价值是多少,和凑成总和的元素有没有顺序没关系,即:有顺序也行,没有顺序也行!
而本题要求凑成总和的组合数,元素之间明确要求没有顺序。
所以纯完全背包是能凑成总和就行,不用管怎么凑的。
本题是求凑出来的方案个数,且每个方案个数是组合数。
那么本题,两个for循环的先后顺序可就有说法了。
我们先来看 外层for循环遍历物品(钱币),内层for遍历背包(金钱总额)的情况。
代码如下:
1 | for (int i = 0; i < coins.size(); i++) { // 遍历物品 |
假设:coins[0] = 1,coins[1] = 5。
那么就是先把1加入计算,然后再把5加入计算,得到的方法数量只有{1, 5}这种情况。而不会出现{5, 1}的情况。
所以这种遍历顺序中dp[j]里计算的是组合数!
如果把两个for交换顺序,代码如下:
1 | for (int j = 0; j <= amount; j++) { // 遍历背包容量 |
背包容量的每一个值,都是经过 1 和 5 的计算,包含了{1, 5} 和 {5, 1}两种情况。
此时dp[j]里算出来的就是排列数!
可能这里很多同学还不是很理解,建议动手把这两种方案的dp数组数值变化打印出来,对比看一看!(实践出真知)
5、举例推导dp数组
输入: amount = 5, coins = [1, 2, 5] ,dp状态图如下:
最后红色框dp[amount]为最终结果。
1 | class Solution { |
16、组合总和4
给你一个由 不同 整数组成的数组 nums
,和一个目标整数 target
。请你从 nums
中找出并返回总和为 target
的元素组合的个数。
题目数据保证答案符合 32 位整数范围。
示例 1:
1 | 输入:nums = [1,2,3], target = 4 |
示例 2:
1 | 输入:nums = [9], target = 3 |
提示:
1 <= nums.length <= 200
1 <= nums[i] <= 1000
nums
中的所有元素 互不相同1 <= target <= 1000
思路
跟上题得出,上题求的是组合数,这题求的是排列数,这里就要思考遍历顺序了,01背包由于只有一次使用机会,所以不会有排列数和组合数这种情况,但是完全背包就会出现,
如果是外层背包,内层物品,那么求的是排列数,
如果是外层物品,内层背包,求的是组合数。
思路理解之后开始dp
1、确定dp数的含义
dp[j]表示凑成j的排列个数
在这道题目中target就是背包容量,nums就是物品
2、确定递推公式
dp[i] += dp[i - nums[j]]
3、确定遍历顺序
1 | int[] dp = new int[target + 1]; |
4、初始化
初始化的话
dp[0] = 1
没有意义,不用强行解释,单纯是为了得到答案
5、举例画出dp
我们再来用示例中的例子推导一下:
整体代码如下
1 | class Solution { |
17、爬楼梯(进阶版)
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬至多m (1 <= m < n)个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
输入描述:输入共一行,包含两个正整数,分别表示n, m
输出描述:输出一个整数,表示爬到楼顶的方法数。
输入示例:3 2
输出示例:3
提示:
当 m = 2,n = 3 时,n = 3 这表示一共有三个台阶,m = 2 代表你每次可以爬一个台阶或者两个台阶。
此时你有三种方法可以爬到楼顶。
- 1 阶 + 1 阶 + 1 阶段
- 1 阶 + 2 阶
- 2 阶 + 1 阶
思路
之前没有讲背包问题,只是讲了一下爬楼梯最直接的动态规划方法(斐波那契),因为之前是至多只能爬两个台阶
这次可以爬m个台阶。
首先这是一个完全背包问题
那么n个台阶是目标数,物品数量就是m,那么这个题是求排列数,因为先爬1个楼梯再爬两个和先爬两个再爬一个是不一样的。
理清楚思路开始dp
1、确定dp下标和含义
dp[i] 表示组成 i 大小有dp[i]种方法
2、确定递推公式
dp[i] += dp[i - j]
这里的j是楼梯的大小,因为爬2楼有几种方法就是爬1楼有几种方法然后1楼爬一层
3、确定遍历顺序
1 | for(int i = 1 ; i <= n ; i++){ |
4、确定初始化
dp[0] = 1
5、举例子
这里跟上一题几乎一样就不举例子了
1 | import java.util.*; |
18、零钱兑换
给你一个整数数组 coins
,表示不同面额的硬币;以及一个整数 amount
,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1
。
你可以认为每种硬币的数量是无限的。
示例 1:
1 | 输入:coins = [1, 2, 5], amount = 11 |
示例 2:
1 | 输入:coins = [2], amount = 3 |
示例 3:
1 | 输入:coins = [1], amount = 0 |
提示:
1 <= coins.length <= 12
1 <= coins[i] <= 231 - 1
0 <= amount <= 104
思路
肯定是完全背包问题开始dp
1、确定dp数组以及下标
dp[j] 表示凑足总额为 j 所需要钱币最少得个数为dp[j]
2、确定递推公式
凑足总额为j - coins[i]的最少个数为dp[j - coins[i]],那么只需要加上一个钱币coins[i]即dp[j - coins[i]] + 1就是dp[j](考虑coins[i])
所以dp[j] 要取所有 dp[j - coins[i]] + 1 中最小的。
递推公式:dp[j] = min(dp[j - coins[i]] + 1, dp[j]);
因为要考虑用不用第i个硬币
3、dp数组初始化
首先走卒金额为0的所需钱币的数量一定是0,所以dp[0] = 0
那么其他下标都是对应最大值,因为每次都要取最小
4、确定遍历顺序
首先这个是组合数,所以组合数的话需要先遍历背包数再遍历物品数
5、举例推导dp数组
以输入:coins = [1, 2, 5], amount = 5为例
- 如果
dp[j - coins[i]]
是MAX_VALUE
,说明“前面这部分都凑不出来”,那加一个硬币也无济于事,跳过
1 | class Solution { |
19、完全平方数
给你一个整数 n
,返回 和为 n
的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1
、4
、9
和 16
都是完全平方数,而 3
和 11
不是。
示例 1:
1 | 输入:n = 12 |
示例 2:
1 | 输入:n = 13 |
提示:
1 <= n <= 104
思路
首先翻译一下题目:完全平方数就是物品(可以无限使用),凑成n,n就是背包,问凑满这个背包最少有多少物品
1、确定dp的含义和下标
dp[j] 表示凑满 j 最少需要 dp[j] 个平方数
2、确定递推公式
dp[j] 可以由dp[j - i i]推出, dp[j - i i] + 1 便可以凑成dp[j]。
此时我们要选择最小的dp[j],所以递推公式:dp[j] = min(dp[j - i * i] + 1, dp[j]);
3、dp数组如何初始化
dp[0]表示 和为0的完全平方数的最小数量,那么dp[0]一定是0
非0的值肯定就是最大值了
4、确定遍历顺序
这题求的是组合数和顺序数都无所谓因为是取最小值
组合数的话就需要外层物品,内层背包
1 | for(int i = 0 ; i )//写到这里发现这个物品好像没限制,所以试试先背包再物品 |
5、举例推导dp数组
已输入n为5例,dp状态图如下:
1 | class Solution { |
其他版本
1 | class Solution { |
20、单词拆分
给你一个字符串 s
和一个字符串列表 wordDict
作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s
则返回 true
。
注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
示例 1:
1 | 输入: s = "leetcode", wordDict = ["leet", "code"] |
示例 2:
1 | 输入: s = "applepenapple", wordDict = ["apple", "pen"] |
示例 3:
1 | 输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"] |
提示:
1 <= s.length <= 300
1 <= wordDict.length <= 1000
1 <= wordDict[i].length <= 20
s
和wordDict[i]
仅由小写英文字母组成wordDict
中的所有字符串 互不相同
思路
背包问题
单词就是物品,字符串s就是背包,单词能否组成字符串s,就是问物品能不能把背包装满。
拆分时可以重复使用字典中的单词,说明就是一个完全背包!
动规五部曲分析如下:
1、确定 dp 数组以及下标的含义
dp[i]
:表示字符串s
的前i
个字符(即s[0:i]
)是否可以被字典中的单词完全拆分。
dp[i] = true
:表示子串s[0...i-1]
(长度为i
)可以被拆分成字典中的单词。dp[0] = true
:空字符串总是可以被“拆分”(作为初始条件,方便后续递推)。
✅ 举例: 假设 s = "leetcode"
,i = 4
,那么 dp[4] = true
表示子串 "leet"
可以被字典中的单词拆分(实际上它本身就在字典里)。
2、确定递推公式
我们要判断 dp[i]
是否为 true
,可以尝试在 i
前面找一个位置 j
(j < i
),使得:
dp[j] == true
:表示前j
个字符可以被拆分 ✅子串
s[j:i]
(即从第j
个字符到第i-1
个字符)存在于字典中 ✅如果这两个条件都满足,那么
dp[i]
就可以设为true
。
3、dp数组如何初始化
从递推公式中可以看出,dp[i] 的状态依靠 dp[j] 是否为true,那么dp[0] 就是递推的根基,dp[0] 一定要为true,否则递推下去后面都是false了。
那么dp[0]有没有意义呢
dp[0] 表示如果字符串为空的话,说明出现在字典里。
但题目中说了“给定一个非空字符串s ” 所以测试数据中不会出现 i 为 0 的情况,那么dp[0] 初始为true完全就是为了推导公式
下标非0的dp[i] 初始化为false,只要没有被覆盖说明都是不可拆分为一个或多个在字典中出现的单词。
4、确定遍历顺序
题目中说是拆分为一个或多个在字典中出现的单词,所以这是完全背包。
还要讨论两层for循环的前后顺序。
如果求组合数,就是外层for遍历物品,内层遍历背包
如果是排列数,就是外层for遍历背包,内层遍历物品
本题求的事排列数!!!!为什么呢?
拿 s = “applepenapple”, wordDict = [“apple”, “pen”] 举例。
“apple”, “pen” 是物品,那么我们要求 物品的组合一定是 “apple” + “pen” + “apple” 才能组成 “applepenapple”。
“apple” + “apple” + “pen” 或者 “pen” + “apple” + “apple” 是不可以的,那么我们就是强调物品之间顺序。
所以说,本题一定是 先遍历 背包,再遍历物品。
5、举例推导dp[i]
以输入: s = “leetcode”, wordDict = [“leet”, “code”]为例,dp状态如图:
dp[s.size()]就是最终结果。
1 | class Solution { |
21、多重背包理论基础
对于多重背包,我在力扣上还没发现对应的题目,所以这里就做一下简单介绍,大家大概了解一下。
有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。
多重背包和01背包是非常像的, 为什么和01背包像呢?
每件物品最多有Mi件可用,把Mi件摊开,其实就是一个01背包问题了。
例如:
背包最大重量为10。
物品为:
重量 | 价值 | 数量 | |
---|---|---|---|
物品0 | 1 | 15 | 2 |
物品1 | 3 | 20 | 3 |
物品2 | 4 | 30 | 2 |
问背包能背的物品最大价值是多少?
和如下情况有区别么?
重量 | 价值 | 数量 | |
---|---|---|---|
物品0 | 1 | 15 | 1 |
物品0 | 1 | 15 | 1 |
物品1 | 3 | 20 | 1 |
物品1 | 3 | 20 | 1 |
物品1 | 3 | 20 | 1 |
物品2 | 4 | 30 | 1 |
物品2 | 4 | 30 | 1 |
毫无区别,这就转成了一个01背包问题了,且每个物品只用一次。
1 | import java.io.*; |
22、打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
1 | 输入:[1,2,3,1] |
示例 2:
1 | 输入:[2,7,9,3,1] |
提示:
1 <= nums.length <= 100
0 <= nums[i] <= 400
思路
大家如果刚接触这样的题目,会有点困惑,当前的状态我是偷还是不偷呢?
仔细一想,当前房屋偷与不偷取决于 前一个房屋和前两个房屋是否被偷了。
所以这里就更感觉到,当前状态和前面状态会有一种依赖关系,那么这种依赖关系都是动规的递推公式。
当然以上是大概思路,打家劫舍是dp解决的经典问题,接下来我们来动规五部曲分析如下:
- 确定dp数组(dp table)以及下标的含义
dp[i]:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i]。
- 确定递推公式
决定dp[i]的因素就是第i房间偷还是不偷。
如果偷第i房间,那么dp[i] = dp[i - 2] + nums[i] ,即:第i-1房一定是不考虑的,找出 下标i-2(包括i-2)以内的房屋,最多可以偷窃的金额为dp[i-2] 加上第i房间偷到的钱。
如果不偷第i房间,那么dp[i] = dp[i - 1],即考 虑i-1房,(注意这里是考虑,并不是一定要偷i-1房,这是很多同学容易混淆的点)
然后dp[i]取最大值,即dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
- dp数组如何初始化
从递推公式dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);可以看出,递推公式的基础就是dp[0] 和 dp[1]
从dp[i]的定义上来讲,dp[0] 一定是 nums[0],dp[1]就是nums[0]和nums[1]的最大值即:dp[1] = max(nums[0], nums[1]);
- 确定遍历顺序
dp[i] 是根据dp[i - 2] 和 dp[i - 1] 推导出来的,那么一定是从前到后遍历!
- 举例推导dp数组
以示例二,输入[2,7,9,3,1]为例。
红框dp[nums.size() - 1]为结果。
1 | class Solution { |
23、打家劫舍2
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
示例 1:
1 | 输入:nums = [2,3,2] |
示例 2:
1 | 输入:nums = [1,2,3,1] |
示例 3:
1 | 输入:nums = [1,2,3] |
提示:
1 <= nums.length <= 100
0 <= nums[i] <= 1000
思路
这题跟上一题很像
对于一个数组,成环的话主要有如下三种情况:
- 情况一:考虑不包含首尾元素
- 情况二:考虑包含首元素,不包含尾元素
- 情况三:考虑包含尾元素,不包含首元素
注意我这里用的是”考虑”,例如情况三,虽然是考虑包含尾元素,但不一定要选尾部元素! 对于情况三,取nums[1] 和 nums[3]就是最大的。
而情况二 和 情况三 都包含了情况一了,所以只考虑情况二和情况三就可以了。
所以这题就分成了两个打家劫舍1的问题,所以最后在讲两个部分比大小就行了。
1 | class Solution { |
24、打家劫舍3
小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root
。
除了 root
之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。
给定二叉树的 root
。返回 *在不触动警报的情况下 ,小偷能够盗取的最高金额* 。
示例 1:
1 | 输入: root = [3,2,3,null,3,null,1] |
示例 2:
1 | 输入: root = [3,4,5,1,3,null,1] |
提示:
- 树的节点数在
[1, 104]
范围内 0 <= Node.val <= 104
思路
对于树的话,首先就要想到遍历方式,前中后序(深度优先搜索)还是层序遍历(广度优先搜索)。
本题一定是要后序遍历,因为通过递归函数的返回值来做下一步计算。
与198.打家劫舍,213.打家劫舍II一样,关键是要讨论当前节点抢还是不抢。
如果抢了当前节点,两个孩子就不能动,如果没抢当前节点,就可以考虑抢左右孩子(注意这里说的是“考虑”)
1、暴力递归
1 | /** |
这个是可以的但是最后超时了。
因为偷和不偷的导致重复计算了两遍。
2、动态规划
这里可以使用一个长度为2的数组,记录当前节点偷和不偷所得到的最大金钱。
这道题目算是树形dp的入门题目,因为是在树上进行状态转移,我们在讲解二叉树的时候说过递归三部曲,那么下面以递归三部曲为框架,其中融合dp五部曲的内容进行思考
1、确定递归函数的参数和返回值。
这里我们要求一个节点 偷和不偷的两个状态所得到的金钱,那么返回值就是一个长度为2的数组
1 | int[] robTree(TreeNode cur); |
其实这里的返回数组就是dp数组。
所以dp数组的下标和含义:记录下标0记录不偷该节点所得到的最大金钱,下标为1记录偷该节点所得到的最大金钱。
那么长度为2的数组怎么标记树种每个节点的状态呢?
这是因为系统栈会保存每一层递归的参数。不理解的话看代码就知道了。
2、确定终止条件
在遍历过程中如果遇到空节点的话,就返回
1 | if(cur == null){ |
这也相当于dp数组的初始化
3、确定遍历顺序
首先明确的事使用后序遍历,因为要通过递归函数的返回值来做下一个计算。
通过递归左节点,得到左节点偷和不偷的金钱。
通过递归右节点,得到有节点偷和不偷的近期那。
1 | int[] left = robTree(cur.left); |
4、确定单层递归的逻辑
如果是偷当前节点,那么左右孩子就不能偷,val1 = cur->val + left[0] + right[0];
如果不偷当前节点,那么左右孩子就可以偷,至于到底偷不偷一定是选一个最大的,所以:val2 = max(left[0], left[1]) + max(right[0], right[1]);
最后当前节点的状态就是{val2, val1}; 即:{不偷当前节点得到的最大金钱,偷当前节点得到的最大金钱}
- 举例推导dp数组
以示例1为例,dp数组状态如下:(注意用后序遍历的方式推导)
最后头结点就是 取下标0 和 下标1的最大值就是偷得的最大金钱。
1 | /** |
25、买卖股票的最佳时机
给定一个数组 prices
,它的第 i
个元素 prices[i]
表示一支给定股票第 i
天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0
。
示例 1:
1 | 输入:[7,1,5,3,6,4] |
示例 2:
1 | 输入:prices = [7,6,4,3,1] |
提示:
1 <= prices.length <= 105
0 <= prices[i] <= 104
思路
贪心
由于这个是只买一次,那么只找左边最小,右边最大就行了,那么就直接贪心就好了
1 | class Solution { |
动态规划的思路
dp五部曲
1、确定dp数组以及下标的含义
dp[i][0]表示第i天持有股票所得最多现金
其实一开始现金是 0 ,那么加入第 i 天买入股票现金就是-prices[ i ] ,这是现金是一个负数
dp[i][1]表示第i天不持有股票所得最多现金
注意这里说的是持有,持有不代表当天买入,也有可能是昨天就买入了,今天保持持有状态
2、确定递推公式
如果第 i 天持有股票即dp[i][0] ,那么可以由两个状态推出来
- 第i - 1 天就 持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金
dp[i - 1][0]
- 第 i 天买入股票,所得现金就是买入今天的股票后所得的现金就是 - prices[ i ];
然后取最大的
如果第 i 天不持有股票即dp[i][1],也可以由两个状态推出来
- 第 i - 1 天就不持有股票,那么就保持现状
dp[i - 1][1]
- 第 i 天卖出股票,所得现金就是按照今天股票价格卖出后所得现金
prices[i] + dp[i - 1][0]
同样dp[i][1]取最大的
3、dp数组如何初始化
从递推公式看出来,要从dp[0][0]和dp[0][1]推导出来
那么dp[0][0]表示第0天持有股票,此时股票就一定是买入股票了,所以dp[0][0] -= prices[0]
dp[0][1]表示第0天不持有股票,不持有股票那么现金就是0,所以dp[0][1] = 0;
4、确定遍历顺序
肯定是从前往后
5、举例推导dp数组
以示例1,输入:[7,1,5,3,6,4]为例,dp数组状态如下:
dp[5][1]就是最终结果。
为什么不是dp[5][0]呢?
因为本题中不持有股票状态所得金钱一定比持有股票状态得到的多
1 | class Solution { |
26、买股票最佳时机2
122. 买卖股票的最佳时机 II - 力扣(LeetCode)
给你一个整数数组 prices
,其中 prices[i]
表示某支股票第 i
天的价格。
在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售。
返回 你能获得的 最大 利润 。
示例 1:
1 | 输入:prices = [7,1,5,3,6,4] |
示例 2:
1 | 输入:prices = [1,2,3,4,5] |
示例 3:
1 | 输入:prices = [7,6,4,3,1] |
提示:
1 <= prices.length <= 3 * 104
0 <= prices[i] <= 104
思路
这题可以用贪心,但是这题也可以用动规的解法。
这题和上题的唯一区别就是本题股票可以买卖多次, 注意,只有一只股票,所以再次购买前要出售掉之前的股票
在dp五部曲中,除了递推公式不同,其他都是一样的,所以这里重点讲递推公式
1、dp数组的含义
dp[i][0]表示第 i 天持有股票所得现金
dp[i][1]表示第 i 天不持有股票所得最多现金
如果第 i 天持有股票即 dp[i][0],那么可以由两个状态推出来
第 i 天买入股票,所得现金就是昨天不持有股票的所得现金减去 今天的股票价格
dp[i - 1][1] - prices[i]
第 i 天买入股票,所得现金就是昨天不持有股票的所得现金减去 今天的股票价格 即
dp[i - 1][1] - prices[i]
这里和第一题唯一不同的地方,就是推导dp[i] [0] 的时候,第i 天买入股票的时候
上一题,因为全程只能买卖一次,所以如果买入股票,那么第 i 天 持有股票即dp[i][0]一定就是-prices[i]
而本题,因为一只股票可以买卖多次,所以当第 i 天买入股票的时候,所持有的现金可能有之前买卖过的利润
那么第 i 天持有股票即dp[i][0]
,如果第 i 天买入股票,所得现金就是昨天不持有股票的所得现金 减去 今天的股票价格 dp[i - 1][1] - prices[i]
在来看看如果第 i 天不持有股票的情况即dp[i][1]的情况,依然可以由两个状态推出来
- 第i - 1 天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即
dp[i - 1][1]
- 第 i 天卖出股票,所得现金就是按照今天股票价格卖出后所得现金即
prices[i] + dp[i - 1][0]
1 |
|
27、买卖股票的最佳时机3
123. 买卖股票的最佳时机 III - 力扣(LeetCode)
给定一个数组,它的第 i
个元素是一支给定的股票在第 i
天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例 1:
1 | 输入:prices = [3,3,5,0,0,3,1,4] |
示例 2:
1 | 输入:prices = [1,2,3,4,5] |
示例 3:
1 | 输入:prices = [7,6,4,3,1] |
示例 4:
1 | 输入:prices = [1] |
提示:
1 <= prices.length <= 105
0 <= prices[i] <= 105
思路
这道题跟前面两个相比难了很多,至多买卖两次,这意味着可以买卖一次,可以两次,也可以不买卖
用dp五部曲详细分析一下
1、确定dp数组以及下标的含义
一天一共就有五个状态
0、没有操作
1、第一次持有股票
2、第一次不持有股票
3、第二次持有股票
4、第二次不持有股票
dp[i][j] 中 i 表示第 i 天,j 为[0 - 4]五个状态,dp[i][j]表示第 i 天状态 j 所剩最大现金。
2、确定递推公式
达到dp[i][1]状态,有两个具体操作
- 操作一:第 i 天买入股票了,那么
dp[i][1] = dp[i - 1][0] - prices[i]
- 操作二:第 i 天没哟操作,而是沿用前一天买入的状态,即
dp[i][1] = dp[i - 1][1]
然后两个取最大的
同理dp[i][2]
也是两个操作
然后一直推出剩下的状态部分
3、dp数组如何初始化
第0天没有操作,这个最容易想到,就是0
第0天做第一次买入的操作dp[0][1] = -prices[0]
第0天做第一次卖出的动作,就是0
第0天第二次买入操作,初始值应该是多少呢?
第二次买入依赖于第一次卖出的状态,其实相当于第0天第一次买入了,第一次卖出了,然后再买入一次(第二次买入),那么现在手头上没有现金,只要买入,现金就做相应的减少。
所以第二次买入操作,初始化为dp[0][3] = -prices[0]
同理第二次卖出初始化问为0
4、确定遍历顺序
从递归公式可以看出一定是从前往后的
5、举例推导dp数组
以输入[1,2,3,4,5]为例
大家可以看到红色框为最后两次卖出的状态。
现在最大的时候一定是卖出的状态,而两次卖出的状态现金最大一定是最后一次卖出。如果想不明白的录友也可以这么理解:如果第一次卖出已经是最大值了,那么我们可以在当天立刻买入再立刻卖出。所以dp[4][4]
已经包含了dp[4][2]
的情况。也就是说第二次卖出手里所剩的钱一定是最多的。
所以最终最大利润是dp[4][4]
整体代码如下
1 | // 版本一 |
28、买卖股票的最佳时机4
188. 买卖股票的最佳时机 IV - 力扣(LeetCode)
给你一个整数数组 prices
和一个整数 k
,其中 prices[i]
是某支给定的股票在第 i
天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 k
笔交易。也就是说,你最多可以买 k
次,卖 k
次。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例 1:
1 | 输入:k = 2, prices = [2,4,1] |
示例 2:
1 | 输入:k = 2, prices = [3,2,6,5,0,3] |
提示:
1 <= k <= 100
1 <= prices.length <= 1000
0 <= prices[i] <= 1000
思路
这里比上面更难了,这里要求至多k次交易
dp五部曲
1、确定dp数组以及下标的含义
我们是定义了一个二维dp数组,本题依然可以用一个二维dp数组
使用二维数组dp[i][j]: 第 i 天状态为 j ,所剩下的最大现金是dp[i][j]
j 的 状态表示为:
- 0 表示不操作
- 1 第一次买入
- 2 第一次卖出
- 3 第二次买入
- 4 第二次卖出
- …..
大家应该发现规律了吧 ,除了0以外,偶数就是卖出,奇数就是买入。
题目要求是至多有K笔交易,那么j的范围就定义为 2 * k + 1 就可以。
2、确定递推公式
还要强调一下:dp[i][1],表示的是第i天,买入股票的状态,并不是说一定要第i天买入股票,这是很多同学容易陷入的误区。
达到dp[i][1]状态,有两个具体操作:
- 操作一:第i天买入股票了,那么
dp[i][1] = dp[i - 1][0] - prices[i]
- 操作二:第i天没有操作,而是沿用前一天买入的状态,即:
dp[i][1] = dp[i - 1][1]
选最大的,所以 dp[i][1] = max(dp[i - 1][0] - prices[i], dp[i - 1][1]);
同理dp[i][2]也有两个操作:
- 操作一:第i天卖出股票了,那么
dp[i][2] = dp[i - 1][1] + prices[i]
- 操作二:第i天没有操作,沿用前一天卖出股票的状态,即:
dp[i][2] = dp[i - 1][2]
所以dp[i][2] = max(dp[i - 1][1] + prices[i], dp[i - 1][2])
同理可以类比剩下的状态,代码如下:
1 | for (int j = 0; j < 2 * k - 1; j += 2) { |
本题和动态规划:123.买卖股票的最佳时机III (opens new window)最大的区别就是这里要类比j为奇数是买,偶数是卖的状态。
- dp数组如何初始化
第0天没有操作,这个最容易想到,就是0,即:dp[0][0] = 0;
第0天做第一次买入的操作,dp[0][1] = -prices[0];
第0天做第一次卖出的操作,这个初始值应该是多少呢?
此时还没有买入,怎么就卖出呢? 其实大家可以理解当天买入,当天卖出,所以dp[0][2] = 0;
第0天第二次买入操作,初始值应该是多少呢?应该不少同学疑惑,第一次还没买入呢,怎么初始化第二次买入呢?
第二次买入依赖于第一次卖出的状态,其实相当于第0天第一次买入了,第一次卖出了,然后在买入一次(第二次买入),那么现在手头上没有现金,只要买入,现金就做相应的减少。
所以第二次买入操作,初始化为:dp[0][3] = -prices[0];
第二次卖出初始化dp[0][4] = 0;
所以同理可以推出dp[0][j]
当j为奇数的时候都初始化为 -prices[0]
代码如下:
1 | for (int j = 1; j < 2 * k; j += 2) { |
在初始化的地方同样要类比j为偶数是卖、奇数是买的状态。
- 确定遍历顺序
从递归公式其实已经可以看出,一定是从前向后遍历,因为dp[i],依靠dp[i - 1]的数值。
- 举例推导dp数组
以输入[1,2,3,4,5],k=2为例。
最后一次卖出,一定是利润最大的,`dp[prices.size() - 1
][2 * k]即红色部分就是最后求解。
1 | // 版本一: 三维 dp数组 |
29、买卖股票时机含冷冻期
309. 买卖股票的最佳时机含冷冻期 - 力扣(LeetCode)
给定一个整数数组,其中第 i 个元素代表了第 i 天的股票价格 。
设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):
- 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
- 卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
示例:
- 输入: [1,2,3,0,2]
- 输出: 3
- 解释: 对应的交易状态为: [买入, 卖出, 冷冻期, 买入, 卖出]
思路
1、dp数组以及下标的含义
dp[i][j],第 i 天状态为 j ,所剩的最多现金为dp[i][j]
具体可以分出如下四个状态:
- 状态一:持有股票状态(今天买入股票,或者是之前就买入了股票然后没有操作,一直持有)
- 不持有股票状态,这里就有两种卖出股票状态
- 状态二:保持卖出股票的状态(两天前就卖出了股票,度过一天冷冻期。或者是前一天就是卖出股票状态,一直没操作)
- 状态三:今天卖出股票
状态四:今天为冷冻状态,但冷冻状态不可持续,只有一天。
j的状态为:
- 0:状态一
- 1:状态二
- 2:状态三
- 3:状态四
很多题解为什么讲的比较模糊,是因为把这四个状态合并成三个状态了,其实就是把状态二和状态四合并在一起了。
从代码上来看确实可以合并,但从逻辑上分析合并之后就很难理解了,所以我下面的讲解是按照这四个状态来的,把每一个状态分析清楚。
如果大家按照代码随想录顺序来刷的话,会发现 买卖股票最佳时机 1,2,3,4 的题目讲解中
「今天卖出股票」我是没有单独列出一个状态的归类为「不持有股票的状态」,而本题为什么要单独列出「今天卖出股票」 一个状态呢?
因为本题我们有冷冻期,而冷冻期的前一天,只能是 「今天卖出股票」状态,如果是 「不持有股票状态」那么就很模糊,因为不一定是 卖出股票的操作。
如果没有按照 代码随想录 顺序去刷的录友,可能看这里的讲解 会有点困惑,建议把代码随想录本篇之前股票内容的讲解都看一下,领会一下每天 状态的设置。
注意这里的每一个状态,例如状态一,是持有股票股票状态并不是说今天一定就买入股票,而是说保持买入股票的状态即:可能是前几天买入的,之后一直没操作,所以保持买入股票的状态。
- 确定递推公式
达到买入股票状态(状态一)即:dp[i][0],有两个具体操作:
- 操作一:前一天就是持有股票状态(状态一),
dp[i][0] = dp[i - 1][0]
- 操作二:今天买入了,有两种情况
- 前一天是冷冻期(状态四),
dp[i - 1][3] - prices[i]
- 前一天是保持卖出股票的状态(状态二),
dp[i - 1][1] - prices[i]
- 前一天是冷冻期(状态四),
那么dp[i][0] = max(dp[i - 1][0], dp[i - 1][3] - prices[i], dp[i - 1][1] - prices[i]);
达到保持卖出股票状态(状态二)即:dp[i][1],有两个具体操作:
- 操作一:前一天就是状态二
- 操作二:前一天是冷冻期(状态四)
dp[i][1] = max(dp[i - 1][1], dp[i - 1][3]);
达到今天就卖出股票状态(状态三),即:dp[i][2] ,只有一个操作:
昨天一定是持有股票状态(状态一),今天卖出
即:dp[i][2] = dp[i - 1][0] + prices[i];
达到冷冻期状态(状态四),即:dp[i][3],只有一个操作:
昨天卖出了股票(状态三)
dp[i][3] = dp[i - 1][2];
综上分析,递推代码如下:
1 | dp[i][0] = max(dp[i - 1][0], max(dp[i - 1][3], dp[i - 1][1]) - prices[i]); |
- dp数组如何初始化
这里主要讨论一下第0天如何初始化。
如果是持有股票状态(状态一)那么:dp[0][0] = -prices[0]
,一定是当天买入股票。
保持卖出股票状态(状态二),这里其实从 「状态二」的定义来说 ,很难明确应该初始多少,这种情况我们就看递推公式需要我们给他初始成什么数值。
如果i为1,第1天买入股票,那么递归公式中需要计算 dp[i - 1][1] - prices[i]
,即 dp[0][1] - prices[1]
,那么大家感受一下 dp[0][1]
(即第0天的状态二)应该初始成多少,只能初始为0。想一想如果初始为其他数值,是我们第1天买入股票后 手里还剩的现金数量是不是就不对了。
今天卖出了股票(状态三),同上分析,dp[0][2]初始化为0,dp[0][3]也初始为0。
- 确定遍历顺序
从递归公式上可以看出,dp[i] 依赖于 dp[i-1],所以是从前向后遍历。
- 举例推导dp数组
以 [1,2,3,0,2] 为例,dp数组如下:
最后结果是取 状态二,状态三,和状态四的最大值,不少同学会把状态四忘了,状态四是冷冻期,最后一天如果是冷冻期也可能是最大值
1 | class Solution { |
30、买卖股票的最佳时机含手续费
714. 买卖股票的最佳时机含手续费 - 力扣(LeetCode)
给定一个整数数组 prices,其中第 i 个元素代表了第 i 天的股票价格 ;非负整数 fee 代表了交易股票的手续费用。
你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。
返回获得利润的最大值。
注意:这里的一笔交易指买入持有并卖出股票的整个过程,每笔交易你只需要为支付一次手续费。
示例 1:
- 输入: prices = [1, 3, 2, 8, 4, 9], fee = 2
- 输出: 8
解释: 能够达到的最大利润:
- 在此处买入 prices[0] = 1
- 在此处卖出 prices[3] = 8
- 在此处买入 prices[4] = 4
- 在此处卖出 prices[5] = 9
- 总利润: ((8 - 1) - 2) + ((9 - 4) - 2) = 8.
注意:
- 0 < prices.length <= 50000.
- 0 < prices[i] < 50000.
- 0 <= fee < 50000
思路
相对于动态规划:122.买卖股票的最佳时机II (opens new window),本题只需要在计算卖出操作的时候减去手续费就可以了,代码几乎是一样的。
唯一差别在于递推公式部分,所以本篇也就不按照动规五部曲详细讲解了,主要讲解一下递推公式部分。
这里重申一下dp数组的含义:
dp[i][0]
表示第i天持有股票所得最多现金。 dp[i][1] 表示第i天不持有股票所得最多现金
如果第i天持有股票即dp[i][0],
那么可以由两个状态推出来
- 第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:
dp[i - 1][0]
- 第i天买入股票,所得现金就是昨天不持有股票的所得现金减去 今天的股票价格 即:
dp[i - 1][1] - prices[i]
所以:dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]);
在来看看如果第i天不持有股票即dp[i][1]
的情况, 依然可以由两个状态推出来
- 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:
dp[i - 1][1]
- 第i天卖出股票,所得现金就是按照今天股票价格卖出后所得现金,注意这里需要有手续费了即:
`dp[i - 1][0] + prices[i] - fee
所以:dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i] - fee);
本题和动态规划:122.买卖股票的最佳时机II (opens new window)的区别就是这里需要多一个减去手续费的操作。
1 | /** |
31、最长递增子序列
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例 1:
- 输入:nums = [10,9,2,5,3,7,101,18]
- 输出:4
- 解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:
- 输入:nums = [0,1,0,3,2,3]
- 输出:4
示例 3:
- 输入:nums = [7,7,7,7,7,7,7]
- 输出:1
提示:
- 1 <= nums.length <= 2500
- -10^4 <= nums[i] <= 104
思路
首先通过本题大家要明确什么是子序列,“子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。”
子序列问题是动态规划解决的经典问题 当前下标 i 的递增子序列长度,其实和 i 之前的下标 j 的子序列长度有关系,那又是什么样的关系呢。
接下来,我们依然用动规五部曲来详细分析一下:
1、dp[i]的定义
本题中,定义dp数组的含义十分重要
dp[i]表示 i 之前包括 i 的以nums[i]结尾的最长递增子序列的长度
为什么一定表示以nums[i]结尾的最长递增子序列,因为我们在做 递增比较的时候,如果比较nums[j]和nums[i]的大小,那么两个递增子序列一定分别以nums[j]结尾和nums[i]为结尾, 要不然这个比较就没有意义了,不是尾部元素的比较那么 如何算递增呢。
2、 状态转移方程
位置i的最长升序子序列等于j从0到i-1各个位置的最长升序子序列 + 1 的最大值。
所以:if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
注意这里不是要dp[i] 与 dp[j] + 1进行比较,而是我们要取dp[j] + 1的最大值。
3、dp[i]的初始化
每一个i,对应的dp[i](即最长递增子序列)起始大小至少都是1.
4、确定遍历顺序
dp[i] 是有0到i-1各个位置的最长递增子序列 推导而来,那么遍历i一定是从前向后遍历。
j其实就是遍历0到i-1,那么是从前到后,还是从后到前遍历都无所谓,只要吧 0 到 i-1 的元素都遍历了就行了。 所以默认习惯 从前向后遍历。
遍历i的循环在外层,遍历j则在内层,代码如下:
1 | for (int i = 1; i < nums.size(); i++) { |
5、举例推导dp数组
输入:[0,1,0,3,2],dp数组的变化如下:
整体代码如下
1 | class Solution { |
32、最长连续递增序列
给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。
连续递增的子序列 可以由两个下标 l 和 r(l < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], …, nums[r - 1], nums[r]] 就是连续递增子序列。
示例 1:
- 输入:nums = [1,3,5,4,7]
- 输出:3
- 解释:最长连续递增序列是 [1,3,5], 长度为3。尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 5 和 7 在原数组里被 4 隔开。
示例 2:
- 输入:nums = [2,2,2,2,2]
- 输出:1
- 解释:最长连续递增序列是 [2], 长度为1。
提示:
- 0 <= nums.length <= 10^4
- -10^9 <= nums[i] <= 10^9
思路
dp五部曲
1、确定dp数组以及下标的含义
dp[i]:以下标i为结尾的连续递增的子序列长度为dp[i]。
注意这里的定义,一定是以下标i为结尾,并不是说一定以下标0为起始位置。
2、确定递推公式
如果nums[i] > nums[i - 1],那么以 i 为结尾的连续递增的子序列长度一定等于 以 i - 1为结尾的连续递增的子序列长度 + 1
即:dp[i] = dp[i - 1] + 1;
因为本题要求连续递增子序列,所以就只要比较nums[i]与nums[i - 1],而不用去比较nums[j]与nums[i] (j是在0到i之间遍历)。
既然不用j了,那么也不用两层for循环,本题一层for循环就行,比较nums[i] 和 nums[i - 1]。
3、dp数组如何初始化
以下标i为结尾的连续递增的子序列长度最少也应该是1,即就是nums[i]这一个元素。
所以dp[i]应该初始1;
4、确定遍历顺序
从前向后遍历
1 | for (int i = 1; i < nums.size(); i++) { |
5、举例推导dp数组
已输入nums = [1,3,5,4,7]为例,dp数组状态如下:
注意这里要取dp[i]里的最大值,所以dp[2]才是结果!
1 | class Solution { |
33、最长重复子数组
给两个整数数组 nums1
和 nums2
,返回 两个数组中 公共的 、长度最长的子数组的长度 。
示例 1:
1 | 输入:nums1 = [1,2,3,2,1], nums2 = [3,2,1,4,7] |
示例 2:
1 | 输入:nums1 = [0,0,0,0,0], nums2 = [0,0,0,0,0] |
提示:
1 <= nums1.length, nums2.length <= 1000
0 <= nums1[i], nums2[i] <= 100
思路
要求两个数组中最长重复子数组,如果是暴力的解法,只需要先两层for循环确定两个数组起始位置,然后 再来一个循环可以是for或者while,两个从位置开始比较,取得重复子数组的长度。
本题其实是动规解决的经典题目,要想到用二维数组可以记录两个字符串的所有比较情况,这样就比较好推递推公式了。
1、确定dp数组以及下标的含义
dp[i][j]:以下标i - 1 为结尾的A,和以下标j - 1 为结尾大的B,最长重复子数组长度为dp[i][j]。
那么dp[0][0]是什么含义呢?总不能是以下标-1为结尾的A数组吧
其实这个定义决定,在遍历dp数组的时候 i 和 j 都要从1 开始。
2、确定递推公式
根据dp[i][j]的定义,dp[i][j]的状态只能由dp[i - 1][j - 1]推导出来。
即当A[i - 1] 和B[j - 1]相等的时候,dp[i][j] = dp[i - 1][j - 1] + 1;
根据递推公式可以看出,遍历i 和 j 要从1开始!
3、dp数组如何初始化
根据dp[i][j]的定义,dp[i][0] 和dp[0][j]其实都是没有意义的!
但dp[i][0] 和dp[0][j]要初始值,因为 为了方便递归公式dp[i][j] = dp[i - 1][j - 1] + 1;
所以dp[i][0] 和dp[0][j]初始化为0。
举个例子A[0]如果和B[0]相同的话,dp[1][1] = dp[0][0] + 1,只有dp[0][0]初始为0,正好符合递推公式逐步累加起来。
4、确定遍历顺序
外层for循环遍历A,内层for循环遍历B。
那又有同学问了,外层for循环遍历B,内层for循环遍历A。不行么?
也行,一样的,我这里就用外层for循环遍历A,内层for循环遍历B了。
同时题目要求长度最长的子数组的长度。所以在遍历的时候顺便把dp[i][j]的最大值记录下来。
代码如下:
1 | for (int i = 1; i <= nums1.size(); i++) { |
- 举例推导dp数组
拿示例1中,A: [1,2,3,2,1],B: [3,2,1,4,7]为例,画一个dp数组的状态变化,如下:
1 | class Solution { |
34、最长公共子序列
给定两个字符串 text1
和 text2
,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0
。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
- 例如,
"ace"
是"abcde"
的子序列,但"aec"
不是"abcde"
的子序列。
两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。
示例 1:
1 | 输入:text1 = "abcde", text2 = "ace" |
示例 2:
1 | 输入:text1 = "abc", text2 = "abc" |
示例 3:
1 | 输入:text1 = "abc", text2 = "def" |
提示:
1 <= text1.length, text2.length <= 1000
text1
和text2
仅由小写英文字符组成。
思路
这题跟上题区别是相对顺序,不是子序列
动规五部曲
1、确定dp数组以及下标的含义
dp[i][j]:长度为[0, i - 1]的字符串text1与长度为[0,j - 1]的字符串text2的最长公共子序列为dp[i][j]
2、确定递推公式
主要就是两大情况:text1[i - 1]与text2[j - 1]相同,text1[i - 1]与text2[j - 1]不相同
如果相同,那么找到了一个公共元素,所以dp[i][j] = dp[i - 1][j - 1] + 1
如果不相同,那么就看看text1[0 , i - 2]与text2[0 , j - 1]的最长公共子序列和text1[0 , i - 1]和text2[0 , j - 2] 的最长公共子序列,取最大的。
相当于在说:“既然最后一个字符不匹配,那我看看少一个字符的情况,哪个能给我更长的公共子序列?”
举个例子
dp[1][2]
:"a"
和"ae"
的 LCS → 是 1(”a”)dp[2][1]
:"ab"
和"a"
的 LCS → 是 1(”a”)
取最大值 → dp[2][2] = 1
即:dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
代码如下:
1 | if(text1[i - 1] == text2[ j - 1]){ |
3、dp数组如何初始化
先看看dp[i] [0] 应该是多少呢?
text1[0, i -1]和空串的LCS = 0 所以dp[i][0] = 0
同理dp[0][j] = 0
4、确定遍历顺序
从递推公式可以看出,有三个方向可以推出dp,如图:
那么为了递推的过程中,这三个方向都是经过计算的数值,所以要从前向后,从上到下来遍历这个矩阵
- 举例推导dp数组
以输入:text1 = “abcde”, text2 = “ace” 为例,dp状态如图:
最后红框dp[text1.size()][text2.size()]为最终结果
1 | class Solution { |
35、不相交的线
在两条独立的水平线上按给定的顺序写下 nums1
和 nums2
中的整数。
现在,可以绘制一些连接两个数字 nums1[i]
和 nums2[j]
的直线,这些直线需要同时满足:
nums1[i] == nums2[j]
- 且绘制的直线不与任何其他连线(非水平线)相交。
请注意,连线即使在端点也不能相交:每个数字只能属于一条连线。
以这种方法绘制线条,并返回可以绘制的最大连线数。
示例 1:
1 | 输入:nums1 = [1,4,2], nums2 = [1,2,4] |
示例 2:
1 | 输入:nums1 = [2,5,1,2,5], nums2 = [10,5,2,1,5,2] |
示例 3:
1 | 输入:nums1 = [1,3,7,1,7,5], nums2 = [1,9,2,5,1] |
提示:
1 <= nums1.length, nums2.length <= 500
1 <= nums1[i], nums2[j] <= 2000
思路
直线不能相交,这就说明在字符串Nums1中找到一个字符串num2相同的子序列,且这个子序列不能改变相对顺序,只要相对顺序不改变,连接相同数字的直线就不会相交。
其实也就是说nums1和nums2的最长公共子序列是1,4,也就是求两个字符串的最长公共子序列的长度。所以跟上题是一样的。
1 | class Solution { |
36、最大子序和
给你一个整数数组 nums
,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组是数组中的一个连续部分。
示例 1:
1 | 输入:nums = [-2,1,-3,4,-1,2,1,-5,4] |
示例 2:
1 | 输入:nums = [1] |
示例 3:
1 | 输入:nums = [5,4,-1,7,8] |
提示:
1 <= nums.length <= 105
-104 <= nums[i] <= 104
思路
dp五部曲
1、确定dp数组下标含义
dp[i] 表示 nums[0 - i -1]最大子数组和
2、确定递推公式
- dp[i - 1] + nums[i],即:nums[i]加入当前连续子序列和
- nums[i],即:从头开始计算当前连续子序列和
一定是取最大的,所以dp[i] = max(dp[i - 1] + nums[i], nums[i]);
3、确定遍历顺序
肯定是从前往后
4、初始化
dp[0] = nums[0]
5、举例推导dp数组
以示例一为例,输入:nums = [-2,1,-3,4,-1,2,1,-5,4],对应的dp状态如下:
注意最后的结果可不是dp[nums.size() - 1]! ,而是dp[6]。
1 | class Solution { |
37、判断子序列
给定字符串 s 和 t ,判断 s 是否为 t 的子序列。
字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"
是"abcde"
的一个子序列,而"aec"
不是)。
进阶:
如果有大量输入的 S,称作 S1, S2, … , Sk 其中 k >= 10亿,你需要依次检查它们是否为 T 的子序列。在这种情况下,你会怎样改变代码?
致谢:
特别感谢 @pbrother 添加此问题并且创建所有测试用例。
示例 1:
1 | 输入:s = "abc", t = "ahbgdc" |
示例 2:
1 | 输入:s = "axc", t = "ahbgdc" |
提示:
0 <= s.length <= 100
0 <= t.length <= 10^4
- 两个字符串都只由小写字符组成。
思路
这套题也可以用双指针的思路来实现,时间复杂度也是On
这道题是 编辑距离的入门题目,因为从题意中我们也可以发现,只需要计算删除的情况,不用考虑增加和替换的情况。
所以掌握本题的动态规划解法是对后面要学的编辑距离的题目打下基础
dp五部曲分析如下
1、确定dp数组以及下标的含义
dp[i][j]表示以下标i - 1为结尾的字符串s , 和以下标j - 1为结尾的字符串 t ,相同子序列的长度为dp[i][j]
注意这里是判断s是否为t的子序列。即 t 的长度是大于等于s的。
2、确定递推公式
在确定递推公式的时候,首先要考虑如何做出如下两种操作,整理如下
- if (s[i - 1] == t[j - 1])
- t中找到了一个字符在s中也出现了
- if (s[i - 1] != t[j - 1])
- 相当于t要删除元素,继续匹配
if (s[i - 1] == t[j - 1]),那么dp[i][j] = dp[i - 1][j - 1] + 1;
,因为找到了一个相同的字符,相同子序列长度自然要在dp[i-1][j-1]
的基础上加1
if (s[i - 1] != t[j - 1])
,此时相当于t要删除元素,t如果把当前元素t[j - 1]删除,那么`dp[i][j]
的数值就是 看s[i - 1]与 t[j - 2]的比较结果了
即dp[i][j] = dp[i][j - 1]
3、dp数组如何初始化
从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1],所以dp[0][0]和dp[i][0]是一定要初始化的。
这里大家已经可以发现,在定义dp[i][j]含义的时候为什么要**表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]**。
因为这样的定义在dp二维矩阵中可以留出初始化的区间,如图:
如果要是定义的dp[i][j]是以下标i为结尾的字符串s和以下标j为结尾的字符串t,初始化就比较麻烦了。
dp[i][0] 表示以下标i-1为结尾的字符串,与空字符串的相同子序列长度,所以为0. dp[0][j]同理。
4、确定遍历顺序
同理从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1]
,那么遍历顺序也应该是从上到下,从左到右
如图所示:
- 举例推导dp数组
以示例一为例,输入:s = “abc”, t = “ahbgdc”,dp状态转移图如下:
dp[i][j]表示以下标i-1为结尾的字符串s和以下标j-1为结尾的字符串t 相同子序列的长度,所以如果`dp[s.size()][t.size()]
与 字符串s的长度相同说明:s与t的最长相同子序列就是s,那么s 就是 t 的子序列。
图中`dp[s.size()][t.size()] = 3
, 而s.size() 也为3。所以s是t 的子序列,返回true。
1 | class Solution { |
38、不同的子序列
给你两个字符串 s
和 t
,统计并返回在 s
的 子序列 中 t
出现的个数。
测试用例保证结果在 32 位有符号整数范围内。
示例 1:
1 | 输入:s = "rabbbit", t = "rabbit" |
示例 2:
1 | 输入:s = "babgbag", t = "bag" |
提示:
1 <= s.length, t.length <= 1000
s
和t
由英文字母组成
思路
这道题目如果不是子序列,而是要求连续序列的,那就可以考虑用KMP。
这道题目相对于72.编辑距离,简单了不少,因为本题相当于只有删除操作,不用考虑替换增加之类的。
但相对于上一题有难度了,这道题目双指针法做不了,开始dp五部曲。
1、确定dp数组以及下标的含义
dp[i][j]:以i - 1为结尾的s子序列中出现以j - 1为结尾的t的个数为dp[i][j]
2、确定递推公式
这一类问题,基本是要分析两种情况
- s[i - 1] 与 t[j - 1]相等
- s[i - 1] 与 t[j - 1] 不相等
当s[i - 1] 与 t[j - 1]相等时,dp[i][j]可以有两部分组成。
一部分是用s[i - 1]来匹配,那么个数为dp[i - 1][j - 1]。
即不需要考虑当前s子串和t子串的最后一位字母,所以只需要 dp[i-1][j-1]。
一部分是不用s[i - 1]来匹配,个数为dp[i - 1][j]
为什么还要考虑不用s[i - 1]来匹配
例如: s:bagg 和 t:bag ,s[3] 和 t[2]是相同的,但是字符串s也可以不用s[3]来匹配,即用s[0]s[1]s[2]组成的bag。
当然也可以用s[3]来匹配,即:s[0]s[1]s[3]组成的bag。
所以当s[i - 1] 与 t[j - 1]相等时,dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
当s[i - 1] 与 t[j - 1]不相等时,dp[i][j]
只有一部分组成,不用s[i - 1]来匹配(就是模拟在s中删除这个元素),即:`dp[i - 1
][j]
所以将两种情况加起来就是最终的递推公式
所以递推公式为:dp[i][j] = dp[i - 1][j];
这里可能有录友还疑惑,为什么只考虑 “不用s[i - 1]来匹配” 这种情况, 不考虑 “不用t[j - 1]来匹配” 的情况呢。
这里大家要明确,我们求的是 s 中有多少个 t,而不是 求t中有多少个s,所以只考虑 s中删除元素的情况,即 不用s[i - 1]来匹配 的情况。
- dp数组如何初始化
从递推公式dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
和 dp[i][j] = dp[i - 1][j];
中可以看出 dp[i][j]
是从上方和左上方推导而来,如图:,那么 dp[i][0] 和dp[0][j]是一定要初始化的。
每次当初始化的时候,都要回顾一下dp[i][j]
的定义,不要凭感觉初始化。
dp[i][0]
表示什么呢?
dp[i][0]
表示:以i-1为结尾的s可以随便删除元素,出现空字符串的个数。
那么dp[i][0]
一定都是1,因为也就是把以i-1为结尾的s,删除所有元素,出现空字符串的个数就是1。
再来看dp[0][j],dp[0][j]:
空字符串s可以随便删除元素,出现以j-1为结尾的字符串t的个数。
那么`dp[0
][j]一定都是0,s如论如何也变成不了t。
最后就要看一个特殊位置了,即:`dp[0][0]
应该是多少。
dp[0
][0]应该是1,空字符串s,可以删除0个元素,变成空字符串t。
初始化分析完毕,代码如下:
1 | vector<vector<long long>> dp(s.size() + 1, vector<long long>(t.size() + 1)); |
- 确定遍历顺序
从递推公式`dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]; 和 dp[i][j] = dp[i - 1][j];
中可以看出dp[i][j]
都是根据左上方和正上方推出来的。
所以遍历的时候一定是从上到下,从左到右,这样保证dp[i][j]可以根据之前计算出来的数值进行计算。
代码如下:
1 | for (int i = 1; i <= s.size(); i++) { |
- 举例推导dp数组
以s:”baegg”,t:”bag”为例,推导dp数组状态如下:
如果写出来的代码怎么改都通过不了,不妨把dp数组打印出来,看一看,是不是这样的。
1 | class Solution { |
39、两个字符串的删除操作
583. 两个字符串的删除操作 - 力扣(LeetCode)
给定两个单词 word1
和 word2
,返回使得 word1
和 word2
相同所需的最小步数。
每步 可以删除任意一个字符串中的一个字符。
示例 1:
1 | 输入: word1 = "sea", word2 = "eat" |
示例 2:
1 | 输入:word1 = "leetcode", word2 = "etco" |
提示:
1 <= word1.length, word2.length <= 500
word1
和word2
只包含小写英文字母
思路
动态规划
本题和上题相比,其实就是两个字符串都可以删除,情况虽说复杂一些,但整体思路是不变的。
这次是两个字符串可以互删了,这种题目也知道用动态规划的思路来解,动规五部曲
1、确定dp数组以及下标的含义
dp[i][j]:以i - 1为结尾的字符串word1,和以j - 1为结尾的字符串word2,想要达到相等所需要删除元素的最少次数
2、确定递推公式
- 当word1[i - 1] 和 word2[ j - 1]相同的时候
- 当不同的时候
相同的时候
dp[i][j] = dp [i - 1][j - 1]
不同的时候有三种情况
1、删除word1[i - 1] 最少操作次数为dp[i - 1][j] + 1
2、删除word2[j - 1],最少操作次数为dp[i][j - 1] + 1
3、同时删除两个dp[i - 1][j - 1] + 2
那最后是取最小值
因为dp[i][j - 1] + 1 = dp[i - 1][j - 1] + 2
所以递推公式可以简化为dp[i][j] = min(dp[i - 1][j] + 1,dp[i][j - 1] + 1)
3、dp数组如何初始化
很明显dp[i][0] = i
因为要让两个字符串相同就要把i全删除
同理dp[0][j] = j
4、确定遍历顺序
遍历一定是从上到下,从左到右的。
5、举例推导dp数组
以word1:”sea”,word2:”eat”为例,推导dp数组状态图如下:
1 | class Solution { |
40、编辑距离
给你两个单词 word1
和 word2
, 请返回将 word1
转换成 word2
所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
示例 1:
1 | 输入:word1 = "horse", word2 = "ros" |
示例 2:
1 | 输入:word1 = "intention", word2 = "execution" |
提示:
0 <= word1.length, word2.length <= 500
word1
和word2
由小写英文字母组成
思路
这是用dp能解决的题目
dp五部曲
1、确定dp数组以及下标的含义
dp[i][j]表示以下标i - 1为结尾的字符串word1,和以下标j - 1为结尾的字符串word2,最近编辑距离为dp[i][j]
2、确定递推公式
在确定递推公式的时候,首先要考虑清楚编辑的几种操作
1 | if (word1[i - 1] == word2[j - 1]) |
也就是如上4种情况。
如果两个字符串相等,那么说明不用任何编辑,dp[i][j]就等于dp[i - 1][j - 1]
那么如果不相等就需要编辑了,如何编辑呢?
- 操作一:word1删除一个元素,那么就是以下标I - 2为结尾的word1与 j - 1为结尾的word2的最近编辑距离在加上一个操作。
即dp[i][j] = dp[i - 1][j] + 1
- 操作二:word2删除一个元素,那么就是
dp[i][j] = dp[i][ j - 1] + 1
那怎么都是删除元素,增加元素怎么处理。
增加元素相当于另一个word删除一个元素。
- 操作三:替换元素,只需要一个操作就能让word1[i - 1]和word2[j - 1]相同
所以dp[i][j] = dp[i - 1][j - 1] + 1
3、dp数组初始化
再回顾一下`dp[i
][j]的定义:
dp[i][j] `表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为`dp[i][j]
。
那么`dp[i][0] 和 dp[0][j]
表示什么呢?
dp[i][0] :以下标i-1为结尾的字符串word1,和空字符串word2,最近编辑距离为dp[i][0]。
那么dp[i][0]就应该是i,对word1里的元素全部做删除操作,即:dp[i][0] = i;
同理dp[0][j] = j;
4、确定遍历顺序
从左到右从上到下
5、举例推导
以示例1为例,输入:word1 = "horse", word2 = "ros"
为例,dp矩阵状态图如下:
1 | class Solution { |
41、回文子串
给你一个字符串 s
,请你统计并返回这个字符串中 回文子串 的数目。
回文字符串 是正着读和倒过来读一样的字符串。
子字符串 是字符串中的由连续字符组成的一个序列。
示例 1:
1 | 输入:s = "abc" |
示例 2:
1 | 输入:s = "aaa" |
提示:
1 <= s.length <= 1000
s
由小写英文字母组成
思路
动态规划
1、确定dp数组以及下标的含义
做了很多这种子序列相关的题目,在定义dp数组的时候,很自然就会想到,题目球什么,就咋那么定义dp数组。
绝大多数题目确实是这样,不过本题如果我们定义,dp[i] 为 下标i结尾的字符串有 dp[i]个回文串的话,我们会发现很难找到递归关系。
dp[i] 和 dp[i-1] ,dp[i + 1] 看上去都没啥关系。
所以我们要看回文串的性质。 如图:
我们在判断字符串S是否是回文,那么如果我们知道 s[1],s[2],s[3] 这个子串是回文的,那么只需要比较 s[0]和s[4]这两个元素是否相同,如果相同的话,这个字符串s 就是回文串。
那么此时我们是不是能找到一种递归关系,也就是判断一个子字符串(字符串下标范围[i,j])是否回文,依赖于,子字符串(下标范围[i + 1, j - 1])) 是否是回文。
所以为了明确这种递归关系,我们的dp数组是要定义成一位二维dp数组。
布尔类型的dp[i][j]:
表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]
为true,否则为false。
2、确定递推公式
在确定递推公式时,要分析如下几种情况。
整体上是两种,就是s[i]与s[j]相等,s[i]与s[j]不相等这两种。
当不相等,那没啥好说的了,一定是false。
如果相等的时候,就复杂一点了,有如下三种情况
- 情况一:下标 i 和 j 相同,同一个字符例如a,当然是回文子串
- 情况二:下标i 和 j 相差为 1,例如 aa ,也是回文
- 情况三:下标 i 与 j 相差大于1的时候,例如cabac,此时s[ i ] 和s[ j ]已经相同了,我们看到 i 到 j 区间是不是回文子串就看aba是不是回文就可以了,那么aba区间就是 i +1与 j - 1区间,这个区间是不是回文就看dp[i + 1] [j - 1]是否为true
递归公式如下
1 | if (s[i] == s[j]) { |
result就是统计回文子串的数量。
注意这里我没有列出当s[i]与s[j]不相等的时候,因为在下面dp[i][j]
初始化的时候,就初始为false。
3、dp数组初始化
初始化为false
4、确定遍历顺序
这次遍历顺序可以看出来,情况三是根据dp[i+1][j - 1]是否为true来对dp[i][j]赋值的。
dp[i + 1][j - 1]在dp[i][j]的左下角
如图:
如果这个矩阵是从上到下,从左到右遍历,那么会用到没有计算过的dp[i + 1][j - 1]
,也就是根据不确定是不是回文的区间来判断是不是回文,所以肯定不对。
所以一定要从下到上,从左到右遍历,这样保证dp[i + 1][j - 1]都是经过计算的
有的代码实现是优先遍历列,然后遍历行,也是一个道路,都是为了保证dp[i +1][j - 1]
都是经过计算的。
1 | for (int i = s.size() - 1; i >= 0; i--) { // 注意遍历顺序 |
- 举例推导dp数组
举例,输入:”aaa”,dp[i][j]
状态如下:
图中有6个true,所以就是有6个回文子串。
整体代码如下
1 | class Solution { |
42、最长回文子序列
给你一个字符串 s
,找出其中最长的回文子序列,并返回该序列的长度。
子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。
示例 1:
1 | 输入:s = "bbbab" |
示例 2:
1 | 输入:s = "cbbd" |
提示:
1 <= s.length <= 1000
s
仅由小写英文字母组成
思路
我们刚做过回文子串,求的是回文子串,而本题是回文子序列。
回文子串是要连续的,回文子序列可不是连续的
dp五部曲
1、确定dp数组以及下标含义
dp[i][j]:字符串s在[i , j ]范围内最长的回文子序列的长度为dp[i][j]
2、确定递推公式
在判断回文子串的题目中,最关键的就是判断s[i]和s[j]是否相同。
如果相同那么dp[i][j] = dp[i + 1][j - 1] + 2
如图:
如果不相同,说明s[i]和s[j]的同时加入 并不能增加回文子序列的长度,那么就分别加入s[i]、s[j]看看哪个一可以
加入s[j]的回文子序列长度为dp[i + 1][j]。
加入s[i]的回文子序列长度为dp[i][j - 1]
。
那么dp[i][j]一定是取最大的,即:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
1 | if (s[i] == s[j]) { |
3、dp数组如何初始化
首先考虑当 i 和 j 相同的情况,从递推公式:dp[i][j] = dp[i + 1][j - 1] +2
可以看出 递推公式是计算不到 i 和 j 相同时候的情况。
所以需要手动初始化一下,当 i 和 j相同,那么dp[i][j]一定是等于1的。即 一个字符串的回文子序列长度为1
其他情况默认初始为0就可以。
4、确定遍历顺序
从递推公式可以看出,dp[i][j] 依赖于 dp[i + 1][j - 1] ,dp[i + 1][j]
和 dp[i][j - 1]
,如图:
所以遍历i的时候一定要从下到上遍历,这样才能保证下一行的数据是经过计算的。
j的话,可以正常从左向右遍历。
1 | for (int i = s.size() - 1; i >= 0; i--) { |
- 举例推导dp数组
输入s:”cbbd” 为例,dp数组状态如图:
红色框即:dp[0][s.size() - 1]
; 为最终结果。
整体代码如下
1 | class Solution { |
第十章 单调栈
1、每日温度
给定一个整数数组 temperatures
,表示每天的温度,返回一个数组 answer
,其中 answer[i]
是指对于第 i
天,下一个更高温度出现在几天后。如果气温在这之后都不会升高,请在该位置用 0
来代替。
示例 1:
1 | 输入: temperatures = [73,74,75,71,69,72,76,73] |
示例 2:
1 | 输入: temperatures = [30,40,50,60] |
示例 3:
1 | 输入: temperatures = [30,60,90] |
提示:
1 <= temperatures.length <= 105
30 <= temperatures[i] <= 100
思路
首先想到的当然是暴力解法,两层for循环,把至少要等待的天数就出来了,时间复杂度是On*2
那么接下来就看看单调栈的解法。
那么什么时候会想到用单调栈呢?
通常是一维数组,要寻找任意一个元素的右边或者左边第一个比自己大或者小的元素的位置,此时我们就要想到可以用单调栈了。时间复杂度为On
例如本题其实就是找到一个元素右边第一个比自己大的元素,此时就应该想到单调栈了。
那么单调栈的原理是什么呢?为什么时间复杂度是On就可以找到每一个元素的右边第一个比他大的元素位置呢?
单调栈的本质就是空间换时间,因为在便利的过过程中需要用一个栈来记录右边第一个比当前元素高的元素。优点是整个数组只需要遍历一次。
更直白的来说,就是用一个栈来记录我们遍历过的元素因为我们遍历数组的时候,我们不知道之前都遍历了哪些元素,以至于遍历一个元素找不到是不是之前遍历过一个更小的,所以我们需要用一个容器(这里用单调栈)来记录我们遍历过的元素。
在使用单调栈的时候首先要明确如下几点:
1、单调栈里存放的元素是什么?
单调栈里只需要存放元素的下标 i 就可以了,如果需要使用对应的元素,直接T[i] 就可以获取。
2、单调栈里元素是递增呢?还是递减呢?
注意以下讲解中,顺序的描述为 从栈头到栈底的顺序,因为单纯的说从左到右或者从前到后,不说栈头朝哪个方向的话,大家一定比较懵。
这里我们要使用递增循序(再强调一下是指从栈头到栈底的顺序),因为只有递增的时候,栈里要加入一个元素i的时候,才知道栈顶元素在数组中右面第一个比栈顶元素大的元素是i。
即:如果求一个元素右边第一个更大元素,单调栈就是递增的,如果求一个元素右边第一个更小元素,单调栈就是递减的。
文字描述理解起来有点费劲,接下来我画了一系列的图,来讲解单调栈的工作过程,大家再去思考,本题为什么是递增栈。
使用单调栈主要有三个判断条件。
- 当前遍历的元素T[i]小于栈顶元素T[st.top()]的情况
- 当前遍历的元素T[i]等于栈顶元素T[st.top()]的情况
- 当前遍历的元素T[i]大于栈顶元素T[st.top()]的情况
把这三种情况分析清楚了,也就理解透彻了。
接下来我们用temperatures = [73, 74, 75, 71, 71, 72, 76, 73]为例来逐步分析,输出应该是 [1, 1, 4, 2, 1, 1, 0, 0]。
首先先将第一个遍历元素加入单调栈
加入T[1] = 74,因为T[1] > T[0](当前遍历的元素T[i]大于栈顶元素T[st.top()]的情况)。
我们要保持一个递增单调栈(从栈头到栈底),所以将T[0]弹出,T[1]加入,此时result数组可以记录了,result[0] = 1,即T[0]右面第一个比T[0]大的元素是T[1]。
加入T[2],同理,T[1]弹出
加入T[3],T[3] < T[2] (当前遍历的元素T[i]小于栈顶元素T[st.top()]的情况),加T[3]加入单调栈。
加入T[4],T[4] == T[3] (当前遍历的元素T[i]等于栈顶元素T[st.top()]的情况),此时依然要加入栈,不用计算距离,因为我们要求的是右面第一个大于本元素的位置,而不是大于等于!
加入T[5],T[5] > T[4] (当前遍历的元素T[i]大于栈顶元素T[st.top()]的情况),将T[4]弹出,同时计算距离,更新result
T[4]弹出之后, T[5] > T[3] (当前遍历的元素T[i]大于栈顶元素T[st.top()]的情况),将T[3]继续弹出,同时计算距离,更新result
直到发现T[5]小于T[st.top()],终止弹出,将T[5]加入单调栈
加入T[6],同理,需要将栈里的T[5],T[2]弹出
同理,继续弹出
此时栈里只剩下了T[6]
加入T[7], T[7] < T[6] 直接入栈,这就是最后的情况,result数组也更新完了。
此时有同学可能就疑惑了,那result[6] , result[7]怎么没更新啊,元素也一直在栈里。
其实定义result数组的时候,就应该直接初始化为0,如果result没有更新,说明这个元素右面没有更大的了,也就是为0。
以上在图解的时候,已经把,这三种情况都做了详细的分析。
- 情况一:当前遍历的元素T[i]小于栈顶元素T[st.top()]的情况
- 情况二:当前遍历的元素T[i]等于栈顶元素T[st.top()]的情况
- 情况三:当前遍历的元素T[i]大于栈顶元素T[st.top()]的情况
通过以上过程,大家可以自己再模拟一遍,就会发现:只有单调栈递增(从栈口到栈底顺序),就是求右边第一个比自己大的,单调栈递减的话,就是求右边第一个比自己小的。
1 | class Solution { |
2、下一个更大元素1
nums1
中数字 x
的 下一个更大元素 是指 x
在 nums2
中对应位置 右侧 的 第一个 比 x
大的元素。
给你两个 没有重复元素 的数组 nums1
和 nums2
,下标从 0 开始计数,其中nums1
是 nums2
的子集。
对于每个 0 <= i < nums1.length
,找出满足 nums1[i] == nums2[j]
的下标 j
,并且在 nums2
确定 nums2[j]
的 下一个更大元素 。如果不存在下一个更大元素,那么本次查询的答案是 -1
。
返回一个长度为 nums1.length
的数组 ans
作为答案,满足 ans[i]
是如上所述的 下一个更大元素 。
示例 1:
1 | 输入:nums1 = [4,1,2], nums2 = [1,3,4,2]. |
示例 2:
1 | 输入:nums1 = [2,4], nums2 = [1,2,3,4]. |
提示:
1 <= nums1.length <= nums2.length <= 1000
0 <= nums1[i], nums2[i] <= 104
nums1
和nums2
中所有整数 互不相同nums1
中的所有整数同样出现在nums2
中
思路
需要对单调栈使用的更熟练一些,才能顺利的把题目写出来。
从题目示例中,我们可以看出最后是要求nums1的每个元素在nums2中下一个比当前元素大的元素,那么就要定义一个和nums1一样大小的数组res来存放结果。
那么定义这个res数组初始化应该为多少呢?
题目说如果不存在对应位置就输出-1,所以res数组如果某位位置没有被复制,那么就应该是-1,所以初始化为-1.
在遍历num2的过过程中,我们要判断nums2[i] 是否在nums1中出现过,因为最后是要根据nums1元素的下标来更新res数组。
注意题目中说是两个没有重复元素 的数组 nums1 和 nums2
没有重复元素,我们就可以用map来做映射了,根据数值快速找到下标,还可以判断nums2[i] 是否在nums1中出现过。
然后就先初始化一下
1 | HashMap<int,int> map = new HashMap<>(); |
使用单调栈,首先要想单调栈是从大道到小还是从小到大。
栈头到栈底的顺序,要从小到大,也就是保持栈里的元素为递增顺序。
只要保持递增,才能找到右边第一个比自己大的元素。
接下来分析如下三种情况,一定要分析清楚。
1、情况一:当前遍历的元素T[i] 小于栈顶元素T[st.peek()]的情况
此时满足递增栈,所以直接入栈。
2、情况二:当前遍历的元素T[i] 等于栈顶元素T[st.peek()]的情况
如果相等的话,依然直接入栈,因为我们要求的是右边第一个比自己大的元素,而不是大于等于
3、情况三:当前遍历的元素T[i] 大于栈顶元素T[peek()]的情况
此时如果入栈就不满足递增栈了,这也是找到右边第一个比自己大的元素的时候。
判断栈顶元素是否在nums1出现过,(注意栈里的元素是nums2的元素),如果出现过,开始记录结果。
记录结果,要清楚,此时栈顶元素在nums2数组的右边第一个大的元素是nums2[i] (即当前遍历元素)
代码如下
1 | //当前遍历的元素大于栈顶元素并且栈不为空 |
整体代码如下
1 | class Solution { |
3、下一个更大元素2
503. 下一个更大元素 II - 力扣(LeetCode)
给定一个循环数组 nums
( nums[nums.length - 1]
的下一个元素是 nums[0]
),返回 nums
中每个元素的 下一个更大元素 。
数字 x
的 下一个更大的元素 是按数组遍历顺序,这个数字之后的第一个比它更大的数,这意味着你应该循环地搜索它的下一个更大的数。如果不存在,则输出 -1
。
示例 1:
1 | 输入: nums = [1,2,1] |
示例 2:
1 | 输入: nums = [1,2,3,4,3] |
提示:
1 <= nums.length <= 104
-109 <= nums[i] <= 109
思路
这题和每日温度几乎如出一辙
但是本题是循环数组
问题是如何处理循环数组,首先循环数组不一定是递增的,其次如果循环到最后还找不到比当前元素大的是否有可能在左边部分,并且如果是最大的元素如何判断出来,不然就会一直循环下去了
反正是循环数组,那么可以把两个数组拼接在一起,然后使用单调栈求下一个最大值。
将两个nums数组拼接在一起,使用单调栈计算出每一个元素的下一个最大值,最后再把结果集res数组,恢复到原数组大小就可以了。
但是优化一下,遇到循环数组可以使用 % 的方法
1 | class Solution { |
4、接雨水
给定 n
个非负整数表示每个宽度为 1
的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
示例 1:
1 | 输入:height = [0,1,0,2,1,0,1,3,2,1,2,1] |
示例 2:
1 | 输入:height = [4,2,0,3,2,5] |
提示:
n == height.length
1 <= n <= 2 * 104
0 <= height[i] <= 105
思路
单调栈
首先这道题目先思考一下,怎么会接到雨水,是不是右边最大元素以及左边最大元素,也就是右边有比自己大的元素,并且左边也有比自己大的元素。
准备工作
那么本题使用单调栈有如下几个问题
1、首先单调栈是按照行方向来计算雨水的,如图:
2、使用单调栈内元素的顺序
从大到小还是从小到大呢?
从栈头到栈底应该的顺序是从小到大的顺序。
因为一旦发现添加的竹子高度大于栈头元素了,此时就出现凹槽了,栈头元素就是凹槽底部的柱子,栈头第二个元素就是凹槽左边的柱子,而添加的元素就是凹槽右边的柱子。
求一个元素右边第一个更大元素,单调栈就是递增的
求一个元素右边第一个更小元素,单调栈就是递减的
3、遇到相同高的柱子怎么办
遇到相同的元素,更新栈内下标,就是将栈里元素(旧下标)弹出,新元素加入栈中。
例如5 5 1 3 这种情况,如果添加第二个5的时候就应该将第一个5的下标弹出,第二个5加入到栈中。
因为我们要求宽度的时候 如果遇到相同高度的柱子,需要使用最右边的柱子来计算宽度
4、栈里要保存什么数值
使用单调栈,也就是通过 长 * 宽 来计算雨水面积的。
长就是通过柱子的高度来计算,宽是通过柱子之间的下标来计算。
单调栈的处理逻辑
以下逻辑主要就是三种情况
- 情况一:当前遍历的元素(柱子)高度小于栈顶元素的高度 height[i] < height[st.top()]
- 情况二:当前遍历的元素(柱子)高度等于栈顶元素的高度 height[i] == height[st.top()]
- 情况三:当前遍历的元素(柱子)高度大于栈顶元素的高度 height[i] > height[st.top()]
先将下标0的柱子加入到栈中,st.push(0);
。 栈中存放我们遍历过的元素,所以先将下标0加进来。
然后开始从下标1开始遍历所有的柱子,for (int i = 1; i < height.size(); i++)
。
如果当前遍历的元素(柱子)高度小于栈顶元素的高度,就把这个元素加入栈中,因为栈里本来就要保持从小到大的顺序(从栈头到栈底)。
代码如下:
1 | if (height[i] < height[st.top()]) st.push(i); |
如果当前遍历的元素(柱子)高度等于栈顶元素的高度,要跟更新栈顶元素,因为遇到相相同高度的柱子,需要使用最右边的柱子来计算宽度。
代码如下:
1 | if (height[i] == height[st.top()]) { // 例如 5 5 1 7 这种情况 |
如果当前遍历的元素(柱子)高度大于栈顶元素的高度,此时就出现凹槽了,如图所示:
取栈顶元素,将栈顶元素弹出,这个就是凹槽的底部,也就是中间位置,下标记为mid,对应的高度为height[mid](就是图中的高度1)。
此时的栈顶元素st.top(),就是凹槽的左边位置,下标为st.top(),对应的高度为height[st.top()](就是图中的高度2)。
当前遍历的元素i,就是凹槽右边的位置,下标为i,对应的高度为height[i](就是图中的高度3)。
此时大家应该可以发现其实就是栈顶和栈顶的下一个元素以及要入栈的元素,三个元素来接水!
那么雨水高度是 min(凹槽左边高度, 凹槽右边高度) - 凹槽底部高度,代码为:int h = min(height[st.top()], height[i]) - height[mid];
雨水的宽度是 凹槽右边的下标 - 凹槽左边的下标 - 1(因为只求中间宽度),代码为:int w = i - st.top() - 1 ;
当前凹槽雨水的体积就是:h * w
。
求当前凹槽雨水的体积代码如下:
1 | while (!st.empty() && height[i] > height[st.top()]) { // 注意这里是while,持续跟新栈顶元素 |
整体代码如下
1 | class Solution { |
暴力解法
本题暴力解法也是双指针
首先要明确,要按照行来计算,还是按照列来计算。
按照行来计算如图:
按照列来计算如图:
一些同学在实现的时候,很容易一会按照行来计算一会按照列来计算,这样就会越写越乱。
我个人倾向于按照列来计算,比较容易理解,接下来看一下按照列如何计算。
首先,如果按照列来计算的话,宽度一定是1了,我们再把每一列的雨水的高度求出来就可以了。
可以看出每一列雨水的高度,取决于,该列 左侧最高的柱子和右侧最高的柱子中最矮的那个柱子的高度。
这句话可以有点绕,来举一个理解,例如求列4的雨水高度,如图:
列4 左侧最高的柱子是列3,高度为2
列4 右侧最高的柱子是列7,高度为3
列4 柱子的高度为1
那么列4的雨水高度为 列3和列7的高度最小值减列4高度
列4的雨水高度求出来了,宽度为1,相乘就是列4的雨水的体积了。
首先从头遍历所有的列,并且要注意第一个柱子和最后一个柱子不接雨水
整体代码如下
1 | class Solution { |
但是这个方法是超时了。
这里其实是有重复的,因为每次都要找左边最高的列和右边最高的列,可能两列或者三列的左边最高和右边最高时同一个。
双指针优化
在暴力解法中,我们可以看到只要记录左边柱子的最高高度 和 右边柱子的最高高度,就可以计算当前位置的雨水面积。
那么为了解决记录左右柱子的最高高度,我们使用双指针来遍历。把每一个位置的左边最高高度记录在一个数组上(maxLeft),右边也是记录在(maxRight)上,这样就避免了重复计算。
双指针版本
1 | class Solution { |
5、柱状图中最大的矩形
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
示例 1:
1 | 输入:heights = [2,1,5,6,2,3] |
示例 2:
1 | 输入: heights = [2,4] |
提示:
1 <= heights.length <=105
0 <= heights[i] <= 104
思路
原理上跟接雨水相同,但是细节上又有差异。
双指针
本题要记录每个柱子左边第一个小于该柱子的下标,而不是左边第一个小于该柱子的高度。
min一开始记录的不是最小值而是每一列第一个更小的左边和第一个更小的右边。
然后怎么记录第一个更小的呢?
一开始记录左边第一个小于该柱子的下标是-1,然后从第一列开始,然后用一个t记录该点的左边一列,然后用一个while循环,while循环的条件是t的高度,然后如果t的高度小于当前列的高度,那么就说明是第一个小的了,然后记录在Min里面,如果是大于这里优化过,t是直接跳到第一个比t小的下标的元素中,而不是t减减。
注意这里完成minLeft的时候是从左到右,记录MinRight是从右到左因为left是记录第一个左边比他小的,而right是右边比他大的
准备好Minleft和minright后就开始求和
求和的逻辑是
首先sum是一列一列求的,所以高度是已经知道了,然后宽度就是用高度乘宽度,宽度是下标相减,然后求出最大的面积就行了。
1 | class Solution { |
第十一章 图论
前言
图论分为五大模块:
1、深搜和广搜
2、并查集
3、最小生成树
4、拖布排序
5、最短路算法
输出细节
同样,图论的输出也有细节,例如 求节点1 到节点5的所有路径, 输出可能是:
1 | 1 2 4 5 |
表示有两条路可以到节点5, 那储存这个结果需要二维数组,最后在一起输出,力扣是直接return数组就好了,但 ACM模式要求我们自己输出,这里有就细节了。
就拿 只输出一行数据,输出 1 2 4 5
来说,发现结果是对的,一提交,发现OJ返回 格式错误 或者 结果错误。
如果没练习过这种输出方式的录友,就开始怀疑了,这结果一样一样的,怎么就不对,我在力扣上提交都是对的!
大家要注意,5 后面要不要有空格!
上面这段代码输出,5后面是加上了空格了,如果判题机判断 结果的长度,标准答案1 2 4 5
长度是7,而上面代码输出的长度是 8,很明显就是不对的。
1、图论理论基础
图的基本概念
二维坐标中,两个点可以连成线,多个点连成的线就构成了图。
当然图也可以就一个几点,甚至没有节点(空图)
图的种类
整体上一般分为 有向图 和 无向图
有向图是指 图中边是有方向的:
无向图是指 图中边没有方向:
加权有向图,就是图中边是有权值的,例如:
加权无向图也是同理。