【算法】力扣

数据结构

数组;字符串;链表;哈希;栈、辅助栈、单调栈;队列、单调队列、优先队列;树、二叉树、二叉搜索树、字典树、线段树;图;集合框架


链表

链表

链表定义:

public class ListNode {
    int val;
    ListNode next;
    ListNode() { }
}

做题步骤:

  • 按需定义 dummy 节点
  • 按需定义 prev、now、slow、fast 指针
  • 确定指针的含义
  • 确定指针指向的变化
  • 更新指针

栈

栈、辅助栈、单调栈

单调栈:

  • 对于每个元素,找出第一个满足某种条件的元素的位置:寻找「左边 / 右边」第一个比我「大 / 小」的

  • 解题形式

    // 寻找右边第一个比我大的
    int [] ans = new int[n];
    Deque<Integer> stack = new LinkedList<>();
    for (int i = 0; i < n; i++) {
        // 单调栈, 递减, 只有新元素大于栈顶时, 才出栈, 同时更新答案
        while (!stack.isEmpty() && temperatures[stack.peek()] < temperatures[i]) {
            int idx = stack.pop();
            ans[idx] = i - idx;
        }
        stack.push(i);  // 不管是否出栈, 都要将新元素入栈
    }
    
    • 寻找「右边」第一个比我「大」的:从左到右遍历,单调栈递减,新元素大于栈顶时出栈
    • 寻找「右边」第一个比我「小」的:从左到右遍历,单调栈递增,新元素小于栈顶时出栈
    • 寻找「左边」第一个比我「大」的:从右到左遍历,单调栈递减,新元素大于栈顶时出栈
    • 寻找「左边」第一个比我「小」的:从右到左遍历,单调栈递增,新元素小于栈顶时出栈

队列

队列、单调队列、优先队列

  • 队列:一头进,一头出
  • 双端队列:两头进,两头出
  • 单调队列:队头是最大 / 最小值,从队头到队尾,递减 / 递增;队头出队,队尾入队,不满足单调性时,队尾出队;可用双端队列实现

树

树、二叉树、二叉搜索树、字典树、线段树

二叉树定义:

public class TreeNode {
    int val;
    TreeNode left;
    TreeNode right;
    TreeNode() { }
}

二叉树遍历:

  • DFS:前序、中序、后序,递归或使用栈迭代实现

    • 无论前序、中序、后序,左节点都在右节点之前遍历
    • 前序:中间节点在左右节点之前遍历
    • 中序:中间节点在左右节点之间遍历
    • 后序:中间节点在左右节点之后遍历
    // 递归写法
    public void dfs(TreeNode node) {
        if (node == null) {
            return;
        }
        dfs(node.left);
        ans.add(node.val);  // 前序、中序、后序, 取决于本行代码的位置
        dfs(node.right);
    }
    
    // 迭代写法 (与递归时访问顺序严格相同)
    Deque<TreeNode> stack = new LinkedList<>();
    TreeNode tmp = root;
    TreeNode prev = null;  // 上一次添加到 ans 的节点
    while (!(stack.isEmpty() && tmp == null)) {  // 栈不为空, 或 tmp 不为空
        while (tmp != null) {
            // ans.add(tmp.val);  // 前序遍历
            stack.push(tmp);
            tmp = tmp.left;
        }
        tmp = stack.pop();
        // ans.add(tmp.val);  // 中序遍历
        // tmp = tmp.right;  // 前序遍历, 中序遍历
        // 后序遍历
        if (tmp.right == null || tmp.right == prev) {  // 如果右节点为空, 或右节点为上一次添加到 ans 的节点, 说明当前节点可以被添加到 ans
            ans.add(tmp.val);
            prev = tmp;
            tmp = null;
        } else {  // 否则继续向右遍历
            stack.push(tmp);
            tmp = tmp.right;
        }
    }
    
    // 迭代写法 (与递归时访问顺序不同, 仅前序和后序)
    Deque<TreeNode> stack = new LinkedList<>();
    if (root != null) {
        stack.push(root);
    }
    while (!stack.isEmpty()) {
        TreeNode node = stack.pop();
        ans.add(node.val);
        if (node.right != null) {  // 前序先将右节点入栈, 后序先将左节点入栈
            stack.push(node.right);
        }
        if (node.left != null) {
            stack.push(node.left);
        }
    }
    // Collections.reverse(ans);  // 后序需要反转结果数组
    
  • BFS:层次,使用队列迭代实现

    Queue<TreeNode> q = new LinkedList<>();
    if (root != null) {
        q.add(root);
    }
    while (!q.isEmpty()) {
        int cnt = q.size();
        List<Integer> one = new ArrayList<>();
        for (int i = 0; i < cnt; i++) {
            TreeNode node = q.poll();
            if (node.left != null) {
                q.offer(node.left);
            }
            if (node.right != null) {
                q.offer(node.right);
            }
            one.add(node.val);
        }
        ans.add(one);
    }
    

