数据结构的基本存储方式就是链式和顺序两种,基本操作就是增删查改,遍历方式无非迭代和递归。
写递归算法的关键是要明确函数的「定义」是什么,然后相信这个定义,利用这个定义推导最终结果,绝不要试图跳入递归。递归一定要有base case
PS:但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助。
数据结构
链表
双指针
链表的算法题中是很常见的「虚拟头节点」技巧,也就是dummy节点
快慢指针
左右指针
栈
数组
解题技巧
- 双指针
- 滑动窗口
- 前缀和数组
- 二分搜索
- hashmap
对于数组相关的算法问题,有一个通用的技巧:要尽量避免在中间删除元素,那我就 先 想办法把这个元素换到最后去。
在尾部插入、删除元素是比较高效的,时间复杂度是 O(1),但是如果在中间或者开头插入、删除元素,就会涉及数据的搬移,时间复杂度为 O(N),效率较低。
主要使用 双指针技巧 中的快慢指针技巧,也可以避免直接删除数组中的元素,降低算法的复杂度。
通常排序+双指针解决,nSum和题目
字符串
- 双指针
- 滑动窗口
- 递归/栈
- hashmap
栈
- 括号类问题
队列
- 层序遍历(BFS)
- 优先级队列(堆的实现) 第K大的数或者前K大的数,用长度=K的小顶堆
树
- 写树相关的算法,简单说就是,先搞清楚当前
root节点该做什么,然后根据函数定义递归调用子节点,递归调用会让孩子节点做相同的事情。 如果需要关注前面节点,则可以在递归是带入额外参数。
二叉树题目的一个难点就是,如何把题目的要求细化成每个节点需要做的事情。
-
树的遍历方式:
前序 中序 后序 层序,把题目的要求细化,搞清楚根节点应该做什么,然后剩下的事情抛给前/中/后/层序的遍历框架就行了。那怎么确定用那种遍历方式呢?思考一个二叉树节点需要做什么,到底用什么遍历顺序就清楚了。比如说:
如果当前节点要做的事情需要通过左右子树的计算结果推导出来,就要用到后序遍历。 -
数学归纳法的思想:假设子节点成立,然后根据这个成立的条件推导父节点成立,最终得到答案
-
遇到任何递归型的问题,无非就是灵魂三问:
1、这个函数是干嘛的?
2、这个函数参数中的变量是什么?
3、得到函数的递归结果,你应该干什么?
BST的解题套路
- 从做算法题的角度来看 BST,除了它的定义,还有一个重要的性质:BST 的中序遍历结果是有序的(升序)
- 针对 BST 的遍历框架:
void BST(TreeNode root, int target) {
if (root.val == target)
// 找到目标,做点什么
if (root.val < target)
BST(root.right, target);
if (root.val > target)
BST(root.left, target);
}
这个代码框架其实和二叉树的遍历框架差不多,无非就是利用了 BST 左小右大的特性而已。
- 一旦涉及「改」,函数就要返回
TreeNode类型,并且对递归调用的返回值进行接收。 - 如果当前节点会对下面的子节点有整体影响,可以通过辅助函数增长参数列表,借助参数传递信息。
二叉树的分类
满二叉树:二叉树中所有非叶子结点的度都是2,且叶子结点都在同一层次上
完全二叉树:如果把满二叉树从右至左、从下往上删除一些节点,剩余的结构就构成完全二叉树
二叉查找树(BST):左子树上所有结点的值均小于它的根结点的值; 右子树上所有结点的值均大于它的根结点的值; 它的左、右子树也分别为二叉排序树。
平衡二叉树:一种特殊的BST,它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
字符串
子串、子数组问题一般使用双指针
动态规划(DP)
重叠子问题、最优子结构、状态转移方程就是动态规划三要素
最优子结构: 要符合「最优子结构」,子问题间必须互相独立,互不影响,且子问题最优能推导出主问题最优。比如:你的、、原问题是考出最高的总成绩,那么你的子问题就是要把语文考到最高,数学考到最高...
动态规划题目问法
- 最值问题(比如,最长公共子序列、最小编辑距离、背包能装的最大价值…)
- 所有方案的个数(比如,两点之间路径个数、爬楼梯问题…)
- 是否类问题(比如,两个字符串是否按一定方式匹配、背包是否能被填满…)
当然,还有些题目基本上不大可能使用动态规划求解:
- 找出所有的方案(比如,N 皇后问题、求解一个集合的所有子集、排列组合类问题…)
- 排序相关的问题(比如,滑动窗口类问题、双指针类问题…)
- 极值类问题(比如,求数组当中的峰值…)
解法框架
暴力穷举->备忘录->动态规划
解题步骤:
1.先确定「状态」,也就是原问题和子问题中变化的变量,也可以理解为函数的参数,dp[x]中的x
2.然后确定dp函数的定义,确定dp[x]
记住如何解释dp含义,一旦你觉得哪里不好理解,把它翻译成自然语言就容易理解了。
3.然后确定「选择」并择优,选择是写出推导方程的关键
4.写出推导方程(数学归纳法)(base case要先明确), 即函数表达式
动态规划的核心设计思想是数学归纳法。
数学归纳法:
证明当n等于任意一个自然数时某命题成立。证明分下面两步:
- 证明当n= 1时命题成立。
- 假设n=m时命题成立,那么可以推导出在n=m+1时命题也成立。(m代表任意自然数)
重点
1、明确 dp 数组所存数据的含义。这一步对于任何动态规划问题都很重要,如果不得当或者不够清晰,会阻碍之后的步骤。
2、根据 dp 数组的定义,运用数学归纳法的思想,假设 dp[0...i-1] 都已知,想办法求出 dp[i],一旦这一步完成,整个题目基本就解决了。
但如果无法完成这一步,很可能就是 dp 数组的定义不够恰当,需要重新定义 dp 数组的含义;或者可能是 dp 数组存储的信息还不够,不足以推出下一步的答案,需要把 dp 数组扩大成二维数组甚至三维数组
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 择优(选择1,选择2...)
遍历的方式有三种:
- 斜着遍历
// 斜着遍历数组
for (int l = 1; l < n; l++) {
for (int i = 0; i < n - l; i++) {
int j = l + i;
// 计算 dp[i][j]
}
}
- 正向遍历:
int[][] dp = new int[m][n];
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
// 计算 dp[i][j]
- 反向遍历:
for (int i = m - 1; i >= 0; i--)
for (int j = n - 1; j >= 0; j--)
// 计算 dp[i][j]
怎么确定用那种遍历方式呢?
1、遍历的过程中,所需的状态必须是已经计算出来的。
2、遍历的终点必须是存储结果的那个位置。
分类设计dp数组
1. 矩阵类问题
画表,找到 dp[i][j] 和 dp[i-1][j], dp[i][j-1], dp[i-1][j-1]的关系,例如路径类问题
dp[i][j] = min(dp[i-1][j],dp[i][j-1])+num[i][j]
可以压缩为:dp[j] = min(dp[j],dp[j-1])+num[i][j]
2. 子序列
相比矩阵类动态规划,序列类动态规划最大的不同在于,对于第 i 个位置的状态分析,它不仅仅需要考虑当前位置的状态,还需要考虑前面 i - 1 个位置的状态
思考的方向其实在于 寻找当前状态和之前所有状态的关系
例如最长递增子序列: dp[i] 表示以位置 i 结尾的子序列的最大长度,nums[i]可以接在任何一个nums[<i]的后面成为一个新的子序列
dp[i] = Math.max(dp[j],...,dp[k]) + 1
1、 第一种思路模板是一个一维的 dp 数组
int n = array.length;
int[] dp = new int[n];
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
dp[i] = 最值(dp[i], dp[j] + ...)
}
}
举个我们写过的例子 最长递增子序列,在这个思路中 dp 数组的定义是:
在子数组array[0..i]中,以array[i] 结尾的目标子序列(最长递增子序列)的长度是dp[i]。
为啥最长递增子序列需要这种思路呢?前文说得很清楚了,因为这样符合归纳法,可以找到状态转移的关系,这里就不具体展开了。
2、第二种思路模板是一个二维的 dp 数组
int n = arr.length;
int[][] dp = new dp[n][n];
for (int i = 0; i < n; i++) {
for (int j = 1; j < n; j++) {
if (arr[i] == arr[j])
dp[i][j] = dp[i][j] + ...
else
dp[i][j] = 最值(...)
}
}
这种思路运用相对更多一些,尤其是涉及两个字符串/数组的子序列。本思路中 dp 数组含义又分为「只涉及一个字符串」和「涉及两个字符串」两种情况。
2.1 涉及两个字符串/数组时
dp 数组的含义如下:
在子数组arr1[0..i]和子数组arr2[0..j]中,我们要求的子序列(最长公共子序列)长度为dp[i][j] 。
解决两个字符串的动态规划问题,一般都是用两个指针
i,j分别指向两个字符串的最后,然后一步步往前走,缩小问题的规模。
2.2 只涉及一个字符串/数组时
(比如本文要讲的最长回文子序列),dp 数组的含义如下:
在子数组array[i..j]中,我们要求的子序列(最长回文子序列)的长度为dp[i][j] 。
最终求的结果就是i=0 j=n-1
3. 字符匹配类问题(双序列类)
字符串匹配问题的核心永远是字符之间的比较:
往往这类问题的状态比较好找,你可以先假设状态 dp[i][j] 就是子问题 str1(0...i) str2(0...j) 的状态。
拆解问题主要思考 dp[i][j] 和子问题的状态 dp[i - 1][j],dp[i][j-1] 以及 dp[i - 1][j - 1] 的联系,因为字符串会存在空串的情况,所以动态规划状态数组往往会多开一格。
4.连续类问题
dp[i] = Math.max(dp[i-1]*nums[i],nums[i])
要么i加入前一段,要么自成一段。
最大子数组和 、 最长公共子串
5.背包问题
回溯(DFS)
解决一个回溯问题,实际上就是一个决策树的遍历过程。你只需要思考 3 个问题:
1、 路径:也就是已经做出的选择。
2、选择列表:也就是你当前可以做的选择。
3、结束条件:也就是到达决策树底层,无法再做选择的条件。
代码方面,回溯算法的框架:
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
#剪枝
合法性判断
# 做选择
将该选择从选择列表移除
路径.add(选择)
backtrack(路径, 选择列表)
# 撤销选择
路径.remove(选择)
将该选择再加入选择列表
其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」,特别简单。
做选择、撤销选择、结束条件不同的场景业务逻辑不一样,框架完全套用
!!!一定要注意result.add(路径)时要new一个对象, 如result.add(new ArrayList<>(track)
排列组合子集类问题
子集问题可以利用数学归纳思想,假设已知一个规模较小的问题的结果,思考如何推导出原问题的结果。也可以用回溯算法,要用 start 参数排除已选择的数字。
组合问题利用的是回溯思想,结果可以表示成树结构,我们只要套用回溯算法模板即可,关键点在于要用一个 start 排除已经选择过的数字。
排列问题是回溯思想,也可以表示成树结构套用算法模板,不同之处在于使用 contains 方法排除已经选择的数字,前文有详细分析,这里主要是和组合问题作对比。
对于这三个问题,关键区别在于回溯树的结构,不妨多观察递归树的结构,很自然就可以理解代码的含义了。
BFS
BFS 的核心思想应该不难理解的,就是把一些问题抽象成图,从一个点开始,向四周开始扩散。一般来说,我们写 BFS 算法都是用队列这种数据结构,每次将一个节点周围的所有节点加入队列。
BFS 相对 DFS 的最主要的区别是:BFS 找到的路径一定是最短的,但代价就是空间复杂度比 DFS 大很多。
我们先举例一下 BFS 出现的常见场景好吧,问题的本质就是让你在一幅「图」中找到从起点start到终点target的最近距离,这个例子听起来很枯燥,但是 BFS 算法问题其实都是在干这个事儿。
算法解题框架:
// 计算从起点 start 到终点 target 的最近距离
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++;
}
}
队列q就不说了,BFS 的核心数据结构;cur.adj()泛指cur相邻的节点,比如说二维数组中,cur上下左右四面的位置就是相邻节点;visited的主要作用是防止走回头路,大部分时候都是必须的,但是像一般的二叉树结构,没有子节点到父节点的指针,不会走回头路就不需要visited。
传统的 BFS 框架就是从起点开始向四周扩散,遇到终点时停止;而双向 BFS 则是从起点和终点同时开始扩散,当两边有交集的时候停止。
贪心
一种特殊情况的动态规划,每一次选择最优->全局最优
分治
双指针
双指针套路
我把双指针技巧再分为两类,一类是「快慢指针」,一类是「左右指针」。前者解决主要解决链表中的问题,比如典型的判定链表中是否包含环;后者主要解决数组(或者字符串)中的问题,比如二分查找。
- 快慢指针 1.链表
- 左右指针
1.二分查找
2.数组反转
3.两数之和
4.滑动窗口
滑动窗口
一种特殊的双指针
这也许是双指针技巧的最高境界了,如果掌握了此算法,可以解决一大类子字符串匹配、子数组的问题,不过「滑动窗口」稍微比上述的这些算法复杂些
详细看这里
滑动窗口算法的思路是这样:
1、 我们在字符串S中使用双指针中的左右指针技巧,初始化left = right = 0,把索引左闭右开区间[left, right)称为一个「窗口」。
2、 我们先不断地增加right指针扩大窗口[left, right),直到窗口中的字符串符合要求(包含了T中的所有字符)。
3、 此时,我们停止增加right,转而不断增加left指针缩小窗口[left, right),直到窗口中的字符串不再符合要求(不包含T中的所有字符了)。同时,每次增加left,我们都要更新一轮结果。
4、 重复第 2 和第 3 步,直到right到达字符串S的尽头。
这个思路其实也不难,第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解, 也就是最短的覆盖子串。左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动,这就是「滑动窗口」这个名字的来历。
现在开始套模板,只需要思考以下四个问题:
1、 当移动right扩大窗口,即加入字符时,应该更新哪些数据?
2、 什么条件下,窗口应该暂停扩大,开始移动left缩小窗口?
3、 当移动left缩小窗口,即移出字符时,应该更新哪些数据?
4、 我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?
如果一个字符进入窗口,应该增加window计数器;如果一个字符将移出窗口的时候,应该减少window计数器;当valid满足need时应该收缩窗口;应该在收缩窗口的时候更新最终结果。