刷题之前这些东西你了解吗?
Posted 小王子jvm
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了刷题之前这些东西你了解吗?相关的知识,希望对你有一定的参考价值。
说在前头:本文主要参考算法小抄写的,原作者在这: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 +<以上是关于刷题之前这些东西你了解吗?的主要内容,如果未能解决你的问题,请参考以下文章
leetcode刷题(128)——1575. 统计所有可行路径,动态规划解法