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,否则就先看 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 这一段区间的最优值/可行性/方案总数
  • 匹配型
    • 通常给出两个字符串
    • 两个字符串的匹配值依赖于两个字符串前缀的匹配值
    • 字符串长度为 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);
  }
}

猜你喜欢

转载自blog.csdn.net/SmallTeddy/article/details/130233453