二叉搜索树:

  • 左子树只包含小于当前节点的数,右子树只包含大于当前节点的数,左子树和右子树自身也是二叉搜索树
  • 中序遍历得到的结果是有序的

字典树:

public class TreeNode {
    TreeNode [] child = new TreeNode[26];
    boolean end = false;  // 需要判断当前节点是否是某个单词的结尾
    TreeNode() {}
}

线段树:

  • 维护区间信息:在 \(O(\log N)\) 的时间复杂度内实现单点修改、区间修改、区间查询 (求和 / 求最大值 / 求最小值)

  • 原始数组是 \(a\),线段树是 \(d\)

    • \(d_1\) 中包含 \(a\) 中 \([1, n]\) 的信息,\(d_2\) 中包含 \(a\) 中 \([1, \frac{n}{2}]\) 的信息,\(d_3\) 中包含 \(a\) 中 \([\frac{n}{2}+1, n]\) 的信息
    • 如果 \(d_i\) 表示的是 \([s, t]\) 的信息,则左儿子节点 \(d_{2\times i}\) 表示的是 \([s, \frac{s+t}{2}]\) 的信息,右儿子节点 \(d_{2\times i+1}\) 表示的是 \([\frac{s+t}{2}+1, t]\) 的信息
    • 堆式存储,空间复杂度为 \(O(4n)\)
  • 原始数组是 \(a\),线段树是 \(d\),懒惰标记是 \(b\)

    • 每次修改时,使用懒惰标记表明该节点对应的区间在某一次操作中被更改,但不更新该节点的子节点的信息,实际的修改在下一次访问带有标记的节点时才进行
    • 懒惰标记的长度与线段树的长度相同
  • 动态开点线段树:结点只有在有需要的时候才被创建

  • 递归建树

    // 对 [s, t] 区间建立线段树, p 为当前节点的编号
    void build(int s, int t, int p) {
        if (s == t) {
            d[p] = a[s];
            return;
        }
        int m = s + ((t - s) >> 1);
        build(s, m, p * 2);
        build(m + 1, t, p * 2 + 1);
        d[p] = d[p * 2] + d[(p * 2) + 1];
    }
    
  • 区间查询 (以求和为例)

    // [l, r] 为查询区间, [s, t] 为当前节点包含的区间, p 为当前节点的编号
    int getsum(int l, int r, int s, int t, int p) {
        if (l <= s && t <= r) {
            return d[p];
        }
        int m = s + ((t - s) >> 1);
        if (b[p] && s != t) {  // 若当前节点懒标记非空, 则更新当前节点的子节点和懒标记
            d[p * 2] += b[p] * (m - s + 1), d[p * 2 + 1] += b[p] * (t - m);
            b[p * 2] += b[p], b[p * 2 + 1] += b[p];
            b[p] = 0;
        }
        int sum = 0;
        if (l <= m) sum = getsum(l, r, s, m, p * 2);
        if (r > m) sum += getsum(l, r, m + 1, t, p * 2 + 1);
        return sum;
    }
    
  • 区间修改 (以求和为例)

    // [l, r] 为修改区间, c 为被修改的元素的变化量, [s, t] 为当前节点包含的区间, p 为当前节点的编号
    void update(int l, int r, int c, int s, int t, int p) {
        if (l <= s && t <= r) {
            d[p] += (t - s + 1) * c;
            b[p] += c;
            return;
        }
        int m = s + ((t - s) >> 1);
        if (b[p] && s != t) {  // 若当前节点懒标记非空, 则更新当前节点的子节点和懒标记
            d[p * 2] += b[p] * (m - s + 1), d[p * 2 + 1] += b[p] * (t - m);
            b[p * 2] += b[p], b[p * 2 + 1] += b[p];
            b[p] = 0;
        }
        if (l <= m) update(l, r, c, s, m, p * 2);
        if (r > m) update(l, r, c, m + 1, t, p * 2 + 1);
        d[p] = d[p * 2] + d[p * 2 + 1];
    }
    

