X

曜彤.手记

随记,关于互联网技术、产品与创业

  1. 数组链表
  2. 哈希表
  3. 二叉树
    1. 类别
    2. 树的各种遍历
      1. 递归遍历(DFS)
      2. 层序遍历(BFS)
  4. 多叉树
    1. 递归遍历(DFS)
    2. 层序遍历(BFS)
  5. 二叉堆
    1. 优先级队列
  6. 队列 / 栈
    1. 单调栈(Monotonic Stack)
    2. 单调队列(Monotonic Queue)
  7. 排序算法
    1. 选择排序
    2. 冒泡排序
    3. 插入排序
    4. 计数排序(稳定版)
    5. 归并排序
    6. 桶排序
    7. 快速排序
  8. 线段树
    1. 链式结构
    2. 数组实现
  9. 技巧
    1. 分治算法 & 分治思想
    2. 链表双指针
    3. 矩阵
    4. 数组
    5. 二叉树
      1. 递归的两种思维模式
      2. 二叉树构造
      3. 最近公共祖先(Lowest Common Ancestor)
    6. 动态规划(Dynamic Programming)
      1. 答题技巧
    7. 回溯算法
    8. LRU & LFU
    9. 其他技巧

自用算法模版(C++)

数组链表

  • 数组
    • 内存连续 ✅;
    • 支持元素的随机访问,时间复杂度 O(1) ✅。
    • 不适用于需要频繁插入 \ 删除的操作 ❌;
    • 大小固定,扩容需搬移元素 ❌。
    • 动态数组底层还是静态数组,会使用动态扩容机制自动扩容。
  • 链表
    • 适用于需要频繁插入 \ 删除的操作 ✅;
    • 大小可以动态扩展 ✅。
    • 在内存的缓存空间局部性(Spatial Locality)上相较数组更差 ❌;
    • 不支持随机访问,需从头 \ 尾遍历,时间复杂度 O(N) ❌。
  • 环形数组:用求模运算,将普通数组变成逻辑上的环形数组,让我们可以用 O(1) 的时间在数组头部增删元素。
#include <iostream>
#include <stdexcept>
#include <ostream>
#include <memory>

class CircularArray {
  // 区间 [start, end)。
  std::unique_ptr<int[]> arr = nullptr;
  size_t start; 
  size_t end;
  size_t count;
public:
  CircularArray(size_t size) : count(0), start(0), end(0) {
    arr = std::make_unique<int[]>(size);
  }
  void addFirst(int n) {
    // 先左移,再赋值;
    start = (start + count - 1) % count;
    arr[start] = n;
    ++count;
  }
  void addLast(int n) {
    // 先赋值,再右移;
    arr[end] = n;
    end = (end + 1) % count;
    ++count;
  }
  int removeFirst() {
    // 先保存值,再右移;
    auto v = arr[start];
    start = (start + 1) % count;
    return v;
  }
  int removeLast() {
    // 先左移,再返回值;
    end = (end + count - 1) % count;
    return arr[end];
  }
};  

哈希表

  • 负载因子(Load Factor):α = 存储的键值对数量 / 底层数组大小,表示哈希表装满的程度的度量,值越大,说明存储的键值对越多。α 过大会导致哈希表扩容;
  • 哈希冲突:两个不同的 key 通过哈希函数得到了相同的索引;
    • 拉链法(Chaining):每个哈希桶(槽)不直接存储一个值,而是存储一个链表或其他数据结构(如链表、链表树、动态数组等),该链表中保存所有映射到同一位置的元素。负载因子可以无限增大;
    • 线性探查法(Linear Probing):在冲突发生时,按照固定的步长,逐步检查下一个桶,直到找到一个空槽来存储元素。负载因子最大为 1。
  • 哈希表中键的遍历顺序是无序的,因为遍历过程实际上是遍历哈希表的底层数组,数组中元素的存放顺序可能会由于哈希表扩容而发生改变;
  • 哈希表中的 key 必须是不可变类型,因为 key 的变化会使原有的索引失效,导致无法找到之前存储的数据;
  • LinkedHashMap:使用双向链表记录哈希表中键值对的插入顺序。双向链表可以保证哈希表值删除的 O(1) 复杂度。

  • ArrayHashMap:使用数组维护加入到哈希表中的所有键值对,哈希表中存放 (key, index),数组中按照 index 存放键值对的实际 Node。可用于从哈希表中以 O(1) 复杂度随机获得 key。下述实例代码没有进行越界检查
#include <vector>
#include <unordered_map>
#include <random>

struct Node {
  int key;
  int val;
};
class ArrayHashMap {
  std::unordered_map<int, int> map;
  std::vector<Node> arr;
  std::mt19937 gen;  // Mersenne Twister 19937 伪随机数生成器(PRNG),提供高质量的伪随机数;
public:
  ArrayHashMap() : gen(std::random_device{}()) {}  // 随机种子,决定随机数的起始状态;
  void put(int key, int val) {
    arr.push_back({ key, val });
    map[key] = arr.size() - 1;
  }
  int get(int key) {
    auto index = map[key];
    return arr[index].val;
  }
  void remove(int key) {
    // 调换待删除元素和数组尾部元素;
    auto index = map[key];
    auto& node = arr.back();
    std::swap(arr[index], node);
    // 调整尾部元素存储在哈希表中的索引位置;
    map[node.key] = index;
    // 删除数组尾部元素;
    arr.pop_back();
    // 删除哈希表中的元素;
    map.erase(key);
  }
  int randomKey() { 
    std::uniform_int_distribution<int> dist(0, arr.size() - 1);
    return arr[dist(gen)].key;
  }
};

