说在前头:本文主要参考算法小抄写的,原作者在这:https://labuladong.gitbook.io/algo/
但是这个网站经常打不开,我也不懂。。。
另外,本文没有列出所有例子,只挑选了部分,更多的请参考原作者。
框架思维和套路
数据结构的存储方式只有两种:数组(顺序存储)和链表(链式存储)
这句话一定要明白,不论后面的什么二叉树,栈,队列啥的,都是基于这两种东西深入。
对于任何数据结构,其基本操作无非遍历 + 访问,再具体一点就是:增删查改。
如何遍历 + 访问?
我们仍然从最高层来看,各种数据结构的遍历 + 访问无非两种形式:线性的和非线性的。
线性就是 for/while
迭代为代表,非线性就是递归为代表。再具体一步,无非以下几种框架。
数组的遍历方式
void traverse(int[] arr) {
for (int i = 0; i < arr.length; i++) {
// 迭代访问 arr[i]
}
}
所有的数组都可以使用这样的方式去遍历。
链表的遍历方式
/* 基本的单链表节点 */
class ListNode {
int val;
ListNode next;
}
//使用线性的遍历方式
void traverse(ListNode head) {
for (ListNode p = head; p != null; p = p.next) {
// 迭代访问 p.val
}
}
//递归遍历方式
void traverse(ListNode head) {
// 递归访问 head.val
traverse(head.next)
}
二叉树的遍历方式
/* 基本的二叉树节点 */
class TreeNode {
int val;
TreeNode left, right;
}
void traverse(TreeNode root) {
traverse(root.left)
traverse(root.right)
}
就是这个基本的东西,就可以完成前序,中序,后序遍历!
二叉树框架可以扩展为 N 叉树的遍历框架:
class TreeNode {
int val;
TreeNode[] children;
}
//迭代 + 递归
void traverse(TreeNode root) {
for (TreeNode child : root.children) {
traverse(child)
}
}
所谓框架,就是套路。不管增删查改,这些代码都是永远无法脱离的结构,你可以把这个结构作为大纲,根据具体问题在框架上添加代码就行了。
递归的理解
线性遍历很好理解,递归遍历就优点困难了。
写递归算法的关键是要明确函数的「定义」是什么,然后相信这个定义,利用这个定义推导最终结果,绝不要试图跳入递归。
递归的三要素:
明确函数想要干啥
要完成什么样的一件事,而这个,是完全由你自己来定义的。也就是说,我们先不管函数里面的代码什么,而是要先明白,你这个函数是要用来干什么。
例如:求一个数的阶乘,那么返回值就是需要的这个数的阶乘结果,参数就是需要被求的数
// 算 n 的阶乘(假设n不为0)
int f(int n){
}
寻找递归结束的条件
必须要找出递归的结束条件,不然的话,会一直调用自己,进入无底洞。也就是说,我们需要找出当参数为啥时,递归结束,之后直接把结果返回,请注意,这个时候我们必须能根据这个参数的值,能够直接知道函数的结果是什么。
例如上面的例子:
// 算 n 的阶乘(假设n不为0)
int f(int n){
if(n == 1){
return 1;
}
}
但是,n=2的时候也是这个函数的结束条件,所以有:
// 算 n 的阶乘(假设n不为0)
int f(int n){
if(n <= 2){
return n;
}
}
找出函数的等价关系式
我们要不断缩小参数的范围,缩小之后,我们可以通过一些辅助的变量或者操作,使原函数的结果不变。
例如,f(n) 这个范围比较大,我们可以让 f(n) = n * f(n-1)。这样,范围就由 n 变成了 n-1 了,范围变小了,并且为了原函数f(n) 不变,我们需要让 f(n-1) 乘以 n。
说白了,就是要找到原函数的一个等价关系式,f(n) 的等价关系式为 n * f(n-1),即f(n) = n * f(n-1)。
等价关系式的寻找,可以说是最难的一步了,找出了这个等价,继续完善我们的代码,我们把这个等价式写进函数里。如下:
// 算 n 的阶乘(假设n不为0)
int f(int n){
if(n <= 2){
return n;
}
// 把 f(n) 的等价操作写进去
return f(n-1) * n;
}
至此,递归三要素已经都写进代码里了,所以这个 f(n) 功能的内部代码我们已经写好了。
一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n
级的台阶总共有多少种跳法。
按照这个思路过来,第一步确定这个函数需要干什么。
一个N级台阶,这显然就是一个输入条件,共有多少中跳法,这个就是需要求的,也就是返回值,于是:
int f(int n){
}
这样一来,函数就有了。
第二步,找到结束的条件。台阶一共两种跳法,当n为1,自然就是只有一种跳法,n为2就有两种跳法,n等于3,恰好有三种跳法,后面的值就不是线性增长的了,所以结束条件也就有了:
int f(int n){
if(n <= 3){
return n;
}
}
第三步,找到等价关系式,也就是缩小这个参数值。跳一步,剩下n-1个,剩下的跳法有f(n-1),跳两步,剩下的跳法有f(n-2)。所以f(n) = f(n-1) + f(n-2)
。
于是有了这个:
int f(int n){
if(n <= 3){
return n;
}
return f(n-1) + f(n-2)
}
哎,还是需要多刷题。
动态规划解题框架
动态规划问题的一般形式就是求最值。动态规划其实是运筹学的一种最优化方法,只不过在计算机问题上应用比较多,比如说让你求最长递增子序列呀,最小编辑距离呀等等。
求最值的核心问题就是把所有的结果都需要遍历出来,然后找到其中的最值。
但是这样存在很多问题,比如重叠子问题,暴力穷举会重复计算很多的值,所以需要一个备忘录来优化穷举的过程,避免不必要的计算。
而且,动态规划问题一定会具备「最优子结构」,才能通过子问题的最值得到原问题的最值。
虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,只有列出正确的**「状态转移方程」**才能正确地穷举。
这里面最难的就是状态转移方程了。
斐波那契数列
一个简单的例子,理解一下这个过程
暴力递归,斐波那契数列的数学形式就是递归的,写成代码就是这样:
int fib(int N) {
if (N == 1 || N == 2) return 1;
return fib(N - 1) + fib(N - 2);
}
虽然简介,但是十分低效!
[ PS ]:但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助。
在这张递归树的图中,就可以看出有很多子问题被重复的计算。这也就是动态规划需要解决的第一个问题:重叠子问题。
带备忘录的递归解法
直到这个问题,怎么解决呢,既然是因为重叠的计算这些引起的,那就要想办法减少这些重复的计算,比如说每一个计算过的都给保存起来,这也就是造一个备忘录,下次计算前查这个备忘录中有没有这个值,然后再进行计算。一般使用一个数组来充当这个备忘录:
int fib(int N) {
if (N < 1) return 0;
int dp[] = new int[N+1];
return reduce(dp,N);
}
//递归的三要素之一:明确函数目标
int reduce(int dp[],int n){
//递归的三要素之二:结束条件
if (n == 1 || n == 2) return 1;
if(dp[n] != 0) return dp[n]; //不为0说明被计算过了
//递归的三要素之三:递归等价关系式
dp[n] = reduce(dp,n-1) + reduce(dp,n-2);
return dp[n];
}
你会发现每次递归到一个已经被计算的值都会直接返回!
实际上,带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题的个数。
使用递归自顶向下可以解决这个问题,也可以使用迭代方式自底向上,这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。
dp 数组的迭代解法
在上一步使用数组存储中间元素后,发现,每个递归方法都会依赖(也就是要把这个表传进)这个表,那如果把他独立出来,也就是自底向上法:
int fib(int N) {
if (N < 1) return 0;
int dp[] = new int[N+1];
dp[1] = dp[0] = 1;
for (int i = 3; i <= N; i++)
dp[i] = dp[i - 1] + dp[i - 2];
return dp[N];
}
你发现这个 DP table 特别像之前那个「剪枝」后的结果,只是反过来算而已。实际上,带备忘录的递归解法中的「备忘录」,最终完成后就是这个 DP table,所以说这两种解法其实是差不多的,大部分情况下,效率也基本相同。
这里,引出「状态转移方程」这个名词,实际上就是描述问题结构的数学形式:
把 f(n) 想做一个状态 n,这个状态 n 是由状态 n - 1 和状态 n - 2 相加转移而来,这就叫状态转移,仅此而已。
所有的操作都是围绕这个方程式的不同表现形式。可见列出「状态转移方程」的重要性,它是解决问题的核心。很容易发现,其实状态转移方程直接代表着暴力解法。
上面这个问题你会发现,至始至终使用到就那么三个变量,所以,这里还可以优化一下:
int fib(int N) {
if (N < 1) return 0;
int a = b = 1;
int sum = 0;
for (int i = 3; i <= N; i++){
sum = a + b;
a = b;
b = sum;
}
return sum;
}
回溯法解题框架
解决一个回溯问题,实际上就是一个决策树的遍历过程。你只需要思考 3 个问题:
1、路径:也就是已经做出的选择。
2、选择列表:也就是你当前可以做的选择。
3、结束条件:也就是到达决策树底层,无法再做选择的条件。
伪代码框架:
result = []; //保存结果
def backtrack(路径,选择列表){
if(满足条件){
result.add(路径); //当前满足条件路径添加到集合
return; //跳出这个递归方法
}
for(选择 : 可选列表){
做选择
backtrack(路径,选择列表);
撤销选择
}
}
其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」
做选择和撤销选择,这也就是核心的部分。
全排列问题
n
个不重复的数,全排列共有 n! 个。
[ PS ]:为了简单清晰起见,这次讨论的全排列问题不包含重复的数字。
比方说给三个数[1,2,3]
,一般是这样:
先固定第一位为 1,然后第二位可以是 2,那么第三位只能是 3;然后可以把第二位变成 3,第三位就只能是 2 了;然后就只能变化第一位,变成 2,然后再穷举后两位……其实这就是回溯算法!
只要从根遍历这棵树,记录路径上的数字,其实就是所有的全排列。不妨把这棵树称为回溯算法的「决策树」,为啥说这是决策树呢,因为你在每个节点上其实都在做决策。
这个时候就可以来解释开局的几个名词解释了:
-
路径:我们选择的每一个点,比如第一次选择的2。
-
选择列表:第一个选择2后,剩下的1,3就是选择列表。
-
结束条件:到底没了,这就是结束条件
我们定义的backtrack
函数其实就像一个指针,在这棵树上游走,同时要正确维护每个节点的属性,每当走到树的底层,其「路径」就是一个全排列。
再来看一下全排列代码:
List<List<Integer>> res = new LinkedList<>();
/* 主函数,输入一组不重复的数字,返回它们的全排列 */
List<List<Integer>> permute(int[] nums) {
// 记录「路径」
LinkedList<Integer> track = new LinkedList<>();
backtrack(nums, track);
return res;
}
// 路径:记录在 track 中
// 选择列表:nums 中不存在于 track 的那些元素
// 结束条件:nums 中的元素全都在 track 中出现
void backtrack(int[] nums, LinkedList<Integer> track) {
// 触发结束条件
if (track.size() == nums.length) {
res.add(new LinkedList(track));
return;
}
for (int i = 0; i < nums.length; i++) {
// 排除不合法的选择
if (track.contains(nums[i]))
continue;
// 做选择
track.add(nums[i]);
// 进入下一层决策树
backtrack(nums, track);
// 取消选择
track.removeLast();
}
}
必须说明的是,不管怎么优化,都符合回溯框架,而且时间复杂度都不可能低于 O(N!),因为穷举整棵决策树是无法避免的。这也是回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高。
BFS算法框架
BFS 出现的常见场景,问题的本质就是让你在一幅「图」中找到从起点start
到终点target
的最近距离,这个例子听起来很枯燥,但是 BFS 算法问题其实都是在干这个事儿。
代码框架:
//首先明确函数目标,给定起点和终点,返回路径
int BFS(Node start, Node target) {
Queue<Node> q; //核心数据结构,保存需要遍历的节点
Set<Node> visited; //避免走回头路
q.offer(start); // 将起点加入队列
visited.add(start);
int step = 0; // 记录扩散的步数
while (q not empty) { //只要队列不为空,说明还有选择可以尝试
int sz = q.size();
/* 将当前队列中的所有节点向四周扩散 */
for (int i = 0; i < sz; i++) {
Node cur = q.poll(); //出队列
/* 划重点:这里判断是否到达终点 */
if (cur is target)
return step;
/* 将 cur 的相邻节点加入队列 */
for (Node x : cur.adj()){
if (x not in visited) {
q.offer(x);
visited.add(x);
}
}
}
/* 划重点:更新步数在这里 */
step++;
}
}
BFS 的核心数据结构;cur.adj()
泛指cur
相邻的节点,比如说二维数组中,cur
上下左右四面的位置就是相邻节点;visited
的主要作用是防止走回头路,大部分时候都是必须的,但是像一般的二叉树结构,没有子节点到父节点的指针,不会走回头路就不需要visited
。
二叉树的最小高度
首先明确起点和终点:
- 起点:也就是根节点
- 终点:也就是靠经起点的叶子节点(也就空节点)
所以判断结束的条件出来了:
if(cur.left == null && cur.right == null); //表明到终点
然后按照框架思路进行改造:
int minDepth(TreeNode root) {
if (root == null) return 0;
Queue<TreeNode> q = new LinkedList<>();
q.offer(root);
// root 本身就是一层,depth 初始化为 1
int depth = 1;
while (!q.isEmpty()) {
int sz = q.size();
/* 将当前队列中的所有节点向四周扩散 */
for (int i = 0; i < sz; i++) {
TreeNode cur = q.poll();
/* 判断是否到达终点 */
if (cur.left == null && cur.right == null)
return depth;
/* 将 cur 的相邻节点加入队列 */
if (cur.left != null)
q.offer(cur.left);
if (cur.right != null)
q.offer(cur.right);
}
/* 这里增加步数 */
depth++;
}
return depth;
}
非递归版,还是比较好理解的。
二分法查找
几个最常用的二分查找场景:寻找一个数、寻找左侧边界、寻找右侧边界。而且,我们就是要深入细节,比如不等号是否应该带等号,mid 是否应该加一等等。
零、二分查找框架
代码框架:
int binarySearch(int[] nums, int target) {
int left = 0, right = ...;
while(...) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
...
} else if (nums[mid] < target) {
left = ...
} else if (nums[mid] > target) {
right = ...
}
}
return ...;
}
整个代码的思路大概就是这个样子。
分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节。
其中...
标记的部分,就是可能出现细节问题的地方,当你见到一个二分查找的代码时,首先注意这几个地方。后文用实例分析这些地方能有什么样的变化。
另外声明一下,计算 mid 时需要防止溢出,代码中left + (right - left) / 2
就和(left + right) / 2
的结果相同,但是有效防止了left
和right
太大直接相加导致溢出。
寻找一个数(基本的二分搜索)
这个场景是最简单的,肯能也是大家最熟悉的,即搜索一个数,如果存在,返回其索引,否则返回 -1
这里就直接给出代码了:
int binarySeacher(int nums[],int target){
int left = 0;
int right = nums.length - 1;
while(left <= right){
int mid = left + (right - left) / 2;
if(nums[mid] == target)
return mid;
else if(nums[mid] < target){
left = mid + 1;
}else if(nums[mid] > target){
right = mid - 1;
}
}
return -1;
}
这里需要解决的几个问题:
-
为什么while循环的条件中是 <= ,而不是 <?
因为初始化
right
的赋值是nums.length - 1
,即最后一个元素的索引,而不是nums.length
,这二者可能出现在不同功能的二分查找中,区别是:前者相当于两端都闭区间[left, right]
,后者相当于左闭右开区间[left, right)
,因为索引大小为nums.length
是越界的。 -
为什么
left = mid + 1
,right = mid - 1
?我看有的代码是right = mid
或者left = mid
,没有这些加加减减,到底怎么回事,怎么判断?这也是二分查找的一个难点,刚才明确了「搜索区间」这个概念,而且本算法的搜索区间是两端都闭的,即
[left, right]
。那么当我们发现索引mid
不是要找的target
时应该去搜索[left, mid-1]
或者[mid+1, right]
-
此算法有什么缺陷?
给你有序数组
nums = [1,2,2,2,3]
,target
为 2,此算法返回的索引是 2,没错。但是如果我想得到target
的左侧边界,即索引 1,或者我想得到target
的右侧边界,即索引 3,这样的话此算法是无法处理的。
寻找左侧边界的二分搜索
以下是最常见的代码形式,其中的标记是需要注意的细节:
int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0;
int right = nums.length; // 注意,上面例子为nums.length-1
while (left < right) { // 注意
int mid = (left + right) / 2;
if (nums[mid] == target) {
right = mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid; // 注意
}
}
return left;
}
如果说我们选择的右边值为数组长度而非该数组最长的下标(这里需要理解一下),因此每次循环的「搜索区间」是[left, right)
左闭右开。那么循环结束的条件就要改为while(left < right)终止的条件是left == right
此时搜索区间[left, left)
为空,所以可以正确终止。
但是left这个值显然是不对的,因为如果没找到,这个left的值要么为0,要么为right。所以这个结束条件就需要改动一下:
// target 比所有数都大
if (left == nums.length) return -1;
// 类似之前算法的处理方式
return nums[left] == target ? left : -1;
为什么该算法能够搜索左侧边界?
关键在于对于nums[mid] == target
这种情况的处理:
if (nums[mid] == target)
right = mid;
可见,找到 target 时不要立即返回,而是缩小「搜索区间」的上界right
,在区间[left, mid)
中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。这个时候即使中途没有找到,也会因为缩小至left==right这个情况,依然可以获取刚开始得到的值。
寻找右侧边界的二分查找
类似寻找左侧边界的算法,这里也会提供两种写法,还是先写常见的左闭右开的写法,只有两处和搜索左侧边界不同,已标注:
int right_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length;
while (left < right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
left = mid + 1; // 注意
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
}
}
return left - 1; // 注意
}
为什么这个算法能够找到右侧边界?
关键在于这个:
if (nums[mid] == target) {
left = mid + 1;
当nums[mid] == target
时,不要立即返回,而是增大「搜索区间」的下界left
,使得区间不断向右收缩,达到锁定右侧边界的目的。
双指针解题技巧
双指针技巧还可以分为两类,一类是「快慢指针」,一类是「左右指针」。前者解决主要解决链表中的问题,比如典型的判定链表中是否包含环;后者主要解决数组(或者字符串)中的问题,比如二分查找。
快慢指针
快慢指针一般都初始化指向链表的头结点 head,前进时快指针 fast 在前,慢指针 slow 在后,巧妙解决一些链表中的问题。
经典例题:判定链表中是否有环
单链表的特点是每个节点只知道下一个节点,所以一个指针的话无法判断链表中是否含有环的。如果链表中不含环,那么这个指针最终会遇到空指针 null 表示链表到头了,这还好说,可以判断该链表不含环。
于是一个不含环的判定就出来了:
boolean hasCycle(ListNode head) {
while (head != null)
head = head.next;
return false;
}
这个里面还是有问题的,如果有环呢?就会无限的循环下去。
经典解法就是用两个指针,一个每次前进两步,一个每次前进一步。如果不含有环,跑得快的那个指针最终会遇到 null,说明链表不含环;如果含有环,快指针最终会超慢指针一圈,和慢指针相遇,说明链表含有环。
boolean hasCycle(ListNode head) {
ListNode fast,slow;
fast = slow = head;
while(fast != null && fast.next != null){
fast = fast.next.next;
slow = slow.next;
if(fast == slow) return true;
}
return false;
}
进阶:判定链表中是否有环
已知链表中含有环,返回这个环的起始位置。
这里还需要一点点数学计算的逻辑能力,假设第一次相遇,slow走了K步,而fast走了2K步,这个是一定的。然后你会发现K = 未进环步数 + s在环中走的步数
,而 2K= 未进环步数 + f在环中走的步数
,fast在环中走的步数 = slow在环中走的步数 + 环的长度
,所以环的长度为K,设相遇点距环的起点的距离为 m,那么环的起点距头结点 head 的距离为 k - m,也就是说如果从 head 前进 k - m 步就能到达环起点。巧的是,如果从相遇点继续前进 k - m 步,也恰好到达环起点。
所以,只要我们把快慢指针中的任一个重新指向 head,然后两个指针同速前进,k - m 步后就会相遇,相遇之处就是环的起点了。
代码如下:
ListNode hasCycle(ListNode head) {
ListNode fast,slow;
fast = slow = head;
while(fast != null && fast.next != null){
fast = fast.next.next;
slow = slow.next;
if(fast == slow) break;
}
slow = head;
while(slow != fast){
slow = slow.next;
fast = fast.next;
}
return slow;
}
寻找链表的中点
类似上面的思路,我们还可以让快指针一次前进两步,慢指针一次前进一步,当快指针到达链表尽头时,慢指针就处于链表的中间位置。
ListNode hasCycle(ListNode head) {
ListNode fast,slow;
fast = slow = head;
while(fast != null && fast.next != fast){
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
当链表的长度是奇数时,slow 恰巧停在中点位置;如果长度是偶数,slow 最终的位置是中间偏右,经过上面两个题,这个应该很好理解了。
寻找链表的倒数第 k 个元素
思路还是使用快慢指针,让快指针先走 k 步,然后快慢指针开始同速前进。这样当快指针走到链表末尾 null 时,慢指针所在的位置就是倒数第 k 个链表节点(为了简化,假设 k 不会超过链表长度):
ListNode hasCycle(ListNode head) {
ListNode fast,slow;
fast = slow = head;
while(k > 0){
fast = fast.next;
k--;
}
while(fast != null){
slow = slow.next;
fast = fast.next;
}
return slow;
}
左右双指针
左右指针在数组中实际是指两个索引值,一般初始化为 left = 0, right = nums.length - 1 。
一个非常经典的例子,比如上面所说的二分法查找。
再比如Leetcode上的第一题,只要数组变成有序的,使用二分法也是非常容易处理的。
再比如反转数组,直接两两交换即可。
滑动窗口——最经典的左右双指针
算法大致逻辑如下:
int left = 0, right = 0;
while (right < s.size()) {
// 增大窗口
window.add(s[right]);
right++;
while (window needs shrink) {
// 缩小窗口
window.remove(s[left]);
left++;
}
}
这个算法技巧的时间复杂度是 O(N),比一般的字符串暴力算法要高效得多。
如何向窗口中添加新元素,如何缩小窗口,在窗口滑动的哪个阶段更新结果。这个就是滑动窗口最难的一个点了。
总结就是有这么一个框架:(不一定符合Java语法,这只是一个思路)
/* 滑动窗口算法框架 */
void slidingWindow(string s, string t) {
Map<char, int> need, window;
for (char c : t) need[c]++; //初始化每个字符需要的个数
int left = 0, right = 0;
int valid = 0;
while (right < s.size()) {
// c 是将移入窗口的字符
char c = s[right];
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
...
/*** debug 输出的位置 ***/
printf("window: [%d, %d)\n", left, right);
/********************/
// 判断左侧窗口是否要收缩
while (window needs shrink) {
// d 是将移出窗口的字符
char d = s[left];
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
其中两处...
表示的更新窗口数据的地方,到时候直接往里面填就行了。
这两个...
处的操作分别是右移和左移窗口更新操作,会发现它们操作是完全对称的。
最小覆盖子串
LeetCode 76 题,Minimum Window Substring,难度 Hard
要在S
(source) 中找到包含T
(target) 中全部字母的一个子串,且这个子串一定是所有可能子串中最短的。
滑动窗口算法的思路是这样:
- 我们在字符串
S
中使用双指针中的左右指针技巧,初始化left = right = 0
,把索引左闭右开区间[left, right)
称为一个「窗口」。 - 我们先不断地增加
right
指针扩大窗口[left, right)
,直到窗口中的字符串符合要求(包含了T
中的所有字符)。 - 此时,我们停止增加
right
,转而不断增加left
指针缩小窗口[left, right)
,直到窗口中的字符串不再符合要求(不包含T
中的所有字符了)。同时,每次增加left
,我们都要更新一轮结果。 - 重复第 2 和第 3 步,直到
right
到达字符串S
的尽头。
**第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解,**也就是最短的覆盖子串。左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动,这就是「滑动窗口」这个名字的来历。
初始状态如下:
增加right
,直到窗口[left, right)
包含了T
中所有字符:
现在开始增加left
,缩小窗口[left, right)
:
直到窗口中的字符串不再符合要求,left
不再继续移动:
之后重复上述过程,先移动right
,再移动left
…… 直到right
指针到达字符串S
的末端,算法结束。
再来回顾这个算法框架怎么使用:
1、首先,初始化window
和need
两个哈希表,记录窗口中的字符和需要凑齐的字符:
Map<char, int> need, window;
for (char c : t) need[c]++; //初始化每个字符需要的个数
2、然后,使用left
和right
变量初始化窗口的两端,不要忘了,区间[left, right)
是左闭右开的,所以初始情况下窗口没有包含任何元素:
int left = 0, right = 0;
int valid = 0;
while (right < s.size()) {
// 开始滑动
}
其中valid
变量表示窗口中满足need
条件的字符个数,如果valid
和need.size
的大小相同,则说明窗口已满足条件,已经完全覆盖了串T
。
然后使用这个模板需要注意的四个问题:
**1、**当移动right
扩大窗口,即加入字符时,应该更新哪些数据?
这个显然是需要更新Window计数器的,还有一个valid
**2、**什么条件下,窗口应该暂停扩大,开始移动left
缩小窗口?
显然易见,在window这个计数器刚好达到need中的要求就停止扩大,开始缩小
**3、**当移动left
缩小窗口,即移出字符时,应该更新哪些数据?
收缩窗口应该更新window和valid
**4、**我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?
肯定是在缩小的时候更新,因为我们需要的是最终结果
完整代码如下:
class Solution {
public String minWindow(String s, String t) {
//每个字符所需数量
HashMap<Character,Integer> need = new HashMap<Character,Integer>();
//当前窗口字符以及数量
HashMap<Character,Integer> window = new HashMap<Character,Integer>();
//初始化
for (char c : t.toCharArray()) {
need.put(c,need.getOrDefault(c,0)+1);
}
int left = 0, right = 0;
int valid = 0;
// 记录最小覆盖子串的起始索引及长度
int start = 0;
int len = Integer.MAX_VALUE;
//未到顶
while(right < s.length()){
//取出这个字符
char c = s.charAt(right);
right++;
if(need.containsKey(c)){
window.put(c, window.getOrDefault(c, 0) + 1);
//表示窗口中满足need条件的字符个数
if(need.get(c).intValue() >= window.get(c).intValue()) valid++;
}
//判断是不是需要缩小这个窗口
while(valid == t.length()){
//更新最小字符串
if(right - left < len){
start = left;
len = right - left;
}
char x = s.charAt(left); //取出左边的字符
left++;
if(need.containsKey(x)){
//表示窗口中满足need条件的字符个数
if(need.get(x).intValue() == window.get(x).intValue()) valid--;
window.put(x, window.getOrDefault(x, 0) - 1);
}
}
}
return len == Integer.MAX_VALUE ? "" : s.substring(start, start+len);
}
}
需要注意的是,当我们发现某个字符在window
的数量满足了need
的需要,就要更新valid
,表示有一个字符已经满足要求。而且,你能发现,两次对窗口内数据的更新操作是完全对称的。
当valid == need.size()
时,说明T
中所有字符已经被覆盖,已经得到一个可行的覆盖子串,现在应该开始收缩窗口了,以便得到「最小覆盖子串」。
移动left
收缩窗口时,窗口内的字符都是可行解,所以应该在收缩窗口的阶段进行最小覆盖子串的更新,以便从可行解中找到长度最短的最终结果。
二叉树
之前提到过树的遍历框架:
/* 二叉树遍历框架 */
void traverse(TreeNode root) {
// 前序遍历
traverse(root.left)
// 中序遍历
traverse(root.right)
// 后序遍历
}
对于这种问题一般都会涉及递归问题,递归一定要明确这个方法是干什么,绝对不要试图跳进这个递归里面。
怎么理解呢,我们用一个具体的例子来说,比如说让你计算一棵二叉树共有几个节点:
// 定义:count(root) 返回以 root 为根的树有多少节点
int count(TreeNode root) {
// 结束条件
if (root == null) return 0;
// 自己加上子树的节点数就是整棵树的节点数
return 1 + count(root.left) + count(root.right);
}
root
本身就是一个节点,加上左右子树的节点数就是以root
为根的树的节点总数。左右子树的节点数怎么算?其实就是计算根为root.left
和root.right
两棵树的节点数呗,按照定义,递归调用count
函数即可算出来。所以说不要试图一层一层的跳进这个递归里面。
写树相关的算法,简单说就是,先搞清楚当前root
节点该做什么,然后根据函数定义递归调用子节点,递归调用会让孩子节点做相同的事情。
翻转二叉树
力扣第 226 题「翻转二叉树」,输入一个二叉树根节点root
,让你把整棵树镜像翻转,比如输入的二叉树如下:
4
/ \
2 7
/ \ / \
1 3 6 9
算法原地翻转二叉树,使得以root为根的树变成:
4
/ \
7 2
/ \ / \
9 6 3 1
发现只要把二叉树上的每一个节点的左右子节点进行交换,最后的结果就是完全翻转之后的二叉树。所以代码如下:
// 将整棵树的节点翻转
TreeNode invertTree(TreeNode root) {
// base case
if (root == null) {
return null;
}
/**** 前序遍历位置 ****/
// root 节点需要交换它的左右子节点
TreeNode tmp = root.left;
root.left = root.right;
root.right = tmp;
// 让左右子节点继续翻转它们的子节点
invertTree(root.left);
invertTree(root.right);
return root;
}
关键思路在于翻转整棵树就是交换每个节点的左右子节点,于是把交换左右子节点的代码放在了前序遍历的位置。
二叉树题目的一个难点就是,如何把题目的要求细化成每个节点需要做的事情。
填充二叉树节点的右侧指针
力扣第 116 题.
题目的意思就是把二叉树的每一层节点都用next
指针连接起来:
而且题目说了,输入是一棵「完美二叉树」,形象地说整棵二叉树是一个正三角形,除了最右侧的节点next
指针会指向null
,其他节点的右侧一定有相邻的节点。
模仿上面的代码如下:
// 将整棵树的节点翻转
TreeNode connect(TreeNode root) {
// base case
if (root == null root.left == null) {
return root;
}
/**** 前序遍历位置 ****/
root.left.next = root.right;
// 让左右子节点继续翻转它们的子节点
connect(root.left);
connect(root.right);
return root;
}
看似没有问题,但是这是有一个问题的:
节点 5 和节点 6 不属于同一个父节点,那么按照这段代码的逻辑,它俩就没办法被穿起来,这是不符合题意的。
一个根节点无法做到,那就两个节点:
// 主函数
Node connect(Node root) {
if (root == null) return null;
//左右两个子节点
connectTwoNode(root.left, root.right);
return root;
}
// 定义:输入两个节点,将它俩连接起来
void connectTwoNode(Node node1, Node node2) {
if (node1 == null || node2 == null) {
return;
}
/**** 前序遍历位置 ****/
// 将传入的两个节点连接
node1.next = node2;
// 连接相同父节点的两个子节点
connectTwoNode(node1.left, node1.right);
connectTwoNode(node2.left, node2.right);
// 连接跨越父节点的两个子节点
connectTwoNode(node1.right, node2.left);
}
这样,connectTwoNode
函数不断递归,可以无死角覆盖整棵二叉树。
小结
递归算法的关键要明确函数的定义,相信这个定义,而不要跳进递归细节。
写二叉树的算法题,都是基于递归框架的,先要搞清楚root
节点它自己要做什么,然后根据题目要求选择使用前序,中序,后续的递归框架。
二叉树题目的难点在于如何通过题目的要求思考出每一个节点需要做什么,这个只能通过多刷题进行练习了。