基础算法

排序、二分、回溯、深搜、广搜

排序:

  • 冒泡排序:比较相邻元素,按需交换

    int n = nums.length;
    for (int i = 0; i < n; i++) {
        int ys = 0;
        for (int j = 0; j < n - i - 1; j++) {  // 每轮迭代会把最大的元素交换到数组末尾
            if (nums[j] > nums[j + 1]) {
                int tmp = nums[j];
                nums[j] = nums[j + 1];
                nums[j + 1] = tmp;
                ys = 1;
            }
        }
        if (ys == 0) { break; }
    }
    
  • 插入排序

  • 选择排序

  • 归并排序:把输入序列分成两个子序列,先递归使两个子序列有序,再合并两个有序的子序列

    // 调用方法 mergeSort(nums, 0, n - 1);
    public void mergeSort(int[] nums, int l, int r) {
        if (l >= r) { return; }  // 终止条件, 区间内只有一个点或没有点
        
        int m = (l + r) / 2;  // 递归, 左闭右闭
        mergeSort(nums, l, m);
        mergeSort(nums, m + 1, r);
        
        int [] tmp = new int[r - l + 1];  // 合并
        int tmpi = 0, li = l, ri = m + 1;
        for (; li <= m && ri <= r; tmpi++) {
            if (nums[li] <= nums[ri]) {
                tmp[tmpi] = nums[li++];
            } else {
                tmp[tmpi] = nums[ri++];
            }
        }
        for (; li <= m; li++, tmpi++) { tmp[tmpi] = nums[li]; }
        for (; ri <= r; ri++, tmpi++) { tmp[tmpi] = nums[ri]; }
        for (int i = 0; i < tmpi; i++) { nums[l + i] = tmp[i]; }
    }
    
  • 快速排序

二分:

  • 解题形式 (左闭右闭)

    // 寻找 >= target 的最小下标
    int l = 0, r = n - 1;  // 初始化
    while (l <= r) {  // 终止条件, 区间内没有点
        int m = l + ((r - l) >> 1);
        if (nums[m] < target) {  // 左端点以左的部分满足 nums[l - 1] < target
            l = m + 1;
        } else {  // 右端点以右的部分满足 nums[r + 1] >= target
            r = m - 1;
        }
    }
    return l;  // 答案
    
  • 三种区间划分方式

    初始化 终止条件 更新 答案
    左闭右闭 l = 0, r = n - 1 l <= r l = m + 1, r = m - 1 l 或 r + 1
    左闭右开 l = 0, r = n l < r l = m + 1, r = m l 或 r
    左开右开 l = -1, r = n l + 1 < r l = m, r = m l + 1 或 r
  • 四种不等式关系互换方式

    • 寻找 >= target 的最小下标
    • 寻找 > target 的最小下标,相当于,寻找 >= target + 1 的最小下标
    • 寻找 < target 的最大下标,相当于,寻找 >= target 的最小下标,然后 -1
    • 寻找 <= target 的最大下标,相当于,寻找 >= target + 1 的最小下标,然后 -1

回溯:

  • 子集、排列、组合、切割、棋盘

  • 解题形式

    List<List<Integer>> ans = new ArrayList<>();  // 用来存储答案的全局变量
    List<Integer> path = new ArrayList<>();
    
    public void dfs(int now, int n, int k) {  // 回溯函数的返回值及参数
        if (path.size() == k) {  // 回溯终止条件
            ans.add(new ArrayList(path));
            return;
        }
        for (int i = now; i <= n; i++) {  // 回溯搜索的遍历过程
            path.add(i);
            dfs(i + 1, n, k);
            path.remove(path.size() - 1);
        }
    }
    
    • 回溯过程可抽象为树形结构,从根节点到叶节点是一组答案,因此遍历过程相当于 DFS
    • for 循环在树的同一层上横向遍历,可以使用 int now 或 int [] used 等方式确定某个节点是否需要访问
    • 递归在树的同一支上纵向遍历,递归前后要修改现场,如果除 path 外用到其他变量,也需相应修改
    • 回溯终止条件可以没有
    • 如果需要去重,可以对初始 nums 排序,并在 for 循环中跳过某些节点

深搜与广搜:

  • 见 二叉树遍历 部分

动态规划