二叉树

类别

  • 满二叉树(Perfect Binary Tree):每一层节点都是满的;
  • 完全二叉树(Complete Binary Tree):每一层的节点都紧凑靠左排列,且除了最后一层,其他每层都必须是满的;
    • 完全二叉树的左右子树中,至少有一棵是满二叉树。
  • 二叉搜索树(Binary Search Tree):树中的每个节点,其左子树的每个节点的值都要小于这个节点的值,右子树的每个节点的值都要大于这个节点的值。你可以简单记为「左小右大」。二叉搜索树的性能取决于树的高度,树的高度取决于树的平衡性
    • 主要的实际应用是 TreeMap 和 TreeSet,TreeMap 类似于哈希表,它把键值对存储在一棵二叉搜索树的节点里。TreeMap 中的键值对按照 key 自动排序,插入、删除、查找的时间复杂度为 O(logN)。
  • 平衡二叉树(Balanced Binary Tree):它的「每个节点」的左右子树的高度差不超过 1。假设平衡二叉树中共有 N 个节点,那么平衡二叉树的高度是 O(logN)。

树的各种遍历

  • 在实际的算法问题中,DFS 算法常用来穷举所有路径,BFS 算法常用来寻找最短路径

递归遍历(DFS)

  • 前序位置:在刚刚进入一个二叉树节点的时候执行;
  • 中序位置:在一个二叉树节点左子树都遍历完,即将开始遍历右子树的时候执行。BST 的中序遍历结果是有序的;
  • 后序位置:在将要离开一个二叉树节点的时候执行。
struct TreeNode {
  TreeNode* left;
  TreeNode* right;
  int val;
  TreeNode(int val) : left(nullptr), right(nullptr), val(val) {}
};
void traverse(TreeNode* root) {
  if (!root) return;
  // 前序位置;
  traverse(root->left);
  // 中序位置;
  traverse(root->right);
  // 后序位置;
}

层序遍历(BFS)

  • 可以记录遍历层级的写法框架:
void bfsBinaryTreeTraverse(TreeNode* root) {
  if (!root) return;
  std::queue<TreeNode*> q;
  q.push(root);

  size_t depth = 1;
  while (!q.empty()) {
    int sz = q.size();
    while (sz-- > 0) {  // 处理当前层的所有节点;
      auto node = q.front();
      q.pop();
      if (node->left) {
        q.push(node->left);
      }
      if (node->right) {
        q.push(node->right);
      }
    }
    ++depth;
  }
}
  • 带有节点路径权重和的写法框架:
struct State {
  TreeNode* node;
  size_t depth;
};
void bfsBinaryTreeTraverseWithPriority(TreeNode* root) {
  if (!root) return;
  std::queue<State> q;
  q.push({ root, 1 });
  while (!q.empty()) {
    auto cur = q.front();
    q.pop();
    if (cur.node->left) {
      q.push({ cur.node->left, cur.depth + 1 });
    }
    if (cur.node->right) {
      q.push({ cur.node->right, cur.depth + 1 });
    }
  }
}

多叉树

  • 森林是多个多叉树的集合。一棵多叉树也是一个特殊的森林。

递归遍历(DFS)

struct TreeNode {
  int val;
  std::vector<TreeNode*> children;
};
void traverse(TreeNode* root) {
  if (!root) return;
  // 前序位置;
  for (auto child : root->children) {
    traverse(child);
  }
  // 后序位置;
}

层序遍历(BFS)

void bfsTraverse(TreeNode* root) {
  if (!root) return;
  std:queue<TreeNode*> q;
  q.push(root);
  while (!q.empty()) {
    auto node = q.front();
    q.pop();
    for (auto child : node->children) {
      q.push(child);
    }
  }
}

void bfsBinaryTreeTraverseWithPriority(TreeNode* root) {
  if (!root) return;
  std::queue<TreeNode*> q;
  q.push(root);
  size_t depth = 1;
  while (!q.empty()) {
    size_t sz = q.size();
    while (sz-- > 0) {
      auto cur = q.front();
      q.pop();
      for (auto child : cur->children) {
        q.push(child);
      }
    }
    ++depth;
  }
}

struct State {
  TreeNode* node;
  size_t depth;
};
void bfsBinaryTreeTraverseWithPriorityAsState(TreeNode* root) {
  if (!root) return;
  std::queue<State> q;
  q.push({ root, 1 });
  while (!q.empty()) {
    auto cur = q.front();
    q.pop();
    for (auto child : cur.node->children) {
      q.push({ child, cur.depth + 1 });
    }
  }
}

二叉堆

  • 二叉堆是一种拥有特殊性质的完全二叉树,这棵二叉树上的任意节点的值,都必须大于等于(或小于等于)其左右子树所有节点的值。如果是大于等于,我们称之为「大顶堆」,如果是小于等于,我们称之为「小顶堆」;
  • 一个二叉堆的左右子堆(子树)也是一个二叉堆。这个性质主要在堆排序算法的优化中有用到;
  • 主要操作就两个:sink(下沉)、swim(上浮),用以维护二叉堆的性质。
  • 主要应用有两个:
    • 优先级队列(Priority Queue),增删元素的复杂度是 O(logN);
    • 堆排序(Heap Sort)。

优先级队列

// 简化小顶堆实现;
#include<vector>
#include<iostream>
#include<algorithm>
class PriorityQueue {
  std::vector<int> heap;
  size_t size = 0;

  static int parent(size_t node) {
    return (node - 1) / 2;
  }
  static int left(size_t node) {
    return node * 2 + 1;
  }
  static int right(size_t node) {
    return node * 2 + 2;
  }
  void swim(size_t node) {
    while (node > 0 && heap[node] < heap[parent(node)]) {
      std::swap(heap[node], heap[parent(node)]);
      node = parent(node);
    }
  }
  void sink(size_t node) {
    while (left(node) < size) {  // 完全二叉树,只要有左子节点就继续;
      // 比较自己和左右子节点,看看谁最小;
      auto min = node;
      if (left(node) < size && heap[left(node)] < heap[min]) {
        min = left(node);
      }
      if (right(node) < size && heap[right(node)] < heap[min]) {
        min = right(node);
      }
      if (min == node) break;
      std::swap(heap[node], heap[min]);  // 如果左右子节点中有比自己小的,就交换;
      node = min;
    }
  }
public:
  PriorityQueue(int capacity) {
    heap.resize(capacity);
  }
  auto peak() {
    return heap[0];
  }
  // 向堆中插入一个元素,时间复杂度 O(logN);
  auto push(int x) {
    heap[size] = x;  // 把新元素追加到最后(最后一层的最右,保持完全二叉树);
    ++size;
    swim(size - 1);  // 向堆顶上浮元素;
  }
  // 删除栈顶元素,时间复杂度 O(logN);
  auto pop() {
    int n = heap[0];
    heap[0] = heap[size - 1];  // 把堆底元素放到堆顶;
    --size;
    sink(0);  // 下沉位于堆顶的元素,使其位于正确位置;
    return n;
  }
};

队列 / 栈

  • [🟠 重排链表]:用栈存储节点,然后逆序取出重新排列;
  • 底层均是数组或链表实现,可以用两个栈实现队列:
class Queue {
  stack<int> s1, s2;  // 两个背对方向的栈;
public:
  void push(int x) {
    s2.push(x);  // 新元素永远 push 入右侧的栈;
  }
  int pop() {
    peek();  // 左侧栈是否还有元素,没有则从右侧栈搬移过来;
    auto r = s1.top();
    s1.pop();
    return r;
  }
  int peek() {
    if (s1.empty()) {
      while (!s2.empty()) {  // 将右侧栈中的元素依次搬入左侧栈;
        const auto v = s2.top();
        s1.push(v);
        s2.pop();
      }
    }
    return s1.top();
  }
  auto empty() {
    return s1.empty() && s2.empty();
  }
};

单调栈(Monotonic Stack)

  • 新元素入栈后,栈内的元素都保持有序(单调递增或单调递减)。
  • 常用于解决的问题:
    • 每个元素左 / 右边第一个大 / 小的元素;
    • 每日温度;
vector<int> dailyTemperatures(vector<int>& temperatures) {
  int n = temperatures.size();
  vector<int> res(n);
  stack<int> stk; // 存放下标,保证栈内 temperatures[stk.top()] 单调递减;

  for (int i = n - 1; i >= 0; --i) {  // 从后往前遍历,得到每一个左侧元素右侧最近的大于元素;
    /**
     * 想找大元素,就在入栈前把栈里比它小的元素踢掉。
     * 想找小元素,就在入栈前把栈里比它大的元素踢掉。
     */
    // 弹出所有比当前温度小或相等的索引;
    while (!stk.empty() && temperatures[i] >= temperatures[stk.top()]) {
      stk.pop();
    }
    // 如果栈不为空,说明找到了下一个更高温度的下标;
    res[i] = stk.empty() ? 0 : stk.top() - i;
    stk.push(i); // 当前索引入栈;
  }
  return res;
}

单调队列(Monotonic Queue)

  • 新元素入队列后,队列内的元素都保持有序(单调递增或单调递减)。
  • 主要用于辅助滑动窗口相关问题
class MonotonicQueue {
  list<int> maxq;  // 双向链表,可以快速在头尾进行操作;
public:
  void push(int n) {
    while (!maxq.empty() && maxq.back() < n) {
      maxq.pop_back();  // 在队尾移除小元素;
    }
    maxq.push_back(n);
  }
  auto max() {
    return maxq.front();  // 队头为最大元素;
  }
  void pop(int n) {
    if (n == maxq.front()) {  // 判断队头元素是否还存在(可能在上一步 push 时被移除);
      maxq.pop_front();  // 移除队头的元素;
    }
  } 
};

排序算法

  • 稳定性:相同键值(参与排序的字段)的元素,其排序前后的相对位置必须保持一致。

选择排序

// 就地排序,不稳定;
void selectionSort(vector<int>& v) {
  int sortIndex = 0;
  while (sortIndex < v.size()) {
    int minValIdx = sortIndex;
    // 每次从后续所有待排元素中搜索最小元素;
    for (auto i = sortIndex; i < v.size(); ++i) {
      if (v[i] < v[minValIdx]) {
        minValIdx = i;
      }
    }
    // 交换元素;
    swap(v[minValIdx], v[sortIndex]);
    ++sortIndex;
  }
}

冒泡排序

// 对选择排序的改进,稳定排序;
void bubbleSort(vector<int>& v) {
  for (int i = v.size() - 1; i > 0; --i) {
    bool hasSwap = false;
    for (int j = 0; j < i; ++j) {
      if (v[j] > v[j + 1]) {
        swap(v[j], v[j + 1]);
        hasSwap = true;  // 如果没有元素交换,则数组中每个元素都满足 v[j] > v[j + 1],即数组已经排序;
      }
    }
    if (!hasSwap) break;
  }
}

插入排序

// 对选择排序的改进,稳定排序;
void insertionSort(vector<int>& v) {
  // 已排区间 [i, ...),逐步将 v[i - 1] 排入 [i, ...)
  for (int i = v.size() - 1; i > 0; --i) {
    for (int j = i - 1; j < v.size() - 1; ++j) {
      if (v[j] > v[j + 1]) {
        swap(v[j], v[j + 1]);
      } else {
        break;
      }
    }
  }
}

计数排序(稳定版)

// 线性时空复杂度;
auto countingSort(vector<int>& v, function<int(int)> indexFn) {
  if (v.empty()) return {};
  // 找到映射后的最大值;
  int maxV = INT_MIN;
  for (auto num : v) {
    auto index = indexFn(num);
    maxV = max(maxV, index);
  }
  vector<int> count(maxV + 1, 0), res(v.size());
  // 统计每个映射后的元素出现的次数;
  for (auto num : v) ++count[indexFn(num)]; 
  // 向后累加 count 数组;
  // count 数组的索引是目标数组的值,count 数组的值是对应该索引值的结束位置;
  for (int i = 1; i < count.size(); ++i) count[i] += count[i - 1];
  // 从后往前遍历原数组,恢复排序后的数组;
  for (int i = v.size() - 1; i >= 0; --i) {
    auto index = indexFn(v[i]);
    res[count[index] - 1] = v[i];
    --count[index];
  }
  return res;
}

归并排序

// 非原地排序,稳定排序,复杂度 O(nlogn);
class MergeSort {
  static vector<int> temp;
  // 对数组 [lo, hi] 进行排序;
  static void sort(vector<int>& v, int lo, int hi) {
    if (lo == hi) return;
    auto mid = lo + (hi - lo) / 2;
    sort(v, lo, mid);
    sort(v, mid + 1, hi);
    // 后续位置,将两个有序数组合并成一个数组;
    merge(v, lo, mid, hi);
  }
  static void merge(vector<int>& v, int lo, int mid, int hi) {
    // 优化元素拷贝范围;
    // copy(v.cbegin() + lo, v.cbegin() + hi + 1, temp.begin() + lo);
    copy(v.cbegin(), v.cend(), temp.begin());
    // 双指针合并两个有序数组 [lo, mid] 与 [mid + 1, hi];
    int i = lo, j = mid + 1;
    for (int p = lo; p <= hi; ++p) {
      if (i == mid + 1) {  // 左边已排好;
        v[p] = temp[j++];
      } else if (j == hi + 1) {  // 右侧已排好;
        v[p] = temp[i++];
      } else if (temp[j] < temp[i]) {  // 继续排 j 所在数组;
        v[p] = temp[j++];
      } else {
        v[p] = temp[i++];  // 小于等于的元素排在左侧;
      }
    }

  }
public:
  static void sort(vector<int>& v) {
    temp.resize(v.size());
    sort(v, 0, v.size() - 1);
  }
};
vector<int> MergeSort::temp;

桶排序

快速排序

// 原地排序,不稳定;
class Quick {
  static void quickSort(vector<int>& v, int lo, int hi) {
    if (lo >= hi) return;
    // 类似于二叉树的先序遍历;
    auto p = partition(v, lo, hi);
    // 继续对左右子集进行排序;
    quickSort(v, lo, p - 1);
    quickSort(v, p + 1, hi);
  }
  static int partition(vector<int>& v, int lo, int hi) {
    // 对 [lo, hi] 进行切分;
    auto pivot = v[lo];
    int i = lo + 1, j = hi;
    // i,j 对向而行;
    while (i <= j) {
      // 记住要先检测边界,然后再检查值;
      while (i <= hi && v[i] <= pivot) ++i;  // 若 i 找到一个大于 pivot 的值;
      while (j >= lo && v[j] > pivot) --j;  // 且 j 找到一个小于等于 pivot 的值;
      if (i >= j) break;
      swap(v[i], v[j]);  // 则将两者交换,pivot 左侧均 <= 它;
    }
    // 将 pivot 元素放到中间位置,保持 [lo, i] <= pivot && [j, hi] > pivot;
    swap(v[lo], v[j]);
    return j;  // 返回交换后 pivot 的位置;
  }
public:
  static void sort(vector<int>& v) {
    random_device rd;
    mt19937 g(rd());
 
    shuffle(v.begin(), v.end(), g);
    // 对 [lo, hi] 进行排序;
    quickSort(v, 0, v.size() - 1);
  }
};

线段树

  • 用于高效解决数组的区间查询和区间动态修改问题
  • 可以在 O(logN) 的时间复杂度查询任意长度的区间元素聚合值,在 O(logN) 的时间复杂度对任意长度的区间元素进行动态修改,其中 N 为数组中的元素个数。

链式结构

// 线段树节点;
struct SegmentNode {
  size_t l, r;  // 该节点表示的区间范围 [l, r];
  int mergeVal;
  SegmentNode* left = nullptr;
  SegmentNode* right = nullptr;
  SegmentNode(int mergeVal, size_t l, size_t r) : mergeVal(mergeVal), l(l), r(r) {}
};
class SegmentTree {
  SegmentNode* root;
  std::function<int(int, int)> merger;
  SegmentNode* build(const std::vector<int>& nums, int l, int r) {
    if (l == r) {
      return new SegmentNode(nums[l], l, r);
    }
    int mid = l + (r - l) / 2;
    auto left = build(nums, l, mid);
    auto right = build(nums, mid + 1, r);
    auto node = new SegmentNode(merger(left->mergeVal, right->mergeVal), l, r);
    node->left = left;
    node->right = right;
    return node;
  }

  void update(SegmentNode* node, int index, int value) {
    // 找到目标节点;
    if (node->l == node->r) {
      node->mergeVal = value;
      return;
    }
    int mid = node->l + (node->r - node->l) / 2;
    if (index <= mid) {
      // 若 index 较小,则去左子树更新;
      update(node->left, index, value);
    } else {
      // 若 index 较大,则去右子树更新;
      update(node->right, index, value);
    }
    // 在后续位置更新父节点的值;
    node->mergeVal = merger(node->left->mergeVal, node->right->mergeVal);
  }
  int query(SegmentNode* node, int qL, int qR) {
    if (node->l == qL && node->r == qR) {
      return node->mergeVal;
    }

    // 未直接命中区间,需要向下查找;
    int mid = node->l + (node->r - node->l) / 2;
    if (qR <= mid) {
      return query(node->left, qL, qR);
    } else if (qL > mid) {
      return query(node->right, qL, qR);
    } else {
      // node.l <= qL <= mid < qR <= node.r;
      // 目标区间横跨左右子树;
      return merger(
        query(node->left, qL, mid),
        query(node->right, mid + 1, qR)
      );
    }
  }
public:
  SegmentTree(const std::vector<int>& nums, std::function<int(int, int)> merger) : merger(merger) {
    root = build(nums, 0, nums.size() - 1);
  }

  void update(int index, int value) {
    update(root, index, value);
  }

  int query(int qL, int qR) {
    return query(root, qL, qR);
  }
};

数组实现


技巧

分治算法 & 分治思想

  • 分治思想:把一个问题分解成若干个子问题,然后分别解决这些子问题,最后合并子问题的解得到原问题的解,这种思想广泛存在于递归算法中。

链表双指针

  • 当需要创造一条新链表的时候,可以使用虚拟头结点简化边界情况的处理。
// dummy.next 永远指向新链表,dummyCursor 用来游动;
ListNode dummy(-1), *dummyCursor = &dummy;
  • 如果需要把原链表的节点接到新链表上,而不是 new 新节点来组成新链表的话,那么断开节点和原链表之间的链接可能是必要的;
  • 如何只遍历一次链表,就得到倒数第 k 个节点?第一个指针先走 k 步,第二个指针接着从链表头部与第一个指针同步向尾部移动。当第二个指针到达最后一个节点后时,第一个指针指向的就是链表倒数第 k 个节点;
  • 快慢指针(Floyd 算法):两个指针均从链表头开始移动,每当慢指针 slow 前进一步,快指针 fast 就前进 n 步,这样,当 fast 走到链表末尾时,slow 就指向了链表低 1/n 处的节点。该技巧可用于判断链表是否有环、找链表中点。
// 检测链表中的环,并返回环起点;
ListNode *detectCycle(ListNode *head) {
  ListNode *fast, *slow;
  fast = slow = head;
  while (!fast && !fast->next) {
    fast = fast->next->next;  // 快指针每次走两步;
    slow = slow->next;  // 慢指针每次走一步;
    if (fast == slow) break;
  }
  // 快指针走到了结尾,表示无环;
  if (!fast || !fast->next) {
    return nullptr;
  }

  // 将其中一个指针移到开头;
  slow = head;

  // 快慢指针同步前进,相交点就是环起点;
  while (slow != fast) {
    fast = fast->next;
    slow = slow->next;
  }
  return slow;
}

矩阵

// 基于状态机的解法:
// 1. 状态集合:S = { RIGHT, DOWN, LEFT, UP };
// 2. 初始状态:RIGHT;
// 3. 状态转移函数:δ(s, c) = (s + 1) % 4,其中 c 表示遇到边界或已访问单元格;
// 4. 输入:矩阵边界条件和已访问标记;
// 在每一步,状态机检查下一个位置是否有效(未越界且未访问)。如果无效,则应用状态转移函数,改变当前移动方向。
vector<int> spiralOrder(vector<vector<int>>& matrix) {
  if (matrix.empty()) return {};
  vector<int> result;
  const auto rows = matrix.size();  // 行数;
  const auto cols = matrix[0].size();  // 列数;
  auto curDir = 0;  // 初始状态 - RIGHT;

  // 定义状态,对应四个移动方向;
  vector<vector<int>> dirs = {
    { 0, 1 }, // RIGHT
    { 1, 0 },  // DOWN
    { 0, -1 },  // LEFT
    { -1, 0 }  // UP
  };
  vector<vector<bool>> visited(rows, vector<bool>(cols, false));

  int row = 0, col = 0;
  int count = 0;
  while (count < rows * cols) {
    // 记录当前单元格;
    result.push_back(matrix[row][col]);
    visited[row][col] = true;
    ++count;

    // 计算下一个位置;
    auto nextRow = row + dirs[curDir][0];
    auto nextCol = col + dirs[curDir][1];

    // 边界检测,超出边界或遇到已访问元素时,调整状态;
    if (nextRow < 0 || nextRow >= rows || nextCol < 0 || nextCol >= cols || visited[nextRow][nextCol]) {
      // 状态转移;
      curDir = (curDir + 1) % 4;
      nextRow = row + dirs[curDir][0];
      nextCol = col + dirs[curDir][1];
    }
    row = nextRow;
    col = nextCol;
  }
  return result;
}

数组

  • 双指针、快慢指针;
    • 查找回文串:从中心向两端扩散的双指针;
  • 滑动窗口:快慢指针的一种变体,一快一慢两个指针前后相随,中间的部分就是窗口。该技巧主要用来解决子数组问题,比如:寻找符合某个条件的最长/最短子数组
// 一个经典的滑动窗口结构:
string minWindow(string s, string t) {
  std::unordered_map<char, size_t> win, need;
  for (const char ch : t) {
    need[ch]++;
  }
  size_t left = 0, right = 0;
  size_t valid = 0;
  size_t start = 0, len = INT_MAX;
  // 窗口由 left、right 组成,左闭右开;
  while (right < s.size()) {
    // 移动窗口右边;
    const auto ch = s.at(right);
    if (need.count(ch)) {
      win[ch]++;
      if (win[ch] == need[ch]) {
        ++valid;
      }
    }
    ++right;

    while (valid == need.size()) {
      if (right - left < len) {
        start = left;
        len = right - left;
      }
      const auto ch = s.at(left);
      if (need.count(ch)) {
        if (win[ch] == need[ch])
          --valid;
        --win[ch];
      }
      ++left;
    }
  }
  return len == INT_MAX ? "" : s.substr(start, len);
}
// 普通版;
int binarySearch(vector<int>& nums, int target) {
  int left = 0, right = nums.size() - 1;
  while (left <= right) {  // 此时 [left, right] 均为闭区间;
    const auto mid = left + (right - left) / 2;
    if (nums[mid] == target) return mid;
    else if (nums[mid] < target) {
      // target 在右侧,更新 left;
      left = mid + 1;  // [left, right] 都是待搜索的闭区间;
    } else {
      right = mid - 1;
    }
  }
  return -1;
}
// 查找目标左右边界版,即第一次出现和最后一次出现的位置;
int leftBound(vector<int>& nums, int target) {
  int left = 0, right = nums.size() - 1;
  while (left <= right) {  // 此时 [left, right] 均为闭区间;
    const auto mid = left + (right - left) / 2;
    if (nums[mid] == target) {
      right = mid - 1;  // 往左边继续查;
    } else if (nums[mid] < target) {
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }
  if (left >= nums.size()) {
    return -1;
  }
  // 若找不到,可能返回大于 target 的最小元素位置;
  return nums[left] == target ? left : -1;
}
int rightBound(vector<int>& nums, int target) {
  int left = 0, right = nums.size() - 1;
  while (left <= right) { 
     const auto mid = left + (right - left) / 2;
     if (nums[mid] == target) {
       left = mid + 1;  // 往右边继续查;
     } else if (nums[mid] < target) {
       left = mid + 1; 
     } else {
       right = mid - 1;
     }
  }
  if (right < 0) {
    return -1;
  } 
  // 若找不到,可能返回小于 target 的最大元素位置;
  return nums[right] == target ? right : -1; 
}
  • 前缀和数组数组每一个元素都是前面所有元素的和,适用于快速、频繁地计算一个索引区间内的元素之和。这个技巧也可以用于二维矩阵。
class NumArray {
public:
  vector<int> prefixArray;
  NumArray(vector<int>& nums) {
    prefixArray.resize(nums.size() + 1, 0);   // 便于计算累加和;
    for (size_t i = 1; i <= nums.size(); ++i) {  // 注意 i 的结束条件,保证 nums 的遍历范围是完整的;
      prefixArray[i] = nums[i - 1] + prefixArray[i - 1];
    }
  }
  int sumRange(int left, int right) {
    return prefixArray[right + 1] - prefixArray[left];  // 得到索引范围 [left, right] 的数组元素和;
  }
};
  • 差分数组数组保存的是每个元素与前一个元素的差值,适用于频繁对原始数组的某个区间的元素进行增减。
class Difference {
  std::vector<int> diff;
public:
  Difference(std::vector<int>& nums) {
    diff = std::vector<int>(nums.size());
    diff[0] = nums[0];  // 第一个元素保持不变;
    for (auto i = 1; i < nums.size(); ++i) {
      diff[i] = nums[i] - nums[i - 1];  // 构建差分数组;
    }
  }
  void increment(int i, int j, int val) {  // 给数组区间 [i, j] 增加 val;
    diff[i] += val;
    if (j + 1 < diff.size())
      diff[j + 1] -= val;  // 注意是给区间后面第一个元素减去 val;
  }
  auto result() {
    std::vector<int> res(diff.size());
    res[0] = diff[0];
    for (auto i = 1; i < diff.size(); ++i) {
      res[i] = res[i - 1] + diff[i];
    }
    return res;
  }
};

二叉树

  • 直径:就是树中某个节点的左右子树的最大深度之和。

递归的两种思维模式

// 分解问题的思维模式;
int maxDepth(TreeNode* root) {
  if (!root) return 0;
  // 分解问题:想计算整颗树的最大深度,先计算左右子树的最大深度,然后再加自己(+1);
  int leftMax = maxDepth(root->left);
  int rightMax = maxDepth(root->right);
  return 1 + max(leftMax, rightMax);
}
// 遍历的思维模式:遍历整棵树,通过全局状态记录高度;
class Solution {
  int depth = 0;
  int result = 0;
public:
  auto maxDepth(TreeNode* root) {
    traverse(root);
    return result;
  }
  void traverse(TreeNode* root) {
    if (!root) return;
    ++depth;  // 进入当前节点,高度 +1;
    if (depth > result) result = depth;
    if (root->left) traverse(root->left);
    if (root->right) traverse(root->right);
    --depth;   // 即将退出当前节点,高度 -1;
  }
};

二叉树构造

最近公共祖先(Lowest Common Ancestor)

动态规划(Dynamic Programming)

  • [🟠 领钱兑换];
  • [🟢 斐波那契数]:
    • 重叠子问题:比如 f(5) 的计算中,f(3)、f(2) 要重复计算多次;
    • 优化方式
      • 使用「备忘录」存储中间结果 - 适用于自顶向下的计算方式,在递归过程中存储已经计算过的结果,以避免重复计算;
      • 使用「DP Table」优化穷举过程 - 适用于自底向上的计算方式,显式存储所有子问题的解。
// 自顶向下 + 备忘录;
class Solution {
  vector<int> memo;
public:
  int dp(int n) {
    if (n <= 1) return n;
    if (memo[n] != 0) return memo[n];  // 从备忘录中提取之前的计算结果;
    const int result = dp(n - 1) + dp(n - 2);
    memo[n] = result;  // 更新备忘录;
    return result;
  }
  int fib(int n) {
    memo = vector<int>(n + 1, 0);
    return dp(n);
  }
};
// 自底向上 + DP Table;
class Solution {
public:
  int fib(int n) {
    if (n <= 1) return n;
    vector<int> dp(n + 1, 0);
    dp[0] = 0, dp[1] = 1;
    for (auto i = 2; i <= n; ++i) {
      dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
  }
};

答题技巧

  1. 确定「状态」,也就是原问题和子问题中会变化的变量;
  2. 确定「选择」,也就是导致「状态」产生变化的行为;
  3. 明确「dp 函数/数组」的定义。
for 状态1 in 状态1的所有取值:
  for 状态2 in 状态2的所有取值:
    for ...
      dp[状态1][状态2][...] = 择优(选择1,选择2...)

回溯算法

class Solution {
  vector<vector<int>> result;
public:
  void iterate(vector<int>& nums, vector<int>& tracks, unordered_map<int, bool>& colorMap) {
    // 回溯 -> 在循环里递归;
    // tracks 收集每一个路径,当 nums 数字全被标记时推入 result;
    if (tracks.size() == nums.size()) {
      result.push_back(tracks);
    }
    // 只遍历当前递归中,nums 没有被标记的部分,元素不重复选择;
  for (auto num : nums) {
      if (colorMap[num]) continue;
      // 修改状态;
      colorMap[num] = true;
      tracks.push_back(num);
       // 每一次回溯都是一次选择,当满足条件时放入 tracks;
      iterate(nums, tracks, colorMap);
      // 恢复状态;
      colorMap[num] = false;
      tracks.pop_back();
    }
  }   
  vector<vector<int>> permute(vector<int>& nums) {
    vector<int> tracks;
    unordered_map<int, bool> colorMap; 
    for (auto num: nums) colorMap[num] = false;
    iterate(nums, tracks, colorMap);
    return result;
  }
};

LRU & LFU

  • LRU(Least Recently Used):最近使用过的数据应该是「有用的」,很久没用过的数据应该是无用的(优先删除);
    • get 与 put 方法须均为 O(1),key 不存在返回 -1;
    • 使用哈希链表(LinkedHashMap)实现,即双向链表 + 哈希表;
// 1. 构建 DoubleList 双向链表类;
// 1.1 编写 Node 节点类;
// 1.2 编写基础操作方法:
//     - void addLast(Node*);
//     - Node* removeFirst();
//     - void remove(Node*);
//     - auto getSize();
// 2. 构建 LRUCache 类:
// 2.1 引入 unordered_map 存储链表节点指针;
// 2.2 编写抽象方法(基于 key、val 的操作):
//     - void addLatest(key, val);
//     - void makeLatest(key);
//     - void removeOldest();
//     - void remove(key);
// 2.3 编写最后的 put 和 get 方法。
struct Node {  // 双链表节点;
  int key, val;
  Node* next = nullptr;
  Node* prev = nullptr;
  Node (int k, int v) : key(k), val(v) {}
};
class DoubleList {  // 双链表类;
  Node* head;
  Node* tail;
  int size;
public:
  DoubleList() {
    head = new Node(0, 0);  // 头尾哑节点;
    tail = new Node(0, 0);
    head->next = tail;
    tail->prev = head;
    size = 0;
  }
  auto getSize() { return size; }
  void addLast(Node* x) {  // 在链表尾部添加节点 x,即最近使用元素;
    x->prev = tail->prev;
    x->next = tail;
    tail->prev->next = x;
    tail->prev = x;
    ++size;
  }
  void remove(Node* x) {  // 从链表中删除给定节点 x;
    x->prev->next = x->next;
    x->next->prev = x->prev;
    --size;
  }
  Node* removeFirst() {  // 删除链表中的第一个节点,并返回,即最久未使用元素;
    if (head->next == tail) return nullptr;
    auto first = head->next;
    remove(first);
    return first;
  }
};
class LRUCache {
  unordered_map<int, Node*> map;
  DoubleList cache;
  int cap;
  // 封装抽象方法;
  void addRecently(int key, int val) {  // 插入新节点;
    auto x = new Node(key, val);
    cache.addLast(x);
    map[key] = x;
  }
  void deleteKey(int key) {  // 删除某个节点;
    auto x = map[key];
    cache.remove(x);
    map.erase(key);
  }
  void removeLeastRecently() {  // 删除最久未用的元素;
    auto node = cache.removeFirst();
    map.erase(node->key);
  }
  void makeRecently(int key) {  // 将某个 key 提升为最近使用;
    auto x = map[key];
    cache.remove(x);
    cache.addLast(x);
  }
public:
  LRUCache(int capacity) : cap(capacity) {}
  int get(int key) {
    if (!map.count(key)) return -1;
    makeRecently(key);
    return map[key]->val;
  }
  void put(int key, int val) {
    // 1. 若 key 存在:则删除值,重新添加;
    if (map.count(key)) {
      deleteKey(key);
      addRecently(key, val);
      return;
    }
    // 2. 若 key 不存在,但空间不够:移除最久未用,然后添加;
    if (cap == cache.getSize()) {
      removeLeastRecently();
    }
    addRecently(key, val);
  }
};
  • LFU(Least Frequently Used):每次淘汰使用次数最少的数据。
    • get 与 put 方法须均为 O(1),key 不存在返回 -1;
    • 只要用 get 或者 put 方法访问一次某个 key,该 key 的 freq 就要加一;
    • 如果在容量满了的时候进行插入,则需要将 freq 最小的 key 删除,如果最小的 freq 对应多个 key,则删除其中最旧的那一个;
// 1. 准备需要的数据结构;
//    - unordered_map<> keyToVal;
//    - unordered_map<> keyToFreq;
//    - int minFreq;
//    - unordered_map<key, list> freqToKeys;  
//    - int cap;
// 2. 编写抽象通用函数;
// 2.1 void increaseFreq(key);
// 2.2 void removeMinFreqKey();
// 3. 补全 LRUCache 的基本框架,即 get 和 put 函数;
// 4. 优化 freqTokeys 中的 list 操作到 O(n);
// 4.1 使用 unordered_map<key, list::iterator> 存储所有 list 中所有元素的迭代器;
// 4.2 对所有 freqToKeys 操作的地方进行优化;
class LFUCache {
  unordered_map<int, int> keyToVal;  // 快速取值;
  unordered_map<int, int> keyToFreq;  // 快速操作 key 对应的 freq;
  // 移除元素时,需要拿到 freq 最小的 key;
  int minFreq = 0;
  // 一个 freq 可能对应多个 key,多个 key 需要保持插入顺序;
  unordered_map<int, list<int>> freqToKeys;  // 存储的 key 需要有序,且支持快速删除;
  unordered_map<int, list<int>::iterator> keyToIter;  // 存储所有 list 中所有元素的迭代器;
  int cap;
  void increaseFreq(int key) {
    auto freq = keyToFreq[key];  // 取出现在的 freq;
    ++keyToFreq[key];
    auto& keyList = freqToKeys[freq];
    keyList.erase(keyToIter[key]);  // 先清除;
    freqToKeys[freq + 1].push_back(key);  // 再添加到下一个 freq 列表;
    keyToIter[key] = --freqToKeys[freq + 1].end();
    if (keyList.empty()) {  // 已没有当前频率的 key;
      freqToKeys.erase(freq);
      if (freq == minFreq) ++minFreq;
    }
  }
  void removeMinFreqKey() {
    // 获得 freq 最小的那个 list;
    auto& keyList = freqToKeys[minFreq];
    // 队列头部的 key 为目标 key;
    auto deletedKey = keyList.front();
    keyList.pop_front();
    if (keyList.empty()) {  // 已没有当前频率的 key;
      freqToKeys.erase(minFreq);
    }
    keyToVal.erase(deletedKey);
    keyToFreq.erase(deletedKey);
    keyToIter.erase(deletedKey);
  }
public:
  LFUCache(int capacity) : cap(capacity) {}
  int get(int key) {
    if (!keyToVal.count(key)) return -1;
    increaseFreq(key);  // 增加给定 key 的 freq;
    return keyToVal[key];
  }
  void put(int key, int val) {
    if (this->cap <= 0) return;
    // 若 key 存在,修改 val,增加 freq;
    if (keyToVal.count(key)) {
      keyToVal[key] = val;
      increaseFreq(key);
      return;
    }
    if (keyToVal.size() >= cap) {
      removeMinFreqKey();  // 容量不够,先移除某个 key;
    }
    // 插入新元素,同时更新三个表;
    keyToVal[key] = val;
    keyToFreq[key] = 1;
    freqToKeys[1].push_back(key);
    keyToIter[key] = --freqToKeys[1].end();  // 尾后迭代器左边;
    minFreq = 1;
  }
};

其他技巧

  • 丑数(Ugly Number):如果一个数 n 可以表示为:n = 2^a x 3^b x 5^c,其中 a、b、c 都是非负整数,那么这个数就是一个丑数。
  • 质数(素数,Prime):一个数只能被 1 和它本身整除。Sieve of Eratosthenes 筛选法:

#include <vector>
#include <algorithm>

int countPrimes(int n) {
  if (n < 2) return 0;
  std::vector<bool> isPrime(n, true);
  isPrime[0] = isPrime[1] = false;  // 0 和 1 不是质数;
  for (int i = 2; i * i < n; ++i) {
    if (isPrime[i]) {
      for (int j = i * i; j < n; j += i) {
        isPrime[j] = false;
      }
    }
  }
  return std::count(isPrime.begin(), isPrime.end(), true);
}
  • 牛顿迭代法求近似平方根:
struct Solution {
  auto mySqrt(int x) {
    if (x == 0) return 0;
    double y = x; 
    while (abs(y * y - x) > 1e-6) {  // 控制逼近精度;
      y = (y + x / y) / 2;
    }
    return y;
  }
};
  • Boyer-Moore 投票算法:即使将多数元素(出现次数大于 n/2)和其他所有元素两两抵消,最终剩下的一定还是多数元素。
int majorityElement(vector<int>& nums) {
  auto candidate = nums[0];
  int count = 0;
  for (int i = 0; i < nums.size(); ++i) {
    if (count == 0) {
      candidate = nums[i];
      count = 1;
    } else if (nums[i] == candidate) ++count;  // 相同元素相加;
    else --count;  // 不同元素抵消;
  }
  return candidate;  // 剩下的元素必定为多数元素;
}



评论 | Comments


Loading ...