一、二分查找
使用条件
- 排序数组 (30-40%是二分)
- 当面试官要求你找一个比 O(n) 更小的时间复杂度算法的时候(99%)
- 找到数组中的一个分割位置,使得左半部分满足某个条件,右半部分不满足(100%)
- 找到一个最大/最小的值使得某个条件被满足(90%)
复杂度
- 时间复杂度: O(log(n))
- 空间复杂度: O(1)
function binarySearch(nums, target) {
// corner case 处理
if (!nums || nums.length === 0) {
return -1;
}
let start = 0, end = nums.length - 1;
// 用 start + 1 < end 而不是 start < end 的目的是为了避免死循环
// 在 first position of target 的情况下不会出现死循环
// 但是在 last position of target 的情况下会出现死循环
// 样例: nums = [1, 1] target = 1
// 为了统一模板,我们就都采用 start + 1 < end,就保证不会出现死循环
while (start + 1 < end) {
// Math.floor() 确保 mid 是整数
let mid = Math.floor((start + end) / 2);
// > , =, < 的逻辑先分开写, 然后在看看= 的情况是否能合并到其他分支里
if (nums[mid] < target) {
start = mid;
} else if (nums[mid] === target) {
end = mid;
} else {
end = mid;
}
}
// 因为上面的循环退出条件是 start + 1 < end
// 因此这里循环结束的时候, start 和 end 的关系是相邻关系(1 和 2, 3 和 4 这种)
// 因此需要再单独判断 start 和 end 这两个数谁是我们要的答案
// 如果是找 first position of target 就先看 start,否则就先看 end
if (nums[start] === target) {
return start;
}
if (nums[end] === target) {
return end;
}
return -1;
}
二、双指针
使用条件
- 滑动窗口 (90%)
- 时间复杂度要求 O(n) (80%是双指针)
- 要求原地操作,只可以使用交换,不能使用额外空间 (80%)
- 有子数组 subarray /子字符串 substring 的关键词 (50%)
- 有回文 Palindrome 关键词(50%)
复杂度
- 时间复杂度: O(n)
- 时间复杂度与最内层循环主体的执行次数有关
- 与有多少重循环无关
- 空间复杂度: O(1)
- 只需要分配两个指针的额外内存
1、相向双指针
function partition(A, start, end) {
if (start >= end) {
return;
}
let left = start,
right = end;
const pivot = A[Math.floor((start + end) / 2)];
while (left <= right) {
while (left <= right && A[left] < pivot) {
left++;
}
while (left <= right && A[right] > pivot) {
right--;
}
if (left <= right) {
[A[left], A[right]] = [A[right], A[left]];
left++;
right--;
}
}
}
2、背向双指针
function backwardPointers(s, position) {
let left = position,
right = position + 1;
while (left >= 0 && right < s.length) {
if (left 可以停下来了 && right 可以停下来了) {
break;
}
left--;
right++;
}
}
3、同向双指针
function forwardPointers(n) {
let j = 0;
for (let i = 0; i < n; i++) {
while (j < n && !满足条件(i, j)) {
j++;
}
if (满足条件(i, j)) {
处理(i, j);
}
}
}
4、合并双指针
function merge(list1, list2) {
const newList = [];
let i = 0,
j = 0;
while (i < list1.length && j < list2.length) {
if (list1[i] < list2[j]) {
newList.push(list1[i]);
i++;
} else {
newList.push(list2[j]);
j++;
}
}
while (i < list1.length) {
newList.push(list1[i]);
i++;
}
while (j < list2.length) {
newList.push(list2[j]);
j++;
}
return newList;
}
三、二叉树分治
使用条件
- 二叉树相关的问题 (99%)
- 可以一分为二去分别处理之后再合并结果 (100%)
- 数组相关的问题 (10%)
复杂度
时间复杂度 O(n)
空间复杂度 O(n) (含递归调用的栈空间最大耗费)
function divide_conquer(root) {
// 递归出口
if (!root) {
return ...;
}
// 处理左子树
let left_result = divide_conquer(root.left);
// 处理右子树
let right_result = divide_conquer(root.right);
// 合并答案
let result = merge(left_result, right_result); // 这里需要自己实现merge函数
return result;
}
四、二叉搜索树非递归 BST
使用条件
- 用非递归的方式(Non-recursion / Iteration) 实现二叉树的中序遍历
- 常用于 BST 但不仅仅可以用于 BST
复杂度
时间复杂度 O(n)
空间复杂度 O(n)
function inorder_traversal(root) {
if (!root) {
return [];
}
// 创建一个 dummy node,右指针指向 root
// 并放到 stack 里,此时 stack 的栈顶 dummy 是 iterator 的当前位置
let dummy = new TreeNode(0);
dummy.right = root;
let stack = [dummy];
let inorder = [];
// 每次将 iterator 挪到下一个点,也就是调整 stack 使得栈顶到下一个点
while (stack.length) {
let node = stack.pop();
if (node.right) {
node = node.right;
while (node) {
stack.push(node);
node = node.left;
}
}
if (stack.length) {
inorder.push(stack[stack.length - 1]);
}
}
return inorder;
}
五、宽度优先搜索 BFS
使用条件
- 拓扑排序(100%)
- 出现连通块的关键词(100%)
- 分层遍历(100%)
- 简单图最短路径(100%)
- 给定一个变换规则,从初始状态变到终止状态最少几步(100%)
复杂度
- 时间复杂度: O(n + m)
- n 是点数, m 是边数
- 空间复杂度: O(n)
function get_indegrees(nodes) {
let counter = {
};
nodes.forEach(node => {
counter[node] = 0;
});
nodes.forEach(node => {
node.get_neighbors().forEach(neighbor => {
counter[neighbor]++;
});
});
return counter;
}
function topological_sort(nodes) {
// 统计入度
let indegrees = get_indegrees(nodes);
// 所有入度为 0 的点都放到队列里
let queue = [];
nodes.forEach(node => {
if (indegrees[node] === 0) {
queue.push(node);
}
});
// 用 BFS 算法一个个把点从图里挖出来
let topo_order = [];
while (queue.length) {
let node = queue.shift();
topo_order.push(node);
node.get_neighbors().forEach(neighbor => {
indegrees[neighbor]--;
if (indegrees[neighbor] === 0) {
queue.push(neighbor);
}
});
}
// 判断是否有循环依赖
if (topo_order.length !== nodes.length) {
return "有循环依赖(环),没有拓扑序";
}
return topo_order;
}
六、深度优先搜索 DFS
使用条件
- 找满足某个条件的所有方案 (99%)
- 二叉树 Binary Tree 的问题 (90%)
- 组合问题(95%)
- 问题模型:求出所有满足条件的“组合”
- 判断条件: 组合中的元素是顺序无关的
- 排列问题 (95%)
- 问题模型:求出所有满足条件的“排列”
- 判断条件: 组合中的元素是顺序“相关”的。
不要用 DFS 的场景
- 连通块问题(一定要用 BFS,否则 StackOverflow)
- 拓扑排序(一定要用 BFS,否则 StackOverflow)
- 一切 BFS 可以解决的问题复杂度
时间复杂度
- O(方案个数 * 构造每个方案的时间)
- 树的遍历 : O(n)
- 排列问题 : O(n! * n)
- 组合问题 : O(2^n * n)
function dfs(参数列表) {
if (递归出口) {
记录答案;
return;
}
for (所有的拆解可能性) {
修改所有的参数;
dfs(参数列表);
还原所有被修改过的参数;
}
return something; // 如果需要的话,很多时候不需要 return 值除了分治的写法
}
七、动态规划
使用条件
使用场景:
- 求方案总数(90%)
- 求最值(80%)
- 求可行性(80%)
不适用的场景:
- 找所有具体的方案(准确率 99%)
- 输入数据无序(除了背包问题外,准确率 60%~70%)
- 暴力算法已经是多项式时间复杂度(准确率 80%)
动态规划四要素(对比递归的四要素):
- 状态 (State) – 递归的定义
- 方程 (Function) – 递归的拆解
- 初始化 (Initialization) – 递归的出口
- 答案 (Answer) – 递归的调用
几种常见的动态规划:
- 背包型
- 给出 n 个物品及其大小,问是否能挑选出一些物品装满大小为 m 的背包
- 题目中通常有“和”与“差”的概念,数值会被放到状态中
- 通常是二维的状态数组,前 i 个组成和为 j 状态数组的大小需要开 (n + 1) * (m + 1)
- 区间型
- 题目中有 subarray / substring 的信息
- 大区间依赖小区间
- 用 dp[i][j] 表示数组/字符串中 i, j 这一段区间的最优值/可行性/方案总数
- 题目中有 subarray / substring 的信息
- 匹配型
- 通常给出两个字符串
- 两个字符串的匹配值依赖于两个字符串前缀的匹配值
- 字符串长度为 n,m 则需要开 (n + 1) x (m + 1) 的状态数组
- 要初始化 dp[i][0] 与 dp[0][i]
- 通常都可以用滚动数组进行空间优化
- 划分型
- 是前缀型动态规划的一种, 有前缀的思想
- 如果指定了要划分为几个部分:
- dp[i][j] 表示前 i 个数/字符划分为 j 个 部分的最优值/方案数/可行性
- 如果没有指定划分为几个部分:
- dp[i] 表示前 i 个数/字符划分为若干个 部分的最优值/方案数/可行性
- 接龙型
- 通常会给一个接龙规则, 问你最长的龙有多长
- 状态表示通常为: dp[i] 表示以坐标为 i 的元素结尾的最长龙的长度
- 方程通常是: dp[i] = max{dp[j] + 1}, j 的后面可以接上 i
- LIS 的二分做法选择性的掌握,但并不是所有的接龙型 DP 都可以用二分来优化
复杂度
- 时间复杂度:
- O(状态总数 * 每个状态的处理耗费)
- 等于 O(状态总数 * 决策数)
- 空间复杂度:
- O(状态总数) (不使用滚动数组优化)
- O(状态总数 / n)(使用滚动数组优化, n 是被滚动掉的那一个维度)
八、堆
使用条件
- 找最大值或者最小值(60%)
- 找第 k 大(pop k 次 复杂度 O(nlogk))(50%)
- 要求 logn 时间对数据进行操作(40%)
堆不能解决的问题
- 查询比某个数大的最小值/最接近的值(平衡排序二叉树 Balanced BST 才可以解决)
- 找某段区间的最大值最小值(线段树 SegmentTree 可以解决)
- O(n)找第 k 大 (使用快排中的 partition 操作)
class ValueIndexPair {
constructor(val, index) {
this.val = val;
this.index = index;
}
}
class Heap {
constructor() {
this.minheap = new PriorityQueue((p1, p2) => p1.val - p2.val);
this.deleteSet = new Set();
}
push(index, val) {
this.minheap.add(new ValueIndexPair(val, index));
}
lazyDeletion() {
while (this.minheap.size() !== 0 && this.deleteSet.has(this.minheap.peek().index)) {
const pair = this.minheap.poll();
this.deleteSet.delete(pair.index);
}
}
top() {
this.lazyDeletion();
return this.minheap.peek();
}
pop() {
this.lazyDeletion();
this.minheap.poll();
}
delete(index) {
this.deleteSet.add(index);
}
isEmpty() {
return this.minheap.size() === 0;
}
}
九、并查集
使用条件
- 需要查询图的连通状况的问题
- 需要支持快速合并两个集合的问题
复杂度
- 时间复杂度 union O(1), find O(1)
- 空间复杂度 O(n)
class UnionFind {
constructor() {
this.father = new Map();
this.sizeOfSet = new Map();
this.numOfSet = 0;
}
add(x) {
if (this.father.has(x)) {
return;
}
this.father.set(x, null);
this.sizeOfSet.set(x, 1);
this.numOfSet++;
}
merge(x, y) {
const rootX = this.find(x);
const rootY = this.find(y);
if (rootX !== rootY) {
this.father.set(rootX, rootY);
this.numOfSet--;
this.sizeOfSet.set(rootY, this.sizeOfSet.get(rootX) + this.sizeOfSet.get(rootY));
}
}
find(x) {
let root = x;
while (this.father.get(root) !== null) {
root = this.father.get(root);
}
while (x !== root) {
const originalFather = this.father.get(x);
this.father.set(x, root);
x = originalFather;
}
return root;
}
isConnected(x, y) {
return this.find(x) === this.find(y);
}
getNumOfSet() {
return this.numOfSet;
}
getSizeOfSet(x) {
return this.sizeOfSet.get(this.find(x));
}
}
十、字典树
使用条件
- 需要查询包含某个前缀的单词/字符串是否存在
- 字符矩阵中找单词的问题
复杂度
- 时间复杂度 O(L) 增删查改
- 空间复杂度 O(N * L) N 是单词数, L 是单词长度
class TrieNode {
// 儿子节点
children = new Map();
// 根节点到该节点是否是一个单词
isWord = false;
// 根节点到该节点的单词是什么
word = null;
constructor() {
}
}
class Trie {
constructor() {
this.root = new TrieNode();
}
getRoot() {
return this.root;
}
// 插入单词
insert(word) {
let node = this.root;
for (let i = 0; i < word.length; i++) {
let letter = word.charAt(i);
if (!node.children.has(letter)) {
node.children.set(letter, new TrieNode());
}
node = node.children.get(letter);
}
node.isWord = true;
node.word = word;
}
// 判断单词 word 是不是在字典树中
hasWord(word) {
let L = word.length;
let node = this.root;
for (let i = 0; i < L; i++) {
let letter = word.charAt(i);
if (!node.children.has(letter)) {
return false;
}
node = node.children.get(letter);
}
return node.isWord;
}
// 判断前缀 prefix 是不是在字典树中
hasPrefix(prefix) {
let L = prefix.length;
let node = this.root;
for (let i = 0; i < L; i++) {
let letter = prefix.charAt(i);
if (!node.children.has(letter)) {
return false;
}
node = node.children.get(letter);
}
return true;
}
}
十一、LRU 缓存
复杂度
- 时间复杂度 get O(1), set O(1)
- 空间复杂度 O(n)
class LRUCache {
// 单链表节点
class ListNode {
constructor(key, val) {
this.key = key;
this.val = val;
this.next = null;
}
}
// cache 的最大容量
constructor(capacity) {
this.capacity = capacity;
this.size = 0;
this.keyToPrev = new Map();
// dummy 点的 key 和 value 随意
this.dummy = new ListNode(0, 0);
this.tail = this.dummy;
}
// 将 key 节点移动到尾部
moveToTail(key) {
const prev = this.keyToPrev.get(key);
const curt = prev.next;
// 如果 key 节点已经再尾部,无需移动
if (this.tail === curt) {
return;
}
// 从链表中删除 key 节点
prev.next = prev.next.next;
this.tail.next = curt;
curt.next = null;
// 分两种情况更新当前节点下一个节点对应的前导节点为 prev
if (prev.next !== null) {
this.keyToPrev.set(prev.next.key, prev);
}
this.keyToPrev.set(curt.key, this.tail);
this.tail = curt;
}
get(key) {
// 如果这个 key 根本不存在于缓存,返回 -1
if (!this.keyToPrev.has(key)) {
return -1;
}
// 这个 key 刚刚被访问过,因此 key 节点应当被移动到链表尾部
this.moveToTail(key);
// key 节点被移动到链表尾部,返回尾部的节点值,即 tail.val
return this.tail.val;
}
set(key, value) {
// 如果 key 已经存在,更新 keyNode 的 value
if (this.get(key) !== -1) {
const prev = this.keyToPrev.get(key);
prev.next.val = value;
return;
}
// 如果 key 不存在于 cache 且 cache 未超上限
// 再结尾存入新的节点
if (this.size < this.capacity) {
this.size++;
const curt = new ListNode(key, value);
this.tail.next = curt;
this.keyToPrev.set(key, this.tail);
this.tail = curt;
return;
}
// 如果超过上限,删除链表头,继续保存。此处可与上边合并
const first = this.dummy.next;
this.keyToPrev.delete(first.key);
first.key = key;
first.val = value;
this.keyToPrev.set(key, this.dummy);
this.moveToTail(key);
}
}