动态规划、0-1 背包、完全背包、多重背包;贪心

做题步骤:

  • 确定 dp 数组以及下标的含义
  • 确定状态转移方程
  • dp 数组如何初始化
  • 确定遍历顺序
  • 举例推导 dp 数组

其他说明:

  • 动态规划原理:最优子结构、后无效性、子问题重叠
  • 记忆化搜索约等于动态规划
    • 步骤:确定状态转移方程、写出 DFS 函数、添加记忆化数组
    • 记忆化搜索难以使用滚动数组等优化,且由于存在递归,运行效率会低于动态规划

0-1 背包:

  • 物品只能放或不放

    // 无空间优化版
    // 当遍历到物品 i 时,背包容量不超过 j 的最大背包价值是 dp[i][j]
    int [][] dp = new int[weight.size()+1][bagWeight+1];
    for (int j = weight[0]; j <= bagweight; j++) {
        dp[0][j] = value[0];
    }
    // 可以先遍历物品, 也可以先遍历背包容量, 物品和背包容量可以正序遍历也可以倒序遍历
    for(int i = 1; i < weight.size(); i++) {  // 遍历物品
        for(int j = 0; j <= bagweight; j++) {  // 遍历背包容量
            if (j < weight[i]) dp[i][j] = dp[i-1][j];
            else dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]);
        }
    }
    
    // 滚动数组版
    // 当遍历到物品 i 时,背包容量不超过 j 的最大背包价值是 dp[j]
    int [] dp = new int[bagWeight+1];
    // 只能先遍历物品, 再遍历背包容量, 背包容量必须倒序遍历
    for(int i = 0; i < weight.size(); i++) {  // 遍历物品
        for(int j = bagWeight; j >= weight[i]; j--) {  // 遍历背包容量, 倒序遍历
            dp[j] = Math.max(dp[j], dp[j-weight[i]] + value[i]);
        }
    }
    
    • 计算当前物品的 dp 数组时,需要使用上一个物品的 dp 数组中背包容量小于当前的 \(j\) 的部分,由于采用滚动数组,为避免上一个物品的 dp 数组中需要使用的部分被覆盖,需要倒序遍历

完全背包:

  • 物品可以不放或放多个

    // 滚动数组版
    // 当遍历到物品 i 时,背包容量不超过 j 的最大背包价值是 dp[j]
    int [] dp = new int[bagWeight+1];
    // 可以先遍历物品, 也可以先遍历背包容量, 背包容量必须正序遍历
    for(int i = 0; i < weight.size(); i++) {  // 遍历物品
        for(int j =  weight[i]; j <= bagWeight; j++) {  // 遍历背包容量, 正序遍历
            dp[j] = Math.max(dp[j], dp[j-weight[i]] + value[i]);
        }
    }
    
    • 状态转移方程:

      \[dp[i][j] = \max_{k=0}^{\infin}(dp[i-1][j-k \times w_i] + k \times v_i) \]

    • 状态转移方程优化:

      \[dp[i][j] = \max(dp[i-1][j], dp[i][j-w_i] + v_i) \]

    • 由于 $$dp[i][j-w_i]$$ 是由 \(dp[i][j-2 \times w_i]\) 得出的,因此在计算 \(dp[i][j]\) 时,已考虑过物品 \(i\) 被放入的情况下背包容量不超过 \(j-w_i\) 的最优值,此时只需要根据 $$dp[i][j-w_i]$$ 进行计算,从而消去参数 \(k\)

    • 计算当前物品的 dp 数组时,需要使用上一个物品的 dp 数组中背包容量等于当前的 \(j\) 的值,以及当前物品的 dp 数组中背包容量小于当前的 \(j\) 的部分,因此需要正序遍历,因此可以先遍历物品,也可以先遍历背包容量,这两种方式均可保证计算 \(dp[j]\) 所需的部分已在之前计算过并且没有被覆盖

    • 在实际问题中,遍历顺序可能会发生变化

多重背包:

  • 物品可以放 \(0\) 到 \(k_i\) 个,等价于有 \(k_i\) 个相同的物品,每个物品选一次,可以转化为 0-1 背包

    // 无时间优化版
    for(int i = 0; i < weight.size(); i++) {
        for(int j = bagWeight; j >= weight[i]; j--) {
          	for (int k = 1; k * w[i] <= weight && k <= cnt[i]; k++) {
            		dp[j] = Math.max(dp[j], dp[j-k*weight[i]] + k * value[i]);
            }
        }
    }
    

