> 文章列表 > JavaScript前端面试常考算法模板

JavaScript前端面试常考算法模板

JavaScript前端面试常考算法模板

一、二分查找

使用条件

  • 排序数组 (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,否则就先看 endif (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 这一段区间的最优值/可行性/方案总数
  • 匹配型
    • 通常给出两个字符串
    • 两个字符串的匹配值依赖于两个字符串前缀的匹配值
    • 字符串长度为 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;// 分两种情况更新当前节点下一个节点对应的前导节点为 previf (prev.next !== null) {this.keyToPrev.set(prev.next.key, prev);}this.keyToPrev.set(curt.key, this.tail);this.tail = curt;}get(key) {// 如果这个 key 根本不存在于缓存,返回 -1if (!this.keyToPrev.has(key)) {return -1;}// 这个 key 刚刚被访问过,因此 key 节点应当被移动到链表尾部this.moveToTail(key);// key 节点被移动到链表尾部,返回尾部的节点值,即 tail.valreturn this.tail.val;}set(key, value) {// 如果 key 已经存在,更新 keyNode 的 valueif (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);}
}