# 笔记随想录
# 速查手册
# java 中 Stack、Queue 的接口函数
# Stack 类
Stack 的声明如下,可以看到 Stack 继承了 Vector,因此 Stack 可以使用 Vector 中的方法,如 size () 等。
public | |
class Stack<E> extends Vector<E> |
方法 | 作用 |
---|---|
boolean empty() | 判断栈是否为空 |
E peek() | 返回栈顶部的对象,但不从栈中移除它 |
E pop() | 移除栈顶部的对象,并作为此函数的值返回该对象 |
E push(E item) | 把对象压入栈顶部 |
int search(Object o) | 返回对象在堆栈中的位置,若不存在则返回 - 1 |
# Queue 接口
与 Stack 不同,Java 里的 Queue 不是一个类,而是一个接口,它的声明为
public interface Queue<E> extends Collection<E> |
定义了 6 个方法:add、element、remove 为一组,他们均在出错时抛出异常;offer、peek、poll 为一组,他们在出错时返回特定的值
方法 | 作用 |
---|---|
boolean add(E e) | 在队列尾部插入一个元素 |
boolean offer(E e) | 入队:在队列尾部插入一个元素 |
E element() | 返回队列头部的对象,但不从栈中移除它 |
E peek() | 返回队列头部的对象,但不从栈中移除它 |
E remove() | 返回队列头部的对象,并从栈中移除它 |
E poll() | 出队:返回队列头部的对象,并从栈中移除它 |
add () 和 offer () 向队列尾部中添加一个元素。他们的不同在于:当使用有容量限制的队列(如 ArrayBlockingQueue)时,若队列已满,调用 add 会抛出一个 IllegalStateException,而调用 offer 不会抛出异常,只会返回 false。
与 add () 和 offer () 类似,element () 和 peek () 的区别在于:当队列为空时,调用 element () 抛出一个 NoSuchElementException 异常,而 peek () 返回 null。
remove () 和 poll () 方法都是从队列中删除第一个元素。如果队列元素为空,调用 remove () 的行为与 Collection 接口相似,会抛出 NoSuchElementException 异常,而是新的 poll () 方法在用空集合调用时只是返回 null。
# 递归算法的时间、空间复杂度
时间:递归的次数 * 每次递归的时间复杂度
递归的次数其实就是二叉树中的节点的个数,按至多计算。
一棵深度(按根节点深度为 1)为 k 的二叉树最多可以有 2^k - 1 个节点。
空间:递归深度 * 每次递归的空间复杂度
这里是递归的深度,而不是递归的次数。因为每次递归所需的空间都被压到调用栈里(这是内存管理里面的数据结构,和算法里的栈原理是一样的),一次递归结束,这个栈就是就是把本次递归的数据弹出去。所以这个栈最大的长度就是递归的深度。
# 数组
# 理论基础
定义:数组是存放在连续内存空间上的相同类型数据的集合。
注意:
在删除或者增添元素的时候,就难免要移动其他元素的地址。
数组的元素是不能删的,只能覆盖。
在 C++ 中二维数组在地址空间上是连续的。
# 704. 二分查找
使用二分法的 **前提条件**:
数组是有序的
数组中无重复元素
否则返回的元素下标可能不是唯一的
难点:边界条件如何确定,需要坚持根据 **区间的定义**(不变量),即循环不变量规则
左闭右闭 [left, right]
- 初始化 right =
nums.length - 1
- while(left
<=
right),因为 left == right 是有意义的,对 [left,right] 有效 - if (nums [middle] > target) 时 right 要赋值为
middle - 1
- 初始化 right =
左闭右开 [left, right)
- 初始化 right =
nums.length
- while (left
<
right),因为 left == right 在区间 [left, right) 是没有意义的 - if (nums [middle] > target) 时 right 更新为
middle
,因为当前 nums [middle] 不等于 target,去左区间继续寻找,而寻找区间是左闭右开区间,所以 right 更新为 middle,即:下一个查询区间不会去比较 nums [middle]
- 初始化 right =
代码:
// 左闭右闭区间 | |
class Solution { | |
public int search(int[] nums, int target) { | |
if (target < nums[0] || target > nums[nums.length - 1]) { | |
return -1;// 如果目标值小于数组中最小值或者大于数组中最大值,直接返回 - 1,目的是为了减少循环次数 | |
} | |
int left = 0, right = nums.length - 1; | |
while (left <= right) { // 注意这里是 & lt;=,因为当 left==right 时区间 [left,right] 依然有效,如果是 & lt;,那么最后一次循环时,left=right,此时区间 [left,right] 中只有一个元素,如果此时 target=nums [left],那么就会漏掉这个元素 | |
int mid = left + ((right - left) >> 1); // 为什么不用 (left+right)/2,因为这样可能会溢出,即 left+right 的值可能会大于 int 的最大值,所以用位运算,>>1 相当于除以 2 | |
if (nums[mid] == target) | |
return mid; | |
else if (nums[mid] < target) | |
left = mid + 1; //target 在右区间,所以 [mid+1,right] | |
else if (nums[mid] > target) | |
right = mid - 1; //target 在左区间,所以 [left,mid-1] | |
} | |
return -1; // 如果没有找到,返回 - 1 | |
} | |
} |
// 左闭右开区间:[left, right) | |
class Solution { | |
public int search(int[] nums, int target) { | |
if (target < nums[0] || target > nums[nums.length - 1]) { | |
return -1; | |
} | |
int left = 0, right = nums.length; // 注意这里是 nums.length,因为是左闭右开区间 | |
while (left < right) { // 注意这里是 & lt;,因为当 left==right 时区间 [left,right) 无效,如果是 & lt;=,那么最后一次循环时,left=right,此时区间 [left,right) 中没有元素 | |
int mid = left + ((right - left) >> 1); | |
if (nums[mid] == target) | |
return mid; | |
else if (nums[mid] < target) | |
left = mid + 1; | |
else if (nums[mid] > target) | |
right = mid; //target 在左区间,所以 [left,mid),因为右边是开区间,所以不用 mid-1 | |
} | |
return -1; | |
} | |
} |
# 35. 搜索插入位置
很容易判断该题符合 **“二分查找” 方法的两个前提条件:有序数组、无重复元素 **。
因此要解决 **“二分查找” 的难点:区间的确定 **(左闭右闭 / 左闭右开)。
与 [704. 二分查找] 不同的是,该题要求在数组中找不到目标值时,需要返回它将会被按顺序插入的位置下标,无非四种情况:
- 目标值在数组所有元素之前
- 目标值等于数组中某一个元素(直接返回下标)
- 目标值插入数组中的位置
- 目标值在数组所有元素之后
# 暴力解法
class Solution { | |
/* 一共四种情况: | |
①目标值在数组所有元素之前 | |
②目标值等于数组中某一个元素 | |
③目标值插入数组中的位置 | |
④目标值在数组所有元素之后 | |
*/ | |
// 暴力解法 | |
public int searchInsert(int[] nums, int target) { | |
for(int i = 0; i < nums.length; i++) { | |
if(nums[i] >= target) { | |
return i; // ①②③ | |
} | |
} | |
return nums.length; // ④ | |
} | |
} |
- 时间复杂度:O (n)
- 空间复杂度:当参数 nums 为首地址时为 O (1),当参数 nums 为拷贝数组时为 O (n)
效率如下:
# 二分查找法
// 二分查找法(左闭右闭) | |
class Solution { | |
/* 一共四种情况: | |
①目标值在数组所有元素之前 | |
②目标值等于数组中某一个元素 | |
③目标值插入数组中的位置 | |
④目标值在数组所有元素之后 | |
*/ | |
public int searchInsert(int[] nums, int target) { | |
int left = 0, right = nums.length - 1; | |
while (left <= right) { | |
int mid = left + ((right - left) >> 1); | |
if (nums[mid] == target) { | |
return mid; // ② | |
} else if (nums[mid] < target) { | |
left = mid + 1; | |
} else if (nums[mid] > target) { | |
right = mid - 1; | |
} | |
} | |
// ①[0, -1] | |
// ③[left, right],return right + 1 | |
// ④[left, right],因为是右闭区间,所以 return right + 1 | |
return right + 1; | |
} | |
} |
- 时间复杂度:O (log n)
- 空间复杂度:O (1)
// 二分查找法(左闭右开) | |
class Solution { | |
/* 一共四种情况: | |
①目标值在数组所有元素之前 | |
②目标值等于数组中某一个元素 | |
③目标值插入数组中的位置 | |
④目标值在数组所有元素之后 | |
*/ | |
public int searchInsert(int[] nums, int target) { | |
int left = 0, right = nums.length; | |
while (left < right) { | |
int mid = left + ((right - left) >> 1); | |
if (nums[mid] == target) { | |
return mid; // ② | |
} else if (nums[mid] < target) { | |
left = mid + 1; | |
} else if (nums[mid] > target) { | |
right = mid; | |
} | |
} | |
// ① [0,0) | |
// ③ [left, right) ,return right 即可 | |
// ④ [left, right),因为是右开区间,所以 return right | |
return right; | |
} | |
} |
- 时间复杂度:O (log n)
- 空间复杂度:O (1)
# 27. 移除元素
首先明确:数组的元素在内存地址中是连续的,不能单独删除数组中的某个元素,只能覆盖。
# 暴力解法
两层 for 循环,一个 for 循环遍历数组元素 ,第二个 for 循环将后面的元素集体往前移动一位。删除过程如下:
class Solution { | |
public int removeElement(int[] nums, int val) { | |
// 暴力解法,时间复杂度 O (n^2),空间复杂度 O (1) | |
int size = nums.length; | |
for (int i = 0; i < size; i++) { | |
if (nums[i] == val) { // 找到了要删除的元素 | |
for (int j = i + 1; j < size; j++) { // 将后面的元素往前移动一位 | |
nums[j - 1] = nums[j]; | |
} | |
i--; // 因为 i 后面的元素都往前移动了一位,所以 i 也要往前移动一位 | |
size--; // 数组长度减一 | |
} | |
} | |
return size; | |
} | |
} |
# 双指针法
双指针法(快慢指针法): ** 通过一个快指针和慢指针在一个 for 循环下完成两个 for 循环的工作。** 对整个数组 nums 重新赋一遍值。
在数组和链表的操作中是非常常见的,很多考察数组、链表、字符串等操作的面试题,都使用双指针法。
- 快指针:寻找新数组的元素 ,新数组就是不含有目标元素的数组
- 慢指针:指向更新 新数组下标的位置
class Solution { | |
public int removeElement(int[] nums, int val) { | |
// 双指针,时间复杂度 O (n),空间复杂度 O (1) | |
int slow = 0, fast = 0; //slow 指向新数组的更新位置,fast 指向新数组的元素 | |
for (; fast < nums.length; fast++) { | |
if (nums[fast] != val) { | |
nums[slow] = nums[fast]; | |
slow++; | |
} | |
} | |
return slow; | |
} | |
} |
以上两种方法都没有改变元素的相对位置。
# 相向双指针
不懂
class Solution { | |
public int removeElement(int[] nums, int val) { | |
// 相向双指针,基于元素顺序可以改变的条件,改变了元素相对位置,确保了移动最少元素 | |
// 时间复杂度:O (n),空间复杂度:O (1) | |
int leftIndex = 0, rightIndex = nums.length - 1; | |
while(rightIndex>=0 && nums[rightIndex] == val) rightIndex--; // 将 rightIndex 移动到右数第一个不等于 val 的位置 | |
while(leftIndex <= rightIndex){ | |
if(nums[leftIndex] == val){ //leftIndex 位置的元素需要移除 | |
// 将 rightIndex 位置的元素移到 leftIndex(覆盖),rightIndex 位置移除 | |
nums[leftIndex] = nums[rightIndex]; | |
rightIndex--; | |
} | |
leftIndex++; | |
while(rightIndex>=0 && nums[rightIndex] == val) rightIndex--; // 将 rightIndex 移动到右数第一个不等于 val 的位置 | |
} | |
return leftIndex; | |
} | |
} |
# 977. 有序数组的平方
双指针风骚起来,也是无敌
# 暴力排序
每个数平方之后,排个序,美滋滋!
class Solution { | |
public int[] sortedSquares(int[] nums) { | |
// 暴力解法,对数组进行平方,然后排序 | |
// 时间复杂度 O (n + nlogn),空间复杂度 O (1) | |
for (int i = 0; i < nums.length; i++) { | |
nums[i] *= nums[i]; | |
} | |
Arrays.sort(nums); // Arrays.sort () 为快速排序,时间复杂度为 O (nlogn) | |
return nums; | |
} | |
} |
- 时间复杂度:O (n + nlogn)
- 空间复杂度:O (1)
# 双指针法
数组其实是有序的, 只不过负数平方之后可能成为最大数了。
那么数组平方的最大值就在数组的两端,不是最左边就是最右边,不可能是根。
此时可以考虑双指针法,i 指向起始位置,j 指向终止位置。
定义一个新数组 result,和 A 数组一样的大小,让 k 指向 result 数组终止位置。
如果 A[i] * A[i] < A[j] * A[j]
那么 result[k--] = A[j] * A[j];
。
如果 A[i] * A[i] >= A[j] * A[j]
那么 result[k--] = A[i] * A[i];
。
如动画所示:
class Solution { | |
public int[] sortedSquares(int[] nums) { | |
// 双指针法,时间复杂度 O (n) | |
int k = nums.length - 1; //k 指向最后一个元素 | |
int[] res = new int[nums.length]; // 结果数组 | |
//i 指向第一个元素,j 指向最后一个元素 | |
for (int i = 0, j = nums.length - 1; i <= j; ) { | |
if (nums[i] * nums[i] > nums[j] * nums[j]) { | |
res[k--] = nums[i] * nums[i]; | |
i++; | |
} else { | |
res[k--] = nums[j] * nums[j]; | |
j--; | |
} | |
} | |
return res; | |
} | |
} |
- 时间复杂度:O (n)。比暴力解法的 O (n+nlog n) 要好。
# 209. 长度最小的子数组
# 暴力解法
两层 for 循环,外层遍历起始位置,内层遍历终止位置,枚举出所有子数组情况,找出最小的长度即可。
class Solution { | |
public int minSubArrayLen(int target, int[] nums) { | |
// 暴力解法 | |
int result = Integer.MAX_VALUE; | |
int sum = 0; | |
int subLength = 0; | |
for (int i = 0; i < nums.length; i++) { | |
sum = 0; | |
for (int j = i; j < nums.length; j++) { | |
sum += nums[j]; | |
if (sum >= target) { // 找到了满足条件的子数组 | |
subLength = j - i + 1; | |
result = Math.min(result, subLength); // 更新最小长度 | |
break; // 直接退出内层循环,因为要寻找的是最小长度的子数组,所以不需要再往后找了 | |
} | |
} | |
} | |
return result == Integer.MAX_VALUE ? 0 : result; | |
} | |
} |
时间复杂度:O (n^2)。
# 滑动窗口
所谓滑动窗口,就是不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。
滑动窗口用一个 for 循环来完成。
首先要思考如果用一个 for 循环,那么应该表示滑动窗口的起始位置,还是终止位置?
- 如果只用一个 for 循环来表示 滑动窗口的起始位置,那么如何遍历剩下的终止位置?此时难免再次陷入 暴力解法的怪圈。
- 所以 只用一个 for 循环,那么这个循环的索引,一定是表示 滑动窗口的终止位置。
那么问题来了, 滑动窗口的起始位置如何移动呢?可以看如下动画,假设 target=7,最后找到 4,3 是最短距离(即 2)。
可以发现滑动窗口也可以理解为双指针法的一种!
在本题中实现滑动窗口,主要确定如下三点:
- 窗口内是什么:满足其和 ≥ s 的长度最小的 连续 子数组。
- 窗口的起始位置:如果当前窗口的值大于 s 了,起始位置就要向前移动了(也就是窗口该缩小了)
- 窗口的结束位置:即 for 循环里的索引
解题的关键在于 窗口的起始位置如何移动,如图所示:
可以发现滑动窗口的精妙之处在于根据当前子序列和大小的情况,不断调节子序列的起始位置。从而将 O (n^2) 暴力解法降为 O (n)。
class Solution { | |
public int minSubArrayLen(int target, int[] nums) { | |
// 滑动窗口,时间复杂度 O (n) | |
int result = Integer.MAX_VALUE; // 最小长度 | |
int sum = 0; // 窗口内的和 | |
int subLength = 0; // 窗口的长度 | |
int i = 0; // 起始位置 | |
for (int j = 0; j < nums.length; j++) { // 结束位置 | |
sum += nums[j]; | |
while (sum >= target) { // 如果窗口内的和大于等于 target,就缩小窗口,争取找到最小的窗口 | |
subLength = j - i + 1; | |
result = Math.min(result, subLength); | |
sum -= nums[i++]; // 滑动窗口的精髓:起始位置如何移动 | |
} | |
} | |
return result == Integer.MAX_VALUE ? 0 : result; | |
} | |
} |
- 时间复杂度:O (n)
- 空间复杂度:O (1)
一些录友会疑惑为什么时间复杂度是 O (n)。
不要以为 for 里放一个 while 就以为是 O (n^2) 啊, 主要是看每一个元素被操作的次数,每个元素在滑动窗后进来操作一次,出去操作一次,每个元素都是被操作两次,所以时间复杂度是 2 × n 也就是 O (n)。
# 59. 螺旋矩阵 II
例如:
输入:n = 3
输出:[[1,2,3],[8,9,4],[7,6,5]]
本体并不涉及什么算法,就是模拟过程。
前面在二分查找法中提到一定要坚持循环不变量原则,本题亦如此。
模拟顺时针画矩阵的过程:
- 填充上行从左到右
- 填充右列从上到下
- 填充下行从右到左
- 填充左列从下到上
由外向内一圈一圈这么画下去。
可以发现这里的边界条件非常多,在一个循环中,如此多的边界条件,必须坚持按照固定规则来遍历。
这里一圈下来,我们要画每四条边,这四条边怎么画,每画一条边都要坚持一致的原则(左闭右开,或者左开右闭),这样这一圈才能按照统一的规则画下来。
那么我按照左闭右开 [left,right) 的原则,来画一圈,大家看一下:
这里每一种颜色,代表一条边,我们遍历的长度,可以看出每一个拐角处的处理规则,拐角处让给新的一条边来继续画。这也是坚持了每条边左闭右开的原则。
代码如下,已经详细注释了每一步的目的,可以看出 while 循环里判断的情况是很多的,代码里处理的原则也是统一的左闭右开。
class Solution { | |
public int[][] generateMatrix(int n) { | |
int[][] result = new int[n][n]; | |
int start = 0; // 每次循环一个圈的起始位置:(start, start) | |
int loop = n / 2; // 循环圈数 | |
int mid = n / 2; // 当 n 为奇数时,需要对中间位置额外处理赋值 | |
int count = 1; // 赋值计数器 | |
int offset = 1; // 需要控制每一条边遍历的长度,每次循环右边界收缩一位 | |
int i, j; | |
while (loop-- > 0) { | |
i = start; // 行索引 | |
j = start; // 列索引 | |
// 下面开始模拟螺旋矩阵的赋值过程 | |
// 模拟填充上行从左到右 (左闭右开) | |
for (j = start; j < n - offset; j++) { | |
result[start][j] = count++; | |
} | |
// 模拟填充右列从上到下 (左闭右开) | |
for (i = start; i < n - offset; i++) { | |
result[i][j] = count++; | |
} | |
// 模拟填充下行从右到左 (左闭右开) | |
for (; j > start; j--) { | |
result[i][j] = count++; | |
} | |
// 模拟填充左列从下到上 (左闭右开) | |
for (; i > start; i--) { | |
result[i][j] = count++; | |
} | |
// 每次循环右边界收缩一位 | |
offset++; | |
// 每次循环起始位置向内移动一位 | |
start++; | |
} | |
// 当 n 为奇数时,需要对中间位置额外处理赋值 | |
if (n % 2 == 1) { | |
result[mid][mid] = count; | |
} | |
return result; | |
} | |
} |
# 总结
二维数组在内存的空间地址是连续的么?
我们来举一个 Java 的例子,例如:
int[][] rating = new int[3][4];
, 这个二维数组在内存空间可不是一个3*4
的连续地址空间看了下图,就应该明白了:
所以 Java 的二维数组在内存中不是
3*4
的连续地址空间,而是四条连续的地址空间组成!二分法
- 暴力解法时间复杂度:O (n)
- 二分法时间复杂度:O(logn)
循环不变量原则,只有在循环中坚持对区间的定义,才能清楚的把握循环中的各种细节。
双指针法
- 暴力解法时间复杂度:O (n^2)
- 双指针时间复杂度:O(n)
通过一个快指针和慢指针在一个 for 循环下完成两个 for 循环的工作。
数组中的元素为什么不能删除,主要是因为以下两点:
- 数组在内存中是连续的地址空间,不能释放单一元素,如果要释放,就是全释放(程序运行结束,回收内存栈空间)。
- C++ 中 vector 和 array 的区别一定要弄清楚,vector 的底层实现是 array,封装后使用更友好。
双指针法(快慢指针法)在数组和链表的操作中是非常常见的,很多考察数组和链表操作的面试题,都使用双指针法。
滑动窗口
- 暴力解法时间复杂度:O (n^2)
- 滑动窗口时间复杂度:O(n)
主要要理解滑动窗口如何移动窗口起始位置,达到动态更新窗口大小的,从而得出长度最小的符合条件的长度。
滑动窗口的精妙之处在于根据当前子序列和大小的情况,不断调节子序列的起始位置。从而将 O (n^2) 的暴力解法降为 O (n)。
模拟行为
循环不变量原则,需要明确边界条件,明确区间的定义,其实这也是写程序中的重要原则。
小结
# 链表
# 链表理论基础
# 类型
- 单链表
一种通过指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域,一个是指针域(存放指向下一个节点的指针)。
最后一个节点的指针域指向
null
(空指针的意思)。链表的入口节点称为链表的头结点也就是head
。
- 双链表
每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点。
双链表既可以向前查询也可以向后查询。
- 循环链表
链表首尾相连。
可以用来解决约瑟夫环问题。
# 存储方式
数组是在内存中是连续分布的,但是链表在内存中可不是连续分布的。
链表是通过指针域的指针链接在内存中各个节点。
所以链表中的节点在内存中不是连续分布的 ,而是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理。
这个链表起始节点为 2, 终止节点为 7, 各个节点分布在内存的不同地址空间上,通过指针串联在一起。
# 节点的定义
public class ListNode { | |
// 结点的值 | |
int val; | |
// 下一个结点 | |
ListNode next; | |
// 节点的构造函数 (无参) | |
public ListNode() { | |
} | |
// 节点的构造函数 (有一个参数) | |
public ListNode(int val) { | |
this.val = val; | |
} | |
// 节点的构造函数 (有两个参数) | |
public ListNode(int val, ListNode next) { | |
this.val = val; | |
this.next = next; | |
} | |
} |
# 操作
- 删除节点
删除 D 节点,如图所示:
只要将 C 节点的 next 指针 指向 E 节点就可以了。
那有同学说了,D 节点不是依然存留在内存里么?只不过是没有在这个链表里而已。
是这样的,所以在 C++ 里最好是再手动释放这个 D 节点,释放这块内存。
其他语言例如 Java、Python,就有自己的内存回收机制,就不用自己手动释放了。
- 添加节点
如图所示:
可以看出链表的增添和删除都是 O (1) 操作,也不会影响到其他节点。
但是要注意,要是删除第五个节点,需要从头节点查找到第四个节点通过 next 指针进行删除操作,查找的时间复杂度是 O (n)。
# 性能分析(与数组做对比)
# 203. 移除链表元素
链表操作中,可以使用原链表来直接进行删除操作,也可以设置一个虚拟头结点在进行删除操作,接下来看一看哪种方式更方便。
如果使用 C,C++ 编程语言的话,不要忘了还要从内存中删除这两个移除的节点。当然如果使用 java ,python 的话就不用手动管理内存了。
- 如果移除的不是头结点,直接让节点的 next 指针指向下下一个节点就行了。
如果删除的是头结点,有两种链表操作方式:
移除头结点和移除其他节点的操作是不一样的。
因为链表的 **其他节点都是通过前一个节点来移除当前节点,而头结点没有前一个节点 **。
- 直接使用原来的链表来进行删除操作。
只要将头结点向后移动一位就可以,这样就从链表中移除了一个头结点。
依然别忘将原头结点从内存中删掉。
- 设置一个虚拟头结点在进行删除操作。
如果采用上面那种方法来移除头结点的话,会发现在写代码的时候也会发现,需要单独写一段逻辑来处理移除头结点的情况。
其实可以设置一个虚拟头结点,以一种统一的逻辑来移除链表的节点。
来看看如何设置一个虚拟头。依然还是在这个链表中,移除元素 1。
这里来给链表添加一个虚拟头结点为新的头结点,此时要移除这个旧头结点元素 1。
这样是不是就可以使用和移除链表其他节点的方式统一了呢?
来看一下,如何移除元素 1 呢,让虚拟头结点的 next 指针指向下一个元素,然后从内存中删除元素 1。
最后呢在题目中,return 头结点的时候,别忘了
return dummyNode->next;
, 这才是新的头结点。
代码如下:
class Solution { | |
// 不设置虚拟头结点 | |
public ListNode removeElements(ListNode head, int val) { | |
while (head != null && head.val == val) { // 链表非空,且要删除的是头结点 | |
head = head.next; // 将头结点向后移一位 | |
} | |
if (head == null) { // 链表为空,或者仅有一个头结点,且已经被删除 | |
return null; // 直接返回空 | |
} | |
// 链表至少有两个节点,且头结点不是要删除的节点 | |
ListNode prev = head; // 设置前驱节点 | |
while (prev.next != null) { // 遍历链表 | |
if (prev.next.val == val) { // 如果当前节点的下一个节点是要删除的节点 | |
prev.next = prev.next.next; // 将当前节点的下一个节点指向下下个节点 | |
} else { // 如果当前节点的下一个节点不是要删除的节点 | |
prev = prev.next; // 将前驱节点向后移一位 | |
} | |
} | |
return head; // 返回头结点 | |
} | |
} |
class Solution { | |
// 设置虚拟头结点 | |
public ListNode removeElements(ListNode head, int val) { | |
if (head == null) { // 如果链表为空,直接返回 | |
return null; | |
} | |
ListNode dummyHead = new ListNode(-1); // 虚拟头结点 | |
dummyHead.next = head; // 虚拟头结点指向 head | |
ListNode cur = dummyHead; | |
while (cur.next != null) { | |
if (cur.next.val == val) { | |
cur.next = cur.next.next; | |
} else { | |
cur = cur.next; | |
} | |
} | |
return dummyHead.next; // 返回虚拟头结点的下一个节点 | |
} | |
} |
# 707. 设计链表
听说这道题目把链表常见的五个操作都覆盖了?
删除链表节点:
添加链表节点:
这道题目设计链表的五个接口:
- 获取链表第 index 个节点的数值
- 在链表的最前面插入一个节点
- 在链表的最后面插入一个节点
- 在链表第 index 个节点前面插入一个节点
- 删除链表的第 index 个节点
可以说这五个接口,已经覆盖了链表的常见操作,是练习链表操作非常好的一道题目
链表操作的两种方式:
- 直接使用原来的链表来进行操作。
- 设置一个虚拟头结点在进行操作。
下面采用的设置一个虚拟头结点(这样更方便一些,大家看代码就会感受出来)。
代码如下,分别是实现单链表、双链表:
// 单链表节点 | |
class ListNode { | |
int val; | |
ListNode next; | |
ListNode() { | |
} | |
ListNode(int val) { | |
this.val = val; | |
} | |
} | |
// 单链表 | |
class MyLinkedList { | |
int size; // 链表长度 | |
ListNode dummyHead; // 虚拟头节点 | |
// 初始化链表 | |
public MyLinkedList() { | |
size = 0; | |
dummyHead = new ListNode(0); | |
} | |
// 获取链表中第 index 个节点的值。如果索引无效,则返回 - 1。 | |
public int get(int index) { | |
if (index < 0 || index >= size) return -1; | |
ListNode cur = dummyHead; | |
for (int i = 0; i < index + 1; i++) { // 包含一个虚拟头节点,所以查找第 index+1 个节点 | |
cur = cur.next; | |
} | |
return cur.val; | |
} | |
// 在链表的第一个元素之前添加一个值为 val 的节点。插入后,新节点将成为链表的第一个节点。 | |
public void addAtHead(int val) { | |
addAtIndex(0, val); | |
} | |
// 将值为 val 的节点追加到链表的最后一个元素。 | |
public void addAtTail(int val) { | |
addAtIndex(size, val); | |
} | |
// 在链表中的第 index 个节点之前添加值为 val 的节点。如果 index 等于链表的长度,则该节点将附加到链表的末尾。 | |
public void addAtIndex(int index, int val) { | |
if (index > size) return; | |
if (index < 0) index = 0; | |
size++; | |
ListNode prev = dummyHead; | |
for (int i = 0; i < index; i++) { | |
prev = prev.next; | |
} | |
ListNode toAdd = new ListNode(val); | |
toAdd.next = prev.next; | |
prev.next = toAdd; | |
} | |
// 如果索引 index 有效,则删除链表中的第 index 个节点。 | |
public void deleteAtIndex(int index) { | |
if (index < 0 || index >= size) return; | |
size--; | |
ListNode prev = dummyHead; | |
for (int i = 0; i < index; i++) { | |
prev = prev.next; | |
} | |
prev.next = prev.next.next; | |
} | |
} |
// 双链表节点 | |
class ListNode { | |
int val; | |
ListNode prev; | |
ListNode next; | |
ListNode() { | |
} | |
ListNode(int val) { | |
this.val = val; | |
} | |
} | |
// 双链表 | |
class MyLinkedList { | |
int size; // 链表长度 | |
ListNode dummyHead, tail; // 虚拟头节点,尾节点 | |
// 初始化链表 | |
public MyLinkedList() { | |
size = 0; | |
dummyHead = new ListNode(0); | |
tail = new ListNode(0); | |
// 这一步很重要,不然在 addAtHead 时,会出现空指针异常,即 null.next 错误 | |
dummyHead.next = tail; | |
tail.prev = dummyHead; | |
} | |
// 获取链表中第 index 个节点的值。如果索引无效,则返回 - 1。 | |
public int get(int index) { | |
if (index < 0 || index >= size) return -1; | |
ListNode cur = dummyHead; | |
// 判断 index 在前半段还是后半段,从而使遍历时间更短 | |
if (index >= size / 2) { // 使用 tail 遍历 | |
cur = tail; | |
for (int i = size; i > index; i--) { | |
cur = cur.prev; | |
} | |
} else { // 使用 dummyHead 遍历 | |
for (int i = 0; i <= index; i++) { | |
cur = cur.next; | |
} | |
} | |
return cur.val; | |
} | |
// 在链表的第一个元素之前添加一个值为 val 的节点。插入后,新节点将成为链表的第一个节点。 | |
public void addAtHead(int val) { | |
addAtIndex(0, val); | |
} | |
// 将值为 val 的节点追加到链表的最后一个元素。 | |
public void addAtTail(int val) { | |
addAtIndex(size, val); | |
} | |
// 在链表中的第 index 个节点之前添加值为 val 的节点。如果 index 等于链表的长度,则该节点将附加到链表的末尾。 | |
public void addAtIndex(int index, int val) { | |
if (index > size) return; | |
if (index < 0) index = 0; | |
size++; | |
ListNode pre = dummyHead; // 找到前驱节点 | |
for (int i = 0; i < index; i++) { | |
pre = pre.next; | |
} | |
ListNode toAdd = new ListNode(val); | |
toAdd.next = pre.next; | |
toAdd.prev = pre; | |
pre.next.prev = toAdd; | |
pre.next = toAdd; | |
} | |
// 如果索引 index 有效,则删除链表中的第 index 个节点。 | |
public void deleteAtIndex(int index) { | |
if (index < 0 || index >= size) return; | |
size--; | |
ListNode pre = dummyHead; | |
for (int i = 0; i < index; i++) { | |
pre = pre.next; | |
} | |
pre.next = pre.next.next; | |
pre.next.prev = pre; | |
} | |
} |
# 206. 反转链表
反转链表的写法很简单,一些同学甚至可以背下来但过一阵就忘了该咋写,主要是因为没有理解真正的反转过程。
不需要再浪费内存去定义一个新的链表来实现反转,只需要改变链表的 next 指针的指向,直接将链表反转:
# 双指针法
举例,如动画所示:(纠正:动画应该是先移动 pre,在移动 cur)
首先定义一个 cur 指针,指向头结点,再定义一个 pre 指针,初始化为 null。
然后就要开始反转了,首先要把 cur->next 节点用 tmp 指针保存一下,也就是保存一下这个节点。
为什么要保存一下这个节点呢,因为接下来要改变 cur->next 的指向了,将 cur->next 指向 pre ,此时已经反转了第一个节点了。
接下来,就是循环走如下代码逻辑了,继续移动 pre 和 cur 指针。
最后,cur 指针已经指向了 null,循环结束,链表也反转完毕了。 此时我们 return pre 指针就可以了,pre 指针就指向了新的头结点。
class Solution { | |
// 双指针法 | |
public ListNode reverseList(ListNode head) { | |
ListNode pre = null; | |
ListNode cur = head; | |
while (cur != null) { | |
ListNode tmp = cur.next; // 暂存下一个节点 | |
cur.next = pre; // 反转操作 | |
// 更新 pre、cur(指针后移) | |
pre = cur; // 先更新 pre,否则当 cur.next = null 时会出错 | |
cur = tmp; | |
} | |
return pre; | |
} | |
} |
# 递归法
递归法相对抽象一些,但是其实和双指针法是一样的逻辑,同样是当 cur 为空的时候循环结束,不断将 cur 指向 pre 的过程。
关键是初始化的地方,可能有的同学会不理解, 可以看到双指针法中初始化 cur = head,pre = NULL,在递归法中可以从如下代码看出初始化的逻辑也是一样的,只不过写法变了。
具体可以看代码,双指针法写出来之后,理解如下递归写法就不难了,代码逻辑都是一样的。
// 递归:从前往后 | |
class Solution { | |
public ListNode reverseList(ListNode head) { | |
return reverse(null, head); // 将参数 prev 初始化为 null,参数 cur 初始化为 head。与双指针法一样。 | |
} | |
private ListNode reverse(ListNode prev, ListNode cur) { | |
if (cur == null) return prev; // 递归终止条件 | |
ListNode tmp = cur.next; // 暂存下一个节点 | |
cur.next = prev; // 反转 | |
return reverse(cur, tmp); // 递归 | |
} | |
} |
# 24. 两两交换链表中的节点
这道题目正常模拟就可以了。
建议使用虚拟头结点,这样会方便很多,要不然每次针对头结点(没有前一个指针指向头结点),还要单独处理。
接下来就是交换相邻两个元素了,此时一定要画图,不画图,操作多个指针很容易乱,而且要操作的先后顺序初始时,cur 指向虚拟头结点,然后进行如下三步:
操作之后,链表如下:
看这个可能就更直观一些了:
代码如下:
// 虚拟头结点 | |
class Solution { | |
public ListNode swapPairs(ListNode head) { | |
// 初始化虚拟头节点 | |
ListNode dummyNode = new ListNode(0); | |
dummyNode.next = head; | |
//cur 指向当前需要交换的两个节点的前一个节点 | |
ListNode cur = dummyNode; // 这里的 = 的拷贝类型是浅拷贝,所以 cur 和 dummyNode 指向的是同一个对象 | |
while (cur.next != null && cur.next.next != null) { // 链表中至少有两个节点,确保需要交换 | |
ListNode tmp = head.next.next; // 暂存第三个节点 | |
cur.next = head.next; // 步骤 1 | |
head.next.next = head; // 步骤 2 | |
head.next = tmp; // 步骤 3 | |
cur = head; // 步进 1 个节点,即交换后的第二个节点 | |
head = head.next; // 步进 1 个节点 | |
} | |
return dummyNode.next; | |
} | |
} |
// 递归 | |
class Solution { | |
public ListNode swapPairs(ListNode head) { | |
// 递归终止条件:链表为空或者链表只有一个节点 | |
if (head == null || head.next == null) { | |
return head; | |
} | |
// 获取要交换的两个节点在交换前的第二个节点 | |
ListNode cur = head.next; | |
// 进行递归 | |
ListNode newHead = swapPairs(cur.next); | |
// 交换 | |
cur.next = head; | |
head.next = newHead; | |
return cur; | |
} | |
} |
# 19. 删除链表的倒数第 n 个结点
双指针的经典应用
- 一个注意的地方:操作指针需指向欲删除结点的上一个结点。
- 一个难点:** 如何找到倒数第 n 个结点?** 下面围绕该问题展开。
分为如下几步:
- 首先这里我推荐大家使用虚拟头结点,这样方便处理删除实际头结点的逻辑
- 定义 fast 指针和 slow 指针,初始值为虚拟头结点,如图:
- fast 首先走 n + 1 步 ,为什么是 n+1 呢,因为只有这样同时移动的时候slow 才能指向删除节点的上一个节点(方便做删除操作),如图:
- fast 和 slow 同时移动,直到 fast 指向 null,如题:
- 删除 slow 指向的下一个节点,如图:
class Solution { | |
public ListNode removeNthFromEnd(ListNode head, int n) { | |
ListNode dummyHead = new ListNode(0, head); // 虚拟头节点 | |
ListNode slowNode = dummyHead; // 慢指针 | |
ListNode fastNode = dummyHead; // 快指针 | |
// 快指针先走 n+1 步 | |
for (int i = 0; i < n + 1; i++) { | |
fastNode = fastNode.next; | |
} | |
// 快、慢指针同时移动,直至快指针到达链表尾部 | |
while (fastNode != null) { | |
slowNode = slowNode.next; | |
fastNode = fastNode.next; | |
} | |
// 此时慢指针指向待删除节点的前一个节点,执行删除操作 | |
slowNode.next = slowNode.next.next; | |
return dummyHead.next; | |
} | |
} |
# 面试题 02.07. 链表相交
简单来说,就是求两个链表交点节点的指针。 这里同学们要注意,交点不是数值相等,而是指针相等(为了方便举例,下图中假设节点元素数值相等,则节点指针相等)。
看如下两个链表,目前 curA 指向链表 A 的头结点,curB 指向链表 B 的头结点:
我们求出两个链表的长度,并求出两个链表长度的差值,然后让 curA 移动到,和 curB 末尾对齐的位置,如图:
此时我们就可以比较 curA 和 curB 是否相同
- 如果不相同,同时向后移动 curA 和 curB
- 如果遇到 curA == curB,则找到交点
否则循环退出返回空指针。
public class Solution { | |
public ListNode getIntersectionNode(ListNode headA, ListNode headB) { | |
ListNode curA = headA; | |
ListNode curB = headB; | |
int lenA = 0, lenB = 0; | |
while (curA != null) { // 计算链表 A 的长度 | |
lenA++; | |
curA = curA.next; | |
} | |
while (curB != null) { // 计算链表 B 的长度 | |
lenB++; | |
curB = curB.next; | |
} | |
// 重置 curA 和 curB | |
curA = headA; | |
curB = headB; | |
// 令 curA 指向较长的链表,curB 指向较短的链表 | |
if (lenA < lenB) { | |
// 交换 lenA 和 lenB | |
int tmpLen = lenA; | |
lenA = lenB; | |
lenB = tmpLen; | |
// 交换 curA 和 curB | |
ListNode tmpNode = curA; | |
curA = curB; | |
curB = tmpNode; | |
} | |
// 计算长度差 | |
int gap = lenA - lenB; | |
//curA 先走 gap 步,保证 curA 和 curB 的末尾对齐 | |
while (gap > 0) { | |
curA = curA.next; | |
gap--; | |
} | |
// 同时遍历 curA 和 curB,寻找相交的节点 | |
while (curA != null && curB != null) { | |
if (curA == curB) { | |
return curA; | |
} | |
curA = curA.next; | |
curB = curB.next; | |
} | |
// 未找到相交节点,返回 null | |
return null; | |
} | |
} |
# 142. 环形链表 II
找到有没有环已经很不容易了,还要让我找到环的入口?
主要考察两知识点:
- 判断链表是否环
- 如果有环,如何找到这个环的入口
# 判断链表是否有环
可以使用快慢指针法,分别定义 fast 和 slow 指针,从头结点出发,fast 指针每次移动两个节点,slow 指针每次移动一个节点,如果 fast 和 slow 指针在途中相遇 ,说明这个链表有环。
为什么 fast 走两个节点,slow 走一个节点,有环的话,一定会在环内相遇呢,而不是永远的错开呢
首先第一点:fast 指针一定先进入环中,如果 fast 指针和 slow 指针相遇的话,一定是在环中相遇,这是毋庸置疑的。
那么来看一下,为什么 fast 指针和 slow 指针一定会相遇呢?
可以画一个环,然后让 fast 指针在任意一个节点开始追赶 slow 指针。
会发现最终都是这种情况, 如下图:
fast 和 slow 各自再走一步, fast 和 slow 就相遇了
这是因为 fast 是走两步,slow 是走一步,其实相对于 slow 来说,fast 是一个节点一个节点的靠近 slow 的,所以 fast 一定可以和 slow 重合。
动画如下:
# 如果有环,如何找到这个环的入口
此时已经可以判断链表是否有环了,那么接下来要找这个环的入口了。
假设从头结点到环形入口节点 的节点数为 x。 环形入口节点到 fast 指针与 slow 指针相遇节点 节点数为 y。 从相遇节点 再到环形入口节点节点数为 z。 如图所示:
那么相遇时: slow 指针走过的节点数为: x + y
, fast 指针走过的节点数: x + y + n (y + z)
,n 为 fast 指针在环内走了 n 圈才遇到 slow 指针, (y+z)为 一圈内节点的个数 A。
因为 fast 指针是一步走两个节点,slow 指针一步走一个节点, 所以 fast 指针走过的节点数 = slow 指针走过的节点数 * 2:
(x + y) * 2 = x + y + n (y + z)
两边消掉一个(x+y): x + y = n (y + z)
因为要找环形的入口,那么要求的是 x,因为 x 表示 头结点到 环形入口节点的的距离。
所以要求 x ,将 x 单独放在左面: x = n (y + z) - y
,
再从 n (y+z) 中提出一个 (y+z)来,整理公式之后为如下公式: x = (n - 1) (y + z) + z
注意这里 n 一定是大于等于 1 的,因为 fast 指针至少要多走一圈才能相遇 slow 指针。
这个公式说明什么呢?
先拿 n 为 1 的情况来举例,意味着 fast 指针在环形里转了一圈之后,就遇到了 slow 指针了。
当 n 为 1 的时候,公式就化解为 x = z
,
这就意味着,从头结点出发一个指针,从相遇节点 也出发一个指针,这两个指针每次只走一个节点, 那么当这两个指针相遇的时候就是 环形入口的节点。
也就是在相遇节点处,定义一个指针 index1,在头结点处定一个指针 index2。
让 index1 和 index2 同时移动,每次移动一个节点, 那么他们相遇的地方就是 环形入口的节点。
动画如下:
那么 n 如果大于 1 是什么情况呢,就是 fast 指针在环形转 n 圈之后才遇到 slow 指针。
其实这种情况和 n 为 1 的时候 效果是一样的,一样可以通过这个方法找到 环形的入口节点,只不过,index1 指针在环里 多转了 (n-1) 圈,然后再遇到 index2,相遇点依然是环形的入口节点。
public class Solution { | |
public ListNode detectCycle(ListNode head) { | |
ListNode fast = head; | |
ListNode slow = head; | |
while (fast != null && fast.next != null) { // 确保 fast 可以大胆走两步 | |
fast = fast.next.next; | |
slow = slow.next; | |
if (fast == slow) { // 此时 fast 追上 slow 相遇,说明有环 | |
ListNode index1 = head; | |
ListNode index2 = slow; // 相遇点,也可以是 fast,因为 fast==slow | |
while (index1 != index2) { // 两个指针同时走,相遇的地方就是环的入口 | |
index1 = index1.next; | |
index2 = index2.next; | |
} | |
return index1; // 返回环的入口,也可以返回 index2 | |
} | |
} | |
return null; // 链表为空,或者链表无环 | |
} | |
} |
# 补充
在推理过程中,大家可能有一个疑问就是:为什么第一次在环中相遇,slow 的 步数 是 x+y 而不是 x + 若干环的长度 + y 呢?
首先 slow 进环的时候,fast 一定是先进环来了。
如果 slow 进环入口,fast 也在环入口,那么把这个环展开成直线,就是如下图的样子:
可以看出如果 slow 和 fast 同时在环入口开始走,一定会在环入口 3 相遇,slow 走了一圈,fast 走了两圈。
重点来了,slow 进环的时候,fast 一定是在环的任意一个位置,如图:
那么 fast 指针走到环入口 3 的时候,已经走了 k + n 个节点,slow 相应的应该走了 (k + n) / 2 个节点。
因为 k 是小于 n 的(图中可以看出),所以 (k + n) / 2 一定小于 n。
也就是说 slow 一定没有走到环入口 3,而 fast 已经到环入口 3 了。
这说明什么呢?
在 slow 开始走的那一环已经和 fast 相遇了。
那有同学又说了,为什么 fast 不能跳过去呢? 在刚刚已经说过一次了,fast 相对于 slow 是一次移动一个节点,所以不可能跳过去。
好了,这次把为什么第一次在环中相遇,slow 的 步数 是 x+y 而不是 x + 若干环的长度 + y ,用数学推理了一下,算是对链表:环找到了,那入口呢?的补充。
# 链表总结篇
# 链表的理论基础
在这篇文章链表理论基础中,介绍了如下几点:
- 链表的种类主要为:单链表,双链表,循环链表
- 链表的存储方式:链表的节点在内存中是分散存储的,通过指针连在一起。
- 链表是如何进行增删改查的。
- 数组和链表在不同场景下的性能分析。
可以说把链表基础的知识都概括了,但又不像教科书那样的繁琐。
# 链表经典题目
# 虚拟头节点
在 [203. 移除链表元素](#203. 移除链表元素) 中,我们讲解了链表操作中一个非常总要的技巧:虚拟头节点。
链表的一大问题就是操作当前节点必须要找前一个节点才能操作。这就造成了,头结点的尴尬,因为头结点没有前一个节点了。
每次对应头结点的情况都要单独处理,所以使用虚拟头结点的技巧,就可以解决这个问题。
在 [203. 移除链表元素](#203. 移除链表元素) 中,我给出了用虚拟头结点和没用虚拟头结点的代码,大家对比一下就会发现,使用虚拟头结点的好处。
# 链表的基本操作
在 [707. 设计链表](#707. 设计链表) 中,我们通设计链表把链表常见的五个操作练习了一遍。
这是练习链表基础操作的非常好的一道题目,考察了:
- 获取链表第 index 个节点的数值
- 在链表的最前面插入一个节点
- 在链表的最后面插入一个节点
- 在链表第 index 个节点前面插入一个节点
- 删除链表的第 index 个节点的数值
可以说把这道题目做了,链表基本操作就 OK 了,再也不用担心链表增删改查整不明白了。
这里我依然使用了虚拟头结点的技巧,大家复习的时候,可以去看一下代码。
# 反转链表
在 [206. 反转链表](#206. 反转链表) 中,讲解了如何反转链表。
因为反转链表的代码相对简单,有的同学可能直接背下来了,但一写还是容易出问题。
反转链表是面试中高频题目,很考察面试者对链表操作的熟练程度。
我在 [206. 反转链表](#206. 反转链表) 中,给出了两种反转的方式,迭代法和递归法。
建议大家先学透迭代法,然后再看递归法,因为递归法比较绕,如果迭代还写不明白,递归基本也写不明白了。
可以先通过迭代法,彻底弄清楚链表反转的过程!
# 删除倒数第 N 个节点
在 [19. 删除链表的倒数第 n 个结点](#19. 删除链表的倒数第 n 个结点) 中我们结合虚拟头结点 和 双指针法来移除链表倒数第 N 个节点。
# 链表相交
[面试题 02.07. 链表相交](# 面试题 02.07. 链表相交) 使用双指针来找到两个链表的交点(引用完全相同,即:内存地址完全相同的交点)
# 环形链表
在 [142. 环形链表 II](#142. 环形链表 II) 中,讲解了在链表如何找环,以及如何找环的入口位置。
这道题目可以说是链表的比较难的题目了。 但代码却十分简洁,主要在于一些数学证明。
# 哈希表
# 理论基础
# 哈希表
hash table,又称散列表
是根据关键码的值而直接进行访问的数据结构。
直白来讲其实数组就是一张哈希表。
哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素,如下图所示:
那么哈希表能解决什么问题呢,一般哈希表都是用来快速判断一个元素是否出现集合里。
例如要查询一个名字是否在这所学校里。
要枚举的话时间复杂度是 O (n),但如果 ** 使用哈希表的话, 只需要 O (1)** 就可以做到。
我们只需要初始化把这所学校里学生的名字都存在哈希表里,在查询的时候通过索引直接就可以知道这位同学在不在这所学校里了。
将学生姓名映射到哈希表上就涉及到了哈希函数。
# 哈希函数
哈希函数,把学生的姓名直接映射为哈希表上的索引,然后就可以通过查询索引下标快速知道这位同学是否在这所学校里了。
哈希函数如下图所示,通过 hashCode 把名字转化为数值,一般 hashcode 是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把学生名字映射为哈希表上的索引数字了。
如果 hashCode 得到的数值大于 哈希表的大小了,也就是大于 tableSize 了,怎么办呢?
此时为了保证映射出来的索引数值都落在哈希表上,我们会在再次对数值做一个取模的操作,就要我们就保证了学生姓名一定可以映射到哈希表上了。
# 哈希碰撞
同时映射到哈希表同一索引下标的位置的现象,称为哈希碰撞。
# 拉链法
刚刚小李和小王在索引 1 的位置发生了冲突,发生冲突的元素都被存储在链表中。 这样我们就可以通过索引找到小李和小王了
(数据规模是 dataSize, 哈希表的大小为 tableSize)
其实拉链法就是要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
# 线性探测法
使用线性探测法,一定要保证 tableSize 大于 dataSize。 我们需要依靠哈希表中的空位来解决碰撞问题。
例如冲突的位置,放了小李,那么就向下找一个空位放置小王的信息。所以要求 tableSize 一定要大于 dataSize ,要不然哈希表上就没有空置的位置来存放冲突的数据了。如图所示:
其实关于哈希碰撞还有非常多的细节,感兴趣的同学可以再好好研究一下,这里我就不再赘述了。
# 常见的三种哈希结构
# 数组
没啥可说的
# 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 值会导致整棵树的错乱,所以只能删除和增加。
# map(映射)
映射 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
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 的底层实现是红黑树,不是哈希表,std::set、std::multiset 使用红黑树来索引和存储,不过给我们的使用方式,还是哈希法的使用方式,即 key 和 value。所以使用这些数据结构来解决映射问题的方法,我们依然称之为哈希法。 map 也是一样的道理。
这里在说一下,一些 C++ 的经典书籍上 例如 STL 源码剖析,说到了 hash_set hash_map,这个与 unordered_set,unordered_map 又有什么关系呢?
实际上功能都是一样一样的, 但是 unordered_set 在 C11 的时候被引入标准库了,而 hash_set 并没有,所以建议还是使用 unordered_set 比较好,这就好比一个是官方认证的,hash_set,hash_map 是 C11 标准之前民间高手自发造的轮子。
# 总结
总结一下,当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。
但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set 或者是 map 来存放数据,才能实现快速的查找。
如果在做面试题目的时候遇到需要判断一个元素是否出现过的场景也应该第一时间想到哈希法!
# 242. 有效的字母异位词
数组就是简单的哈希表,但是数组的大小可不是无限开辟的
先看暴力的解法,两层 for 循环,同时还要记录字符是否重复出现,很明显时间复杂度是 O (n^2)。
暴力的方法这里就不做介绍了,直接看一下有没有更优的方式。
数组其实就是一个简单哈希表,而且这道题目中字符串只有小写字符,那么就可以定义一个数组,来记录字符串 s 里字符出现的次数。
需要定义一个多大的数组呢,定一个数组叫做 record,大小为 26 就可以了,初始化为 0,因为字符 a 到字符 z 的 ASCII 也是 26 个连续的数值。
为了方便举例,判断一下字符串 s= "aee", t = "eae"。
操作动画如下:
定义一个数组叫做 record 用来记录字符串 s 里字符出现的次数。
需要把字符映射到数组也就是哈希表的索引下标上,因为字符 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)。
class Solution { | |
// 时间复杂度:O (n),空间复杂度:O (1) | |
public boolean isAnagram(String s, String t) { | |
int[] record = new int[26]; | |
for (int i = 0; i < s.length(); i++) { | |
record[s.charAt(i) - 'a']++; // 记录 s 中每个字符出现的次数 | |
} | |
for (int i = 0; i < t.length(); i++) { | |
record[t.charAt(i) - 'a']--; // 记录 t 中每个字符出现的次数 | |
} | |
for (int count : record) { | |
if (count != 0) { // 如果 s 和 t 中每个字符出现的次数都相同,则 count 数组中每个元素都为 0 | |
return false; | |
} | |
} | |
return true; | |
} | |
} |
# 383. 赎金信
在哈希法中有一些场景就是为数组量身定做的。
# 暴力解法
// 暴力解法,时间复杂度 O (n^2),空间复杂度 O (1) | |
class Solution { | |
public boolean canConstruct(String ransomNote, String magazine) { | |
for (int i = 0; i < magazine.length(); i++) { | |
for (int j = 0; j < ransomNote.length(); j++) { | |
if (ransomNote.charAt(j) == magazine.charAt(i)) { | |
ransomNote = ransomNote.substring(0, j) + ransomNote.substring(j + 1); // 从字符串中删除该字符,substring (0,j) 表示从 0 到 j-1 的字符串,substring (j+1) 表示从 j+1 到最后的字符串,要头不要尾 | |
break; | |
} | |
} | |
} | |
return ransomNote.length() == 0; | |
} | |
} |
# 哈希解法
因为题目所只有小写字母,那可以采用空间换取时间的哈希策略, 用一个长度为 26 的数组还记录 magazine 里字母出现的次数。
然后再用 ransomNote 去验证这个数组是否包含了 ransomNote 所需要的所有字母。
依然是数组在哈希法中的应用。
一些同学可能想,用数组干啥,都用 map 完事了,其实在本题的情况下,使用 map 的空间消耗要比数组大一些的,因为 map 要维护红黑树或者哈希表,而且还要做哈希函数,是费时的!数据量大的话就能体现出来差别了。 所以数组更加简单直接有效!
代码如下:
// 哈希解法,时间复杂度 O (n),空间复杂度 O (1) | |
class Solution { | |
public boolean canConstruct(String ransomNote, String magazine) { | |
int[] record = new int[26]; | |
for (char c : magazine.toCharArray()) { | |
record[c - 'a']++; // 记录 magazine 中每个字符出现的次数 | |
} | |
for (char c : ransomNote.toCharArray()) { | |
record[c - 'a']--; //ransomNote 中每个字符出现的次数减一 | |
} | |
for (int i : record) { | |
if (i < 0) { // 如果出现次数小于 0,说明 magazine 中没有 ransomNote 中的某个字符 | |
return false; | |
} | |
} | |
return true; | |
} | |
} |
// 哈希解法,时间复杂度 O (n),空间复杂度 O (1) | |
class Solution { | |
public boolean canConstruct(String ransomNote, String magazine) { | |
int[] record = new int[26]; | |
for (char c : magazine.toCharArray()) { | |
record[c - 'a']++; // 记录 magazine 中每个字符出现的次数 | |
} | |
for (char c : ransomNote.toCharArray()) { | |
if (record[c - 'a'] == 0) { | |
return false; | |
} | |
record[c - 'a']--; // 减去 ransomNote 中每个字符出现的次数 | |
} | |
return true; | |
} | |
} |
# 349. 两个数组的交集
如果哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费!
这道题目,主要要学会使用一种哈希数据结构:unordered_set,这个数据结构可以解决很多类似的问题。
注意题目特意说明:输出结果中的每个元素一定是唯一的,也就是说输出的结果的去重的, 同时可以不考虑输出结果的顺序
这道题用暴力的解法时间复杂度是 O (n^2),那来看看使用哈希法进一步优化。
那么用数组来做哈希表也是不错的选择,例如 242. 有效的字母异位词
但是要注意,使用数组来做哈希的题目,是因为题目都限制了数值的大小。
而这道题目没有限制数值的大小,就无法使用数组来做哈希表了。
而且如果哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。
此时就要使用另一种结构体了,set ,关于 set,C++ 给提供了如下三种可用的数据结构:
- std::set
- std::multiset
- std::unordered_set
std::set 和 std::multiset 底层实现都是红黑树,std::unordered_set 的底层实现是哈希表, 使用 unordered_set 读写效率是最高的,并不需要对数据进行排序,而且还不要让数据重复,所以选择 unordered_set。
思路如图所示:
//set,时间复杂度 O (n+m),空间复杂度 O (n+m) | |
class Solution { | |
public int[] intersection(int[] nums1, int[] nums2) { | |
if (nums1 == null || nums1.length == 0 || nums2 == null || nums2.length == 0) | |
return new int[0]; // 两个数组有一个为空,直接返回空数组 | |
Set<Integer> set = new HashSet<>(); | |
for (int num : nums1) { | |
set.add(num); // 将 nums1 中的元素添加到 set 中 | |
} | |
Set<Integer> res = new HashSet<>(); | |
for (int num : nums2) { | |
if (set.contains(num)) { // 如果 nums2 中的元素在 set 中存在,说明是交集,添加到 res 中 | |
res.add(num); | |
} | |
} | |
// 将 res 转换为数组 | |
return res.stream().mapToInt(Integer::valueOf).toArray(); //stream () 将集合转换为流,mapToInt () 将流中的元素转换为 int 类型,toArray () 将流转换为数组 | |
} | |
} |
# 拓展
那有同学可能问了,遇到哈希问题我直接都用 set 不就得了,用什么数组啊。
直接使用 set 不仅占用空间比数组大,而且速度要比数组慢,set 把数值映射到 key 上都要做 hash 计算的。
不要小瞧 这个耗时,在数据量大的情况,差距是很明显的。
# 后记
本题后面 力扣改了 题目描述 和 后台测试数据,增添了 数值范围:
- 1 <= nums1.length, nums2.length <= 1000
- 0 <= nums1[i], nums2[i] <= 1000
所以就可以 使用数组来做哈希表了, 因为数组都是 1000 以内的。
# 202. 快乐数
该用 set 的时候,还是得用 set
这道题目看上去貌似一道数学问题,其实并不是!
题目中说了会 无限循环,那么也就是说求和的过程中,sum 会重复出现,这对解题很重要!
当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法了。
所以这道题目使用哈希法,来判断这个 sum 是否重复出现,如果重复了就是 return false, 否则一直找到 sum 为 1 为止。
判断 sum 是否重复出现就可以使用 unordered_set。
还有一个难点就是求和的过程,如果对取数值各个位上的单数操作不熟悉的话,做这道题也会比较艰难。
class Solution { | |
public boolean isHappy(int n) { | |
Set<Integer> record = new HashSet<>(); | |
while (n != 1 && !record.contains(n)) { // 当 n 不为 1 且 n 不在 record 中时,循环 | |
record.add(n); | |
n = getNext(n); | |
} // 当 n 为 1(可确定输入的 n 是快乐数),或 n 在 record 中(求和结果重复循环出现)时,跳出循环 | |
return n == 1; | |
} | |
private int getNext(int n) { | |
int totalSum = 0; | |
while (n > 0) { | |
int d = n % 10; // 取 n 的个位数 | |
n = n / 10; // 去掉 n 的个位数 | |
totalSum += d * d; // 平方和 | |
} | |
return totalSum; | |
} | |
} |
# 1. 两数之和
很明显暴力的解法是两层 for 循环查找,时间复杂度是 O (n^2)。
首先我在强调一下 什么时候使用哈希法,当我们需要查询一个元素是否出现过,或者一个元素是否在集合里的时候,就要第一时间想到哈希法。
本题呢,我就需要一个集合来存放我们遍历过的元素,然后在遍历数组的时候去询问这个集合,某元素是否遍历过,也就是 是否出现在这个集合。
那么我们就应该想到使用哈希法了。
再来看一下使用数组和 set 来做哈希法的局限。
- 数组:大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
- set:是一个集合,里面放的元素只能是一个 key,而两数之和这道题目,不仅要判断 y 是否存在而且还要记录 y 的下标位置,因为要返回 x 和 y 的下标。所以 set 也不能用。
因为本地,我们不仅要知道元素有没有遍历过,还有知道这个元素对应的下标,需要使用 key value 结构来存放,key 来存元素,value 来存下标,那么使用 map 正合适。
此时就要选择另一种数据结构:map ,map 是一种 key value 的存储结构,可以用 key 保存数值,用 value 在保存数值所在的下标。
C++ 中 map,有三种类型:
映射 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
std::map | 红黑树 | key 有序 | key 不可重复 | key 不可修改 | O(log n) | O(log n) |
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 也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解)。 更多哈希表的理论知识请看关于哈希表,你该了解这些!。
这道题目中并不需要 key 有序,选择 std::unordered_map 效率更高! 使用其他语言的录友注意了解一下自己所用语言的数据结构就行。
接下来需要明确两点:
- map 用来做什么
- map 中 key 和 value 分别表示什么
map 目的用来存放我们访问过的元素,因为遍历数组的时候,需要记录我们之前遍历过哪些元素和对应的下表,这样才能找到与当前元素相匹配的(也就是相加等于 target)
接下来是 map 中 key 和 value 分别表示什么。
这道题 我们需要 给出一个元素,判断这个元素是否出现过,如果出现过,返回这个元素的下标。
那么判断元素是否出现,这个元素就要作为 key,所以数组中的元素作为 key,有 key 对应的就是 value,value 用来存下标。
所以 map 中的存储结构为 {key:数据元素,value:数组元素对应的下表}。
在遍历数组的时候,只需要向 map 去查询是否有和目前遍历元素比配的数值,如果有,就找到的匹配对,如果没有,就把目前遍历的元素放进 map 中,因为 map 存放的就是我们访问过的元素。
过程如下:
class Solution { | |
public int[] twoSum(int[] nums, int target) { | |
// 输出:符合条件的两个正数的下标,故用一个大小为 2 的数组来存储 | |
int[] result = new int[2]; | |
// 特判,如果数组为空,直接返回空数组 | |
if (nums == null || nums.length == 0) { | |
return result; | |
} | |
// 用一个 map 来存储数组中访问过的元素,key 为元素值,value 为元素下标 | |
Map<Integer, Integer> map = new HashMap<>(); | |
// 遍历数组 | |
for (int i = 0; i < nums.length; i++) { | |
// 判断 target-nums [i] 是否访问过(即 map 的 key 中是否存在) | |
if (map.containsKey(target - nums[i])) { | |
// 找到了符合条件的两个数,将下标存入 result 数组中 | |
result[0] = map.get(target - nums[i]); | |
result[1] = i; | |
break; | |
} | |
// 将当前元素存入 map 中 | |
map.put(nums[i], i); | |
} | |
// 没有找到符合条件的两个数,返回空数组 | |
return result; | |
} | |
} |
# 总结
本题其实有四个重点:
- 为什么会想到用哈希表
因为题目要求找到符合条件的元素
- 哈希表为什么用 map
因为题目不仅要求找到符合条件的元素,还要返回对应的下标索引
- 本题 map 是用来存什么的
遍历过的元素,及其下标
- map 中的 key 和 value 用来存什么的
key:元素的数值,value:元素的下标
把这四点想清楚了,本题才算是理解透彻了。
很多录友把这道题目 通过了,但都没想清楚 map 是用来做什么的,以至于对代码的理解其实是 一知半解的。
# 454. 四数相加 II
需要哈希的地方都能找到 map 的身影
本题咋眼一看好像和 0015. 三数之和 ,0018. 四数之和 差不多,其实差很多。
本题是使用哈希法的经典题目,而 0015. 三数之和,0018. 四数之和 并不合适使用哈希法,因为三数之和和四数之和这两道题目使用哈希法在不超时的情况下做到对结果去重是很困难的,很有多细节需要处理。
而这道题目是四个独立的数组,只要找到 A [i] + B [j] + C [k] + D [l] = 0 就可以,不用考虑有重复的四个元素相加等于 0 的情况,所以相对于题目 18. 四数之和,题目 15. 三数之和,还是简单了不少!
如果本题想难度升级:就是给出一个数组(而不是四个数组),在这里找出四个元素相加等于 0,答案中不可以包含重复的四元组,大家可以思考一下,后续的文章我也会讲到的。
本题解题步骤:
- 首先定义 一个 unordered_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 就可以了
class Solution { | |
public int fourSumCount(int[] nums1, int[] nums2, int[] nums3, int[] nums4) { | |
// 输出:符合条件的四元组的个数 | |
int count = 0; | |
Map<Integer, Integer> map = new HashMap<>(); //key:a+b 的数值,value:a+b 数值出现的次数 | |
// 遍历 A、B 数组,统计两个数组元素之和 a+b,和出现的次数,放到 map 中 | |
for (int i = 0; i < nums1.length; i++) { | |
for (int j = 0; j < nums2.length; j++) { | |
int sum = nums1[i] + nums2[j]; | |
map.put(sum, map.getOrDefault(sum, 0) + 1); //map.getOrDefault (sum, 0):如果 map 中没有 sum 这个 key,就返回 0,否则返回 sum 对应的 value | |
} | |
} | |
// 遍历 C、D 数组,记录两个数组元素之和 c+d,查询 map 中是否存在 -(c+d),如果存在,就把 map 中 -(c+d) 对应的 value 值加到 count 中 | |
for (int i = 0; i < nums3.length; i++) { | |
for (int j = 0; j < nums4.length; j++) { | |
int sum = nums3[i] + nums4[j]; | |
if (map.containsKey(-sum)) { // 如果 map 中有 - sum 这个 key,即存在两个数的和为 - sum | |
count += map.get(-sum); // 将两个数的和为 - sum 的次数加到 count 中,即为符合条件的四元组的个数 | |
} | |
} | |
} | |
return count; | |
} | |
} |
# 15. 三数之和
用哈希表解决了两数之和,那么三数之和呢?
# 哈希解法
# 双指针
其实这道题目使用哈希法并不十分合适,因为在去重的操作中有很多细节需要注意,在面试中很难直接写出没有 bug 的代码。
而且使用哈希法 在使用两层 for 循环的时候,能做的剪枝操作很有限,虽然时间复杂度是 O (n^2),也是可以在 leetcode 上通过,但是程序的执行时间依然比较长 。
接下来我来介绍另一个解法:双指针法,这道题目使用双指针法要比哈希法高效一些,那么来讲解一下具体实现的思路。
动画效果如下:
拿这个 nums 数组来举例,首先将数组排序,然后有一层 for 循环
- i 从下标 0 的地方开始
- 定义一个下标 left 定义在 i+1 的位置上
- 定义下标 right 在数组结尾的位置上
依然还是在数组中找到 abc 使得 a + b +c =0,我们这里相当于 a = nums [i],b = nums [left],c = nums [right]。
接下来如何移动 left 和 right 呢?
- 如果 nums [i] + nums [left] + nums [right] > 0 就说明 此时三数之和大了,因为数组是排序后了,所以 right 下标就应该向左移动,这样才能让三数之和小一些
- 如果 nums [i] + nums [left] + nums [right] < 0 说明 此时 三数之和小了,left 就向右移动,才能让三数之和大一些
- 直到 left 与 right 相遇为止。
时间复杂度:O (n^2)
// 双指针法,先排序,然后固定一个数,然后双指针。时间复杂度 O (n^2),空间复杂度 O (1) | |
class Solution { | |
public List<List<Integer>> threeSum(int[] nums) { | |
// 输出:一个二维数组,每个元素是一个三元数组 | |
List<List<Integer>> res = new ArrayList<>(); | |
// 1. 排序 | |
Arrays.sort(nums); | |
// 2. 遍历 | |
for (int i = 0; i < nums.length; i++) { | |
// 2.1 剪枝:如果当前数字大于 0,则三数之和一定大于 0,所以结束循环 | |
if (nums[i] > 0) { | |
break; | |
} | |
// 2.2 去重(对 a):如果和前一个数相同,则跳过 | |
if (i > 0 && nums[i] == nums[i - 1]) { | |
continue; | |
} | |
// 2.3 定义左右指针 | |
int left = i + 1; | |
int right = nums.length - 1; | |
// 2.4 遍历 | |
while (left < right) { | |
// 2.4.1 计算 a+b+c | |
int sum = nums[i] + nums[left] + nums[right]; | |
// 2.4.2 如果 a+b+c>0,则 right 左移 | |
if (sum > 0) { | |
right--; | |
} | |
// 2.4.3 如果 a+b+c<0,则 left 右移 | |
else if (sum < 0) { | |
left++; | |
} | |
// 2.4.4 如果 a+b+c=0,则将结果加入 res | |
else { | |
res.add(Arrays.asList(nums[i], nums[left], nums[right])); | |
// 2.4.4.1 对 b 去重:如果和左边的数相同,则 left 右移 | |
while (left < right && nums[left] == nums[left + 1]) { | |
left++; | |
} | |
// 2.4.4.2 对 c 去重:如果和右边的数相同,则 right 左移 | |
while (left < right && nums[right] == nums[right - 1]) { | |
right--; | |
} | |
left++; | |
right--; | |
} | |
} | |
} | |
return res; | |
} | |
} |
# 去重逻辑的思考
说道去重,其实主要考虑三个数的去重。 a, b ,c, 对应的就是 nums [i],nums [left],nums [right]。
# a 的去重
a 如果重复了怎么办,a 是 nums 里遍历的元素,那么应该直接跳过去。
但这里有一个问题,是判断 nums [i] 与 nums [i + 1] 是否相同,还是判断 nums [i] 与 nums [i-1] 是否相同。
有同学可能想,这不都一样吗。
其实不一样!
都是和 nums [i] 进行比较,是比较它的前一个,还是比较他的后一个。
如果我们的写法是 这样:
if (nums[i] == nums[i + 1]) { // 去重操作 | |
continue; | |
} |
那就我们就把三元组中出现重复元素的情况直接 pass 掉了。 例如 {-1, -1 ,2} 这组数据,当遍历到第一个 - 1 的时候,判断 下一个也是 - 1,那这组数据就 pass 了。
我们要做的是 不能有重复的三元组,但三元组内的元素是可以重复的!
所以这里是有两个重复的维度。
那么应该这么写:
if (i > 0 && nums[i] == nums[i - 1]) { | |
continue; | |
} |
这么写就是当前使用 nums [i],我们判断前一位是不是一样的元素,在看 {-1, -1 ,2} 这组数据,当遍历到 第一个 -1 的时候,只要前一位没有 - 1,那么 {-1, -1 ,2} 这组数据一样可以收录到 结果集里。
这是一个非常细节的思考过程。
# b、c 的去重
很多同学写本题的时候,去重的逻辑多加了 对 right 和 left 的去重:(代码中注释部分)
while (right > left) { | |
if (nums[i] + nums[left] + nums[right] > 0) { | |
right--; | |
// 去重 right | |
while (left < right && nums[right] == nums[right + 1]) right--; | |
} else if (nums[i] + nums[left] + nums[right] < 0) { | |
left++; | |
// 去重 left | |
while (left < right && nums[left] == nums[left - 1]) left++; | |
} else { | |
} | |
} |
但细想一下,这种去重其实对提升程序运行效率是没有帮助的。
拿 right 去重为例,即使不加这个去重逻辑,依然根据 while (right > left)
和 if (nums[i] + nums[left] + nums[right] > 0)
去完成 right-- 的操作。
多加了 while (left < right && nums[right] == nums[right + 1]) right--;
这一行代码,其实就是把 需要执行的逻辑提前执行了,但并没有减少 判断的逻辑。
最直白的思考过程,就是 right 还是一个数一个数的减下去的,所以在哪里减的都是一样的。
所以这种去重 是可以不加的。 仅仅是 把去重的逻辑提前了而已。
# 思考
既然三数之和可以使用双指针法,我们之前讲过的 1. 两数之和,可不可以使用双指针法呢?
如果不能,题意如何更改就可以使用双指针法呢?
两数之和就不能使用双指针法,因为 1. 两数之和要求返回的是索引下标, 而双指针法一定要排序,一旦排序之后原数组的索引就被改变了。
如果 1. 两数之和要求返回的是数值的话,就可以使用双指针法了。
# 18. 四数之和
一样的道理,能解决四数之和 那么五数之和、六数之和、N 数之和呢?
四数之和,和 15. 三数之和是一个思路,都是使用双指针法,基本解法就是在 15. 三数之和的基础上再套一层 for 循环。
但是要注意剪枝、去重的操作不一样了。
但是有一些细节需要注意,例如: 不要判断 nums[k] > target
就返回了,三数之和 可以通过 nums[i] > 0
就返回了,因为 0 已经是确定的数了,四数之和这道题目 target 是任意值。比如:数组是 [-4, -3, -2, -1]
, target
是 -10
,不能因为 -4 > -10
而跳过。但是我们依旧可以去做剪枝,** 逻辑变成 nums[i] > target && (nums[i] >=0 || target >= 0)
** 就可以了。
15. 三数之和的双指针解法是一层 for 循环 num [i] 为确定值,然后循环内有 left 和 right 下标作为双指针,找到 nums [i] + nums [left] + nums [right] == 0。
四数之和的双指针解法是两层 for 循环 nums [k] + nums [i] 为确定值,依然是循环内有 left 和 right 下标作为双指针,找出 nums [k] + nums [i] + nums [left] + nums [right] == target 的情况,三数之和的时间复杂度是 O (n^2),四数之和的时间复杂度是 O (n^3) 。
class Solution { | |
public List<List<Integer>> fourSum(int[] nums, int target) { | |
// 输出:一个二维数组,每个元素是一个三元数组 | |
List<List<Integer>> res = new ArrayList<>(); | |
// 1. 排序 | |
Arrays.sort(nums); | |
// 2. 遍历 | |
for (int i = 0; i < nums.length; i++) { | |
// 一级剪枝 | |
if (nums[i] > target && nums[i] > 0) | |
break; | |
// 一级去重 | |
if (i > 0 && nums[i] == nums[i - 1]) | |
continue; | |
for (int j = i + 1; j < nums.length; j++) { | |
// 二级剪枝 | |
if (nums[i] + nums[j] > target && nums[j] > 0) | |
break; | |
// 二级去重(对 a+b) | |
if (j > i + 1 && nums[j] == nums[j - 1]) { | |
continue; | |
} | |
// 2.3 定义左右指针 | |
int left = j + 1; | |
int right = nums.length - 1; | |
// 2.4 遍历 | |
while (left < right) { | |
// 2.4.1 计算 a+b+c+d | |
int sum = nums[i] + nums[j] + nums[left] + nums[right]; | |
// 2.4.2 如果 a+b+c+d>target,则 right 左移 | |
if (sum > target) { | |
right--; | |
} | |
// 2.4.3 如果 a+b+c+d<target,则 left 右移 | |
else if (sum < target) { | |
left++; | |
} | |
// 2.4.4 如果 a+b+c+d=target,则将结果加入 res | |
else { | |
res.add(Arrays.asList(nums[i], nums[j], nums[left], nums[right])); | |
// 2.4.4.1 对 c 去重:如果和左边的数相同,则 left 右移 | |
while (left < right && nums[left] == nums[left + 1]) { | |
left++; | |
} | |
// 2.4.4.2 对 d 去重:如果和右边的数相同,则 right 左移 | |
while (left < right && nums[right] == nums[right - 1]) { | |
right--; | |
} | |
left++; | |
right--; | |
} | |
} | |
} | |
} | |
return res; | |
} | |
} |
那么一样的道理,五数之和、六数之和等等都采用这种解法。
对于 15. 三数之和双指针法就是将原本暴力 O (n^3) 的解法,降为 O (n^2) 的解法,四数之和的双指针解法就是将原本暴力 O (n^4) 的解法,降为 O (n^3) 的解法。
之前我们讲过哈希表的经典题目:454. 四数相加 II ,相对于本题简单很多,因为本题是要求在一个集合中找出四个数相加等于 target,同时四元组不能重复。
而 454. 四数相加 II 是四个独立的数组,只要找到 A [i] + B [j] + C [k] + D [l] = 0 就可以,不用考虑有重复的四个元素相加等于 0 的情况,所以相对于本题还是简单了不少!
我们来回顾一下,几道题目使用了双指针法。
双指针法将时间复杂度:O (n^2) 的解法优化为 O (n) 的解法。也就是降一个数量级,题目如下:
- 27. 移除元素
- 15. 三数之和
- 18. 四数之和
链表相关双指针题目:
- 206. 反转链表
- 19. 删除链表的倒数第 N 个节点
- 面试题 02.07. 链表相交
- 142 题。环形链表 II
双指针法在字符串题目中还有很多应用,后面还会介绍到。
# 总结
# 理论基础
在关于哈希表,你该了解这些! 中,我们介绍了哈希表的基础理论知识,不同于枯燥的讲解,这里介绍了都是对刷题有帮助的理论知识点。
一般来说哈希表都是用来快速判断一个元素是否出现集合里。
对于哈希表,要知道哈希函数和哈希碰撞在哈希表中的作用.
哈希函数是把传入的 key 映射到符号表的索引上。
哈希碰撞处理有多个 key 映射到相同索引上时的情景,处理碰撞的普遍方式是拉链法和线性探测法。
接下来是常见的三种哈希结构:
- 数组
- set(集合)
- map(映射)
在 C++ 语言中,set 和 map 都分别提供了三种数据结构,每种数据结构的底层实现和用途都有所不同,在关于哈希表,你该了解这些! 中我给出了详细分析,这一知识点很重要!
例如什么时候用 std::set,什么时候用 std::multiset,什么时候用 std::unordered_set,都是很有考究的。
只有对这些数据结构的底层实现很熟悉,才能灵活使用,否则很容易写出效率低下的程序。
# 经典题目
# 数组作为哈希表
一些应用场景就是为数组量身定做的。
在 242. 有效的字母异位词 中,我们提到了数组就是简单的哈希表,但是数组的大小是受限的!
这道题目包含小写字母,那么使用数组来做哈希最合适不过。
在 383. 赎金信 中同样要求只有小写字母,那么就给我们浓浓的暗示,用数组!本题和 242. 有效的字母异位词 很像,242. 有效的字母异位词 是求 字符串 a 和 字符串 b 是否可以相互组成,在 383. 赎金信 中是求字符串 a 能否组成字符串 b,而不用管字符串 b 能不能组成字符串 a。
一些同学可能想,用数组干啥,都用 map 不就完事了。
上面两道题目用 map 确实可以,但使用 map 的空间消耗要比数组大一些,因为 map 要维护红黑树或者符号表,而且还要做哈希函数的运算。所以数组更加简单直接有效!
# set作为哈希表
在 349. 两个数组的交集 中我们给出了什么时候用数组就不行了,需要用 set。
这道题目没有限制数值的大小,就无法使用数组来做哈希表了。
主要因为如下两点:
- 数组的大小是有限的,受到系统栈空间(不是数据结构的栈)的限制。
- 如果数组空间够大,但哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。
所以此时一样的做映射的话,就可以使用 set 了。
关于 set,C++ 给提供了如下三种可用的数据结构:(详情请看关于哈希表,你该了解这些! )
- std::set
- std::multiset
- std::unordered_set
std::set 和 std::multiset 底层实现都是红黑树,std::unordered_set 的底层实现是哈希, 使用 unordered_set 读写效率是最高的,本题并不需要对数据进行排序,而且还不要让数据重复,所以选择 unordered_set。
在 202. 快乐数 中,我们再次使用了 unordered_set 来判断一个数是否重复出现过。
# map作为哈希表
在 1. 两数之和 中 map 正式登场。
来说一说:使用数组和 set 来做哈希法的局限。
- 数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
- set 是一个集合,里面放的元素只能是一个 key,而两数之和这道题目,不仅要判断 y 是否存在而且还要记录 y 的下标位置,因为要返回 x 和 y 的下标。所以 set 也不能用。
map 是一种 <key, value>
的结构,本题可以用 key 保存数值,用 value 在保存数值所在的下标。所以使用 map 最为合适。
C++ 提供如下三种 map::(详情请看关于哈希表,你该了解这些! )
- std::map
- std::multimap
- std::unordered_map
std::unordered_map 底层实现为哈希,std::map 和 std::multimap 的底层实现是红黑树。
同理,std::map 和 std::multimap 的 key 也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解),1. 两数之和 中并不需要 key 有序,选择 std::unordered_map 效率更高!
在 454. 四数相加 中我们提到了其实需要哈希的地方都能找到 map 的身影。
本题咋眼一看好像和 18. 四数之和 ,15. 三数之和 差不多,其实差很多!
关键差别是本题为四个独立的数组,只要找到 A [i] + B [j] + C [k] + D [l] = 0 就可以,不用考虑重复问题,而 18. 四数之和 ,15. 三数之和 是一个数组(集合)里找到和为 0 的组合,可就难很多了!
用哈希法解决了两数之和,很多同学会感觉用哈希法也可以解决三数之和,四数之和。
其实是可以解决,但是非常麻烦,需要去重导致代码效率很低。
在 15. 三数之和 中我给出了哈希法和双指针两个解法,大家就可以体会到,使用哈希法还是比较麻烦的。
所以 18. 四数之和,15. 三数之和都推荐使用双指针法!
# 总结
对于哈希表的知识相信很多同学都知道,但是没有成体系。
本篇我们从哈希表的理论基础到数组、set 和 map 的经典应用,把哈希表的整个全貌完整的呈现给大家。
同时也强调虽然 map 是万能的,详细介绍了什么时候用数组,什么时候用 set。
相信通过这个总结篇,大家可以对哈希表有一个全面的了解。
# 字符串
# 344. 反转字符串
打基础的时候,不要太迷恋于库函数。
在反转链表中,使用了双指针的方法。
那么反转字符串依然是使用双指针的方法,只不过对于字符串的反转,其实要比链表简单一些。
因为(单)链表只能借助 next 指针进行反转,所以两个指针必须同向运动,而对字符串的反转,直接交换两侧对应位置的字符即可
因为字符串也是一种数组,所以元素在内存中是连续分布,这就决定了反转链表和反转字符串方式上还是有所差异的。
对于字符串,我们定义两个指针(也可以说是索引下标),一个从字符串前面,一个从字符串后面,两个指针同时向中间移动,并交换元素。
以字符串 hello
为例,过程如下:
// 双指针法 | |
class Solution { | |
public void reverseString(char[] s) { | |
int left = 0; // 指向头 | |
int right = s.length - 1; // 指向尾 | |
for (; left < right; left++, right--) { // 交换 | |
char temp = s[left]; | |
s[left] = s[right]; | |
s[right] = temp; | |
} | |
} | |
} |
交换的两种实现方式
- 交换数值
char temp = s[left]; | |
s[left] = s[right]; | |
s[right] = temp; |
- 位运算
s[left] ^= s[right]; // 这里的 ^ 是异或运算,相同为 0,不同为 1。 | |
s[right] ^= s[left]; | |
s[left] ^= s[right]; |
# 541. 反转字符串 II
简单的反转还不够,我要花式反转
这道题目其实也是模拟,实现题目中规定的反转规则就可以了。
一些同学可能为了处理逻辑:每隔 2k 个字符的前 k 的字符,写了一堆逻辑代码或者再搞一个计数器,来统计 2k,再统计前 k 个字符。
其实在遍历字符串的过程中,只要让 ** i += (2 * k)
**,i 每次移动 2 * k 就可以了,然后判断是否需要有反转的区间。
因为要找的也就是每 2 * k 区间的起点,这样写,程序会高效很多。
所以当需要固定规律一段一段去处理字符串的时候,要想想在在 for 循环的表达式上做做文章。
class Solution { | |
public String reverseStr(String s, int k) { | |
for (int i = 0; i < s.length(); i += 2 * k) { | |
// 1. 每隔 2k 个字符的前 k 个字符进行反转 [i,i+k) | |
// 2. 剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符 [i,i+k) | |
if (i + k <= s.length()) { | |
s = s.substring(0, i) + new StringBuilder(s.substring(i, i + k)).reverse().toString() + s.substring(i + k); | |
} else { | |
// 3. 剩余字符少于 k 个,则将剩余字符全部反转 [i,s.length ()) | |
s = s.substring(0, i) + new StringBuilder(s.substring(i)).reverse().toString(); | |
} | |
} | |
return s; | |
} | |
} |
class Solution { | |
public String reverseStr(String s, int k) { | |
for (int i = 0; i < s.length(); i += 2 * k) { | |
// 1. 每隔 2k 个字符的前 k 个字符进行反转 [i,i+k) | |
// 2. 剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符 [i,i+k) | |
if (i + k <= s.length()) { | |
s = reverse(s, i, i + k - 1); | |
} else { | |
// 3. 剩余字符少于 k 个,则将剩余字符全部反转 [i,s.length ()) | |
s = reverse(s, i, s.length() - 1); | |
} | |
} | |
return s; | |
} | |
private String reverse(String s, int start, int end) { | |
char[] chars = s.toCharArray(); | |
for (int i = start, j = end; i < j; i++, j--) { | |
char temp = chars[i]; | |
chars[i] = chars[j]; | |
chars[j] = temp; | |
} | |
return new String(chars); | |
} | |
} |
# 剑指 Offer 05. 替换空格
# 暴力法
粗鄙!太粗鄙了!
// 暴力法 | |
class Solution { | |
public String replaceSpace(String s) { | |
StringBuilder sb = new StringBuilder(); | |
for (int i = 0; i < s.length(); i++) { | |
if (s.charAt(i) == ' ') { | |
sb.append("%20"); | |
} else { | |
sb.append(s.charAt(i)); | |
} | |
} | |
return sb.toString(); | |
} | |
} |
- 时间复杂度:O (n^2)
- 空间复杂度:O (n)
# 双指针法
如果想把这道题目做到极致,就不要用额外的辅助空间了!
首先扩充数组到每个空格替换成 "%20" 之后的大小。
然后从后向前替换空格,也就是 **双指针法**,过程如下:
i 指向新长度的末尾,j 指向旧长度的末尾。
// 双指针法,从后往前填充,时间复杂度 O (n),空间复杂度 O (1) | |
class Solution { | |
public String replaceSpace(String s) { | |
// 统计空格数 | |
int count = 0; | |
for (int i = 0; i < s.length(); i++) { | |
if (s.charAt(i) == ' ') { | |
count++; | |
} | |
} | |
// 扩充字符串 | |
char[] chars = new char[s.length() + count * 2]; | |
// 双指针,从后往前填充 | |
int i = s.length() - 1; // 指向旧字符串的尾 | |
int j = chars.length - 1; // 指向新字符串的尾 | |
for (; i >= 0; i--) { | |
if (s.charAt(i) == ' ') { | |
chars[j--] = '0'; | |
chars[j--] = '2'; | |
chars[j--] = '%'; | |
} else { | |
chars[j--] = s.charAt(i); | |
} | |
} | |
return new String(chars); | |
} | |
} |
- 时间复杂度:O (n)
- 空间复杂度:O (n)
// 双指针法,从后往前填充,时间复杂度 O (n),空间复杂度 O (1) | |
class Solution { | |
// 方式二:双指针法 | |
public String replaceSpace(String s) { | |
// 特判:空串 | |
if (s == null || s.length() == 0) { | |
return s; | |
} | |
// 扩充的额外空间:空格数量的 2 倍 | |
StringBuilder sb = new StringBuilder(); | |
for (int i = 0; i < s.length(); i++) { | |
if (s.charAt(i) == ' ') { | |
sb.append(" "); | |
} | |
} | |
// 特判:非空串,但没有空格直接返回 | |
if (sb.length() == 0) { | |
return s; | |
} | |
// 有空格情况 定义两个指针 | |
int left = s.length() - 1;// 左指针:指向旧字符串最后一个位置 | |
s += sb.toString(); // 扩充字符串 | |
int right = s.length() - 1;// 右指针:指向新字符串的最后一个位置 | |
char[] chars = s.toCharArray(); | |
for (; left >= 0; left--) { | |
if (chars[left] == ' ') { | |
chars[right--] = '0'; | |
chars[right--] = '2'; | |
chars[right--] = '%'; | |
} else { | |
chars[right--] = chars[left]; | |
} | |
} | |
return new String(chars); | |
} | |
} |
有同学问了,为什么要从后向前填充,从前向后填充不行么?
从前向后填充就是 O (n^2) 的算法了,因为每次添加元素都要将添加元素之后的所有元素向后移动。
其实很多数组填充类的问题,都可以先预先给数组扩容带填充后的大小,然后在从后向前进行操作。
这么做有两个好处:
- 不用申请新数组。
- 从后向前填充元素,避免了从前向后填充元素时,每次添加元素都要将添加元素之后的所有元素向后移动的问题。
时间复杂度,空间复杂度均超过 100% 的用户。
# 151. 反转字符串中的单词
综合考察字符串操作的好题。
提高一下本题的难度:不要使用辅助空间,空间复杂度要求为 O (1)。
不能使用辅助空间之后,那么只能在原字符串上下功夫了。
想一下,我们将整个字符串都反转过来,那么单词的顺序指定是倒序了,只不过单词本身也倒序了,那么再把单词反转一下,单词不就正过来了。
举个例子,源字符串为:"the sky is blue"
- 移除多余空格 : "the sky is blue"
- 字符串反转:"eulb si yks eht"
- 单词反转:"blue is sky the"
所以解题思路如下:
移除多余空格
这部分和 27. 移除元素使用双指针法的逻辑是一样一样的,本题是移除空格,而 27. 移除元素 就是移除元素。
可以做到 O (n) 的时间复杂度。
// 双指针(快慢指针),移除多余空格
private char[] removeExtraSpace(char[] chars) { // 去除所有空格,并在相邻单词间添加空格
int slow = 0;
int fast = 0;
for (; fast < chars.length; fast++) {
if (chars[fast] != ' ') { // 遇到非空格字符,将其复制到 slow 指针的位置
if (slow != 0) // 当前 slow 指针不在字符串开头,说明前面已经有字符了,此时需要添加空格,作为单词间的分隔
chars[slow++] = ' ';
while (fast < chars.length && chars[fast] != ' ') { // 复制连续的非空格字符,直到遇到空格
chars[slow++] = chars[fast++];
}
}
}
return Arrays.copyOfRange(chars, 0, slow); //slow 指针指向最后一个非空格字符的后一个位置 [0, slow)
}
将整个字符串反转
可参考 [344. 反转字符串],采用双指针法,一个从字符串前面,一个从字符串后面,两个指针同时向中间移动,并交换元素。
// 双指针法,反转字符串 s 中的 [start, end] 区间的字符
private void reverse(char[] chars, int start, int end) {
for (int i = start, j = end; i < j; i++, j--) { // 交换区间 [start,end] 两端的字符
char temp = chars[i];
chars[i] = chars[j];
chars[j] = temp;
}
}
将每个单词反转
// 双指针法,反转单词
private void reverseEachWord(char[] chars) {
int start = 0; // 因为 chars 已去除多余空格,所以 0 位置一定是单词的开头
int end = 0;
for (; end <= chars.length; end++) {
if (end == chars.length || chars[end] == ' ') { // 遇到空格,或者到达字符串末尾,说明当前单词结束
reverse(chars, start, end - 1); // 反转 [start, end - 1] 区间的字符
start = end + 1; // 下一个单词的开始位置
}
}
}
完整代码如下:
class Solution { | |
// 双指针(快慢指针),移除多余空格 | |
private char[] removeExtraSpace(char[] chars) { // 去除所有空格,并在相邻单词间添加空格 | |
int slow = 0; | |
int fast = 0; | |
for (; fast < chars.length; fast++) { | |
if (chars[fast] != ' ') { // 遇到非空格字符,将其复制到 slow 指针的位置 | |
if (slow != 0) // 当前 slow 指针不在字符串开头,说明前面已经有字符了,此时需要添加空格,作为单词间的分隔 | |
chars[slow++] = ' '; | |
while (fast < chars.length && chars[fast] != ' ') { // 复制连续的非空格字符,直到遇到空格 | |
chars[slow++] = chars[fast++]; | |
} | |
} | |
} | |
return Arrays.copyOfRange(chars, 0, slow); //slow 指针指向最后一个非空格字符的后一个位置 [0, slow) | |
} | |
// 双指针法,反转字符串 s 中的 [start, end] 区间的字符 | |
private void reverse(char[] chars, int start, int end) { | |
for (int i = start, j = end; i < j; i++, j--) { // 交换区间 [start,end] 两端的字符 | |
char temp = chars[i]; | |
chars[i] = chars[j]; | |
chars[j] = temp; | |
} | |
} | |
// 双指针法,反转单词 | |
private void reverseEachWord(char[] chars) { | |
int start = 0; // 因为 chars 已去除多余空格,所以 0 位置一定是单词的开头 | |
int end = 0; | |
for (; end <= chars.length; end++) { | |
if (end == chars.length || chars[end] == ' ') { // 遇到空格,或者到达字符串末尾,说明当前单词结束 | |
reverse(chars, start, end - 1); // 反转 [start, end - 1] 区间的字符 | |
start = end + 1; // 下一个单词的开始位置 | |
} | |
} | |
} | |
public String reverseWords(String s) { | |
char[] chars = s.toCharArray(); | |
// 去除多余空格 | |
chars = removeExtraSpace(chars); | |
// 反转整个字符串 | |
reverse(chars, 0, chars.length - 1); | |
// 反转每个单词 | |
reverseEachWord(chars); | |
return new String(chars); | |
} | |
} |
# 剑指 Offer 58 - II. 左旋转字符串
反转个字符串还有这么多用处?
为了让本题更有意义,提升一下本题难度:不能申请额外空间,只能在本串上操作。
不能使用额外空间的话,模拟在本串操作要实现左旋转字符串的功能还是有点困难的。
那么我们可以想一下上一题目字符串:花式反转还不够!中讲过,使用整体反转 + 局部反转就可以实现反转单词顺序的目的。
这道题目也非常类似,依然可以通过局部反转 + 整体反转 达到左旋转的目的。
具体步骤为:
- 反转区间为前 n 的子串
- 反转区间为 n 到末尾的子串
- 反转整个字符串
最后就可以达到左旋 n 的目的,而不用定义新的字符串,完全在本串上操作。
例如 :示例 1 中 输入:字符串 abcdefg,n=2
如图:
最终得到左旋 2 个单元的字符串:cdefgab
思路明确之后,那么代码实现就很简单了
class Solution { | |
public String reverseLeftWords(String s, int n) { | |
char[] chars = s.toCharArray(); | |
// 反转 [0,n) 区间的字符 | |
reverse(chars, 0, n - 1); | |
// 反转 [n,s.length) 区间的字符 | |
reverse(chars, n, s.length() - 1); | |
// 反转整个字符串 | |
reverse(chars, 0, s.length() - 1); | |
return new String(chars); | |
} | |
private void reverse(char[] chars, int start, int end) { | |
while (start < end) { | |
char temp = chars[start]; | |
chars[start] = chars[end]; | |
chars[end] = temp; | |
start++; | |
end--; | |
} | |
} | |
} |
# 28. 找出字符串中第一个匹配项的下标:实现 strStr ()
在一个串中查找是否出现过另一个串,称为字符串的模式匹配,这是 KMP 的看家本领。
# KMP 有什么用
KMP 主要应用在字符串的模式匹配上。
KMP 的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。
所以如何记录已经匹配的文本内容,是 KMP 的重点,也是 next 数组肩负的重任。
# 什么是前缀表
写过 KMP 的同学,一定都写过 next 数组,那么这个 next 数组究竟是个啥呢?
next 数组就是一个前缀表(prefix table)。
前缀表有什么作用呢?
前缀表是用来回退的,它记录了模式串与主串 (文本串) 不匹配的时候,模式串应该从哪里开始重新匹配。
为了清楚地了解前缀表的来历,我们来举一个例子:
要在 文本串
:aabaabaafa 中查找是否出现过一个 模式串
:aabaaf。
如动画所示:
动画里,我特意把 子串 aa
标记上了,这是有原因的,大家先注意一下,后面还会说到。
可以看出,文本串中第六个字符 b 和 模式串的第六个字符 f,不匹配了。如果暴力匹配,发现不匹配,此时就要从头匹配了。
但如果使用前缀表,就不会从头匹配,而是从上次已经匹配的内容开始匹配,找到了模式串中第三个字符 b 继续开始匹配。
此时就要问了前缀表是如何记录的呢?
首先要知道前缀表的任务是:当前位置匹配失败,找到之前已经匹配上的位置,再重新匹配,此也意味着在某个字符失配时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置。
那么什么是前缀表:记录下标 i 之前(包括 i)的字符串中,有多大长度的相同前缀后缀。
# 最长公共前后缀 or 最长相等前后缀
文章中字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串。
后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。
正确理解什么是前缀什么是后缀很重要!
那么网上清一色都说 “kmp 最长公共前后缀” 又是什么回事呢?
我查了一遍 算法导论 和 算法 4 里 KMP 的章节,都没有提到 “最长公共前后缀” 这个词,也不知道从哪里来了,我理解是用 “最长相等前后缀” 更准确一些。
因为前缀表要求的就是相同前后缀的长度。
而最长公共前后缀里面的 “公共”,更像是说前缀和后缀公共的长度。这其实并不是前缀表所需要的。
所以字符串 a 的最长相等前后缀为 0。 字符串 aa 的最长相等前后缀为 1。 字符串 aaa 的最长相等前后缀为 2。 等等.....。
# 为什么一定要用前缀表
这就是前缀表,那为啥就能告诉我们 上次匹配的位置,并跳过去呢?
回顾一下,刚刚匹配的过程在下标 5 的地方遇到不匹配,模式串是指向 f,如图:
然后就找到了下标 2,指向 b,继续匹配:如图:
以下这句话,对于理解为什么使用前缀表可以告诉我们匹配失败之后跳到哪里重新匹配 非常重要!
下标 5 之前这部分的字符串(也就是字符串 aabaa)的最长相等的前缀 和 后缀字符串是 子字符串 aa ,因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面重新匹配就可以了。
所以前缀表具有告诉我们当前位置匹配失败,跳到之前已经匹配过的地方的能力。
很多介绍 KMP 的文章或者视频并没有把为什么要用前缀表?这个问题说清楚,而是直接默认使用前缀表。
# 如何计算前缀表
接下来就要说一说怎么计算前缀表。
如图:
长度为前 1 个字符的子串 a
,最长相同前后缀的长度为 0。(注意字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串;后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。)
长度为前 2 个字符的子串 aa
,最长相同前后缀的长度为 1。
长度为前 3 个字符的子串 aab
,最长相同前后缀的长度为 0。
以此类推: 长度为前 4 个字符的子串 aaba
,最长相同前后缀的长度为 1。 长度为前 5 个字符的子串 aabaa
,最长相同前后缀的长度为 2。 长度为前 6 个字符的子串 aabaaf
,最长相同前后缀的长度为 0。
那么把求得的最长相同前后缀的长度就是对应前缀表的元素,如图:
可以看出模式串与前缀表对应位置的数字表示的就是:下标 i 之前(包括 i)的字符串中,有多大长度的相同前缀后缀。
再来看一下如何利用 前缀表找到 当字符不匹配的时候应该指针应该移动的位置。如动画所示:
找到的不匹配的位置, 那么此时我们要看它的前一个字符的前缀表的数值是多少。
为什么要前一个字符的前缀表的数值呢,因为要找前面字符串的最长相同的前缀和后缀。
所以要看前一位的 前缀表的数值。
前一个字符的前缀表的数值是 2, 所以把下标移动到数值对应的位置继续比配。 可以再反复看一下上面的动画。
最后就在文本串中找到了和模式串匹配的子串了
# 前缀表与 next 数组
很多 KMP 算法的时间都是使用 next 数组来做回退操作,那么 next 数组与前缀表有什么关系呢?
next 数组就可以是前缀表,但是很多实现都是把前缀表统一减一(右移一位,初始位置为 - 1)之后作为 next 数组。
为什么这么做呢,其实也是很多文章视频没有解释清楚的地方。
其实这并不涉及到 KMP 的原理,而是具体实现,next 数组既可以就是前缀表,也可以是前缀表统一减一(右移一位,初始位置为 - 1)。
后面我会提供两种不同的实现代码,大家就明白了。
# 使用 next 数组来匹配
以下我们以前缀表统一减一之后的 next 数组来做演示。
有了 next 数组,就可以根据 next 数组来 匹配文本串 s,和模式串 t 了。
匹配过程动画如下:
# 时间复杂度分析
其中 n 为文本串长度,m 为模式串长度,因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是 O (n),之前还要单独生成 next 数组,时间复杂度是 O (m)。所以 ** 整个 KMP 算法的时间复杂度是 O (n+m)** 的。
暴力的解法显而易见是 O (n × m),所以 KMP 在字符串匹配中极大地提高了搜索的效率。
为了和力扣题目 28. 实现 strStr 保持一致,方便大家理解,以下文章统称 haystack 为文本串,needle 为模式串。
都知道使用 KMP 算法,一定要构造 next 数组。
# 构造 next 数组?
我们定义一个函数 getNext 来构建 next 数组,函数参数为指向 next 数组的指针,和一个字符串。 代码如下:
void getNext(int* next, const string& s)
构造 next 数组其实就是计算模式串 s,前缀表的过程。 主要有如下三步:
- 初始化
- 处理前后缀不相同的情况
- 处理前后缀相同的情况
接下来我们详解一下。
# 1. 初始化
定义两个指针 i 和 j,j 指向前缀末尾位置,i 指向后缀末尾位置。
然后还要对 next 数组进行初始化赋值,如下:
int j = -1; | |
next[0] = j; |
j 为什么要初始化为 -1 呢,因为之前说过 前缀表要统一减一的操作仅仅是其中的一种实现,我们这里选择 j 初始化为 - 1,下文我还会给出 j 不初始化为 - 1 的实现代码。
next [i] 表示 i(包括 i)之前最长相等的前后缀长度(其实就是 j)
所以初始化 next [0] = j 。
# 2. 处理前后缀不相同的情况
因为 j 初始化为 - 1,那么 i 就从 1 开始,进行 s [i] 与 s [j+1] 的比较。
所以遍历模式串 s 的循环下标 i 要从 1 开始,代码如下:
for (int i = 1; i < s.size(); i++) { |
如果 s [i] 与 s [j+1] 不相同,也就是遇到 前后缀末尾不相同的情况,就要向前回退。
怎么回退呢?
next [j] 就是记录着 j(包括 j)之前的子串的相同前后缀的长度。
那么 s [i] 与 s [j+1] 不相同,就要找 j+1 前一个元素在 next 数组里的值(就是 next [j])。
所以,处理前后缀不相同的情况代码如下:
while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了 | |
j = next[j]; // 向前回退 | |
} |
# 3. 处理前后缀相同的情况
如果 s [i] 与 s [j + 1] 相同,那么就同时向后移动 i 和 j 说明找到了相同的前后缀,同时还要将 j(前缀的长度)赋给 next [i], 因为 next [i] 要记录相同前后缀的长度。
代码如下:
if (s[i] == s[j + 1]) { // 找到相同的前后缀 | |
j++; | |
} | |
next[i] = j; |
最后整体构建 next 数组的函数代码如下:
void getNext(int* next, const string& s){ | |
int j = -1; // 指向前缀的末尾位置,也可以理解为 “最长相等前后缀的长度” | |
next[0] = j; // 模式串中第一个字符无前、后缀,故 “最长相等前后缀长度” 为 0,又因采用 “前缀表统一减一” 的策略,故初始化为 - 1,这也是 j 初始化为 - 1 的原因 | |
for(int i = 1; i < s.size(); i++) { //i 指向后缀的末尾位置,从 1 开始 | |
while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了 | |
j = next[j]; // 向前回退 | |
} | |
if (s[i] == s[j + 1]) { // 找到相同的前后缀 | |
j++; | |
} | |
next[i] = j; // 将 j(前缀的长度)赋给 next [i] | |
} | |
} |
代码构造 next 数组的逻辑流程动画如下:
得到了 next 数组之后,就要用这个来做匹配了。
# 使用 next 数组来做匹配
在文本串 s 里 找是否出现过模式串 t。
定义两个下标 j 指向模式串起始位置,i 指向文本串起始位置。
那么 j 初始值依然为 - 1,为什么呢? 依然因为 next 数组里记录的起始位置为 - 1。
i 就从 0 开始,遍历文本串,代码如下:
for (int i = 0; i < s.size(); i++) |
接下来就是 s [i] 与 t [j + 1] (因为 j 从 - 1 开始的) 进行比较。
如果 s [i] 与 t [j + 1] 不相同,j 就要从 next 数组里寻找下一个匹配的位置。
代码如下:
while(j >= 0 && s[i] != t[j + 1]) { | |
j = next[j]; | |
} |
如果 s [i] 与 t [j + 1] 相同,那么 i 和 j 同时向后移动, 代码如下:
if (s[i] == t[j + 1]) { | |
j++; //i 的增加在 for 循环里 | |
} |
如何判断在文本串 s 里出现了模式串 t 呢,如果 j 指向了模式串 t 的末尾,那么就说明模式串 t 完全匹配文本串 s 里的某个子串了。
本题要在文本串字符串中找出模式串出现的第一个位置 (从 0 开始),所以返回当前在文本串匹配模式串的位置 i 减去 模式串的长度,就是文本串字符串中出现模式串的第一个位置。
代码如下:
if (j == (t.size() - 1) ) { | |
return (i - t.size() + 1); | |
} |
那么使用 next 数组,用模式串匹配文本串的整体代码如下:
int j = -1; // 因为 next 数组里记录的起始位置为 - 1 | |
for (int i = 0; i < s.size(); i++) { // 注意 i 就从 0 开始 | |
while(j >= 0 && s[i] != t[j + 1]) { // 不匹配 | |
j = next[j]; //j 寻找之前匹配的位置 | |
} | |
if (s[i] == t[j + 1]) { // 匹配,j 和 i 同时向后移动 | |
j++; //i 的增加在 for 循环里 | |
} | |
if (j == (t.size() - 1) ) { // 文本串 s 里出现了模式串 t | |
return (i - t.size() + 1); | |
} | |
} |
此时所有逻辑的代码都已经写出来了,力扣 28. 实现 strStr 题目的整体代码如下:
# 前缀表统一减一 C++ 代码实现
class Solution { | |
public: | |
void getNext(int* next, const string& s) { | |
int j = -1; | |
next[0] = j; | |
for(int i = 1; i < s.size(); i++) { // 注意 i 从 1 开始 | |
while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了 | |
j = next[j]; // 向前回退 | |
} | |
if (s[i] == s[j + 1]) { // 找到相同的前后缀 | |
j++; | |
} | |
next[i] = j; // 将 j(前缀的长度)赋给 next [i] | |
} | |
} | |
int strStr(string haystack, string needle) { | |
if (needle.size() == 0) { | |
return 0; | |
} | |
int next[needle.size()]; | |
getNext(next, needle); | |
int j = -1; //// 因为 next 数组里记录的起始位置为 - 1 | |
for (int i = 0; i < haystack.size(); i++) { // 注意 i 就从 0 开始 | |
while(j >= 0 && haystack[i] != needle[j + 1]) { // 不匹配 | |
j = next[j]; //j 寻找之前匹配的位置 | |
} | |
if (haystack[i] == needle[j + 1]) { // 匹配,j 和 i 同时向后移动 | |
j++; //i 的增加在 for 循环里 | |
} | |
if (j == (needle.size() - 1) ) { // 文本串 s 里出现了模式串 t | |
return (i - needle.size() + 1); | |
} | |
} | |
return -1; | |
} | |
}; |
# 前缀表(不减一)C++ 实现
那么前缀表就不减一了,也不右移的,到底行不行呢?
行!
我之前说过,这仅仅是 KMP 算法实现上的问题,如果就直接使用前缀表可以换一种回退方式,找 j=next[j-1]
来进行回退。
主要就是 j=next [x] 这一步最为关键!
我给出的 getNext 的实现为:(前缀表统一减一)
void getNext(int* next, const string& s) { | |
int j = -1; | |
next[0] = j; | |
for(int i = 1; i < s.size(); i++) { // 注意 i 从 1 开始 | |
while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了 | |
j = next[j]; // 向前回退 | |
} | |
if (s[i] == s[j + 1]) { // 找到相同的前后缀 | |
j++; | |
} | |
next[i] = j; // 将 j(前缀的长度)赋给 next [i] | |
} | |
} |
此时如果输入的模式串为 aabaaf,对应的 next 为 - 1 0 -1 0 1 -1。
这里 j 和 next [0] 初始化为 - 1,整个 next 数组是以 前缀表减一之后的效果来构建的。
那么前缀表不减一来构建 next 数组,代码如下:
void getNext(int* next, const string& s) { | |
int j = 0; | |
next[0] = 0; | |
for(int i = 1; i < s.size(); i++) { | |
while (j > 0 && s[i] != s[j]) { //j 要保证大于 0,因为下面有取 j-1 作为数组下标的操作 | |
j = next[j - 1]; // 注意这里,是要找前一位的对应的回退位置了 | |
} | |
if (s[i] == s[j]) { | |
j++; | |
} | |
next[i] = j; | |
} | |
} |
此时如果输入的模式串为 aabaaf,对应的 next 为 0 1 0 1 2 0,(其实这就是前缀表的数值了)。
那么用这样的 next 数组也可以用来做匹配,代码要有所改动。
实现代码如下:
class Solution { | |
public: | |
void getNext(int* next, const string& s) { | |
int j = 0; | |
next[0] = 0; | |
for(int i = 1; i < s.size(); i++) { | |
while (j > 0 && s[i] != s[j]) { | |
j = next[j - 1]; | |
} | |
if (s[i] == s[j]) { | |
j++; | |
} | |
next[i] = j; | |
} | |
} | |
int strStr(string haystack, string needle) { | |
if (needle.size() == 0) { | |
return 0; | |
} | |
int next[needle.size()]; | |
getNext(next, needle); | |
int j = 0; | |
for (int i = 0; i < haystack.size(); i++) { | |
while(j > 0 && haystack[i] != needle[j]) { | |
j = next[j - 1]; | |
} | |
if (haystack[i] == needle[j]) { | |
j++; | |
} | |
if (j == needle.size() ) { | |
return (i - needle.size() + 1); | |
} | |
} | |
return -1; | |
} | |
}; |
java 代码如下:
class Solution { | |
// KMP 算法,时间复杂度 O (m+n),空间复杂度 O (n) | |
// 前缀表(不减一) | |
public int strStr(String haystack, String needle) { | |
// 特判:needle 为空串 | |
if (needle.length() == 0) | |
return 0; | |
// 特判:haystack 长度小于 needle 长度 | |
if (haystack.length() < needle.length()) | |
return -1; | |
// 构造前缀表,长度与 needle 相同 | |
int[] next = new int[needle.length()]; | |
getNext(next, needle); | |
// 匹配 | |
int i = 0, j = 0; //i 指向 haystack,j 指向 needle | |
for (; i < haystack.length(); i++) { | |
// 不匹配,且 j 不为 0,j 回溯,直到 j 为 0 或匹配 | |
while (j > 0 && haystack.charAt(i) != needle.charAt(j)) | |
j = next[j - 1]; | |
// 匹配,j++ | |
if (haystack.charAt(i) == needle.charAt(j)) | |
j++; | |
// 匹配成功,返回下标 | |
if (j == needle.length()) | |
return i - needle.length() + 1; | |
} | |
return -1; | |
} | |
// 构建前缀表 | |
private void getNext(int[] next, String s) { | |
int j = 0; // 指向前缀末尾 | |
next[0] = 0; // 第一个字符无前缀、后缀,故 “最长相等前后缀” 的长度为 0 | |
for (int i = 1; i < s.length(); i++) { // 指向后缀末尾,从下标 1 开始 | |
/* 开始计算 next [i]:即 s [0..i] 的最长相等前后缀的长度,需要匹配前、后缀 */ | |
// 前缀末尾字符与后缀末尾字符不相等时,需要回退 | |
while (j > 0 && s.charAt(i) != s.charAt(j)) | |
j = next[j - 1]; // 回退到前一个字符的最长相等前后缀的长度 | |
// 前缀末尾字符与后缀末尾字符相等时,最长相等前后缀的长度加 1 | |
if (s.charAt(j) == s.charAt(i)) | |
j++; | |
// 记录最长相等前后缀的长度 | |
next[i] = j; | |
} | |
} | |
} |
# 总结
我们介绍了什么是 KMP,KMP 可以解决什么问题,然后分析 KMP 算法里的 next 数组,知道了 next 数组就是前缀表,再分析为什么要是前缀表而不是什么其他表。
接着从给出的模式串中,我们一步一步的推导出了前缀表,得出前缀表无论是统一减一还是不减一得到的 next 数组仅仅是 kmp 的实现方式的不同。
其中还分析了 KMP 算法的时间复杂度,并且和暴力方法做了对比。
然后先用前缀表统一减一得到的 next 数组,求得文本串 s 里是否出现过模式串 t,并给出了具体分析代码。
又给出了直接用前缀表作为 next 数组,来做匹配的实现代码。
可以说把 KMP 的每一个细微的细节都扣了出来,毫无遮掩的展示给大家了!
# 459. 重复的子字符串
KMP 算法还能干这个
# 暴力法
如果重复的子串存在,其起始位置必定在首字符,因此只需枚举子串的长度,即可确定子串,这是外层 for 循环的任务。
内层 for 循环的任务:判断子串是否能重复构成字符串,
- 若有一个字符与当前长度的子串不相等,就枚举下一个长度的子串。若枚举完所有长度的子串均没找到符合条件的子串,返回 false
- 若所有字符均与当前长度的子串匹配,就返回 true
时间复杂度:O (n^2)
一些细节:
- 子串的长度从 1 开始枚举,0 没有意义
- 外层 for 循环的判断条件
subLength * 2 <= strLength
,只需要遍历到中间位置,因为子串结束位置大于中间位置的话,一定不能重复组成字符串。符合条件的子串至少要重复两次,否则不符题意 - 只有长度能被字符串长度整除的子串才有可能重复组成字符串
- 在内层 for 循环判断子串是否能重复构成字符串的过程中,判断条件是
if (s.charAt(i) != s.charAt(i - subLength))
class Solution { | |
// 暴力法,时间复杂度 O (n^2) | |
public boolean repeatedSubstringPattern(String s) { | |
int strLength = s.length(); | |
for (int subLength = 1; subLength * 2 <= strLength; subLength++) { // 枚举子串长度(从 1 开始),要判断 subLength * 2 <= n,即 subLength <= n/2,因为子串长度不能超过字符串长度的一半,否则肯定不符合题意。 | |
if (strLength % subLength == 0) { // 子串长度能整除字符串长度,才有可能重复 | |
boolean match = true; | |
for (int i = subLength; i < strLength; i++) { // 从子串的第二个字符开始,与子串的第一个字符比较 | |
if (s.charAt(i) != s.charAt(i - subLength)) { // 有一个字符不相等,就不是重复的子串 | |
match = false; | |
break; // 退出循环,继续枚举子串长度 | |
} | |
} | |
if (match) { | |
return true; // 所有字符都匹配,返回 true | |
} | |
} | |
} | |
return false; // 枚举完所有子串长度,都不符合题意,返回 false | |
} | |
} |
# 移动匹配
当一个字符串 s:abcabc,内部由重复的子串组成,那么这个字符串的结构一定是这样的:
也就是由前后相同的子串组成。
那么既然前面有相同的子串,后面有相同的子串,用 s + s,这样组成的字符串中,后面的子串做前串,前后的子串做后串,就一定还能组成一个 s,如图:
所以判断字符串 s 是否由重复子串组成,只要两个 s 拼接在一起,里面还出现一个 s 的话,就说明是由重复子串组成。
当然,我们在判断 s + s 拼接的字符串里是否出现一个 s 的的时候,要刨除 s + s 的首字符和尾字符,这样避免在 s+s 中搜索出原来的 s,我们要搜索的是中间拼接出来的 s。
代码如下:
class Solution { | |
// 移动匹配:将 s+s 的拼接字符串的首、尾各去掉一个字符,如果 s 在拼接字符串中存在,则说明 s 可以由它的一个子串重复多次构成 | |
public boolean repeatedSubstringPattern(String s) { | |
return (s + s).substring(1, (s + s).length() - 1).contains(s); | |
} | |
} |
不过这种解法还有一个问题,就是 我们最终还是要判断 一个字符串(s + s)是否出现过 s 的过程,大家可能直接用 contains,find 之类的库函数。 却忽略了实现这些函数的时间复杂度(暴力解法是 m * n,一般库函数实现为 O (m + n))。
如果我们做过 28. 实现 strStr 题目的话,其实就知道,实现一个 高效的算法来判断 一个字符串中是否出现另一个字符串是很复杂的,这里就涉及到了 KMP 算法。
# KMP 算法
# 为什么会使用 KMP 算法
在一个串中查找是否出现过另一个串,这是 KMP 的看家本领。那么寻找重复子串怎么也涉及到 KMP 算法了呢?
KMP 算法中 next 数组为什么遇到字符不匹配的时候可以找到上一个匹配过的位置继续匹配,靠的是有计算好的前缀表。 前缀表里,统计了各个位置为终点字符串的最长相同前后缀的长度。
那么最长相同前后缀和重复子串的关系又有什么关系呢。
可能很多录友又忘了 前缀和后缀的定义,再回顾一下:
- 前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串;
- 后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串
在由重复子串组成的字符串中,最长相等前后缀不包含的子串就是最小重复子串,这里拿字符串 s:abababab 来举例,ab 就是最小重复单位,如图所示:
# 如何找到最小重复子串
这里有同学就问了,为啥一定是开头的 ab 呢。 其实最关键还是要理解 最长相等前后缀,如图:
步骤一:因为 这是相等的前缀和后缀,t [0] 与 k [0] 相同, t [1] 与 k [1] 相同,所以 s [0] 一定和 s [2] 相同,s [1] 一定和 s [3] 相同,即:,s [0] s [1] 与 s [2] s [3] 相同 。
步骤二: 因为在同一个字符串位置,所以 t [2] 与 k [0] 相同,t [3] 与 k [1] 相同。
步骤三: 因为 这是相等的前缀和后缀,t [2] 与 k [2] 相同 ,t [3] 与 k [3] 相同,所以,s [2] 一定和 s [4] 相同,s [3] 一定和 s [5] 相同,即:s [2] s [3] 与 s [4] s [5] 相同。
步骤四:循环往复。
所以字符串 s,s [0] s [1] 与 s [2] s [3] 相同, s [2] s [3] 与 s [4] s [5] 相同,s [4] s [5] 与 s [6] s [7] 相同。
正是因为 最长相等前后缀的规则,当一个字符串由重复子串组成的,最长相等前后缀不包含的子串就是最小重复子串。
# 简单推理
这里再给出一个数学推导,就容易理解很多。
假设字符串 s 使用多个重复子串构成(这个子串是最小重复单位),重复出现的子字符串长度是 x,所以 s 是由 n * x 组成。
因为字符串 s 的最长相同前后缀的长度一定是不包含 s 本身,所以 最长相同前后缀长度必然是 m * x,而且 n - m = 1,(这里如果不懂,看上面的推理)
所以如果 nx % (n - m)x = 0
,就可以判定有重复出现的子字符串。
next 数组记录的就是最长相同前后缀 字符串:KMP 算法精讲这里介绍了什么是前缀,什么是后缀,什么又是最长相同前后缀), 如果 next [len - 1] != -1,则说明字符串有最长相同的前后缀(就是字符串里的前缀子串和后缀子串相同的最长长度)。
最长相等前后缀的长度为:next [len - 1] + 1。(这里的 next 数组是以统一减一的方式计算的,因此需要 + 1,两种计算 next 数组的具体区别看这里:字符串:KMP 算法精讲)
数组长度为:len。
如果 len % (len - (next[len - 1] + 1)) == 0
,则说明数组的长度正好可以被 (数组长度 - 最长相等前后缀的长度) 整除 ,说明该字符串有重复的子字符串。
前提是满足条件
next[len - 1] != 0
,即存在最长相等前后缀。
数组长度减去最长相同前后缀的长度相当于是第一个周期的长度,也就是一个周期的长度,如果这个周期可以被整除,就说明整个数组就是这个周期的循环。
强烈建议大家把 next 数组打印出来,看看 next 数组里的规律,有助于理解 KMP 算法
如图:
next [len - 1] = 7,next [len - 1] + 1 = 8,8 就是此时字符串 asdfasdfasdf 的最长相同前后缀的长度。
(len - (next [len - 1] + 1)) 也就是: 12 (字符串的长度) - 8 (最长公共前后缀的长度) = 4, 4 正好可以被 12 (字符串的长度) 整除,所以说明有重复的子字符串(asdf)。
代码如下:
class Solution { | |
// KMP 算法(前缀表不减一) | |
public boolean repeatedSubstringPattern(String s) { | |
boolean res = false; | |
// 特判:字符串长度小于 2,直接返回 false | |
if (s.length() < 2) { | |
return res; | |
} | |
// 构造前缀表 | |
int[] next = new int[s.length()]; | |
getNext(next, s); | |
// 判断是否存在重复子串 | |
int len = s.length(); // 字符串总长度 | |
int repeatLen = len - next[len - 1]; // 重复子串长度 = 字符串总长度 - 前缀表最后一个元素 (最长公共前后缀长度) | |
if (next[len - 1] != 0 && len % repeatLen == 0) { // 最长公共前后缀长度不为 0,且字符串总长度能被重复子串长度整除,说明存在重复子串 | |
res = true; | |
} | |
return res; | |
} | |
// 获取前缀表 | |
private void getNext(int[] next, String s) { | |
int j = 0; | |
next[0] = 0; | |
for (int i = 1; i < s.length(); i++) { | |
while (j > 0 && s.charAt(i) != s.charAt(j)) { | |
j = next[j - 1]; | |
} | |
if (s.charAt(i) == s.charAt(j)) { | |
j++; | |
} | |
next[i] = j; | |
} | |
} | |
} |
# 双指针法
# [1. 移除元素](#27. 移除元素)
# [2. 反转字符串](#344. 反转字符串)
# [3. 替换空格](# 剑指 Offer 05. 替换空格)
# [4. 翻转字符串里的单词](#151. 反转字符串中的单词)
- 移除冗余空格
- 翻转整个字符串
- 翻转每个单词
# [5. 反转链表](#206. 反转链表)
动画中应该是 pre 先移动,cur 再移动
# [6. 删除链表的倒数第 n 个节点](#19. 删除链表的倒数第 n 个结点)
slow、fast 指针的初始值为虚拟头节点
fast 先走 n+1 步
fast 和 slow 同时移动,直到 fast 指向 null
删除 slow 指向的下一个节点
# [7. 链表相交](# 面试题 02.07. 链表相交)
求出两个链表的长度差值,让 curA 移动到和 curB 末尾对齐的地方
比较 curA 和 curB 是否相同
如果不相同,同时向后移动 curA 和 curB
如果遇到 curA == curB,则找到交点
因为交点不是数值相等,而是指针相等。
若循环结束,返回空指针
# [8. 环形链表 II](#142. 环形链表 II)
判断链表是否有环
如果有环,如何找到环的入口
# [9. 三数之和](#15. 三数之和)
对数组排序
for 循环
i:从下标 0 的地方开始
left:定义在 i+1 的位置上
right:在数组结尾的位置上
for 循环中如何移动 left、right
- 如果 nums [i] + nums [left] + nums [right] > 0 就说明 此时三数之和大了,因为数组是排序后了,所以 right 下标就应该向左移动,这样才能让三数之和小一些
- 如果 nums [i] + nums [left] + nums [right] < 0 说明 此时 三数之和小了,left 就向右移动,才能让三数之和大一些
- 直到 left 与 right 相遇为止。
注意:对三个数都要进行去重。
# [10. 四数之和](#18. 四数之和)
在三数之和的基础上再套一层 for 循环,但是最外两层的去重、剪枝不一样了。
# 总结
# 数组篇
在移除元素中,原地移除数组上的元素,我们说到了数组上的元素,不能真正的删除,只能覆盖。
一些同学可能会写出如下代码(伪代码):
for (int i = 0; i < array.size(); i++) { | |
if (array[i] == target) { | |
array.erase(i); | |
} | |
} |
这个代码看上去好像是 O (n) 的时间复杂度,其实是 O (n^2) 的时间复杂度,因为 erase 操作也是 O (n) 的操作。
所以此时使用双指针法才展现出效率的优势:通过两个指针在一个 for 循环下完成两个 for 循环的工作。
# 字符串篇
在反转字符串 中讲解了反转字符串,注意这里强调要原地反转,要不然就失去了题目的意义。
使用双指针法,定义两个指针(也可以说是索引下标),一个从字符串前面,一个从字符串后面,两个指针同时向中间移动,并交换元素。,时间复杂度是 O (n)。
在替换空格 中介绍使用双指针填充字符串的方法,如果想把这道题目做到极致,就不要只用额外的辅助空间了!
思路就是首先扩充数组到每个空格替换成 "%20" 之后的大小。然后双指针从后向前替换空格。
有同学问了,为什么要从后向前填充,从前向后填充不行么?
从前向后填充就是 O (n^2) 的算法了,因为每次添加元素都要将添加元素之后的所有元素向后移动。
其实很多数组(字符串)填充类的问题,都可以先预先给数组扩容带填充后的大小,然后在从后向前进行操作。
那么在翻转字符串里的单词中,我们使用双指针法,用 O (n) 的时间复杂度完成字符串删除类的操作,因为题目要删除冗余空格。
在删除冗余空格的过程中,如果不注意代码效率,很容易写成了 O (n^2) 的时间复杂度。其实使用双指针法 O (n) 就可以搞定。
主要还是大家用 erase 用的比较随意,一定要注意 for 循环下用 erase 的情况,一般可以用双指针写效率更高!
# 链表篇
翻转链表是现场面试,白纸写代码的好题,考察了候选者对链表以及指针的熟悉程度,而且代码也不长,适合在白纸上写。
在翻转链表 中,讲如何使用双指针法来翻转链表,只需要改变链表的 next 指针的指向,直接将链表反转 ,而不用重新定义一个新的链表。
思路还是很简单的,代码也不长,但是想在白纸上一次性写出 bugfree 的代码,并不是容易的事情。
在链表中求环,应该是双指针在链表里最经典的应用,在环形链表 II 中讲解了如何通过双指针判断是否有环,而且还要找到环的入口。
使用快慢指针(双指针法),分别定义 fast 和 slow 指针,从头结点出发,fast 指针每次移动两个节点,slow 指针每次移动一个节点,如果 fast 和 slow 指针在途中相遇 ,说明这个链表有环。
那么找到环的入口,其实需要点简单的数学推理,我在文章中把找环的入口清清楚楚的推理的一遍,如果对找环入口不够清楚的同学建议自己看一看环形链表 II。
# N 数之和篇
在哈希表:解决了两数之和,那么能解决三数之和么? 中,讲到使用哈希法可以解决 [1. 两数之和](#1. 两数之和) 的问题
其实使用双指针也可以解决 [1. 两数之和](#1. 两数之和) 的问题,只不过 [1. 两数之和](#1. 两数之和) 求的是两个元素的下标,没法用双指针,如果改成求具体两个元素的数值就可以了,大家可以尝试用双指针做一个 leetcode 上两数之和的题目,就可以体会到我说的意思了。
使用了哈希法解决了两数之和,但是哈希法并不使用于三数之和!
使用哈希法的过程中要把符合条件的三元组放进 vector 中,然后在去去重,这样是非常费时的,很容易超时,也是三数之和通过率如此之低的根源所在。
去重的过程不好处理,有很多小细节,如果在面试中很难想到位。
时间复杂度可以做到 O (n^2),但还是比较费时的,因为不好做剪枝操作。
所以这道题目使用双指针法才是最为合适的,用双指针做这道题目才能就能真正体会到,通过前后两个指针不算向中间逼近,在一个 for 循环下完成两个 for 循环的工作。
只用双指针法时间复杂度为 O (n^2),但比哈希法的 O (n^2) 效率高得多,哈希法在使用两层 for 循环的时候,能做的剪枝操作很有限。
在双指针法:一样的道理,能解决四数之和 中,讲到了四数之和,其实思路是一样的,在三数之和的基础上再套一层 for 循环,依然是使用双指针法。
对于三数之和使用双指针法就是将原本暴力 O (n^3) 的解法,降为 O (n^2) 的解法,四数之和的双指针解法就是将原本暴力 O (n^4) 的解法,降为 O (n^3) 的解法。
同样的道理,五数之和,n 数之和都是在这个基础上累加。
# 小结
本文中一共介绍了 leetcode 上九道使用双指针解决问题的经典题目,除了链表一些题目一定要使用双指针,其他题目都是使用双指针来提高效率,一般是将 O (n^2) 的时间复杂度,降为。
建议大家可以把文中涉及到的题目在好好做一做,琢磨琢磨,基本对双指针法就不在话下了。
# 栈与队列
# 理论基础
我想栈和队列的原理大家应该很熟悉了,队列是先进先出,栈是先进后出。
如图所示:
那么我这里在列出四个关于栈的问题,大家可以思考一下。以下是以 C++ 为例,相信使用其他编程语言的同学也对应思考一下,自己使用的编程语言里栈和队列是什么样的。
- C++ 中 stack 是容器么?
- 我们使用的 stack 是属于哪个版本的 STL?
- 我们使用的 STL 中 stack 是如何实现的?
- stack 提供迭代器来遍历 stack 空间么?
相信这四个问题并不那么好回答, 因为一些同学使用数据结构会停留在非常表面上的应用,稍稍往深一问,就会有好像懂,好像也不懂的感觉。
有的同学可能仅仅知道有栈和队列这么个数据结构,却不知道底层实现,也不清楚所使用栈和队列和 STL 是什么关系。
所以这里我在给大家扫一遍基础知识。
首先大家要知道栈和队列是 STL(C++ 标准库)里面的两个数据结构。
C++ 标准库是有多个版本的,要知道我们使用的 STL 是哪个版本,才能知道对应的栈和队列的实现原理。
那么来介绍一下,三个最为普遍的 STL 版本:
- HP STL 其他版本的 C++ STL,一般是以 HP STL 为蓝本实现出来的,HP STL 是 C++ STL 的第一个实现版本,而且开放源代码。
- P.J.Plauger STL 由 P.J.Plauger 参照 HP STL 实现出来的,被 Visual C++ 编译器所采用,不是开源的。
- SGI STL 由 Silicon Graphics Computer Systems 公司参照 HP STL 实现,被 Linux 的 C++ 编译器 GCC 所采用,SGI STL 是开源软件,源码可读性甚高。
接下来介绍的栈和队列也是 SGI STL 里面的数据结构, 知道了使用版本,才知道对应的底层实现。
来说一说栈,栈先进后出,如图所示:
栈提供 push 和 pop 等等接口,所有元素必须符合先进后出规则,所以栈不提供走访功能,也不提供迭代器 (iterator)。 不像是 set 或者 map 提供迭代器 iterator 来遍历所有元素。
栈是以底层容器完成其所有的工作,对外提供统一的接口,底层容器是可插拔的(也就是说我们可以控制使用哪种容器来实现栈的功能)。
所以 STL 中栈往往不被归类为容器,而被归类为 container adapter(容器适配器)。
那么问题来了,STL 中栈是用什么容器实现的?
从下图中可以看出,栈的内部结构,栈的底层实现可以是 vector,deque,list 都是可以的, 主要就是数组和链表的底层实现。
我们常用的 SGI STL,如果没有指定底层实现的话,默认是以 deque 为缺省情况下栈的底层结构。
deque 是一个双向队列,只要封住一段,只开通另一端就可以实现栈的逻辑了。
我们也可以指定 vector 为栈的底层实现,初始化语句如下:
std::stack<int, std::vector<int> > third; // 使用 vector 为底层容器的栈 |
刚刚讲过栈的特性,对应的队列的情况是一样的。
队列中先进先出的数据结构,同样不允许有遍历行为,不提供迭代器,SGI STL 中队列一样是以 deque 为缺省情况下的底部结构。
也可以指定 list 为起底层实现,初始化 queue 的语句如下:
std::queue<int, std::list<int>> third; // 定义以 list 为底层容器的队列 |
所以 STL 队列也不被归类为容器,而被归类为 container adapter( 容器适配器)。
我这里讲的都是 C++ 语言中情况, 使用其他语言的同学也要思考栈与队列的底层实现问题, 不要对数据结构的使用浅尝辄止,而要深挖起内部原理,才能夯实基础。
# 232. 用栈实现队列
这是一道模拟题,不涉及到具体算法,考察的就是对栈和队列的掌握程度。
使用栈来模式队列的行为,如果仅仅用一个栈,是一定不行的,所以需要两个栈一个输入栈,一个输出栈,这里要注意输入栈和输出栈的关系。
下面动画模拟以下队列的执行过程如下:
执行语句:
queue.push(1);
queue.push(2);
queue.pop(); 注意此时的输出栈的操作
queue.push (3);
queue.push(4);
queue.pop();
queue.pop(); 注意此时的输出栈的操作
queue.pop ();
queue.empty();
在 push 数据的时候,只要数据放进输入栈就好,但在 pop 的时候,操作就复杂一些,输出栈如果为空,就把进栈数据全部导入进来(注意是全部导入),再从出栈弹出数据,如果输出栈不为空,则直接从出栈弹出数据就可以了。
最后如何判断队列为空呢?如果进栈和出栈都为空的话,说明模拟的队列为空了。
在代码实现的时候,会发现 pop () 和 peek () 两个函数功能类似,代码实现上也是类似的,可以思考一下如何把代码抽象一下。
class MyQueue { | |
Stack<Integer> stackIn; // 输入栈 | |
Stack<Integer> stackOut; // 输出栈 | |
public MyQueue() { | |
stackIn = new Stack<>(); | |
stackOut = new Stack<>(); | |
} | |
public void push(int x) { | |
stackIn.push(x); | |
} | |
public int pop() { | |
if (stackOut.isEmpty()) { // 输出栈为空 | |
while (!stackIn.isEmpty()) { // 将输入栈的元素全部倒入输出栈 | |
stackOut.push(stackIn.pop()); | |
} | |
} | |
return stackOut.pop(); | |
} | |
public int peek() { | |
int result = this.pop(); // 复用 pop 方法 | |
stackOut.push(result); // 将弹出的元素重新压入输出栈 | |
return result; | |
} | |
public boolean empty() { | |
return stackIn.isEmpty() && stackOut.isEmpty(); // 输入栈和输出栈都为空时,队列为空 | |
} | |
} |
# 225. 用队列实现栈
用队列实现栈还是有点别扭。
(这里要强调是单向队列)
有的同学可能疑惑这种题目有什么实际工程意义,其实很多算法题目主要是对知识点的考察和教学意义远大于其工程实践的意义,所以面试题也是这样!
刚刚做过栈与队列:我用栈来实现队列怎么样? 的同学可能依然想着用一个输入队列,一个输出队列,就可以模拟栈的功能,仔细想一下还真不行!
队列模拟栈,其实一个队列就够了,那么我们先说一说两个队列来实现栈的思路。
队列是先进先出的规则,把一个队列中的数据导入另一个队列中,数据的顺序并没有变,并没有变成先进后出的顺序。
所以用栈实现队列, 和用队列实现栈的思路还是不一样的,这取决于这两个数据结构的性质。
但是依然还是要用两个队列来模拟栈,只不过没有输入和输出的关系,而是另一个队列完全用又来备份的!
如下面动画所示,用两个队列 que1 和 que2 实现栈的功能,que2 其实完全就是一个备份的作用,把 que1 最后面的元素以外的元素都备份到 que2,然后弹出最后面的元素,再把其他元素从 que2 导回 que1。
模拟的队列执行语句如下:
queue.push(1); | |
queue.push(2); | |
queue.pop(); // 注意弹出的操作 | |
queue.push(3); | |
queue.push(4); | |
queue.pop(); // 注意弹出的操作 | |
queue.pop(); | |
queue.pop(); | |
queue.empty(); |
代码如下(用两个队列实现栈):
// 用两个队列实现栈(不带 top 数据成员) | |
class MyStack { | |
private Queue<Integer> queue1; | |
private Queue<Integer> queue2; | |
public MyStack() { | |
queue1 = new LinkedList<>(); | |
queue2 = new LinkedList<>(); | |
} | |
public void push(int x) { | |
queue1.offer(x); //offer ():将指定元素插入队列尾部 | |
} | |
public int pop() { | |
while (queue1.size() > 1) { // 将 queue1 中的元素全部转移到 queue2 中,只留下一个元素 | |
queue2.offer(queue1.poll()); //poll ():获取并移除队列头部元素 | |
} | |
int res = queue1.poll(); | |
// 交换 queue1 和 queue2 | |
Queue<Integer> temp = queue1; | |
queue1 = queue2; | |
queue2 = temp; | |
return res; | |
} | |
public int top() { | |
int result = this.pop(); | |
queue1.offer(result); | |
return result; | |
} | |
public boolean empty() { | |
return queue1.isEmpty(); | |
} | |
} |
# 优化
其实这道题目就是用一个队列就够了。
一个队列在模拟栈弹出元素的时候,只要将队列头部的所有元素(除了最后一个元素外) 重新添加到队列尾部,此时再去弹出元素就是栈的顺序了。
class MyStack { | |
private Queue<Integer> queue; | |
public MyStack() { | |
queue = new LinkedList<>(); | |
} | |
public void push(int x) { | |
queue.offer(x); | |
} | |
public int pop() { | |
int size = queue.size(); | |
while (size > 1) { | |
queue.offer(queue.poll()); //poll ():检索并删除队列的头部元素 | |
size--; | |
} | |
return queue.poll(); | |
} | |
public int top() { | |
int result = this.pop(); | |
queue.offer(result); | |
return result; | |
} | |
public boolean empty() { | |
return queue.isEmpty(); | |
} | |
} |
# 20. 有效的括号
# 题外话
括号匹配是使用栈解决的经典问题。
题意其实就像我们在写代码的过程中,要求括号的顺序是一样的,有左括号,相应的位置必须要有右括号。
如果还记得编译原理的话,编译器在 词法分析的过程中处理括号、花括号等这个符号的逻辑,也是使用了栈这种数据结构。
再举个例子,linux 系统中,cd 这个进入目录的命令我们应该再熟悉不过了。
cd a/b/c/../../ |
这个命令最后进入 a 目录,系统是如何知道进入了 a 目录呢 ,这就是栈的应用(其实可以出一道相应的面试题了)
所以栈在计算机领域中应用是非常广泛的。
有的同学经常会想学的这些数据结构有什么用,也开发不了什么软件,大多数同学说的软件应该都是可视化的软件例如 APP、网站之类的,那都是非常上层的应用了,底层很多功能的实现都是基础的数据结构和算法。
所以数据结构与算法的应用往往隐藏在我们看不到的地方!
这里我就不过多展开了,先来看题。
# 进入正题
由于栈结构的特殊性,非常适合做对称匹配类的题目。
首先要弄清楚,字符串里的括号不匹配有几种情况。
一些同学,在面试中看到这种题目上来就开始写代码,然后就越写越乱。
建议要写代码之前要分析好有哪几种不匹配的情况,如果不动手之前分析好,写出的代码也会有很多问题。
先来分析一下 这里有三种不匹配的情况,
- 第一种情况,字符串里左方向的括号多余了 ,所以不匹配。
- 第二种情况,括号没有多余,但是括号的类型没有匹配上。
- 第三种情况,字符串里右方向的括号多余了,所以不匹配。
我们的代码只要覆盖了这三种不匹配的情况,就不会出问题,可以看出动手之前分析好题目的重要性。
动画如下:
已经遍历完了字符串,但是栈不为空,说明有相应的左括号没有右括号来匹配,所以 return false
左括号多余
遍历字符串匹配的过程中,发现栈里没有要匹配的字符。所以 return false
括号类型不匹配
遍历字符串匹配的过程中,栈已经为空了,没有匹配的字符了,说明右括号没有找到对应的左括号 return false
右括号多余
那么什么时候说明左括号和右括号全都匹配了呢,就是字符串遍历完之后,栈是空的,就说明全都匹配了。
分析完之后,代码其实就比较好写了,
但还有一些技巧,在匹配左括号的时候,右括号先入栈,就只需要比较当前元素和栈顶相不相等就可以了,比左括号先入栈代码实现要简单的多了!
class Solution { | |
public boolean isValid(String s) { | |
boolean flag = true; | |
// 特判:字符串长度为奇数,直接返回 false | |
if (s.length() % 2 == 1) { | |
return false; | |
} | |
Stack<Character> stack = new Stack<>(); | |
for (int i = 0; i < s.length(); i++) { | |
char c = s.charAt(i); | |
// 如果是左括号,入栈 | |
if (c == '(' || c == '[' || c == '{') { | |
stack.push(c); | |
} else { // 如果是右括号 | |
// 情况 3:如果栈为空,说明没有左括号与之匹配,直接返回 false | |
if (stack.isEmpty()) { | |
return false; | |
} | |
// 情况 2:如果栈不为空,且栈顶元素与当前右括号不匹配,直接返回 false | |
char topChar = stack.pop(); | |
if (c == ')' && topChar != '(') { | |
return false; | |
} | |
if (c == ']' && topChar != '[') { | |
return false; | |
} | |
if (c == '}' && topChar != '{') { | |
return false; | |
} | |
} | |
} | |
// 情况 1:遍历字符串结束,此时如果栈不为空,说明有左括号没有匹配,直接返回 false | |
if (!stack.isEmpty()) { | |
return false; | |
} | |
return flag; | |
} | |
} |
# 1047. 删除字符串中的所有相邻重复项
匹配问题都是栈的强项
本题要删除相邻相同元素,相对于 20. 有效的括号来说其实也是匹配问题,20. 有效的括号是匹配左右括号,本题是匹配相邻元素,最后都是做消除的操作。
本题也是用栈来解决的经典题目。
那么栈里应该放的是什么元素呢?
我们在删除相邻重复项的时候,其实就是要知道当前遍历的这个元素,我们在前一位是不是遍历过一样数值的元素,那么如何记录前面遍历过的元素呢?
所以就是用栈来存放,那么栈的目的,就是存放遍历过的元素,当遍历当前的这个元素的时候,去栈里看一下我们是不是遍历过相同数值的相邻元素。
然后再去做对应的消除操作。 如动画所示:
从栈中弹出剩余元素,此时是字符串 ac,因为从栈里弹出的元素是倒序的,所以在对字符串进行反转一下,就得到了最终的结果。
class Solution { | |
// 用栈来解决匹配问题 | |
public String removeDuplicates(String s) { | |
String res = ""; | |
Stack<Character> stack = new Stack<>(); | |
for (int i = 0; i < s.length(); i++) { // 遍历字符串 | |
char c = s.charAt(i); | |
if (stack.isEmpty() || stack.peek() != c) { // 如果栈为空或者栈顶元素不等于当前元素,入栈 | |
stack.push(c); | |
} else { // 如果栈顶元素等于当前元素,出栈 | |
stack.pop(); | |
} | |
} | |
while (!stack.isEmpty()) { // 将栈中元素出栈,拼接成字符串 | |
res += stack.pop(); | |
} | |
return new StringBuilder(res).reverse().toString(); // 反转字符串 | |
} | |
} |
可是效率有点低啊:
# 优化:直接拿字符串作为栈
class Solution { | |
// 拿字符串直接作为栈 | |
public String removeDuplicates(String s) { | |
String resStr = ""; | |
for (char c : s.toCharArray()) { | |
if (resStr.length() == 0 || resStr.charAt(resStr.length() - 1) != c) | |
resStr += c; // 如果栈为空,或者栈顶元素不等于当前元素,入栈 | |
else | |
resStr = resStr.substring(0, resStr.length() - 1); // 否则,出栈 | |
} | |
return resStr; | |
} | |
} |
可惜效率还是很低:
# 双指针法
class Solution { | |
// 双指针法(慢指针指向更新数组的位置,快指针去遍历) | |
public String removeDuplicates(String s) { | |
int slow = 0, fast = 0; // 慢指针指向当前不重复的位置,快指针遍历字符串 | |
char[] chars = s.toCharArray(); | |
for (; fast < chars.length; fast++) { | |
if (slow == 0 || chars[slow - 1] != chars[fast]) // 如果慢指针指向的位置和快指针指向的位置不相等,就将快指针指向的位置赋值给慢指针指向的位置 | |
chars[slow++] = chars[fast]; | |
else // 如果相等,就将慢指针向前移动一位,相当于删除了重复的字符 | |
slow--; | |
} | |
return new String(chars, 0, slow); | |
} | |
} |
双指针 yyds:
# 150. 逆波兰表达式求值
这不仅仅是一道好题,也展现出计算机的思考方式
# 概念
逆波兰表达式:是一种后缀表达式,所谓后缀就是指算符写在后面。
平常使用的算式则是一种中缀表达式,如 (1 + 2) * ( 3 + 4 ) 。
该算式的逆波兰表达式写法为 (( 1 2 +) ( 3 4 + ) * ) 。
逆波兰表达式主要有以下两个优点:
- 去掉括号后表达式无歧义,上式即便写成 1 2 + 3 4 + * 也可以依据次序计算出正确结果。
- 适合用栈操作运算:遇到数字则入栈;遇到算符则取出栈顶两个数字进行计算,并将结果压入栈中。
# 思路
在上一篇文章中 [1047. 删除字符串中的所有相邻重复项](#1047. 删除字符串中的所有相邻重复项) 提到了递归就是用栈来实现的。
所以栈与递归之间在某种程度上是可以转换的! 这一点我们在后续讲解二叉树的时候,会更详细的讲解到。
那么来看一下本题,其实逆波兰表达式相当于是二叉树中的后序遍历。 大家可以把运算符作为中间节点,按照后序遍历的规则画出一个二叉树。
但我们没有必要从二叉树的角度去解决这个问题,只要知道逆波兰表达式是用后序遍历的方式把二叉树序列化了,就可以了。
在进一步看,本题中每一个子表达式要得出一个结果,然后拿这个结果再进行运算,那么这岂不就是一个相邻字符串消除的过程,和 [1047. 删除字符串中的所有相邻重复项](#1047. 删除字符串中的所有相邻重复项) 中的对对碰游戏是不是就非常像了。
如动画所示:
相信看完动画大家应该知道,这和 [1047. 删除字符串中的所有相邻重复项](#1047. 删除字符串中的所有相邻重复项) 是差不多的,只不过本题不要相邻元素做消除了,而是做运算!
class Solution { | |
// 用栈实现 | |
public int evalRPN(String[] tokens) { | |
Stack<Integer> stack = new Stack<>(); | |
for (String t : tokens) { | |
// 注意:字符串的比较不能用 ==,要用 equals () | |
if (t.equals("+")) | |
stack.push(stack.pop() + stack.pop()); | |
else if (t.equals("-")) | |
stack.push(-stack.pop() + stack.pop()); // 注意减法的顺序 | |
else if (t.equals("*")) | |
stack.push(stack.pop() * stack.pop()); | |
else if (t.equals("/")) { | |
int num1 = stack.pop(), num2 = stack.pop(); | |
stack.push(num2 / num1); // 注意除法的顺序 | |
} else | |
stack.push(Integer.parseInt(t)); | |
} | |
return stack.pop(); | |
} | |
} |
# 题外话
我们习惯看到的表达式都是中缀表达式,因为符合我们的习惯,但是中缀表达式对于计算机来说就不是很友好。
例如:4 + 13 / 5,这就是中缀表达式,计算机从左到右去扫描的话,扫到 13,还要判断 13 后面是什么运算法,还要比较一下优先级,然后 13 还和后面的 5 做运算,做完运算之后,还要向前回退到 4 的位置,继续做加法,你说麻不麻烦!
那么将中缀表达式,转化为后缀表达式之后:["4", "13", "5", "/", "+"] ,就不一样了。 后缀表达式对计算机来说是非常友好的,因为计算机可以利用栈里顺序处理,不需要考虑优先级了。也不用回退了。
可以说本题不仅仅是一道好题,也展现出计算机的思考方式。
在 1970 年代和 1980 年代,惠普在其所有台式和手持式计算器中都使用了 RPN(后缀表达式),直到 2020 年代仍在某些模型中使用了 RPN。
参考维基百科如下:
During the 1970s and 1980s, Hewlett-Packard used RPN in all of their desktop and hand-held calculators, and continued to use it in some models into the 2020s.
# 239. 滑动窗口最大值
第一道 hard,要用啥数据结构呢?
这是使用 ** 单调队列 ** 的经典题目。
难点是如何求一个区间里的最大值呢? (这好像是废话),暴力一下不就得了。
暴力方法,遍历一遍的过程中每次从窗口中在找到最大的数值,这样很明显是 O (n × k) 的算法。
有的同学可能会想用一个大顶堆(优先级队列)来存放这个窗口里的 k 个数字,这样就可以知道最大的最大值是多少了, 但是问题是这个窗口是移动的,而大顶堆每次只能弹出最大值,我们无法移除其他数值,这样就造成大顶堆维护的不是滑动窗口里面的数值了。所以不能用大顶堆。
此时我们需要一个队列,这个队列呢,放进去窗口里的元素,然后随着窗口的移动,队列也一进一出,每次移动之后,队列告诉我们里面的最大值是什么。
这个队列应该长这个样子:
class MyQueue { | |
public: | |
void pop(int value) { | |
} | |
void push(int value) { | |
} | |
int front() { | |
return que.front(); | |
} | |
}; |
每次窗口移动的时候,调用 que.pop (滑动窗口中移除元素的数值),que.push (滑动窗口添加元素的数值),然后 que.front () 就返回我们要的最大值。
这么个队列香不香,要是有现成的这种数据结构是不是更香了!
可惜了,没有! 我们需要自己实现这么个队列。
然后在分析一下,队列里的元素一定是要排序的,而且要最大值放在出队口,要不然怎么知道最大值呢。
但如果把窗口里的元素都放进队列里,窗口移动的时候,队列需要弹出元素。
那么问题来了,已经排序之后的队列 怎么能把窗口要移除的元素(这个元素可不一定是最大值)弹出呢。
大家此时应该陷入深思.....
其实队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队里的元素数值是由大到小的。
那么这个维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。C++ 中没有直接支持单调队列,需要我们自己来一个单调队列
不要以为实现的单调队列就是对窗口里面的数进行排序,如果排序的话,那和优先级队列又有什么区别了呢。
来看一下单调队列如何维护队列里的元素。
动画如下:
对于窗口里的元素 {2, 3, 5, 1 ,4},单调队列里只维护 {5, 4} 就够了,保持单调队列里单调递减,此时队列出口元素就是窗口里最大元素。
此时大家应该怀疑单调队列里维护着 {5, 4} 怎么配合窗口进行滑动呢?
设计单调队列的时候,pop,和 push 操作要保持如下规则:
- pop (value):如果窗口移除的元素 value 等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
- push (value):如果 push 的元素 value 大于入口元素的数值,那么就将队列入口的元素弹出,直到 push 元素的数值小于等于队列入口元素的数值为止
保持如上规则,每次窗口移动的时候,只要问 que.front () 就可以返回当前窗口的最大值。
为了更直观的感受到单调队列的工作过程,以题目示例为例,输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3,动画如下:
那么我们用什么数据结构来实现这个单调队列呢?
使用 deque 最为合适,在文章栈与队列:来看看栈和队列不为人知的一面中,我们就提到了常用的 queue。在没有指定容器的情况下,deque 就是默认底层容器。
代码如下:
// 自定义单调队列类(使用双端队列 deque 作为底层数据结构) | |
class MonotonicQueue { | |
private Deque<Integer> deque; | |
public MonotonicQueue() { | |
deque = new LinkedList<>(); | |
} | |
// 返回当前队列中的最大值 | |
public int getMax() { | |
return deque.peekFirst(); //peekFirst () 方法返回队列头部元素 | |
} | |
// 向队列尾部添加元素 | |
public void push(int n) { | |
// 从队尾开始,依次弹出比 n 小的元素,确保队列单调递减 | |
while (!deque.isEmpty() && deque.peekLast() < n) { | |
deque.pollLast(); //pollLast () 方法弹出队列尾部元素,这就是 Deque 的好处 | |
} | |
deque.offerLast(n); //offerLast () 方法在队列尾部添加元素 | |
} | |
// 如果队列非空,且头部元素等于 n,则弹出头部元素,否则不做任何操作 | |
public void pop(int n) { | |
if (!deque.isEmpty() && deque.peekFirst() == n) { | |
deque.pollFirst(); | |
} | |
} | |
} | |
class Solution { | |
public int[] maxSlidingWindow(int[] nums, int k) { | |
MonotonicQueue window = new MonotonicQueue(); | |
int[] res = new int[nums.length - k + 1]; | |
for (int i = 0; i < nums.length; i++) { | |
if (i < k - 1) | |
window.push(nums[i]); // 先填满窗口的前 k - 1 | |
else { | |
window.push(nums[i]); // 窗口向前滑动 | |
res[i - k + 1] = window.getMax(); // 记录窗口最大值 | |
window.pop(nums[i - k + 1]); // 移除窗口最左侧元素 | |
} | |
} | |
return res; | |
} | |
} |
在来看一下时间复杂度,使用单调队列的时间复杂度是 O (n)。
有的同学可能想了,在队列中 push 元素的过程中,还有 pop 操作呢,感觉不是纯粹的 O (n)。
其实,大家可以自己观察一下单调队列的实现,nums 中的每个元素最多也就被 push_back 和 pop_back 各一次,没有任何多余操作,所以整体的复杂度还是 O (n)。
空间复杂度因为我们定义一个辅助队列,所以是 O (k)。
大家貌似对单调队列 都有一些疑惑,首先要明确的是,题解中单调队列里的 pop 和 push 接口,仅适用于本题哈。单调队列不是一成不变的,而是不同场景不同写法,总之要保证队列里单调递减或递增的原则,所以叫做单调队列。 不要以为本题中的单调队列实现就是固定的写法哈。
大家貌似对 deque 也有一些疑惑,C++ 中 deque 是 stack 和 queue 默认的底层实现容器(这个我们之前已经讲过啦),deque 是可以两边扩展的,而且 deque 里元素并不是严格的连续分布的。
# 347. 前 K 个高频元素
前 K 个大数问题,老生常谈,不得不谈
这道题目主要涉及到如下三块内容:
要统计元素出现频率
使用哈希结构中的 map (映射)
对频率排序
- 调用库函数,时间复杂度为 O (n*logn)
- 使用一种容器适配器:优先级队列
找出前 K 个高频元素
调用库函数对频率进行排序的代码如下:
class Solution { | |
// 用 map 存储元素,key 为元素,value 为出现的次数,对 map 的 value 进行排序,取前 k 个 | |
public int[] topKFrequent(int[] nums, int k) { | |
Map<Integer, Integer> map = new HashMap<>(); | |
for (int num : nums) { | |
map.put(num, map.getOrDefault(num, 0) + 1); //map.getOrDefault (key, defaultValue):如果 key 存在,返回 key 对应的 value,否则返回 defaultValue | |
} | |
List<Integer> list = new ArrayList<>(map.keySet()); // 将 map 的 key 存入 list | |
list.sort((o1, o2) -> map.get(o2) - map.get(o1)); // 对 list 进行降序排序,按照 map 的 value 进行排序,时间复杂度为 O (nlogn) | |
int[] res = new int[k]; | |
for (int i = 0; i < k; i++) { | |
res[i] = list.get(i); | |
} | |
return res; | |
} | |
} |
可见效率是比较低的,主要原因是调用了库函数 sort () 对频率进行排序(假如是快速排序),时间复杂度为。
因此,最好使用优先级队列来对频率进行排序。什么是优先级队列呢?
其实就是一个披着队列外衣的堆,因为优先级队列 ** 对外接口只是从队头取元素,从队尾添加元素**,再无其他取元素的方式,看起来就是一个队列。
而且优先级队列内部元素是自动依照元素的权值排列。那么它是如何有序排列的呢?
缺省情况下 priority_queue 利用 max-heap(大顶堆)完成对元素的排序,这个大顶堆是以 vector 为表现形式的 complete binary tree(完全二叉树)。
什么是堆呢?
堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。
所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用 priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。
本题我们就要使用优先级队列来对部分频率进行排序。
为什么不用快排呢, 使用快排要将 map 转换为 vector 的结构,然后对整个数组进行排序, 而这种场景下,我们其实只需要维护 k 个有序的序列就可以了,所以使用优先级队列是最优的。
此时要思考一下,是使用小顶堆呢,还是大顶堆?
有的同学一想,题目要求前 K 个高频元素,那么果断用大顶堆啊。
那么问题来了,定义一个大小为 k 的大顶堆,在每次移动更新大顶堆的时候,每次弹出都把最大的元素弹出去了,那么怎么保留下来前 K 个高频元素呢。
而且使用大顶堆就要把所有元素都进行排序,那能不能只排序 k 个元素呢?
所以我们要用小顶堆,因为要统计最大前 k 个元素,只有小顶堆每次将最小的元素弹出,最后小顶堆里积累的才是前 k 个最大元素。
寻找前 k 个最大元素流程如图所示:(图中的频率只有三个,所以正好构成一个大小为 3 的小顶堆,如果频率更多一些,则用这个小顶堆进行扫描)
class Solution { | |
// 使用小顶堆实现 | |
public int[] topKFrequent(int[] nums, int k) { | |
Map<Integer, Integer> map = new HashMap<>(); // 用 map 存储元素,key 为元素,value 为出现的次数 | |
for (int num : nums) { | |
map.put(num, map.getOrDefault(num, 0) + 1); | |
} | |
PriorityQueue<int[]> pq = new PriorityQueue<>((pair1, pair2) -> pair1[1] - pair2[1]); // 小顶堆,存储二元组 (元素,出现的次数) | |
for (Map.Entry<Integer, Integer> entry : map.entrySet()) { // 遍历 map 中每对 (key:value) | |
int num = entry.getKey(), count = entry.getValue(); | |
if (pq.size() < k) // 如果堆中元素个数小于 k 个,直接将元素加入堆中 | |
pq.offer(new int[]{num, count}); //offer ():将元素加入队列尾部 | |
else { // 如果堆中元素个数大于等于 k 个 | |
if (count > pq.peek()[1]) { // 如果当前元素的出现次数大于堆顶元素的出现次数(即 k 个元素中出现次数最少的那个) | |
pq.poll(); //poll ():移除并返回队列头部的元素 | |
pq.offer(new int[]{num, count}); // 将当前元素加入堆中 | |
} | |
} | |
} | |
int[] res = new int[k]; | |
for (int i = k - 1; i >= 0; i--) { // 因为是小顶堆,所以需要将堆中元素逆序输出 | |
res[i] = pq.poll()[0]; | |
} | |
return res; | |
} | |
} |
这时间效率一下就从 29.18% 飙到了 90.54%。因为使用小顶堆的时间复杂度是,而 是常数项,比库函数的 强多了。
# 总结
栈与队列是我们熟悉的不能再熟悉的数据结构,但它们的底层实现,很多同学都比较模糊,这其实就是基础所在。
可以出一道面试题:栈里面的元素在内存中是连续分布的么?
这个问题有两个陷阱:
- 陷阱 1:栈是容器适配器,底层容器使用不同的容器,导致栈内数据在内存中是不是连续分布。
- 陷阱 2:缺省情况下,默认底层容器是 deque,那么 deque 的在内存中的数据分布是什么样的呢? 答案是:不连续的,下文也会提到 deque。
所以这就是考察候选者基础知识扎不扎实的好问题。
大家还是要多多重视起来!
了解了栈与队列基础之后,那么可以用栈与队列:栈实现队列 和 栈与队列:队列实现栈 来练习一下栈与队列的基本操作。
值得一提的是,用栈与队列:用队列实现栈还有点别扭 中,其实只用一个队列就够了。
一个队列在模拟栈弹出元素的时候只要将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部,此时在去弹出元素就是栈的顺序了。
# 栈 —— 经典题目
# 栈在系统中的应用
如果还记得编译原理的话,编译器在 词法分析的过程中处理括号、花括号等这个符号的逻辑,就是使用了栈这种数据结构。
再举个例子,linux 系统中,cd 这个进入目录的命令我们应该再熟悉不过了。
cd a/b/c/../../ |
这个命令最后进入 a 目录,系统是如何知道进入了 a 目录呢 ,这就是栈的应用。这在 leetcode 上也是一道题目,编号:71. 简化路径,大家有空可以做一下。
递归的实现是栈:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。
所以栈在计算机领域中应用是非常广泛的。
有的同学经常会想学的这些数据结构有什么用,也开发不了什么软件,大多数同学说的软件应该都是可视化的软件例如 APP、网站之类的,那都是非常上层的应用了,底层很多功能的实现都是基础的数据结构和算法。
所以数据结构与算法的应用往往隐藏在我们看不到的地方!
# [括号匹配问题](#20. 有效的括号)
建议要写代码之前要分析好有哪几种不匹配的情况,如果不动手之前分析好,写出的代码也会有很多问题。
先来分析一下 这里有三种不匹配的情况,
- 第一种情况,字符串里左方向的括号多余了 ,所以不匹配。
- 第二种情况,括号没有多余,但是括号的类型没有匹配上。
- 第三种情况,字符串里右方向的括号多余了,所以不匹配。
这里还有一些技巧,在匹配左括号的时候,右括号先入栈,就只需要比较当前元素和栈顶相不相等就可以了,比左括号先入栈代码实现要简单的多了!
# [字符串去重问题](#1047. 删除字符串中的所有相邻重复项)
思路:把字符串按顺序放到一个栈中,放之前与栈顶字符比较,然后如果相同的话栈就弹出,这样最后栈里剩下的元素都是相邻不相同的元素了。
# [逆波兰表达式问题](#150. 逆波兰表达式求值)
思路:遇到数字则入栈;遇到算符则取出栈顶两个数字进行计算,并将结果压入栈中。
# 队列 —— 经典题目
# [滑动窗口最大值问题](#239. 滑动窗口最大值)
使用 == 单调队列 ==,主要思想是队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。
那么这个维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。C++ 中没有直接支持单调队列,需要我们自己来一个单调队列
而且不要以为实现的单调队列就是对窗口里面的数进行排序,如果排序的话,那和优先级队列又有什么区别了呢。
设计单调队列的时候,pop,和 push 操作要保持如下规则:
- pop(value):如果窗口移除的元素 value 等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
- push(value):如果 push 的元素 value 大于入口元素的数值,那么就将队列出口的元素弹出,直到 push 元素的数值小于等于队列入口元素的数值为止
保持如上规则,每次窗口移动的时候,只要问 que.front () 就可以返回当前窗口的最大值。
一些同学还会对单调队列都有一些困惑,首先要明确的是,题解中单调队列里的 pop 和 push 接口,仅适用于本题。
单调队列不是一成不变的,而是不同场景不同写法,总之要保证队列里单调递减或递增的原则,所以叫做单调队列。
不要以为本地中的单调队列实现就是固定的写法。
我们用 deque 作为单调队列的底层数据结构,C++ 中 deque 是 stack 和 queue 默认的底层实现容器(这个我们之前已经讲过),deque 是可以两边扩展的,而且 deque 里元素并不是严格的连续分布的。
# 求前 k 个高频元素
通过求前 K 个高频元素,引出另一种队列就是 ** 优先级队列 **。
什么是优先级队列呢?
其实就是一个披着队列外衣的堆,因为优先级队列对外接口只是:从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。
而且优先级队列内部元素是自动依照元素的权值排列。那么它是如何有序排列的呢?
缺省情况下 priority_queue 利用 max-heap(大顶堆)完成对元素的排序,这个大顶堆是以 vector 为表现形式的 complete binary tree(完全二叉树)。
什么是堆呢?
堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。
所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用 priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。
本题就要使用优先级队列来对部分频率进行排序。 注意这里是对部分数据进行排序而不需要对所有数据排序!
所以排序的过程的时间复杂度是,整个算法的时间复杂度是。
# 小结
在栈与队列系列中,我们强调栈与队列的基础,也是很多同学容易忽视的点。
使用抽象程度越高的语言,越容易忽视其底层实现,而 C++ 相对来说是比较接近底层的语言。
我们用栈实现队列,用队列实现栈来掌握的栈与队列的基本操作。
接着,通过括号匹配问题、字符串去重问题、逆波兰表达式问题来系统讲解了栈在系统中的应用,以及使用技巧。
通过求滑动窗口最大值,以及前 K 个高频元素介绍了两种队列:单调队列和优先级队列,这是特殊场景解决问题的利器,是一定要掌握的。
好了,栈与队列我们就总结到这里了,接下来 Carl 就要带大家开启新的篇章了,大家加油!
# 二叉树
# 二叉树的理论基础
# 种类
在我们解题过程中二叉树有两种主要的形式:满二叉树和完全二叉树。
# 满二叉树
如果一棵二叉树只有度为 0 的结点和度为 2 的结点,并且度为 0 的结点在同一层上,则这棵二叉树为满二叉树。
如图所示:
这棵二叉树为满二叉树,也可以说深度为 k,有 2k-1 个节点的二叉树。
# 完全二叉树
满二叉树是完全二叉树
在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层,则该层包含 1~ 2(h-1) 个节点。
大家要自己看完全二叉树的定义,很多同学对完全二叉树其实不是真正的懂了。
我来举一个典型的例子如题:
相信不少同学最后一个二叉树是不是完全二叉树都中招了。
之前我们刚刚讲过优先级队列其实是一个堆,堆就是一棵完全二叉树,同时保证父子节点的顺序关系。
# 二叉搜索树
前面介绍的树,都没有数值的,而二叉搜索树是有数值的了,二叉搜索树是一个有序树。
若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
它的左、右子树也分别为二叉排序树
二叉搜索树,也称二叉排序树、二叉查找树;
树的定义中常见的 “递归定义”;
下面这两棵树都是搜索树
# 平衡二叉搜索树
又被称为 AVL(Adelson-Velsky and Landis)树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过 1,并且左右两个子树都是一棵平衡二叉树。
如图:
最后一棵 不是平衡二叉树,因为它的左右两个子树的高度差的绝对值超过了 1。
C++ 中 map、set、multimap,multiset 的底层实现都是平衡二叉搜索树,所以 map、set 的增删操作时间时间复杂度是 logn,注意我这里没有说 unordered_map、unordered_set,unordered_map、unordered_map 底层实现是哈希表。
所以大家使用自己熟悉的编程语言写算法,一定要知道常用的容器底层都是如何实现的,最基本的就是 map、set 等等,否则自己写的代码,自己对其性能分析都分析不清楚!
# 存储方式
二叉树可以链式存储,也可以顺序存储。
那么链式存储方式就用指针, 顺序存储的方式就是用数组。
顾名思义就是顺序存储的元素在内存是连续分布的,而链式存储则是通过指针把分布在散落在各个地址的节点串联一起。
- 链式存储如图:
链式存储是大家很熟悉的一种方式,那么我们来看看如何顺序存储呢?
- 其实就是用数组来存储二叉树,顺序存储的方式如图:
用数组来存储二叉树如何遍历的呢?
如果父节点的数组下标是 i,那么它的左孩子就是 i * 2 + 1,右孩子就是 i * 2 + 2。
但是用链式表示的二叉树,更有利于我们理解,所以一般我们都是用链式存储二叉树。
所以大家要了解,用数组依然可以表示二叉树。
# 遍历方式
二叉树主要有两种遍历方式:
- 深度优先遍历:先往深走,遇到叶子节点再往回走。
- 广度优先遍历:一层一层的去遍历。
这两种遍历是图论中最基本的两种遍历方式,后面在介绍图论的时候 还会介绍到。
那么从深度优先遍历和广度优先遍历进一步拓展,才有如下遍历方式:
- 深度优先遍历
- 前序遍历(递归法,迭代法)
- 中序遍历(递归法,迭代法)
- 后序遍历(递归法,迭代法)
- 广度优先遍历
- 层次遍历(迭代法)
在深度优先遍历中:有三个顺序,前中后序遍历, 有同学总分不清这三个顺序,经常搞混,我这里教大家一个技巧。
这里前中后,其实指的就是根节点的遍历顺序,只要大家记住前中后序指的就是根节点的位置就可以了。
看如下根节点的顺序,就可以发现,根节点的顺序就是所谓的遍历方式
- 前序遍历:中左右
- 中序遍历:左中右
- 后序遍历:左右中
大家可以对着如下图,看看自己理解的前后中序有没有问题。
最后再说一说二叉树中深度优先和广度优先遍历实现方式,我们做二叉树相关题目,经常会使用递归的方式来实现深度优先遍历,也就是实现前中后序遍历,使用递归是比较方便的。
之前我们讲栈与队列的时候,就说过栈其实就是递归的一种是实现结构,也就说前中后序遍历的逻辑其实都是可以借助栈使用非递归的方式来实现的。
而广度优先遍历的实现一般使用队列来实现,这也是队列先进先出的特点所决定的,因为需要先进先出的结构,才能一层一层的来遍历二叉树。
这里其实我们又了解了栈与队列的一个应用场景了。
具体的实现我们后面都会讲的,这里大家先要清楚这些理论基础。
# 定义
链式存储的二叉树节点的定义方式:
public class TreeNode { | |
int val; | |
TreeNode left; | |
TreeNode right; | |
TreeNode() { | |
} | |
TreeNode(int val) { | |
this.val = val; | |
} | |
TreeNode(int val, TreeNode left, TreeNode right) { | |
this.val = val; | |
this.left = left; | |
this.right = right; | |
} | |
} |
大家会发现二叉树的定义和链表是差不多的,相对于链表 ,二叉树的节点里多了一个指针, 有两个指针,指向左右孩子。
这里要提醒大家要注意二叉树节点定义的书写方式。
在现场面试的时候 面试官可能要求手写代码,所以数据结构的定义以及简单逻辑的代码一定要锻炼白纸写出来。
因为我们在刷 leetcode 的时候,节点的定义默认都定义好了,真到面试的时候,需要自己写节点定义的时候,有时候会一脸懵逼!
# 总结
二叉树是一种基础数据结构,在算法面试中都是常客,也是众多数据结构的基石。
本篇我们介绍了二叉树的种类、存储方式、遍历方式以及定义,比较全面的介绍了二叉树各个方面的重点,帮助大家扫一遍基础。
说到二叉树,就不得不说递归,很多同学对递归都是又熟悉又陌生,递归的代码一般很简短,但每次都是一看就会,一写就废。
# 二叉树的递归遍历
一看就会,一写就废!
这次我们要好好谈一谈递归,为什么很多同学看递归算法都是 “一看就会,一写就废”。
主要是对递归不成体系,没有方法论,每次写递归算法 ,都是靠玄学来写代码,代码能不能编过都靠运气。
本篇将介绍前后中序的递归写法,一些同学可能会感觉很简单,其实不然,我们要通过简单题目把方法论确定下来,有了方法论,后面才能应付复杂的递归。
这里帮助大家确定下来递归算法的三个要素。每次写递归,都按照这三要素来写,可以保证大家写出正确的递归算法!
- 确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数, 并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型。
- 确定终止条件: 写完了递归算法,运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。
- 确定单层递归的逻辑: 确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。
好了,我们确认了递归的三要素,接下来就来练练手:
以下以前序遍历为例:
确定递归函数的参数和返回值:因为要打印出前序遍历节点的数值,所以参数里需要传入 List 来存放各个节点的数值,除了这一点就不需要在处理什么数据了也不需要有返回值,所以递归函数返回类型就是 void,代码如下:
public void preorder(TreeNode cur, List<Integer> res)
确定终止条件:在递归的过程中,如何算是递归结束了呢,当然是当前遍历的节点是空了,那么本层递归就要要结束了,所以如果当前遍历的这个节点是空,就直接 return,代码如下:
if (cur == null)
return;
确定单层递归的逻辑:前序遍历是中左右的循序,所以在单层递归的逻辑,是要先取中节点的数值,代码如下:
res.add(cur.val); // 首先访问根节点
preorder(cur.left, res); // 递归访问左子树
preorder(cur.right, res); // 递归访问右子树
单层递归的逻辑就是按照中左右的顺序来处理的,这样二叉树的前序遍历,基本就写完了,再看一下完整代码:
# 前序遍历(递归)
public void preorder(TreeNode cur, List<Integer> res) { | |
if (cur == null) | |
return; | |
res.add(cur.val); // 首先访问根节点 | |
preorder(cur.left, res); // 递归访问左子树 | |
preorder(cur.right, res); // 递归访问右子树 | |
} |
# 中序遍历(递归)
public void inorder(TreeNode cur, List<Integer> res) { | |
if (cur == null) | |
return; | |
inorder(cur.left, res); // 递归访问左子树 | |
res.add(cur.val); // 访问根节点 | |
inorder(cur.right, res); // 递归访问右子树 | |
} |
# 后序遍历(递归)
public void postorder(TreeNode cur, List<Integer> res) { | |
if (cur == null) | |
return; | |
postorder(cur.left, res); // 递归访问左子树 | |
postorder(cur.right, res); // 递归访问右子树 | |
res.add(cur.val); // 访问根节点 | |
} |
下面看一下 leetcode 上三道题目,分别是:
144. 二叉树的前序遍历
class Solution {
// 递归
public void preorder(TreeNode cur, List<Integer> res) {
if (cur == null)
return;
res.add(cur.val); // 首先访问根节点
preorder(cur.left, res); // 递归访问左子树
preorder(cur.right, res); // 递归访问右子树
}
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
preorder(root, res);
return res;
}
}
145. 二叉树的后序遍历
class Solution {
public void postorder(TreeNode cur, List<Integer> res) {
if (cur == null)
return;
postorder(cur.left, res);
postorder(cur.right, res);
res.add(cur.val);
}
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
postorder(root, res);
return res;
}
}
94. 二叉树的中序遍历
class Solution {
public void inorder(TreeNode cur, List<Integer> res) {
if (cur == null)
return;
inorder(cur.left, res);
res.add(cur.val);
inorder(cur.right, res);
}
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
inorder(root, res);
return res;
}
}
可能有同学感觉前后中序遍历的递归太简单了,要打迭代法(非递归),别急,我们明天打迭代法,打个通透
# 二叉树的迭代遍历
听说还可以用非递归的方式 —— 栈
为什么可以用迭代法(非递归的方式)来实现二叉树的前后中序遍历呢?
我们在栈与队列:匹配问题都是栈的强项中提到了,递归的实现就是:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。
此时大家应该知道我们用栈也可以是实现二叉树的前后中序遍历了。
# 前序遍历(迭代)
前序遍历是中左右,每次先处理的是中间节点,那么先将根节点放入栈中,然后将右孩子加入栈,再加入左孩子。
为什么要先加入 右孩子,再加入左孩子呢? 因为这样出栈的时候才是中左右的顺序。
动画如下:
不难写出如下代码: (注意代码中空节点不入栈)
class Solution { | |
// 迭代实现前序遍历 | |
public List<Integer> preorderTraversal(TreeNode root) { | |
List<Integer> res = new ArrayList<>(); | |
if (root == null) | |
return res; | |
Stack<TreeNode> stack = new Stack<>(); | |
stack.push(root); | |
while (!stack.isEmpty()) { | |
TreeNode node = stack.pop(); | |
res.add(node.val); // 将栈顶元素的值添加到结果集 | |
if (node.right != null) // 先压入右子树 | |
stack.push(node.right); | |
if (node.left != null) // 再压入左子树 | |
stack.push(node.left); | |
} | |
return res; | |
} | |
} |
此时会发现貌似使用迭代法写出前序遍历并不难,确实不难。
此时是不是想改一点前序遍历代码顺序就把中序遍历搞出来了?
其实还真不行!
但接下来,再用迭代法写中序遍历的时候,会发现套路又不一样了,目前的前序遍历的逻辑无法直接应用到中序遍历上。
# 中序遍历(迭代)
为了解释清楚,我说明一下 刚刚在迭代的过程中,其实我们有两个操作:
- 处理:将元素放进 result 数组中
- 访问:遍历节点
分析一下为什么刚刚写的前序遍历的代码,不能和中序遍历通用呢,因为前序遍历的顺序是中左右,先访问的元素是中间节点,要处理的元素也是中间节点,所以刚刚才能写出相对简洁的代码,因为要访问的元素和要处理的元素顺序是一致的,都是中间节点。
那么再看看中序遍历,中序遍历是左中右,先访问的是二叉树顶部的节点,然后一层一层向下访问,直到到达树左面的最底部,再开始处理节点(也就是在把节点的数值放进 result 数组中),这就造成了处理顺序和访问顺序是不一致的。
那么在使用迭代法写中序遍历,就需要借用指针的遍历来帮助访问节点,栈则用来处理节点上的元素。
动画如下:
中序遍历,可以写出如下代码:
class Solution { | |
// 迭代实现中序遍历:左根右 | |
public List<Integer> inorderTraversal(TreeNode root) { | |
List<Integer> res = new ArrayList<>(); | |
Stack<TreeNode> stack = new Stack<>(); // 用于存放节点 | |
TreeNode cur = root; // 用于遍历节点 | |
while (cur != null || !stack.isEmpty()) { // 节点不为空 / 栈不为空 | |
if (cur != null) { | |
stack.push(cur); // 将节点压入栈 | |
cur = cur.left; // 一路向左 | |
} else { //cur == null 意味着已经到达最左边的节点 | |
cur = stack.pop(); // 弹出栈顶元素,同时 cur 回退 | |
res.add(cur.val); // 根 | |
cur = cur.right; // 右 | |
} | |
} // 此时栈为空,且 cur 为 null,遍历结束 | |
return res; | |
} | |
} |
# 后续遍历(迭代)
再来看后序遍历,先序遍历是中左右,后续遍历是左右中,那么我们只需要调整一下先序遍历的代码顺序,就变成中右左的遍历顺序,然后在反转 result 数组,输出的结果顺序就是左右中了,如下图:
所以后序遍历只需要前序遍历的代码稍作修改就可以了,代码如下:
class Solution { | |
// 迭代实现后序遍历:左右根 | |
public List<Integer> postorderTraversal(TreeNode root) { | |
List<Integer> res = new ArrayList<>(); | |
if (root == null) { | |
return res; | |
} | |
Stack<TreeNode> stack = new Stack<>(); | |
stack.push(root); | |
while (!stack.isEmpty()) { | |
TreeNode node = stack.pop(); | |
res.add(node.val); | |
if (node.left != null) { // 先压入左子树 | |
stack.push(node.left); | |
} | |
if (node.right != null) { // 再压入右子树 | |
stack.push(node.right); | |
} | |
} | |
Collections.reverse(res); // 反转结果集 | |
return res; | |
} | |
} |
# 总结
此时我们用迭代法写出了二叉树的前后中序遍历,大家可以看出前序和中序是完全两种代码风格,并不像递归写法那样代码稍做调整,就可以实现前后中序。
这是因为前序遍历中访问节点(遍历节点)和处理节点(将元素放进 result 数组中)可以同步处理,但是中序就无法做到同步!
上面这句话,可能一些同学不太理解,建议自己亲手用迭代法,先写出来前序,再试试能不能写出中序,就能理解了。
那么问题又来了,难道 二叉树前后中序遍历的迭代法实现,就不能风格统一么(即前序遍历 改变代码顺序就可以实现中序 和 后序)?
当然可以,这种写法,还不是很好理解,我们将在下一篇文章里重点讲解,敬请期待!
# ☆二叉树的统一迭代法
此时我们在二叉树的递归遍历中用递归的方式,实现了二叉树前中后序的遍历。
在二叉树的迭代遍历中用栈实现了二叉树前后中序的迭代遍历(非递归)。
之后我们发现迭代法实现的先中后序,其实风格也不是那么统一,除了先序和后序,有关联,中序完全就是另一个风格了,一会用栈遍历,一会又用指针来遍历。
实践过的同学,也会发现使用迭代法实现先中后序遍历,很难写出统一的代码,不像是递归法,实现了其中的一种遍历方式,其他两种只要稍稍改一下节点顺序就可以了。
其实针对三种遍历方式,使用迭代法是可以写出统一风格的代码!
重头戏来了,接下来介绍一下统一写法。
我们以中序遍历为例,在二叉树的迭代遍历中提到说使用栈的话,无法同时解决访问节点(遍历节点)和处理节点(将元素放进结果集)不一致的情况。
那我们就将访问的节点放入栈中,把要处理的节点也放入栈中但是要做标记。
如何标记呢,就是要处理的节点放入栈之后,紧接着放入一个空指针作为标记。 这种方法也可以叫做 == 标记法 ==。
# 中序遍历(统一迭代)
先来看一下动画 (中序遍历):
动画中,result 数组就是最终结果集。
可以看出我们将访问的节点直接加入到栈中,但如果是处理的节点则后面放入一个空节点, 这样只有空节点弹出的时候,才将下一个节点放进结果集。
class Solution { | |
// (统一格式的)迭代实现中序遍历:左根右 | |
public List<Integer> inorderTraversal(TreeNode root) { | |
List<Integer> res = new ArrayList<>(); | |
Stack<TreeNode> stack = new Stack<>(); // 用于存放节点 | |
if (root != null) | |
stack.push(root); | |
while (!stack.isEmpty()) { | |
TreeNode node = stack.peek(); // 获取栈顶元素,但不弹出 | |
if (node != null) { | |
stack.pop(); // 弹出栈顶元素,避免重复访问,下面再将右、中、左节点入栈 | |
if (node.right != null) | |
stack.push(node.right); // 右节点入栈 | |
stack.push(node); // 中节点入栈 | |
stack.push(null); // 标记中节点已访问,待处理 | |
if (node.left != null) | |
stack.push(node.left); // 左节点入栈 | |
} else { // 遇到 null,说明中节点已访问,弹出 null,弹出中节点,将中节点值加入结果集 | |
stack.pop(); | |
node = stack.pop(); | |
res.add(node.val); | |
} | |
} | |
return res; | |
} | |
} |
# 前序遍历(统一迭代)
迭代法前序遍历代码如下: (注意此时我们和中序遍历相比仅仅改变了两处代码的顺序)
class Solution { | |
// (统一格式的)迭代实现前序遍历:根左右 | |
public List<Integer> preorderTraversal(TreeNode root) { | |
List<Integer> res = new ArrayList<>(); | |
Stack<TreeNode> stack = new Stack<>(); // 用于存放节点 | |
if (root != null) | |
stack.push(root); | |
while (!stack.isEmpty()) { | |
TreeNode node = stack.peek(); // 获取栈顶元素,但不弹出 | |
if (node != null) { | |
stack.pop(); // 弹出栈顶元素,避免重复访问,下面再将右、中、左节点入栈 | |
if (node.right != null) | |
stack.push(node.right); // 右节点入栈 | |
if (node.left != null) | |
stack.push(node.left); // 左节点入栈 | |
stack.push(node); // 中节点入栈 | |
stack.push(null); // 标记中节点已访问,待处理 | |
} else { // 遇到 null,说明中节点已访问,弹出 null,弹出中节点,将中节点值加入结果集 | |
stack.pop(); | |
node = stack.pop(); | |
res.add(node.val); | |
} | |
} | |
return res; | |
} | |
} |
# 后续遍历(统一迭代)
后续遍历代码如下: (注意此时我们和中序遍历相比仅仅改变了两处代码的顺序)
class Solution { | |
// 迭代实现后序遍历:左右根 | |
public List<Integer> postorderTraversal(TreeNode root) { | |
List<Integer> res = new ArrayList<>(); | |
Stack<TreeNode> stack = new Stack<>(); // 用于存放节点 | |
if (root != null) | |
stack.push(root); | |
while (!stack.isEmpty()) { | |
TreeNode node = stack.peek(); // 获取栈顶元素,但不弹出 | |
if (node != null) { | |
stack.pop(); // 弹出栈顶元素,避免重复访问,下面再将右、中、左节点入栈 | |
stack.push(node); // 中节点入栈 | |
stack.push(null); // 标记中节点已访问,待处理 | |
if (node.right != null) | |
stack.push(node.right); // 右节点入栈 | |
if (node.left != null) | |
stack.push(node.left); // 左节点入栈 | |
} else { // 遇到 null,说明中节点已访问,弹出 null,弹出中节点,将中节点值加入结果集 | |
stack.pop(); | |
node = stack.pop(); | |
res.add(node.val); | |
} | |
} | |
return res; | |
} | |
} |
# 总结
此时我们写出了统一风格的迭代法,不用在纠结于前序写出来了,中序写不出来的情况了。
但是统一风格的迭代法并不好理解,而且想在面试直接写出来还有难度的。
所以大家根据自己的个人喜好,对于二叉树的前中后序遍历,选择一种自己容易理解的递归和迭代法。
# 102. 二叉树的层序遍历
学会二叉树的层序遍历,可以一口气打完以下十题:
- 102. 二叉树的层序遍历
- 107. 二叉树的层序遍历 II
- 199. 二叉树的右视图
- 637. 二叉树的层平均值
- 429. N 叉树的层序遍历
- 515. 在每个树行中找最大值
- 116. 填充每个节点的下一个右侧节点指针
- 117. 填充每个节点的下一个右侧节点指针 II
- 104. 二叉树的最大深度
- 111. 二叉树的最小深度
之前讲过了三篇关于二叉树的深度优先遍历的文章:
- 二叉树:前中后序递归法
- 二叉树:前中后序迭代法
- 二叉树:前中后序迭代方式统一写法
接下来我们再来介绍二叉树的另一种遍历方式:层序遍历。
层序遍历一个二叉树。就是从左到右一层一层的去遍历二叉树。这种遍历的方式和我们之前讲过的都不太一样。
需要借用一个辅助数据结构即队列来实现,队列先进先出,符合一层一层遍历的逻辑,而是用栈先进后出适合模拟深度优先遍历也就是递归的逻辑。
而这种层序遍历方式就是图论中的广度优先遍历,只不过我们应用在二叉树上。
使用队列实现二叉树广度优先遍历,动画如下:
这样就实现了层序从左到右遍历二叉树。
# 迭代(借助队列)
代码如下:这份代码也可以作为二叉树层序遍历的模板,打十个就靠它了。
// 迭代(借助队列)实现二叉树的层序遍历 | |
class Solution { | |
public List<List<Integer>> levelOrder(TreeNode root) { | |
List<List<Integer>> resList = new ArrayList<>(); // 保存结果的列表,是一个二维列表 | |
Queue<TreeNode> que = new LinkedList<TreeNode>(); // 保存节点的队列 | |
if (root != null) { | |
que.offer(root); // 将根节点入队 | |
} else { | |
return resList; | |
} | |
while (!que.isEmpty()) { | |
int size = que.size(); // 当前层的节点数 | |
List<Integer> levelList = new ArrayList<>(); // 保存当前层的节点值 | |
while (size > 0) { | |
TreeNode tmpNode = que.poll(); // 出队 | |
levelList.add(tmpNode.val); // 将节点值加入当前层的列表 | |
if (tmpNode.left != null) { | |
que.offer(tmpNode.left); // 将左子节点入队 | |
} | |
if (tmpNode.right != null) { | |
que.offer(tmpNode.right); // 将右子节点入队 | |
} | |
size--; | |
} | |
resList.add(levelList); // 将当前层的列表加入到结果列表中 | |
} | |
return resList; | |
} | |
} |
# 递归
// 递归实现二叉树的层序遍历 | |
class Solution { | |
public List<List<Integer>> levelOrder(TreeNode root) { | |
List<List<Integer>> resList = new ArrayList<>(); // 保存结果的列表,是一个二维列表 | |
int depth = 0; // 记录当前层的深度 | |
recursionFunc(root, resList, depth); | |
return resList; | |
} | |
// DFS - 递归方式 | |
public void recursionFunc(TreeNode cur, List<List<Integer>> resList, Integer deep) { | |
if (cur == null) // 递归终止条件 | |
return; | |
if (resList.size() == deep) // 如果当前层还没有创建对应的 list,就创建一个 | |
resList.add(new ArrayList<>()); | |
resList.get(deep).add(cur.val); // 将当前节点的值加入到对应的层的 list 中 | |
recursionFunc(cur.left, resList, deep + 1); // 递归遍历左子树 | |
recursionFunc(cur.right, resList, deep + 1); // 递归遍历右子树 | |
} | |
} |
# 226. 翻转二叉树
# 题外话
这道题目是非常经典的题目,也是比较简单的题目(至少一看就会)。
但正是因为这道题太简单,一看就会,一些同学都没有抓住起本质,稀里糊涂的就把这道题目过了。
如果做过这道题的同学也建议认真看完,相信一定有所收获!
# 思路
这得怎么翻转呢?
如果要从整个树来看,翻转还真的挺复杂,整个树以中间分割线进行翻转,如图:
可以发现想要翻转它,其实就 ** 把每一个节点的左右孩子交换一下就可以了 **。
关键在于遍历顺序,前中后序应该选哪一种遍历顺序? (一些同学这道题都过了,但是不知道自己用的是什么顺序)
遍历的过程中去翻转每一个节点的左右孩子就可以达到整体翻转的效果。
注意只要把每一个节点的左右孩子翻转一下,就可以达到整体翻转的效果
这道题目使用前序遍历和后序遍历都可以,唯独中序遍历不方便,因为中序遍历会把某些节点的左右孩子翻转了两次!建议拿纸画一画,就理解了
那么层序遍历可以不可以呢?依然可以的!只要把每一个节点的左右孩子翻转一下的遍历方式都是可以的!
# 递归法
下文以前序遍历(根左右)为例,通过动画来看一下翻转的过程:
我们来看一下递归三部曲:
- 确定递归函数的参数和返回值
参数就是要传入节点的指针,不需要其他参数了,通常此时定下来主要参数,如果在写递归的逻辑中发现还需要其他参数的时候,随时补充。
返回值的话其实也不需要,但是题目中给出的要返回 root 节点的指针,可以直接使用题目定义好的函数,所以就函数的返回类型为 TreeNode*
。
public TreeNode invertTree(TreeNode root) |
- 确定终止条件
当前节点为空的时候,就返回
if (root == null) | |
return root; |
- 确定单层递归的逻辑
因为是先前序遍历,所以先进行交换左右孩子节点,然后反转左子树,反转右子树。
// 根:交换左右子树 | |
TreeNode temp = root.left; | |
root.left = root.right; | |
root.right = temp; | |
// 左:递归 | |
invertTree(root.left); | |
// 右:递归 | |
invertTree(root.right); |
基于这递归三步法,代码基本写完,代码如下:
class Solution { | |
// 递归(前序遍历) | |
public TreeNode invertTree(TreeNode root) { | |
// 递归终止条件 | |
if (root == null) | |
return root; | |
// 根:交换左右子树 | |
TreeNode temp = root.left; | |
root.left = root.right; | |
root.right = temp; | |
// 左:递归 | |
invertTree(root.left); | |
// 右:递归 | |
invertTree(root.right); | |
return root; | |
} | |
} |
对于后序遍历,直接把 “根” 的代码放到 “右” 的后面即可。
但对于中序遍历,就不能简单地把 “根” 的代码放到 “左” 的后面了,还需要把 “右” 处改为 root.left
。这样才能避免对 root 的左子树交换两次,而右子树没变动的情况。
class Solution { | |
// 递归(中序遍历) | |
public TreeNode invertTree(TreeNode root) { | |
// 递归终止条件 | |
if (root == null) | |
return null; | |
// 左子树翻转 | |
invertTree(root.left); | |
// 根:交换左右子树 | |
TreeNode tmp = root.left; | |
root.left = root.right; | |
root.right = tmp; | |
// "左" 子树翻转!!!! | |
invertTree(root.left); | |
return root; | |
} | |
} |
# 迭代法
# 深度优先遍历
前序遍历:
class Solution { | |
// 迭代(前序遍历),借助栈 | |
public TreeNode invertTree(TreeNode root) { | |
if (root == null) | |
return root; | |
Stack<TreeNode> stack = new Stack<>(); | |
stack.push(root); | |
while (!stack.isEmpty()) { | |
// 根:交换左右子树 | |
TreeNode node = stack.pop(); | |
TreeNode temp = node.left; | |
node.left = node.right; | |
node.right = temp; | |
// 右:入栈 | |
if (node.right != null) | |
stack.push(node.right); | |
// 左:入栈 | |
if (node.left != null) | |
stack.push(node.left); | |
} | |
return root; | |
} | |
} |
对于后序遍历,只需要把” 右 “处代码和” 左 “处的交换位置即可。
对于中序遍历,不方便直接变动上述代码,但之前介绍了一种统一的迭代遍历方式,下面以前序遍历举例:
搞不懂啊!
class Solution { | |
// 统一格式迭代(前序遍历),借助栈 | |
public TreeNode invertTree(TreeNode root) { | |
Stack<TreeNode> stack = new Stack<>(); | |
if (root != null) { | |
stack.push(root); | |
} | |
while (!stack.isEmpty()) { | |
TreeNode node = stack.peek(); | |
if (node != null) { // 非空节点 | |
stack.pop(); | |
// 右 | |
if (node.right != null) { | |
stack.push(node.right); | |
} | |
// 左 | |
if (node.left != null) { | |
stack.push(node.left); | |
} | |
// 中 | |
stack.push(node); | |
stack.push(null); | |
} else { // 空节点 | |
stack.pop(); // 弹出 null 节点 | |
node = stack.peek(); // 待处理的节点 | |
stack.pop(); // 弹出待处理的节点 | |
// 交换左右子树 | |
TreeNode temp = node.left; | |
node.left = node.right; | |
node.right = temp; | |
} | |
} | |
return root; | |
} | |
} |
这效率有够拉的:
# 广度优先遍历
就是层序遍历嘛
class Solution { | |
// 层序遍历(广度优先搜索),借助队列 | |
public TreeNode invertTree(TreeNode root) { | |
Queue<TreeNode> que = new LinkedList<TreeNode>(); | |
if (root != null) { | |
que.offer(root); | |
} | |
while (!que.isEmpty()) { | |
int size = que.size(); | |
while (size > 0) { | |
TreeNode tmpNode = que.poll(); | |
// 处理中间节点:交换左右子树 | |
TreeNode tmp = tmpNode.left; | |
tmpNode.left = tmpNode.right; | |
tmpNode.right = tmp; | |
// 左右子树入队 | |
if (tmpNode.left != null) { | |
que.offer(tmpNode.left); | |
} | |
if (tmpNode.right != null) { | |
que.offer(tmpNode.right); | |
} | |
size--; | |
} | |
} | |
return root; | |
} | |
} |
这效率有点牛:
# 拓展
文中我指的是递归的中序遍历是不行的,因为使用递归的中序遍历,某些节点的左右孩子会翻转两次。
如果非要使用递归中序的方式写,也可以,如下代码就可以避免节点左右孩子翻转两次的情况:
class Solution { | |
// 递归(中序遍历) | |
public TreeNode invertTree(TreeNode root) { | |
// 递归终止条件 | |
if (root == null) | |
return null; | |
// 左子树翻转 | |
invertTree(root.left); | |
// 根:交换左右子树 | |
TreeNode tmp = root.left; | |
root.left = root.right; | |
root.right = tmp; | |
// "左" 子树翻转!!!! | |
invertTree(root.left); | |
return root; | |
} | |
} |
代码虽然可以,但这毕竟不是真正的递归中序遍历了。
但使用迭代方式统一写法的中序是可以的。
代码如下:
class Solution { | |
// 统一格式的迭代法(中序遍历),使用栈 | |
public TreeNode invertTree(TreeNode root) { | |
Stack<TreeNode> stack = new Stack<>(); | |
if (root != null) | |
stack.push(root); | |
while (!stack.isEmpty()) { | |
TreeNode node = stack.peek(); // 获取栈顶元素 | |
if (node != null) { | |
stack.pop(); | |
if (node.right != null) stack.push(node.right); // 右 | |
stack.push(node); // 中 | |
stack.push(null); | |
if (node.left != null) stack.push(node.left); // 左 | |
} else { | |
stack.pop(); // 弹出 null | |
node = stack.pop(); // 弹出中,要处理 | |
TreeNode temp = node.left; | |
node.left = node.right; | |
node.right = temp; | |
} | |
} | |
return root; | |
} | |
} |
为什么这个中序就是可以的呢,因为这是用栈来遍历,而不是靠指针来遍历,避免了递归法中翻转了两次的情况,大家可以画图理解一下,这里有点意思的。
# 总结
针对二叉树的问题,解题之前一定要想清楚究竟是前中后序遍历,还是层序遍历。
二叉树解题的大忌就是自己稀里糊涂的过了(因为这道题相对简单),但是也不知道自己是怎么遍历的。
这也是造成了二叉树的题目 “一看就会,一写就废” 的原因。
针对翻转二叉树,我给出了一种递归,三种迭代(两种模拟深度优先遍历,一种层序遍历)的写法,都是之前我们讲过的写法,融汇贯通一下而已。
大家一定也有自己的解法,但一定要成方法论,这样才能通用,才能举一反三!
# 本周小结
# 周一
关于二叉树的理论基础,红黑树就是一种二叉平衡搜索树,这两个树不是独立的,所以 C++ 中 map、multimap、set、multiset 的底层实现机制是二叉平衡搜索树,再具体一点是红黑树。
二叉平衡搜索树:又被称为 AVL(Adelson-Velsky and Landis)树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过 1,并且左右两个子树都是一棵平衡二叉树。此外,二叉搜索树是一种有序树。
对于二叉树节点的定义:
public class TreeNode { | |
int val; | |
TreeNode left; | |
TreeNode right; | |
TreeNode() { | |
} | |
TreeNode(int val) { | |
this.val = val; | |
} | |
TreeNode(int val, TreeNode left, TreeNode right) { | |
this.val = val; | |
this.left = left; | |
this.right = right; | |
} | |
} |
在介绍前中后序遍历的时候,有递归和迭代(非递归),还有一种牛逼的遍历方式:morris 遍历。
morris 遍历是二叉树遍历算法的超强进阶算法,morris 遍历可以将非递归遍历中的空间复杂度降为 O (1),感兴趣大家就去查一查学习学习,比较小众,面试几乎不会考。我其实也没有研究过,就不做过多介绍了。
# 周二
在二叉树的递归遍历中讲到了递归三要素,以及前、中、后序的递归写法。
文章中我给出了 leetcode 上三道二叉树的前中后序题目,但是看完二叉树的递归遍历,依然可以解决 n 叉树的前后序遍历,在 leetcode 上分别是
589. N 叉树的前序遍历
class Solution {
// 递归实现 N 叉树的前序遍历:根 孩子 1 孩子 2 孩子 3 ...
public List<Integer> preorder(Node root) {
List<Integer> resList = new ArrayList<>();
// 递归终止条件
if (root == null)
return resList;
// 递归过程
resList.add(root.val); // 根
for (Node child : root.children) {
resList.addAll(preorder(child)); // 孩子
}
return resList;
}
}
时间效率太低辣!
class Solution {
// 递归实现 N 叉树的前序遍历:根 孩子 1 孩子 2 孩子 3 ...
public List<Integer> preorder(Node root) {
List<Integer> resList = new ArrayList<>();
helper(root, resList);
return resList;
}
private void helper(Node root, List<Integer> resList) {
// 递归终止条件
if (root == null)
return;
// 递归过程
resList.add(root.val); // 根
for (Node child : root.children) { // 孩子 1 孩子 2 孩子 3 ...
helper(child, resList);
}
}
}
590. N 叉树的后序遍历
class Solution {
// 递归实现 N 叉树的后序遍历:孩子 1 孩子 2 ... 孩子 n 根
public List<Integer> postorder(Node root) {
List<Integer> resList = new ArrayList<>();
helper(root, resList);
return resList;
}
private void helper(Node root, List<Integer> resList) {
// 递归终止条件
if (root == null)
return;
// 递归过程
for (Node child : root.children)
helper(child, resList); // 递归遍历孩子
resList.add(root.val); // 遍历完孩子后,再遍历根
}
}
大家可以再去把这两道题目做了。
# 周三
在二叉树的迭代遍历中我们开始用 == 栈 == 来实现递归的写法,也就是所谓的迭代法。
细心的同学发现文中前后序遍历空节点是否入栈写法是不同的
其实空节点入不入栈都差不多,但感觉空节点不入栈确实清晰一些,符合文中动画的演示。
拿前序遍历来举例,空节点入栈:
class Solution { | |
// 迭代实现前序遍历(空节点入栈) | |
public List<Integer> preorderTraversal(TreeNode root) { | |
List<Integer> res = new ArrayList<>(); | |
Stack<TreeNode> stack = new Stack<>(); | |
stack.push(root); // 即使是空节点也入栈 | |
while (!stack.isEmpty()) { | |
TreeNode node = stack.pop(); | |
if (node != null) | |
res.add(node.val); // 只处理非空节点 | |
else | |
continue; // 空间点直接跳过 | |
// 先右后左,因为栈是先进后出,所以先入栈的后出栈 | |
stack.push(node.right); // 右节点入栈,即使是空节点也入栈 | |
stack.push(node.left); // 左节点入栈,即使是空节点也入栈 | |
} | |
return res; | |
} | |
} |
拿前序遍历来举例,空节点不入栈:
class Solution { | |
// 迭代实现前序遍历(空节点不入栈) | |
public List<Integer> preorderTraversal(TreeNode root) { | |
List<Integer> res = new ArrayList<>(); | |
if (root == null) // 空节点直接返回,不用入栈 | |
return res; | |
Stack<TreeNode> stack = new Stack<>(); | |
stack.push(root); | |
while (!stack.isEmpty()) { | |
TreeNode node = stack.pop(); | |
res.add(node.val); | |
if (node.right != null) // 先压入右子树 | |
stack.push(node.right); | |
if (node.left != null) // 再压入左子树 | |
stack.push(node.left); | |
} | |
return res; | |
} | |
} |
在实现迭代法的过程中,有同学问了:递归与迭代究竟谁优谁劣呢?
从时间复杂度上其实迭代法和递归法差不多(在不考虑函数调用开销和函数调用产生的堆栈开销),但是空间复杂度上,递归开销会大一些,因为递归需要系统堆栈存参数返回值等等。
递归更容易让程序员理解,但收敛不好,容易栈溢出。
这么说吧,递归是方便了程序员,难为了机器(各种保存参数,各种进栈出栈)。
在实际项目开发的过程中我们是要尽量避免递归!因为项目代码参数、调用关系都比较复杂,不容易控制递归深度,甚至会栈溢出。
# 周四
在☆二叉树的统一迭代法中我们使用空节点作为标记,给出了统一的前中后序迭代法。
此时又多了一种前中后序的迭代写法,那么有同学问了:前中后序迭代法是不是一定要统一来写,这样才算是规范。
其实没必要,还是自己感觉哪一种更好记就用哪种。
但是一定要掌握前中后序一种迭代的写法,并不因为某种场景的题目一定要用迭代,而是现场面试的时候,面试官看你顺畅的写出了递归,一般会进一步考察能不能写出相应的迭代。
# 周五
在 [102. 二叉树的层序遍历](#102. 二叉树的层序遍历) 中我们介绍了二叉树的另一种遍历方式(图论中广度优先搜索在二叉树上的应用)即:层序遍历。
看完这篇文章,去 leetcode 上怒刷五题,文章中编号 107 题目的样例图放错了(原谅我匆忙之间总是手抖),但不影响大家理解。
只有同学发现 leetcode 上 “515. 在每个树行中找最大值”,也是层序遍历的应用,依然可以分分钟解决,所以就是一鼓作气解决六道了,哈哈。
层序遍历遍历相对容易一些,只要掌握基本写法(也就是框架模板),剩下的就是在二叉树每一行遍历的时候做做逻辑修改。
# 周六
在 [226. 翻转二叉树](#226. 翻转二叉树) 中我们把翻转二叉树这么一道简单又经典的问题,充分的剖析了一波,相信就算做过这道题目的同学,看完本篇之后依然有所收获!
文中我指的是递归的中序遍历是不行的,因为使用递归的中序遍历,某些节点的左右孩子会翻转两次。
如果非要使用递归中序的方式写,也可以,如下代码就可以避免节点左右孩子翻转两次的情况:
class Solution { | |
// 递归(中序遍历) | |
public TreeNode invertTree(TreeNode root) { | |
// 递归终止条件 | |
if (root == null) | |
return null; | |
// 左子树翻转 | |
invertTree(root.left); | |
// 根:交换左右子树 | |
TreeNode tmp = root.left; | |
root.left = root.right; | |
root.right = tmp; | |
// "左" 子树翻转!!!! | |
invertTree(root.left); // 注意 这里依然要遍历左孩子,因为中间节点已经翻转了 | |
return root; | |
} | |
} |
代码虽然可以,但这毕竟不是真正的递归中序遍历了。
但使用 **迭代方式统一写法的中序** 是可以的。
代码如下:
class Solution { | |
// 统一格式的迭代法(中序遍历),使用栈 | |
public TreeNode invertTree(TreeNode root) { | |
Stack<TreeNode> stack = new Stack<>(); | |
if (root == null) | |
return null; | |
else | |
stack.push(root); | |
while (!stack.isEmpty()) { | |
TreeNode node = stack.peek(); | |
if (node == null) { | |
stack.pop(); // 为 null 的节点出栈 | |
node = stack.pop(); // 处理根节点:交换左右子树 | |
TreeNode temp = node.left; | |
node.left = node.right; | |
node.right = temp; | |
} else { | |
stack.pop(); // 根节点出栈 | |
// 按照右中左的顺序入栈 | |
if (node.right != null) | |
stack.push(node.right); | |
stack.push(node); // 根节点再次入栈 | |
stack.push(null); // 根节点的左子树为 null | |
if (node.left != null) | |
stack.push(node.left); | |
} | |
} | |
return root; | |
} | |
} |
为什么这个中序就是可以的呢,因为这是用栈来遍历,而不是靠指针来遍历,避免了递归法中翻转了两次的情况,大家可以画图理解一下,这里有点意思的。
# 小结
本周我们都是讲解了二叉树,从理论基础到遍历方式,从递归到迭代,从深度遍历到广度遍历,最后再用了一个翻转二叉树的题目把我们之前讲过的遍历方式都串了起来。
# 101. 对称二叉树
首先想清楚,判断对称二叉树要比较的是哪两个节点,要比较的可不是左右节点!
对于二叉树是否对称,要比较的是根节点的左子树与右子树是不是相互翻转的,理解这一点就知道了其实我们要比较的是两个树(这两个树是根节点的左右子树),所以在递归遍历的过程中,也是要同时遍历两棵树。
那么如果比较呢?
比较的是两个子树的里侧和外侧的元素是否相等。如图所示:
那么遍历的顺序应该是什么样的呢?
本题遍历 ** 只能是 “后序遍历”**,因为我们要通过递归函数的返回值来判断两个子树的内侧节点和外侧节点是否相等。
正是因为要遍历两棵树而且要比较内侧和外侧节点,所以准确的来说是一个树的遍历顺序是左右中,一个树的遍历顺序是右左中。
但都可以理解算是后序遍历,尽管已经不是严格上在一个树上进行遍历的后序遍历了。
其实后序也可以理解为是一种回溯,当然这是题外话,讲回溯的时候会重点讲的。
说到这大家可能感觉我有点啰嗦,哪有这么多道理,上来就干就完事了。别急,我说的这些在下面的代码讲解中都有身影。
那么我们先来看看递归法的代码应该怎么写。
# √递归法
递归三部曲:
参数、返回值
因为我们要比较的是根节点的两个子树是否是相互翻转的,进而判断这个树是不是对称树,所以要比较的是两个树,参数自然也是左子树节点和右子树节点。
返回值自然是 bool 类型。
private boolean compare(TreeNode left, TreeNode right)
递归终止条件
要比较两个节点数值相不相同,首先要把两个节点为空的情况弄清楚!否则后面比较数值的时候就会操作空指针了。
节点为空的情况有:(注意我们比较的其实不是左孩子和右孩子,所以如下我称之为左节点右节点)
- 左节点为空,右节点不为空,不对称,return false
- 左不为空,右为空,不对称 return false
- 左右都为空,对称,返回 true
此时已经排除掉了节点为空的情况,那么剩下的就是左右节点不为空:
- 左右都不为空,比较节点数值,不相同就 return false
此时左右节点不为空,且数值也不相同的情况我们也处理了。
// 递归终止条件
// 先排除存在空节点的情况
if (left == null && right != null) return false;
else if (left != null && right == null) return false;
else if (left == null && right == null) return true;
// 此时两个节点都不为空,但是值不相等
else if (left.val != right.val) return false;
注意上面最后一种情况,我没有使用 else,而是 else if, 因为我们把以上情况都排除之后,剩下的就是:左右节点都不为空,且数值相同的情况,也就是我们的递归过程逻辑。
单层递归的逻辑
此时才进入单层递归的逻辑,单层递归的逻辑就是处理 左右节点都不为空,且数值相同的情况。
- 比较二叉树外侧是否对称:传入的是左节点的左孩子,右节点的右孩子。
- 比较内测是否对称,传入左节点的右孩子,右节点的左孩子。
- 如果左右都对称就返回 true ,有一侧不对称就返回 false 。
// 递归(对应的情况是:左右子树都不为空,且值相等)
boolean outside = compare(left.left, right.right); // 比较外侧:左子树。左 与 右子树。右
boolean inside = compare(left.right, right.left); // 比较内侧:左子树。右 与 右子树。左
return outside && inside; // 两侧都相等才是对称的:左子树。中 与 右子树。中
如上代码中,我们可以看出使用的遍历方式,左子树:左 -> 右 -> 中,右子树:右 -> 左 -> 中,所以我把这个遍历顺序也称之为 “后序遍历”(尽管不是严格的后序遍历)。
递归的 java 整体代码如下:
class Solution { | |
// 递归法,后续遍历 | |
public boolean isSymmetric(TreeNode root) { | |
if (root == null) return true; | |
return compare(root.left, root.right); | |
} | |
private boolean compare(TreeNode left, TreeNode right) { | |
// 递归终止条件 | |
// 先排除存在空节点的情况 | |
if (left == null && right != null) return false; | |
else if (left != null && right == null) return false; | |
else if (left == null && right == null) return true; | |
// 此时两个节点都不为空,但是值不相等 | |
else if (left.val != right.val) return false; | |
// 递归(对应的情况是:左右子树都不为空,且值相等) | |
boolean outside = compare(left.left, right.right); // 比较外侧:左子树。左 与 右子树。右 | |
boolean inside = compare(left.right, right.left); // 比较内侧:左子树。右 与 右子树。左 | |
return outside && inside; // 两侧都相等才是对称的:左子树。中 与 右子树。中 | |
} | |
} |
、
# 迭代法
这道题目我们也可以使用迭代法,但要注意,这里的迭代法可不是前中后序的迭代写法,因为本题的本质是判断两个树是否是相互翻转的,其实已经不是所谓二叉树遍历的前中后序的关系了。
# 使用队列
这里我们可以使用队列来比较两个树(根节点的左右子树)是否相互翻转,(注意这不是层序遍历)
通过队列来判断根节点的左子树和右子树的内侧和外侧是否相等,如动画所示:
如下的条件判断和递归的逻辑是一样的。
class Solution { | |
// 迭代法,使用队列 | |
public boolean isSymmetric(TreeNode root) { | |
if (root == null) return true; | |
Queue<TreeNode> queue = new LinkedList<>(); | |
queue.offer(root.left); // 将左子树的根节点入队 | |
queue.offer(root.right); // 将右子树的根节点入队 | |
while (!queue.isEmpty()) { | |
TreeNode left = queue.poll(); // 将左子树的根节点出队 | |
TreeNode right = queue.poll(); // 将右子树的根节点出队 | |
// 左右子树的根节点都为空,继续循环 | |
if (left == null && right == null) continue; | |
// 左右子树的根节点有一个为空,或者两个根节点的值不相等,返回 false | |
if (left == null || right == null || left.val != right.val) return false; | |
// 此时左、右子树的根节点均不为空,且值相等 | |
queue.offer(left.left); // 将左子树的左子节点入队 | |
queue.offer(right.right); // 将右子树的右子节点入队 | |
queue.offer(left.right); // 将左子树的右子节点入队 | |
queue.offer(right.left); // 将右子树的左子节点入队 | |
} | |
return true; | |
} | |
} |
时间效率比递归法差:
# 使用栈
细心的话,其实可以发现,这个迭代法,其实是把左右两个子树要比较的元素顺序放进一个容器,然后成对成对的取出来进行比较,那么其实使用栈也是可以的。
只要把队列原封不动的改成栈就可以了,我下面也给出了代码。
class Solution { | |
// 迭代法,使用栈 | |
public boolean isSymmetric(TreeNode root) { | |
if (root == null) return true; | |
Stack<TreeNode> stack = new Stack<>(); | |
stack.push(root.left); // 将左子树的根节点入队 | |
stack.push(root.right); // 将右子树的根节点入队 | |
while (!stack.isEmpty()) { | |
TreeNode left = stack.pop(); // 将左子树的根节点出队 | |
TreeNode right = stack.pop(); // 将右子树的根节点出队 | |
// 左右子树的根节点都为空,继续循环 | |
if (left == null && right == null) continue; | |
// 左右子树的根节点有一个为空,或者两个根节点的值不相等,返回 false | |
if (left == null || right == null || left.val != right.val) return false; | |
// 此时左、右子树的根节点均不为空,且值相等 | |
stack.push(left.left); // 将左子树的左子节点入队 | |
stack.push(right.right); // 将右子树的右子节点入队 | |
stack.push(left.right); // 将左子树的右子节点入队 | |
stack.push(right.left); // 将右子树的左子节点入队 | |
} | |
return true; | |
} | |
} |
# 总结
这次我们又深度剖析了一道二叉树的 “简单题”,大家会发现,真正的把题目搞清楚其实并不简单,leetcode 上 accept 了和真正掌握了还是有距离的。
我们介绍了递归法和迭代法,递归依然通过递归三部曲来解决了这道题目,如果只看精简的代码根本看不出来递归三部曲是如果解题的。
在迭代法中我们使用了队列,需要注意的是这不是层序遍历,而且仅仅通过一个容器来成对的存放我们要比较的元素,知道这一本质之后就发现,用队列,用栈,甚至用数组,都是可以的。
如果已经做过这道题目的同学,读完文章可以再去看看这道题目,思考一下,会有不一样的发现!
# 相关题目推荐
这两道题目基本和本题是一样的,只要稍加修改就可以 AC。
- 100. 相同的树
class Solution { | |
// 递归 | |
public boolean isSameTree(TreeNode p, TreeNode q) { | |
return compare(p, q); | |
} | |
private boolean compare(TreeNode p, TreeNode q) { | |
// 递归终止条件 | |
// 存在空节点的情况 | |
if (p == null && q != null) return false; | |
else if (p != null && q == null) return false; | |
else if (p == null && q == null) return true; | |
// 两个节点都不为空,且值不相等 | |
else if (p.val != q.val) return false; | |
// 单层递归的逻辑 | |
boolean left = compare(p.left, q.left); // 左子树是否相同 | |
boolean right = compare(p.right, q.right); // 右子树是否相同 | |
return left && right; | |
} | |
} |
572. 另一棵树的子树
双递归!
看到题目描述,首先判断一个树是否是另一棵树的子树,很明显想到可以用递归,但是两棵树完全相同也可以看做一棵树是另一棵树的子树。 所以自然而然想到用一个判断两棵树是否相同的递归函数。
class Solution { | |
// 递归判断 subRoot 是否是 root 的子树 | |
public boolean isSubtree(TreeNode root, TreeNode subRoot) { | |
// 递归终止条件 | |
if (root == null && subRoot != null) return false; //root 为空,subRoot 不为空,那么 subRoot 必不是 root 的子树 | |
if (subRoot == null) return true; //subRoot 为 null,则一定是 root 的子树 | |
// 递归过程,满足子树条件的情况有三种: | |
// 1. root 和 subRoot 是同一棵树;2. root 的左子树是 subRoot;3. root 的右子树是 subRoot | |
return isSameTree(root, subRoot) || isSubtree(root.left, subRoot) || isSubtree(root.right, subRoot); | |
} | |
// 递归判断两棵树是否相同 | |
private boolean isSameTree(TreeNode root, TreeNode subRoot) { | |
// 递归终止条件 | |
if (root == null && subRoot == null) return true; | |
if (root == null || subRoot == null) return false; | |
if (root.val != subRoot.val) return false; | |
// 递归过程:此时 root 和 subRoot 都不为空,且值相等,则比较左右子树 | |
return isSameTree(root.left, subRoot.left) && isSameTree(root.right, subRoot.right); | |
} | |
} |
# 104. 二叉树的最大深度
看完本篇可以一起做了如下两道题目:
- 104. 二叉树的最大深度
- 559.n 叉树的最大深度
# 递归
本题可以使用前序(中左右),也可以使用后序遍历(左右中),使用前序求的就是深度,使用后序求的是高度。
二叉树节点的深度:指从根节点到该节点的最长简单路径边的条数或者节点数(取决于深度从 0 开始还是从 1 开始)
从上往下访问(前序遍历),每到下一层,深度 + 1
二叉树节点的高度:指从该节点到叶子节点的最长简单路径边的条数或者节点数(取决于高度从 0 开始还是从 1 开始)
从下往上访问(后序遍历),每到上一层,统计左、右子树的最大高度,在此基础上 + 1
而根节点的高度就是二叉树的最大深度,所以本题中我们通过后序求的根节点高度来求的二叉树最大深度。
这一点其实是很多同学没有想清楚的,很多题解同样没有讲清楚。
# 后序遍历:计算根节点的高度
参数、返回值:参数就是传入树的根节点,返回就返回这棵树的深度,所以返回值为 int 类型。
public int maxDepth(TreeNode root)
递归终止条件:如果为空节点的话,就返回 0,表示高度为 0。
if (root == null)
return 0;
单层递归的逻辑:先求它的左子树的深度,再求的右子树的深度,最后取左右深度最大的数值 再 + 1 (加 1 是因为算上当前中间节点)就是目前节点为根节点的树的深度。
int leftDepth = maxDepth(root.left);
int rightDepth = maxDepth(root.right);
return Math.max(leftDepth, rightDepth) + 1;
整体 java 代码如下:
class Solution { | |
// 递归实现 | |
public int maxDepth(TreeNode root) { | |
// 递归终止条件 | |
if (root == null) | |
return 0; | |
// 递归过程 | |
int leftDepth = maxDepth(root.left); // 左 | |
int rightDepth = maxDepth(root.right); // 右 | |
return Math.max(leftDepth, rightDepth) + 1; // 根:+1 是因为算上当前根节点 | |
} | |
} |
精简后的代码如下:
class Solution { | |
// 递归实现 | |
public int maxDepth(TreeNode root) { | |
// 递归终止条件 | |
if (root == null) | |
return 0; | |
// 递归过程 | |
return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1; | |
} | |
} |
精简之后的代码根本看不出是哪种遍历方式,也看不出递归三部曲的步骤,所以如果对二叉树的操作还不熟练,尽量不要直接照着精简代码来学。
# 前序遍历:计算最大深度
本题当然也可以使用 **前序**,代码如下:(充分表现出求深度回溯的过程)
class Solution { | |
private int res; | |
private void getDepth(TreeNode node, int depth) { | |
res = Math.max(res, depth); // 根 | |
// 递归终止条件:叶子节点 | |
if (node.left == null && node.right == null) | |
return; | |
if (node.left != null) { // 左 | |
depth++; | |
getDepth(node.left, depth); | |
depth--; // 回溯 | |
} | |
if (node.right != null) { // 右 | |
depth++; | |
getDepth(node.right, depth); | |
depth--; // 回溯 | |
} | |
return; | |
} | |
// 递归实现(前序遍历:求根节点到当前结点 root 的 “深度”) | |
public int maxDepth(TreeNode root) { | |
res = 0; | |
if (root == null) return res; | |
getDepth(root, 1); // 从根节点开始,深度为 1 | |
return res; | |
} | |
} |
可以看出使用了前序(中左右)的遍历顺序,这才是真正求深度的逻辑!
简化后的代码如下:
class Solution { | |
private int res; | |
private void getDepth(TreeNode node, int depth) { | |
res = Math.max(res, depth); // 根 | |
// 递归终止条件:叶子节点 | |
if (node.left == null && node.right == null) | |
return; | |
if (node.left != null) { // 左 | |
getDepth(node.left, depth + 1); | |
} | |
if (node.right != null) { // 右 | |
getDepth(node.right, depth + 1); | |
} | |
return; | |
} | |
// 递归实现(前序遍历:求根节点到当前结点 root 的 “深度”) | |
public int maxDepth(TreeNode root) { | |
res = 0; | |
if (root == null) return res; | |
getDepth(root, 1); // 从根节点开始,深度为 1 | |
return res; | |
} | |
} |
# 迭代(层序遍历)
使用迭代法的话,使用层序遍历是最为合适的,因为最大的深度就是二叉树的层数,和层序遍历的方式极其吻合。
在二叉树中,一层一层的来遍历二叉树,记录一下遍历的层数就是二叉树的深度,如图所示:
所以这道题的迭代法就是一道模板题,可以使用二叉树层序遍历的模板来解决的。
如果对层序遍历还不清楚的话,可以看这篇:[102. 二叉树的层序遍历](#102. 二叉树的层序遍历)
class Solution { | |
// 迭代实现(借助队列,层序遍历) | |
public int maxDepth(TreeNode root) { | |
int res = 0; | |
Queue<TreeNode> queue = new LinkedList<>(); | |
if (root == null) | |
return res; | |
else | |
queue.offer(root); // 根节点入队 | |
while (!queue.isEmpty()) { | |
int size = queue.size(); // 当前层的节点数 | |
while (size > 0) { | |
TreeNode tmpNode = queue.poll(); // 出队 | |
if (tmpNode.left != null) | |
queue.offer(tmpNode.left); // 左子节点入队 | |
if (tmpNode.right != null) | |
queue.offer(tmpNode.right); // 右子节点入队 | |
size--; | |
} // 当前层的节点全部出队 | |
res++; // 每出队一层,深度加一 | |
} // 队列为空,遍历结束 | |
return res; | |
} | |
} |
# 559. N 叉树的最大深度
依然可以提供递归法和迭代法,来解决这个问题,思路是和二叉树思路一样的,只不过二叉树中遍历的是左、右孩子,而 N 叉树遍历的是所有孩子,直接给出代码如下:
# 递归(后序遍历)
class Solution { | |
// 递归实现(后序遍历) | |
public int maxDepth(Node root) { | |
// 递归终止条件 | |
if (root == null) return 0; | |
// 递归过程 | |
int max = 0; | |
for (Node child : root.children) { // 遍历子节点 | |
max = Math.max(max, maxDepth(child)); | |
} | |
return max + 1; // 根:在子节点中找到最大深度,然后加 1 | |
} | |
} |
# 迭代(层序遍历)
class Solution { | |
// 迭代实现(借助队列,层序遍历) | |
public int maxDepth(Node root) { | |
int res = 0; | |
Queue<Node> queue = new LinkedList<>(); // Queue 接口,LinkedList 实现类 | |
if (root == null) | |
return res; | |
else | |
queue.offer(root); //root 入队 | |
while (!queue.isEmpty()) { | |
int size = queue.size(); // 当前层的节点数 | |
while (size > 0) { | |
Node tmpNode = queue.poll(); | |
for (Node node : tmpNode.children) { | |
queue.offer(node); // 将当前节点的所有子节点入队 | |
} | |
size--; | |
} // 当前层遍历完毕 | |
res++; // 深度加一 | |
} // 队列为空,遍历完毕 | |
return res; | |
} | |
} |
# 111. 二叉树的最小深度
和求最大深度一个套路?
直觉上好像和求最大深度差不多,其实还是差不少的。
本题依然是前序遍历和后序遍历都可以,前序求的是深度,后序求的是高度。
- 二叉树节点的深度:指从根节点到该节点的最长简单路径边的条数或者节点数(取决于深度从 0 开始还是从 1 开始)
- 二叉树节点的高度:指从该节点到叶子节点的最长简单路径边的条数后者节点数(取决于高度从 0 开始还是从 1 开始)
那么使用后序遍历,其实求的是根节点到叶子节点的最小距离,就是求高度的过程,这不过这个最小距离也同样是最小深度。
以下讲解中遍历顺序上依然采用后序遍历(因为要比较递归返回之后的结果,本文我也给出前序遍历的写法)。
本题还有一个误区,在处理节点的过程中,最大深度很容易理解,最小深度就不那么好理解,如图:
这就重新审题了,题目中说的是:最小深度是从根节点到最近叶子节点的最短路径上的节点数量。注意是叶子节点。
什么是叶子节点,左右孩子都为空的节点才是叶子节点!
# 递归(后序遍历)
参数、返回值:参数为要传入的二叉树根节点,返回的是 int 类型的深度。
public int minDepth(TreeNode root)
终止条件:遇到空节点返回 0,表示当前节点的高度为 0。
if (root == null) return 0;
单层递归逻辑:这块和求最大深度可就不一样了,一些同学可能会写如下代码:
int leftDepth = minDepth(node.left);
int rightDepth = minDepth(node.right);
int result = 1 + min(leftDepth, rightDepth);
return result;
这个代码就犯了此图中的误区:
如果这么求的话,没有左孩子的分支会算为最短深度。
所以,
- 如果左子树为空,右子树不为空,说明最小深度是 1 + 右子树的深度
- 如果右子树为空,左子树不为空,最小深度是 1 + 左子树的深度
- 最后,如果左右子树都不为空,返回左右子树深度最小值 + 1
int leftDepth = minDepth(root.left); // 左子树的最小深度
int rightDepth = minDepth(root.right); // 右子树的最小深度
// 中
// 若左子树为空,右子树非空,此时并不是最小深度
if (root.left == null && root.right != null) return 1 + rightDepth;
// 若右子树为空,左子树非空,此时并不是最小深度
if (root.left != null && root.right == null) return 1 + leftDepth;
// 若左右子树都不为空,返回左右子树最小深度的最小值
return 1 + Math.min(leftDepth, rightDepth);
遍历的顺序为后序(左右中),可以看出:求二叉树的最小深度和求二叉树的最大深度的差别主要在于处理左右孩子不为空的逻辑。
完整代码如下:
class Solution { | |
// 递归(后序遍历) | |
public int minDepth(TreeNode root) { | |
// 递归终止条件 | |
if (root == null) return 0; | |
// 递归过程 | |
int leftDepth = minDepth(root.left); // 左子树的最小深度 | |
int rightDepth = minDepth(root.right); // 右子树的最小深度 | |
// 中 | |
// 若左子树为空,右子树非空,此时并不是最小深度 | |
if (root.left == null && root.right != null) return 1 + rightDepth; | |
// 若右子树为空,左子树非空,此时并不是最小深度 | |
if (root.left != null && root.right == null) return 1 + leftDepth; | |
// 若左右子树都不为空,返回左右子树最小深度的最小值 | |
return 1 + Math.min(leftDepth, rightDepth); | |
} | |
} |
# 迭代(层序遍历)
借助队列,通过层序遍历。
需要注意的是,只有当左右孩子都为空的时候,才说明遍历的最低点了。如果其中一个孩子为空则不是最低点。
class Solution { | |
// 借助队列实现 | |
public int minDepth(TreeNode root) { | |
int minDepth = 0; | |
if (root == null) | |
return minDepth; | |
Queue<TreeNode> queue = new LinkedList<>(); | |
queue.offer(root); | |
while (!queue.isEmpty()) { | |
minDepth++; | |
int size = queue.size(); | |
while (size > 0) { | |
TreeNode tmpNode = queue.poll(); | |
// 如果当前节点是叶子节点,直接返回 | |
if (tmpNode.left == null && tmpNode.right == null) | |
return minDepth; | |
if (tmpNode.left != null) | |
queue.offer(tmpNode.left); | |
if (tmpNode.right != null) | |
queue.offer(tmpNode.right); | |
size--; | |
} | |
} | |
return minDepth; // 实际上不会执行到这里,因为上面的 return 已经返回了 | |
} | |
} |
# 222. 完全二叉树的节点个数
# 普通二叉树
首先按照普通二叉树的逻辑来求。
这道题目的递归法和求二叉树的深度写法类似, 而迭代法,二叉树:层序遍历登场!遍历模板稍稍修改一下,记录遍历的节点数量就可以了。
# 递归(后序遍历)
参数、返回值:参数就是传入树的根节点,返回就返回以该节点为根的二叉树的节点数量,所以返回值为 int 类型。
public int countNodes(TreeNode root)
递归终止条件:如果为空节点的话,就返回 0,表示节点数为 0。
if (root == null) return 0;
单层递归逻辑:先求它的左子树的节点数量,再求的右子树的节点数量,最后取总和再加一 (加 1 是因为算上当前中间节点)就是目前节点为根节点的节点数量。
int leftCount = countNodes(root.left); // 左子树节点个数
int rightCount = countNodes(root.right); // 右子树节点个数
int treeCount = leftCount + rightCount + 1; // 当前树的节点个数,+1 是根节点
return treeCount;
完整的 java 代码如下:
class Solution { | |
// 递归(后序遍历) | |
public int countNodes(TreeNode root) { | |
// 递归终止条件 | |
if (root == null) return 0; | |
// 递归过程 | |
int leftCount = countNodes(root.left); // 左子树节点个数 | |
int rightCount = countNodes(root.right); // 右子树节点个数 | |
int treeCount = leftCount + rightCount + 1; // 当前树的节点个数,+1 是根节点 | |
return treeCount; | |
} | |
} |
- 时间复杂度:O (n)
- 空间复杂度:O (log n),算上了递归系统栈占用的空间
# 迭代(层序遍历)
class Solution { | |
// 迭代(层序遍历),借助队列 | |
public int countNodes(TreeNode root) { | |
int count = 0; | |
Queue<TreeNode> queue = new LinkedList<>(); | |
if (root == null) | |
return count; | |
else | |
queue.offer(root); | |
while (!queue.isEmpty()) { | |
int size = queue.size(); | |
while (size > 0) { | |
TreeNode tmpNode = queue.poll(); | |
count++; // 每次出队一个节点,计数加一 | |
if (tmpNode.left != null) | |
queue.offer(tmpNode.left); | |
if (tmpNode.right != null) | |
queue.offer(tmpNode.right); | |
size--; | |
} | |
} | |
return count; | |
} | |
} |
- 时间复杂度:O (n)
- 空间复杂度:O (n)
# 完全二叉树(递归(后序遍历))
在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层,则该层包含 1~ 2^(h-1) 个节点。
大家要自己看完全二叉树的定义,很多同学对完全二叉树其实不是真正的懂了。
我来举一个典型的例子如题:
完全二叉树只有两种情况:
满二叉树
用
2^树深度^ - 1
来计算所有节点个数,注意这里根节点深度为 1最后一层叶子节点没有满
分别递归左孩子,和右孩子,递归到某一深度一定会有左孩子或者右孩子为满二叉树,然后依然可以按照情况 1 来计算。
完全二叉树(一)如图:
完全二叉树(二)如图:
可以看出如果整个树不是满二叉树,就递归其左右孩子,直到遇到满二叉树为止,用公式计算这个子树(满二叉树)的节点数量。
这里关键在于如果判断一个左子树或者右子树是不是满二叉树呢?
在完全二叉树中,如果递归向左遍历的深度等于递归向右遍历的深度,那说明就是满二叉树。如图:
在完全二叉树中,如果递归向左遍历的深度不等于递归向右遍历的深度,则说明不是满二叉树,如图:
哪有录友说了,这种情况,递归向左遍历的深度等于递归向右遍历的深度,但也不是满二叉树,如题:
如果这么想,大家就是对 完全二叉树理解有误区了,以上这棵二叉树,它根本就不是一个完全二叉树!
判断其子树是不是满二叉树,如果是则利用用公式计算这个子树(满二叉树)的节点数量,如果不是则继续递归。那么递归三部曲为:
参数、返回值:传入节点,返回以该节点为根的树的节点数量,类型为 int
public int countNodes(TreeNode root)
递归终止条件:
如果节点为空,直接返回 0
if (root == null) return 0;
如果节点的左、右子树的递归深度相同(可判断以节点为根的树为满二叉树),根据满二叉树的节点个数计算公式返回节点数
TreeNode left = root.left;
TreeNode right = root.right;
int leftDepth = 0;
int rightDepth = 0;
while (left != null) {
leftDepth++;
left = left.left;
}
while (right != null) {
rightDepth++;
right = right.right;
}
// 如果左右深度相等,说明 root 为满二叉树,直接计算公式返回节点数
if (leftDepth == rightDepth) {
// 计算以 root 为根节点的满二叉树的节点数:2^(leftDepth+1)-1
return (int) Math.pow(2, leftDepth + 1) - 1;
}
// 如果左右深度不等,说明 root 不为满二叉树,继续递归,直到找到满二叉树
单层递归逻辑:后序遍历
/* --- 递归过程 --- */
int leftCount = countNodes(root.left);
int rightCount = countNodes(root.right);
int res = leftCount + rightCount + 1;
return res;
完整的 java 代码如下:
class Solution { | |
// 递归(利用完全二叉树的特性,后序遍历) | |
public int countNodes(TreeNode root) { | |
/* --- 递归终止条件 --- */ | |
if (root == null) return 0; | |
// 开始根据 左深度 == 右深度 判断 root 是否为满二叉树 | |
TreeNode left = root.left; | |
TreeNode right = root.right; | |
int leftDepth = 0; | |
int rightDepth = 0; | |
while (left != null) { | |
leftDepth++; | |
left = left.left; | |
} | |
while (right != null) { | |
rightDepth++; | |
right = right.right; | |
} | |
// 如果左右深度相等,说明 root 为满二叉树,直接计算公式返回节点数 | |
if (leftDepth == rightDepth) { | |
// 计算以 root 为根节点的满二叉树的节点数:2^(leftDepth+1)-1 | |
return (int) Math.pow(2, leftDepth + 1) - 1; | |
} | |
// 如果左右深度不等,说明 root 不为满二叉树,继续递归,直到找到满二叉树 | |
/* --- 递归过程 --- */ | |
int leftCount = countNodes(root.left); | |
int rightCount = countNodes(root.right); | |
int res = leftCount + rightCount + 1; | |
return res; | |
} | |
} |
时间复杂度:O (log n × log n)
因为每次递归都要遍历一次树的高度,时间复杂度为 O (logn),而递归次数为 logn
空间复杂度:O (log n)
递归栈的深度为树的高度,即 logn
# ☆110. 平衡二叉树
定义:一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过 1 。
使用递归方法,既然要比较高度,必然是要后序遍历。
参数、返回值:
- 参数:当前传入节点。
- 返回值:以当前传入节点为根节点的树的高度。
那么如何标记左右子树是否差值大于 1 呢?
如果当前传入节点为根节点的二叉树已经不是二叉平衡树了,还返回高度的话就没有意义了。
所以如果已经不是二叉平衡树了,可以返回 - 1 来标记已经不符合平衡树的规则了。
private int getHeight(TreeNode node)
递归终止条件:递归的过程中依然是遇到空节点了为终止,返回 0,表示当前节点为根节点的树高度为 0
if (node == null) return 0;
单层递归逻辑:如何判断以当前传入节点为根节点的二叉树是否是平衡二叉树呢?当然是其左子树高度和其右子树高度的差值。
分别求出其左右子树的高度,然后如果差值小于等于 1,则返回当前二叉树的高度,否则则返回 - 1,表示已经不是二叉平衡树了。
int leftHeight = getHeight(node.left); // 左子树高度
if (leftHeight == -1) return -1; // 剪枝
int rightHeight = getHeight(node.right); // 右子树高度
if (rightHeight == -1) return -1; // 剪枝
int res;
if (Math.abs(leftHeight - rightHeight) <= 1) {
res = 1 + Math.max(leftHeight, rightHeight); // 中间节点的高度
} else {
res = -1;
}
return res;
此时递归的函数就已经写出来了,这个递归的函数传入节点指针,返回以该节点为根节点的二叉树的高度,如果不是二叉平衡树,则返回 - 1。
完整 java 代码如下:
// 递归,后序遍历 | |
private int getHeight(TreeNode node) { | |
// 递归终止条件 | |
if (node == null) return 0; | |
// 递归过程 | |
int leftHeight = getHeight(node.left); // 左子树高度 | |
if (leftHeight == -1) return -1; // 剪枝 | |
int rightHeight = getHeight(node.right); // 右子树高度 | |
if (rightHeight == -1) return -1; // 剪枝 | |
int res; | |
if (Math.abs(leftHeight - rightHeight) <= 1) { | |
res = 1 + Math.max(leftHeight, rightHeight); // 中间节点的高度 | |
} else { | |
res = -1; | |
} | |
return res; | |
} |
最后本题整体递归代码如下:
class Solution { | |
// 递归,后序遍历 | |
private int getHeight(TreeNode node) { | |
// 递归终止条件 | |
if (node == null) return 0; | |
// 递归过程 | |
int leftHeight = getHeight(node.left); // 左子树高度 | |
if (leftHeight == -1) return -1; // 剪枝 | |
int rightHeight = getHeight(node.right); // 右子树高度 | |
if (rightHeight == -1) return -1; // 剪枝 | |
int res; | |
if (Math.abs(leftHeight - rightHeight) <= 1) { | |
res = 1 + Math.max(leftHeight, rightHeight); // 中间节点的高度 | |
} else { | |
res = -1; | |
} | |
return res; | |
} | |
public boolean isBalanced(TreeNode root) { | |
return getHeight(root) != -1; | |
} | |
} |
# 总结
通过本题可以了解求二叉树深度 和 二叉树高度的差异,求深度适合用前序遍历,而求高度适合用后序遍历。
本题迭代法其实有点复杂,大家可以有一个思路,也不一定说非要写出来。
但是递归方式是一定要掌握的!
# ☆257. 二叉树的所有路径
以为只用了递归,其实还用了回溯
这道题目要求从根节点到叶子的路径,所以需要前序遍历,这样才方便让父节点指向孩子节点,找到对应的路径。
在这道题目中将第一次涉及到回溯,因为我们要把路径记录下来,需要回溯来回退一一个路径在进入另一个路径。
前序遍历以及回溯的过程如图:
我们先使用递归的方式,来做前序遍历。要知道递归和回溯就是一家的,本题也需要回溯。
# 递归
参数、返回值:要传入根节点 root,记录每一条路径的 path,和存放结果集的 res,这里递归不需要返回值
private void traversal(TreeNode cur, List<Integer> path, List<String> res)
递归终止条件:之前都习惯了这么写:
if (cur == null) {
终止处理逻辑
}
但是本题的终止条件这样写会很麻烦,因为本题要找到叶子节点,就开始结束的处理逻辑了(把路径放进 result 里)。
那么什么时候算是找到了叶子节点? 是当 cur 不为空,其左右孩子都为空的时候,就找到叶子节点。
所以本题的终止条件是:
if (cur.left == null && cur.right == null) {
终止处理逻辑
}
为什么没有判断 cur 是否为空呢,因为下面的逻辑可以控制空节点不入循环。
再来看一下终止处理的逻辑。
这里使用 List 结构 path 来记录路径,所以要把 List 结构的 path 转为 string 格式,再把这个 string 放进 res 里。
那么为什么使用了 List 结构来记录路径呢? 因为在下面处理单层递归逻辑的时候,方便来做回溯。
可能有的同学问了,我看有些人的代码也没有回溯啊。
其实是有回溯的,只不过隐藏在函数调用时的参数赋值里,下文我还会提到。
这里我们先使用 List 结构的 path 容器来记录路径,那么终止处理逻辑如下:
// 递归终止条件:当前节点为叶子节点
if (cur.left == null && cur.right == null) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < path.size() - 1; i++)
sb.append(path.get(i)).append("->"); // 将 path 里记录的路径转为 string 格式
sb.append(path.get(path.size() - 1));
res.add(sb.toString()); // 收集一个路径
return; // 递归终止
}
单层递归逻辑:因为是前序遍历,需要先处理中间节点,中间节点就是我们要记录路径上的节点,先放进 path 中。
path.push_back(cur->val);
然后是递归和回溯的过程,上面说过没有判断 cur 是否为空,那么在这里递归的时候,如果为空就不进行下一层递归了。
所以递归前要加上判断语句,下面要递归的节点是否为空,如下:
// 左
if (cur.left != null) { // 空节点不遍历,否则在 “递归终止条件” 处会报空指针异常
traversal(cur.left, path, res);
path.remove(path.size() - 1); // 回溯
}
// 右
if (cur.right != null) {
traversal(cur.right, path, res);
path.remove(path.size() - 1); // 回溯
}
注意:我们知道,回溯和递归是一一对应的,有一个递归,就要有一个回溯,不能把递归和回溯拆开了, 一个在花括号里,一个在花括号外。回溯要和递归永远在一起,世界上最遥远的距离是你在花括号里,而我在花括号外!
本题的完整代码如下:
class Solution { | |
// 递归(前序遍历)+ 回溯 | |
public List<String> binaryTreePaths(TreeNode root) { | |
List<String> res = new ArrayList<>(); | |
List<Integer> path = new ArrayList<>(); // 记录单条路径 | |
traversal(root, path, res); | |
return res; | |
} | |
// 递归函数 | |
private void traversal(TreeNode cur, List<Integer> path, List<String> res) { | |
// 中,之所以写在这,因为叶子结点也要加入到 path 中 | |
path.add(cur.val); | |
// 递归终止条件:当前节点为叶子节点 | |
if (cur.left == null && cur.right == null) { | |
StringBuilder sb = new StringBuilder(); | |
for (int i = 0; i < path.size() - 1; i++) | |
sb.append(path.get(i)).append("->"); // 将 path 里记录的路径转为 string 格式 | |
sb.append(path.get(path.size() - 1)); | |
res.add(sb.toString()); // 收集一个路径 | |
return; // 递归终止 | |
} | |
// 左 | |
if (cur.left != null) { // 空节点不遍历,否则在 “递归终止条件” 处会报空指针异常 | |
traversal(cur.left, path, res); | |
path.remove(path.size() - 1); // 回溯 | |
} | |
// 右 | |
if (cur.right != null) { | |
traversal(cur.right, path, res); | |
path.remove(path.size() - 1); // 回溯 | |
} | |
} | |
} |
# 本周小结
# 周一
本周刚开始我们讲解了判断二叉树是否对称的写法, [101. 对称二叉树](#101. 对称二叉树)。
这道题目的本质是要比较两个树(这两个树是根节点的左右子树),遍历两棵树而且要比较内侧和外侧节点,所以准确的来说是一个树的遍历顺序是左右中,一个树的遍历顺序是右左中。
而本题的迭代法中我们使用了队列,需要注意的是这不是层序遍历,而且仅仅通过一个容器来成对的存放我们要比较的元素,认识到这一点之后就发现:用队列,用栈,甚至用数组,都是可以的。
那么做完本题之后,在看如下两个题目。
- 100. 相同的树
- 572. 另一个树的子树
# 周二
在 [104. 二叉树的最大深度](#104. 二叉树的最大深度) 中,我们讲解了如何求二叉树的最大深度。
本题可以使用前序,也可以使用后序遍历(左右中),使用前序求的就是深度,使用后序呢求的是高度。
而根节点的高度就是二叉树的最大深度,所以本题中我们通过后序求的根节点高度来求的二叉树最大深度,所以 [104. 二叉树的最大深度](#104. 二叉树的最大深度) 中使用的是后序遍历,代码见后序遍历:计算根节点的高度。
本题当然也可以使用前序,代码如下,前序遍历:计算最大深度
# 周三
在 [111. 二叉树的最小深度](#111. 二叉树的最小深度) 中,我们讲解如何求二叉树的最小深度, 这道题目要是稍不留心很容易犯错。
注意这里最小深度是从根节点到最近叶子节点的最短路径上的节点数量。注意是叶子节点。
什么是叶子节点,左右孩子都为空的节点才是叶子节点!
求二叉树的最小深度和求二叉树的最大深度的差别主要在于处理左右孩子不为空的逻辑。
注意到这一点之后 递归法和迭代法 都可以参照 [104. 二叉树的最大深度](#104. 二叉树的最大深度) 写出来。
# 周四
我们在 [222. 完全二叉树的节点个数](#222. 完全二叉树的节点个数) 中,讲解了如何求二叉树的节点数量。
这一天是十一长假的第一天,又是双节,所以简单一些,只要把之前两篇 [104. 二叉树的最大深度](#104. 二叉树的最大深度), [111. 二叉树的最小深度](#111. 二叉树的最小深度) 都认真看了的话,这道题目可以分分钟刷掉了。
估计此时大家对这一类求二叉树节点数量以及求深度应该非常熟练了。
# 周五
在 [☆110. 平衡二叉树](#☆110. 平衡二叉树) 中讲解了如何判断二叉树是否是平衡二叉树
今天讲解一道判断平衡二叉树的题目,其实 方法上我们之前讲解深度的时候都讲过了,但是这次我们通过这道题目彻底搞清楚二叉树高度与深度的问题,以及对应的遍历方式。
- 二叉树节点的 ** 深度 **:↓指从根节点到该节点的最长简单路径边的条数。
- 二叉树节点的 ** 高度 **:↑指从该节点到叶子节点的最长简单路径边的条数。
但 leetcode 中强调的深度和高度很明显是按照节点来计算的。
关于根节点的深度究竟是 1 还是 0,不同的地方有不一样的标准,leetcode 的题目中都是以节点为一度,即根节点深度是 1。但维基百科上定义用边为一度,即根节点的深度是 0,我们暂时以 leetcode 为准(毕竟要在这上面刷题)。
当然此题用迭代法,其实效率很低,因为没有很好的模拟回溯的过程,所以迭代法有很多重复的计算。
虽然理论上所有的递归都可以用迭代来实现,但是有的场景难度可能比较大。
例如:都知道回溯法其实就是递归,但是很少人用迭代的方式去实现回溯算法!
讲了这么多二叉树题目的迭代法,有的同学会疑惑,迭代法中究竟什么时候用队列,什么时候用栈?
如果是模拟前中后序遍历就用栈,如果是适合层序遍历就用队列,当然还是其他情况,那么就是 先用队列试试行不行,不行就用栈。
# 周六
在 [☆257. 二叉树的所有路径](#☆257. 二叉树的所有路径) 中正式涉及到了回溯,很多同学过了这道题目,可能都不知道自己使用了回溯,其实回溯和递归都是相伴相生的。最后我依然给出了迭代法的版本。
我在题解中第一个版本的代码会把回溯的过程充分体现出来,如果大家直接看简洁的代码版本,很可能就会忽略的回溯的存在。
我在文中也强调了这一点。
有的同学还不理解 ,文中精简之后的递归代码,回溯究竟隐藏在哪里了。
文中我明确的说了:回溯就隐藏在 traversal (cur->left, path + "->", result); 中的 path + "->"。 每次函数调用完,path 依然是没有加上 "->" 的,这就是回溯了。
# 小结
二叉树的题目,我都是使用了递归三部曲一步一步的把整个过程分析出来,而不是上来就给出简洁的代码。
一些同学可能上来就能写出代码,大体上也知道是为啥,可以自圆其说,但往细节一扣,就不知道了。
所以刚接触二叉树的同学,建议按照文章分析的步骤一步一步来,不要上来就照着精简的代码写(那样写完了也很容易忘的,知其然不知其所以然)。
简短的代码看不出遍历的顺序,也看不出分析的逻辑,还会把必要的回溯的逻辑隐藏了,所以尽量按照原理分析一步一步来,写出来之后,再去优化代码。
大家加个油!!
# 404. 左叶子之和
首先要注意是判断左叶子,不是二叉树左侧节点,所以不要上来想着层序遍历。
因为题目中其实没有说清楚左叶子究竟是什么节点,那么我来给出左叶子的明确定义:节点 A 的左孩子不为空,且左孩子的左右孩子都为空(说明是叶子节点),那么 A 节点的左孩子为左叶子节点
大家思考一下如下图中二叉树,左叶子之和究竟是多少?
其实是 0,因为这棵树根本没有左叶子!
但看这个图的左叶子之和是多少?
相信通过这两个图,大家可以最左叶子的定义有明确理解了。
那么判断当前节点是不是左叶子是无法判断的,必须要通过节点的父节点来判断其左孩子是不是左叶子。
如果该节点的左节点不为空,该节点的左节点的左节点为空,该节点的左节点的右节点为空,则找到了一个左叶子,判断代码如下:
if(cur.left != null && cur.left.left == null && cur.left.right == null){ | |
// 左叶子的处理逻辑 | |
} |
# 递归
递归的遍历顺序为后序遍历(左右中),是因为要通过递归函数的返回值来累加求取左叶子数值之和。
递归三部曲:
参数和返回值:判断一个树的左叶子节点之和,那么一定要传入树的根节点,递归函数的返回值为数值之和,所以为 int,
使用题目中给出的函数就可以了。
public int sumOfLeftLeaves(TreeNode root)
确定终止条件:
- 如果遍历到空节点,那么左叶子值一定是 0
- 如果当前遍历的节点是叶子节点,那其左叶子也必定是 0
if (root == null) return 0; //null 节点
if (root.left == null && root.right == null) return 0; // 叶子节点
单层递归逻辑:当遇到左叶子节点的时候,记录数值,然后通过递归求取左子树左叶子之和,和 右子树左叶子之和,相加便是整个树的左叶子之和。
int leftSum = sumOfLeftLeaves(root.left); // 左子树的左叶子之和
if (root.left != null && root.left.left == null && root.left.right == null)
leftSum += root.left.val; // !!! 左子树是叶子节点,加入到左叶子之和中!!!
int rightSum = sumOfLeftLeaves(root.right); // 右子树的左叶子之和
int midSum = leftSum + rightSum; // 中
return midSum;
完整的 java 代码:
class Solution { | |
// 递归(后序遍历) | |
public int sumOfLeftLeaves(TreeNode root) { | |
// 递归终止条件 | |
if (root == null) return 0; //null 节点 | |
if (root.left == null && root.right == null) return 0; // 叶子节点 | |
// 递归过程 | |
int leftSum = sumOfLeftLeaves(root.left); // 左子树的左叶子之和 | |
if (root.left != null && root.left.left == null && root.left.right == null) | |
leftSum += root.left.val; // !!! 左子树是叶子节点,加入到左叶子之和中!!! | |
int rightSum = sumOfLeftLeaves(root.right); // 右子树的左叶子之和 | |
int midSum = leftSum + rightSum; // 中 | |
return midSum; | |
} | |
} |
# 迭代
本题迭代法使用前中后序都是可以的,只要把左叶子节点统计出来,就可以了,那么参考文章 二叉树的迭代遍历和二叉树的统一迭代法中的写法,可以写出一个前序遍历的迭代法。
判断条件都是一样的,代码如下:
class Solution { | |
// 迭代(前序遍历),借助栈 | |
public int sumOfLeftLeaves(TreeNode root) { | |
int res = 0; | |
if (root == null) return 0; | |
Stack<TreeNode> stack = new Stack<>(); | |
stack.push(root); | |
while (!stack.isEmpty()) { | |
TreeNode cur = stack.pop(); | |
if (cur.left != null && cur.left.left == null && cur.left.right == null) | |
res += cur.left.val; // 中间节点 cur 的左子树是左叶子,加入结果 | |
if (cur.right != null) stack.push(cur.right); // 先右 | |
if (cur.left != null) stack.push(cur.left); // 后左 | |
} | |
return res; | |
} | |
} |
# 总结
这道题目要求左叶子之和,其实是比较绕的,因为不能判断本节点是不是左叶子节点。
此时就要通过节点的父节点来判断其左孩子是不是左叶子了。
平时我们解二叉树的题目时,已经习惯了通过节点的左右孩子判断本节点的属性,而本题我们要通过节点的父节点判断本节点的属性。
希望通过这道题目,可以扩展大家对二叉树的解题思路。
# 513. 找树左下角的值
本地要找出树的最后一行找到最左边的值。此时大家应该想起用层序遍历是非常简单的了,反而用递归的话会比较难一点。
我们依然还是先介绍递归法。
# 递归(前序)
咋眼一看,这道题目用递归的话就就一直向左遍历,最后一个就是答案呗?
没有这么简单,一直向左遍历到最后一个,它未必是最后一行啊。
我们来分析一下题目:在树的最后一行找到最左边的值。
如何判断是最后一行呢?
要找深度最大的叶子节点
如何找最左边的呢?
可以使用前序遍历(当然中序,后序都可以,因为本题没有 中间节点的处理逻辑,只要左优先就行),保证优先左边搜索,然后记录深度最大的叶子节点,此时就是树的最后一行最左边的值。
递归三部曲:
参数和返回值:
参数必须有要遍历的树的根节点,还有就是一个 int 型的变量用来记录最长深度。 这里就不需要返回值了,所以递归函数的返回类型为 void。
本题还需要类里的两个全局变量,maxLen 用来记录最大深度,result 记录最大深度最左节点的数值。
private int maxDepth = Integer.MIN_VALUE; // 记录最大深度
private int res = 0; // 记录结果:最大深度,最左边的值
private void traversal(TreeNode root, int depth)
递归终止条件:
当遇到叶子节点的时候,就需要统计一下最大的深度了,所以需要遇到叶子节点来更新最大深度。
if (root.left == null && root.right == null) { // 叶子节点
if (depth > maxDepth) {
maxDepth = depth; // 更新最大深度
res = root.val; // 更新结果
}
return;
}
递归单层逻辑:
在找最大深度的时候,递归的过程中依然要使用回溯,代码如下:
// 中
if (root.left != null) { // 左
depth++;
traversal(root.left, depth);
depth--;
}
if (root.right != null) { // 右
depth++;
traversal(root.right, depth);
depth--;
}
return;
完整的 java 代码如下:
class Solution { | |
// 递归(前序遍历) | |
private int maxDepth = Integer.MIN_VALUE; // 记录最大深度 | |
private int res = 0; // 记录结果:最大深度,最左边的值 | |
private void traversal(TreeNode root, int depth) { | |
// 递归终止条件 | |
if (root.left == null && root.right == null) { // 叶子节点 | |
if (depth > maxDepth) { | |
maxDepth = depth; // 更新最大深度 | |
res = root.val; // 更新结果 | |
} | |
return; | |
} | |
// 递归过程 | |
// 中 | |
if (root.left != null) { // 左 | |
depth++; | |
traversal(root.left, depth); | |
depth--; | |
} | |
if (root.right != null) { // 右 | |
depth++; | |
traversal(root.right, depth); | |
depth--; | |
} | |
return; | |
} | |
public int findBottomLeftValue(TreeNode root) { | |
traversal(root, 0); | |
return res; | |
} | |
} |
# √迭代(层序遍历)
本题使用层序遍历再合适不过了,比递归要好理解的多!
只需要记录最后一行第一个节点的数值就可以了。
如果对层序遍历不了解,看这篇 [102. 二叉树的层序遍历](#102. 二叉树的层序遍历),这篇里也给出了层序遍历的模板,稍作修改就一过刷了这道题了。
代码如下:
class Solution { | |
// 迭代(层序遍历),借助队列 | |
public int findBottomLeftValue(TreeNode root) { | |
List<List<Integer>> resList = new ArrayList<>(); // 逐层记录所有节点值 | |
if (root == null) return 0; | |
Queue<TreeNode> queue = new LinkedList<>(); | |
queue.offer(root); | |
while (!queue.isEmpty()) { | |
int size = queue.size(); | |
List<Integer> levelList = new ArrayList<>(); // 记录每一层的节点值 | |
while (size > 0) { | |
TreeNode cur = queue.poll(); | |
levelList.add(cur.val); | |
if (cur.left != null) queue.offer(cur.left); | |
if (cur.right != null) queue.offer(cur.right); | |
size--; | |
} | |
resList.add(levelList); // 将每一层的节点值记录到 resList 中 | |
} | |
return resList.get(resList.size() - 1).get(0); // 返回最后一层的第一个节点值 | |
} | |
} |
# 总结
本题涉及如下几点:
- 递归求深度的写法,我们在 [110. 平衡二叉树](#110. 平衡二叉树) 中详细的分析了深度应该怎么求,高度应该怎么求。
- 递归中其实隐藏了回溯,在 [257. 二叉树的所有路径](#257. 二叉树的所有路径) 中讲解了究竟哪里使用了回溯,哪里隐藏了回溯。
- 层次遍历,在 [102. 二叉树的层序遍历](#102. 二叉树的层序遍历) 深度讲解了二叉树层次遍历。 所以本题涉及到的点,我们之前都讲解过,这些知识点需要同学们灵活运用,这样就举一反三了。
# ☆112. 路径总和
相信很多同学都会疑惑,递归函数什么时候要有返回值,什么时候没有返回值,特别是有的时候递归函数返回类型为 bool 类型。
那么接下来我通过详细讲解如下两道题,来回答这个问题:
- 112. 路径总和
- 113. 路径总和 ii
这道题我们要遍历从根节点到叶子节点的的路径看看总和是不是目标和。
# 递归
中间节点不需要处理,注意回溯
可以使用深度优先遍历的方式(本题前中后序都可以,无所谓,因为中节点也没有处理逻辑)来遍历二叉树
参数、返回类型:
参数:需要二叉树的根节点,还需要一个计数器,这个计数器用来计算二叉树的一条边之和是否正好是目标和,计数器为 int 型。
再来看返回值,** 递归函数什么时候需要返回值?什么时候不需要返回值?** 这里总结如下三点:
如果需要搜索整棵二叉树且不用处理递归返回值,递归函数就不要返回值。(这种情况就是本文下半部分介绍的 113. 路径总和 ii)
如果需要搜索整棵二叉树且需要处理递归返回值,递归函数就需要返回值。 (这种情况我们在 236. 二叉树的最近公共祖先 中介绍)
如果要搜索其中一条符合条件的路径,那么递归一定需要返回值,因为遇到符合条件的路径了就要及时返回。(本题的情况)
而本题我们要找一条符合条件的路径,所以递归函数需要返回值,及时返回,那么返回类型是什么呢?
如图所示:
图中可以看出,遍历的路线,并不要遍历整棵树,所以递归函数需要返回值,可以用 bool 类型表示。
所以代码如下:
public boolean traversal(TreeNode root, int targetSum)
终止条件:
首先计数器如何统计这一条路径的和呢?
不要去累加然后判断是否等于目标和,那么代码比较麻烦,可以用递减,让计数器 count 初始为目标和,然后每次减去遍历路径节点上的数值。
如果最后 count == 0,同时到了叶子节点的话,说明找到了目标和
如果遍历到了叶子节点,count 不为 0,就是没找到
if (root.left == null && root.right == null && targetSum == 0)
return true;
if (root.left == null && root.right == null && targetSum != 0)
return false;
单层递归逻辑:
因为终止条件是判断叶子节点,所以递归的过程中就不要让空节点进入递归了,否则会引发空指针异常。
递归函数是有返回值的,如果递归函数返回 true,说明找到了合适的路径,应该立刻返回。
注意回溯。
if (root.left != null) { // 左子树
targetSum -= root.left.val;
if (traversal(root.left, targetSum))
return true;
targetSum += root.left.val;
}
if (root.right != null) { // 右子树
targetSum -= root.right.val;
if (traversal(root.right, targetSum))
return true;
targetSum += root.right.val;
}
return false;
整体的 java 代码如下:
class Solution { | |
// 递归(前序遍历) | |
public boolean traversal(TreeNode root, int targetSum) { | |
// 递归终止条件 | |
if (root.left == null && root.right == null && targetSum == 0) | |
return true; | |
if (root.left == null && root.right == null && targetSum != 0) | |
return false; | |
// 递归过程 | |
if (root.left != null) { // 左子树 | |
targetSum -= root.left.val; | |
if (traversal(root.left, targetSum)) | |
return true; | |
targetSum += root.left.val; | |
} | |
if (root.right != null) { // 右子树 | |
targetSum -= root.right.val; | |
if (traversal(root.right, targetSum)) | |
return true; | |
targetSum += root.right.val; | |
} | |
return false; | |
} | |
public boolean hasPathSum(TreeNode root, int targetSum) { | |
if (root == null) | |
return false; | |
targetSum -= root.val; // 根节点 | |
return traversal(root, targetSum); | |
} | |
} |
# 迭代
如果使用栈模拟递归的话,那么如果做回溯呢?
此时栈里一个元素不仅要记录该节点指针,还要记录从头结点到该节点的路径数值总和。
c++ 就我们用 pair 结构来存放这个栈里的元素。
定义为: pair<TreeNode*, int>
pair <节点指针,路径数值>
这个为栈里的一个元素。
如下代码是使用栈模拟的前序遍历,如下:(详细注释)
class Solution { | |
// 迭代,借助栈 | |
public boolean hasPathSum(TreeNode root, int targetSum) { | |
if (root == null) | |
return false; | |
Stack<TreeNode> nodeStack = new Stack<>(); // 节点栈 | |
Stack<Integer> sumStack = new Stack<>(); // 路径和栈 | |
nodeStack.push(root); | |
sumStack.push(root.val); | |
while (!nodeStack.isEmpty()) { | |
int size = nodeStack.size(); // 一定要先记录 size,因为后面会改变 | |
while (size > 0) { | |
TreeNode node = nodeStack.pop(); | |
int sum = sumStack.pop(); | |
// 如果是叶子节点,且 sum 等于 targetSum,返回 true | |
if (node.left == null && node.right == null && sum == targetSum) | |
return true; | |
// 右节点入栈 | |
if (node.right != null) { | |
nodeStack.push(node.right); | |
sumStack.push(sum + node.right.val); | |
} | |
// 左节点入栈 | |
if (node.left != null) { | |
nodeStack.push(node.left); | |
sumStack.push(sum + node.left.val); | |
} | |
size--; | |
} | |
} | |
return false; | |
} | |
} |
# 113. 路径总和 II
本题要遍历整个树,找到所有路径,所以递归函数不要返回值!
class Solution { | |
// 递归,注意回溯 | |
private void traversal(TreeNode root, int targetSum, List<Integer> path, List<List<Integer>> res) { | |
path.add(root.val); | |
// 递归终止条件 | |
if (root.left == null && root.right == null) { // 叶子节点 | |
if (targetSum - root.val == 0) { // 找到一条路径 | |
res.add(new ArrayList<>(path)); | |
} | |
return; // 如果该叶子节点不满足条件,直接返回 | |
} | |
// 递归 | |
if (root.left != null) { | |
traversal(root.left, targetSum - root.val, path, res); | |
path.remove(path.size() - 1); // 回溯 | |
} | |
if (root.right != null) { | |
traversal(root.right, targetSum - root.val, path, res); | |
path.remove(path.size() - 1); // 回溯 | |
} | |
} | |
public List<List<Integer>> pathSum(TreeNode root, int targetSum) { | |
List<List<Integer>> res = new ArrayList<>(); | |
List<Integer> path = new ArrayList<>(); // 存放单条路径 | |
if (root == null) | |
return res; | |
traversal(root, targetSum, path, res); | |
return res; | |
} | |
} |
# 总结
本篇通过 leetcode 上 112. 路径总和 和 113. 路径总和 ii 详细的讲解了 递归函数什么时候需要返回值,什么不需要返回值。
这两道题目是掌握这一知识点非常好的题目,大家看完本篇文章再去做题,就会感受到搜索整棵树和搜索某一路径的差别。
对于 112. 路径总和,我依然给出了递归法和迭代法,这种题目其实用迭代法会复杂一些,能掌握递归方式就够了!
# 106. 从中序与后序遍历序列构造二叉树
看完本文,可以一起解决如下两道题目
- 106. 从中序与后序遍历序列构造二叉树
- 105. 从前序与中序遍历序列构造二叉树
首先回忆一下如何根据两个顺序构造一个唯一的二叉树,相信理论知识大家应该都清楚,就是以 后序数组的最后一个元素为切割点,先切中序数组,根据中序数组,反过来在切后序数组。一层一层切下去,每次后序数组最后一个元素就是节点元素。
如果让我们肉眼看两个序列,画一棵二叉树的话,应该分分钟都可以画出来。
流程如图:
那么代码应该怎么写呢?
说到一层一层切割,就应该想到了递归。
来看一下一共分几步:
- 第一步:如果数组大小为零的话,说明是空节点了。
- 第二步:如果不为空,那么取后序数组最后一个元素作为节点元素。
- 第三步:找到后序数组最后一个元素在中序数组的位置,作为切割点
- 第四步:切割中序数组,切成中序左数组和中序右数组 (顺序别搞反了,一定是先切中序数组)
- 第五步:切割后序数组,切成后序左数组和后序右数组
- 第六步:递归处理左区间和右区间
不难写出如下代码:(先把框架写出来)
难点大家应该发现了,就是如何切割,以及边界值找不好很容易乱套。
此时应该注意确定切割的标准,是左闭右开,还有左开右闭,还是左闭右闭,这个就是不变量,要在递归中保持这个不变量。
在切割的过程中会产生四个区间,把握不好不变量的话,一会左闭右开,一会左闭右闭,必然乱套!
我在 [35. 搜索插入位置](#35. 搜索插入位置) 和 [59. 螺旋矩阵 II](#59. 螺旋矩阵 II) 中都强调过循环不变量的重要性,在二分查找以及螺旋矩阵的求解中,坚持循环不变量非常重要,本题也是。
首先要切割中序数组,为什么先切割中序数组呢?
切割点在后序数组的最后一个元素,就是用这个元素来切割中序数组的,所以必要先切割中序数组。
中序数组相对比较好切,找到切割点(后序数组的最后一个元素)在中序数组的位置,然后切割,如下代码中我坚持左闭右开的原则:
接下来就要切割后序数组了。
首先后序数组的最后一个元素指定不能要了,这是切割点 也是 当前二叉树中间节点的元素,已经用了。
后序数组的切割点怎么找?
后序数组没有明确的切割元素来进行左右切割,不像中序数组有明确的切割点,切割点左右分开就可以了。
此时有一个很重的点,就是中序数组大小一定是和后序数组的大小相同的(这是必然)。
中序数组我们都切成了左中序数组和右中序数组了,那么后序数组就可以按照左中序数组的大小来切割,切成左后序数组和右后序数组。
代码如下:
此时,中序数组切成了左中序数组和右中序数组,后序数组切割成左后序数组和右后序数组。
接下来可以递归了,代码如下:
# 分割方式:创建新数组
整体代码如下:
class Solution { | |
public TreeNode buildTree(int[] inorder, int[] postorder) { | |
if (inorder.length == 0 || postorder.length == 0) return null; | |
return traversal(inorder, postorder); | |
} | |
private TreeNode traversal(int[] inorder, int[] postorder) { | |
// 递归终止条件 | |
if (inorder.length == 0 || postorder.length == 0) return null; | |
// 取后序数组的最后一个元素作为根节点 | |
TreeNode root = new TreeNode(postorder[postorder.length - 1]); | |
if (inorder.length == 1) return root; // 只有一个元素,直接返回 | |
// 在中序数组中找到根节点的位置 | |
int rootIndex = 0; | |
for (; rootIndex < inorder.length; rootIndex++) { | |
if (inorder[rootIndex] == root.val) break; | |
} | |
// 切割中序数组,坚持左闭右开区间:[0, rootIndex) 和 [rootIndex + 1, inorder.length) | |
int[] leftInorder = Arrays.copyOfRange(inorder, 0, rootIndex); | |
int[] rightInorder = Arrays.copyOfRange(inorder, rootIndex + 1, inorder.length); | |
// 切割后序数组,坚持左闭右开区间:[0, leftInorder.length) 和 [leftInorder.length, postorder.length - 1) | |
int[] leftPostorder = Arrays.copyOfRange(postorder, 0, leftInorder.length); | |
int[] rightPostorder = Arrays.copyOfRange(postorder, leftInorder.length, postorder.length - 1); // 注意这里不闭合的右区间是 postorder.length - 1,因为要舍弃最后一个根节点 | |
// 递归构造 root 的左右子树 | |
root.left = traversal(leftInorder, leftPostorder); | |
root.right = traversal(rightInorder, rightPostorder); | |
return root; | |
} | |
} |
性能并不好,因为每层递归定定义了新的数组,既耗时又耗空间,但上面的代码是最好理解的,为了方便读者理解,所以用如上的代码来讲解。
# 分割方式:下标索引
下面给出用下标索引写出的代码版本:(思路是一样的,只不过不用重复定义数组了,每次用下标索引来分割)
class Solution { | |
Map<Integer, Integer> map; // 用于存储中序遍历的值和索引,方便查找 | |
// 循环不变量:左闭右开区间 | |
private TreeNode findNode(int[] inorder, int inBegin, int inEnd, int[] postorder, int postBegin, int postEnd) { | |
// 递归终止条件:区间无效 | |
if (inBegin >= inEnd || postBegin >= postEnd) return null; | |
// 取后序数组最后一个元素作为根节点 | |
int rootVal = postorder[postEnd - 1]; | |
// 根据根节点的值找到中序数组中的索引 | |
int rootIndex = map.get(rootVal); | |
// 构造根节点 | |
TreeNode root = new TreeNode(rootVal); | |
// 切割中序数组,找到左右子树的区间 | |
int lenOfLeft = rootIndex - inBegin; // 左中序子树的长度,用于确定左后序子树的区间 | |
// 递归构造左右子树 | |
root.left = findNode(inorder, inBegin, rootIndex, postorder, postBegin, postBegin + lenOfLeft); | |
root.right = findNode(inorder, rootIndex + 1, inEnd, postorder, postBegin + lenOfLeft, postEnd - 1); | |
return root; | |
} | |
public TreeNode buildTree(int[] inorder, int[] postorder) { | |
map = new HashMap<>(); | |
for (int i = 0; i < inorder.length; i++) { | |
map.put(inorder[i], i); | |
} | |
return findNode(inorder, 0, inorder.length, postorder, 0, postorder.length); | |
} | |
} |
# 105. 从前序与中序遍历序列构造二叉树
# 分割方式:创建新数组
对上面的代码稍加修改即可:
class Solution { | |
private TreeNode traversal(int[] preorder, int[] inorder) { | |
// 递归终止条件 | |
if (preorder.length == 0 || inorder.length == 0) return null; | |
// 取前序数组的第一个元素作为根节点 | |
TreeNode root = new TreeNode(preorder[0]); | |
if (preorder.length == 1) return root; // 只有一个元素,直接返回 | |
// 在中序数组中找到根节点的位置 | |
int rootIndex = 0; | |
for (; rootIndex < inorder.length; rootIndex++) { | |
if (inorder[rootIndex] == root.val) break; | |
} | |
// 切割中序数组,坚持左闭右开区间:[0, rootIndex) 和 [rootIndex + 1, inorder.length) | |
int[] leftInorder = Arrays.copyOfRange(inorder, 0, rootIndex); | |
int[] rightInorder = Arrays.copyOfRange(inorder, rootIndex + 1, inorder.length); | |
// 切割前序数组,坚持左闭右开区间:[1, leftInorder.length + 1) 和 [leftInorder.length + 1, preorder.length) | |
int[] leftPreorder = Arrays.copyOfRange(preorder, 1, leftInorder.length + 1); | |
int[] rightPreorder = Arrays.copyOfRange(preorder, leftInorder.length + 1, preorder.length); | |
// 递归构造左右子树 | |
root.left = traversal(leftPreorder, leftInorder); | |
root.right = traversal(rightPreorder, rightInorder); | |
return root; | |
} | |
public TreeNode buildTree(int[] preorder, int[] inorder) { | |
if (preorder.length == 0 || inorder.length == 0) return null; | |
return traversal(preorder, inorder); | |
} | |
} |
# 分割方式:下标索引
class Solution { | |
// 递归,构造二叉树一般选择前序遍历,分割方式:通过下标索引 | |
private TreeNode buildTree_(int[] preorder, int preStart, int preEnd, int[] inorder, int inStart, int inEnd) { | |
// 递归终止条件 | |
if (preStart > preEnd || inStart > inEnd) | |
return null; | |
// 取前序遍历的第一个节点作为根节点 | |
int rootVal = preorder[preStart]; | |
TreeNode root = new TreeNode(rootVal); | |
// 在中序数组中找到根节点的位置 | |
int rootIndex = 0; | |
for (int i = inStart; i <= inEnd; i++) { | |
if (inorder[i] == rootVal) { | |
rootIndex = i; | |
break; | |
} | |
} | |
// 递归构造左子树 | |
root.left = buildTree_(preorder, preStart + 1, preStart + rootIndex - inStart, inorder, inStart, rootIndex - 1); | |
// 递归构造右子树 | |
root.right = buildTree_(preorder, preStart + rootIndex - inStart + 1, preEnd, inorder, rootIndex + 1, inEnd); | |
return root; | |
} | |
public TreeNode buildTree(int[] preorder, int[] inorder) { | |
// 循环不变量:左闭右闭区间 | |
return buildTree_(preorder, 0, preorder.length - 1, inorder, 0, inorder.length - 1); | |
} | |
} |
# 654. 最大二叉树
构造树一般采用的是前序遍历,因为先构造中间节点,然后递归构造左子树和右子树。
# 分割方式:创建新数组
递归三部曲:
参数和返回值:
参数就是传入的是存放元素的数组,返回该数组构造的二叉树的头结点,返回类型是指向节点的指针。
public TreeNode constructMaximumBinaryTree(int[] nums)
递归终止条件:
题目中说了输入的数组大小一定是大于等于 1 的,所以我们不用考虑小于 1 的情况,那么当递归遍历的时候,如果传入的数组大小为 1,说明遍历到了叶子节点了。
那么应该定义一个新的节点,并把这个数组的数值赋给新的节点,然后返回这个节点。 这表示一个数组大小是 1 的时候,构造了一个新的节点,并返回。
if (nums.length == 1) { // 只有一个叶子节点
return new TreeNode(nums[0]); // 返回叶子节点
}
单层递归的逻辑:
3.1. 找到数组中最大的值和对应的下标, 最大的值构造根节点,下标用来下一步分割数组
// 找到 nums 中的最大值
int max = Integer.MIN_VALUE;
int maxIndex = 0;
for (int i = 0; i < nums.length; i++) {
if (nums[i] > max) {
max = nums[i];
maxIndex = i;
}
}
// 创建根节点
TreeNode root = new TreeNode(max);
3.2. 根据最大值下标将数组分割为左、右子数组
// 将 nums 分为左右两部分
int[] leftNums = new int[maxIndex];
int[] rightNums = new int[nums.length - maxIndex - 1];
for (int i = 0; i < maxIndex; i++) {
leftNums[i] = nums[i];
}
for (int i = maxIndex + 1; i < nums.length; i++) {
rightNums[i - maxIndex - 1] = nums[i];
}
3.3. 递归构造根节点的左、右子树
// 递归创建左右子树
if (leftNums.length > 0) {
root.left = constructMaximumBinaryTree(leftNums);
}
if (rightNums.length > 0) {
root.right = constructMaximumBinaryTree(rightNums);
}
return root;
完整的 java 代码如下:
class Solution { | |
// 递归(前序遍历) | |
public TreeNode constructMaximumBinaryTree(int[] nums) { | |
// 递归终止条件 | |
if (nums.length == 1) { // 只有一个叶子节点 | |
return new TreeNode(nums[0]); // 返回叶子节点 | |
} | |
// 递归过程 | |
// 找到 nums 中的最大值 | |
int max = Integer.MIN_VALUE; | |
int maxIndex = 0; | |
for (int i = 0; i < nums.length; i++) { | |
if (nums[i] > max) { | |
max = nums[i]; | |
maxIndex = i; | |
} | |
} | |
// 创建根节点 | |
TreeNode root = new TreeNode(max); | |
// 将 nums 分为左右两部分 | |
int[] leftNums = new int[maxIndex]; | |
int[] rightNums = new int[nums.length - maxIndex - 1]; | |
for (int i = 0; i < maxIndex; i++) { | |
leftNums[i] = nums[i]; | |
} | |
for (int i = maxIndex + 1; i < nums.length; i++) { | |
rightNums[i - maxIndex - 1] = nums[i]; | |
} | |
// 递归创建左右子树 | |
if (leftNums.length > 0) { | |
root.left = constructMaximumBinaryTree(leftNums); | |
} | |
if (rightNums.length > 0) { | |
root.right = constructMaximumBinaryTree(rightNums); | |
} | |
return root; | |
} | |
} |
以上代码比较冗余,效率也不高,每次切割的时候还都要定义新的数组,但逻辑比较清晰。
# 分割方式:下标索引
和文章 [106. 从中序与后序遍历序列构造二叉树](#106. 从中序与后序遍历序列构造二叉树) 中一样的优化思路,就是每次分隔不用定义新的数组,而是通过下标索引直接在原数组上操作。这回导致以下几方面的变换:
- 不能直接在原函数上编写递归逻辑了,需要另外定义一个递归函数,因为参数改变了,除了数组 nums,还需要起始下标、终止下标
- 还需要明确循环不变量:区间的定义原则(是左闭右开?还是左闭右闭?),这里选择左闭右闭
- 递归终止条件变了:
- 数组是否为空:是则返回 null 节点
- 数组长度是否为 1:是则返回该叶子节点
- 最重要的是,找到最大值作为根节点后,对数组 nums 的分割方式改变了!
优化后代码如下:
class Solution { | |
// 递归(前序遍历),每次分割不需要重新创建数组,只需通过下标索引在原数组上操作 | |
public TreeNode constructMaximumBinaryTree(int[] nums) { | |
return buildTree(nums, 0, nums.length - 1); // 区间:左闭右闭 | |
} | |
// 循环不变量:左闭右闭区间 | |
private TreeNode buildTree(int[] nums, int startIndex, int endIndex) { | |
// 递归终止条件 | |
if (startIndex > endIndex) // 数组为空 | |
return null; | |
if (startIndex == endIndex) // 数组只有一个元素 | |
return new TreeNode(nums[startIndex]); | |
// 找到最大值,及其下标 | |
int maxIndex = startIndex; | |
for (int i = startIndex; i <= endIndex; i++) { | |
if (nums[i] > nums[maxIndex]) { | |
maxIndex = i; | |
} | |
} | |
// 构造根节点 | |
TreeNode root = new TreeNode(nums[maxIndex]); | |
// 递归构造左右子树(这里没有对传入的 nums 是否为空进行判断,需要在 “递归终止条件” 中加以判断) | |
root.left = buildTree(nums, startIndex, maxIndex - 1); | |
root.right = buildTree(nums, maxIndex + 1, endIndex); | |
return root; | |
} | |
} |
优化后的速度明显快了许多:
# 本周小结
# 周一
在二叉树:以为使用了递归,其实还隐藏着回溯中,通过 leetcode [☆257. 二叉树的所有路径](#☆257. 二叉树的所有路径),讲解了递归如何隐藏着回溯,一些代码会把回溯的过程都隐藏了起来了,甚至刷过这道题的同学可能都不知道自己用了回溯。
文章中第一版代码把每一个细节都展示了输出来了,大家可以清晰的看到回溯的过程。
然后给出了第二版优化后的代码,分析了其回溯隐藏在了哪里,如果要把这个回溯扣出来的话,在第二版的基础上应该怎么改。
主要需要理解:回溯隐藏在 traversal(cur->left, path + "->", result);
中的 path + "->"
。 每次函数调用完,path 依然是没有加上"->" 的,这就是回溯了。
# 周二
在文章 [404. 左叶子之和](#404. 左叶子之和) 中提供了另一个判断节点属性的思路,平时我们习惯了使用通过节点的左右孩子判断本节点的属性,但发现使用这个思路无法判断左叶子。
此时需要相连的三层之间构成的约束条件,也就是要通过节点的父节点以及孩子节点来判断本节点的属性。
这道题目可以扩展大家对二叉树的解题思路。
# 周三
在 [513. 找树左下角的值](#513. 找树左下角的值) 中的题目如果使用递归的写法还是有点难度的,层次遍历反而很简单。
题目其实就是要在树的最后一行找到最左边的值。
如何判断是最后一行呢,其实就是深度最大的叶子节点一定是最后一行。
在这篇文章中,我们使用递归算法实实在在的求了一次深度,然后使用靠左的遍历,保证求得靠左的最大深度,而且又一次使用了回溯。
如果对二叉树的高度与深度又有点模糊了,在看这里 [☆110. 平衡二叉树](#☆110. 平衡二叉树),回忆一下吧。
[513. 找树左下角的值](#513. 找树左下角的值) 中把我们之前讲过的内容都过了一遍,此外,还用前序遍历的技巧求得了靠左的最大深度。
求二叉树的各种最值,就想应该采用什么样的遍历顺序,确定了遍历循序,其实就和数组求最值一样容易了。
# 周四
在 [☆112. 路径总和](#☆112. 路径总和) 中通过两道题目,彻底说清楚递归函数的返回值问题。
一般情况下:
如果需要搜索整棵二叉树,那么递归函数就不要返回值
如果要搜索其中一条符合条件的路径,递归函数就需要返回值,因为遇到符合条件的路径了就要及时返回
特别是有些时候 递归函数的返回值是 bool 类型,一些同学会疑惑为啥要加这个,其实就是为了找到一条边立刻返回
后序遍历需要根据左右递归的返回值推出中间节点的状态,这种需要有返回值
例如 [222. 完全二叉树的节点个数](#222. 完全二叉树的节点个数),[☆110. 平衡二叉树](#☆110. 平衡二叉树)
# 周五
之前都是讲解遍历二叉树,这次该 ** 构造二叉树 ** 了,在 [106. 从中序与后序遍历序列构造二叉树](#106. 从中序与后序遍历序列构造二叉树) 中,我们通过前序和中序,后序和中序,构造了唯一的一棵二叉树。
构造二叉树有三个注意的点:
- 分割时候,坚持区间不变量原则,左闭右开,或者左闭又闭。
- 分割的时候,注意后序 或者 前序已经有一个节点作为中间节点了,不能继续使用了。
- 如何使用切割后的中序数组来切割后序数组?利用中序数组大小一定是和后序数组的大小相同这一特点来进行切割。
这道题目代码实现并不简单,大家啃下来之后,二叉树的构造应该不是问题了。
最后我还给出了为什么前序和后序不能唯一构成一棵二叉树,因为没有中序遍历就无法确定左右部分,也就无法分割。
# 周六
知道了如何构造二叉树,那么使用一个套路就可以解决文章 [654. 最大二叉树](#654. 最大二叉树) 中的问题。
注意类似用数组构造二叉树的题目,每次分隔尽量不要定义新的数组,而是通过下标索引直接在原数组上操作,这样可以节约时间和空间上的开销。
文章中我还给出了递归函数什么时候加 if,什么时候不加 if,其实就是控制空节点(空指针)是否进入递归,是不同的代码实现方式,都是可以的。
一般情况来说
- 如果让空节点(空指针)进入递归,就不加 if
- 如果不让空节点进入递归,就加 if 限制一下
终止条件也会相应的调整
# 小结
本周我们深度讲解了如下知识点:
- 递归中如何隐藏着回溯
- 如何通过三层关系确定左叶子
- 如何通过二叉树深度来判断左下角的值
- 递归函数究竟什么时候需要返回值,什么时候不要返回值?
- 前序和中序,后序和中序构造唯一二叉树
- 使用数组构造某一特性的二叉树
如果大家一路跟下来,一定收获满满,如果周末不做这个总结,大家可能都不知道自己收获满满,啊哈!
# 617. 合并二叉树
相信这道题目很多同学疑惑的点是如何同时遍历两个二叉树呢?
其实和遍历一个树逻辑是一样的,只不过传入两个树的节点,同时操作。
# √递归
二叉树使用递归,就要想使用前中后哪种遍历方式?
本题使用哪种遍历都是可以的!
我们下面以前序遍历为例。
动画如下:
那么我们来按照递归三部曲来解决:
参数、返回值:
- 参数:至少是要传入两个二叉树的根节点
- 返回值:就是合并之后二叉树的根节点
public TreeNode mergeTrees(TreeNode root1, TreeNode root2)
递归终止条件:
因为是传入了两个树,那么就有两个树遍历的节点 t1 和 t2
- 如果
t1 == NULL
,两个树合并就应该是 t2 了(如果 t2 也为 NULL 也无所谓,合并之后就是 NULL) - 如果
t2 == NULL
,那么两个数合并就是 t1(如果 t1 也为 NULL 也无所谓,合并之后就是 NULL)
if (root1 == null) return root2;
if (root2 == null) return root1;
- 如果
单层递归逻辑:
单层递归的逻辑就比较好写了,这里我们重复利用一下 t1 这个树,t1 就是合并之后树的根节点(就是修改了原来树的结构)
把两棵树的元素加到一起
t1 的左子树:合并 t1 左子树 t2 左子树之后的左子树
t1 的右子树:合并 t1 右子树 t2 右子树之后的右子树
最终 t1 就是合并之后的根节点。
root1.val = root1.val + root2.val; // 根节点合并,重复利用 root1
root1.left = mergeTrees(root1.left, root2.left); // 左子树合并
root1.right = mergeTrees(root1.right, root2.right); // 右子树合并
return root1;
完整的 java 代码如下:
class Solution { | |
// 递归实现(前序遍历) | |
public TreeNode mergeTrees(TreeNode root1, TreeNode root2) { | |
// 递归终止条件 | |
if (root1 == null) return root2; | |
if (root2 == null) return root1; | |
// 递归过程 | |
root1.val = root1.val + root2.val; // 根节点合并,重复利用 root1 | |
root1.left = mergeTrees(root1.left, root2.left); // 左子树合并 | |
root1.right = mergeTrees(root1.right, root2.right); // 右子树合并 | |
return root1; | |
} | |
} |
对于中序遍历、后序遍历,只需要更改一下代码顺序即可。但是前序遍历是最好理解的,我建议大家用前序遍历来做就 OK。
如上的方法修改了 t1 的结构,当然也可以不修改 t1 和 t2 的结构,重新定义一个树。
不修改输入树的结构,前序遍历,代码如下:
class Solution { | |
// 递归实现(前序遍历) | |
public TreeNode mergeTrees(TreeNode root1, TreeNode root2) { | |
// 递归终止条件 | |
if (root1 == null) return root2; | |
if (root2 == null) return root1; | |
// 递归过程 | |
TreeNode root = new TreeNode(root1.val + root2.val); // 根节点合并,新建一个节点 | |
root.left = mergeTrees(root1.left, root2.left); // 左子树合并 | |
root.right = mergeTrees(root1.right, root2.right); // 右子树合并 | |
return root; | |
} | |
} |
# 迭代
使用迭代法,如何同时处理两棵树呢?
思路我们在 [101. 对称二叉树](#101. 对称二叉树) 中的迭代法已经讲过一次了,求二叉树对称的时候就是把两个树的节点同时加入队列进行比较。
本题我们也使用队列,模拟的层序遍历,代码如下:
class Solution { | |
// 迭代(层序遍历),借助队列 | |
public TreeNode mergeTrees(TreeNode root1, TreeNode root2) { | |
// 特判 | |
if (root1 == null) return root2; | |
if (root2 == null) return root1; | |
Queue<TreeNode> queue = new LinkedList<>(); | |
queue.offer(root1); | |
queue.offer(root2); | |
while (!queue.isEmpty()) { | |
TreeNode node1 = queue.poll(); | |
TreeNode node2 = queue.poll(); | |
// 根节点处理逻辑 | |
node1.val += node2.val; | |
// 左子树处理逻辑 | |
if (node1.left != null && node2.left != null) { | |
queue.offer(node1.left); | |
queue.offer(node2.left); | |
} else if (node1.left == null && node2.left != null) { | |
node1.left = node2.left; | |
} else if (node1.left != null && node2.left == null) { | |
// 什么都不做,因为本身就是复用 node1 | |
} | |
// 右子树处理逻辑 | |
if (node1.right != null && node2.right != null) { | |
queue.offer(node1.right); | |
queue.offer(node2.right); | |
} else if (node1.right == null && node2.right != null) { | |
node1.right = node2.right; | |
} else if (node1.right != null && node2.right == null) { | |
// 什么都不做,因为本身就是复用 node1 | |
} | |
} | |
return root1; | |
} | |
} |
# 总结
合并二叉树,也是二叉树操作的经典题目,如果没有接触过的话,其实并不简单,因为我们习惯了操作一个二叉树,一起操作两个二叉树,还会有点懵懵的。
这不是我们第一次操作两棵二叉树了,在 [101. 对称二叉树](#101. 对称二叉树) 中也一起操作了两棵二叉树。
迭代法中,一般一起操作两个树都是使用队列模拟类似层序遍历,同时处理两个树的节点,这种方式最好理解,如果用模拟递归的思路的话,要复杂一些。
最后拓展中,我给了一个操作指针的野路子,大家随便看看就行了,如果学习 C++ 的话,可以再去研究研究。
# 700. 二叉搜索树中的搜索
之前我们讲的都是普通二叉树,那么接下来看看二叉搜索树。
在二叉树的理论基础中,我们已经讲过了二叉搜索树。
二叉搜索树是一个有序树:
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 它的左、右子树也分别为二叉搜索树
这就决定了,二叉搜索树,递归遍历和迭代遍历和普通二叉树都不一样。
本题,其实就是在二叉搜索树中搜索一个节点。那么我们来看看应该如何遍历。
# 递归
参数、返回值:参数传入的就是根节点和要搜索的数值,返回的就是以这个搜索数值所在的节点
public TreeNode searchBST(TreeNode root, int val)
递归终止条件:
- 如果 root 为空,就返回 root 节点
- 如果 root 符合条件,也返回 root 节点
if (root == null || root.val == val) {
return root;
}
单层递归的逻辑:
看看二叉搜索树的单层递归逻辑有何不同。
因为二叉搜索树的节点是有序的,所以可以有方向的去搜索。
如果 root->val > val,搜索左子树,如果 root->val < val,就搜索右子树,最后如果都没有搜索到,就返回 NULL。
TreeNode res = null;
if (root.val > val)
res = searchBST(root.left, val); // 左
else if (root.val < val)
res = searchBST(root.right, val); // 右
return res;
很多录友写递归函数的时候 习惯直接写
searchBST(root->left, val)
,却忘了 递归函数还有返回值。递归函数的返回值是什么?是 左子树如果搜索到了 val,要将该节点返回。 如果不用一个变量将其接住,那么返回值不就没了。
所以要
result = searchBST(root->left, val)
。
完整的 java 代码如下:
class Solution { | |
// 递归(前序遍历) | |
public TreeNode searchBST(TreeNode root, int val) { | |
// 递归终止条件:root 为空 或者 root 的值等于 val | |
if (root == null || root.val == val) { | |
return root; | |
} | |
// 单层递归逻辑(无根节点的处理逻辑,在终止条件中) | |
TreeNode res = null; | |
if (root.val > val) | |
res = searchBST(root.left, val); // 左 | |
else if (root.val < val) | |
res = searchBST(root.right, val); // 右 | |
return res; | |
} | |
} |
# 迭代
一提到二叉树遍历的迭代法,可能立刻想起使用栈来模拟深度遍历,使用队列来模拟广度遍历。
深度遍历:前、中、后序
广度遍历:层序
对于二叉搜索树可就不一样了,因为二叉搜索树的特殊性,也就是 ** 节点的有序性,可以不使用辅助栈或者队列就可以写出迭代法 **
对于一般二叉树,递归过程中还有回溯的过程,例如走一个左方向的分支走到头了,那么要调头,在走右分支
而 ** 对于二叉搜索树,不需要回溯的过程,因为节点的有序性就帮我们确定了搜索的方向 **
例如要搜索元素为 3 的节点,我们不需要搜索其他节点,也不需要做回溯,查找的路径已经规划好了
中间节点如果大于 3 就向左走,如果小于 3 就向右走,如图:
所以迭代法代码如下:
class Solution { | |
// 迭代 | |
public TreeNode searchBST(TreeNode root, int val) { | |
while (root != null) { | |
if (root.val < val) { | |
root = root.right; // 右子树 | |
} else if (root.val > val) { | |
root = root.left; // 左子树 | |
} else { | |
return root; // 找到 | |
} | |
} | |
return null; // 未找到 | |
} | |
} |
# 总结
本篇我们介绍了二叉搜索树的遍历方式,因为二叉搜索树的有序性,遍历的时候要比普通二叉树简单很多。
但是一些同学很容易忽略二叉搜索树的特性,所以写出遍历的代码就未必真的简单了。
所以针对二叉搜索树的题目,一样要利用其特性。
文中我依然给出递归和迭代两种方式,可以看出写法都非常简单,就是利用了二叉搜索树有序的特点。
# 98. 验证二叉搜索树
要知道中序遍历下,输出的二叉搜索树节点的数值是递增的有序序列。
有了这个特性,验证二叉搜索树,就相当于变成了判断一个序列是不是递增的了。
# 递归
# √转为有序数组
可以递归中序遍历将二叉搜索树转变成一个数组,代码如下:
private void inorder(TreeNode root, List<Integer> list) { | |
// 递归终止条件 | |
if (root == null) return; | |
// 递归过程 | |
inorder(root.left, list); | |
list.add(root.val); | |
inorder(root.right, list); | |
} |
然后只要比较一下,这个数组是否是有序的,注意二叉搜索树中不能有重复元素。
public boolean isValidBST(TreeNode root) { | |
List<Integer> list = new ArrayList<>(); | |
inorder(root, list); | |
for (int i = 1; i < list.size(); i++) { | |
if (list.get(i) <= list.get(i - 1)) { | |
return false; | |
} | |
} | |
return true; | |
} |
整体代码如下:
class Solution { | |
// 递归(中序遍历),判断是否是升序即可 | |
public boolean isValidBST(TreeNode root) { | |
List<Integer> list = new ArrayList<>(); | |
inorder(root, list); | |
for (int i = 1; i < list.size(); i++) { | |
if (list.get(i) <= list.get(i - 1)) { | |
return false; | |
} | |
} | |
return true; | |
} | |
private void inorder(TreeNode root, List<Integer> list) { | |
// 递归终止条件 | |
if (root == null) return; | |
// 递归过程 | |
inorder(root.left, list); | |
list.add(root.val); | |
inorder(root.right, list); | |
} | |
} |
以上代码中,我们把二叉树转变为数组来判断,是最直观的,但其实不用转变成数组,可以在递归遍历的过程中直接判断是否有序。
# 前、后双指针
这道题目比较容易陷入两个陷阱:
- 陷阱 1
不能单纯的比较左节点小于中间节点,右节点大于中间节点就完事了。
if (root.val > root.left.val && root.val < root.right.val) { | |
return true; | |
} else { | |
return false; | |
} |
我们要比较的是 左子树所有节点小于中间节点,右子树所有节点大于中间节点。所以以上代码的判断逻辑是错误的。
例如: [10,5,15,null,null,6,20] 这个 case:
节点 10 大于左节点 5,小于右节点 15,但右子树里出现了一个 6 这就不符合了!
- 陷阱 2
样例中最小节点 可能是 int 的最小值,如果这样使用最小的 int 来比较也是不行的。
此时可以初始化比较元素为 longlong 的最小值。
问题可以进一步演进:如果样例中根节点的 val 可能是 longlong 的最小值 又要怎么办呢?文中会解答。
了解这些陷阱之后我们来看一下代码应该怎么写:
递归三部曲:
返回值、参数:
要定义一个 longlong 的全局变量,用来比较遍历的节点是否有序,因为后台测试数据中有 int 最小值,所以定义为 longlong 的类型,初始化为 longlong 最小值。
注意递归函数要有 bool 类型的返回值, 我们在二叉树:递归函数究竟什么时候需要返回值,什么时候不要返回值?中讲了,只有寻找某一条边(或者一个节点)的时候,递归函数会有 bool 类型的返回值。
其实本题是同样的道理,我们在寻找一个不符合条件的节点,如果没有找到这个节点就遍历了整个树,如果找到不符合的节点了,立刻返回。
这样看来可以直接使用原函数作为递归函数。
TreeNode pre = null; // 记录上一个节点
public boolean isValidBST(TreeNode root)
递归终止条件:
如果是空节点 是不是二叉搜索树呢?
是的,二叉搜索树也可以为空!
代码如下:
if (root == null) return true;
单层递归逻辑:
中序遍历,一直更新 maxVal,一旦发现 maxVal >= root->val,就返回 false,注意元素相同时候也要返回 false。
// 递归过程:中序遍历,一直更新 pre,一旦发现 pre.val >= root->val,就返回 false,注意元素相同时候也要返回 false。
boolean left = isValidBST(root.left); // 左子树
// 中间节点
if (pre != null && pre.val >= root.val)
return false;
pre = root;
boolean right = isValidBST(root.right); // 右子树
return left && right;
整体代码:
class Solution { | |
// 递归(中序遍历),在递归过程中判断是否有序 | |
TreeNode pre = null; // 记录上一个节点 | |
public boolean isValidBST(TreeNode root) { | |
// 递归终止条件 | |
if (root == null) return true; | |
// 递归过程:中序遍历,一直更新 pre,一旦发现 pre.val >= root->val,就返回 false,注意元素相同时候也要返回 false。 | |
boolean left = isValidBST(root.left); // 左子树 | |
// 中间节点 | |
if (pre != null && pre.val >= root.val) | |
return false; | |
pre = root; | |
boolean right = isValidBST(root.right); // 右子树 | |
return left && right; | |
} | |
} |
# 迭代
可以用迭代法模拟二叉树中序遍历,对前中后序迭代法生疏的同学可以看这两篇二叉树的迭代遍历,☆二叉树的统一迭代法
迭代法中序遍历稍加改动就可以了,代码如下:
class Solution { | |
// 迭代(中序遍历),借助栈、节点指针 | |
public boolean isValidBST(TreeNode root) { | |
Stack<TreeNode> stack = new Stack<>(); | |
TreeNode cur = root; | |
TreeNode pre = null; // 记录前一个节点 | |
while (cur != null || !stack.isEmpty()) { | |
if (cur != null) { | |
stack.push(cur); | |
cur = cur.left; // 一路向左 | |
} else { // 此时 cur == null,到达最左节点下的 null 节点 | |
cur = stack.pop(); //cur 回退到中间节点 | |
if (pre != null && cur.val <= pre.val) { // 发现 pre ≥ cur | |
return false; | |
} | |
pre = cur; // 更新 pre | |
cur = cur.right; // 右 | |
} | |
} | |
return true; | |
} | |
} |
在 [700. 二叉搜索树中的搜索](#700. 二叉搜索树中的搜索) 中我们分明写出了痛哭流涕的简洁迭代法,怎么在这里不行了呢,因为本题是要验证二叉搜索树啊。
# 总结
这道题目是一个简单题,但对于没接触过的同学还是有难度的。
所以初学者刚开始学习算法的时候,看到简单题目没有思路很正常,千万别怀疑自己智商,学习过程都是这样的,大家智商都差不多,哈哈。
只要把基本类型的题目都做过,总结过之后,思路自然就开阔了。
# 530. 二叉搜索树的最小绝对差
题目中要求在二叉搜索树上任意两节点的差的绝对值的最小值。
注意是二叉搜索树,二叉搜索树可是有序的。
遇到在二叉搜索树上求什么最值啊,差值之类的,就把它想成在一个有序数组上求最值,求差值,这样就简单多了。
# 递归
# √转为有序数组
那么二叉搜索树采用中序遍历,其实就是一个有序数组。
在一个有序数组上求两个数最小差值,这是不是就是一道送分题了。
最直观的想法,就是把二叉搜索树转换成有序数组,然后遍历一遍数组,就统计出来最小差值了。
代码如下:
class Solution { | |
public int getMinimumDifference(TreeNode root) { | |
List<Integer> list = new ArrayList<>(); | |
inorder(root, list); | |
// 找出递增数组 list 中的最小差值 | |
int min = Integer.MAX_VALUE; | |
// 通过双层循环遍历 | |
for (int i = 0; i < list.size(); i++) { | |
for (int j = i + 1; j < list.size(); j++) { | |
min = Math.min(min, list.get(j) - list.get(i)); | |
} | |
} | |
return min; | |
} | |
private void inorder(TreeNode root, List<Integer> list) { | |
// 递归终止条件 | |
if (root == null) return; | |
// 递归过程 | |
inorder(root.left, list); | |
list.add(root.val); | |
inorder(root.right, list); | |
} | |
} |
效率奇差:
其实这里不必通过双层 for 循环去查找有序数组中的最小差值,因为最小差值一定是在相邻元素之间的(因为数组是递增的),更改如下:
class Solution { | |
public int getMinimumDifference(TreeNode root) { | |
List<Integer> list = new ArrayList<>(); | |
inorder(root, list); | |
// 找出递增数组 list 中的最小差值 | |
int min = Integer.MAX_VALUE; | |
for (int i = 1; i < list.size(); i++) | |
min = Math.min(min, list.get(i) - list.get(i - 1)); | |
return min; | |
} | |
private void inorder(TreeNode root, List<Integer> list) { | |
// 递归终止条件 | |
if (root == null) return; | |
// 递归过程 | |
inorder(root.left, list); | |
list.add(root.val); | |
inorder(root.right, list); | |
} | |
} |
# √前、后双指针
双指针:pre 和 root
以上代码是把二叉搜索树转化为有序数组了,其实在二叉搜素树中序遍历的过程中,我们就可以直接计算了。
需要用一个 pre 节点记录一下 cur 节点的前一个节点。
如图:
一些同学不知道在递归中如何记录前一个节点的指针,其实实现起来是很简单的,大家只要看过一次,写过一次,就掌握了。
class Solution { | |
private int minRes = Integer.MAX_VALUE; | |
TreeNode pre = null; | |
public int getMinimumDifference(TreeNode root) { | |
inorder(root); | |
return minRes; | |
} | |
private void inorder(TreeNode root) { | |
// 递归终止条件 | |
if (root == null) return; | |
// 递归过程 | |
inorder(root.left); // 左 | |
if (pre != null) // 中 | |
minRes = Math.min(minRes, root.val - pre.val); | |
pre = root; | |
inorder(root.right); // 右 | |
} | |
} |
# 迭代
双指针:pre 和 cur
用 ** 栈模拟中序遍历的递归法,同时需要节点指针 ** 以进行遍历,代码如下:
class Solution { | |
// 迭代(中序遍历),借助栈、节点指针 | |
public int getMinimumDifference(TreeNode root) { | |
int minRes = Integer.MAX_VALUE; | |
Stack<TreeNode> stack = new Stack<>(); | |
TreeNode cur = root; // 当前节点,用于遍历 | |
TreeNode pre = null; // 记录前一个节点 | |
while (cur != null || !stack.isEmpty()) { // 当前节点不为空(第一次) 或者 栈不为空 | |
if (cur != null) { | |
stack.push(cur); | |
cur = cur.left; // 一路向左 | |
} else { | |
cur = stack.pop(); // 中 | |
if (pre != null) | |
minRes = Math.min(minRes, cur.val - pre.val); | |
pre = cur; // 记录前一个节点 | |
cur = cur.right; // 右 | |
} | |
} | |
return minRes; | |
} | |
} |
# 总结
遇到在二叉搜索树上求什么最值,求差值之类的,都要思考一下二叉搜索树可是有序的,要利用好这一特点。
同时要学会在递归遍历的过程中如何记录前后两个指针,这也是一个小技巧,学会了还是很受用的。
后面我将继续介绍一系列利用二叉搜索树特性的题目。
# 501. 二叉搜索树中的众数
二叉树上应该怎么求,二叉搜索树上又应该怎么求?
这道题目呢,递归法我从两个维度来讲。
首先如果不是二叉搜索树的话,应该怎么解题,是二叉搜索树,又应该如何解题,两种方式做一个比较,可以加深大家对二叉树的理解。
# 递归法
# √不是二叉搜索树
如果不是二叉搜索树,最直观的方法一定是把这个树都遍历了,用 map 统计频率,把频率排个序,最后取前面高频的元素的集合。
具体步骤如下:
这个树都遍历了,用 map 统计频率
至于用前中后序哪种遍历也不重要,因为就是要全遍历一遍,怎么个遍历法都行,层序遍历都没毛病!
这里采用前序遍历,代码如下:
private void preorder(TreeNode root) {
// 递归终止条件
if (root == null) return;
// 递归过程
map.put(root.val, map.getOrDefault(root.val, 0) + 1); // 中
preorder(root.left); // 左
preorder(root.right); // 右
}
把统计的出来的出现频率(即 map 中的 value)排个序
有的同学可能可以想直接对 map 中的 value 排序,还真做不到,C++ 中如果使用 std::map 或者 std::multimap 可以对 key 排序,但不能对 value 排序。
所以要把 map 转化数组即 vector,再进行排序,当然 vector 里面放的也是
pair<int, int>
类型的数据,第一个 int 为元素,第二个 int 为出现频率。// 对 map 的 value 进行降序排序
List<Map.Entry<Integer, Integer>> list = new ArrayList<>(map.entrySet());
list.sort((o1, o2) -> o2.getValue() - o1.getValue());
取前面高频的元素
此时数组 vector 中已经是存放着按照频率排好序的 pair,那么把前面高频的元素取出来就可以了。
// 找出最大的 value
int max = list.get(0).getValue();
// 找出所有 value 等于 max 的 key
for (Map.Entry<Integer, Integer> entry : list) {
if (entry.getValue() == max) {
res.add(entry.getKey());
} else {
break; // 因为 list 已经按 value 降序排序,所以一旦出现 value 不等于 max 的情况,就可以跳出循环
}
}
// 将 list 转为数组
int[] arr = new int[res.size()];
for (int i = 0; i < res.size(); i++) {
arr[i] = res.get(i);
}
return arr;
整体的 java 代码如下:
class Solution { | |
private Map<Integer, Integer> map = new HashMap<>(); //key: 节点值,value: 节点值出现的次数 | |
List<Integer> res = new ArrayList<>(); | |
// 递归(前序遍历) | |
public int[] findMode(TreeNode root) { | |
preorder(root); | |
// 对 map 的 value 进行降序排序 | |
List<Map.Entry<Integer, Integer>> list = new ArrayList<>(map.entrySet()); | |
list.sort((o1, o2) -> o2.getValue() - o1.getValue()); | |
// 找出最大的 value | |
int max = list.get(0).getValue(); | |
// 找出所有 value 等于 max 的 key | |
for (Map.Entry<Integer, Integer> entry : list) { | |
if (entry.getValue() == max) { | |
res.add(entry.getKey()); | |
} else { | |
break; // 因为 list 已经按 value 降序排序,所以一旦出现 value 不等于 max 的情况,就可以跳出循环 | |
} | |
} | |
// 将 list 转为数组 | |
int[] arr = new int[res.size()]; | |
for (int i = 0; i < res.size(); i++) { | |
arr[i] = res.get(i); | |
} | |
return arr; | |
} | |
private void preorder(TreeNode root) { | |
// 递归终止条件 | |
if (root == null) return; | |
// 递归过程 | |
map.put(root.val, map.getOrDefault(root.val, 0) + 1); // 中 | |
preorder(root.left); // 左 | |
preorder(root.right); // 右 | |
} | |
} |
# 是二叉搜索树
既然是搜索树,它中序遍历就是有序的。
中序遍历代码如下:
void searchBST(TreeNode* cur) { | |
if (cur == NULL) return ; | |
searchBST(cur->left); // 左 | |
(处理节点) // 中 | |
searchBST(cur->right); // 右 | |
return ; | |
} |
遍历有序数组的元素出现频率,从头遍历,那么一定是相邻两个元素作比较,然后就把出现频率最高的元素输出就可以了。
关键是在有序数组上的话,好搞,在树上怎么搞呢?
这就考察对树的操作了。
在 [530. 二叉搜索树的最小绝对差](#530. 二叉搜索树的最小绝对差) 中我们就使用了 pre 指针和 cur 指针的技巧,这次又用上了。
弄一个指针指向前一个节点,这样每次 cur(当前节点)才能和 pre(前一个节点)作比较。
而且初始化的时候 pre = NULL,这样当 pre 为 NULL 时候,我们就知道这是比较的第一个元素。
if (pre == NULL) { // 第一个节点 | |
count = 1; // 频率为 1 | |
} else if (pre->val == cur->val) { // 与前一个节点数值相同 | |
count++; | |
} else { // 与前一个节点数值不同 | |
count = 1; | |
} | |
pre = cur; // 更新上一个节点 |
此时又有问题了,因为要求最大频率的元素集合(注意是集合,不是一个元素,可以有多个众数),如果是数组上大家一般怎么办?
应该是先遍历一遍数组,找出最大频率(maxCount),然后再重新遍历一遍数组把出现频率为 maxCount 的元素放进集合。(因为众数有多个)
这种方式遍历了两遍数组。
那么我们遍历两遍二叉搜索树,把众数集合算出来也是可以的。
但这里其实只需要遍历一次就可以找到所有的众数。
那么如何只遍历一遍呢?
如果 频率 count 等于 maxCount(最大频率),当然要把这个元素加入到结果集中(以下代码为 result 数组),代码如下:
if (count == maxCount) { // 如果和最大值相同,放进 result 中 | |
result.push_back(cur->val); | |
} |
是不是感觉这里有问题,result 怎么能轻易就把元素放进去了呢,万一,这个 maxCount 此时还不是真正最大频率呢。
所以下面要做如下操作:
频率 count 大于 maxCount 的时候,不仅要更新 maxCount,而且要清空结果集(以下代码为 result 数组),因为结果集之前的元素都失效了。
if (count > maxCount) { // 如果计数大于最大值 | |
maxCount = count; // 更新最大频率 | |
result.clear(); // 很关键的一步,不要忘记清空 result,之前 result 里的元素都失效了 | |
result.push_back(cur->val); | |
} |
关键代码都讲完了,完整代码如下:(只需要遍历一遍二叉搜索树,就求出了众数的集合)
class Solution { | |
int maxCount;// 众数出现的最大次数 | |
int count; // 统计出现次数 | |
TreeNode pre; // 记录前一个节点 | |
List<Integer> resList; // 众数列表 | |
// 递归(中序遍历),不适用额外空间,利用二叉搜索树的特性 | |
private void inorder(TreeNode root) { | |
// 递归终止条件 | |
if (root == null) return; | |
// 递归过程 | |
inorder(root.left); // 左 | |
int rootVal = root.val; // 中 | |
if (pre == null || rootVal != pre.val) // 当前节点与前一个节点不相等 | |
count = 1; | |
else // 当前节点与前一个节点相等 | |
count++; | |
if (count > maxCount) { // 更新 | |
resList.clear(); // 清空 | |
resList.add(rootVal); | |
maxCount = count; | |
} else if (count == maxCount) // 与最大次数相等 | |
resList.add(rootVal); | |
pre = root; // 更新前一个节点 | |
inorder(root.right); // 右 | |
} | |
public int[] findMode(TreeNode root) { | |
maxCount = 0; | |
count = 0; | |
pre = null; | |
resList = new ArrayList<>(); | |
inorder(root); | |
int[] res = new int[resList.size()]; | |
for (int i = 0; i < resList.size(); i++) | |
res[i] = resList.get(i); | |
return res; | |
} | |
} |
# 迭代
只要把中序遍历转成迭代,中间节点的处理逻辑完全一样。
下面我给出其中的一种中序遍历的迭代法,其中间处理逻辑一点都没有变(我从递归法直接粘过来的代码,连注释都没改,哈哈)
代码如下:
class Solution { | |
// 迭代(中序遍历),借助栈 | |
public int[] findMode(TreeNode root) { | |
int maxCount = 0;// 众数出现的最大次数 | |
int count = 0; // 统计出现次数 | |
TreeNode pre = null; // 记录前一个节点 | |
List<Integer> resList = new ArrayList<>(); // 众数列表 | |
Stack<TreeNode> stack = new Stack<>(); | |
TreeNode cur = root; | |
while (cur != null || !stack.isEmpty()) { | |
if (cur != null) { | |
stack.push(cur); | |
cur = cur.left; // 左 | |
} else { | |
cur = stack.pop(); // 中 | |
if (pre == null || cur.val != pre.val) // 当前节点与前一个节点不相等 | |
count = 1; | |
else // 当前节点与前一个节点相等 | |
count++; | |
if (count > maxCount) { // 更新 | |
maxCount = count; | |
resList.clear(); // 清空 | |
resList.add(cur.val); | |
} else if (count == maxCount) // 与最大次数相等 | |
resList.add(cur.val); | |
pre = cur; // 更新前一个节点 | |
cur = cur.right; // 右 | |
} | |
} | |
return resList.stream().mapToInt(Integer::valueOf).toArray(); // 将列表 List 转为数组 [] | |
} | |
} |
# 总结
本题在递归法中,我给出了如果是普通二叉树,应该怎么求众数。
知道了普通二叉树的做法时候,我再进一步给出二叉搜索树又应该怎么求众数,这样鲜明的对比,相信会对二叉树又有更深层次的理解了。
在递归遍历二叉搜索树的过程中,我还介绍了一个统计最高出现频率元素集合的技巧, 要不然就要遍历两次二叉搜索树才能把这个最高出现频率元素的集合求出来。
为什么没有这个技巧一定要遍历两次呢? 因为要求的是集合,会有多个众数,如果规定只有一个众数,那么就遍历一次稳稳的了。
最后我依然给出对应的迭代法,其实就是迭代法中序遍历的模板加上递归法中中间节点的处理逻辑,分分钟就可以写出来,中间逻辑的代码我都是从递归法中直接粘过来的。
求二叉搜索树中的众数其实是一道简单题,但大家可以发现我写了这么一大篇幅的文章来讲解,主要是为了尽量从各个角度对本题进剖析,帮助大家更快更深入理解二叉树。
需要强调的是 leetcode 上的耗时统计是非常不准确的,看个大概就行,一样的代码耗时可以差百分之 50 以上,所以 leetcode 的耗时统计别太当回事,知道理论上的效率优劣就行了。
# 236. 二叉树的最近公共祖先
下一篇再谈二叉搜索树的最近公共祖先问题
遇到这个题目首先想的是要是能自底向上查找就好了,这样就可以找到公共祖先了。
那么二叉树如何可以自底向上查找呢?
回溯啊,二叉树回溯的过程就是从底到上。
后序遍历(左右中)就是天然的回溯过程,可以根据左右子树的返回值,来处理中节点的逻辑。
接下来就看如何判断一个节点是节点 q 和节点 p 的公共祖先呢。
首先最容易想到的一个情况:如果找到一个节点,发现左子树出现结点 p,右子树出现节点 q,或者 左子树出现结点 q,右子树出现节点 p,那么该节点就是节点 p 和 q 的最近公共祖先。 即情况一:
判断逻辑是 如果递归遍历遇到 q,就将 q 返回,遇到 p 就将 p 返回,那么如果 左右子树的返回值都不为空,说明此时的中节点,一定是 q 和 p 的最近祖先。
那么有录友可能疑惑,会不会左子树 遇到 q 返回,右子树也遇到 q 返回,这样并没有找到 q 和 p 的最近祖先。
这么想的录友,要审题了,题目强调:二叉树节点数值是不重复的,而且一定存在 q 和 p。
但是很多人容易忽略一个情况,就是节点本身 p (q),它拥有一个子孙节点 q (p)。 情况二:
其实情况一 和 情况二 代码实现过程都是一样的,也可以说,实现情况一的逻辑,顺便包含了情况二。
因为遇到 q 或者 p 就返回,这样也包含了 q 或者 p 本身就是 公共祖先的情况。
这一点是很多录友容易忽略的,在下面的代码讲解中,可以再去体会。
# 递归(后序遍历)
递归三部曲:
参数、返回值:
需要递归函数返回值,来告诉我们是否找到节点 q 或者 p,那么返回值为 bool 类型就可以了。
但我们还要返回最近公共节点,可以利用上题目中返回值是 TreeNode * ,那么如果遇到 p 或者 q,就把 q 或者 p 返回,返回值不为空,就说明找到了 q 或者 p。
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q)
递归终止条件:
遇到空的话,因为树都是空了,所以返回空。
如果 root == q,或者 root == p,说明找到 q p ,则将其返回,这个返回值,后面在中节点的处理过程中会用到,那么中节点的处理逻辑,下面讲解。
if (root == null) return null;
if (root == p || root == q) return root;
单层递归的逻辑:
值得注意的是 本题函数有返回值,是因为回溯的过程需要递归函数的返回值做判断,但本题我们依然要遍历树的所有节点。
我们在 [☆112. 路径总和](#☆112. 路径总和) 中说了 递归函数有返回值就是要遍历某一条边,但有返回值也要看如何处理返回值!
如果递归函数有返回值,如何区分要搜索一条边,还是搜索整个树呢?
搜索一条边的写法:
if (递归函数(root->left)) return ;
if (递归函数(root->right)) return ;
搜索整个树写法:
left = 递归函数(root->left); // 左
right = 递归函数(root->right); // 右
left与right的逻辑处理; // 中
看出区别了没?
在递归函数有返回值的情况下:
- 如果要搜索一条边,递归函数返回值不为空的时候,立刻返回
- 如果搜索整个树,直接用一个变量 left、right 接住返回值,这个 left、right 后序还有逻辑处理的需要,也就是后序遍历中处理中间节点的逻辑(也是回溯)。
那么为什么要遍历整棵树呢?直观上来看,找到最近公共祖先,直接一路返回就可以了。
如图:
就像图中一样直接返回 7,多美滋滋。
但事实上还要遍历根节点右子树(即使此时已经找到了目标节点了),也就是图中的节点 4、15、20。
因为在如下代码的后序遍历中,如果想利用 left 和 right 做逻辑处理, 不能立刻返回,而是要等 left 与 right 逻辑处理完之后才能返回。
left = 递归函数(root->left); // 左
right = 递归函数(root->right); // 右
left与right的逻辑处理; // 中
所以此时大家要知道我们要遍历整棵树。知道这一点,对本题就有一定深度的理解了。
那么先用 left 和 right 接住左子树和右子树的返回值,代码如下:
TreeNode left = lowestCommonAncestor(root.left, p, q); // 左
TreeNode right = lowestCommonAncestor(root.right, p, q); // 右
如果 left 和 right 都不为空,说明此时 root 就是最近公共节点。这个比较好理解
如果 left 为空,right 不为空,就返回 right,说明目标节点是通过 right 返回的,反之依然。
这里有的同学就理解不了了,为什么 left 为空,right 不为空,目标节点通过 right 返回呢?
如图:
图中节点 10 的左子树返回 null,右子树返回目标值 7,那么此时节点 10 的处理逻辑就是把右子树的返回值(最近公共祖先 7)返回上去!
这里也很重要,可能刷过这道题目的同学,都不清楚结果究竟是如何从底层一层一层传到头结点的。
那么如果 left 和 right 都为空,则返回 left 或者 right 都是可以的,也就是返回空。
代码如下:
if (left == null && right == null) return null; // 左右都为空,说明 p,q 都不在 root 的左右子树中
else if (left != null && right == null) return left; // 左不为空,右为空,说明 p,q 都在 root 的左子树中
else if (left == null && right != null) return right; // 左为空,右不为空,说明 p,q 都在 root 的右子树中
else return root; // 左右都不为空,说明 p,q 分别在 root 的左右子树中,root 为最近公共祖先
那么寻找最小公共祖先,完整流程图如下:
从图中,大家可以看到,我们是如何回溯遍历整棵二叉树,将结果返回给头结点的!
整体的 java 代码如下:
class Solution { | |
// 递归(后序遍历) | |
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { | |
// 递归终止条件 | |
if (root == null) return null; | |
if (root == p || root == q) return root; | |
// 单层递归逻辑 | |
TreeNode left = lowestCommonAncestor(root.left, p, q); // 左 | |
TreeNode right = lowestCommonAncestor(root.right, p, q); // 右 | |
if (left == null && right == null) return null; // 左右都为空,说明 p,q 都不在 root 的左右子树中 | |
else if (left != null && right == null) return left; // 左不为空,右为空,说明 p,q 都在 root 的左子树中 | |
else if (left == null && right != null) return right; // 左为空,右不为空,说明 p,q 都在 root 的右子树中 | |
else return root; // 左右都不为空,说明 p,q 分别在 root 的左右子树中,root 为最近公共祖先 | |
} | |
} |
# 总结
这道题目刷过的同学未必真正了解这里面回溯的过程,以及结果是如何一层一层传上去的。
那么我给大家归纳如下三点:
- 求最小公共祖先,需要从底向上遍历,那么二叉树,只能通过后序遍历(即:回溯)实现从底向上的遍历方式。
- 在回溯的过程中,必然要遍历整棵二叉树,即使已经找到结果了,依然要把其他节点遍历完,因为要使用递归函数的返回值(也就是代码中的 left 和 right)做逻辑判断。
- 要理解如果返回值 left 为空,right 不为空为什么要返回 right,为什么可以用返回 right 传给上一层结果。
可以说这里每一步,都是有难度的,都需要对二叉树,递归和回溯有一定的理解。
本题没有给出迭代法,因为迭代法不适合模拟回溯的过程。理解递归的解法就够了。
# 本周小结
# 周一
在 [617. 合并二叉树](#617. 合并二叉树) 中讲解了如何合并两个二叉树,平时我们都习惯了操作一个二叉树,一起操作两个树可能还有点陌生。
单层递归的逻辑:
把两棵树的元素加到一起
t1 的左子树:合并 t1 左子树 t2 左子树之后的左子树
t1 的右子树:合并 t1 右子树 t2 右子树之后的右子树
其实套路是一样,只不过一起操作两个树的指针,我们之前讲过求 [101. 对称二叉树](#101. 对称二叉树) 的时候,已经初步涉及到了 一起遍历两棵二叉树了。
迭代法中,一般一起操作两个树都是使用队列模拟类似层序遍历,同时处理两个树的节点,这种方式最好理解,如果用模拟递归的思路的话,要复杂一些。
# 周二
周二开始讲解一个新的树,二叉搜索树,开始要换一个思路了,如果没有利用好二叉搜索树的特性,就容易把简单题做成了难题了。
学习 [700. 二叉搜索树中的搜索](#700. 二叉搜索树中的搜索),还是比较容易的。
单层递归逻辑:
TreeNode res = null; if (root.val > val) res = searchBST(root.left, val); // 左 else if (root.val < val) res = searchBST(root.right, val); // 右 return res;
大多是二叉搜索树的题目,其实都离不开中序遍历,因为这样就是有序的。
至于迭代法,相信大家看到文章中如此简单的迭代法的时候,都会感动的痛哭流涕。
# 周三
了解了二搜索树的特性之后, 开始验证 [98. 验证二叉搜索树](#98. 验证二叉搜索树)。
首先在此强调一下二叉搜索树的特性:
- 节点的左子树只包含小于当前节点的数。
- 节点的右子树只包含大于当前节点的数。
- 所有左子树和右子树自身必须也是二叉搜索树。
那么我们在验证二叉搜索树的时候,有两个陷阱:
- 陷阱一
不能单纯的比较左节点小于中间节点,右节点大于中间节点就完事了,而是 ** 左子树都小于中间节点,右子树都大于中间节点 **。
- 陷阱二
在一个有序序列求最值的时候,不要定义一个全局遍历,然后遍历序列更新全局变量求最值。因为最值可能就是 int 或者 longlong 的最小值。
推荐要通过前一个数值(pre)和后一个数值比较(cur),得出最值。
在二叉树中通过两个前后指针作比较,会经常用到。
本文 [98. 验证二叉搜索树](#98. 验证二叉搜索树) 中迭代法中为什么没有周一那篇那么简洁了呢,因为本篇是验证二叉搜索树,前提默认它是一棵普通二叉树,所以还是要回归之前老办法。
# 周四
了解了 [700. 二叉搜索树中的搜索](#700. 二叉搜索树中的搜索),并且知道 [98. 验证二叉搜索树](#98. 验证二叉搜索树),本篇就很简单了。
要知道二叉搜索树和中序遍历是好朋友!
在 [530. 二叉搜索树的最小绝对差](#530. 二叉搜索树的最小绝对差) 中强调了要利用搜索树的特性,把这道题目想象成在一个有序数组上求两个数最小差值,这就是一道送分题了。
需要明确:在有序数组求任意两数最小值差等价于相邻两数的最小值差。
同样本题也需要用 pre 节点记录 cur 节点的前一个节点。(这种写法一定要掌握)
# 周五
此时大家应该知道遇到二叉搜索树,就想是有序数组,那么在二叉搜索树中求二叉搜索树众数就很简单了。
在 [501. 二叉搜索树中的众数](#501. 二叉搜索树中的众数) 中我给出了如果是普通二叉树,应该如何求众数的集合,然后进一步讲解了二叉搜索树应该如何求众数集合。
在求众数集合的时候有一个技巧,因为题目中众数是可以有多个的,所以一般的方法需要遍历两遍才能求出众数的集合。
但可以遍历一遍就可以求众数集合,使用了适时清空结果集的方法,这个方法还是很巧妙的。相信仔细读了文章的同学会惊呼其巧妙!
所以大家不要看题目简单了,就不动手做了,我选的题目,一般不会简单到不用动手的程度,哈哈。
# 周六
在 [236. 二叉树的最近公共祖先](#236. 二叉树的最近公共祖先) 中,我们开始讲解如何在二叉树中求公共祖先的问题,本来是打算和二叉搜索树一起讲的,但发现篇幅过长,所以先讲二叉树的公共祖先问题。
如果找到一个节点,发现左子树出现结点 p,右子树出现节点 q,或者 左子树出现结点 q,右子树出现节点 p,那么该节点就是节点 p 和 q 的最近公共祖先。
这道题目的看代码比较简单,而且好像也挺好理解的,但是如果把每一个细节理解到位,还是不容易的。
主要思考如下几点:
- 如何从底向上遍历?
- 遍历整棵树,还是遍历局部树?
- 如何把结果传到根节点的?
这些问题都需要弄清楚,上来直接看代码的话,是可能想不到这些细节的。
公共祖先问题,还是有难度的,初学者还是需要慢慢消化!
# 小结
本周我们讲了 [617. 合并二叉树](#617. 合并二叉树),了解了如何操作两个二叉树。
然后开始另一种树:二叉搜索树,了解 [700. 二叉搜索树中的搜索](#700. 二叉搜索树中的搜索),然后 [98. 验证二叉搜索树](#98. 验证二叉搜索树)。
了解以上知识之后,就开始利用其特性,做一些二叉搜索树上的题目,[530. 二叉搜索树的最小绝对差](#530. 二叉搜索树的最小绝对差),[501. 二叉搜索树中的众数](#501. 二叉搜索树中的众数)。
接下来,开始求二叉树与二叉搜索树的公共祖先问题,单篇篇幅原因,先单独介绍 [236. 二叉树的最近公共祖先](#236. 二叉树的最近公共祖先)。
现在已经讲过了几种二叉树了,二叉树,二叉平衡树,完全二叉树,二叉搜索树,后面还会有平衡二叉搜索树。 那么一些同学难免会有混乱了,我针对如下三个问题,帮大家在捋顺一遍:
- 平衡二叉搜索树是不是二叉搜索树和平衡二叉树的结合?
是的,是二叉搜索树和平衡二叉树的结合。
- 平衡二叉树与完全二叉树的区别在于底层节点的位置?
是的,完全二叉树底层必须是从左到右连续的,且次底层是满的。
- 堆是完全二叉树和排序的结合,而不是平衡二叉搜索树?
堆是一棵完全二叉树,同时保证父子节点的顺序关系(有序)。 但完全二叉树一定是平衡二叉树,堆的排序是父节点大于子节点,而搜索树是父节点大于左孩子,小于右孩子,所以堆不是平衡二叉搜索树。
# 235. 二叉搜索树的最近公共祖先
做过 [236. 二叉树的最近公共祖先](#236. 二叉树的最近公共祖先) 题目的同学应该知道,利用回溯从底向上搜索,遇到一个节点的左子树里有 p,右子树里有 q,那么当前节点就是最近公共祖先。
那么本题是二叉搜索树,二叉搜索树是有序的,那得好好利用一下这个特点。
在有序树里,如果判断一个节点的左子树里有 p,右子树里有 q 呢?
因为是有序树,所有 如果 中间节点是 q 和 p 的公共祖先,那么 中节点的数组 一定是在 [p, q] 区间的。即 中节点 > p && 中节点 < q 或者 中节点 > q && 中节点 < p。
那么只要从上到下去遍历,遇到 cur 节点是数值在 [p, q] 区间中则一定可以说明该节点 cur 就是 q 和 p 的公共祖先。 那问题来了,一定是最近公共祖先吗?
如图,我们从根节点搜索,第一次遇到 cur 节点是数值在 [p, q] 区间中,即 节点 5,此时可以说明 p 和 q 一定分别存在于 节点 5 的左子树,和右子树中。
此时节点 5 是不是最近公共祖先? 如果 从节点 5 继续向左遍历,那么将错过成为 q 的祖先, 如果从节点 5 继续向右遍历则错过成为 p 的祖先。
所以 ** 当我们从上向下去递归遍历,第一次遇到 cur 节点是数值在 [p, q] 区间中,那么 cur 就是 p 和 q 的最近公共祖先 **。
理解这一点,本题就很好解了。
而递归遍历顺序,本题就不涉及到 前中后序了(这里没有中节点的处理逻辑,遍历顺序无所谓了)。
如图所示:p 为节点 6,q 为节点 9
可以看出直接按照指定的方向,就可以找到节点 8,为最近公共祖先,而且不需要遍历整棵树,找到结果直接返回!
# 递归
递归三部曲:
参数、返回值:
参数就是当前节点,以及两个结点 p、q
返回值是要返回最近公共祖先,所以是 TreeNode
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q)
递归终止条件:
遇到空返回就可以了。
其实都不需要这个终止条件,因为题目中说了 p、q 为不同节点且均存在于给定的二叉搜索树中。也就是说一定会找到公共祖先的,所以并不存在遇到空的情况。
if (root == null) return null;
单层递归的逻辑:
在遍历二叉搜索树的时候就是寻找区间 [p->val, q->val](注意这里是左闭又闭)
- 那么如果 cur->val 大于 p->val,同时 cur->val 大于 q->val,那么就应该向左遍历(说明目标区间在左子树上)。
需要注意的是此时不知道 p 和 q 谁大,所以两个都要判断
代码如下:
if (cur->val > p->val && cur->val > q->val) {
TreeNode* left = traversal(cur->left, p, q);
if (left != NULL) {
return left;
}
}
细心的同学会发现,在这里调用递归函数的地方,把递归函数的返回值 left,直接 return。
在 [236. 二叉树的最近公共祖先](#236. 二叉树的最近公共祖先) 中,如果递归函数有返回值,如何区分要搜索一条边,还是搜索整个树。
搜索一条边的写法:
if (递归函数(root->left)) return ;
if (递归函数(root->right)) return ;
搜索整个树写法:
left = 递归函数(root->left);
right = 递归函数(root->right);
left与right的逻辑处理;
本题就是标准的搜索一条边的写法,遇到递归函数的返回值,如果不为空,立刻返回。
- 如果 cur->val 小于 p->val,同时 cur->val 小于 q->val,那么就应该向右遍历(目标区间在右子树)。
if (cur->val < p->val && cur->val < q->val) {
TreeNode* right = traversal(cur->right, p, q);
if (right != NULL) {
return right;
}
}
- 剩下的情况,就是 cur 节点在区间(p->val <= cur->val && cur->val <= q->val)或者 (q->val <= cur->val && cur->val <= p->val)中,那么 cur 就是最近公共祖先了,直接返回 cur。
代码如下:
return cur;
整体的 java 代码如下:
class Solution { | |
// 递归 | |
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { | |
// 递归终止条件 | |
if (root == null) return null; | |
// 单层递归的逻辑 | |
if (root.val > p.val && root.val > q.val) { //p,q 都在左子树 | |
TreeNode left = lowestCommonAncestor(root.left, p, q); | |
if (left != null) return left; | |
} | |
if (root.val < p.val && root.val < q.val) { //p,q 都在右子树 | |
TreeNode right = lowestCommonAncestor(root.right, p, q); | |
if (right != null) return right; | |
} | |
//p,q 分别在左右子树 | |
return root; | |
} | |
} |
化简后的代码如下:
class Solution { | |
// 递归 | |
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { | |
// 递归终止条件 | |
if (root == null) return null; | |
// 单层递归的逻辑 | |
if (root.val > p.val && root.val > q.val) { //p,q 都在左子树 | |
return lowestCommonAncestor(root.left, p, q); | |
} else if (root.val < p.val && root.val < q.val) { //p,q 都在右子树 | |
return lowestCommonAncestor(root.right, p, q); | |
} else { //p,q 分别在左右子树 | |
return root; | |
} | |
} | |
} |
# 迭代
对于二叉搜索树的迭代法,大家应该在 [700. 二叉搜索树中的搜索](#700. 二叉搜索树中的搜索) 就了解了。
利用其有序性,迭代的方式还是比较简单的,解题思路在递归中已经分析了。
class Solution { | |
// 迭代 | |
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { | |
while (root != null) { | |
if (root.val > p.val && root.val > q.val) { //p,q 都在左子树 | |
root = root.left; | |
} else if (root.val < p.val && root.val < q.val) { //p,q 都在右子树 | |
root = root.right; | |
} else { //p,q 分别在左右子树 | |
return root; | |
} | |
} | |
return null; // 不存在 | |
} | |
} |
灵魂拷问:是不是又被简单的迭代法感动到痛哭流涕?
# 小结
对于二叉搜索树的最近祖先问题,其实要比 [236. 二叉树的最近公共祖先](#236. 二叉树的最近公共祖先) 简单的多。
不用使用回溯,二叉搜索树自带方向性,可以方便的从上向下查找目标区间,遇到目标区间内的节点,直接返回。
最后给出了对应的迭代法,二叉搜索树的迭代法甚至比递归更容易理解,也是因为其有序性(自带方向性),按照目标区间找就行了。
# 701. 二叉搜索树中的插入操作
这道题目其实是一道简单题目,但是题目中的提示:有多种有效的插入方式,还可以重构二叉搜索树,一下子吓退了不少人,瞬间感觉题目复杂了很多。
其实可以不考虑题目中提示所说的改变树的结构的插入方式。
如下演示视频中可以看出:只要 ** 按照二叉搜索树的规则去遍历,遇到空节点就插入节点 ** 就可以了。
例如插入元素 10 ,需要找到末尾节点插入便可,一样的道理来插入元素 15,插入元素 0,插入元素 6,需要调整二叉树的结构么? 并不需要。
# √递归
递归三部曲:
参数、返回值:
参数就是根节点指针,以及要插入元素,这里递归函数要不要有返回值呢?
可以有,也可以没有,但递归函数如果没有返回值的话,实现是比较麻烦的,下面也会给出其具体实现代码。
有返回值的话,可以利用返回值完成新加入的节点与其父节点的赋值操作。(下面会进一步解释)
递归函数的返回类型为节点类型 TreeNode * 。
public TreeNode insertIntoBST(TreeNode root, int val)
递归终止条件:
终止条件就是找到遍历的节点为 null 的时候,就是要插入节点的位置了,并把插入的节点返回。
if (root == null) {
return new TreeNode(val);
}
这里把添加的节点返回给上一层,就完成了父子节点的赋值操作了,详细再往下看。
单层递归的逻辑:
此时要明确,需要遍历整棵树么?
别忘了这是搜索树,遍历整棵搜索树简直是对搜索树的侮辱,哈哈。
搜索树是有方向了,可以根据插入元素的数值,决定递归方向。
if (root.val > val)
root.left = insertIntoBST(root.left, val);
else if (root.val < val)
root.right = insertIntoBST(root.right, val);
return root;
到这里,大家应该能感受到,如何通过递归函数返回值完成了新加入节点的父子关系赋值操作了,下一层将加入节点返回,本层用 root->left 或者 root->right 将其接住。
完整的 java 代码如下:
class Solution { | |
// 递归 | |
public TreeNode insertIntoBST(TreeNode root, int val) { | |
// 递归终止条件 | |
if (root == null) { | |
return new TreeNode(val); | |
} | |
// 递归过程 | |
if (root.val > val) | |
root.left = insertIntoBST(root.left, val); | |
else if (root.val < val) | |
root.right = insertIntoBST(root.right, val); | |
return root; | |
} | |
} |
# 迭代(前、后双指针)
再来看看迭代法,对二叉搜索树迭代写法不熟悉,可以看这篇:[700. 二叉搜索树中的搜索](#700. 二叉搜索树中的搜索)
在迭代法遍历的过程中,需要记录一下当前遍历的节点的父节点,这样才能做插入节点的操作。
在 [530. 二叉搜索树的最小绝对差](#530. 二叉搜索树的最小绝对差) 和 [501. 二叉搜索树中的众数](#501. 二叉搜索树中的众数) 中,都是用了 ** 记录 pre 和 cur 两个指针 ** 的技巧,本题也是一样的。
class Solution { | |
// 迭代 | |
public TreeNode insertIntoBST(TreeNode root, int val) { | |
if (root == null) | |
return new TreeNode(val); | |
TreeNode cur = root; | |
TreeNode pre = root; // 记录父节点,否则无法插入 | |
while (cur != null) { | |
pre = cur; | |
if (cur.val > val) | |
cur = cur.left; | |
else | |
cur = cur.right; | |
} // 此时 cur 为 null,pre 指向要插入的位置。这就是 pre 的意义! | |
TreeNode node = new TreeNode(val); // 新节点 | |
if (pre.val > val) | |
pre.left = node; | |
else | |
pre.right = node; | |
return root; | |
} | |
} |
# 总结
首先在二叉搜索树中的插入操作,大家不用恐惧其重构搜索树,其实根本不用重构。
然后在递归中,我们重点讲了如何通过递归函数的返回值完成新加入节点和其父节点的赋值操作,并强调了搜索树的有序性。
最后依然给出了迭代的方法,迭代的方法就需要记录当前遍历节点的父节点了,这个和没有返回值的递归函数实现的代码逻辑是一样的。
# 450. 删除二叉搜索树中的节点
BST 删除节点就涉及到结构调整了
搜索树的节点删除要比节点增加复杂的多,有很多情况需要考虑,做好心理准备。
# 递归
递归三部曲:
参数、返回值:
说到递归函数的返回值,在 [701. 二叉搜索树中的插入操作](#701. 二叉搜索树中的插入操作) 中通过递归返回值来加入新节点, 这里也可以通过递归返回值删除节点。
public TreeNode deleteNode(TreeNode root, int key)
递归终止条件:
遇到空返回,其实这也说明没找到删除的节点,遍历到空节点直接返回了
if (root == null) return null;
单层递归的逻辑:
这里就把二叉搜索树中删除节点遇到的情况都搞清楚。
有以下五种情况:
第一种情况:没找到删除的节点,遍历到空节点直接返回了
递归终止条件
找到删除的节点
- 第二种情况:左右孩子都为空(叶子节点),直接删除节点, 返回 NULL 为根节点
- 第三种情况:删除节点的左孩子为空,右孩子不为空,删除节点,右孩子补位,返回右孩子为根节点
- 第四种情况:删除节点的右孩子为空,左孩子不为空,删除节点,左孩子补位,返回左孩子为根节点
- 第五种情况:左右孩子节点都不为空,则将删除节点的左子树头结点(左孩子)放到删除节点的右子树的最左面节点的左孩子上,返回删除节点右孩子为新的根节点
第五种情况有点难以理解,看下面动画:
动画中的二叉搜索树中,删除元素 7, 那么删除节点(元素 7)的左孩子就是 5,删除节点(元素 7)的右子树的最左面节点是元素 8。
将删除节点(元素 7)的左孩子放到删除节点(元素 7)的右子树的最左面节点(元素 8)的左孩子上,就是把 5 为根节点的子树移到了 8 的左孩子的位置。
要删除的节点(元素 7)的右孩子(元素 9)为新的根节点。.
这样就完成删除元素 7 的逻辑,最好动手画一个图,尝试删除一个节点试试。
if (root.val == key) { // 找到了要删除的 key
// 情况 2:key 是叶子节点,直接删除,返回 null 为根节点
if (root.left == null && root.right == null) return null;
// 情况 3:key 的左孩子为空,右孩子不为空,删除节点,右孩子补位,直接返回右子树为根节点
else if (root.left == null && root.right != null) return root.right;
// 情况 4:key 的右孩子为空,左孩子不为空,删除节点,左孩子补位,直接返回左子树为根节点
else if (root.left != null && root.right == null) return root.left;
// 情况 5:key 的左右孩子均不为空,将 key 的左子树接到 key 的右子树的最左节点的左孩子上,并返回 key 的右孩子为根节点
else {
TreeNode cur = root.right;
while (cur.left != null)
cur = cur.left;
cur.left = root.left;
return root.right;
}
}
这里相当于把新的节点返回给上一层,上一层就要用 root->left 或者 root->right 接住,代码如下:
else if (root.val > key)
root.left = deleteNode(root.left, key);
else if (root.val < key)
root.right = deleteNode(root.right, key);
return root;
整体的 java 代码如下:
class Solution { | |
// 递归 | |
public TreeNode deleteNode(TreeNode root, int key) { | |
// 递归终止条件 | |
if (root == null) return null; // 情况 1:未找到 key | |
// 单层递归逻辑 | |
if (root.val == key) { // 找到了要删除的 key | |
// 情况 2:key 是叶子节点,直接删除,返回 null 为根节点 | |
if (root.left == null && root.right == null) return null; | |
// 情况 3:key 的左孩子为空,右孩子不为空,删除节点,右孩子补位,直接返回右子树为根节点 | |
else if (root.left == null && root.right != null) return root.right; | |
// 情况 4:key 的右孩子为空,左孩子不为空,删除节点,左孩子补位,直接返回左子树为根节点 | |
else if (root.left != null && root.right == null) return root.left; | |
// 情况 5:key 的左右孩子均不为空,将 key 的左子树接到 key 的右子树的最左节点的左孩子上,并返回 key 的右孩子为根节点 | |
else { | |
TreeNode cur = root.right; | |
while (cur.left != null) | |
cur = cur.left; | |
cur.left = root.left; | |
return root.right; | |
} | |
} | |
else if (root.val > key) | |
root.left = deleteNode(root.left, key); | |
else if (root.val < key) | |
root.right = deleteNode(root.right, key); | |
return root; | |
} | |
} |
# 总结
读完本篇,大家会发现二叉搜索树删除节点比增加节点复杂的多。
因为二叉搜索树添加节点只需要在叶子上添加就可以的,不涉及到结构的调整,而删除节点操作涉及到结构的调整。
这里我们依然使用递归函数的返回值来完成把节点从二叉树中移除的操作。
这里最关键的逻辑就是第五种情况(删除一个左右孩子都不为空的节点),这种情况一定要想清楚。
而且就算想清楚了,对应的代码也未必可以写出来,所以这道题目既考察思维逻辑,也考察代码能力。
递归中我给出了两种写法,推荐大家学会第一种(利用搜索树的特性)就可以了,第二种递归写法其实是比较绕的。
最后我也给出了相应的迭代法,就是模拟递归法中的逻辑来删除节点,但需要一个 pre 记录 cur 的父节点,方便做删除操作。
迭代法其实不太容易写出来,所以如果是初学者的话,彻底掌握第一种递归写法就够了。
# 669. 修剪二叉搜索树
如果不对递归有深刻的理解,本题有点难 单纯移除一个节点那还不够,要修剪!
相信看到这道题目大家都感觉是一道简单题,但还真的不简单!
# 递归
# 误区
直接想法就是:递归处理,然后遇到 root->val < low || root->val > high
的时候直接 return NULL,一波修改,赶紧利落。
不难写出如下代码:
class Solution { | |
public: | |
TreeNode* trimBST(TreeNode* root, int low, int high) { | |
if (root == nullptr || root->val < low || root->val > high) return nullptr; // 这一步不对! | |
root->left = trimBST(root->left, low, high); | |
root->right = trimBST(root->right, low, high); | |
return root; | |
} | |
}; |
然而 [1, 3] 区间在二叉搜索树的中可不是单纯的节点 3 和左孩子节点 0 就决定的,还要考虑节点 0 的右子树。
我们在重新关注一下第二个示例,如图:
所以以上的代码是不可行的!
# 正确思路
递归三部曲:
参数、返回值:
这里我们为什么需要返回值呢?
因为是要遍历整棵树,做修改,其实不需要返回值也可以,我们也可以完成修剪(其实就是从二叉树中移除节点)的操作。
但是有返回值,更方便,可以通过递归函数的返回值来移除节点。
这样的做法在 [701. 二叉搜索树中的插入操作](#701. 二叉搜索树中的插入操作) 和 [450. 删除二叉搜索树中的节点](#450. 删除二叉搜索树中的节点) 中大家已经了解过了。
public TreeNode trimBST(TreeNode root, int low, int high)
递归终止条件:
修剪的操作并不是在终止条件上进行的,所以就是遇到空节点返回就可以了。
if (root == null) return null;
单层递归的逻辑:
如果 root(当前节点)的元素小于 low 的数值,那么应该递归右子树,并返回右子树符合条件的头结点。
如果 root (当前节点) 的元素大于 high 的,那么应该递归左子树,并返回左子树符合条件的头结点。
接下来要将下一层处理完左子树的结果赋给 root->left,处理完右子树的结果赋给 root->right。
最后返回 root 节点。
if (root.val < low) // 当前节点值小于 low,当前节点的左子树不可能符合条件,递归右子树并返回
return trimBST(root.right, low, high);
if (root.val > high) // 当前节点值大于 high,当前节点的右子树不可能符合条件,递归左子树并返回
return trimBST(root.left, low, high);
root.left = trimBST(root.left, low, high); // 当前节点值在 low 和 high 之间,递归左子树
root.right = trimBST(root.right, low, high); // 当前节点值在 low 和 high 之间,递归右子树
return root;
完整的 java 代码:
class Solution { | |
// 递归 | |
public TreeNode trimBST(TreeNode root, int low, int high) { | |
// 递归终止条件 | |
if (root == null) return null; | |
// 递归过程 | |
if (root.val < low) // 当前节点值小于 low,当前节点的左子树不可能符合条件,递归右子树并返回 | |
return trimBST(root.right, low, high); | |
if (root.val > high) // 当前节点值大于 high,当前节点的右子树不可能符合条件,递归左子树并返回 | |
return trimBST(root.left, low, high); | |
root.left = trimBST(root.left, low, high); // 当前节点值在 low 和 high 之间,递归左子树 | |
root.right = trimBST(root.right, low, high); // 当前节点值在 low 和 high 之间,递归右子树 | |
return root; | |
} | |
} |
# 总结
修剪二叉搜索树其实并不难,但在递归法中大家可看出我费了很大的功夫来讲解如何删除节点的,这个思路其实是比较绕的。
最终的代码倒是很简洁。
如果不对递归有深刻的理解,这道题目还是有难度的!
本题我依然给出递归法和迭代法,初学者掌握递归就可以了,如果想进一步学习,就把迭代法也写一写。
# 108. 将有序数组转换为二叉搜索树
构造二叉搜索树,一不小心就平衡了
做这道题目之前大家可以了解一下这几道:
- 106. 从中序与后序遍历序列构造二叉树
- 654. 最大二叉树 中其实已经讲过了,如果根据数组构造一棵二叉树。
- 701. 二叉搜索树中的插入操作
- 450. 删除二叉搜索树中的节点
进入正题:
题目中说要转换为一棵高度平衡二叉搜索树。为什么强调要平衡呢?
因为只要给我们一个有序数组,如果不强调平衡,都可以以线性结构来构造二叉搜索树。
例如 有序数组 [-10,-3,0,5,9] 就可以构造成这样的二叉搜索树,如图。
上图中,是符合二叉搜索树的特性吧,如果要这么做的话,是不是本题意义就不大了,所以才强调是平衡二叉搜索树。
其实数组构造二叉树,构成平衡树是自然而然的事情,因为大家默认都是从数组中间位置取值作为节点元素,一般不会随机取。所以想构成不平衡的二叉树是自找麻烦。
在二叉树:构造二叉树登场! 和二叉树:构造一棵最大的二叉树 中其实已经讲过了,如果根据数组构造一棵二叉树。
本质就是寻找分割点,分割点作为当前节点,然后递归左区间和右区间。
本题其实要比二叉树:构造二叉树登场! 和 二叉树:构造一棵最大的二叉树 简单一些,因为有序数组构造二叉搜索树,寻找分割点就比较容易了。
分割点就是有序数组中间位置的节点。
那么为问题来了,如果数组长度为偶数,中间节点有两个,取哪一个?
取哪一个都可以,只不过构成了不同的平衡二叉搜索树。
例如:输入:[-10,-3,0,5,9]
如下两棵树,都是这个数组的平衡二叉搜索树:
如果要分割的数组长度为偶数的时候,中间元素为两个,是取左边元素 就是树 1,取右边元素就是树 2。
这也是题目中强调答案不是唯一的原因。 理解这一点,这道题目算是理解到位了。
# 递归
三部曲:
参数、返回值:
删除二叉树节点,增加二叉树节点,都是用递归函数的返回值来完成,这样是比较方便的。
相信大家如果仔细看了二叉树:搜索树中的插入操作 和二叉树:搜索树中的删除操作 ,一定会对递归函数返回值的作用深有感触。
那么本题要构造二叉树,依然用递归函数的返回值来构造中节点的左右孩子。
再来看参数,首先是传入数组,然后就是左下标 left 和右下标 right,我们在二叉树:构造二叉树登场! 中提过,在构造二叉树的时候尽量不要重新定义左右区间数组,而是用下标来操作原数组。
private TreeNode buildTree(int[] nums, int left, int right)
递归终止条件:
这里定义的是左闭右闭的区间,所以当区间 left > right 的时候,就是空节点了。
if (left > right) return null;
单层递归的逻辑:
首先取数组中间元素的位置
不难写出
int mid = (left + right) / 2;
,这么写其实有一个问题,就是数值越界,例如 left 和 right 都是最大 int,这么操作就越界了,在二分法中尤其需要注意!所以可以这么写:
int mid = left + ((right - left) / 2);
但本题 leetcode 的测试数据并不会越界,所以怎么写都可以。但需要有这个意识!
取了中间位置,就开始以中间位置的元素构造节点
代码:
TreeNode* root = new TreeNode(nums[mid]);
接着划分区间,root 的左孩子接住下一层左区间的构造节点,右孩子接住下一层右区间构造的节点
最后返回 root 节点
单层递归整体代码如下:
int mid = left + (right - left) / 2; // 取有序数组的中间元素作为根节点,如果数组长度为偶数,中间位置有两个元素,取靠左边的。这样写防止溢出。
TreeNode root = new TreeNode(nums[mid]); // 构建根节点
root.left = buildTree(nums, left, mid - 1); // 递归构建左子树
root.right = buildTree(nums, mid + 1, right); // 递归构建右子树
return root;
整体的 java 代码如下:
class Solution { | |
public TreeNode sortedArrayToBST(int[] nums) { | |
return buildTree(nums, 0, nums.length - 1); | |
} | |
// 递归构建二叉搜索树,左闭右闭区间 | |
private TreeNode buildTree(int[] nums, int left, int right) { | |
// 递归终止条件 | |
if (left > right) return null; | |
// 单层递归逻辑 | |
int mid = left + (right - left) / 2; // 取有序数组的中间元素作为根节点,如果数组长度为偶数,中间位置有两个元素,取靠左边的。这样写防止溢出。 | |
TreeNode root = new TreeNode(nums[mid]); // 构建根节点 | |
root.left = buildTree(nums, left, mid - 1); // 递归构建左子树 | |
root.right = buildTree(nums, mid + 1, right); // 递归构建右子树 | |
return root; | |
} | |
} |
# 总结
在二叉树:构造二叉树登场!和 二叉树:构造一棵最大的二叉树之后,我们顺理成章的应该构造一下二叉搜索树了,一不小心还是一棵平衡二叉搜索树。
其实思路也是一样的,不断中间分割,然后递归处理左区间,右区间,也可以说是分治。
此时相信大家应该对通过递归函数的返回值来增删二叉树很熟悉了,这也是常规操作。
在定义区间的过程中我们又一次强调了循环不变量的重要性。
最后依然给出迭代的方法,其实就是模拟取中间元素,然后不断分割去构造二叉树的过程。
# 538. 把二叉搜索树转换为累加树
一看到累加树,相信很多小伙伴都会疑惑:如何累加?遇到一个节点,然后再遍历其他节点累加?怎么一想这么麻烦呢。
然后再发现这是一棵二叉搜索树,二叉搜索树啊,这是有序的啊。
那么有序的元素如何求累加呢?
其实这就是一棵树,大家可能看起来有点别扭,换一个角度来看,这就是一个有序数组 [2, 5, 13],求从后到前的累加数组,也就是 [20, 18, 13],是不是感觉这就简单了。
为什么变成数组就是感觉简单了呢?
因为数组大家都知道怎么遍历啊,从后向前,挨个累加就完事了,这换成了二叉搜索树,看起来就别扭了一些是不是。
那么知道如何遍历这个二叉树,也就迎刃而解了,从树中可以看出累加的顺序是右中左,所以我们需要反中序遍历这个二叉树,然后顺序累加就可以了。
# 递归
遍历顺序如图所示:
前、后双指针法:本题依然需要一个 pre 指针记录当前遍历节点 cur 的前一个节点,这样才方便做累加。
pre 指针的使用技巧,我们在二叉树:搜索树的最小绝对差和二叉树:我的众数是多少?都提到了,这是常用的操作手段。
递归三部曲:
返回值、参数:
这里很明确了,不需要递归函数的返回值做什么操作了,要遍历整棵树。
同时需要定义一个全局变量 pre,用来保存 cur 节点的前一个节点的数值,定义为 int 型就可以了。
private int pre = 0;
private void traversal(TreeNode cur)
递归终止条件:
if (root == null) return null;
单层递归的逻辑:
注意要右中左来遍历二叉树, 中节点的处理逻辑就是让 cur 的数值加上 pre 的数值,并更新 pre。
convertBST(root.right); // 右
root.val += pre; // 中
pre = root.val;
convertBST(root.left); // 左
整体的 java 代码:
class Solution { | |
private int pre = 0; | |
private void traversal(TreeNode cur) { | |
// 递归终止条件 | |
if (cur == null) return; | |
// 单层递归逻辑 | |
convertBST(cur.right); // 右 | |
cur.val += pre; // 中 | |
pre = cur.val; | |
convertBST(cur.left); // 左 | |
} | |
public TreeNode convertBST(TreeNode root) { | |
traversal(root); | |
return root; | |
} | |
} |
# 迭代
迭代法其实就是中序模板题了,在二叉树:前中后序迭代法和二叉树:前中后序统一方式迭代法可以选一种自己习惯的写法。
这里我给出其中的一种,借助栈,代码如下:
// 迭代(反中序遍历),借助栈 | |
class Solution { | |
private int pre = 0; | |
private void traversal(TreeNode cur) { | |
Stack<TreeNode> stack = new Stack<>(); | |
while (cur != null || !stack.isEmpty()) { | |
if (cur != null) { | |
stack.push(cur); | |
cur = cur.right; // 右 | |
} else { // 此时 cur 为 null,到达最右端 | |
cur = stack.pop(); | |
cur.val += pre; // 中 | |
pre = cur.val; | |
cur = cur.left; // 左 | |
} | |
} | |
} | |
public TreeNode convertBST(TreeNode root) { | |
traversal(root); | |
return root; | |
} | |
} |
# 二叉树总结篇
# 二叉树的理论基础
- 二叉树的理论基础:二叉树的种类、存储方式、遍历方式、定义方式
# 二叉树的遍历方式
- 深度优先遍历
- 二叉树:前中后序递归法:递归三部曲初次亮相
- 二叉树:前中后序迭代法(一):通过栈模拟递归
- 二叉树:前中后序迭代法(二)统一风格:栈 + null 标记法
- 广度优先遍历
- [二叉树的层序遍历](#102. 二叉树的层序遍历):通过队列模拟
# 求二叉树的属性
- [二叉树:是否对称](#101. 对称二叉树)
- 递归:后序,比较的是根节点的左子树与右子树是不是相互翻转
- 迭代:使用队列 / 栈将两个节点顺序放入容器中进行比较
- [二叉树:求最大深度](#104. 二叉树的最大深度)
- 递归:后序,求根节点最大高度就是最大深度,通过递归函数的返回值做计算树的高度
- 迭代:层序遍历
- [二叉树:求最小深度](#111. 二叉树的最小深度)
- 递归:后序,求根节点最小高度就是最小深度,注意最小深度的定义
- 迭代:层序遍历
- [二叉树:求有多少个节点](#222. 完全二叉树的节点个数)
- 递归:后序,通过递归函数的返回值计算节点数量
- 迭代:层序遍历
- [☆二叉树:是否平衡](#☆110. 平衡二叉树)
- 递归:后序,注意后序求高度和前序求深度,递归过程判断高度差
- 迭代:效率很低,不推荐
- [☆二叉树:找所有路径](#☆257. 二叉树的所有路径)
- 递归:前序,方便让父节点指向子节点,涉及回溯处理根节点到叶子的所有路径
- 迭代:一个栈模拟递归,一个栈来存放对应的遍历路径
- 二叉树:递归中如何隐藏着回溯
- 详解 [☆二叉树:找所有路径](#☆257. 二叉树的所有路径) 中递归如何隐藏着回溯
- [二叉树:求左叶子之和](#404. 左叶子之和)
- 递归:后序,必须三层约束条件,才能判断是否是左叶子。
- 迭代:直接模拟后序遍历
- [二叉树:求左下角的值](#513. 找树左下角的值)
- 递归:顺序无所谓,优先左孩子搜索,同时找深度最大的叶子节点。
- 迭代:层序遍历找最后一行最左边
- [☆二叉树:求路径总和](#☆112. 路径总和)
- 递归:顺序无所谓,递归函数返回值为 bool 类型是为了搜索一条边,没有返回值是搜索整棵树。
- 迭代:栈里元素不仅要记录节点指针,还要记录从头结点到该节点的路径数值总和
# 二叉树的修改与构造
- [翻转二叉树](#226. 翻转二叉树)
- 递归:前序 / 后序,交换左右孩子
- 迭代:直接模拟前序遍历
- [构造二叉树](#106. 从中序与后序遍历序列构造二叉树)
- 递归:前序,重点在于找分割点,分左右区间构造
- 迭代:比较复杂,意义不大
- [构造最大的二叉树](#654. 最大二叉树)
- 递归:前序,分割点为数组最大值,分左右区间构造
- 迭代:比较复杂,意义不大
- [合并两个二叉树](#617. 合并二叉树)
- 递归:前序,同时操作两个树的节点,注意合并的规则
- 迭代:使用队列,类似层序遍历
# 求 BST 的属性
- [二叉搜索树中的搜索](#700. 二叉搜索树中的搜索)
- 递归:二叉搜索树的递归是有方向的
- 迭代:因为有方向,所以迭代法很简单
- [是不是二叉搜索树](#98. 验证二叉搜索树)
- 递归:中序,相当于变成了判断一个序列是不是递增的
- 迭代:模拟中序,逻辑相同
- [求二叉搜索树的最小绝对差](#530. 二叉搜索树的最小绝对差)
- 递归:中序,双指针操作
- 迭代:模拟中序,逻辑相同
- [求二叉搜索树的众数](#501. 二叉搜索树中的众数)
- 递归:中序,清空结果集的技巧,遍历一遍便可求众数集合
- [二叉搜索树转成累加树](#538. 把二叉搜索树转换为累加树)
- 递归:中序,双指针操作累加
- 迭代:模拟中序,逻辑相同
# 二叉树公共祖先问题
- [二叉树的最近公共祖先](#236. 二叉树的最近公共祖先)
- 递归:后序,回溯,找到左子树出现目标值,右子树节点目标值的节点。
- 迭代:不适合模拟回溯
- [BST 的公共祖先问题](#235. 二叉搜索树的最近公共祖先)
- 递归:顺序无所谓,如果节点的数值在目标区间就是最近公共祖先
- 迭代:按序遍历
# BST 的修改与构造
- [二叉搜索树中的插入操作](#701. 二叉搜索树中的插入操作)
- 递归:顺序无所谓,通过递归函数返回值添加节点
- 迭代:按序遍历,需要记录插入父节点,这样才能做插入操作
- [二叉搜索树中的删除操作](#450. 删除二叉搜索树中的节点)
- 递归:前序,想清楚删除非叶子节点的情况
- 迭代:有序遍历,较复杂
- [修剪二叉搜索树](#669. 修剪二叉搜索树)
- 递归:前序,通过递归函数返回值删除节点
- 迭代:有序遍历,较复杂
- [构造二叉搜索树](#108. 将有序数组转换为二叉搜索树)
- 递归:前序,数组中间节点分割
- 迭代:较复杂,通过三个队列来模拟
# 最后总结!
在二叉树题目选择什么遍历顺序是不少同学头疼的事情,我们做了这么多二叉树的题目了,Carl 给大家大体分分类。
涉及到二叉树的构造,无论普通二叉树还是二叉搜索树一定前序,都是先构造中节点。
求普通二叉树的属性,一般是后序,一般要通过递归函数的返回值做计算。
注意在普通二叉树的属性中,我用的是一般为后序,例如单纯求深度就用前序,二叉树:找所有路径也用了前序,这是为了方便让父节点指向子节点。
所以求普通二叉树的属性还是要具体问题具体分析。
求二叉搜索树的属性,一定中序了,要不白瞎了有序性了。
二叉树专题汇聚为一张图:
# 回溯算法
# 回溯算法理论基础
# 什么是回溯
回溯法也可以叫做回溯搜索法,它是一种搜索的方式。
在二叉树系列中,我们已经不止一次,提到了回溯,例如二叉树:以为使用了递归,其实还隐藏着回溯。
回溯是递归的副产品,只要有递归就会有回溯。
所以以下讲解中,回溯函数也就是递归函数,指的都是一个函数。
# 回溯法的效率
回溯法的性能如何呢,这里要和大家说清楚了,虽然回溯法很难,很不好理解,但是回溯法并不是什么高效的算法。
因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
那么既然回溯法并不高效为什么还要用它呢?
因为没得选,一些问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。
此时大家应该好奇了,都什么问题,这么牛逼,只能暴力搜索。
# 回溯法解决的问题
回溯法,一般可以解决如下几种问题:
- 组合问题:N 个数里面按一定规则找出 k 个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个 N 个数的集合里有多少符合条件的子集
- 排列问题:N 个数按一定规则全排列,有几种排列方式
- 棋盘问题:N 皇后,解数独等等
相信大家看着这些之后会发现,每个问题,都不简单!
另外,会有一些同学可能分不清什么是组合,什么是排列?
组合是不强调元素顺序的,排列是强调元素顺序。
例如:{1, 2} 和 {2, 1} 在组合上,就是一个集合,因为不强调顺序,而要是排列的话,{1, 2} 和 {2, 1} 就是两个集合了。
记住组合无序,排列有序,就可以了。
# 如何理解回溯法
回溯法解决的问题都可以抽象为树形结构,是的,我指的是所有回溯法的问题都可以抽象为树形结构!
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度,都构成的树的深度。
递归就要有终止条件,所以必然是一棵高度有限的树(N 叉树)。
这块可能初学者还不太理解,后面的回溯算法解决的所有题目中,我都会强调这一点并画图举相应的例子,现在有一个印象就行。
# 回溯法模板
这里给出 Carl 总结的回溯算法模板。
在讲二叉树的递归遍历中我们说了递归三部曲,这里我再给大家列出回溯三部曲。
- 回溯函数模板 ** 返回值、参数 **
在回溯算法中,我的习惯是函数起名字为 backtracking,这个起名大家随意。
回溯算法中函数返回值一般为 void。
再来看一下参数,因为回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数。
但后面的回溯题目的讲解中,为了方便大家理解,我在一开始就帮大家把参数确定下来。
回溯函数伪代码如下:
void backtracking(参数) |
- 回溯函数 ** 终止条件 **
既然是树形结构,那么我们在讲解二叉树的递归遍历的时候,就知道遍历树形结构一定要有终止条件。
所以回溯也有要终止条件。
什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
所以回溯函数终止条件伪代码如下:
if (终止条件) { | |
存放结果; | |
return; | |
} |
- 回溯搜索的 ** 遍历过程 **
在上面我们提到了,回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
如图:
注意图中,我特意举例集合大小和孩子的数量是相等的!
回溯函数遍历过程伪代码如下:
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { | |
处理节点; | |
backtracking(路径,选择列表); // 递归 | |
回溯,撤销处理结果 | |
} |
for 循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个 for 循环就执行多少次。
backtracking 这里自己调用自己,实现递归。
大家可以从图中看出 for 循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
分析完过程,回溯算法模板框架如下:
void backtracking(参数) { | |
if (终止条件) { | |
存放结果; | |
return; | |
} | |
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { | |
处理节点; | |
backtracking(路径,选择列表); // 递归 | |
回溯,撤销处理结果 | |
} | |
} |
这份模板很重要,后面做回溯法的题目都靠它了!
如果从来没有学过回溯算法的录友们,看到这里会有点懵,后面开始讲解具体题目的时候就会好一些了,已经做过回溯法题目的录友,看到这里应该会感同身受了。
# 总结
本篇我们讲解了,什么是回溯算法,知道了回溯和递归是相辅相成的。
接着提到了回溯法的效率,回溯法其实就是暴力查找,并不是什么高效的算法。
然后列出了回溯法可以解决几类问题,可以看出每一类问题都不简单。
最后我们讲到回溯法解决的问题都可以抽象为树形结构(N 叉树),并给出了回溯法的模板。
今天是回溯算法的第一天,按照惯例 Carl 都是先概述一波,然后在开始讲解具体题目,没有接触过回溯法的同学刚学起来有点看不懂很正常,后面和具体题目结合起来会好一些。
# 77. 组合
# 思路
本题是回溯法的经典题目。
直接的解法当然是使用 for 循环。
输入:n = 4,k = 2,很容易想到,用两个 for 循环,这样就可以输出 和示例中一样的结果。
int n = 4; | |
for (int i = 1; i <= n; i++) { | |
for (int j = i + 1; j <= n; j++) { | |
cout << i << " " << j << endl; | |
} | |
} |
输入:n = 100, k = 3,那么就三层 for 循环,代码如下:
int n = 100; | |
for (int i = 1; i <= n; i++) { | |
for (int j = i + 1; j <= n; j++) { | |
for (int u = j + 1; u <= n; n++) { | |
cout << i << " " << j << " " << u << endl; | |
} | |
} | |
} |
如果 n 为 100,k 为 50 呢,那就 50 层 for 循环,是不是开始窒息。
此时就会发现虽然想暴力搜索,但是用 for 循环嵌套连暴力都写不出来!
咋整?
回溯搜索法来了,虽然回溯法也是暴力,但至少能写出来,不像 for 循环嵌套 k 层让人绝望。
那么回溯法怎么暴力搜呢?
上面我们说了要解决 n 为 100,k 为 50 的情况,暴力写法需要嵌套 50 层 for 循环,那么回溯法就用递归来解决嵌套层数的问题。
递归来做层叠嵌套(可以理解是开 k 层 for 循环),每一次的递归中嵌套一个 for 循环,那么递归就可以用于解决多层嵌套循环的问题了。
此时递归的层数大家应该知道了,例如:n 为 100,k 为 50 的情况下,就是递归 50 层。
一些同学本来对递归就懵,回溯法中递归还要嵌套 for 循环,可能就直接晕倒了!
如果脑洞模拟回溯搜索的过程,绝对可以让人窒息,所以需要抽象图形结构来进一步理解。
我们在回溯算法理论基础中说到回溯法解决的问题都可以抽象为树形结构(N 叉树),用树形结构来理解回溯就容易多了。
那么我把组合问题抽象为如下树形结构:
可以看出这棵树,一开始集合是 1,2,3,4, 从左向右取数,取过的数,不再重复取。
第一次取 1,集合变为 2,3,4 ,因为 k 为 2,我们只需要再取一个数就可以了,分别取 2,3,4,得到集合 [1,2] [1,3] [1,4],以此类推。
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。
图中可以发现n 相当于树的宽度,k 相当于树的深度。
那么如何在这个树上遍历,然后收集到我们要的结果集呢?
图中每次搜索到了叶子节点,我们就找到了一个结果。
相当于只需要把达到叶子节点的结果收集起来,就可以求得 n 个数中 k 个数的组合集合。
在回溯算法理论基础中我们提到了回溯法三部曲,那么我们按照回溯法三部曲开始正式讲解代码了。
# 回溯三部曲
递归函数的返回值、参数
在这里要定义两个全局变量,一个用来存放符合条件单一结果,一个用来存放符合条件结果的集合。
private List<List<Integer>> res; // 结果集
private List<Integer> path; // 符合条件的单一结果
其实不定义这两个全局变量也是可以的,把这两个变量放进递归函数的参数里,但函数里参数太多影响可读性,所以我定义全局变量了。
函数里一定有两个参数,既然是集合 n 里面取 k 个数,那么 n 和 k 是两个 int 型的参数。
然后还需要一个参数,为 int 型变量 startIndex,这个参数用来记录本层递归中,集合从哪里开始遍历(集合就是 [1,...,n] )。
为什么要有这个 startIndex 呢?
建议在 77. 组合视频讲解中,07:36 的时候开始听,startIndex 就是防止出现重复的组合。
从下图中红线部分可以看出,在集合 [1,2,3,4] 取 1 之后,下一层递归,就要在 [2,3,4] 中取数了,那么下一层递归如何知道从 [2,3,4] 中取数呢,靠的就是 startIndex。
所以需要 startIndex 来记录下一层递归,搜索的起始位置。
那么整体代码如下:
private List<List<Integer>> res; // 结果集
private List<Integer> path; // 符合条件的单一结果
private void backtracking(int n, int k, int startIndex)
回溯函数终止条件
什么时候到达所谓的叶子节点了呢?
path 这个数组的大小如果达到 k,说明我们找到了一个子集大小为 k 的组合了,在图中 path 存的就是根节点到叶子节点的路径。
如图红色部分:
此时用 result 二维数组,把 path 保存起来,并终止本层递归。
所以终止条件代码如下:
if (path.size() == k) {
res.add(new ArrayList<>(path)); // 注意:这里要 new 一个新的对象,因为 path 是全局变量,会被后续的递归修改
return;
}
单层的搜索过程
回溯法的搜索过程就是一个树型结构的遍历过程,在如下图中,可以看出
- for 循环用来横向遍历
- 递归的过程是纵向遍历
如此我们才遍历完图中的这棵树。
for 循环每次从 startIndex 开始遍历,然后用 path 保存取到的节点 i。
for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
path.add(i); // 处理节点
backtracking(n, k, i + 1); // 递归,控制树的纵向遍历,注意下一层搜索的起点是 i+1
path.remove(path.size() - 1); // 回溯,撤销处理的节点
}
可以看出 backtracking(递归函数)通过不断调用自己一直往深处遍历,总会遇到叶子节点,遇到了叶子节点就要返回。
backtracking 的下面部分就是回溯的操作了,撤销本次处理的结果。
组合问题 java 完整代码如下:
class Solution { | |
private List<List<Integer>> res = new ArrayList<>(); // 结果集 | |
private List<Integer> path = new ArrayList<>(); // 符合条件的单一结果 | |
private void backtracking(int n, int k, int startIndex) { | |
// 递归终止条件 | |
if (path.size() == k) { | |
res.add(new ArrayList<>(path)); // 注意:这里要 new 一个新的对象,因为 path 是全局变量,会被后续的递归修改 | |
return; | |
} | |
// 单层的搜索过程 | |
for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历 | |
path.add(i); // 处理节点 | |
backtracking(n, k, i + 1); // 递归,控制树的纵向遍历,注意下一层搜索的起点是 i+1 | |
path.remove(path.size() - 1); // 回溯,撤销处理的节点 | |
} | |
} | |
public List<List<Integer>> combine(int n, int k) { | |
backtracking(n, k, 1); | |
return res; | |
} | |
} |
还记得我们在回溯算法理论基础中给出的回溯法模板么?
如下:
void backtracking(参数) { | |
if (终止条件) { | |
存放结果; | |
return; | |
} | |
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { | |
处理节点; | |
backtracking(路径,选择列表); // 递归 | |
回溯,撤销处理结果 | |
} | |
} |
对比一下本题的代码,是不是发现有点像! 所以有了这个模板,就有解题的大体方向,不至于毫无头绪。
# 总结
组合问题是回溯法解决的经典问题,我们开始的时候给大家列举一个很形象的例子,就是 n 为 100,k 为 50 的话,直接想法就需要 50 层 for 循环。
从而引出了回溯法就是解决这种 k 层 for 循环嵌套的问题。
然后进一步把回溯法的搜索过程抽象为树形结构,可以直观的看出搜索的过程。
接着用回溯法三部曲,逐步分析了函数参数、终止条件和单层搜索的过程。
# 77. 组合优化
在上一节中,我们通过回溯搜索法,解决了 n 个数中求 k 个数的组合问题。
文中的回溯法是可以剪枝优化的,本篇我们继续来看一下这个题目。
# 剪枝优化
我们说过,回溯法虽然是暴力搜索,但也有时候可以有点剪枝优化一下的。
在遍历的过程中有如下代码:
for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历 | |
path.add(i); // 处理节点 | |
backtracking(n, k, i + 1); // 递归,控制树的纵向遍历,注意下一层搜索的起点是 i+1 | |
path.remove(path.size() - 1); // 回溯,撤销处理的节点 | |
} |
这个遍历的范围是可以剪枝优化的,怎么优化呢?
来举一个例子,n = 4,k = 4 的话,那么第一层 for 循环的时候,从元素 2 开始的遍历都没有意义了。 在第二层 for 循环,从元素 3 开始的遍历都没有意义了。
这么说有点抽象,如图所示:
图中每一个节点(图中为矩形),就代表本层的一个 for 循环,那么每一层的 for 循环从第二个数开始遍历的话,都没有意义,都是无效遍历。
所以,可以剪枝的地方就在递归中每一层的 for 循环所选择的起始位置。
如果 for 循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
注意代码中 i,就是 for 循环里选择的起始位置。
for (int i = startIndex; i <= n; i++) |
接下来看一下优化过程如下:
- 已经选择的元素个数:path.size ();
- 还需要的元素个数为: k - path.size ();
- 在集合 n 中至多要从该起始位置 :
n - (k - path.size()) + 1
,开始遍历
为什么有个 + 1 呢,因为包括起始位置,我们要是一个左闭的集合。
举个例子,n = 4,k = 3, 目前已经选取的元素个数为 0(path.size 为 0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。
从 2 开始搜索都是合理的,可以是组合 [2, 3, 4]。
这里大家想不懂的话,建议也举一个例子,就知道是不是要 + 1 了。
所以优化之后的 for 循环是:
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) |
剪枝优化后的完整 java 代码如下:
class Solution { | |
private List<List<Integer>> res = new ArrayList<>(); // 结果集 | |
private List<Integer> path = new ArrayList<>(); // 符合条件的单一结果 | |
private void backtracking(int n, int k, int startIndex) { | |
// 递归终止条件 | |
if (path.size() == k) { | |
res.add(new ArrayList<>(path)); // 注意:这里要 new 一个新的对象,因为 path 是全局变量,会被后续的递归修改 | |
return; | |
} | |
// 单层的搜索过程(剪枝优化!!!) | |
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) { // 控制树的横向遍历,剪枝优化,只有当剩余的数的个数大于等于 k-path.size () 时,才有必要继续遍历 | |
path.add(i); // 处理节点 | |
backtracking(n, k, i + 1); // 递归,控制树的纵向遍历,注意下一层搜索的起点是 i+1 | |
path.remove(path.size() - 1); // 回溯,撤销处理的节点 | |
} | |
} | |
public List<List<Integer>> combine(int n, int k) { | |
backtracking(n, k, 1); | |
return res; | |
} | |
} |
时间效率大幅提升:
# 总结
本篇我们对求组合问题的回溯法代码做了剪枝优化,这个优化如果不画图的话,其实不好理解,也不好讲清楚。
所以我依然是把整个回溯过程抽象为一棵树形结构,然后可以直观的看出,剪枝究竟是剪的哪里。
# 216. 组合总和 III
别看本篇选的是组合总和 III,而不是组合总和,本题和上一篇 77. 组合相比难度刚刚好!
# 思路
本题就是在 [1,2,3,4,5,6,7,8,9] 这个集合中找到和为 n 的 k 个数的组合。
相对于 [77. 组合](#77. 组合),无非就是多了一个限制,本题是要找到和为 n 的 k 个数的组合,而整个集合已经是固定的了 [1,...,9]。
想到这一点了,做过 [77. 组合](#77. 组合) 之后,本题是简单一些了。
本题 k 相当于树的深度,9(因为整个集合就是 9 个数)就是树的宽度。
例如 k = 2,n = 4 的话,就是在集合 [1,2,3,4,5,6,7,8,9] 中求 k(个数) = 2, n(和) = 4 的组合。
选取过程如图:
图中,可以看出,只有最后取到集合(1,3)和为 4 符合条件。
# 回溯三部曲
参数:
和 [77. 组合](#77. 组合) 一样,依然需要一维数组 path 来存放符合条件的结果,二维数组 result 来存放结果集。
这里我依然定义 path 和 result 为全局变量。
至于为什么取名为 path?从上面树形结构中,可以看出,结果其实就是一条根节点到叶子节点的路径。
接下来还需要如下参数:
- **targetSum(int)** 目标和,也就是题目中的 n。
- **k(int)** 就是题目中要求 k 个数的集合。
- **sum(int)** 为已经收集的元素的总和,也就是 path 里元素的总和。
- **startIndex(int)** 为下一层 for 循环搜索的起始位置。
private List<List<Integer>> result = new ArrayList<>();
private List<Integer> path = new ArrayList<>();
private void backtracking(int targetSum, int k, int sum, int startIndex)
其实这里 sum 这个参数也可以省略,每次 targetSum 减去选取的元素数值,然后判断如果 targetSum 为 0 了,说明收集到符合条件的结果了,我这里为了直观便于理解,还是加一个 sum 参数。
还要强调一下,回溯法中递归函数参数很难一次性确定下来,一般先写逻辑,需要啥参数了,填什么参数。
终止条件:
什么时候终止呢?
在上面已经说了,k 其实就已经限制树的深度,因为就取 k 个元素,树再往下深了没有意义。
所以如果 path.size () 和 k 相等了,就终止。
如果此时 path 里收集到的元素和(sum) 和 targetSum(就是题目描述的 n)相同了,就用 result 收集当前的结果。
if (path.size() == k) {
if (sum == targetSum) // 找到一组解
result.add(new ArrayList<>(path));
return; // 无论是否找到一组解,都要终止递归
}
单层搜索过程:
本题和 [77. 组合](#77. 组合) 区别之一就是集合固定的就是 9 个数 [1,...,9],所以 for 循环固定 i<=9
如图:
处理过程就是 path 收集每次选取的元素,相当于树型结构里的边,sum 来统计 path 里元素的总和。
for (int i = startIndex; i <= 9; i++) {
// 处理节点
path.add(i);
sum += i;
// 递归
backtracking(targetSum, k, sum, i + 1);
// 回溯,撤销处理的节点
path.remove(path.size() - 1);
sum -= i;
}
别忘了处理过程 和 回溯过程是一一对应的,处理有加,回溯就要有减!
完整的 java 代码如下:
class Solution { | |
private List<List<Integer>> result = new ArrayList<>(); | |
private List<Integer> path = new ArrayList<>(); | |
private void backtracking(int targetSum, int k, int sum, int startIndex) { | |
// 递归终止条件 | |
if (path.size() == k) { | |
if (sum == targetSum) // 找到一组解 | |
result.add(new ArrayList<>(path)); | |
return; // 无论是否找到一组解,都要终止递归 | |
} | |
// 单层搜索过程 | |
for (int i = startIndex; i <= 9; i++) { | |
// 处理节点 | |
path.add(i); | |
sum += i; | |
// 递归 | |
backtracking(targetSum, k, sum, i + 1); | |
// 回溯,撤销处理的节点 | |
path.remove(path.size() - 1); | |
sum -= i; | |
} | |
} | |
public List<List<Integer>> combinationSum3(int k, int n) { | |
backtracking(n, k, 0, 1); | |
return result; | |
} | |
} |
# 剪枝
这道题目,剪枝操作其实是很容易想到了,想必大家看上面的树形图的时候已经想到了。
如图:
已选元素总和如果已经大于 n(图中数值为 4)了,那么往后遍历就没有意义了,直接剪掉。
剪枝的地方:
- 可以放在递归函数开始的地方,剪枝代码如下:
if (sum > targetSum) { // 剪枝操作 | |
return; | |
} |
- 可以放在调用递归之前,即放在这里,只不过要记得把回溯操作给做了
for (int i = startIndex; i <= 9; i++) { | |
// 处理节点 | |
path.add(i); | |
sum += i; | |
// 剪枝:记得回溯 | |
if (sum > targetSum) { | |
path.remove(path.size() - 1); | |
sum -= i; | |
return; | |
} | |
// 递归 | |
backtracking(targetSum, k, sum, i + 1); | |
// 回溯,撤销处理的节点 | |
path.remove(path.size() - 1); | |
sum -= i; | |
} |
和 [77. 组合优化](#77. 组合优化) 一样,for 循环的范围也可以剪枝, i <= 9 - (k - path.size()) + 1
就可以了。
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) //n = 9,即集合大小 |
剪枝后的完整 java 代码如下:
// 剪枝优化 | |
class Solution { | |
private List<List<Integer>> result = new ArrayList<>(); | |
private List<Integer> path = new ArrayList<>(); | |
private void backtracking(int targetSum, int k, int sum, int startIndex) { | |
// 剪枝 | |
if (sum > targetSum) return; // 当前路径和已经大于目标值,无需继续搜索 | |
// 递归终止条件 | |
if (path.size() == k) { | |
if (sum == targetSum) // 找到一组解 | |
result.add(new ArrayList<>(path)); | |
return; // 无论是否找到一组解,都要终止递归 | |
} | |
// 单层搜索过程 | |
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) { // 剪枝 | |
// 处理节点 | |
path.add(i); | |
sum += i; | |
// 递归 | |
backtracking(targetSum, k, sum, i + 1); | |
// 回溯,撤销处理的节点 | |
path.remove(path.size() - 1); | |
sum -= i; | |
} | |
} | |
public List<List<Integer>> combinationSum3(int k, int n) { | |
backtracking(n, k, 0, 1); | |
return result; | |
} | |
} |
# 总结
开篇就介绍了本题与 [77. 组合](#77. 组合) 的区别,相对来说加了元素总和的限制,如果做完 [77. 组合](#77. 组合) 再做本题在合适不过。
分析完区别,依然把问题抽象为树形结构,按照回溯三部曲进行讲解,最后给出剪枝的优化。
相信做完本题,大家对组合问题应该有初步了解了。
# 17. 电话号码的字母组合
n = 8(集合 [2,3,...,9] 的大小);k = str.length () ;
# 思路
从示例上来说,输入 "23",最直接的想法就是两层 for 循环遍历了吧,正好把组合的情况都输出了。
如果输入 "233" 呢,那么就三层 for 循环,如果 "2333" 呢,就四层 for 循环.......
大家应该感觉出和 [77. 组合](#77. 组合) 遇到的一样的问题,就是这 for 循环的层数如何写出来,此时又是回溯法登场的时候了。
理解本题后,要解决如下三个问题:
- 数字和字母如何映射
- 两个字母就两个 for 循环,三个字符我就三个 for 循环,以此类推,然后发现代码根本写不出来
- 输入 1 * #按键等等异常情况
# 数字和字母如何映射
可以使用 map 或者定义一个二维数组,例如:string letterMap [10],来做映射,我这里定义一个二维数组,代码如下:
private String[] letterMap = new String[]{ | |
"", // 0 | |
"", // 1 | |
"abc", // 2 | |
"def", // 3 | |
"ghi", // 4 | |
"jkl", // 5 | |
"mno", // 6 | |
"pqrs", // 7 | |
"tuv", // 8 | |
"wxyz" // 9 | |
}; |
# 回溯法来解决 n 个 for 循环的问题
例如:输入:"23",抽象为树形结构,如图所示:
图中可以看出遍历的深度 k,就是输入 "23" 的长度,而叶子节点就是我们要收集的结果,输出 ["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]。
回溯三部曲:
参数:
首先需要两个全局变量:一个字符串 s 来收集叶子节点的结果,然后用一个字符串数组 result 作为结果集起来。
再来看参数,参数指定是有题目中给的 string digits,然后还要有一个参数就是 int 型的 index。
注意这个 index 可不是 [77. 组合](#77. 组合) 和 [216. 组合总和 III](#216. 组合总和 III) 中的 startIndex 了。
这个 index 是记录遍历第几个数字了,就是用来遍历 digits 的(题目中给出数字字符串),同时 index 也表示树的深度。
private List<String> result = new ArrayList<>(); // 结果集
private StringBuilder path_sb = new StringBuilder(); // 符合条件的单一结果
private void backtracking(String digits, int index)
终止条件:
例如输入用例 "23",两个数字,那么根节点往下递归两层就可以了,叶子节点就是要收集的结果集。
那么终止条件就是 ** 如果 index 等于 输入的数字个数(digits.size)** 了(本来 index 就是用来遍历 digits 的)。
然后收集结果,结束本层递归。
if (index == digits.length()) {
result.add(path_sb.toString()); // 存放结果
return;
}
单层搜索过程:
首先要取 index 指向的数字,并找到对应的字符集(手机键盘的字符集)。
然后 for 循环来处理这个字符集,代码如下:
String letters = letterMap[digits.charAt(index) - '0']; // 获取当前数字对应的字母集
for (int i = 0; i < letters.length(); i++) {
// 处理节点
path_sb.append(letters.charAt(i));
// 递归
backtracking(digits, index + 1);
// 回溯,撤销处理的节点
path_sb.deleteCharAt(path_sb.length() - 1);
}
注意这里 for 循环,可不像是在 [216. 组合总和 III](#216. 组合总和 III) 中从 startIndex 开始遍历的。
因为本题每一个数字代表的是不同集合,也就是求不同集合之间的组合,而 [77. 组合](#77. 组合) 和 [216. 组合总和 III](#216. 组合总和 III) 都是求同一个集合中的组合!
注意:输入 1 * #按键等等异常情况
代码中最好考虑这些异常情况,但题目的测试数据中应该没有异常情况的数据,所以我就没有加了。
但是要知道会有这些异常,如果是现场面试中,一定要考虑到!
完整的 java 代码如下:
class Solution { | |
private String[] letterMap = new String[]{"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"}; | |
private List<String> result = new ArrayList<>(); | |
private StringBuilder path_sb = new StringBuilder(); | |
private void backtracking(String digits, int index) { | |
// 递归终止条件 | |
if (index == digits.length()) { | |
result.add(path_sb.toString()); // 存放一组解 | |
return; | |
} | |
// 单层搜索过程 | |
String letters = letterMap[digits.charAt(index) - '0']; // 获取当前数字对应的字母集 | |
for (int i = 0; i < letters.length(); i++) { | |
// 处理节点 | |
path_sb.append(letters.charAt(i)); | |
// 递归 | |
backtracking(digits, index + 1); | |
// 回溯,撤销处理的节点 | |
path_sb.deleteCharAt(path_sb.length() - 1); | |
} | |
} | |
public List<String> letterCombinations(String digits) { | |
if (digits.length() == 0) { | |
return result; | |
} | |
backtracking(digits, 0); | |
return result; | |
} | |
} |
# 总结
本篇将题目的三个要点一一列出,并重点强调了和前面讲解过的 [77. 组合](#77. 组合) 和 [216. 组合总和 III](#216. 组合总和 III) 的区别,本题是多个集合求组合,所以在回溯的搜索过程中,都有一些细节需要注意的。
其实本题不算难,但也处处是细节,大家还要自己亲自动手写一写。
# 回溯周末总结
# 周一
在回溯算法理论基础中介绍了什么是回溯,回溯法的效率,回溯法解决的问题以及回溯法模板。
回溯是递归的副产品,只要有递归就会有回溯。
回溯法就是暴力搜索,并不是什么高效的算法,最多在剪枝一下。
回溯算法能解决如下问题:
- 组合问题:N 个数里面按一定规则找出 k 个数的集合
- 排列问题:N 个数按一定规则全排列,有几种排列方式
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个 N 个数的集合里有多少符合条件的子集
- 棋盘问题:N 皇后,解数独等等
是不是感觉回溯算法有点厉害了。
回溯法确实不好理解,所以需要把回溯法抽象为一个图形来理解就容易多了,每一道回溯法的题目都可以抽象为树形结构。
针对很多同学都写不好回溯,我在回溯算法理论基础用回溯三部曲,分析了回溯算法,并给出了回溯法的模板。
这个模板会伴随整个回溯法系列!
# 周二
在 [77. 组合](#77. 组合) 中,我们开始用回溯法解决第一道题目,组合问题。
我在文中开始的时候给大家列举 k 层 for 循环例子,进而得出都是同样是暴力解法,为什么要用回溯法。
此时大家应该深有体会回溯法的魅力,用递归控制 for 循环嵌套的数量!
本题我把回溯问题抽象为树形结构,可以直观的看出其搜索的过程:for 循环横向遍历,递归纵向遍历,回溯不断调整结果集。
# 周三
针对 [77. 组合](#77. 组合) 还可以做剪枝的操作。
在 [77. 组合优化](#77. 组合优化) 中把回溯法代码做了剪枝优化,在文中我依然把问题抽象为一个树形结构,大家可以一目了然剪的究竟是哪里。
剪枝精髓是:for 循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够 题目要求的 k 个元素了,就没有必要搜索了。
# 周四
在 [216. 组合总和 III](#216. 组合总和 III) 中,相当于 [77. 组合](#77. 组合) 加了一个元素总和的限制。
整体思路还是一样的,本题的剪枝会好想一些,即:已选元素总和如果已经大于 n(题中要求的和)了,那么往后遍历就没有意义了,直接剪掉。
在本题中,依然还可以有一个剪枝,就是 [77. 组合优化](#77. 组合优化) 中提到的,对 for 循环选择的起始范围的剪枝。
所以,剪枝的代码,可以把 for 循环,加上 i <= 9 - (k - path.size()) + 1
的限制!
组合总和问题还有一些花样,下周还会介绍到。
# 周五
在 [17. 电话号码的字母组合](#17. 电话号码的字母组合) 中,开始用多个集合来求组合,还是熟悉的模板题目,但是有一些细节。
例如这里 for 循环,可不像是在 [77. 组合](#77. 组合) 和 [216. 组合总和 III](#216. 组合总和 III) 中从 startIndex 开始遍历的。
因为本题每一个数字代表的是不同集合,也就是求不同集合之间的组合,而 [77. 组合](#77. 组合) 和 [216. 组合总和 III](#216. 组合总和 III) 都是是求同一个集合中的组合!
如果大家在现场面试的时候,一定要注意各种输入异常的情况,例如本题输入 1 * #按键。
其实本题不算难,但也处处是细节,还是要反复琢磨。
# 周六
因为之前链表系列没有写总结,虽然链表系列已经是两个月前的事情,但还是有必要补一下。
所以给出链表总结篇,这里对之前链表理论基础和经典题目进行了总结。
同时对 [142. 环形链表 II](#142. 环形链表 II) 中求环入口的问题又进行了补充证明,可以说把环形链表的方方面面都讲的很通透了,大家如果没有做过环形链表的题目一定要去做一做。
# 总结
相信通过这一周对回溯法的学习,大家已经掌握其题本套路了,也不会对回溯法那么畏惧了。
回溯法抽象为树形结构后,其遍历过程就是:for 循环横向遍历,递归纵向遍历,回溯不断调整结果集。
这个是我做了很多回溯的题目,不断摸索其规律才总结出来的。
对于回溯法的整体框架,网上搜的文章这块一般都说不清楚,按照天上掉下来的代码对着讲解,不知道究竟是怎么来的,也不知道为什么要这么写。
所以,录友们刚开始学回溯法,起跑姿势就很标准了,哈哈。
下周依然是回溯法,难度又要上升一个台阶了。
# 39. 组合总和
题目中的无限制重复被选取,吓得我赶紧想想 出现 0 可咋办,然后看到下面提示:2 <= candidates [i] <= 40,我就放心了。
本题和 [77. 组合](#77. 组合),[216. 组合总和 III](#216. 组合总和 III) 的区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
本题搜索的过程抽象成树形结构如下:
注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过 target,就返回!
而在 [77. 组合](#77. 组合),[216. 组合总和 III](#216. 组合总和 III) 中都可以知道要递归 K 层,因为要取 k 个元素的组合。
# 回溯三部曲
参数:
这里依然是定义两个全局变量,二维数组 result 存放结果集,数组 path 存放符合条件的结果。(这两个变量可以作为函数参数传入)
首先是题目中给出的参数,集合 candidates, 和目标值 target。
此外我还定义了 int 型的 sum 变量来统计单一结果 path 里的总和,其实这个 sum 也可以不用,用 target 做相应的减法就可以了,最后如何 target==0 就说明找到符合的结果了,但为了代码逻辑清晰,我依然用了 sum。
本题还需要 startIndex 来控制 for 循环的起始位置,对于组合问题,什么时候需要 startIndex 呢?
如果是一个集合来求组合的话,就需要 startIndex,例如:[77. 组合](#77. 组合),[216. 组合总和 III](#216. 组合总和 III)。
如果是多个集合取组合,各个集合之间相互不影响,那么就不用 startIndex,例如:[17. 电话号码的字母组合](#17. 电话号码的字母组合)
注意以上我只是说求组合的情况,如果是排列问题,又是另一套分析的套路,后面我再讲解排列的时候就重点介绍。
private List<List<Integer>> result = new ArrayList<>();
private List<Integer> path = new ArrayList<>();
private void backtracking(int[] candidates, int target, int sum, int startIndex)
递归终止条件:
在如下树形结构中:
从叶子节点可以清晰看到,终止只有两种情况:
sum 大于 target 时,直接返回
sum 等于 target 时,收集结果
if (sum > target) return; // 剪枝
if (sum == target) { // 找到一组解
result.add(new ArrayList<>(path));
return;
}
单层搜索的过程:
单层 for 循环依然是从 startIndex 开始,搜索 candidates 集合。
注意本题和 [77. 组合](#77. 组合),[216. 组合总和 III](#216. 组合总和 III) 的一个区别是:本题元素为可重复选取的。
如何重复选取呢,看代码,注释部分:
for (int i = startIndex; i < candidates.length; i++) {
// 处理节点
path.add(candidates[i]);
sum += candidates[i];
// 递归
backtracking(candidates, target, sum, i); // 注意:这里是 i,而不是 i + 1,因为可以重复选取
// 回溯,撤销处理的节点
path.remove(path.size() - 1);
sum -= candidates[i];
}
完整的 java 代码如下:
class Solution { | |
private List<List<Integer>> result = new ArrayList<>(); | |
private List<Integer> path = new ArrayList<>(); | |
private void backtracking(int[] candidates, int target, int sum, int startIndex) { | |
// 递归终止条件 | |
if (sum > target) return; // 剪枝 | |
if (sum == target) { // 找到一组解 | |
result.add(new ArrayList<>(path)); | |
return; | |
} | |
// 单层搜索过程 | |
for (int i = startIndex; i < candidates.length; i++) { | |
// 处理节点 | |
path.add(candidates[i]); | |
sum += candidates[i]; | |
// 递归 | |
backtracking(candidates, target, sum, i); // 注意:这里是 i,而不是 i + 1,因为可以重复选取 | |
// 回溯,撤销处理的节点 | |
path.remove(path.size() - 1); | |
sum -= candidates[i]; | |
} | |
} | |
public List<List<Integer>> combinationSum(int[] candidates, int target) { | |
backtracking(candidates, target, 0, 0); | |
return result; | |
} | |
} |
# 剪枝优化
在这个树形结构中:
以及上面的版本一的代码大家可以看到,对于 sum 已经大于 target 的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断 sum > target 的话就返回。
其实如果已经知道下一层的 sum 会大于 target,就没有必要进入下一层递归了。
那么可以在 for 循环的搜索范围上做做文章了。
对总集合排序之后,如果下一层的 sum(就是本层的 sum + candidates [i])已经大于 target,就可以结束本轮 for 循环的遍历。
如图:
for 循环剪枝代码如下:
for (int i = startIndex; i < candidates.length && sum + candidates[i] <= target; i++) |
优化后的整体 java 代码如下:
// 剪枝优化 | |
class Solution { | |
private List<List<Integer>> result = new ArrayList<>(); | |
private List<Integer> path = new ArrayList<>(); | |
private void backtracking(int[] candidates, int target, int sum, int startIndex) { | |
// 递归终止条件 | |
if (sum == target) { // 找到一组解 | |
result.add(new ArrayList<>(path)); | |
return; | |
} | |
// 单层搜索过程 | |
for (int i = startIndex; | |
i < candidates.length && sum + candidates[i] <= target; // !! 剪枝!! | |
i++) { | |
// 处理节点 | |
path.add(candidates[i]); | |
sum += candidates[i]; | |
// 递归 | |
backtracking(candidates, target, sum, i); // 注意:这里是 i,而不是 i + 1,因为可以重复选取 | |
// 回溯,撤销处理的节点 | |
path.remove(path.size() - 1); | |
sum -= candidates[i]; | |
} | |
} | |
public List<List<Integer>> combinationSum(int[] candidates, int target) { | |
Arrays.sort(candidates);// !! 对数组进行排序,方便剪枝!! | |
backtracking(candidates, target, 0, 0); | |
return result; | |
} | |
} |
# 总结
本题和我们之前讲过的 [77. 组合](#77. 组合),[216. 组合总和 III](#216. 组合总和 III) 有两点不同:
- 组合没有数量要求
- 元素可无限重复选取
针对这两个问题,我都做了详细的分析。
并且给出了对于组合问题,什么时候用 startIndex,什么时候不用,并用 [17. 电话号码的字母组合](#17. 电话号码的字母组合) 做了对比。
最后还给出了本题的剪枝优化,这个优化如果是初学者的话并不容易想到。
在求和问题中,排序之后加剪枝是常见的套路!
可以看出我写的文章都会大量引用之前的文章,就是要不断作对比,分析其差异,然后给出代码解决的方法,这样才能彻底理解题目的本质与难点。
# 40. 组合总和 II
这篇可以说是全网把组合问题如何去重,讲的最清晰的了!
# 思路
这道题目和 [39. 组合总和](#39. 组合总和) 如下区别:
- 本题 candidates 中的每个数字在每个组合中只能使用一次。
- 本题数组 candidates 的元素是有重复的,而 [39. 组合总和](#39. 组合总和) 是无重复元素的数组 candidates
最后本题和 [39. 组合总和](#39. 组合总和) 要求一样,解集不能包含重复的组合。
本题的难点在于区别 2 中:集合(数组 candidates)有重复元素,但还不能有重复的组合。
一些同学可能想了:我把所有组合求出来,再用 set 或者 map 去重,这么做很容易超时!
所以要在搜索的过程中就去掉重复组合。
很多同学在去重的问题上想不明白,其实很多题解也没有讲清楚,反正代码是能过的,感觉是那么回事,稀里糊涂的先把题目过了。
这个去重为什么很难理解呢,所谓去重,其实就是使用过的元素不能重复选取。 这么一说好像很简单!
都知道组合问题可以抽象为树形结构,那么 “使用过” 在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的 “使用过” 是造成大家没有彻底理解去重的根本原因。
那么问题来了,我们是要同一树层上使用过,还是同一树枝上使用过呢?
回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。
所以我们要去重的是同一树层上的 “使用过”,同一树枝上的都是一个组合里的元素,不用去重。
为了理解去重我们来举一个例子,candidates = [1, 1, 2], target = 3,(方便起见 candidates 已经排序了)
强调一下,树层去重的话,需要对数组排序!
选择过程树形结构如图所示:
可以看到图中,每个节点相对于 [39. 组合总和](#39. 组合总和) 我多加了 **used 数组 **,这个 used 数组下面会重点介绍。
# 回溯三部曲
参数:
与 [39. 组合总和](#39. 组合总和) 套路相同,此题还需要加一个 bool 型数组 used,用来记录同一树枝上的元素是否使用过。
这个集合去重的重任就是 used 来完成的。
private List<List<Integer>> result = new ArrayList<>();
private List<Integer> path = new ArrayList<>();
private void backtracking(int[] candidates, int target, int sum, int startIndex, boolean[] used)
递归终止条件:
与 [39. 组合总和](#39. 组合总和) 相同,终止条件为
sum > target
和sum == target
。if(sum > target) return; // 可以忽略
if(sum == target) {
result.add(new ArrayList<>(path));
return;
}
sum > target
这个条件其实可以省略,因为在递归单层遍历的时候,会有剪枝的操作,下面会介绍到。单层的搜索过程:
这里与 [39. 组合总和](#39. 组合总和) 最大的不同就是要 ** 去重 ** 了。
前面我们提到:要去重的是 “同一树层上的使用过”,如何判断同一树层上元素(相同的元素)是否使用过了呢。
如果
candidates[i] == candidates[i - 1]
并且used[i - 1] == false
,就说明:前一个树枝,使用了 candidates [i - 1],也就是说同一树层使用过 candidates [i - 1]。此时 for 循环里就应该做 continue 的操作。
这块比较抽象,如图:
我在图中将 used 的变化用橘黄色标注上,可以看出在 candidates [i] == candidates [i - 1] 相同的情况下:
- used [i - 1] == true,说明同一树枝 candidates [i - 1] 使用过
- used [i - 1] == false,说明同一树层 candidates [i - 1] 使用过
可能有的录友想,为什么 used [i - 1] == false 就是同一树层呢,因为同一树层,used [i - 1] == false 才能表示,当前取的 candidates [i] 是从 candidates [i - 1] 回溯而来的。
而 used [i - 1] == true,说明是进入下一层递归,去下一个数,所以是树枝上,如图所示:
这块去重的逻辑很抽象,网上搜的题解基本没有能讲清楚的,如果大家之前思考过这个问题或者刷过这道题目,看到这里一定会感觉通透了很多!
那么单层搜索的逻辑代码如下:
// 单层搜索的过程
for (int i = startIndex; i < candidates.length && sum + candidates[i] <= target; i++) { // 剪枝
// 去重,当 candidates [i] == candidates [i - 1] 时:
//- 如果 used [i - 1] == true,说明在上一层搜索中,candidates [i - 1] 已经被使用过了,是在同一树枝上,那么在本层搜索中,candidates [i] 也可以使用
//- 如果 used [i - 1] == false,说明在上一层搜索中,candidates [i - 1] 没有被使用过,是在同一树层上,那么在本层搜索中,candidates [i] 不能使用
if (i >= 1 && candidates[i] == candidates[i - 1] && used[i - 1] == false)
continue; // 对于同一树层上的节点,如果前一个节点和当前节点值相同,且前一个节点未使用过,那么当前节点也不能使用
// 处理节点
path.add(candidates[i]);
sum += candidates[i];
used[i] = true;
// 递归。和 39. 组合总和的区别 1:这里 startIndex 是 i + 1,每个数字在每个组合中只能使用一次
backtracking(candidates, target, sum, i + 1, used);
// 回溯,撤销处理的节点
path.remove(path.size() - 1);
sum -= candidates[i];
used[i] = false;
}
注意
sum + candidates[i] <= target
为剪枝操作,在 [39. 组合总和](#39. 组合总和) 有讲解过!
完整的 java 代码如下:
class Solution { | |
private List<List<Integer>> result = new ArrayList<>(); | |
private List<Integer> path = new ArrayList<>(); | |
private void backtracking(int[] candidates, int target, int sum, int startIndex, boolean[] used) { | |
// 递归终止条件 | |
if (sum > target) return; // 可以忽略,因为下面有剪枝 | |
if (sum == target) { | |
result.add(new ArrayList<>(path)); | |
return; | |
} | |
// 单层搜索的过程 | |
for (int i = startIndex; i < candidates.length && sum + candidates[i] <= target; i++) { // 剪枝 | |
// 去重,当 candidates [i] == candidates [i - 1] 时: | |
//- 如果 used [i - 1] == true,说明在上一层搜索中,candidates [i - 1] 已经被使用过了,是在同一树枝上,那么在本层搜索中,candidates [i] 也可以使用 | |
//- 如果 used [i - 1] == false,说明在上一层搜索中,candidates [i - 1] 没有被使用过,是在同一树层上,那么在本层搜索中,candidates [i] 不能使用 | |
if (i >= 1 && candidates[i] == candidates[i - 1] && used[i - 1] == false) | |
continue; // 对于同一树层上的节点,如果前一个节点和当前节点值相同,且前一个节点未使用过,那么当前节点也不能使用 | |
// 处理节点 | |
path.add(candidates[i]); | |
sum += candidates[i]; | |
used[i] = true; | |
// 递归。和 39. 组合总和的区别 1:这里 startIndex 是 i + 1,每个数字在每个组合中只能使用一次 | |
backtracking(candidates, target, sum, i + 1, used); | |
// 回溯,撤销处理的节点 | |
path.remove(path.size() - 1); | |
sum -= candidates[i]; | |
used[i] = false; | |
} | |
} | |
public List<List<Integer>> combinationSum2(int[] candidates, int target) { | |
boolean[] used = new boolean[candidates.length]; // 记录 candidates 中的元素是否被使用过 | |
Arrays.sort(candidates); // 排序,让相同的元素相邻,方便去重 | |
backtracking(candidates, target, 0, 0, used); | |
return result; | |
} | |
} |
# 总结
本题同样是求组合总和,但就是因为其数组 candidates 有重复元素,而要求不能有重复的组合,所以相对于 [39. 组合总和](#39. 组合总和) 难度提升了不少。
关键是去重的逻辑,代码很简单,网上一搜一大把,但几乎没有能把这块代码含义讲明白的,基本都是给出代码,然后说这就是去重了,究竟怎么个去重法也是模棱两可。
所以 Carl 有必要把去重的这块彻彻底底的给大家讲清楚,就连 “树层去重” 和 “树枝去重” 都是我自创的词汇,希望对大家理解有帮助!
# ☆131. 分割回文串
切割问题其实是一种组合问题!
# 思路
本题这涉及到两个关键问题:
- 切割问题,有不同的切割方式
- 判断回文
相信这里不同的切割方式可以搞懵很多同学了。
这种题目,想用 for 循环暴力解法,可能都不那么容易写出来,所以要换一种暴力的方式,就是回溯。
一些同学可能想不清楚 回溯究竟是如何切割字符串呢?
我们来分析一下切割,其实切割问题类似组合问题。
例如对于字符串 abcdef:
- 组合问题:选取一个 a 之后,在 bcdef 中再去选取第二个,选取 b 之后在 cdef 中再选取第三个.....。
- 切割问题:切割一个 a 之后,在 bcdef 中再去切割第二段,切割 b 之后在 cdef 中再切割第三段.....。
感受出来了不?
所以切割问题,也可以抽象为一棵树形结构,如图:
递归用来纵向遍历,for 循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。
此时可以发现,切割问题的回溯搜索的过程和组合问题的回溯搜索的过程是差不多的。
# 回溯三部曲
参数:
全局变量数组 path 存放切割后回文的子串,二维数组 result 存放结果集。 (这两个参数可以放到函数参数里)
本题递归函数参数还需要 startIndex,因为切割过的地方,不能重复切割,和组合问题也是保持一致的。
private List<List<String>> result = new ArrayList<>();
private List<String> path = new ArrayList<>();
private void backtracking(String s, int startIndex)
递归终止条件:
从树形结构的图中可以看出:切割线切到了字符串最后面,说明找到了一种切割方法,此时就是本层递归的终止条件。
那么在代码里什么是切割线呢?
在处理组合问题的时候,递归参数需要传入 startIndex,表示下一轮递归遍历的起始位置,这个 startIndex 就是切割线。
// 递归终止条件:startIndex 超过字符串长度
if(startIndex >= s.length()) {
result.add(new ArrayList<>(path));
return;
}
单层搜索的过程:
来看看在递归循环中如何截取子串呢?
在
for (int i = startIndex; i < s.size(); i++)
循环中,我们 定义了起始位置 startIndex,那么 [startIndex, i] 就是要截取的子串。首先判断这个子串是不是回文,如果是回文,就加入在
vector<string> path
中,path 用来记录切割过的回文子串。// 单层搜索过程
for (int i = startIndex; i < s.length(); i++) {
// 判断 [startIndex, i] 是否为回文串
if (!isPalindrome(s, startIndex, i)) {
continue; // 不是回文串,跳过
} else { // 是回文串
// 处理节点
path.add(s.substring(startIndex, i + 1));
// 递归
backtracking(s, i + 1);
// 回溯
path.remove(path.size() - 1);
}
}
注意切割过的位置,不能重复切割,所以,backtracking (s, i + 1); 传入下一层的起始位置为 i + 1。
# 判断回文子串
最后我们看一下回文子串要如何判断了,判断一个字符串是否是回文。
可以使用双指针法,一个指针从前向后,一个指针从后向前,如果前后指针所指向的元素是相等的,就是回文字符串了。
// 判断子串 s [start,end] 是否是回文串 | |
private boolean isPalindrome(String s, int start, int end) { | |
// 双指针法 | |
for (int i = start, j = end; i < j; i++, j--) { | |
if (s.charAt(i) != s.charAt(j)) | |
return false; | |
} | |
return true; | |
} |
# java 完整代码
class Solution { | |
private List<List<String>> result = new ArrayList<>(); | |
private List<String> path = new ArrayList<>(); | |
// 判断子串 s [start,end] 是否是回文串 | |
private boolean isPalindrome(String s, int start, int end) { | |
// 双指针法 | |
for (int i = start, j = end; i < j; i++, j--) { | |
if (s.charAt(i) != s.charAt(j)) | |
return false; | |
} | |
return true; | |
} | |
private void backtracking(String s, int startIndex) { | |
// 递归终止条件:startIndex 超过字符串长度 | |
if (startIndex >= s.length()) { | |
result.add(new ArrayList<>(path)); | |
return; | |
} | |
// 单层搜索过程 | |
for (int i = startIndex; i < s.length(); i++) { | |
// 判断 [startIndex, i] 是否为回文串 | |
if (!isPalindrome(s, startIndex, i)) { | |
continue; // 不是回文串,跳过 | |
} else { // 是回文串 | |
// 处理节点 | |
path.add(s.substring(startIndex, i + 1)); | |
// 递归 | |
backtracking(s, i + 1); | |
// 回溯 | |
path.remove(path.size() - 1); | |
} | |
} | |
} | |
public List<List<String>> partition(String s) { | |
backtracking(s, 0); | |
return result; | |
} | |
} |
# 优化:动态规划
上面的代码还存在一定的优化空间,在于如何更高效的计算一个子字符串是否是回文字串。上述代码 isPalindrome
函数运用双指针的方法来判定对于一个字符串 s
, 给定起始下标和终止下标,截取出的子字符串是否是回文字串。但是其中有一定的重复计算存在:
例如给定字符串 "abcde"
, 在已知 "bcd"
不是回文字串时,不再需要去双指针操作 "abcde"
而可以直接判定它一定不是回文字串。
具体来说,给定一个字符串 s
, 长度为 n
, 它成为回文字串的充分必要条件是 s[0] == s[n-1]
且 s[1:n-1]
是回文字串。
大家如果熟悉动态规划这种算法的话,我们可以高效地事先一次性计算出,针对一个字符串 s
, 它的任何子串是否是回文字串,然后在我们的回溯函数中直接查询即可,省去了双指针移动判定这一步骤.
具体参考代码如下:
// 回溯 + 动态规划(事先计算好回文串) | |
class Solution { | |
private List<List<String>> result = new ArrayList<>(); | |
private List<String> path = new ArrayList<>(); | |
private boolean[][] dp; //dp [i][j] 表示 s [i:j] 是否为回文串 | |
private void computePalindrome(String s) { | |
dp = new boolean[s.length()][s.length()]; // 初始化 | |
for (int i = s.length() - 1; i >= 0; i--) { // 倒序遍历,保证 dp [i][j] 依赖的 dp [i+1][j-1] 已经计算过 | |
for (int j = i; j < s.length(); j++) { //i 从后往前,j 从前往后 | |
if (j == i) // 只有一个字符,肯定是回文串 | |
dp[i][j] = true; | |
else if (j == i + 1) // 两个字符,判断两个字符是否相等 | |
dp[i][j] = (s.charAt(i) == s.charAt(j)); | |
else // 三个及以上字符,判断首尾字符是否相等,且去掉首尾字符的子串是否为回文串 | |
dp[i][j] = (s.charAt(i) == s.charAt(j) && dp[i + 1][j - 1]); | |
} | |
} | |
} | |
private void backtracking(String s, int startIndex) { | |
// 递归终止条件:startIndex 超过字符串长度 | |
if (startIndex >= s.length()) { | |
result.add(new ArrayList<>(path)); | |
return; | |
} | |
// 单层搜索过程 | |
for (int i = startIndex; i < s.length(); i++) { | |
// 判断 [startIndex, i] 是否为回文串 | |
if (!dp[startIndex][i]) { | |
continue; // 不是回文串,跳过 | |
} else { // 是回文串 | |
// 处理节点 | |
path.add(s.substring(startIndex, i + 1)); | |
// 递归 | |
backtracking(s, i + 1); | |
// 回溯 | |
path.remove(path.size() - 1); | |
} | |
} | |
} | |
public List<List<String>> partition(String s) { | |
computePalindrome(s); | |
backtracking(s, 0); | |
return result; | |
} | |
} |
# 总结
这道题目在 leetcode 上是中等,但可以说是 hard 的题目了,但是代码其实就是按照模板的样子来的。
那么难究竟难在什么地方呢?
我列出如下几个难点:
切割问题可以抽象为组合问题
如何模拟那些切割线
startIndex
切割问题中递归如何终止
if (startIndex >= s.length())
在递归循环中如何截取子串
如何判断回文
我们平时在做难题的时候,总结出来难究竟难在哪里也是一种需要锻炼的能力。
一些同学可能遇到题目比较难,但是不知道题目难在哪里,反正就是很难。其实这样还是思维不够清晰,这种总结的能力需要多接触多锻炼。
本题我相信很多同学主要卡在了第一个难点上:就是不知道如何切割,甚至知道要用回溯法,也不知道如何用。也就是没有体会到按照求组合问题的套路就可以解决切割。
如果意识到这一点,算是重大突破了。接下来就可以对着模板照葫芦画瓢。
但接下来如何模拟切割线,如何终止,如何截取子串,其实都不好想,最后判断回文算是最简单的了。
关于模拟切割线,其实就是 index 是上一层已经确定了的分割线,i 是这一层试图寻找的新分割线。
除了这些难点,本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入 i + 1。
所以本题应该是一道 hard 题目了。
可能刷过这道题目的录友都没感受到自己原来克服了这么多难点,就把这道题目 AC 了,这应该叫做无招胜有招,人码合一,哈哈哈。
# ☆93. 复原 IP 地址
其实只要意识到这是切割问题,切割问题就可以使用回溯搜索法把所有可能性搜出来,和刚做过的 [☆131. 分割回文串](#☆131. 分割回文串) 就十分类似了。
切割问题可以抽象为树型结构,如图:
# 回溯三部曲
参数:
在 [☆131. 分割回文串](#☆131. 分割回文串) 中我们就提到切割问题类似组合问题。
startIndex 一定是需要的,因为不能重复分割,记录下一层递归分割的起始位置。
本题我们还需要一个变量 pointNum,记录添加逗点的数量。
private List<String> result = new ArrayList<>();
private void backtracking(String s, int startIndex, int pointNum)
递归终止条件:
终止条件和 [☆131. 分割回文串](#☆131. 分割回文串) 情况就不同了,本题明确要求分成 4 段,所以不能用切割线切到最后作为终止条件,而是分割的段数作为终止条件。
pointNum 表示逗点数量,pointNum 为 3 说明字符串分成了 4 段了。
然后验证一下第四段是否合法,如果合法就加入到结果集里
// 递归终止条件:因为要求分为 4 段,所以当逗号个数为 3 时,递归终止
if (pointNum == 3) {
// 判断第四段子串是否合法
if (isValid(s, startIndex, s.length() - 1)) {
result.add(s); // 合法则添加到结果集
}
return;
}
单层搜索的过程:
在 [☆131. 分割回文串](#☆131. 分割回文串) 中已经讲过在循环遍历中如何截取子串。
在
for (int i = startIndex; i < s.size(); i++)
循环中 [startIndex, i] 这个区间就是截取的子串,需要判断这个子串是否合法。如果合法就在字符串后面加上符号
.
表示已经分割。如果不合法就结束本层循环,如图中剪掉的分支:
然后就是递归和回溯的过程:
递归调用时,下一层递归的 startIndex 要从 i+2 开始(因为需要在字符串中加入了分隔符
.
),同时记录分割符的数量 pointNum 要 +1。回溯的时候,就将刚刚加入的分隔符
.
删掉就可以了,pointNum 也要 - 1。
// 单层搜索过程
for (int i = startIndex; i < s.length(); i++) {
if (isValid(s, startIndex, i)) { // 子串 s [startIndex, i] 合法
// 处理节点:添加逗号
s = s.substring(0, i + 1) + "." + s.substring(i + 1);
pointNum++;
// 递归
backtracking(s, i + 2, pointNum); //i+2 是因为添加了一个逗号
// 回溯:删除逗号
s = s.substring(0, i + 1) + s.substring(i + 2);
pointNum--;
} else { // 子串 s [startIndex, i] 不合法,直接跳出循环,因为后面的子串更不合法
break;
}
}
# 判断子串是否合法
最后就是在写一个判断段位是否是有效段位了。
主要考虑到如下三点:
- 段位以 0 为开头的数字不合法
- 段位里有非正整数字符不合法
- 段位如果大于 255 了不合法
代码如下:
private boolean isValid(String s, int start, int end) { | |
// 字符区间不合法 | |
if (start > end) return false; | |
// 以 0 开头的,长度超过 1 的字符串不合法 | |
if (s.charAt(start) == '0' && start != end) return false; | |
// 转换成数字 | |
int num = 0; | |
for (int i = start; i <= end; i++) { | |
// 非数字字符不合法 | |
if (s.charAt(i) < '0' || s.charAt(i) > '9') return false; | |
num = num * 10 + (s.charAt(i) - '0'); // 逐位转换成数字 | |
// 数字超过 255 不合法 | |
if (num > 255) return false; | |
} | |
return true; | |
} |
# java 完整代码
class Solution { | |
private List<String> result = new ArrayList<>(); | |
private boolean isValid(String s, int start, int end) { | |
// 字符区间不合法 | |
if (start > end) return false; | |
// 以 0 开头的,长度超过 1 的字符串不合法 | |
if (s.charAt(start) == '0' && start != end) return false; | |
// 转换成数字 | |
int num = 0; | |
for (int i = start; i <= end; i++) { | |
// 非数字字符不合法 | |
if (s.charAt(i) < '0' || s.charAt(i) > '9') return false; | |
num = num * 10 + (s.charAt(i) - '0'); // 逐位转换成数字 | |
// 数字超过 255 不合法 | |
if (num > 255) return false; | |
} | |
return true; | |
} | |
// 回溯:s 表示剩余字符串,startIndex 表示当前处理的起始位置,pointNum 记录添加逗号的个数 | |
private void backtracking(String s, int startIndex, int pointNum) { | |
// 递归终止条件:因为要求分为 4 段,所以当逗号个数为 3 时,递归终止 | |
if (pointNum == 3) { | |
// 判断第四段子串是否合法 | |
if (isValid(s, startIndex, s.length() - 1)) { | |
result.add(s); // 合法则添加到结果集 | |
} | |
return; | |
} | |
// 单层搜索过程 | |
for (int i = startIndex; i < s.length(); i++) { | |
if (isValid(s, startIndex, i)) { // 子串 s [startIndex, i] 合法 | |
// 处理节点:添加逗号 | |
s = s.substring(0, i + 1) + "." + s.substring(i + 1); | |
pointNum++; | |
// 递归 | |
backtracking(s, i + 2, pointNum); //i+2 是因为添加了一个逗号 | |
// 回溯:删除逗号 | |
s = s.substring(0, i + 1) + s.substring(i + 2); | |
pointNum--; | |
} else { // 子串 s [startIndex, i] 不合法,直接跳出循环,因为后面的子串更不合法 | |
break; | |
} | |
} | |
} | |
public List<String> restoreIpAddresses(String s) { | |
// 特判:字符串长度不合法 | |
if (s.length() < 4 || s.length() > 12) return result; | |
backtracking(s, 0, 0); | |
return result; | |
} | |
} |
# 总结
在 [☆131. 分割回文串](#☆131. 分割回文串) 中我列举的分割字符串的难点,本题都覆盖了。
而且本题还需要操作字符串添加逗号作为分隔符,并验证区间的合法性。
可以说是 [☆131. 分割回文串](#☆131. 分割回文串) 的加强版。
在本文的树形结构图中,我已经把详细的分析思路都画了出来,相信大家看了之后一定会思路清晰不少!
# 78. 子集
# 思路
求子集问题和 [77. 组合](#77. 组合) 和 [☆131. 分割回文串](#☆131. 分割回文串) 又不一样了。
如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!
其实子集也是一种组合问题,因为它的集合是无序的,子集 {1,2} 和 子集 {2,1} 是一样的。
那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for 就要从 startIndex 开始,而不是从 0 开始!
有同学问了,什么时候 for 可以从 0 开始呢?
求排列问题的时候,就要从 0 开始,因为集合是有序的,{1, 2} 和 {2, 1} 是两个集合,排列问题我们后续的文章就会讲到的。
以示例中 nums = [1,2,3] 为例把求子集抽象为树型结构,如下:
从图中红线部分,可以看出遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合。
# 回溯三部曲
参数:
两个全局变量,数组 path 为子集收集元素,二维数组 result 存放子集组合。(也可以放到递归函数参数里)
递归函数参数在上面讲到了,需要 startIndex。
private List<List<Integer>> res = new ArrayList<>();
private List<Integer> path = new ArrayList<>();
private void backtracking(int[] nums, int startIndex)
递归终止条件:
从图中可以看出:
剩余集合为空的时候,就是叶子节点。
那么什么时候剩余集合为空呢?
就是 startIndex 已经大于数组的长度了,就终止了,因为没有元素可取了。
其实可以不需要加终止条件,因为 startIndex >= nums.size (),本层 for 循环本来也结束了,不需要剪枝
if (startIndex >= nums.length) {
return;
}
单层搜索的逻辑:
求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。
那么单层递归逻辑代码如下:
for (int i = startIndex; i < nums.length; i++) {
// 处理节点
path.add(nums[i]);
// 递归
backtracking(nums, i + 1); //i+1 是因为集合的无序性,要求不重复
// 回溯,撤销处理的节点
path.remove(path.size() - 1);
}
# java 完整代码
class Solution { | |
private List<List<Integer>> res = new ArrayList<>(); | |
private List<Integer> path = new ArrayList<>(); | |
private void backtracking(int[] nums, int startIndex) { | |
// !!! 收集子集,要放在终止条件的上面,否则会漏掉自己!!! | |
res.add(new ArrayList<>(path)); | |
// 递归终止条件(不需要,因为没有剪枝) | |
if (startIndex >= nums.length) { | |
return; | |
} | |
// 单层搜索的逻辑 | |
for (int i = startIndex; i < nums.length; i++) { | |
// 处理节点 | |
path.add(nums[i]); | |
// 递归 | |
backtracking(nums, i + 1); //i+1 是因为集合的无序性,要求不重复 | |
// 回溯,撤销处理的节点 | |
path.remove(path.size() - 1); | |
} | |
} | |
public List<List<Integer>> subsets(int[] nums) { | |
backtracking(nums, 0); | |
return res; | |
} | |
} |
在注释中,可以发现可以不写终止条件,因为本来我们就要遍历整棵树。
有的同学可能担心不写终止条件会不会无限递归?
并不会,因为每次递归的下一层就是从 i+1 开始的。
# 总结
相信大家经过了
- 组合问题:
- [77. 组合](#77. 组合)
- [77. 组合优化](#77. 组合优化)
- [216. 组合总和 III](#216. 组合总和 III)
- [17. 电话号码的字母组合](#17. 电话号码的字母组合)
- [39. 组合总和](#39. 组合总和)
- [40. 组合总和 II](#40. 组合总和 II)
- 分割问题:
- [☆131. 分割回文串](#☆131. 分割回文串)
- [☆93. 复原 IP 地址](#☆93. 复原 IP 地址)
洗礼之后,发现子集问题还真的有点简单了,其实这就是一道标准的模板题。
但是要清楚子集问题和组合问题、分割问题的的区别,
子集是收集树形结构中树的所有节点的结果
在递归终止条件前要收集结果
组合问题、分割问题是收集树形结构中叶子节点的结果
在递归终止条件中收集结果
# 本周小结!(回溯算法系列二)
# 周一
在 [39. 组合总和](#39. 组合总和) 中讲解的组合总和问题,和以前的组合问题还都不一样。
本题和 [77. 组合](#77. 组合),[216. 组合总和 III](#216. 组合总和 III) 和区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
不少录友都是看到可以重复选择,就义无反顾的把 startIndex 去掉了。
本题还需要 startIndex 来控制 for 循环的起始位置,对于组合问题,什么时候需要 startIndex 呢?
如果是一个集合来求组合的话,就需要 startIndex,例如:[77. 组合](#77. 组合),[216. 组合总和 III](#216. 组合总和 III)。
如果是多个集合取组合,各个集合之间相互不影响,那么就不用 startIndex,例如:[17. 电话号码的字母组合](#17. 电话号码的字母组合)
注意以上我只是说求组合的情况,如果是排列问题,又是另一套分析的套路,后面我再讲解排列的时候就重点介绍。
最后还给出了本题的剪枝优化,如下:
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) |
可以避免进入无意义的递归
这个优化如果是初学者的话并不容易想到。
在求和问题中,排序之后加剪枝是常见的套路!
在 [39. 组合总和](#39. 组合总和) 第一个树形结构没有画出 startIndex 的作用,这里这里纠正一下,准确的树形结构如图所示:
# 周二
在 [40. 组合总和 II](#40. 组合总和 II) 中依旧讲解组合总和问题,本题集合元素会有重复,但要求解集不能包含重复的组合。
所以难就难在去重问题上了。
这个去重问题,相信做过的录友都知道有多么的晦涩难懂。网上的题解一般就说 “去掉重复”,但说不清怎么个去重,代码一甩就完事了。
为了讲解这个去重问题,我自创了两个词汇,“树枝去重” 和 “树层去重”。
都知道组合问题可以抽象为树形结构,那么 “使用过” 在这个树形结构上是有两个维度的,
- 一个维度是同一树枝上 “使用过”
- 一个维度是同一树层上 “使用过”
没有理解这两个层面上的 “使用过” 是造成大家没有彻底理解去重的根本原因。
我在图中将 used 的变化用橘黄色标注上,可以看出在 candidates[i] == candidates[i - 1]
相同的情况下:
used[i - 1] == true
,说明同一树枝 candidates [i - 1] 使用过,可以重复选取used[i - 1] == false
,说明同一树层 candidates [i - 1] 使用过,不可重复选取
这块去重的逻辑很抽象,网上搜的题解基本没有能讲清楚的,如果大家之前思考过这个问题或者刷过这道题目,看到这里一定会感觉通透了很多!
对于去重,其实排列问题也是一样的道理,后面我会讲到。
# 周三
在 [☆131. 分割回文串](#☆131. 分割回文串) 中,我们开始讲解切割问题,虽然最后代码看起来好像是一道模板题,但是从分析到学会套用这个模板,是比较难的。
我列出如下几个难点:
- 切割问题其实类似组合问题
- 如何模拟那些切割线
- 切割问题中递归如何终止
- 在递归循环中如何截取子串
- 如何判断回文
如果想到了用求解组合问题的思路来解决 切割问题本题就成功一大半了,接下来就可以对着模板照葫芦画瓢。
但后续如何模拟切割线,如何终止,如何截取子串,其实都不好想,最后判断回文算是最简单的了。
除了这些难点,本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入 i + 1。
所以本题应该是一个道 hard 题目了。
本题的树形结构中,和代码的逻辑有一个小出入,已经判断不是回文的子串就不会进入递归了,纠正如下:
# 周四
如果没有做过 [☆131. 分割回文串](#☆131. 分割回文串) 的话,[☆93. 复原 IP 地址](#☆93. 复原 IP 地址) 这道题目应该是比较难的。
复原 IP 照 [☆131. 分割回文串](#☆131. 分割回文串) 就多了一些限制,例如只能分四段,而且还是更改字符串,插入逗点。
树形图如下:
在本文的树形结构图中,我已经把详细的分析思路都画了出来,相信大家看了之后一定会思路清晰不少!
本题还可以有一个剪枝,合法 ip 长度为 12,如果 s 的长度超过了 12 就不是有效 IP 地址,直接返回!
代码如下:
if (s.size() > 12) return result; // 剪枝 |
我之前给出 C++ 代码没有加这个限制,也没有超时,因为在第四段超过长度之后,就会截止了,所以就算给出特别长的字符串,搜索的范围也是有限的(递归只会到第三层),及时就会返回了。
# 周五
在 [78. 子集](#78. 子集) 中讲解了子集问题,在树形结构中子集问题是要收集所有节点的结果,而组合问题是收集叶子节点的结果。
如图:
认清这个本质之后,今天的题目就是一道模板题了。
其实可以不需要加终止条件,因为 startIndex >= nums.size (),本层 for 循环本来也结束了,本来我们就要遍历整棵树。
有的同学可能担心不写终止条件会不会无限递归?
并不会,因为每次递归的下一层就是从 i+1 开始的。
如果要写终止条件,注意: result.push_back(path);
要放在终止条件的上面,如下:
result.push_back(path); // 收集子集,要放在终止添加的上面,否则会漏掉自己 | |
if (startIndex >= nums.size()) { // 终止条件可以不加 | |
return; | |
} |
# 总结
本周我们依次介绍了组合问题,分割问题以及子集问题,子集问题还没有讲完,下周还会继续。
我讲解每一种问题,都会和其他问题作对比,做分析,所以只要跟着细心琢磨相信对回溯又有新的认识。
最近这两天题目有点难度,刚刚开始学回溯算法的话,按照现在这个每天一题的速度来,确实有点快,学起来吃力非常正常,这些题目都是我当初学了好几个月才整明白的,哈哈。
所以大家能跟上的话,已经很优秀了!
还有一些录友会很关心 leetcode 上的耗时统计。
这个是很不准确的,相同的代码多提交几次,大家就知道怎么回事了。
leetcode 上的计时应该是以 4ms 为单位,有的多提交几次,多个 4ms 就多击败 50%,所以比较夸张,如果程序运行是几百 ms 的级别,可以看看 leetcode 上的耗时,因为它的误差 10 几 ms 对最终影响不大。
所以我的题解基本不会写击败百分之多少多少,没啥意义,时间复杂度分析清楚了就可以了,至于回溯算法不用分析时间复杂度了,都是一样的爆搜,就看谁剪枝厉害了。
# 90. 子集 II
如何去重?used 数组!
做本题之前一定要先做 [78. 子集](#78. 子集)。
这道题目和 [78. 子集](#78. 子集) 区别就是集合里有重复元素了,而且求取的子集要去重。
那么关于回溯算法中的去重问题,在 [40. 组合总和 II](#40. 组合总和 II) 中已经详细讲解过了,和本题是一个套路。
剧透一下,后期要讲解的排列问题里去重也是这个套路,所以理解 “树层去重” 和 “树枝去重” 非常重要。
用示例中的 [1, 2, 2] 来举例,如图所示: (注意去重需要先对集合排序)
从图中可以看出,同一树层上重复取 2 就要过滤掉,同一树枝上就可以重复取 2,因为同一树枝上元素的集合才是唯一子集!
本题就是其实就是 [78. 子集](#78. 子集) 的基础上加上了去重,去重我们在 [40. 组合总和 II](#40. 组合总和 II) 也讲过了,所以我就直接给出代码了:
class Solution { | |
private List<List<Integer>> res = new ArrayList<>(); | |
private List<Integer> path = new ArrayList<>(); | |
private void backtracking(int[] nums, int startIndex, boolean[] used) { | |
// 在递归终止条件之前,就需要添加结果,因为需要对所有节点进行遍历,而不是只遍历叶子节点 | |
res.add(new ArrayList<>(path)); | |
// 不需要递归终止条件,因为需要遍历整棵树,不需要剪枝 | |
// 单层搜索的逻辑 | |
for (int i = startIndex; i < nums.length; i++) { | |
// 去重:对同一树层上的元素,如果前一个元素和当前元素相同,且前一个元素未被使用过,那么当前元素也不应该被使用 | |
if (i >= 1 && nums[i] == nums[i - 1] && used[i - 1] == false) | |
continue; | |
// 处理节点 | |
path.add(nums[i]); | |
used[i] = true; | |
// 递归 | |
backtracking(nums, i + 1, used); //i+1 是因为不能重复使用同一个元素 | |
// 回溯,撤销处理的节点 | |
path.remove(path.size() - 1); | |
used[i] = false; | |
} | |
} | |
public List<List<Integer>> subsetsWithDup(int[] nums) { | |
// 排序是剪枝(去重)的前提,因为排序后相同的元素会相邻 | |
Arrays.sort(nums); | |
//used 数组记录元素是否被使用过,用于剪枝(去重) | |
boolean[] used = new boolean[nums.length]; | |
backtracking(nums, 0, used); | |
return res; | |
} | |
} |
# 补充
本题也可以不使用 used 数组来去重,因为递归的时候下一个 startIndex 是 i+1 而不是 0。
如果要是全排列的话,每次要从 0 开始遍历,为了跳过已入栈的元素,需要使用 used。
代码如下:
class Solution { | |
private List<List<Integer>> res = new ArrayList<>(); | |
private List<Integer> path = new ArrayList<>(); | |
private void backtracking(int[] nums, int startIndex) { | |
// 在递归终止条件之前,就需要添加结果,因为需要对所有节点进行遍历,而不是只遍历叶子节点 | |
res.add(new ArrayList<>(path)); | |
// 不需要递归终止条件,因为需要遍历整棵树,不需要剪枝 | |
// 单层搜索的逻辑 | |
for (int i = startIndex; i < nums.length; i++) { | |
// 去重:对同一树层上的元素,如果前一个元素和当前元素相同,且前一个元素未被使用过,那么当前元素也不应该被使用 | |
if (i > startIndex && nums[i] == nums[i - 1]) | |
continue; | |
// 处理节点 | |
path.add(nums[i]); | |
// 递归 | |
backtracking(nums, i + 1); //i+1 是因为不能重复使用同一个元素 | |
// 回溯,撤销处理的节点 | |
path.remove(path.size() - 1); | |
} | |
} | |
public List<List<Integer>> subsetsWithDup(int[] nums) { | |
// 排序是剪枝(去重)的前提,因为排序后相同的元素会相邻 | |
Arrays.sort(nums); | |
backtracking(nums, 0); | |
return res; | |
} | |
} |
# 总结
其实这道题目的知识点,我们之前都讲过了,如果之前讲过的子集问题和去重问题都掌握的好,这道题目应该分分钟 AC。
正如补充中所说,本题去重的逻辑,也可以这么写
if (i > startIndex && nums[i] == nums[i - 1]) | |
continue; |
# 491. 递增子序列
和子集问题有点像,但又处处是陷阱
- 输入的是单个集合数组
- 不可重复取元素
- 数组中有重复元素,但不可重复取(需要去重)
# 思路
这个递增子序列比较像是取有序的子集。而且本题也要求不能有相同的递增子序列。
这又是子集,又是去重,是不是不由自主的想起了刚刚讲过的 [90. 子集 II](#90. 子集 II)。
就是因为太像了,更要注意差别所在,要不就掉坑里了!
在 [90. 子集 II](#90. 子集 II) 中我们是通过排序(让重复的元素相邻),再加一个标记数组 used 来达到去重的目的。
而本题求自增子序列,是不能对原数组进行排序的,排完序的数组都是自增子序列了。
所以不能使用之前的去重逻辑!
用 Set
本题给出的示例,还是一个有序数组 [4, 6, 7, 7],这更容易误导大家按照排序的思路去做了。
为了有鲜明的对比,我用 [4, 7, 6, 7] 这个数组来举例,抽象为树形结构如图:
# 回溯三部曲
参数:
- 两个全局变量。
- 一个元素不能重复使用,所以需要 startIndex,调整下一层递归的起始位置。
private List<List<Integer>> res = new ArrayList<>();
private List<Integer> path = new ArrayList<>();
private void backtracking(int[] nums, int startIndex)
递归终止条件:
本题其实类似求子集问题,也是要遍历树形结构找每一个节点,所以和 [78. 子集](#78. 子集) 一样,可以不加终止条件,startIndex 每次都会加 1,并不会无限递归。
但本题收集结果有所不同,题目要求递增子序列大小至少为 2,所以代码如下:
// 收集结果
if (path.size() >= 2) // 题目要求至少两个元素
res.add(new ArrayList<>(path));
单层搜索的逻辑:
在图中可以看出,同一父节点下的同层上使用过的元素就不能再使用了
// 单层搜索的逻辑
Set<Integer> usedSet = new HashSet<>(); // 用于去重
for (int i = startIndex; i < nums.length; i++) {
// 剪枝(去重):对于同一层,如果当前元素已经被使用过,就不再使用
if (
(!path.isEmpty() && nums[i] < path.get(path.size() - 1)) // 不满足递增条件
|| usedSet.contains(nums[i]) // 当前元素已经被使用过
)
continue;
usedSet.add(nums[i]); // 记录当前元素已经被使用过
// 处理节点
path.add(nums[i]);
// 递归
backtracking(nums, i + 1);
// 回溯,撤销处理的节点
path.remove(path.size() - 1);
}
对于已经习惯写回溯的同学,看到递归函数上面的
uset.insert(nums[i]);
,下面却没有对应的 pop 之类的操作,应该很不习惯吧,哈哈这也是需要注意的点,
unordered_set<int> uset;
是记录本层元素是否重复使用,新的一层 uset 都会重新定义(清空),所以要知道 uset 只负责本层!
java 整体代码:
class Solution { | |
private List<List<Integer>> res = new ArrayList<>(); | |
private List<Integer> path = new ArrayList<>(); | |
private void backtracking(int[] nums, int startIndex) { | |
// 收集结果 | |
if (path.size() >= 2) // 题目要求至少两个元素 | |
res.add(new ArrayList<>(path)); | |
// 不需要递归终止条件,因为题目要求返回所有结果 | |
// 单层搜索的逻辑 | |
Set<Integer> usedSet = new HashSet<>(); // 用于去重 | |
for (int i = startIndex; i < nums.length; i++) { | |
// 剪枝(去重):对于同一层,如果当前元素已经被使用过,就不再使用 | |
if ( | |
(!path.isEmpty() && nums[i] < path.get(path.size() - 1)) // 不满足递增条件 | |
|| usedSet.contains(nums[i]) // 当前元素已经被使用过 | |
) | |
continue; | |
usedSet.add(nums[i]); // 记录当前元素已经被使用过 | |
// 处理节点 | |
path.add(nums[i]); | |
// 递归 | |
backtracking(nums, i + 1); | |
// 回溯,撤销处理的节点 | |
path.remove(path.size() - 1); | |
} | |
} | |
public List<List<Integer>> findSubsequences(int[] nums) { | |
backtracking(nums, 0); | |
return res; | |
} | |
} |
# 优化
以上代码用我用了 Set<Integer>
来记录本层元素是否重复使用。
其实用数组来做哈希,效率就高了很多。
注意题目中说了,数值范围 [-100,100],所以完全可以用数组来做哈希。
程序运行的时候对 unordered_set 频繁的 insert,unordered_set 需要做哈希映射(也就是把 key 通过 hash function 映射为唯一的哈希值)相对费时间,而且每次重新定义 set,insert 的时候其底层的符号表也要做相应的扩充,也是费事的。
那么优化后的代码如下:
// 使用数组来做哈希表,而不是 Set。 | |
// 因为题意表明各个数值的范围是 [-100, 100],所以可以使用一个长度为 201 的数组来做哈希表 | |
class Solution { | |
private List<List<Integer>> res = new ArrayList<>(); | |
private List<Integer> path = new ArrayList<>(); | |
private void backtracking(int[] nums, int startIndex) { | |
// 收集结果 | |
if (path.size() >= 2) // 题目要求至少两个元素 | |
res.add(new ArrayList<>(path)); | |
// 不需要递归终止条件,因为题目要求返回所有结果 | |
// 单层搜索的逻辑 | |
int[] used = new int[201]; // 使用数组来做哈希表,用于去重,题意表明各个数值的范围是 [-100, 100] | |
for (int i = startIndex; i < nums.length; i++) { | |
// 剪枝(去重):对于同一层,如果当前元素已经被使用过,就不再使用 | |
if ( | |
(!path.isEmpty() && nums[i] < path.get(path.size() - 1)) // 不满足递增条件 | |
|| used[nums[i] + 100] == 1 // 当前元素已经被使用过 | |
) | |
continue; | |
used[nums[i] + 100] = 1; // 标记当前元素已经被使用过,+100 是为了避免负数下标 | |
// 处理节点 | |
path.add(nums[i]); | |
// 递归 | |
backtracking(nums, i + 1); | |
// 回溯,撤销处理的节点 | |
path.remove(path.size() - 1); | |
} | |
} | |
public List<List<Integer>> findSubsequences(int[] nums) { | |
backtracking(nums, 0); | |
return res; | |
} | |
} |
这份代码在 leetcode 上提交,要比版本一耗时要好的多。
所以正如在哈希表:总结篇!中说的那样,数组,set,map 都可以做哈希表,而且数组干的活,map 和 set 都能干,但如果数值范围小的话能用数组尽量用数组。
# 总结
本题题解清一色都说是深度优先搜索,但我更倾向于说它用回溯法,而且本题我也是完全使用回溯法的逻辑来分析的。
相信大家在本题中处处都能看到是 [90. 子集 II](#90. 子集 II) 的身影,但处处又都是陷阱。
对于养成思维定式或者套模板套嗨了的同学,这道题起到了很好的警醒作用。更重要的是拓展了大家的思路!
# 46. 全排列
此时我们已经学习了 [77. 组合](#77. 组合)、 [☆131. 分割回文串](#☆131. 分割回文串) 和 [78. 子集](#78. 子集),接下来看一看排列问题。
相信这个排列问题就算是让你用 for 循环暴力把结果搜索出来,这个暴力也不是很好写。
所以正如我们在回溯算法理论基础所讲的为什么回溯法是暴力搜索,效率这么低,还要用它?
因为一些问题能暴力搜出来就已经很不错了!
我以 [1,2,3] 为例,抽象成树形结构如下:
# 回溯三部曲
参数:
两个全局变量
startIndex?
首先排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。
可以看出元素 1 在 [1,2] 中已经使用过了,但是在 [2,1] 中还要在使用一次 1,所以处理排列问题就 ** 不用使用 startIndex** 了。
used 数组:标记已经选择的元素
private List<List<Integer>> res = new ArrayList<>();
private List<Integer> path = new ArrayList<>();
private void backtracking(int[] nums, boolean[] used)
递归终止条件:
可以看出叶子节点,就是收割结果的地方。
那么什么时候,算是到达叶子节点呢?
当收集元素的数组 path 的大小达到和 nums 数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。
if (path.size() == nums.length) {
res.add(new ArrayList<>(path));
return;
}
单层搜索的逻辑:
这里和 [77. 组合](#77. 组合)、 [☆131. 分割回文串](#☆131. 分割回文串) 和 [78. 子集](#78. 子集) 最大的不同就是 for 循环里不用 startIndex 了。
因为排列问题,每次都要从头开始搜索,例如元素 1 在 [1,2] 中已经使用过了,但是在 [2,1] 中还要再使用一次 1。
而 used 数组,其实就是记录此时 path 里都有哪些元素使用了,一个排列里一个元素只能使用一次。
for (int i = 0; i < nums.length; i++) {
// 剪枝:path 中已经存在该元素,跳过
if (used[i] == true)
continue;
// 处理节点
path.add(nums[i]);
used[i] = true;
// 递归
backtracking(nums, used);
// 回溯
path.remove(path.size() - 1);
used[i] = false;
}
完整的 java 代码:
class Solution { | |
private List<List<Integer>> res = new ArrayList<>(); | |
private List<Integer> path = new ArrayList<>(); | |
private void backtracking(int[] nums, boolean[] used) { | |
// 递归终止条件 | |
if (path.size() == nums.length) { | |
res.add(new ArrayList<>(path)); | |
return; | |
} | |
// 单层搜索的逻辑 | |
for (int i = 0; i < nums.length; i++) { | |
// 剪枝:path 中已经存在该元素,跳过 | |
if (used[i] == true) | |
continue; | |
// 处理节点 | |
path.add(nums[i]); | |
used[i] = true; | |
// 递归 | |
backtracking(nums, used); | |
// 回溯 | |
path.remove(path.size() - 1); | |
used[i] = false; | |
} | |
} | |
public List<List<Integer>> permute(int[] nums) { | |
// 记录 nums 中的元素是否已经被使用过 | |
boolean[] used = new boolean[nums.length]; | |
backtracking(nums, used); | |
return res; | |
} | |
} |
# 总结
大家此时可以感受出排列问题的不同:
- 每层都是从 0 开始搜索而不是 startIndex
- 需要 used 数组记录 path 里都放了哪些元素了
排列问题是回溯算法解决的经典题目,大家可以好好体会体会。
# 47. 全排列 II
与 [46. 全排列](#46. 全排列) 的区别是:包含可重复数字
关键:如何去重?排序 + used 数组!
# 思路
这道题目和 [46. 全排列](#46. 全排列) 的区别在与给定一个可包含重复数字的序列,要返回所有不重复的全排列。
这里又涉及到去重了。
在 [40. 组合总和 II](#40. 组合总和 II)、[90. 子集 II](#90. 子集 II) 我们分别详细讲解了组合问题、子集问题如何去重。
那么排列问题其实也是一样的套路。
还要强调的是去重一定要对元素进行排序,这样我们才方便通过相邻的节点来判断是否重复使用了。
我以示例中的 [1,1,2] 为例 (为了方便举例,已经排序)抽象为一棵树,去重过程如图:
图中我们对同一树层,前一位(也就是 nums [i-1])如果使用过,那么就进行去重。
一般来说:组合问题、排列问题是在树形结构的叶子节点上收集结果,而子集问题就是取树上所有节点的结果。
在 [46. 全排列](#46. 全排列) 中已经详细讲解了排列问题的写法,在 [40. 组合总和 II](#40. 组合总和 II)、[90. 子集 II](#90. 子集 II) 中详细讲解了去重的写法,所以这次我就不用回溯三部曲分析了,直接给出代码,如下:
class Solution { | |
private List<List<Integer>> res = new ArrayList<>(); | |
private List<Integer> path = new ArrayList<>(); | |
private void backtracking(int[] nums, boolean[] used) { | |
// 递归终止条件 | |
if (path.size() == nums.length) { | |
res.add(new ArrayList<>(path)); | |
return; | |
} | |
// 单层搜索的逻辑 | |
for (int i = 0; i < nums.length; i++) { | |
//used [i - 1] == true,说明同⼀树⽀ nums [i - 1] 使⽤过 | |
//used [i - 1] == false,说明同⼀树层 nums [i - 1] 使⽤过 | |
// 剪枝(去重):如果同一树层上出现相同的元素,且前一个元素未被使用过,则跳过 | |
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) { | |
continue; | |
} | |
// 如果同一树枝上 nums [i] 还没被使用过,则开始处理 | |
if (used[i] == false) { | |
// 处理节点 | |
path.add(nums[i]); | |
used[i] = true; | |
// 递归 | |
backtracking(nums, used); | |
// 回溯,撤销处理的节点 | |
path.remove(path.size() - 1); | |
used[i] = false; | |
} | |
} | |
} | |
public List<List<Integer>> permuteUnique(int[] nums) { | |
// 排序,使得相同的元素都相邻,方便剪枝(去重) | |
Arrays.sort(nums); | |
boolean[] used = new boolean[nums.length]; | |
backtracking(nums, used); | |
return res; | |
} | |
} |
# 拓展
大家发现,去重最为关键的代码为:
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) { // 对 树层 中前一位去重 | |
continue; | |
} |
如果改成 used[i - 1] == true
, 也是正确的!,去重代码如下:
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == true) { // 对 树枝 中前一位去重 | |
continue; | |
} |
这是为什么呢,就是上面我刚说的,
- 如果要对树层中前一位去重,就用
used[i - 1] == false
- 如果要对树枝中前一位去重,就用
used[i - 1] == true
对于排列问题,树层上去重和树枝上去重,都是可以的,但是树层上去重效率更高!
这么说是不是有点抽象?
来来来,我就用输入: [1,1,1] 来举一个例子。
- 树层上去重 (used [i - 1] == false),的树形结构如下:
- 树枝上去重(used [i - 1] == true)的树型结构如下:
大家应该很清晰的看到,树层上对前一位去重非常彻底,效率很高,树枝上对前一位去重虽然最后可以得到答案,但是做了很多无用搜索。
# 本周小结!(回溯算法系列三)
# 周一
在 [90. 子集 II](#90. 子集 II) 中,开始针对子集问题进行去重。
本题就是 [78. 子集](#78. 子集) 的基础上加上了去重,去重我们在 [40. 组合总和 II](#40. 组合总和 II) 也讲过了。
所以本题对大家应该并不难。
树形结构如下:
# 周二
在 [491. 递增子序列](#491. 递增子序列) 中,处处都能看到子集的身影,但处处是陷阱,值得好好琢磨琢磨!
树形结构如下:
[491. 递增子序列](#491. 递增子序列) 留言区大家有很多疑问,主要还是和 [90. 子集 II](#90. 子集 II) 混合在了一起。
# 周三
我们已经分析了组合问题,分割问题,子集问题,那么 [排列问题](#46. 全排列) 又不一样了。
排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。
可以看出元素 1 在 [1,2] 中已经使用过了,但是在 [2,1] 中还要在使用一次 1,所以处理排列问题就不用使用 startIndex 了。
如图:
大家此时可以感受出排列问题的不同:
- 每层都是从 0 开始搜索而不是 startIndex
- 需要 used 数组记录 path 里都放了哪些元素
# 周四
排列问题也要去重了,在 [47. 全排列 II](#47. 全排列 II) 中又一次强调了 “树层去重” 和 “树枝去重”。
树形结构如下:
这道题目神奇的地方就是 used [i - 1] == false 也可以,used [i - 1] == true 也可以!
我就用输入: [1,1,1] 来举一个例子。
- 树层上去重 (
used[i - 1] == false
),的树形结构如下:
- 树枝上去重(
used[i - 1] == true
)的树型结构如下:
可以清晰的看到使用 (used [i - 1] == false),即树层去重,效率更高!
# 性能分析
之前并没有分析各个问题的时间复杂度和空间复杂度,这次来说一说。
这块网上的资料鱼龙混杂,一些所谓的经典面试书籍根本不讲回溯算法,算法书籍对这块也避而不谈,感觉就像是算法里模糊的边界。
所以这块就说一说我个人理解,对内容持开放态度,集思广益,欢迎大家来讨论!
子集问题分析:
- 时间复杂度:,因为每一个元素的状态无外乎取与不取,所以时间复杂度为,构造每一组子集都需要填进数组,又有需要,最终时间复杂度:。
- 空间复杂度:,递归深度为 n,所以系统栈所用空间为,每一层递归所用的空间都是常数级别,注意代码里的 result 和 path 都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为。
排列问题分析:
- 时间复杂度:,这个可以从排列的树形图中很明显发现,每一层节点为 n,第二层每一个分支都延伸了 n-1 个分支,再往下又是 n-2 个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * ..... 1 = n!。每个叶子节点都会有一个构造全排列填进数组的操作(对应的代码:
result.push_back(path)
),该操作的复杂度为。所以,最终时间复杂度为:n * n!,简化为。 - 空间复杂度:,和子集问题同理。
组合问题分析:
- 时间复杂度:,组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
- 空间复杂度:,和子集问题同理。
一般说道回溯算法的复杂度,都说是指数级别的时间复杂度,这也算是一个概括吧!
# 总结
本周我们对 [90. 子集 II](#90. 子集 II),然后介绍了和子集问题非常像的 [491. 递增子序列](#491. 递增子序列),如果还保持惯性思维,这道题就可以掉坑里。
接着介绍了 [排列问题](#46. 全排列),以及对 [47. 全排列 II](#47. 全排列 II)。
最后我补充了子集问题,排列问题和组合问题的性能分析,给大家提供了回溯算法复杂度的分析思路。
# ☆332. 重新安排行程
这也可以用回溯法? 其实深搜和回溯也是相辅相成的,毕竟都用递归。
# 思路
这道题目还是很难的,之前我们用回溯法解决了如下问题:组合问题,分割问题,子集问题,排列问题。
直觉上来看 这道题和回溯法没有什么关系,更像是图论中的深度优先搜索。
实际上确实是深搜,但这是深搜中使用了回溯的例子,在查找路径的时候,如果不回溯,怎么能查到目标路径呢。
所以我倾向于说本题应该使用回溯法,那么我也用回溯法的思路来讲解本题,其实深搜一般都使用了回溯法的思路,在图论系列中我会再详细讲解深搜。
这里就是先给大家拓展一下,原来回溯法还可以这么玩!
这道题目有几个难点:
- 一个行程中,如果航班处理不好容易变成一个圈,成为死循环
- 有多种解法,字母序靠前排在前面,让很多同学望而退步,该如何记录映射关系呢 ?
- 使用回溯法(也可以说深搜) 的话,那么终止条件是什么呢?
- 搜索的过程中,如何遍历一个机场所对应的所有机场。
针对以上问题我来逐一解答!
# 如何理解死循环
对于死循环,我来举一个有重复机场的例子:
为什么要举这个例子呢,就是告诉大家,出发机场和到达机场也会重复的,如果在解题的过程中没有对集合元素处理好,就会死循环。
# 如何记录映射关系
有多种解法,字母序靠前排在前面,让很多同学望而退步,如何该记录映射关系呢 ?
一个机场映射多个机场,机场之间要靠字母序排列。一个机场映射多个机场,可以使用 std::unordered_map,如果让多个机场之间再有顺序的话,就是用 std::map 或者 std::multimap 或者 std::multiset。
如果对 map 和 set 的实现机制不太了解,也不清楚为什么 map、multimap 就是有序的同学,可以看这篇文章关于哈希表,你该了解这些!。
这样存放映射关系可以定义为 unordered_map<string, multiset<string>> targets
或者 unordered_map<string, map<string, int>> targets
。
含义如下:
unordered_map <出发机场,到达机场的集合> targets
unordered_map <出发机场,map < 到达机场,航班次数>> targets
这两个结构,我选择了后者,因为如果使用 unordered_map<string, multiset<string>> targets
遍历 multiset 的时候,不能删除元素,一旦删除元素,迭代器就失效了。
再说一下为什么一定要增删元素呢,正如开篇我给出的图中所示,出发机场和到达机场是会重复的,搜索的过程没及时删除目的机场就会死循环。
所以搜索的过程中就是要不断的删 multiset 里的元素,那么推荐使用 unordered_map<string, map<string, int>> targets
。
在遍历 unordered_map<出发机场, map<到达机场, 航班次数>> targets
的过程中,可以使用 "航班次数" 这个字段的数字做相应的增减,来标记到达机场是否使用过了。
如果 “航班次数” 大于零,说明目的地还可以飞,如果 “航班次数” 等于零说明目的地不能飞了,而不用对集合做删除元素或者增加元素的操作。
相当于说我不删,我就做一个标记!
# 回溯三部曲
本题以输入:[["JFK", "KUL"], ["JFK", "NRT"], ["NRT", "JFK"] 为例,抽象为树形结构如下:
开始回溯三部曲讲解:
参数、返回值:
结果变量 res,定义为全局变量
使用
unordered_map<string, map<string, int>> targets;
来记录航班的映射关系,我定义为全局变量当然把参数放进函数里传进去也是可以的,我是尽量控制函数里参数的长度
参数里还需要 ticketNum,表示有多少个航班(终止条件会用上)
private List<String> res = new ArrayList<>();
// Map< 出发机场,Map< 到达机场,航班次数 >> targets,记录每个机场的映射
private Map<String, Map<String, Integer>> targets = new HashMap<>();
//ticketNum 表示航班数量
private boolean backtracking(int ticketNum)
注意函数返回值我用的是 bool!
我们之前讲解回溯算法的时候,一般函数返回值都是 void,这次为什么是 bool 呢?
因为我们只需要找到一个行程,就是在树形结构中唯一的一条通向叶子节点的路线
所以找到了这个叶子节点了直接返回,这个递归函数的返回值问题我们在讲解二叉树的系列的时候,在这篇 [二叉树:递归函数究竟什么时候需要返回值,什么时候不要返回值?](#☆112. 路径总和) 详细介绍过。
当然本题的 targets 和 result 都需要初始化,代码如下:
res.add("JFK"); // 初始化起点
// 根据 tickets 初始化 targets,注意题目要求按字典排序返回最小的行程组合,所以要对 targets 进行升序排序
for (List<String> ticket : tickets) {
String from = ticket.get(0); // 出发机场
String to = ticket.get(1); // 到达机场
if (!targets.containsKey(from)) { // 如果 targets 中不包含该出发机场,则添加
targets.put(from, new TreeMap<>()); // TreeMap 是有序的!!!
}
targets.get(from).put(to, targets.get(from).getOrDefault(to, 0) + 1); // 记录该航班的剩余次数
}
在 Java 中,TreeMap 是升序的 Map。
递归终止条件:
拿题目中的示例为例,输入: [["MUC", "LHR"], ["JFK", "MUC"], ["SFO", "SJC"], ["LHR", "SFO"]] ,这是有 4 个航班,那么只要找出一种行程,行程里的机场个数是 5 就可以了。
所以终止条件是:我们回溯遍历的过程中,遇到的机场个数,如果达到了(航班数量 + 1),那么我们就找到了一个行程,把所有航班串在一起了。
// 递归终止条件:遇到的机场个数 res.size (),如果达到了(ticketNum+1),返回 true
if (res.size() == ticketNum + 1) {
return true;
}
已经看习惯回溯法代码的同学,到叶子节点了习惯性的想要收集结果,但发现并不需要,本题的 result 相当于 [216. 组合总和 III](#216. 组合总和 III) 中的 path,也就是本题的 result 就是记录路径的(就一条),在如下单层搜索的逻辑中 result 就添加元素了。
单层搜索的逻辑:
回溯的过程中,如何遍历一个机场所对应的所有机场呢?
这里刚刚说过,在选择映射函数的时候,不能选择
unordered_map<string, multiset<string>> targets
, 因为一旦有元素增删 multiset 的迭代器就会失效,当然可能有牛逼的容器删除元素迭代器不会失效,这里就不在讨论了。可以说本题既要找到一个对数据进行排序的容器,而且还要容易增删元素,迭代器还不能失效。
所以我选择了
unordered_map<string, map<string, int>> targets
来做机场之间的映射。遍历过程如下:
// 单层搜索的逻辑:从当前机场 res.get (res.size () - 1) 出发,遍历其所有的目的地机场
String latest_from = res.get(res.size() - 1); // 当前机场
if (targets.containsKey(latest_from)) { // 如果当前机场有目的地机场,遍历。
for (Map.Entry<String, Integer> target : targets.get(latest_from).entrySet()) {
if (target.getValue() > 0) { // 判断该航班是否还有剩余,即是否还能飞
// 处理节点
res.add(target.getKey()); // 添加目的地机场
target.setValue(target.getValue() - 1); // 将该航班的剩余次数减 1
// 递归
if (backtracking(ticketNum)) {
return true; // 如果找到了,直接返回 true
}
// 回溯,撤销处理的节点
res.remove(res.size() - 1); // 删除目的地机场
target.setValue(target.getValue() + 1); // 将该航班的剩余次数加 1
}
}
}
可以看出 通过
unordered_map<string, map<string, int>> targets
里的 int 字段来判断 这个集合里的机场是否使用过,这样避免了直接去删元素。
完整的 java 代码如下:
class Solution { | |
private List<String> res = new ArrayList<>(); | |
// Map< 出发机场,Map< 到达机场,航班次数 >> targets,记录每个机场的映射 | |
private Map<String, Map<String, Integer>> targets = new HashMap<>(); | |
//ticketNum 表示航班数量 | |
private boolean backtracking(int ticketNum) { | |
// 递归终止条件:遇到的机场个数 res.size (),如果达到了(ticketNum+1),返回 true | |
if (res.size() == ticketNum + 1) { | |
return true; | |
} | |
// 单层搜索的逻辑:从当前机场 res.get (res.size () - 1) 出发,遍历其所有的目的地机场 | |
String latest_from = res.get(res.size() - 1); // 当前机场 | |
if (targets.containsKey(latest_from)) { // 如果当前机场有目的地机场,遍历。 | |
for (Map.Entry<String, Integer> target : targets.get(latest_from).entrySet()) { | |
if (target.getValue() > 0) { // 判断该航班是否还有剩余,即是否还能飞 | |
// 处理节点 | |
res.add(target.getKey()); // 添加目的地机场 | |
target.setValue(target.getValue() - 1); // 将该航班的剩余次数减 1 | |
// 递归 | |
if (backtracking(ticketNum)) { | |
return true; // 如果找到了,直接返回 true | |
} | |
// 回溯,撤销处理的节点 | |
res.remove(res.size() - 1); // 删除目的地机场 | |
target.setValue(target.getValue() + 1); // 将该航班的剩余次数加 1 | |
} | |
} | |
} | |
return false; // 如果没有找到,返回 false | |
} | |
public List<String> findItinerary(List<List<String>> tickets) { | |
res.add("JFK"); // 初始化起点 | |
// 根据 tickets 初始化 targets,注意题目要求按字典排序返回最小的行程组合,所以要对 targets 进行升序排序 | |
for (List<String> ticket : tickets) { | |
String from = ticket.get(0); // 出发机场 | |
String to = ticket.get(1); // 到达机场 | |
if (!targets.containsKey(from)) { // 如果 targets 中不包含该出发机场,则添加 | |
targets.put(from, new TreeMap<>()); // TreeMap 是有序的!!! | |
} | |
targets.get(from).put(to, targets.get(from).getOrDefault(to, 0) + 1); // 记录该航班的剩余次数 | |
} | |
backtracking(tickets.size()); | |
return res; | |
} | |
} |
# 总结
如果单纯的回溯搜索(深搜)并不难,难还难在容器的选择和使用上。
本题其实是一道深度优先搜索的题目,但是我完全使用回溯法的思路来讲解这道题题目,算是给大家拓展一下思维方式,其实深搜和回溯也是分不开的,毕竟最终都是用递归。
如果最终代码,发现照着回溯法模板画的话好像也能画出来,但难就难如何知道可以使用回溯,以及如果套进去,所以我再写了这么长的一篇来详细讲解。
# ☆51. N 皇后
# 思路
都知道 n 皇后问题是回溯算法解决的经典问题,但是用回溯解决多了组合、切割、子集、排列问题之后,遇到这种二维矩阵还会有点不知所措。
首先来看一下皇后们的约束条件:
- 不能同行
- 不能同列
- 不能同斜线
确定完约束条件,来看看究竟要怎么搜索皇后们的位置,其实搜索皇后的位置,可以抽象为一棵树。
下面我用一个 3 * 3 的棋盘,将搜索过程抽象为一棵树,如图:
从图中,可以看出,二维矩阵中矩阵的高就是这棵树的高度,矩阵的宽就是树形结构中每一个节点的宽度。
那么我们用皇后们的约束条件,来回溯搜索这棵树,只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置了。
因为递归前会检测是否合理,所以只要能到达叶子节点,都是合理的
# 回溯三部曲
参数:
- res:全局变量二维数组,记录结果
- n:棋盘大小
- row:记录当前遍历到棋盘第几行
- chessboard:记录当前棋盘
private List<List<String>> res = new ArrayList<>();
private void backtracking(int n, int row, char[][] chessboard)
递归终止条件:
当递归到棋盘最底层(也就是叶子节点)的时候,即
row == n
时,就可以收集结果并返回了。if (row == n) {
// 将 char [][] 转换为 List<String>
List<String> tmpList = new ArrayList<>();
for (char[] chars : chessboard) {
tmpList.add(new String(chars));
}
// 将 List<String> 加入结果集
res.add(tmpList);
return;
}
单层搜索的逻辑:
递归深度就是 row 控制棋盘的行,每一层里 for 循环的 col 控制棋盘的列,一行一列,确定了放置皇后的位置。
每次都是要从新的一行的起始位置开始搜,所以都是从 0 开始。
for (int col = 0; col < n; col++) {
if (isValid(chessboard, row, col)) { // 只有合法时才放置
// 做选择
chessboard[row][col] = 'Q';
// 进入下一层决策树
backtracking(n, row + 1, chessboard);
// 撤销选择
chessboard[row][col] = '.';
}
}
# 验证棋盘是否合法
按照如下标准去重:
不能同行
可以不检查,因为在单层搜索的过程中,每一层递归,只会选 for 循环(也就是同一行)里的一个元素,所以不用去重了。
不能同列
不能同斜线 (45 度和 135 度角)
// 判断 (row, col) 是否可以放置皇后 | |
private boolean isValid(char[][] chessboard, int row, int col) { | |
int n = chessboard.length; | |
// 不用检查行,因为每行只放一个皇后 | |
// 检查列 | |
for (int i = 0; i < n; i++) { | |
if (chessboard[i][col] == 'Q') { | |
return false; | |
} | |
} | |
// 检查↗方向,因为是从上往下放,所以不用检查下方 | |
for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) { | |
if (chessboard[i][j] == 'Q') { | |
return false; | |
} | |
} | |
// 检查↖方向,因为是从上往下放,所以不用检查下方 | |
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) { | |
if (chessboard[i][j] == 'Q') { | |
return false; | |
} | |
} | |
return true; | |
} |
# 完整的 Java 代码
可以看出,除了验证棋盘合法性的代码,剩下来部分就是按照回溯法模板来的。
class Solution { | |
private List<List<String>> res = new ArrayList<>(); | |
// 判断 (row, col) 是否可以放置皇后 | |
private boolean isValid(char[][] chessboard, int row, int col) { | |
int n = chessboard.length; | |
// 不用检查行,因为每行只放一个皇后 | |
// 检查列 | |
for (int i = 0; i < n; i++) { | |
if (chessboard[i][col] == 'Q') { | |
return false; | |
} | |
} | |
// 检查↗方向,因为是从上往下放,所以不用检查下方 | |
for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) { | |
if (chessboard[i][j] == 'Q') { | |
return false; | |
} | |
} | |
// 检查↖方向,因为是从上往下放,所以不用检查下方 | |
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) { | |
if (chessboard[i][j] == 'Q') { | |
return false; | |
} | |
} | |
return true; | |
} | |
private void backtracking(int n, int row, char[][] chessboard) { | |
// 递归终止条件 | |
if (row == n) { | |
// 将 char [][] 转换为 List<String> | |
List<String> tmpList = new ArrayList<>(); | |
for (char[] chars : chessboard) { | |
tmpList.add(new String(chars)); | |
} | |
// 将 List<String> 加入结果集 | |
res.add(tmpList); | |
return; | |
} | |
// 单层搜索的逻辑 | |
for (int col = 0; col < n; col++) { | |
if (isValid(chessboard, row, col)) { // 只有合法时才放置 | |
// 做选择 | |
chessboard[row][col] = 'Q'; | |
// 进入下一层决策树 | |
backtracking(n, row + 1, chessboard); | |
// 撤销选择 | |
chessboard[row][col] = '.'; | |
} | |
} | |
} | |
public List<List<String>> solveNQueens(int n) { | |
// 定义并初始化棋盘 | |
char[][] chessboard = new char[n][n]; | |
for (int i = 0; i < n; i++) { | |
Arrays.fill(chessboard[i], '.'); | |
} | |
// 开始回溯 | |
backtracking(n, 0, chessboard); | |
return res; | |
} | |
} |
# 总结
本题是我们解决棋盘问题的第一道题目。
如果从来没有接触过 N 皇后问题的同学看着这样的题会感觉无从下手,可能知道要用回溯法,但也不知道该怎么去搜。
这里我明确给出了棋盘的宽度就是 for 循环的长度,递归的深度就是棋盘的高度,这样就可以套进回溯法的模板里了。
# ☆37. 解数独
棋盘搜索问题可以使用回溯法暴力搜索,只不过这次我们要做的是二维递归。
怎么做二维递归呢?
大家已经跟着「代码随想录」刷过了如下回溯法题目,例如:[77. 组合](#77. 组合),[☆131. 分割回文串](#☆131. 分割回文串),[78. 子集](#78. 子集),[46. 全排列](#46. 全排列),以及 [☆51. N 皇后](#☆51. N 皇后),其实这些题目都是一维递归。
如果以上这几道题目没有做过的话,不建议上来就做这道题哈!
[☆51. N 皇后](#☆51. N 皇后) 是因为每一行每一列只放一个皇后,只需要一层 for 循环遍历一行,递归来遍历列,然后一行一列确定皇后的唯一位置。
本题就不一样了,本题中棋盘的每一个位置都要放一个数字(而 N 皇后是一行只放一个皇后),并检查数字是否合法,解数独的树形结构要比 N 皇后更宽更深。
因为这个树形结构太大了,我抽取一部分,如图所示:
# 回溯三部曲
返回值、参数:
递归函数的返回值需要是 bool 类型,为什么呢?
因为解数独找到一个符合的条件(就在树的叶子节点上)立刻就返回,相当于找从根节点到叶子节点一条唯一路径,所以需要使用 bool 返回值。
boolean backtracking(char[][] board)
递归终止条件:
本题递归不用终止条件,解数独是要遍历整个树形结构寻找可能的叶子节点就立刻返回。
不用终止条件会不会死循环?
递归的下一层的棋盘一定比上一层的棋盘多一个数,等数填满了棋盘自然就终止(填满当然好了,说明找到结果了),所以不需要终止条件!
那么有没有永远填不满的情况呢?
这个问题我在递归单层搜索逻辑里再来讲!
单层搜索的逻辑:
在树形图中可以看出我们需要的是一个二维的递归(也就是两个 for 循环嵌套着递归)
一个 for 循环遍历棋盘的行,一个 for 循环遍历棋盘的列,一行一列确定下来之后,递归遍历这个位置放 9 个数字的可能性!
boolean backtracking(char[][] board) {
// 递归终止条件:不需要。等棋盘填满,自然会终止
// 递归单层搜索逻辑
for (int i = 0; i < board.length; i++) { // 遍历行
for (int j = 0; j < board[0].length; j++) { // 遍历列
if (board[i][j] != '.') // 如果不是空格,跳过
continue;
for (char c = '1'; c <= '9'; c++) { // (i,j) 位置遍历 1-9
if (isValid(i, j, k, board)) {
// 做选择
board[i][j] = c;
// 进入下一层决策树
if (backtracking(board))
return true;
// 撤销选择
board[i][j] = '.';
}
}
// 1-9 都尝试过,依然没有找到可行解,此路不通
return false;
}
}
// 递归完毕没返回 false,说明找到了可行解
return true;
}
注意这里 return false 的地方,这里放 return false 是有讲究的。
因为如果一行一列确定下来了,这里尝试了 9 个数都不行,说明这个棋盘找不到解决数独问题的解!
那么会直接返回, 这也就是为什么没有终止条件也不会永远填不满棋盘而无限递归下去!
# 判断棋盘是否合法
判断棋盘是否合法有如下三个维度:
- 同行是否重复
- 同列是否重复
- 9 宫格里是否重复
boolean isValid(int row, int col, char val, char[][] board) { | |
// 检查行里是否重复 | |
for (int i = 0; i < 9; i++) { | |
if (board[row][i] == val) { | |
return false; | |
} | |
} | |
// 检查列里是否重复 | |
for (int i = 0; i < 9; i++) { | |
if (board[i][col] == val) { | |
return false; | |
} | |
} | |
// 检查 3x3 宫格里是否重复 | |
int startRow = row / 3 * 3; | |
int startCol = col / 3 * 3; | |
for (int i = startRow; i < startRow + 3; i++) { | |
for (int j = startCol; j < startCol + 3; j++) { | |
if (board[i][j] == val) { | |
return false; | |
} | |
} | |
} | |
// 检查通过 | |
return true; | |
} |
# 完整的 Java 代码
class Solution { | |
boolean isValid(int row, int col, char val, char[][] board) { | |
// 检查行里是否重复 | |
for (int i = 0; i < 9; i++) { | |
if (board[row][i] == val) { | |
return false; | |
} | |
} | |
// 检查列里是否重复 | |
for (int i = 0; i < 9; i++) { | |
if (board[i][col] == val) { | |
return false; | |
} | |
} | |
// 检查 3x3 宫格里是否重复 | |
int startRow = row / 3 * 3; | |
int startCol = col / 3 * 3; | |
for (int i = startRow; i < startRow + 3; i++) { | |
for (int j = startCol; j < startCol + 3; j++) { | |
if (board[i][j] == val) { | |
return false; | |
} | |
} | |
} | |
// 检查通过 | |
return true; | |
} | |
boolean backtracking(char[][] board) { | |
// 递归终止条件:不需要。等棋盘填满,自然会终止 | |
// 递归单层搜索逻辑 | |
for (int i = 0; i < board.length; i++) { // 遍历行 | |
for (int j = 0; j < board[0].length; j++) { // 遍历列 | |
if (board[i][j] != '.') // 如果不是空格,跳过 | |
continue; | |
for (char c = '1'; c <= '9'; c++) { // (i,j) 位置遍历 1-9 | |
if (isValid(i, j, c, board)) { | |
// 做选择 | |
board[i][j] = c; | |
// 进入下一层决策树 | |
if (backtracking(board)) | |
return true; | |
// 撤销选择 | |
board[i][j] = '.'; | |
} | |
} | |
// 1-9 都尝试过,依然没有找到可行解,此路不通 | |
return false; | |
} | |
} | |
// 递归完毕没返回 false,说明找到了可行解 | |
return true; | |
} | |
public void solveSudoku(char[][] board) { | |
backtracking(board); | |
} | |
} |
# 总结
解数独可以说是非常难的题目了,如果还一直停留在单层递归的逻辑中,这道题目可以让大家瞬间崩溃。
所以我在开篇就提到了二维递归,这也是我自创词汇,希望可以帮助大家理解解数独的搜索过程。
一波分析之后,再看代码会发现其实也不难,唯一难点就是理解二维递归的思维逻辑。
这样,解数独这么难的问题,也被我们攻克了。
恭喜一路上坚持打卡的录友们,回溯算法已经接近尾声了,接下来就是要一波总结了。
# 回溯法总结篇
回溯是递归的副产品,只要有递归就会有回溯,所以回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归。
回溯法就是暴力搜索,并不是什么高效的算法,最多再剪枝一下。
回溯算法能解决如下问题:
- 组合问题:N 个数里面按一定规则找出 k 个数的集合
- 排列问题:N 个数按一定规则全排列,有几种排列方式
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个 N 个数的集合里有多少符合条件的子集
- 棋盘问题:N 皇后,解数独等等
我在回溯算法系列讲解中就按照这个顺序给大家讲解,可以说深入浅出,步步到位。
回溯法确实不好理解,所以需要把回溯法抽象为一个图形来理解就容易多了,在后面的每一道回溯法的题目我都将遍历过程抽象为树形结构方便大家的理解。
回溯法的模板:
void backtracking(参数) { | |
if (终止条件) { | |
存放结果; | |
return; | |
} | |
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { | |
处理节点; | |
backtracking(路径,选择列表); // 递归 | |
回溯,撤销处理结果 | |
} | |
} |
# 组合问题
# 组合问题
在 [77. 组合](#77. 组合) 中,我们开始用回溯法解决第一道题目:组合问题。
我在文中开始的时候给大家列举 k 层 for 循环例子,进而得出都是同样是暴力解法,为什么要用回溯法!
此时大家应该深有体会回溯法的魅力,用递归控制 for 循环嵌套的数量!
本题我把回溯问题抽象为树形结构,如题:
可以直观的看出其搜索的过程:for 循环横向遍历,递归纵向遍历,回溯不断调整结果集,这个理念贯穿整个回溯法系列,也是我做了很多回溯的题目,不断摸索其规律才总结出来的。
对于回溯法的整体框架,网上搜的文章这块都说不清楚,按照天上掉下来的代码对着讲解,不知道究竟是怎么来的,也不知道为什么要这么写。
所以,录友们刚开始学回溯法,起跑姿势就很标准了!
优化回溯算法只有剪枝一种方法,在 [77. 组合优化](#77. 组合优化) 中把回溯法代码做了剪枝优化,树形结构如图:
大家可以一目了然剪的究竟是哪里。
这里剪枝精髓是:for 循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够题目要求的 k 个元素了,就没有必要搜索了。
在 for 循环上做剪枝操作是回溯法剪枝的常见套路! 后面的题目还会经常用到。
# 组合总和
# 组合总和(一)
在 [216. 组合总和 III](#216. 组合总和 III) 中,相当于 [77. 组合](#77. 组合) 加了一个元素总和的限制。
树形结构如图:
整体思路还是一样的,本题的剪枝会好想一些,即:已选元素总和如果已经大于 n(题中要求的和)了,那么往后遍历就没有意义了,直接剪掉,如图:
在本题中,依然还可以有一个剪枝,就是 [77. 组合优化](#77. 组合优化) 中提到的,对 for 循环选择的起始范围的剪枝。
所以剪枝的代码可以在 for 循环加上 i <= 9 - (k - path.size()) + 1
的限制!
# 组合总和(二)
在 [39. 组合总和](#39. 组合总和) 中讲解的组合总和问题,和 [77. 组合](#77. 组合),[216. 组合总和 III](#216. 组合总和 III) 的区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
不少同学都是看到可以重复选择,就义无反顾的把 startIndex 去掉了。
本题还需要 startIndex 来控制 for 循环的起始位置,对于组合问题,什么时候需要 startIndex 呢?
只是递归传入的 startIndex 参数不是 i+1,而是 i,因为可以重复选择
如果是一个集合来求组合的话,就需要 startIndex,例如:[77. 组合](#77. 组合),[216. 组合总和 III](#216. 组合总和 III)
如果是多个集合取组合,各个集合之间相互不影响,那么就不用 startIndex,例如:[17. 电话号码的字母组合](#17. 电话号码的字母组合)
注意以上我只是说求组合的情况,如果是排列问题,又是另一套分析的套路。
树形结构如下:
最后还给出了本题的剪枝优化,如下:
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) |
优化后树形结构如下:
# 组合总和(三)
在 [40. 组合总和 II](#40. 组合总和 II) 中集合元素会有重复,但要求解集不能包含重复的组合。
所以难就难在去重问题上了。
排序 + used 数组
这个去重问题,相信做过的录友都知道有多么的晦涩难懂。网上的题解一般就说 “去掉重复”,但说不清怎么个去重,代码一甩就完事了。
为了讲解这个去重问题,Carl 自创了两个词汇,“树枝去重” 和 “树层去重”。
都知道组合问题可以抽象为树形结构,那么 “使用过” 在这个树形结构上是有两个维度的,一个维度是同一树枝上 “使用过”,一个维度是同一树层上 “使用过”。没有理解这两个层面上的 “使用过” 是造成大家没有彻底理解去重的根本原因。
我在图中将 used 的变化用橘黄色标注上,可以看出在 candidates [i] == candidates [i - 1] 相同的情况下:
- used [i - 1] == true,说明同一树枝 candidates [i - 1] 使用过
- used [i - 1] == false,说明同一树层 candidates [i - 1] 使用过
这块去重的逻辑很抽象,网上搜的题解基本没有能讲清楚的,如果大家之前思考过这个问题或者刷过这道题目,看到这里一定会感觉通透了很多!
对于去重,其实排列和子集问题也是一样的道理。
# 多个集合求组合
在 [17. 电话号码的字母组合](#17. 电话号码的字母组合) 中,开始用多个集合来求组合,还是熟悉的模板题目,但是有一些细节。
例如这里 for 循环,可不像是在 [77. 组合](#77. 组合),[216. 组合总和 III](#216. 组合总和 III) 中从 startIndex 开始遍历的。
因为本题每一个数字代表的是不同集合,也就是求不同集合之间的组合,而 [77. 组合](#77. 组合),[216. 组合总和 III](#216. 组合总和 III) 都是是求同一个集合中的组合!
树形结构如下:
如果大家在现场面试的时候,一定要注意各种输入异常的情况,例如本题输入 1 * #按键。
其实本题不算难,但也处处是细节,还是要反复琢磨。
# 切割问题
在 [☆131. 分割回文串](#☆131. 分割回文串) 中,我们开始讲解切割问题,虽然最后代码看起来好像是一道模板题,但是从分析到学会套用这个模板,是比较难的。
我列出如下几个难点:
- 切割问题其实类似组合问题
- 如何模拟那些切割线
- 切割问题中递归如何终止
- 在递归循环中如何截取子串
- 如何判断回文
如果想到了用求解组合问题的思路来解决 切割问题本题就成功一大半了,接下来就可以对着模板照葫芦画瓢。
但后序如何模拟切割线,如何终止,如何截取子串,其实都不好想,最后判断回文算是最简单的了。
所以本题应该是一个道 hard 题目了。
除了这些难点,本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入 i + 1。
树形结构如下:
# 子集问题
# 子集问题(一)
在 [78. 子集](#78. 子集) 中讲解了子集问题,在树形结构中子集问题是要收集所有节点的结果,而组合问题是收集叶子节点的结果。
如图:
认清这个本质之后,今天的题目就是一道模板题了。
本题其实可以不需要加终止条件,因为 startIndex >= nums.size (),本层 for 循环本来也结束了,本来我们就要遍历整棵树。
有的同学可能担心不写终止条件会不会无限递归?
并不会,因为每次递归的下一层就是从 i+1 开始的。
如果要写终止条件,注意: result.push_back(path);
要放在终止条件的上面,如下:
result.push_back(path); // 收集子集,要放在终止添加的上面,否则会漏掉结果 | |
if (startIndex >= nums.size()) { // 终止条件可以不加 | |
return; | |
} |
# 子集问题(二)
在 [90. 子集 II](#90. 子集 II) 中,开始针对子集问题进行 ** 去重 **。
排序 + used 数组!
本题就是 [78. 子集](#78. 子集) 的基础上加上了去重,去重我们在 [40. 组合总和 II](#40. 组合总和 II) 也讲过了,一样的套路。
树形结构如下:
# 递增子序列
不能通过排序 + used 数组来去重
在 [491. 递增子序列](#491. 递增子序列) 中,处处都能看到子集的身影,但处处是陷阱,值得好好琢磨琢磨!
树形结构如下:
很多同学都会把这道题目和 [90. 子集 II](#90. 子集 II) 混在一起。
[90. 子集 II](#90. 子集 II) 也可以使用 set 针对同一父节点本层去重,但子集问题一定要排序,为什么呢?
我用没有排序的集合 {2,1,2,2} 来举个例子画一个图,如下:
相信这个图胜过千言万语的解释了。
# 排列问题
# 排列问题(一)
[46. 全排列](#46. 全排列) 又不一样了。
排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。
可以看出元素 1 在 [1,2] 中已经使用过了,但是在 [2,1] 中还要在使用一次 1,所以处理排列问题就不用使用 startIndex 了。
如图:
大家此时可以感受出排列问题的不同:
- 每层都是从 0 开始搜索而不是 startIndex
- 需要 used 数组记录 path 里都放了哪些元素了
# 排列问题(二)
排列问题也要去重了,在 [47. 全排列 II](#47. 全排列 II) 中又一次强调了 “树层去重” 和 “树枝去重”。
树形结构如下:
这道题目神奇的地方就是 used [i - 1] == false 也可以,used [i - 1] == true 也可以!
我就用输入: [1,1,1] 来举一个例子。
树层上去重 (used [i - 1] == false),的树形结构如下:
树枝上去重(used [i - 1] == true)的树型结构如下:
可以清晰的看到使用 (used [i - 1] == false),即树层去重,效率更高!
本题 used 数组即是记录 path 里都放了哪些元素,同时也用来去重,一举两得。
# 棋盘问题
# N 皇后问题
在 [☆51. N 皇后](#☆51. N 皇后) 中终于迎来了传说中的 N 皇后。
下面我用一个 3 * 3 的棋盘,将搜索过程抽象为一棵树,如图:
从图中,可以看出,二维矩阵中矩阵的高就是这棵树的高度,矩阵的宽就是树形结构中每一个节点的宽度。
那么我们用皇后们的约束条件,来回溯搜索这棵树,只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置了。
如果从来没有接触过 N 皇后问题的同学看着这样的题会感觉无从下手,可能知道要用回溯法,但也不知道该怎么去搜。
这里我明确给出了棋盘的宽度就是 for 循环的长度,递归的深度就是棋盘的高度,这样就可以套进回溯法的模板里了。
相信看完本篇 [☆51. N 皇后](#☆51. N 皇后) 也没那么难了,传说已经不是传说了,哈哈。
# 解数独问题
在 [☆37. 解数独](#☆37. 解数独) 中要征服回溯法的最后一道山峰。
解数独应该是棋盘很难的题目了,比 N 皇后还要复杂一些,但只要理解 “二维递归” 这个过程,其实发现就没那么难了。
大家已经跟着「代码随想录」刷过了如下回溯法题目,例如:77. 组合(组合问题) (opens new window),131. 分割回文串(分割问题) (opens new window),78. 子集(子集问题) (opens new window),46. 全排列(排列问题) (opens new window),以及 51.N 皇后(N 皇后问题) (opens new window),其实这些题目都是一维递归。
其中 N 皇后问题 (opens new window) 是因为每一行每一列只放一个皇后,只需要一层 for 循环遍历一行,递归来遍历列,然后一行一列确定皇后的唯一位置。
本题就不一样了,本题中棋盘的每一个位置都要放一个数字,并检查数字是否合法,解数独的树形结构要比 N 皇后更宽更深。
因为这个树形结构太大了,我抽取一部分,如图所示:
解数独可以说是非常难的题目了,如果还一直停留在一维递归的逻辑中,这道题目可以让大家瞬间崩溃。
所以我在 [☆37. 解数独](#☆37. 解数独) 中开篇就提到了二维递归,这也是我自创词汇,希望可以帮助大家理解解数独的搜索过程。
一波分析之后,在看代码会发现其实也不难,唯一难点就是理解二维递归的思维逻辑。
这样,解数独这么难的问题也被我们攻克了。
# 去重问题
以上我都是统一使用 used 数组来去重的,其实使用 set 也可以用来去重!
在本周小结!(回溯算法系列三)续集 (opens new window) 中给出了子集、组合、排列问题使用 set 来去重的解法以及具体代码,并纠正一些同学的常见错误写法。
同时详细分析了 使用 used 数组去重 和 使用 set 去重 两种写法的性能差异:
使用 set 去重的版本相对于 used 数组的版本效率都要低很多,大家在 leetcode 上提交,能明显发现。
原因在回溯算法:递增子序列 (opens new window) 中也分析过,主要是因为程序运行的时候对 unordered_set 频繁的 insert,unordered_set 需要做哈希映射(也就是把 key 通过 hash function 映射为唯一的哈希值)相对费时间,而且 insert 的时候其底层的符号表也要做相应的扩充,也是费时的。
而使用 used 数组在时间复杂度上几乎没有额外负担!
使用 set 去重,不仅时间复杂度高了,空间复杂度也高了,在本周小结!(回溯算法系列三) (opens new window) 中分析过,组合,子集,排列问题的空间复杂度都是 O (n),但如果使用 set 去重,空间复杂度就变成了 O (n^2),因为每一层递归都有一个 set 集合,系统栈空间是 n,每一个空间都有 set 集合。
那有同学可能疑惑 用 used 数组也是占用 O (n) 的空间啊?
used 数组可是全局变量,每层与每层之间公用一个 used 数组,所以空间复杂度是 O (n + n),最终空间复杂度还是 O (n)。
# 重新安排行程(图论额外拓展)
之前说过,有递归的地方就有回溯,深度优先搜索也是用递归来实现的,所以往往伴随着回溯。
在 [☆332. 重新安排行程](#☆332. 重新安排行程) 其实也算是图论里深搜的题目,但是我用回溯法的套路来讲解这道题目,算是给大家拓展一下思路,原来回溯法还可以这么玩!
以输入:[["JFK", "KUL"], ["JFK", "NRT"], ["NRT", "JFK"] 为例,抽象为树形结构如下:
本题可以算是一道 hard 的题目了,关于本题的难点我在文中已经详细列出。
如果单纯的回溯搜索(深搜)并不难,难还难在容器的选择和使用上!
本题其实是一道深度优先搜索的题目,但是我完全使用回溯法的思路来讲解这道题题目,算是给大家拓展一下思维方式,其实深搜和回溯也是分不开的,毕竟最终都是用递归。
# 性能分析
关于回溯算法的复杂度分析在网上的资料鱼龙混杂,一些所谓的经典面试书籍不讲回溯算法,算法书籍对这块也避而不谈,感觉就像是算法里模糊的边界。
所以这块就说一说我个人理解,对内容持开放态度,集思广益,欢迎大家来讨论!
以下在计算空间复杂度的时候我都把系统栈(不是数据结构里的栈)所占空间算进去。
子集问题分析:
- 时间复杂度:O (2n),因为每一个元素的状态无外乎取与不取,所以时间复杂度为 O (2n)
- 空间复杂度:O (n),递归深度为 n,所以系统栈所用空间为 O (n),每一层递归所用的空间都是常数级别,注意代码里的 result 和 path 都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为 O (n)
排列问题分析:
- 时间复杂度:O (n!),这个可以从排列的树形图中很明显发现,每一层节点为 n,第二层每一个分支都延伸了 n-1 个分支,再往下又是 n-2 个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * ..... 1 = n!。
- 空间复杂度:O (n),和子集问题同理。
组合问题分析:
- 时间复杂度:O (2n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
- 空间复杂度:O (n),和子集问题同理。
N 皇后问题分析:
- 时间复杂度:O (n!) ,其实如果看树形图的话,直觉上是 O (nn),但皇后之间不能见面所以在搜索的过程中是有剪枝的,最差也就是 O(n!),n! 表示 n * (n-1) * .... * 1。
- 空间复杂度:O (n),和子集问题同理。
解数独问题分析:
- 时间复杂度:O (9m) , m 是 '.' 的数目。
- 空间复杂度:O (n2),递归的深度是 n2
一般说道回溯算法的复杂度,都说是指数级别的时间复杂度,这也算是一个概括吧!
# 总结
这里的每一种问题,讲解的时候我都会和其他问题作对比,做分析,确保每一个问题都讲的通透。
可以说方方面面都详细介绍到了。
例如:
如何理解回溯法的搜索过程?
什么时候用 startIndex,什么时候不用?
对一个集合进行组合时用
如何去重?如何理解 “树枝去重” 与 “树层去重”?
排序 + used 数组
去重的几种方法?
- 排序 + used 数组
- Set
如何理解二维递归?
递归套在二层循环中
这里的每一个问题,网上几乎找不到能讲清楚的文章,这也是直击回溯算法本质的问题。
相信一路坚持下来的录友们,对回溯算法已经都深刻的认识。
此时回溯算法系列就要正式告一段落了。
录友们可以回顾一下这 21 天,每天的打卡,每天在交流群里和大家探讨代码,最终换来的都是不知不觉的成长。
同样也感谢录友们的坚持,这也是我持续写作的动力,正是因为大家的积极参与,我才知道这件事件是非常有意义的。
回溯专题汇聚为一张图:
# 贪心算法
# 贪心算法理论基础
题目分类大纲如下:
# 贪心的定义
贪心的本质是选择每一阶段的局部最优,从而达到全局最优。
这么说有点抽象,来举一个例子:
例如,有一堆钞票,你可以拿走十张,如果想达到最大的金额,你要怎么拿?
指定每次拿最大的,最终结果就是拿走最大数额的钱。
每次拿最大的就是局部最优,最后拿走最大数额的钱就是推出全局最优。
再举一个例子如果是 有一堆盒子,你有一个背包体积为 n,如何把背包尽可能装满,如果还每次选最大的盒子,就不行了。这时候就需要动态规划。动态规划的问题在下一个系列会详细讲解。
# 什么时候用贪心
很多同学做贪心的题目的时候,想不出来是贪心,想知道有没有什么套路可以一看就看出来是贪心。
说实话贪心算法并没有固定的套路。
所以唯一的难点就是如何通过局部最优,推出整体最优。
那么如何能看出局部最优是否能推出整体最优呢?有没有什么固定策略或者套路呢?
不好意思,也没有! 靠自己手动模拟,如果模拟可行,就可以试一试贪心策略,如果不可行,可能需要动态规划。
有同学问了如何验证可不可以用贪心算法呢?
最好用的策略就是举反例,如果想不到反例,那么就试一试贪心吧。
可有有同学认为手动模拟,举例子得出的结论不靠谱,想要严格的数学证明。
一般数学证明有如下两种方法:
- 数学归纳法
- 反证法
看教课书上讲解贪心可以是一堆公式,估计大家连看都不想看,所以数学证明就不在我要讲解的范围内了,大家感兴趣可以自行查找资料。
面试中基本不会让面试者现场证明贪心的合理性,代码写出来跑过测试用例即可,或者自己能自圆其说理由就行了。
举一个不太恰当的例子:我要用一下 1+1 = 2,但我要先证明 1+1 为什么等于 2。严谨是严谨了,但没必要。
虽然这个例子很极端,但可以表达这么个意思:刷题或者面试的时候,手动模拟一下感觉可以局部最优推出整体最优,而且想不到反例,那么就试一试贪心。
例如刚刚举的拿钞票的例子,就是模拟一下每次拿做大的,最后就能拿到最多的钱,这还要数学证明的话,其实就不在算法面试的范围内了,可以看看专业的数学书籍!
所以这也是为什么很多同学通过(accept)了贪心的题目,但都不知道自己用了贪心算法,因为贪心有时候就是常识性的推导,所以会认为本应该就这么做!
那么刷题的时候什么时候真的需要数学推导呢?
例如这道题目:[142. 环形链表 II](#142. 环形链表 II),这道题不用数学推导一下,就找不出环的起始位置,想试一下就不知道怎么试,这种题目确实需要数学简单推导一下。
# 贪心算法解题步骤
贪心算法一般分为如下四步:
- 将问题分解为若干个子问题
- 找出适合的贪心策略
- 求解每一个子问题的最优解
- 将局部最优解堆叠成全局最优解
其实这个分的有点细了,真正做题的时候很难分出这么详细的解题步骤,可能就是因为贪心的题目往往还和其他方面的知识混在一起。
# 总结
本篇给出了什么是贪心以及大家关心的贪心算法固定套路。
不好意思了,贪心没有套路,说白了就是常识性推导加上举反例。
最后给出贪心的一般解题步骤,大家可以发现这个解题步骤也是比较抽象的,不像是二叉树,回溯算法,给出了那么具体的解题套路和模板。
# 455. 分发饼干
为了满足更多的小孩,就不要造成饼干尺寸的浪费。
大尺寸的饼干既可以满足胃口大的孩子也可以满足胃口小的孩子,那么就应该优先满足胃口大的。
这里的局部最优就是大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩。
可以尝试使用贪心策略,先将饼干数组和小孩数组排序。
然后从后向前遍历小孩数组,用大饼干优先满足胃口大的,并统计满足小孩数量。
如图:
这个例子可以看出饼干 9 只有喂给胃口为 7 的小孩,这样才是整体最优解,并想不出反例,那么就可以撸代码了。
class Solution { | |
// 贪心策略:大饼干先分给大胃口孩子 | |
public int findContentChildren(int[] g, int[] s) { | |
Arrays.sort(g); | |
Arrays.sort(s); | |
int index = s.length - 1; // 饼干的索引 | |
int res = 0; // 满足的孩子数 | |
for (int i = g.length - 1; i >= 0; i--) { // 从胃口最大的孩子开始遍历 | |
if (index >= 0 && s[index] >= g[i]) { //index>=0 是为了防止饼干不够分 | |
res++; | |
index--; | |
} | |
} | |
return res; | |
} | |
} |
从代码中可以看出我用了一个 index 来控制饼干数组的遍历,遍历饼干并没有再起一个 for 循环,而是采用自减的方式,这也是常用的技巧。
有的同学看到要遍历两个数组,就想到用两个 for 循环,那样逻辑其实就复杂了。
也可以换一个思路,小饼干先喂饱小胃口
class Solution { | |
// 贪心策略:小饼干先喂小胃口孩子 | |
public int findContentChildren(int[] g, int[] s) { | |
Arrays.sort(g); | |
Arrays.sort(s); | |
int index = 0; // 饼干的索引 | |
int res = 0; // 满足的孩子数 | |
for (int i = 0; i < s.length; i++) { | |
if (index < g.length && s[i] >= g[index]) { | |
res++; | |
index++; | |
} | |
} | |
return res; | |
} | |
} |
# 总结
这道题是贪心很好的一道入门题目,思路还是比较容易想到的。
文中详细介绍了思考的过程,想清楚局部最优,想清楚全局最优,感觉局部最优是可以推出全局最优,并想不出反例,那么就试一试贪心。
# 376. 摆动序列
# 贪心算法
本题要求通过从原始序列中删除一些(也可以不删除)元素来获得子序列,剩下的元素保持其原始顺序。
相信这么一说吓退不少同学,这要求最大摆动序列又可以修改数组,这得如何修改呢?
来分析一下,要求删除元素使其达到最大摆动序列,应该删除什么元素呢?
用示例二来举例,如图所示:
局部最优:删除单调坡度上的节点(不包括单调坡度两端的节点),那么这个坡度就可以有两个局部峰值。
整体最优:整个序列有最多的局部峰值,从而达到最长摆动序列。
局部最优推出全局最优,并举不出反例,那么试试贪心!
(为方便表述,以下说的峰值都是指局部峰值)
实际操作上,其实连删除的操作都不用做,因为题目要求的是最长摆动子序列的长度,所以只需要统计数组的峰值数量就可以了(相当于是删除单一坡度上的节点,然后统计长度)
这就是贪心所贪的地方,让峰值尽可能的保持峰值,然后删除单一坡度上的节点。
本题代码实现中,还有一些技巧,例如统计峰值的时候,数组最左面和最右面是最不好统计的。
例如序列 [2,5],它的峰值数量是 2,如果靠统计差值来计算峰值个数就需要考虑数组最左面和最右面的特殊情况。
所以可以针对序列 [2,5],可以假设为 [2,2,5],这样它就有坡度了即 preDiff = 0,如图:
针对以上情形,result 初始为 1(默认最右面有一个峰值),此时 curDiff > 0 && preDiff <= 0,那么 result++(计算了左面的峰值),最后得到的 result 就是 2(峰值个数为 2 即摆动序列长度为 2)
class Solution { | |
// 贪心算法 | |
public int wiggleMaxLength(int[] nums) { | |
// 特判:数组长度小于 2,直接返回数组长度 | |
if (nums.length < 2) { | |
return nums.length; | |
} | |
int res = 1; // 记录摆动序列的长度,初始化为 1 | |
int preDiff = 0; // 记录前一个差值 | |
int curDiff = 0; // 记录当前差值 | |
for (int i = 0; i < nums.length - 1; i++) { | |
curDiff = nums[i + 1] - nums[i]; | |
// 判断是否出现峰值,即 curDiff 和 preDiff 是否异号 | |
if ((curDiff > 0 && preDiff <= 0) || (curDiff < 0 && preDiff >= 0)) { | |
res++; | |
preDiff = curDiff; | |
} | |
} | |
return res; | |
} | |
} // 时间复杂度:O (n),空间复杂度:O (1) |
# 总结
贪心的题目说简单有的时候就是常识,说难就难在都不知道该怎么用贪心。
本题大家如果要去模拟删除元素达到最长摆动子序列的过程,那指定绕里面去了,一时半会拔不出来。
而这道题目有什么技巧说一下子能想到贪心么?
其实也没有,类似的题目做过了就会想到。
此时大家就应该了解了:保持区间波动,只需要把单调区间上的元素移除就可以了。
# 53. 最大子数组和
# 暴力解法
暴力解法的思路,第一层 for 就是设置起始位置,第二层 for 循环遍历数组寻找最大值
- 时间复杂度:
- 空间复杂度:
class Solution { | |
// 暴力解法,时间复杂度 O (n^2),空间复杂度 O (1) | |
public int maxSubArray(int[] nums) { | |
int res = Integer.MIN_VALUE; | |
for (int i = 0; i < nums.length; i++) { | |
int tmpSum = 0; | |
for (int j = i; j < nums.length; j++) { | |
tmpSum += nums[j]; | |
res = Math.max(res, tmpSum); | |
} | |
} | |
return res; | |
} | |
} |
可是超时咯!
# 贪心解法
贪心贪的是哪里呢?
如果 -2 1 在一起,计算起点的时候,一定是从 1 开始计算,因为负数只会拉低总和,这就是贪心贪的地方!
局部最优:当前 “连续和” 为负数的时候立刻放弃,从下一个元素重新计算 “连续和”,因为负数加上下一个元素 “连续和” 只会越来越小。
全局最优:选取最大 “连续和”
局部最优的情况下,并记录最大的 “连续和”,可以推出全局最优。
从代码角度上来讲:遍历 nums,从头开始用 count 累积,如果 count 一旦加上 nums [i] 变为负数,那么就应该从 nums [i+1] 开始从 0 累积 count 了,因为已经变为负数的 count,只会拖累总和。
这相当于是暴力解法中的不断调整最大子序和区间的起始位置。
那有同学问了,区间终止位置不用调整么? 如何才能得到最大 “连续和” 呢?
区间的终止位置,其实就是如果 count 取到最大值了,及时记录下来了。例如如下代码:
if (count > result) result = count; |
这样相当于是用 result 记录最大子序和区间和(变相的算是调整了终止位置)。
如动画所示:
红色的起始位置就是贪心每次取 count 为正数的时候,开始一个区间的统计。
class Solution { | |
// 贪心算法,时间复杂度 O (n),空间复杂度 O (1) | |
public int maxSubArray(int[] nums) { | |
int res = Integer.MIN_VALUE; | |
int count = 0; | |
for (int i = 0; i < nums.length; i++) { | |
count += nums[i]; | |
if (count > res) { // 更新最大值 | |
res = count; | |
} | |
if (count < 0) { // 如果 count 小于 0,说明前面的数对后面的数没有增益,所以舍弃 | |
count = 0; | |
} | |
} | |
return res; | |
} | |
} |
当然题目没有说如果数组为空,应该返回什么,所以数组为空的话返回啥都可以了。
不少同学认为 如果输入用例都是 - 1,或者 都是负数,这个贪心算法跑出来的结果是 0, 这是又一次证明脑洞模拟不靠谱的经典案例,建议大家把代码运行一下试一试,就知道了,也会理解 为什么 result 要初始化为最小负数了。
# 总结
本题的贪心思路其实并不好想,这也进一步验证了,别看贪心理论很直白,有时候看似是常识,但贪心的题目一点都不简单!
后续将介绍的贪心题目都挺难的,哈哈,所以贪心很有意思,别小看贪心!
# 贪心周总结一
# 周一
贪心的本质是选择每一阶段的局部最优,从而达到全局最优。
有没有啥套路呢?
不好意思,贪心没套路,就刷题而言,如果感觉好像局部最优可以推出全局最优,然后想不到反例,那就试一试贪心吧!
# 周二
在 [455. 分发饼干](#455. 分发饼干) 中讲解了贪心算法的第一道题目。
这道题目很明显能看出来是用贪心,也是入门好题。
我在文中给出局部最优:大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优:喂饱尽可能多的小孩。
很多录友都是用小饼干优先先喂饱小胃口的。
后来我想一想,虽然结果是一样的,但是大家的这个思考方式更好一些。
因为用小饼干优先喂饱小胃口的 这样可以尽量保证最后省下来的是大饼干(虽然题目没有这个要求)!
所有还是小饼干优先先喂饱小胃口更好一些,也比较直观。
一些录友不清楚 [455. 分发饼干](#455. 分发饼干) 中时间复杂度是怎么来的?
就是快排 O (nlog n),遍历 O (n),加一起就是还是 O (nlogn)。
# 周三
接下来就要上一点难度了,要不然大家会误以为贪心算法就是常识判断一下就行了。
在 [376. 摆动序列](#376. 摆动序列) 中,需要计算最长摇摆序列。
其实就是让序列有尽可能多的局部峰值。
局部最优:删除单调坡度上的节点(不包括单调坡度两端的节点),那么这个坡度就可以有两个局部峰值。
整体最优:整个序列有最多的局部峰值,从而达到最长摆动序列。
在计算峰值的时候,还是有一些代码技巧的,例如序列两端的峰值如何处理。
这些技巧,其实还是要多看多用才会掌握。
# 周四
在 [53. 最大子数组和](#53. 最大子数组和) 中,详细讲解了用贪心的方式来求最大子序列和,其实这道题目是一道动态规划的题目。
贪心的思路为局部最优:当前 “连续和” 为负数的时候立刻放弃,从下一个元素重新计算 “连续和”,因为负数加上下一个元素 “连续和” 只会越来越小。从而推出全局最优:选取最大 “连续和”
代码很简单,但是思路却比较难。还需要反复琢磨。
针对 [53. 最大子数组和](#53. 最大子数组和) 文章中给出的贪心代码如下;
class Solution { | |
// 贪心算法,时间复杂度 O (n),空间复杂度 O (1) | |
public int maxSubArray(int[] nums) { | |
int res = Integer.MIN_VALUE; | |
int count = 0; | |
for (int i = 0; i < nums.length; i++) { | |
count += nums[i]; | |
if (count > res) { // 更新最大值 | |
res = count; | |
} | |
if (count < 0) { // 如果 count 小于 0,说明前面的数对后面的数没有增益,所以舍弃 | |
count = 0; | |
} | |
} | |
return res; | |
} | |
} |
如果数组全是负数这个代码就有问题了,如果数组里有 int 最小值这个代码就有问题了。
大家不要脑洞模拟哈,可以亲自构造一些测试数据试一试,就发现其实没有问题。
数组都为负数,result 记录的就是最小的负数,如果数组里有 int 最小值,那么最终 result 就是 int 最小值。
# 总结
本周我们讲解了贪心算法理论基础,了解了贪心本质:局部最优推出全局最优。
然后讲解了第一道题目 [455. 分发饼干](#455. 分发饼干),还是比较基础的,可能会给大家一种贪心算法比较简单的错觉,因为贪心有时候接近于常识。
其实我还准备一些简单的贪心题目,甚至网上很多都质疑这些题目是不是贪心算法。这些题目我没有立刻发出来,因为真的会让大家感觉贪心过于简单,而忽略了贪心的本质:局部最优和全局最优两个关键点。
所以我在贪心系列难度会有所交替,难的题目在于拓展思路,简单的题目在于分析清楚其贪心的本质,后续我还会发一些简单的题目来做贪心的分析。
在 [376. 摆动序列](#376. 摆动序列) 中大家就初步感受到贪心没那么简单了。
本周最后是 [53. 最大子数组和](#53. 最大子数组和),这道题目要用贪心的方式做出来,就比较有难度,都知道负数加上正数之后会变小,但是这道题目依然会让很多人搞混淆,其关键在于:不能让 “连续和” 为负数的时候加上下一个元素,而不是不让 “连续和” 加上一个负数。这块真的需要仔细体会!
# 122. 买卖股票的最佳时机 II
# 思路
本题首先要清楚两点:
- 只有一只股票!
- 当前只有买股票或者卖股票的操作
想获得利润至少要两天为一个交易单元。
# 贪心解法
这道题目可能我们只会想,选一个低的买入,再选个高的卖,再选一个低的买入..... 循环反复。
如果想到其实最终利润是可以分解的,那么本题就很容易了!
如何分解呢?
假如第 0 天买入,第 3 天卖出,那么利润为:prices [3] - prices [0]。
相当于 (prices [3] - prices [2]) + (prices [2] - prices [1]) + (prices [1] - prices [0])。
此时就是把利润分解为每天为单位的维度,而不是从 0 天到第 3 天整体去考虑!
那么根据 prices 可以得到每天的利润序列:(prices [i] - prices [i - 1]).....(prices [1] - prices [0])。
如图:
一些同学陷入:第一天怎么就没有利润呢,第一天到底算不算的困惑中。
第一天当然没有利润,至少要第二天才会有利润,所以利润的序列比股票序列少一天!
从图中可以发现,其实我们需要收集每天的正利润就可以,收集正利润的区间,就是股票买卖的区间,而我们只需要关注最终利润,不需要记录区间。
那么只收集正利润就是贪心所贪的地方!
局部最优:收集每天的正利润,全局最优:求得最大利润。
局部最优可以推出全局最优,找不出反例,试一试贪心!
// 贪心算法 | |
class Solution { | |
public int maxProfit(int[] prices) { | |
int res = 0; | |
for (int i = 1; i < prices.length; i++) { | |
int diff = prices[i] - prices[i - 1]; | |
if (diff > 0) { // 只要有利润就卖出 | |
res += diff; | |
} | |
} | |
return res; | |
} | |
} // 时间复杂度:O (n) 空间复杂度:O (1) |
# 总结
股票问题其实是一个系列的,属于动态规划的范畴,因为目前在讲解贪心系列,所以股票问题会在之后的动态规划系列中详细讲解。
可以看出有时候,贪心往往比动态规划更巧妙,更好用,所以别小看了贪心算法。
本题中理解利润拆分是关键点! 不要整块的去看,而是把整体利润拆为每天的利润。
一旦想到这里了,很自然就会想到贪心了,即:只收集每天的正利润,最后稳稳的就是最大利润了。
# 55. 跳跃游戏
# 思路
刚看到本题一开始可能想:当前位置元素如果是 3,我究竟是跳一步呢,还是两步呢,还是三步呢,究竟跳几步才是最优呢?
其实跳几步无所谓,关键在于可跳的覆盖范围!
不一定非要明确一次究竟跳几步,每次取最大的跳跃步数,这个就是可以跳跃的覆盖范围。
这个范围内,别管是怎么跳的,反正一定可以跳过来。
那么这个问题就转化为跳跃覆盖范围究竟可不可以覆盖到终点!**
每次移动取最大跳跃步数(得到最大的覆盖范围),每移动一个单位,就更新最大覆盖范围。
贪心算法局部最优解:每次取最大跳跃步数(取最大覆盖范围),整体最优解:最后得到整体最大覆盖范围,看是否能到终点。
局部最优推出全局最优,找不出反例,试试贪心!
如图:
i 每次移动只能在 cover 的范围内移动,每移动一个元素,cover 得到该元素数值(新的覆盖范围)的补充,让 i 继续移动下去。
而 cover 每次只取 max (该元素数值补充后的范围,cover 本身范围)。
如果 cover 大于等于了终点下标,直接 return true 就可以了。
class Solution { | |
// 贪心算法:每次都选择最大的步数,判断覆盖范围是否能到达最后一个位置 | |
public boolean canJump(int[] nums) { | |
// 特判 | |
if (nums.length == 1) return true; | |
int cover = 0; // 覆盖范围 | |
for (int i = 0; i <= cover; i++) { // 注意是 & lt;=,因为 cover 是可以到达的最大位置 | |
int maxStep = i + nums[i]; // 当前位置最大步数 | |
if (maxStep > cover) { | |
cover = maxStep; // 更新覆盖范围 | |
} | |
if (cover >= nums.length - 1) { // 覆盖范围大于等于最后一个位置,说明可以到达 | |
return true; | |
} | |
} | |
return false; | |
} | |
} |
# 总结
这道题目关键点在于:不用拘泥于每次究竟跳几步,而是看覆盖范围,覆盖范围内一定是可以跳过来的,不用管是怎么跳的。
大家可以看出思路想出来了,代码还是非常简单的。
一些同学可能感觉,我在讲贪心系列的时候,题目和题目之间貌似没有什么联系?
** 是真的就是没什么联系,因为贪心无套路!** 没有个整体的贪心框架解决一系列问题,只能是接触各种类型的题目锻炼自己的贪心思维!
# ☆45. 跳跃游戏 II
# 思路
本题相对于 [55. 跳跃游戏](#55. 跳跃游戏) 还是难了不少。
但思路是相似的,还是要看最大覆盖范围。
本题要计算最小步数,那么就要想清楚什么时候步数才一定要加一呢?
贪心的思路,
- 局部最优:当前可移动距离尽可能多走,如果还没到终点,步数再加一。
- 整体最优:一步尽可能多走,从而达到最小步数。
思路虽然是这样,但在写代码的时候还不能真的能跳多远就跳多远,那样就不知道下一步最远能跳到哪里了。
所以真正解题的时候,要从覆盖范围出发,不管怎么跳,覆盖范围内一定是可以跳到的,以最小的步数增加覆盖范围,覆盖范围一旦覆盖了终点,得到的就是最小步数!
这里需要统计两个覆盖范围,当前这一步的最大覆盖和下一步最大覆盖。
如果移动下标达到了当前这一步的最大覆盖最远距离了,还没有到终点的话,那么就必须再走一步来增加覆盖范围,直到覆盖范围覆盖了终点。
如图:
图中覆盖范围的意义在于,只要红色的区域,最多两步一定可以到!(不用管具体怎么跳,反正一定可以跳到)
# 方法一
从图中可以看出来,就是移动下标达到了当前覆盖的最远距离下标时,步数就要加一,来增加覆盖距离。最后的步数就是最少步数。
这里还是有个特殊情况需要考虑,当移动下标达到了当前覆盖的最远距离下标时
- 如果当前覆盖最远距离下标不是是集合终点,步数就加一,还需要继续走。
- 如果当前覆盖最远距离下标就是是集合终点,步数不用加一,因为不能再往后走了。
class Solution { | |
// 贪心算法 | |
public int jump(int[] nums) { | |
// 特判 | |
if (nums.length == 1) return 0; | |
int res = 0; // 记录跳跃次数 | |
int curCover = 0; // 当前能覆盖的最远距离 | |
int nextCover = 0; // 下一步能覆盖的最远距离 | |
for (int i = 0; i < nums.length; i++) { | |
int tmp = i + nums[i]; // 当前位置能覆盖的最远距离 | |
nextCover = Math.max(nextCover, tmp); // 更新下一步能覆盖的最远距离 | |
if (i == curCover) { // 当前位置已经到达当前能覆盖的最远距离 | |
if (curCover != nums.length - 1) { // 仍未到达终点 | |
res++; // 跳跃次数加一 | |
curCover = nextCover; // 更新当前能覆盖的最远距离 | |
if (nextCover >= nums.length - 1) break; // 如果下一步能覆盖的最远距离已经大于等于数组长度,直接跳出循环 | |
} else { | |
break; // 已经到达终点,跳出循环 | |
} | |
} | |
} | |
return res; | |
} | |
} |
# √方法二
依然是贪心,思路和方法一差不多,代码可以简洁一些。
针对于方法一的特殊情况,可以统一处理,即:移动下标只要遇到当前覆盖最远距离的下标,直接步数加一,不考虑是不是终点的情况。
想要达到这样的效果,只要让移动下标,最大只能移动到 nums.size - 2 的地方就可以了。
因为当移动下标指向 nums.size - 2 时:
- 如果移动下标等于当前覆盖最大距离下标, 需要再走一步(即 ans++),因为最后一步一定是可以到的终点。(题目假设总是可以到达数组的最后一个位置),如图:
- 如果移动下标不等于当前覆盖最大距离下标,说明当前覆盖最远距离就可以直接达到终点了,不需要再走一步。如图:
class Solution { | |
// 贪心算法 | |
public int jump(int[] nums) { | |
// 特判 | |
if (nums.length == 1) return 0; | |
int res = 0; // 记录跳跃次数 | |
int curCover = 0; // 当前能覆盖的最远距离 | |
int nextCover = 0; // 下一步能覆盖的最远距离 | |
for (int i = 0; i < nums.length - 1; i++) { | |
int tmp = i + nums[i]; // 当前位置能覆盖的最远距离 | |
nextCover = Math.max(nextCover, tmp); // 更新下一步能覆盖的最远距离 | |
if (i == curCover) { // 当前位置已经到达了当前能覆盖的最远距离 | |
res++; // 跳跃次数加一 | |
curCover = nextCover; // 更新当前能覆盖的最远距离 | |
} | |
} | |
return res; | |
} | |
} |
可以看出版本二的代码相对于版本一简化了不少!
其精髓在于控制移动下标 i 只移动到 nums.size () - 2 的位置,所以移动下标只要遇到当前覆盖最远距离的下标,直接步数加一,不用考虑别的了。
# 总结
相信大家可以发现,这道题目相当于 [55. 跳跃游戏](#55. 跳跃游戏) 难了不止一点。
但代码又十分简单,贪心就是这么巧妙。
理解本题的关键在于:以最小的步数增加最大的覆盖范围,直到覆盖范围覆盖了终点,这个范围内最小步数一定可以跳到,不用管具体是怎么跳的,不纠结于一步究竟跳一个单位还是两个单位。
# 1005. K 次取反后最大化的数组和
# 思路
本题思路其实比较好想了,如何可以让数组和最大呢?
贪心的思路,局部最优:让绝对值大的负数变为正数,当前数值达到最大,整体最优:整个数组和达到最大。
局部最优可以推出全局最优。
那么如果将负数都转变为正数了,K 依然大于 0,此时的问题是一个有序正整数序列,如何转变 K 次正负,让 数组和 达到最大。
那么又是一个贪心:局部最优:只找数值最小的正整数进行反转,当前数值可以达到最大(例如正整数数组 {5, 3, 1},反转 1 得到 - 1 比 反转 5 得到的 - 5 大多了),全局最优:整个 数组和 达到最大。
虽然这道题目大家做的时候,可能都不会去想什么贪心算法,一鼓作气,就 AC 了。
我这里其实是为了给大家展现出来 经常被大家忽略的贪心思路,这么一道简单题,就用了两次贪心!
那么本题的解题步骤为:
第一步:将数组按照绝对值大小降序排序,注意要按照绝对值的大小
Java 中可以直接通过
Arrays.sort(nums,(a,b)->(Math.abs(b)-Math.abs(a)));
对Integer
数组进行绝对值降序排序,但是很难直接对int
数组进行。因此,需要通过
nums = Arrays.stream(nums) // 转换为流
.boxed() // 装箱
.sorted((a, b) -> Math.abs(b) - Math.abs(a))
.mapToInt(Integer::intValue).toArray(); // 拆箱,转换为 int []
来实现对
int
数组按绝对值降序排序。第二步:从前向后遍历,遇到负数将其变为正数,同时 K--
第三步:如果 K 还大于 0,说明所有负数均已取反为正,那么直接反复取反绝对值最小的元素,将 K 用完
若 k 为偶数,不用进行任何处理,因为取反 k 次等于不取反;
若 k 为奇数,直接对绝对值最小的元素进行取反即可,因为取反 k 次等于取反 1 次;
第四步:求和
class Solution { | |
// 贪心算法:优先取反最小的数 | |
public int largestSumAfterKNegations(int[] nums, int k) { | |
// 第一步:按照 绝对值大小 降序排序 | |
nums = Arrays.stream(nums) // 转换为流 | |
.boxed() // 装箱 | |
.sorted((a, b) -> Math.abs(b) - Math.abs(a)) | |
.mapToInt(Integer::intValue).toArray(); // 拆箱,转换为 int [] | |
// 第二步:遍历,遇到负数就取反,同时 k-- | |
for (int i = 0; i < nums.length; i++) { | |
if (nums[i] < 0 && k > 0) { | |
nums[i] = -nums[i]; | |
k--; | |
} | |
} | |
// 第三步:如果 K 还大于 0,说明所有负数均已取反为正,那么直接反复取反绝对值最小的元素,将 K 用完 | |
if (k % 2 == 1) { // 若 k 为奇数,直接对绝对值最小的元素进行取反即可,因为取反 k 次等于取反 1 次 | |
nums[nums.length - 1] *= -1; | |
}// 若 k 为偶数,不用进行任何处理,因为取反 k 次等于不取反 | |
// 第四步:求和 | |
int res = 0; | |
for (int num : nums) { | |
res += num; | |
} | |
return res; | |
} | |
} // 时间复杂度:O (nlogn);空间复杂度:O (logn) |
# 总结
贪心的题目如果简单起来,会让人简单到开始怀疑:本来不就应该这么做么?这也算是算法?我认为这不是贪心?
本题其实很简单,不会贪心算法的同学都可以做出来,但是我还是全程用贪心的思路来讲解。
因为贪心的思考方式一定要有!
如果没有贪心的思考方式(局部最优,全局最优),很容易陷入贪心简单题凭感觉做,贪心难题直接不会做,其实这样就锻炼不了贪心的思考方式了。
所以明知道是贪心简单题,也要靠贪心的思考方式来解题,这样对培养解题感觉很有帮助。
# 贪心周总结二
# 周一
一说到股票问题,一般都会想到动态规划,其实有时候贪心更有效!
在 [122. 买卖股票的最佳时机 II](#122. 买卖股票的最佳时机 II) 中,讲到只能多次买卖一支股票,如何获取最大利润。
这道题目理解利润拆分是关键点! 不要整块的去看,而是把整体利润拆为每天的利润,就很容易想到贪心了。
局部最优:只收集每天的正利润,全局最优:得到最大利润。
如果正利润连续上了,相当于连续持有股票,而本题并不需要计算具体的区间。
如图:
# 周二
在 [55. 跳跃游戏](#55. 跳跃游戏) 中是给你一个数组看能否跳到终点。
本题贪心的关键是:不用拘泥于每次究竟跳几步,而是看覆盖范围,覆盖范围内一定是可以跳过来的,不用管是怎么跳的。
那么这个问题就转化为跳跃覆盖范围究竟可不可以覆盖到终点!
贪心算法局部最优解:移动下标每次取最大跳跃步数(取最大覆盖范围),整体最优解:最后得到整体最大覆盖范围,看是否能到终点
如果覆盖范围覆盖到了终点,就表示一定可以跳过去。
如图:
# 周三
这道题目:[☆45. 跳跃游戏 II](#☆45. 跳跃游戏 II) 可就有点难了。
本题解题关键在于:以最小的步数增加最大的覆盖范围,直到覆盖范围覆盖了终点。
那么局部最优:求当前这步的最大覆盖,那么尽可能多走,到达覆盖范围的终点,只需要一步。整体最优:达到终点,步数最少。
如图:
注意:图中的移动下标是到当前这步覆盖的最远距离(下标 2 的位置),此时没有到终点,只能增加第二步来扩大覆盖范围。
在 [☆45. 跳跃游戏 II](#☆45. 跳跃游戏 II) 中我给出了两个版本的代码。
其实本质都是超过当前覆盖范围,步数就加一,但版本一需要考虑当前覆盖最远距离下标是不是数组终点的情况。
而版本二就比较统一的,超过范围,步数就加一,但在移动下标的范围了做了文章。
即如果覆盖最远距离下标是倒数第二点:直接加一就行,默认一定可以到终点。如图:
如果覆盖最远距离下标不是倒数第二点,说明本次覆盖已经到终点了。如图:
有的录友认为版本一好理解,有的录友认为版本二好理解,其实掌握一种就可以了,也不用非要比拼一下代码的简洁性,简洁程度都差不多了。
我个人倾向于版本一的写法,思路清晰一点,版本二会有点绕。
# 周四
这道题目:[1005. K 次取反后最大化的数组和](#1005. K 次取反后最大化的数组和) 就比较简单了,哈哈,用简单题来讲一讲贪心的思想。
这里其实用了两次贪心!
第一次贪心:
- 局部最优:让绝对值大的负数变为正数,当前数值达到最大
- 整体最优:整个数组和达到最大。
处理之后,如果 K 依然大于 0,此时的问题是一个有序正整数序列,如何转变 K 次正负,让 数组和 达到最大。
第二次贪心:
- 局部最优:只找数值最小的正整数进行反转,当前数值可以达到最大(例如正整数数组 {5, 3, 1},反转 1 得到 - 1 比 反转 5 得到的 - 5 大多了)
- 全局最优:整个 数组和 达到最大。
[1005. K 次取反后最大化的数组和](#1005. K 次取反后最大化的数组和) 中的代码,最后 while 处理 K 的时候,其实直接判断奇偶数就可以了,文中给出的方式太粗暴了,哈哈,Carl 大意了。
例外一位录友留言给出一个很好的建议,因为文中是使用快排,仔细看题,题目中限定了数据范围是正负一百,所以可以使用桶排序,这样时间复杂度就可以优化为 了。但可能代码要复杂一些了。
# 总结
大家会发现本周的代码其实都简单,但思路却很巧妙,并不容易写出来。
如果是第一次接触的话,其实很难想出来,就是接触过之后就会了,所以大家不用感觉自己想不出来而烦躁,哈哈。
相信此时大家现在对贪心算法又有一个新的认识了,加油💪
# ☆134. 加油站
# 暴力解法
暴力的方法很明显就是 的,遍历每一个加油站为起点的情况,模拟一圈。
如果跑了一圈,中途没有断油,而且最后油量大于等于 0,说明这个起点是 ok 的。
暴力的方法思路比较简单,但代码写起来也不是很容易,关键是要模拟跑一圈的过程。
for 循环适合模拟从头到尾的遍历,而 while 循环适合模拟环形遍历,要善于使用 while!
class Solution { | |
// 暴力解法:从每个加油站出发,看能否走完一圈 | |
public int canCompleteCircuit(int[] gas, int[] cost) { | |
for (int i = 0; i < gas.length; i++) { // 从每个加油站出发 | |
int rest = gas[i] - cost[i]; // 剩余油量 | |
int idx = (i + 1) % gas.length; // 下一个加油站的索引 | |
while (idx != i && rest >= 0) { // 未回到起点,且油量充足 | |
rest += gas[idx] - cost[idx]; | |
idx = (idx + 1) % gas.length; | |
} | |
if (idx == i && rest >= 0) { // 回到起点,且油量充足 | |
return i; | |
} | |
} | |
return -1; // 无解,返回 -1 | |
} | |
} // 时间复杂度:O (n^2);空间复杂度:O (1) |
超时咯!
# 贪心解法(方法一)
直接从全局进行贪心选择,情况如下:
情况一:如果
gas
的总和小于cost
总和,那么无论从哪里出发,一定是跑不了一圈的情况二:
rest[i] = gas[i]-cost[i]
为一天剩下的油,i 从 0 开始计算累加到最后一站,- 如果累加没有出现负数,说明从 0 出发,油就没有断过,那么 0 就是起点
- 如果累加的最小值是负数,汽车就要从非 0 节点出发,从后向前,看哪个节点能这个负数填平,能把这个负数填平的节点就是出发节点。
情况三不理解┭┮﹏┭┮
class Solution { | |
// 贪心解法,三种情况: | |
// 1. 如果 gas 总和小于 cost 总和,那么一定无解,直接返回 - 1 | |
// 2. rest [i] = gas [i] - cost [i] 为到达 i+1 站剩余的油量,i 从 0 累加到 n-1 | |
// 2.1 如果累加没有出现负数,说明从 0 出发,油就没有断过,那么 0 就是起点 | |
// 2.2 如果累加的最小值是负数,那么从 0 出发,一定无解,那么就要从非 0 节点出发,从后向前,看哪个节点能这个负数填平,能把这个负数填平的节点就是出发节点 | |
public int canCompleteCircuit(int[] gas, int[] cost) { | |
int curSum = 0; // 当前累加和,即当前剩余油量 | |
int minSum = Integer.MAX_VALUE; // 从起点出发,油箱里的最小值 | |
for (int i = 0; i < gas.length; i++) { // 从 0 出发,看能不能走完一圈 | |
curSum += gas[i] - cost[i]; // 累加上当前的剩余油量 | |
minSum = Math.min(minSum, curSum); // 更新最小值 | |
} | |
if (curSum < 0) return -1; // 情况 1:无解 | |
if (minSum >= 0) return 0; // 情况 2.1:从 0 出发,油就没有断过,那么 0 就可以作起点 | |
// 情况 2.2 | |
for (int i = gas.length - 1; i >= 0; i--) { | |
minSum += gas[i] - cost[i]; // 从后向前,看哪个节点能这个负数填平 | |
if (minSum >= 0) return i; // 能填平,那么这个节点就是出发节点 | |
} | |
return -1; | |
} | |
} // 时间复杂度:O (n),空间复杂度:O (1) |
- 时间复杂度:
- 空间复杂度:
其实我不认为这种方式是贪心算法,因为没有找出局部最优,而是直接从全局最优的角度上思考问题。
但这种解法又说不出是什么方法,这就是一个从全局角度选取最优解的模拟操作。
所以对于本解法是贪心,我持保留意见!
但不管怎么说,解法毕竟还是巧妙的,不用过于执着于其名字称呼。
# √贪心解法(方法二)
可以换一个思路,首先如果总油量减去总消耗大于等于零那么一定可以跑完一圈,说明 各个站点的加油站 剩油量 rest [i] 相加一定是大于等于零的。
每个加油站的剩余量 rest [i] 为 gas [i] - cost [i]。
i 从 0 开始累加 rest [i],和记为 curSum,一旦 curSum 小于零,说明 [0, i] 区间都不能作为起始位置,起始位置从 i+1 算起,再从 0 计算 curSum。
如图:
那么为什么一旦 [i,j] 区间和为负数,起始位置就可以是 j+1 呢,j+1 后面就不会出现更大的负数?
如果出现更大的负数,就是更新 j,那么起始位置又变成新的 j+1 了。
而且 j 之前出现了多少负数,j 后面就会出现多少正数,因为耗油总和是大于零的(前提我们已经确定了一定可以跑完全程)。
- 局部最优:当前累加 rest [j] 的和 curSum 一旦小于 0,起始位置至少要是 j+1,因为从 j 开始一定不行
- 全局最优:找到可以跑一圈的起始位置
局部最优可以推出全局最优,找不出反例,试试贪心!
class Solution { | |
// 贪心解法二 | |
public int canCompleteCircuit(int[] gas, int[] cost) { | |
int curSum = 0; // 当前累加和,即当前剩余油量 | |
int totalSum = 0; // 总累加和,即总剩余油量 | |
int start = 0; // 起始位置 | |
for (int i = 0; i < gas.length; i++) { | |
curSum += gas[i] - cost[i]; | |
totalSum += gas[i] - cost[i]; | |
if (curSum < 0) { // 当前剩余油量小于 0,说明从 start 到 i 这段路程无法走完 | |
start = i + 1; // 从 i+1 开始重新计算 | |
curSum = 0; // 重新计算,当前剩余油量置为 0 | |
} | |
} | |
if (totalSum < 0) return -1; // 总剩余油量小于 0,说明无法绕一圈 | |
return start; | |
} | |
} // 时间复杂度:O (n),空间复杂度:O (1) |
- 时间复杂度:
- 空间复杂度:
说这种解法为贪心算法,才是有理有据的,因为全局最优解是根据局部最优推导出来的。
# 总结
对于本题首先给出了暴力解法,暴力解法模拟跑一圈的过程其实比较考验代码技巧的,要对 while 使用的很熟练。
然后给出了两种贪心算法,对于第一种贪心方法,其实我认为就是一种直接从全局选取最优的模拟操作,思路还是很巧妙的,值得学习一下。
对于第二种贪心方法,才真正体现出贪心的精髓,用局部最优可以推出全局最优,进而求得起始位置。
# ☆135. 分发糖果
这道题目一定是要确定一边之后,再确定另一边,例如比较每一个孩子的左边,然后再比较右边,如果两边一起考虑一定会顾此失彼。
先确定右边评分大于左边的情况(也就是从前向后遍历)
- 局部最优:只要右边评分比左边大,右边的孩子就多一个糖果
- 全局最优:相邻的孩子中,评分高的右孩子获得比左边孩子更多的糖果
局部最优可以推出全局最优。
如果
ratings[i] > ratings[i - 1]
那么 [i] 的糖 一定要比 [i - 1] 的糖多一个,所以贪心:candyVec[i] = candyVec[i - 1] + 1
代码如下:
// 从前向后
for (int i = 1; i < ratings.size(); i++) {
if (ratings[i] > ratings[i - 1]) candyVec[i] = candyVec[i - 1] + 1;
}
如图:
再确定左孩子大于右孩子的情况(从后向前遍历)
遍历顺序这里有同学可能会有疑问,为什么不能从前向后遍历呢?
因为如果从前向后遍历,根据 ratings [i + 1] 来确定 ratings [i] 对应的糖果,那么每次都不能利用上前一次的比较结果了。
所以确定左孩子大于右孩子的情况一定要从后向前遍历!
如果
ratings[i] > ratings[i + 1]
,此时 candyVec [i](第 i 个小孩的糖果数量)就有两个选择了,一个是 candyVec [i + 1] + 1(从右边这个加 1 得到的糖果数量),一个是 candyVec [i](之前比较右孩子大于左孩子得到的糖果数量)。那么又要贪心了,
- 局部最优:取
max(candyVec[i + 1] + 1, candyVec[i])
,保证第 i 个小孩的糖果数量既大于左边的也大于右边的 - 全局最优:相邻的孩子中,评分高的孩子获得更多的糖果。
局部最优可以推出全局最优。
所以就取 candyVec [i + 1] + 1 和 candyVec [i] 最大的糖果数量,candyVec [i] 只有取最大的才能既保持对左边 candyVec [i - 1] 的糖果多,也比右边 candyVec [i + 1] 的糖果多。
如图:
所以该过程代码如下:
// 从后向前
for (int i = ratings.size() - 2; i >= 0; i--) {
if (ratings[i] > ratings[i + 1] ) {
candyVec[i] = max(candyVec[i], candyVec[i + 1] + 1);
}
}
- 局部最优:取
整体的 Java 代码如下:
class Solution { | |
// 贪心算法:从左到右遍历一遍,从右到左遍历一遍 | |
public int candy(int[] ratings) { | |
int[] candies = new int[ratings.length]; // 存储每个孩子的糖果数 | |
Arrays.fill(candies, 1); // 题目要求每个孩子至少分配到 1 个糖果 | |
// 从前向后:确定右边评分>左边评分的孩子的糖果数 | |
for (int i = 1; i < ratings.length; i++) { | |
if (ratings[i] > ratings[i - 1]) { | |
candies[i] = candies[i - 1] + 1; | |
} | |
} | |
// 从后向前:确定左边评分>右边评分的孩子的糖果数 | |
for (int i = ratings.length - 2; i >= 0; i--) { | |
if (ratings[i] > ratings[i + 1]) { | |
candies[i] = Math.max(candies[i], candies[i + 1] + 1); | |
} | |
} | |
// 计算糖果总数 | |
int res = 0; | |
for (int candy : candies) { | |
res += candy; | |
} | |
return res; | |
} | |
} // 时间复杂度:O (n);空间复杂度:O (n) |
# 总结
这在 leetcode 上是一道困难的题目,其难点就在于贪心的策略,如果在考虑局部的时候想两边兼顾,就会顾此失彼。
那么本题我采用了两次贪心的策略:
- 一次是从左到右遍历,只比较右边孩子评分比左边大的情况。
- 一次是从右到左遍历,只比较左边孩子评分比右边大的情况。
这样从局部最优推出了全局最优,即:相邻的孩子中,评分高的孩子获得更多的糖果。
# 860. 柠檬水找零
这是前几天的 leetcode 每日一题,感觉不错,给大家讲一下。
这道题目刚一看,可能会有点懵,这要怎么找零才能保证完成全部账单的找零呢?
但仔细一琢磨就会发现,可供我们做判断的空间非常少!
只需要维护三种金额的数量,5,10 和 20。
有如下三种情况:
- 情况一:账单是 5,直接收下。
- 情况二:账单是 10,消耗一个 5,增加一个 10
- 情况三:账单是 20,优先消耗一个 10 和一个 5,如果不够,再消耗三个 5
此时大家就发现 情况一,情况二,都是固定策略,都不用我们来做分析了,而唯一不确定的其实在情况三。
而情况三逻辑也不复杂甚至感觉纯模拟就可以了,其实情况三这里是有贪心的。
账单是 20 的情况,为什么要优先消耗一个 10 和一个 5 呢?
因为美元 10 只能给账单 20 找零,而美元 5 可以给账单 10 和账单 20 找零,美元 5 更万能!
所以
- 局部最优:遇到账单 20,优先消耗美元 10,完成本次找零。
- 全局最优:完成全部账单的找零。
局部最优可以推出全局最优,并找不出反例,那么就试试贪心算法!
class Solution { | |
public boolean lemonadeChange(int[] bills) { | |
int five = 0, ten = 0, twenty = 0; | |
for (int bill : bills) { | |
// 情况一:顾客给了 5 美元,直接收下 | |
if (bill == 5) five++; | |
// 情况二:顾客给了 10 美元,找 5 美元 | |
else if (bill == 10) { | |
if (five == 0) return false; | |
five--; | |
ten++; | |
} | |
// 情况三:顾客给了 20 美元,优先找 10 美元和 5 美元 | |
else { | |
if (five > 0 && ten > 0) { | |
five--; | |
ten--; | |
twenty++; // 其实这里 twenty++ 可以省略 | |
} else if (five >= 3) { | |
five -= 3; | |
twenty++; | |
} else { | |
return false; | |
} | |
} | |
} | |
return true; | |
} | |
} |
# 总结
咋眼一看好像很复杂,分析清楚之后,会发现逻辑其实非常固定。
这道题目可以告诉大家,遇到感觉没有思路的题目,可以静下心来把能遇到的情况分析一下,只要分析到具体情况了,一下子就豁然开朗了。
如果一直陷入想从整体上寻找找零方案,就会把自己陷进去,各种情况一交叉,只会越想越复杂了。
# ☆406. 根据身高重建队列
本题有两个维度,h 和 k,看到这种题目一定要想如何确定一个维度,然后再按照另一个维度重新排列。
其实如果大家认真做了 [☆135. 分发糖果](#☆135. 分发糖果),就会发现和此题有点点的像。
在 [☆135. 分发糖果](#☆135. 分发糖果) 我就强调过一次,遇到两个维度权衡的时候,一定要先确定一个维度,再确定另一个维度。
如果两个维度一起考虑一定会顾此失彼。
对于本题相信大家困惑的点是先确定 k 还是先确定 h 呢,也就是究竟先按 h 排序呢,还是先按照 k 排序呢?
如果按照 k 来从小到大排序,排完之后,会发现 k 的排列并不符合条件,身高也不符合条件,两个维度哪一个都没确定下来。
那么按照身高 h 来排序呢,身高一定是从大到小排(身高相同的话则 k 小的站前面),让高个子在前面。
为什么?
此时我们可以确定一个维度了,就是身高,前面的节点一定都比本节点高!
那么只需要按照 k 为下标重新插入队列就可以了,为什么呢?
以图中 {5,2} 为例:
按照身高排序之后,优先按身高高的 people 的 k 来插入,后序插入节点也不会影响前面已经插入的节点,最终按照 k 的规则完成了队列。
所以在按照身高从大到小排序后:
局部最优:优先按身高高的 people 的 k 来插入。插入操作过后的 people 满足队列属性
全局最优:最后都做完插入操作,整个队列满足题目队列属性
局部最优可推出全局最优,找不出反例,那就试试贪心。
整个插入过程如下:
排序完的 people: [[7,0], [7,1], [6,1], [5,0], [5,2],[4,4]]
插入的过程:
- 插入 [7,0]:[[7,0]]
- 插入 [7,1]:[[7,0],[7,1]]
- 插入 [6,1]:[[7,0],[6,1],[7,1]]
- 插入 [5,0]:[[5,0],[7,0],[6,1],[7,1]]
- 插入 [5,2]:[[5,0],[7,0],[5,2],[6,1],[7,1]]
- 插入 [4,4]:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
此时就按照题目的要求完成了重新排列。
class Solution { | |
// 贪心算法 | |
public int[][] reconstructQueue(int[][] people) { | |
// 按照 h 降序,h 相同时按 k 升序排序 | |
Arrays.sort(people, (o1, o2) -> { | |
if (o1[0] == o2[0]) { | |
return o1[1] - o2[1]; //k 升序 | |
} else { | |
return o2[0] - o1[0]; //h 降序 | |
} | |
}); | |
// 用(双向)链表模拟队列 | |
LinkedList<int[]> que = new LinkedList<>(); | |
for (int[] p : people) { | |
que.add(p[1], p); // 按照 k 插入 | |
} | |
// 转换为数组 | |
return que.toArray(new int[que.size()][]); | |
} | |
} // 时间复杂度:O (n^2) 空间复杂度:O (n) |
- 时间复杂度:O (nlog n + n^2)
- 空间复杂度:O (n)
# 总结
关于出现两个维度一起考虑的情况,我们已经做过两道题目了,另一道就是 135. 分发糖果 (opens new window)。
其技巧都是确定一边然后贪心另一边,两边一起考虑,就会顾此失彼。
这道题目可以说比 135. 分发糖果 (opens new window) 难不少,其贪心的策略也是比较巧妙。
最后我给出了两个版本的代码,可以明显看是使用 C++ 中的 list(底层链表实现)比 vector(数组)效率高得多。
对使用某一种语言容器的使用,特性的选择都会不同程度上影响效率。
所以很多人都说写算法题用什么语言都可以,主要体现在算法思维上,其实我是同意的但也不同意。
对于看别人题解的同学,题解用什么语言其实影响不大,只要题解把所使用语言特性优化的点讲出来,大家都可以看懂,并使用自己语言的时候注意一下。
对于写题解的同学,刷题用什么语言影响就非常大,如果自己语言没有学好而强调算法和编程语言没关系,其实是会误伤别人的。
这也是我为什么统一使用 C++ 写题解的原因,其实用其他语言 java、python、php、go 啥的,我也能写,我的 Github 上也有用这些语言写的小项目,但写题解的话,我就不能保证把语言特性这块讲清楚,所以我始终坚持使用最熟悉的 C++ 写题解。
# 贪心周总结三
# 周一
在 [☆134. 加油站](#☆134. 加油站) 中给出每一个加油站的汽油和开到这个加油站的消耗,问汽车能不能开一圈。
这道题目咋眼一看,感觉是一道模拟题,模拟一下汽车从每一个节点出发看看能不能开一圈,时间复杂度是 O (n^2)。
即使用模拟这种情况,也挺考察代码技巧的。
for 循环适合模拟从头到尾的遍历,而 while 循环适合模拟环形遍历,对于本题的场景要善于使用 while!
如果代码功力不到位,就模拟这种情况,可能写的也会很费劲。
本题的贪心解法,我给出两种解法。
对于解法一,其实我并不认为这是贪心,因为没有找出局部最优,而是直接从全局最优的角度上思考问题,但思路很巧妙,值得学习一下。
对于解法二,贪心的局部最优:当前累加 rest [j] 的和 curSum 一旦小于 0,起始位置至少要是 j+1,因为从 j 开始一定不行。全局最优:找到可以跑一圈的起始位置。
这里是可以从局部最优推出全局最优的,想不出反例,那就试试贪心。
解法二就体现出贪心的精髓,同时大家也会发现,虽然贪心是常识,有些常识并不容易,甚至很难!
# 周二
在 [☆135. 分发糖果](#☆135. 分发糖果) 中我们第一次接触了需要考虑两个维度的情况。
例如这道题,是先考虑左边呢,还是考虑右边呢?
先考虑哪一边都可以! 就别两边一起考虑,那样就把自己陷进去了。
先贪心一边,局部最优:只要右边评分比左边大,右边的孩子就多一个糖果,全局最优:相邻的孩子中,评分高的右孩子获得比左边孩子更多的糖果
如图:
接着在贪心另一边,左孩子大于右孩子,左孩子的糖果就要比右孩子多。
此时 candyVec [i](第 i 个小孩的糖果数量,左孩子)就有两个选择了,一个是 candyVec [i + 1] + 1(从右孩子这个加 1 得到的糖果数量),一个是 candyVec [i](之前比较右孩子大于左孩子得到的糖果数量)。
那么第二次贪心的局部最优:取 candyVec [i + 1] + 1 和 candyVec [i] 最大的糖果数量,保证第 i 个小孩的糖果数量即大于左边的也大于右边的。全局最优:相邻的孩子中,评分高的孩子获得更多的糖果。
局部最优可以推出全局最优。
如图:
# 周三
在 [860. 柠檬水找零](#860. 柠檬水找零) 中我们模拟了买柠檬水找零的过程。
这道题目刚一看,可能会有点懵,这要怎么找零才能保证完整全部账单的找零呢?
但仔细一琢磨就会发现,可供我们做判断的空间非常少!
美元 10 只能给账单 20 找零,而美元 5 可以给账单 10 和账单 20 找零,美元 5 更万能!
局部最优:遇到账单 20,优先消耗美元 10,完成本次找零。全局最优:完成全部账单的找零。
局部最优可以推出全局最优。
所以把能遇到的情况分析一下,只要分析到具体情况了,一下子就豁然开朗了。
这道题目其实是一道简单题,但如果一开始就想从整体上寻找找零方案,就会把自己陷进去,各种情况一交叉,只会越想越复杂了。
# 周四
在 [☆406. 根据身高重建队列](#☆406. 根据身高重建队列) 中,我们再一次遇到了需要考虑两个维度的情况。
之前我们已经做过一道类似的了就是 [☆135. 分发糖果](#☆135. 分发糖果),但本题比分发糖果难不少!
[☆406. 根据身高重建队列](#☆406. 根据身高重建队列) 中依然是要确定一边,然后在考虑另一边,两边一起考虑一定会蒙圈。
那么本题先确定 k 还是先确定 h 呢,也就是究竟先按 h 排序呢,还先按照 k 排序呢?
这里其实很考察大家的思考过程,如果按照 k 来从小到大排序,排完之后,会发现 k 的排列并不符合条件,身高也不符合条件,两个维度哪一个都没确定下来。
所以先从大到小按照 h 排个序,再来贪心 k。
此时局部最优:优先按身高高的 people 的 k 来插入。插入操作过后的 people 满足队列属性。全局最优:最后都做完插入操作,整个队列满足题目队列属性。
局部最优可以推出全局最优,找不出反例,那么就来贪心。
# 总结
「代码随想录」里已经讲了十一道贪心题目了,大家可以发现在每一道题目的讲解中,我都是把什么是局部最优,和什么是全局最优说清楚。
虽然有时候感觉贪心就是常识,但如果真正是常识性的题目,其实是模拟题,就不是贪心算法了!例如 [☆134. 加油站](#☆134. 加油站) 中的贪心方法一,其实我就认为不是贪心算法,而是直接从全局最优的角度上来模拟,因为方法里没有体现局部最优的过程。
而且大家也会发现,贪心并没有想象中的那么简单,贪心往往妙的出其不意,触不及防!哈哈
# 452. 用最少数量的箭引爆气球
如何使用最少的弓箭呢?
直觉上来看,貌似只射重叠最多的气球,用的弓箭一定最少,那么有没有当前重叠了三个气球,我射两个,留下一个和后面的一起射这样弓箭用的更少的情况呢?
尝试一下举反例,发现没有这种情况。
那么就试一试贪心吧!
- 局部最优:当气球出现重叠,一起射,所用弓箭最少。
- 全局最优:把所有气球射爆所用弓箭最少。
算法确定下来了,那么如何模拟气球射爆的过程呢?是在数组中移除元素还是做标记呢?
如果真实的模拟射气球的过程,应该射一个,气球数组就 remove 一个元素,这样最直观,毕竟气球被射了。
但仔细思考一下就发现:如果把气球排序之后,从前到后遍历气球,被射过的气球仅仅跳过就行了,没有必要让气球数组 remove 气球,只要记录一下箭的数量就可以了。
以上为思考过程,已经确定下来使用贪心了,那么开始解题。
为了让气球尽可能的重叠,需要对数组进行排序。
那么按照气球起始位置排序,还是按照气球终止位置排序呢?
其实都可以!只不过对应的遍历顺序不同,我就按照气球的起始位置排序了。
既然按照起始位置排序,那么就从前向后遍历气球数组,靠左尽可能让气球重复。
从前向后遍历遇到重叠的气球了怎么办?
如果气球重叠了,重叠气球中右边边界的最小值 之前的区间一定需要一个弓箭。
以题目示例: [[10,16],[2,8],[1,6],[7,12]] 为例,如图:(方便起见,已经排序)
可以看出首先第一组重叠气球,一定是需要一个箭,气球 3,的左边界大于了 第一组重叠气球的最小右边界,所以再需要一支箭来射气球 3 了。
class Solution { | |
public int findMinArrowShots(int[][] points) { | |
// 特判:如果气球数量为 1,那么只需要一支箭 | |
if (points.length == 1) return 1; | |
// 按照气球的左边界进行升序排序(为了防止溢出,使用 Integer 内置比较方法 compare) | |
Arrays.sort(points, (o1, o2) -> Integer.compare(o1[0], o2[0])); | |
int res = 1; // 至少需要一支箭 | |
for (int i = 1; i < points.length; i++) { | |
// 如果当前气球的左边界>前一个气球的右边界,即二者不重叠,那么需要一支新的箭 | |
if (points[i][0] > points[i - 1][1]) { | |
res++; | |
} | |
// 否则,二者重叠,需要更新重叠气球最小右边界 | |
else { | |
points[i][1] = Math.min(points[i][1], points[i - 1][1]); | |
} | |
} | |
return res; | |
} | |
} // 时间复杂度:O (nlogn),因为有一个快排 |
# 注意
注意题目中说的是:满足 xstart ≤ x ≤ xend,则该气球会被引爆。那么说明两个气球挨在一起不重叠也可以一起射爆,
所以代码中 if (points[i][0] > points[i - 1][1])
不能是 >=
# 总结
这道题目贪心的思路很简单也很直接,就是重复的一起射了,但本题我认为是有难度的。
就算思路都想好了,模拟射气球的过程,很多同学真的要去模拟了,实时把气球从数组中移走,这么写的话就复杂了。
而且寻找重复的气球,寻找重叠气球最小右边界,其实都有代码技巧。
贪心题目有时候就是这样,看起来很简单,思路很直接,但是一写代码就感觉贼复杂无从下手。
这里其实是需要代码功底的,那代码功底怎么练?
多看多写多总结!
# ☆435. 无重叠区间
相信很多同学看到这道题目都冥冥之中感觉要排序,但是究竟是按照右边界排序,还是按照左边界排序呢?
这其实是一个难点!
按照右边界排序,就要从左向右遍历,因为右边界越小越好,只要右边界越小,留给下一个区间的空间就越大,所以从左向右遍历,优先选右边界小的。
按照左边界排序,就要从右向左遍历,因为左边界数值越大越好(越靠右),这样就给前一个区间的空间就越大,所以可以从右向左遍历。
如果按照左边界排序,还从左向右遍历的话,其实也可以,逻辑会有所不同。
一些同学做这道题目可能真的去模拟去重复区间的行为,这是比较麻烦的,还要去删除区间。
题目只是要求移除区间的个数,没有必要去真实的模拟删除区间!
我来按照右边界排序,从左向右记录非交叉区间的个数。最后用区间总数减去非交叉区间的个数就是需要移除的区间个数了。
此时问题就是要求非交叉区间的最大个数。
右边界排序之后,
局部最优:优先选右边界小的区间,所以从左向右遍历,留给下一个区间的空间大一些,从而尽量避免交叉。
全局最优:选取最多的非交叉区间。
从而使移除的区间数量最少
局部最优推出全局最优,试试贪心!
这里记录非交叉区间的个数还是有技巧的,如图:
区间,1,2,3,4,5,6 都按照右边界排好序。
每次取非交叉区间的时候,都是可右边界最小的来做分割点(这样留给下一个区间的空间就越大),所以第一条分割线就是区间 1 结束的位置。
接下来就是找大于区间 1 结束位置的区间,是从区间 4 开始。那有同学问了为什么不从区间 5 开始?别忘了已经是按照右边界排序的了。
区间 4 结束之后,再找到区间 6,所以一共记录非交叉区间的个数是三个。
总共区间个数为 6,减去非交叉区间的个数 3。移除区间的最小数量就是 3。
class Solution { | |
// 贪心算法:选取最多的非交叉区间,从而使移除的交叉区间数量最少 | |
public int eraseOverlapIntervals(int[][] intervals) { | |
// 特判:intervals 只有 1 个元素 | |
if (intervals.length == 1) return 0; | |
// 按照区间右边界对 intervals 进行升序排序 | |
Arrays.sort(intervals, (o1, o2) -> { | |
return Integer.compare(o1[1], o2[1]); | |
}); | |
int count = 1; // 非交叉区间的个数 | |
int end = intervals[0][1]; // 区间分割点 | |
for (int i = 1; i < intervals.length; i++) { | |
if (end <= intervals[i][0]) { // 当前区间与上一个不相交 | |
end = intervals[i][1]; // 更新区间分割点 | |
count++; // 非交叉区间个数 + 1 | |
} | |
} | |
return intervals.length - count; | |
} | |
} |
- 时间复杂度: ,有一个快排
- 空间复杂度:,有一个快排,最差情况 (倒序) 时,需要 n 次递归调用。因此确实需要 O (n) 的栈空间
大家此时会发现如此复杂的一个问题,代码实现却这么简单!
# 总结
本题我认为难度级别可以算是 hard 级别的!
总结如下难点:
- 难点一:一看题就有感觉需要排序,但究竟怎么排序,按左边界排还是右边界排。
- 难点二:排完序之后如何遍历,如果没有分析好遍历顺序,那么排序就没有意义了。
- 难点三:直接求重复的区间是复杂的,转而求最大非重复区间个数。
- 难点四:求最大非重复区间个数时,需要一个分割点来做标记。
这四个难点都不好想,但任何一个没想到位,这道题就解不了。
一些录友可能看网上的题解代码很简单,照葫芦画瓢稀里糊涂的就过了,但是其题解可能并没有把问题难点讲清楚,然后自己再没有钻研的话,那么一道贪心经典区间问题就这么浪费掉了。
贪心就是这样,代码有时候很简单(不是指代码短,而是逻辑简单),但想法是真的难!
这和动态规划还不一样,动规的代码有个递推公式,可能就看不懂了,而贪心往往是直白的代码,但想法读不懂,哈哈。
所以我把本题的难点也一一列出,帮大家不仅代码看的懂,想法也理解的透彻!
# 763. 划分字母区间
一想到分割字符串就想到了回溯,但本题其实不用回溯去暴力搜索。
题目要求同一字母最多出现在一个片段中,那么如何把同一个字母的都圈在同一个区间里呢?
如果没有接触过这种题目的话,还挺有难度的。
在遍历的过程中相当于是要找每一个字母的边界,如果找到之前遍历过的所有字母的最远边界,说明这个边界就是分割点了。此时前面出现过所有字母,最远也就到这个边界了。
可以分为如下两步:
- 统计每一个字符最后出现的位置
- 从头遍历字符,并更新字符的最远出现下标,如果找到字符最远出现位置下标和当前下标相等了,则找到了分割点
如图:
明白原理之后,代码并不复杂,如下:
class Solution { | |
// 贪心算法 | |
public List<Integer> partitionLabels(String s) { | |
List<Integer> res = new ArrayList<>(); | |
// 遍历字符串,记录每个字母最后出现的位置 | |
int[] hash = new int[27]; | |
for (int i = 0; i < s.length(); i++) { | |
hash[s.charAt(i) - 'a'] = i; | |
} | |
// 遍历字符串,记录当前片段的最后位置 | |
int left = 0, right = 0; | |
for (int i = 0; i < s.length(); i++) { | |
right = Math.max(right, hash[s.charAt(i) - 'a']); // 更新当前片段的最后位置 | |
if (i == right) { // 当前位置到达当前片段的最后位置,划分片段 | |
res.add(right - left + 1); // 记录当前片段的长度 | |
left = i + 1; // 更新下一个片段的起始位置 | |
} | |
} | |
return res; | |
} | |
} // 时间复杂度:O (n) 空间复杂度:O (1) |
- 时间复杂度:
- 空间复杂度:,使用的 hash 数组是固定大小
# 总结
这道题目 leetcode 标记为贪心算法,说实话,我没有感受到贪心,找不出局部最优推出全局最优的过程。就是用最远出现距离模拟了圈字符的行为。
但这道题目的思路是很巧妙的,所以有必要介绍给大家做一做,感受一下。
# 56. 合并区间
大家应该都感觉到了,此题一定要排序,那么按照左边界排序,还是右边界排序呢?
都可以!
那么我按照左边界排序,排序之后
- 局部最优:每次合并都取最大的右边界,这样就可以合并更多的区间了
- 整体最优:合并所有重叠的区间
局部最优可以推出全局最优,找不出反例,试试贪心。
那有同学问了,本来不就应该合并最大右边界么,这和贪心有啥关系?
有时候贪心就是常识!哈哈
按照左边界从小到大排序之后,如果 intervals[i][0] < intervals[i - 1][1]
即 intervals [i] 左边界 < intervals [i - 1] 右边界,则一定有重复,因为 intervals [i] 的左边界一定是大于等于 intervals [i - 1] 的左边界。
即:intervals [i] 的左边界在 intervals [i - 1] 左边界和右边界的范围内,那么一定有重复!
这么说有点抽象,看图:(注意图中区间都是按照左边界排序之后了)
知道如何判断重复之后,剩下的就是合并了,如何去模拟合并区间呢?
其实就是用合并区间后左边界和右边界,作为一个新的区间,加入到 result 数组里就可以了。如果没有合并就把原区间加入到 result 数组。
Java 代码如下:
class Solution { | |
// 贪心算法:每次选择最小的右边界 | |
public int[][] merge(int[][] intervals) { | |
List<int[]> res = new ArrayList<>(); // 这里不能定义为 int [][],因为需要合并区间,对结果集中的区间数是未知 | |
// 特判 | |
if (intervals.length == 1) return intervals; | |
// 按照左边界升序排序 | |
Arrays.sort(intervals, (o1, o2) -> o1[0] - o2[0]); | |
// 遍历数组 | |
res.add(intervals[0]); // 先把第一个区间加入到结果集中 | |
for (int i = 1; i < intervals.length; i++) { | |
if (intervals[i][0] <= res.get(res.size() - 1)[1]) { | |
// 合并区间:如果当前区间的左边界小于等于结果集中最后一个区间的右边界,说明两个区间有重叠,需要合并 | |
res.get(res.size() - 1)[1] = Math.max(res.get(res.size() - 1)[1], intervals[i][1]); | |
} else { | |
// 不重叠,直接加入到结果集中 | |
res.add(intervals[i]); | |
} | |
} | |
return res.toArray(new int[res.size()][]); //toArray () 方法的参数是一个空数组,用来指定返回的数组类型 | |
} | |
} // 时间复杂度:O (nlogn),因为有一个快排;空间复杂度:O (n),快排的最差情况(倒序)时需要 n 次递归 |
- 时间复杂度:O (nlog n) ,有一个快排
- 空间复杂度:O (n),有一个快排,最差情况 (倒序) 时,需要 n 次递归调用。因此确实需要 O (n) 的栈空间
# 总结
对于贪心算法,很多同学都是:如果能凭常识直接做出来,就会感觉不到自己用了贪心,一旦第一直觉想不出来,可能就一直想不出来了。
跟着「代码随想录」刷题的录友应该感受过,贪心难起来,真的难。
那应该怎么办呢?
正如我贪心系列开篇词贪心算法理论基础中讲解的一样,贪心本来就没有套路,也没有框架,所以各种常规解法需要多接触多练习,自然而然才会想到。
# 贪心周总结四
# 周一
在 [452. 用最少数量的箭引爆气球](#452. 用最少数量的箭引爆气球) 中,我们开始讲解了重叠区间问题,用最少的弓箭射爆所有气球,其本质就是找到最大的重叠区间。
按照左边界进行排序后,如果气球重叠了,重叠气球中右边边界的最小值之前的区间一定需要一个弓箭
如图:
模拟射气球的过程,很多同学真的要去模拟了,实时把气球从数组中移走,这么写的话就复杂了,从前向后遍历重复的只要跳过就可以的。
# 周二
在 [☆435. 无重叠区间](#☆435. 无重叠区间) 中要去掉最少的区间,来让所有区间没有重叠。
我来按照右边界排序,从左向右记录非交叉区间的个数。最后用区间总数减去非交叉区间的个数就是需要移除的区间个数了。
如图: