前言
本文只是个人在算法解题方面的一些经验总结,只讨论算法解题过程中的一些常用技巧或思考过程,不讨论书上写得已经很详细的数据结构/算法,比如排序算法,相关的文章太多了,本文不做这方面的讨论。
另外,有部分知识点参考了慕课网的课程《玩转算法面试》,推荐学习。
通用技巧
先处理输入参数错误的情况
很多算法题虽然只给你限定范围内的数字,比如只让你处理正整数,但传入的参数存在负数,或者,传入的参数为 null,要怎么处理,这些都是要和面试官沟通清楚的。
边界处理
判断极端条件,比如 null、0、数组长度是否越界、选用的数据类型是否能储存所有的可能值比如两个 int 相加的结果是否需要使用 long 存储等。
从最简单的子问题开始考虑
如果没有解题思路,那么,可以先从最简单的子问题开始考虑,写几个简单的测试用例,试验一下,说不定就能找到思路。特别是递归相关的问题,比如二叉树问题,只要搞清楚了子问题的求解步骤,就能推导出整个问题的解题思路。比如:Invert Binary Tree、Validate Binary Search Tree
不要忽视暴力解法
不要忽视暴力解法,暴力解法通常是思考的起点,千万不要和面试官说自己没有任何想法
简化问题,分为多个步骤解决
如果问题比较复杂,很难给出简洁的算法,那么可以考虑是否能对数据进行预处理,使问题变得简单,再求解。比如先排序,后解题(Group Anagrams);或者将一个比较难的问题,分解为两个小问题,再根据两次求解结果获得最终结果即可(House Robber II)。
直接在原有数组上进行操作
常见于在条件限制较严格,无法分配更多空间的情况。比如,题目限制了空间复杂度为常量级,那么,如果输入参数本身就是一个数组,则可以在原数组上进行操作(First Missing Positive)。
遍历有限结果集
有时候,题目的最终结果只会有限范围内的计算中产生,此时简单循环遍历即可求解。比如 Binary Watch,实际上是一道组合问题,可以使用递归/回溯解决,但结果集只在有限范围内产生,因此最简单的遍历即可解决。
遍历学过的算法/数据结构
有些问题,如果一时间找不到思路,那么可以遍历学过的数据结构和算法,一个个排查,看是否能帮助解决问题。
需要注意的是,很多问题其实只要借助经典算法中的某一个步骤就足以解决了。比如快排,中间有一个步骤是,将比数字 n 大的的放在右边,比数字 n 小的放在左边,这实际上是一个分治的思想,可用于解决很多问题,比如 Kth Largest Element in an Array。又比如上面提到过的一个例子 First Missing Positive,实际上是表排序的一个应用。
排查过程首先考虑数据结构,要形成对数据结构的敏感度,特别是栈,有些问题借助栈能够很方便地解决,但如果一时想不到,就会很麻烦,比如 Add Two Numbers II。也有些问题,虽然看上去不太明显,但实际上可以转化为对应的数据结构解决,比如 Word Ladder,实际上可以转变为无权图的最短路径问题。
画图
将程序的运行过程画出来,可以更直观地理解问题。特别是递归/回溯相关,比如斐波那契数列,递归的过程其实就是一颗二叉树,画图出来后,会发现其中存在很多重复的结点(也就是重复的计算),进而能推导出一个优化思路是使用 Map 存储中间结果。
两根指针
对撞指针
通常用于解决在数组中查找一个或有限个元素的问题,和快排中的一个步骤很相似。解题步骤大致为:
- 设置左右指针 left,right,初始值为 0 和 nums.length - 1
- 当 left 小于 right 时持续遍历数组中的每一个元素
- 如果找到符合题意的元素,则返回
- 否则根据题目条件执行 left++ 或 right-- 操作,并进行下一次遍历
一个经典的题目是:Two Sum II,给定一个有序数组,和整数 target,在其中寻找两个元素,使其和为 target。
解题思路:设置左右指针 left,right,因为是有序的,因此检查 nums[left] + nums[right] 和 target 的大小关系即可,如果等于 target,就说明找到了,返回结果。否则,大于 target 则 right--,小于 target 则 left++。
滑动窗口
通常用于解决在数组中寻找连续子数组的问题,解题步骤大致为:
- 设置两根指针 i 和 j,初始值为 0 或 1,用于表示子数组 [i..j)
- 当 i、j 小于 nums.length 时,持续遍历数组中的每一个元素
- 假如子数组 [i..j) 符合要求,根据题意,如果寻找的是最长子数组,则执行 j++ 操作,否则执行 i++ 操作,寻找最优解
- 假如子数组 [i..j) 不符合要求,则可能需要对指针 i、j 重新赋值
经典题:Minimum Size Subarray Sum,给定一个整型数组,和一个数字 s,找到数组中最短的一个连续子数组,使得子数组的数字和大于等于 s。
解题思路:设置两根指针 i 和 j,初始值为 0,相加后检查是否满足要求,满足则记录,并移动 i 指针,查找最优解;否则移动 j,再加一个数字。
链表
新建表头节点
常见于表头结点可能被修改的情况,比如 Remove Linked List Elements
交换节点值
常见于需要删除结点的问题,比如待删除结点为 cur,但不知道 pre 结点,因此可将 next 结点值赋给 cur,再删除 next,比如 Delete Node in a Linked List
记录前后节点 pre、next
常用于需要频繁交换结点位置,执行复杂的“穿针引线”的操作的情况,比如 Swap Nodes in Pairs
先分割,再拼接
常见于需要按照特定规则重新排列链表结点的情况,中间可根据需要逆转链表,比如 Reverse Linked List II、Reorder List、Palindrome Linked List
递归和回溯
常用于解决树形问题。所谓树形问题,是指解题过程中的每一个步骤都可能有多个候选项,类似于多分支的树结构,此时,往往需要用到递归+回溯。递归/回溯算法听起来有些高大上,实际上就是暴力解法,尝试每一个可能(分支)以获得最终解。
一个能解决部分问题(比如排列、组合问题)的算法模板是这样的:
class Solution {
// 参数经常是一个数组或集合,或间接表示一个数组/集合的数字
public List<List<Integer>> solve(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
// 通常需要新建数组 visited ,避免在递归过程中重复访问
boolean[] visited = new boolean[nums.length];
dfs(nums, 0, visited, new ArrayList<>(), result);
return result;
}
// 一般情况下,使用 dfs 都是没错的
// 通常使用 index 表示进行到哪一步了,使用 list 记录中间结果,使用 result 记录最终结果
public void dfs(int[] nums, int index, boolean[] visited, List<Integer> list, List<List<Integer>> result) {
// 首先确认终止条件
if (index == nums.length) {
result.add(list); // 通常在这里记录最终结果
return;
}
// 遍历寻找下一个符合条件的值
for (int i = 0; i < nums.length; i++) {
if (visited[i] || 不符合条件) {
continue;
}
// 设置状态为已访问,记录中间结果
visited[i] = true;
list.add(nums[i]);
// 递归寻找最终解
dfs(nums, index + 1, visited, list, result);
// 回溯到原状态,寻找下一个可能解
list.remove(list.size() - 1);
visited[i] = false;
}
}
}
有时候,题目可能需要你处理重复数字,或者,如果需要对算法进行优化的话,那么:
class Solution() {
public List<List<Integer>> solve(int[] nums) {
Arrays.sort(nums); // 对数组进行排序
... // 其它不变
}
private void dfs(...) {
...
// 遍历过程中新增判断条件,进行剪枝操作,以优化算法性能
for (int i = 0; i < nums.length && nums[i] 符合条件; i++) {
if (visited[i]) {
continue;
}
...
// 处理重复数字的方法:
// 如果 visited[i - 1] 为 true,说明正在处理该数字,并且尚未处理完毕,可以处理另一个相同的数字
// 反之,因为处理完毕后会导致回溯,回溯后会设置为 false,
// 因此如果 nums[i - 1] == nums[i] ,并且 visited[i - 1] 为 false,说明已将该数字在这一步的情况处理完毕,不需要再次处理
if (i > 0 && nums[i - 1] == nums[i] && !visited[i - 1]) {
continue;
}
...
}
}
}
还有些问题,重点在于有效状态、无效状态的区分,比如 Pacific Atlantic Water Flow、Number of Islands,对于此类问题,可以从必定有效(或必定无效)的点出发,递归地点亮其它点,最终再进行统计就可以了,这种解题思路也被称为 floodfill 算法。
动态规划
动态规划可能是最具艺术性(对于普通程序员而言)的一种算法思路了,可以很简单,也可以很难。简单在于,只要找到了状态转移方程,那么就相当于解决了问题,而难点恰恰就在于如何找到状态转移方程。
比如斐波那契数列,直接把方程式告诉你了,f(n) = f(n - 1) + f(n - 2),你需要做的,就是设置一个数组 int[] dp,然后循环求解 dp[i] = dp[i - 1] + dp[i - 2] 即可。因为 i 只和 i - 1、i - 2 有关系,因此还可以省去数组的开销,使用三个变量 result = pre1 + pre2 即可求解。
但往往很难找到这么明显的状态转移方程,因此,对于复杂的动态规划问题,可以尝试先用递归+回溯的思路解决,接着看一下递归过程中出现了哪些重叠子问题,最后再根据重叠的部分设置状态转移方程。
还是以斐波那契数列为例,很明显,在递归过程中,除了 n 和 n-1,n - 2 及以下的数字都会重复计算至少 2 次,也就是说,数字 n 就是递归过程中的重复因子,因此,n 就是状态转移方程中的变量,设置数组 int[] dp = new int[n] 即可解决问题。
小结一下,动态规划的解题思路为:
- 先考虑使用递归求解
- 确定一些边界状态的初始值,也就是递归终止条件
- 找到递归过程中的重复因子,作为状态转移方程的变量
- 根据已知的部分(往往是边界状态值),推导出后续的解,确定状态转移方程
一般情况下,重复因子都是问题中的目标变量。比如背包问题,求解目标是在背包重量限制为 m 的情况下,能够从给出的物品数组中拿出最大价值的物品,很明显,物品数组是不变的,真正的目标变量是 m,因此,应该将 m 作为状态转移方程的变量。又比如 Word Break,题目给了列表 wordDict 和字符串 s 两个参数,那么很明显,wordDict 是不变的,求解目标是 s,那么可以将 s 作为状态转移方程的变量,每次只考虑一部分 s,根据已知的子字符串推导出整个字符串的结果。
能用于解决部分动态规划问题的一些技巧:
- 如果有数组 nums,那么可以考虑设置数组 int[] dp,dp[i] 代表只考虑索引为 [0..i) 的数字时的情况,dp[i] 和 dp[i-c] 有着怎样的关系?
- 如果有目标 target,那么可以考虑设置数组 int[] dp,dp[i] 代表只考虑目标为 i 时的情况,dp[i] 和 dp[i-c] 有着怎样的关系?
- 如果有数组 nums 和目标 target,那么可以考虑设置数组 int[][] dp,dp[i][j] 代表只考虑索引为 [0..i),目标为 j 时的情况,dp[i][j] 和 dp[i-c][j - c] 有着怎样的关系?
当然,上面说的只是一些常规题的解题技巧,实际上动态规划很灵活,灵活到很难有一个固定的解题套路——这也是它为什么那么难的原因。比如 Wiggle Subsequence,这道题需要设置两个数组才能很好地解决问题,如果没想到用两个数组,那么就很难了。