其他算法

双指针、滑动窗口、前缀和与差分

滑动窗口:

  • 找出满足「条件」的最大 / 最小子数组的长度

  • 解题形式

    int n = nums.length();
    int start = 0, end = 0;
    int sum = 0, ans = 0;
    for (; end < n; end++) {  // 窗口右端每次移动一个
        sum += (nums.charAt(end) == x) ? 1 : 0;  // 根据条件判断窗口左端是否需要移动
        while (sum > k) {
            sum -= (nums.charAt(start) == x) ? 1 : 0;
            start++;
        }
        if (end - start + 1 > ans) {  // 判断是否需要更新答案
            ans = end - start + 1;
        }
    }
    

前缀和与差分:

  • 维护区间信息:多次对序列的一个区间加上同一个数,并在最后多次查询序列某一位的数

  • 前缀和:前 \(n\) 项的和;差分:求和的逆运算

    \[b_i = \begin{cases} a_i - a_{i-1} & \text{ if } i \in [2,n] \\ a_1 & \text{ if } i = 1 \end{cases} \]

    • \(a\) 是 \(b\) 的前缀和,\(b\) 是 \(a\) 的差分
    • 求 \(b\) 的前 \(n\) 项的和:\(\sum_{j=1}^nb_j\)
    • 求 \(a\) 的前 \(n\) 项的和:\(a_i = \sum_{j=1}^ib_j\),\(\sum_{i=1}^n a_i = \sum_{i=1}^n \sum_{j=1}^i b_j = \sum_{i=1}^n (n - i + 1)b_i\)
  • 原数列是 nums,差分是 b,对原数列的区间 [l, r] 中的每个数加 k

    b[l] = b[l] + k;
    b[r + 1] = b[r + 1] - k;
    

    查询原数列中位置为 q 的值 nums[q]

    int ans = 0;
    for (int i = 0; i <= q; i++) {
        ans += b[i];
    }
    

基础板子

最大公因数和最小公倍数、最近公共祖先、拓补排序

最大公因数和最小公倍数:

  • 最大公因数

    public int gcd(int a, int b) {
        while (b != 0) {  // 辗转相除法, 结束条件 b = r = 0, a 是最大公因数
            int r = a % b;
            a = b;
            b = r;
        }
        return a;
    }
    
  • 最小公倍数 lcm(a, b) = a * b / gcd(a, b)

最近公共祖先:

  • 最近公共祖先

    public TreeNode lca(TreeNode root, TreeNode p, TreeNode q) {
        if (root == null || root == p || root == q) {
            return root;
        }
        TreeNode l = lca(root.left, p, q);
        TreeNode r = lca(root.right, p, q);
        if ((l == p && r == q) || (l == q && r == p)) {  // 第一次遇到左右两个子树中有 p 和 q 时, 该节点是最近公共祖先, 之后的递归直接返回这个最近公共祖先
            return root;
        }
        return l != null ? l : r;
    }
    

拓补排序:

  • 拓补排序 (BFS 实现)

    // 图的存储方式
    int [] indegrees = new int[numNodes];  // 存储每个节点的入度
    List<List<Integer>> edges = new ArrayList<>();  // 存储有向边, 节点 i 指向 edges[i]
    int finish = 0;  // 存储已完成访问的节点数量
    
    // BFS
    Deque<Integer> q = new LinkedList<>();  // 使用队列迭代实现
    for (int i = 0; i < numCourses; i++) {  // 初始化时, 入度为 0 的节点可以入队
        if (indegrees[i] == 0) {
            q.offer(i);
            finish++;
        }
    }
    while (!q.isEmpty()) {
        int cnt = q.size();
        for (int i = 0; i < cnt; i++) {
            int now = q.poll();  // 出队, 访问节点
            for (int course: edges.get(now)) {  // 每个被访问的节点, 将它指向的节点入度减少
                indegrees[course]--;
                if (indegrees[course] == 0) {  // 如果被指向的节点入度为 0, 则可加入队列
                    q.offer(course);
                    finish++;
                }
            }
        }
    }  // 访问顺序即为拓补排序, 如果结束时访问的节点数量小于图的节点数量, 则无法拓补排序
    

解题经验


未整理

位运算、数学、模拟、设计、离线

枚举

posted @ 2025-03-08 09:53  空白4869  阅读(64)  评论(0)    收藏  举报