LeetCode Hot 100

Jan 9, 2026

引言

LeetCode Hot 100 是 LeetCode 上最热门的 100 道题目,涵盖了算法和数据结构的核心知识点。这份清单作为刷题的参考和进度跟踪。

题目按 Hot 100 的原始顺序排列,每道题标注了难度等级(E=Easy, M=Medium, H=Hard)和主要技术点,方便快速定位和复习。

统计

  • 总数: 100 题
  • 难度分布:
    • Easy: 约 20 题
    • Medium: 约 60 题
    • Hard: 约 20 题
  • 主要技术点: 数组、链表、树、动态规划、回溯、双指针、滑动窗口、栈、队列、图等

题目列表

No.DoneID难度技术点ChineseEnglish
1160E链表, 双指针相交链表Intersection of Two Linked Lists
2236M二叉树, DFS二叉树的最近公共祖先Lowest Common Ancestor of a Binary Tree
3234E链表, 双指针, 快慢指针回文链表Palindrome Linked List
4739M栈, 单调栈每日温度Daily Temperatures
5226E二叉树, 递归翻转二叉树Invert Binary Tree
6221M动态规划, 二维DP最大正方形Maximal Square
7215M堆, 快速选择数组中的第K个最大元素Kth Largest Element in an Array
8208MTrie树, 前缀树实现Trie(前缀树)Implement Trie (Prefix Tree)
9207M图, 拓扑排序, DFS课程表Course Schedule
10206E链表, 递归, 迭代反转链表Reverse Linked List
11200M图, DFS, BFS, 并查集岛屿数量Number of Islands
12198M动态规划打家劫舍House Robber
13169E数组, 哈希表, 投票算法多数元素Majority Element
14238M数组, 前缀积除自身以外数组的乘积Product of Array Except Self
15155M栈, 设计最小栈Min Stack
16152M动态规划, 数组乘积最大子数组Maximum Product Subarray
17148M链表, 归并排序, 快慢指针排序链表Sort List
18146M哈希表, 双向链表, LRULRU缓存LRU Cache
19142M链表, 快慢指针, 数学环形链表IILinked List Cycle II
20141E链表, 快慢指针, 哈希表环形链表Linked List Cycle
21139M动态规划, 字符串单词拆分Word Break
22136E位运算, 异或只出现一次的数字Single Number
23647M字符串, 动态规划, 中心扩展回文子串Palindromic Substrings
24128M数组, 哈希表, 并查集最长连续序列Longest Consecutive Sequence
25124H二叉树, DFS, 递归二叉树中的最大路径和Binary Tree Maximum Path Sum
26322M动态规划, 完全背包零钱兑换Coin Change
27494M动态规划, 背包问题目标和Target Sum
28461E位运算, 异或汉明距离Hamming Distance
29448E数组, 哈希表, 原地算法找到所有数组中消失的数字Find All Numbers Disappeared in an Array
30438M字符串, 滑动窗口, 哈希表找到字符串中所有字母异位词Find All Anagrams in a String
31437M二叉树, DFS, 前缀和路径总和IIIPath Sum III
32416M动态规划, 背包问题分割等和子集Partition Equal Subset Sum
33406M数组, 贪心, 排序根据身高重建队列Queue Reconstruction by Height
34399M图, DFS, 并查集除法求值Evaluate Division
35394M字符串, 栈, 递归字符串解码Decode String
36347M堆, 哈希表, 桶排序前K个高频元素Top K Frequent Elements
37338E位运算, 动态规划比特位计数Counting Bits
38337M二叉树, 动态规划, DFS打家劫舍IIIHouse Robber III
39121E数组, 动态规划, 贪心买卖股票的最佳时机Best Time to Buy and Sell Stock
40312H动态规划, 区间DP戳气球Burst Balloons
41309M动态规划, 状态机最佳买卖股票时机含冷冻期Best Time to Buy and Sell Stock with Cooldown
42301H字符串, BFS, DFS, 回溯删除无效的括号Remove Invalid Parentheses
43300M动态规划, 二分查找, 贪心最长递增子序列Longest Increasing Subsequence
44297H二叉树, 序列化, 设计二叉树的序列化与反序列化Serialize and Deserialize Binary Tree
45287M数组, 双指针, 快慢指针, 二分查找寻找重复数Find the Duplicate Number
46283E数组, 双指针移动零Move Zeroes
47279M动态规划, 数学, BFS完全平方数Perfect Squares
48253M堆, 贪心, 排序会议室IIMeeting Rooms II
49240M数组, 二分查找, 分治搜索二维矩阵IISearch a 2D Matrix II
50239H滑动窗口, 单调队列, 堆滑动窗口最大值Sliding Window Maximum
5122M字符串, 回溯, DFS括号生成Generate Parentheses
5249M字符串, 哈希表, 排序字母异位词分组Group Anagrams
5348M数组, 数学, 矩阵旋转图像Rotate Image
5446M数组, 回溯全排列Permutations
5542H数组, 双指针, 栈, 动态规划接雨水Trapping Rain Water
5639M数组, 回溯, DFS组合总和Combination Sum
57543E二叉树, DFS二叉树的直径Diameter of Binary Tree
5834M数组, 二分查找在排序数组中查找元素的第一个和最后一个位置Find First and Last Position of Element in Sorted Array
5933M数组, 二分查找搜索旋转排序数组Search in Rotated Sorted Array
6032H字符串, 动态规划, 栈最长有效括号Longest Valid Parentheses
6131M数组, 双指针, 数学下一个排列Next Permutation
62538M二叉树, DFS, BST把二叉搜索树转换为累加树Convert BST to Greater Tree
6323H链表, 分治, 堆, 归并排序合并K个升序链表Merge k Sorted Lists
64560M数组, 哈希表, 前缀和和为K的子数组Subarray Sum Equals K
6521E链表, 递归, 迭代合并两个有序链表Merge Two Sorted Lists
6620E字符串, 栈有效的括号Valid Parentheses
6719M链表, 双指针删除链表的倒数第N个结点Remove Nth Node From End of List
6817M字符串, 回溯, DFS电话号码的字母组合Letter Combinations of a Phone Number
6915M数组, 双指针, 排序三数之和3Sum
7011M数组, 双指针, 贪心盛最多水的容器Container With Most Water
7110H字符串, 动态规划, 递归正则表达式匹配Regular Expression Matching
725M字符串, 动态规划, 中心扩展最长回文子串Longest Palindromic Substring
734H数组, 二分查找, 分治寻找两个正序数组的中位数Median of Two Sorted Arrays
743M字符串, 哈希表, 滑动窗口无重复字符的最长子串Longest Substring Without Repeating Characters
752M链表, 数学, 模拟两数相加Add Two Numbers
7679M数组, 回溯, DFS单词搜索Word Search
77114M二叉树, 链表, DFS二叉树展开为链表Flatten Binary Tree to Linked List
78621M数组, 贪心, 堆, 数学任务调度器Task Scheduler
79617E二叉树, DFS, 递归合并二叉树Merge Two Binary Trees
80105M二叉树, 数组, 哈希表, 分治从前序与中序遍历序列构造二叉树Construct Binary Tree from Preorder and Inorder Traversal
81104E二叉树, DFS, BFS, 递归二叉树的最大深度Maximum Depth of Binary Tree
82102M二叉树, BFS, 层序遍历二叉树的层序遍历Binary Tree Level Order Traversal
83101E二叉树, DFS, BFS, 递归对称二叉树Symmetric Tree
8498M二叉树, DFS, BST, 递归验证二叉搜索树Validate Binary Search Tree
8596M动态规划, 树, 数学不同的二叉搜索树Unique Binary Search Trees
8694E二叉树, 栈, 递归, 中序遍历二叉树的中序遍历Binary Tree Inorder Traversal
8785H数组, 动态规划, 栈, 单调栈最大矩形Maximal Rectangle
8884H数组, 栈, 单调栈柱状图中最大的矩形Largest Rectangle in Histogram
891E数组, 哈希表两数之和Two Sum
9078M数组, 回溯, 位运算子集Subsets
9176H字符串, 滑动窗口, 哈希表, 双指针最小覆盖子串Minimum Window Substring
9275M数组, 双指针, 排序, 三路快排颜色分类Sort Colors
9372H字符串, 动态规划编辑距离Edit Distance
9470E动态规划, 数学爬楼梯Climbing Stairs
95581M数组, 栈, 贪心最短无序连续子数组Shortest Unsorted Continuous Subarray
9664M数组, 动态规划最小路径和Minimum Path Sum
9762M动态规划, 组合数学不同路径Unique Paths
9856M数组, 排序, 贪心合并区间Merge Intervals
9955M数组, 贪心跳跃游戏Jump Game
10053M数组, 动态规划, 分治, 贪心最大子数组和Maximum Subarray

详细题解

160. 相交链表

LT.160. Intersection of Two Linked Lists

这道题的核心思想是使用两个指针分别遍历两个链表,当其中一个指针到达链表末尾时,将其指向另一个链表的头部,继续遍历。这样做的目的是消除两个链表长度不同的影响,让两个指针同时到达相交节点。

关键洞察:如果两个链表相交,设链表 A 的不相交部分长度为 a,链表 B 的不相交部分长度为 b,公共部分长度为 c。当指针 pa 遍历完 A 再遍历 B,指针 pb 遍历完 B 再遍历 A 时,两个指针会同时到达相交点,因为它们都走了 a + b + c 的长度。

边界情况

  • 如果两个链表不相交,两个指针最终都会变成 null,循环结束返回 null
  • 如果其中一个链表为空,直接返回 null
public class Solution {
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        if (headA == null || headB == null) {
            return null;
        }
        ListNode pa = headA;
        ListNode pb = headB;
        while (pa != pb) {
            pa = pa != null ? pa.next : headB;
            pb = pb != null ? pb.next : headA;
        }
        return pa;
    }
}
/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) {
 *         val = x;
 *         next = null;
 *     }
 * }
 */

// time: O(m + n), m 和 n 分别是两个链表的长度
// space: O(1)

为什么能保证找到交点或同时到达 null

  • 如果相交:pa 走 a + c + b,pb 走 b + c + a,两者在交点相遇
  • 如果不相交:pa 走 m + n,pb 走 n + m,两者同时到达 null

这是空间复杂度 O(1) 的最优解,相比使用哈希表存储已访问节点的 O(m + n) 空间解法更加优雅。

236. 二叉树的最近公共祖先

LT.236. Lowest Common Ancestor of a Binary Tree

这道题的核心思想是使用递归自底向上查找,通过 DFS 后序遍历的方式,让每个节点向上返回它是否包含目标节点 p 或 q。

思考过程

  1. 边界情况:如果当前节点为 null,或者当前节点就是 pq,直接返回当前节点
  2. 递归查找:分别在左子树和右子树中查找 p 和 q
  3. 判断结果
    • 如果左右子树都找到了节点(left != null && right != null),说明当前节点就是 LCA(p 和 q 分别在左右子树)
    • 如果只有一边找到了,说明 p 和 q 都在那一边的子树中,返回那边找到的结果
    • 如果两边都没找到,返回 null

关键洞察:LCA 一定是第一个能让左右子树都返回非 null 的节点。如果只有一边返回非 null,说明 LCA 在那边的子树中;如果两边都返回非 null,当前节点就是 LCA。

class Solution {
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
        if (root == null || p == root || q == root) {
            return root;
        }
        TreeNode left = lowestCommonAncestor(root.left, p, q);
        TreeNode right = lowestCommonAncestor(root.right, p, q);
        if (left != null && right != null) {
            return root;
        }
        return left != null ? left : right;
    }
}
/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val = x; }
 * }
 */

// time: O(n), n 是树中节点的个数
// space: O(h), h 是树的高度,递归栈的深度

234. 回文链表

LT.234. Palindrome Linked List

这道题的核心思想是使用快慢指针找到链表中点,然后反转后半部分,再比较前半部分和反转后的后半部分。

思考过程

  1. 使用快慢指针找到中点:fast 每次走两步,slow 每次走一步
  2. 反转后半部分链表:从 slow.next 开始反转
  3. 比较两部分:前半部分和反转后的后半部分逐一比较

关键点:快慢指针在不同长度下的位置

  • 偶数长度(如 1->2->2->1,4 个节点):

    • fast 停在倒数第二个节点(第二个 2)
    • slow 停在第二个节点(第一个 2),即前半部分的最后一个节点
    • slow.next 是后半部分的起始节点(第二个 2)
  • 奇数长度(如 1->2->3->2->1,5 个节点):

    • fast 停在最后一个节点(最后一个 1)
    • slow 停在中间节点(3),即前半部分的最后一个节点
    • slow.next 是后半部分的起始节点(第二个 2)
    • 中间节点不参与比较,这正是我们想要的

为什么 fast.next != null && fast.next.next != null

  • 这样保证 fast 能走两步时才继续
  • 偶数长度时,fast 停在倒数第二个节点(fast.next != nullfast.next.next == null
  • 奇数长度时,fast 停在最后一个节点(fast.next == null
class Solution {
    public boolean isPalindrome(ListNode head) {
        if (head == null || head.next == null) {
            return true;
        }
        // 使用快慢指针找到中点
        // slow 会停在前半部分的最后一个节点
        ListNode slow = head, fast = head;
        while (fast.next != null && fast.next.next != null) {
            fast = fast.next.next; // fast 走两步
            slow = slow.next;      // slow 走一步
        }
        // slow.next 是后半部分的起始节点
        ListNode p1 = head;
        ListNode p2 = reverse(slow.next); // 反转后半部分
        
        // 比较前半部分和反转后的后半部分
        while (p2 != null) {
            if (p1.val != p2.val) return false;
            p1 = p1.next;
            p2 = p2.next;
        }
        return true;
    }

    // 反转链表
    private ListNode reverse(ListNode head) {
        ListNode prev = null;
        while (head != null) {
            ListNode next = head.next; // 保存下一个节点
            head.next = prev;          // 反转当前节点
            prev = head;               // prev 前移
            head = next;               // head 前移
        }
        return prev;
    }
}
/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */

// time: O(n), n 是链表长度
// space: O(1), 只使用了常数额外空间

739. 每日温度

LT.739. Daily Temperatures

这道题的核心思想是使用单调栈(单调递减栈)来找到每个温度右侧第一个比它高的温度。

思考过程

  1. 使用单调递减栈:栈中存储索引,从栈底到栈顶对应的温度递减
  2. 遍历温度数组:对于每个温度,如果它比栈顶索引对应的温度高,说明找到了栈顶元素右侧第一个更高的温度
  3. 更新答案:计算索引差 i - j,即需要等待的天数
  4. 维护单调性:弹出所有比当前温度低的栈顶元素,然后压入当前索引

关键洞察

  • 栈中存储的是"等待答案"的索引
  • 当遇到更高的温度时,可以为栈中所有比当前温度低的元素提供答案
  • 因为栈是单调递减的,所以弹出的顺序是合理的(从栈顶开始)
class Solution {
    public int[] dailyTemperatures(int[] temperatures) {
        int n = temperatures.length;
        int[] ans = new int[n];
        // 单调递减栈,存储索引(从栈底到栈顶对应的温度递减)
        Deque<Integer> stack = new ArrayDeque<>();
        for (int i = 0; i < n; i++) {
            // 当栈不为空且当前温度高于栈顶索引对应的温度时
            // 说明找到了栈顶元素右侧第一个更高的温度
            while (!stack.isEmpty() && temperatures[stack.peek()] < temperatures[i]) {
                int j = stack.pop();
                ans[j] = i - j; // 计算等待天数
            }
            // 将当前索引压入栈(等待后续更高的温度)
            stack.push(i);
        }
        // 栈中剩余元素的 ans 保持为 0(没有更高的温度)
        return ans;
    }
}

// time: O(n), 每个元素最多进出栈一次
// space: O(n), 栈的空间最多为 n

226. 翻转二叉树

LT.226. Invert Binary Tree

这道题的核心思想是使用递归自底向上翻转每个节点的左右子树。

思考过程

  1. 边界情况:如果当前节点为 null,直接返回 null
  2. 递归翻转:先递归翻转左子树和右子树,得到翻转后的子树
  3. 交换左右子树:将翻转后的左子树赋给右子树,翻转后的右子树赋给左子树
  4. 返回当前节点:返回翻转后的根节点

关键洞察:翻转二叉树就是翻转每个节点的左右子树。递归地先翻转子树,再交换当前节点的左右子树,自然就能翻转整棵树。

变量命名改进:使用 invertedLeftinvertedRight 能更清晰地表达这些是翻转后的子树,避免与 root.leftroot.right 混淆。

class Solution {
    public TreeNode invertTree(TreeNode root) {
        if (root == null) return null;
        // 递归翻转左右子树
        TreeNode invertedLeft = invertTree(root.left);
        TreeNode invertedRight = invertTree(root.right);
        // 交换左右子树
        root.left = invertedRight;
        root.right = invertedLeft;
        return root;
    }
}
/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode() {}
 *     TreeNode(int val) { this.val = val; }
 *     TreeNode(int val, TreeNode left, TreeNode right) {
 *         this.val = val;
 *         this.left = left;
 *         this.right = right;
 *     }
 * }
 */

// time: O(n), n 是树中节点的个数
// space: O(h), h 是树的高度,递归栈的深度

221. 最大正方形

LT.221. Maximal Square

这道题的核心思想是使用动态规划(二维 DP)来找到矩阵中最大的由 '1' 组成的正方形。

思考过程

  1. 定义状态dp[i][j] 表示以 matrix[i-1][j-1] 为右下角的最大正方形的边长
  2. 使用 (i+1, j+1) 索引:为了避免边界判断,dp 数组使用 (m+1) x (n+1) 大小
  3. 状态转移:如果当前位置是 '1',取上方、左方、左上方三个方向的最小值再加 1
  4. 更新最大值:遍历过程中记录最大边长
  5. 返回面积:返回 maxSide * maxSide

关键洞察 - 为什么用 min

  • 要形成一个 k x k 的正方形,需要上方、左方、左上方三个方向都能形成至少 (k-1) x (k-1) 的正方形
  • 取三者最小值,相当于取三个方向中能扩展的最小"半径"
  • 类似于木桶效应:最短的板决定了桶的容量

技术要点

  • 二维 DP:使用二维数组存储子问题结果
  • 最优子结构:以某个位置为右下角的正方形大小依赖于其上方、左方、左上方的结果
  • 边界处理:使用 (m+1) x (n+1) 的 dp 数组,让索引从 1 开始,避免边界判断
class Solution {
    public int maximalSquare(char[][] matrix) {
        int m = matrix.length;
        if (m == 0) return 0;
        int n = matrix[0].length;
        // dp[i][j] 表示以 matrix[i-1][j-1] 为右下角的最大正方形的边长
        // 使用 (m+1) x (n+1) 大小,让索引从 1 开始,避免边界判断
        int[][] dp = new int[m + 1][n + 1];
        int maxSide = 0; // 记录最大边长

        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (matrix[i - 1][j - 1] == '1') {
                    // 取上方、左方、左上方三个方向的最小值
                    // 因为要形成 k x k 正方形,三个方向都必须至少能形成 (k-1) x (k-1) 正方形
                    dp[i][j] = Math.min(dp[i - 1][j], Math.min(dp[i][j - 1], dp[i - 1][j - 1])) + 1;
                    maxSide = Math.max(dp[i][j], maxSide); // 更新最大边长
                }
                // 如果 matrix[i-1][j-1] == '0',dp[i][j] 保持为 0
            }
        }

        return maxSide * maxSide; // 返回面积
    }
}

// time: O(m * n), m 和 n 分别是矩阵的行数和列数
// space: O(m * n), dp 数组的空间

215. 数组中的第K个最大元素

LT.215. Kth Largest Element in an Array

这道题有两种主要解法:最小堆和快速选择(Quick Select)。快速选择使用三路分区(Three-Way Partition)可以高效处理重复元素。

思考过程

  1. 最小堆方法:维护大小为 k 的最小堆,堆顶即为第 k 大元素
  2. 快速选择方法:使用 partition 将数组分成三部分,根据 pivot 位置决定递归方向
  3. 三路分区的优势:当数组中有大量重复元素时,标准分区会导致 TLE,三路分区可以高效处理

方法一:最小堆(推荐,简单稳定)

核心思想:维护大小为 k 的最小堆,堆中存储当前见过的 k 个最大元素,堆顶即为第 k 大元素。

class Solution {
    public int findKthLargest(int[] nums, int k) {
        // 最小堆(默认 PriorityQueue 是最小堆)
        PriorityQueue<Integer> pq = new PriorityQueue<>();
        
        for (int num : nums) {
            pq.offer(num);
            // 保持堆大小为 k,移除最小的元素
            if (pq.size() > k) {
                pq.poll();
            }
        }
        
        return pq.peek(); // 堆顶 = 第 k 大元素
    }
}

// time: O(n log k), n 是数组长度
// space: O(k), 堆的空间

方法二:快速选择 + 三路分区(处理重复元素)

为什么需要三路分区?

标准分区(两路分区)在遇到重复元素时会退化:

  • 当所有元素相等时,标准分区会把所有元素分到一边
  • 每次递归只减少一个元素 → O(n²) 时间复杂度 → TLE

三路分区将数组分成三部分:[< pivot] [= pivot] [> pivot],当所有元素相等时可以直接返回,避免递归。

三路分区详解(Dutch National Flag Algorithm)

核心思想:使用三个指针将数组分成三个区域(实现中将 pivot 交换到 left 位置):

  • lt: [left+1, lt-1]< pivot 的区域(lt 指向第一个 = pivot 的位置)
  • i: 当前扫描指针,[i, gt] 是未处理的区域
  • gt: [gt+1, right]> pivot 的区域(gt 指向最后一个 = pivot 的位置)
  • 分区后:[lt, gt]= pivot 的区域(闭区间)

分区过程

  1. 随机选择 pivot 并交换到 left 位置(简化逻辑)
  2. 如果 nums[i] < pivot: 交换到左区域,i++, lt++
  3. 如果 nums[i] = pivot: 已经在正确位置,i++
  4. 如果 nums[i] > pivot: 交换到右区域,gt--(不移动 i,需要检查交换来的元素)

详细示例

Array: [3, 2, 3, 1, 3, 4, 5, 3], pivot = 3

初始状态:i=0, j=0, k=7, pivot=3
[3, 2, 3, 1, 3, 4, 5, 3]
 i,j                     k

j=0: nums[0]=3 = pivot, j++
[3, 2, 3, 1, 3, 4, 5, 3]
 ↑  ↑
 i  j                  k

j=1: nums[1]=2 < pivot, swap(i,j), i++, j++
[2, 3, 3, 1, 3, 4, 5, 3]
    ↑  ↑
    i  j               k

j=2: nums[2]=3 = pivot, j++
[2, 3, 3, 1, 3, 4, 5, 3]
    ↑     ↑
    i     j            k

j=3: nums[3]=1 < pivot, swap(i,j), i++, j++
[2, 1, 3, 3, 3, 4, 5, 3]
       ↑  ↑
       i  j            k

j=4: nums[4]=3 = pivot, j++
[2, 1, 3, 3, 3, 4, 5, 3]
       ↑     ↑
       i     j         k

j=5: nums[5]=4 > pivot, swap(j,k), k--
[2, 1, 3, 3, 3, 3, 5, 4]
       ↑     ↑     ↑
       i     j     k
注意:j 不移动,需要检查交换来的元素

j=5: nums[5]=3 = pivot, j++
[2, 1, 3, 3, 3, 3, 5, 4]
       ↑        ↑  ↑
       i        j  k

j=6: nums[6]=5 > pivot, swap(j,k), k--
[2, 1, 3, 3, 3, 3, 4, 5]
       ↑        ↑  ↑
       i        j  k

j=6: nums[6]=4 > pivot, swap(j,k), k--
[2, 1, 3, 3, 3, 3, 5, 4]
       ↑        ↑  ↑
       i        j  k
但此时 j=6 > k=5,循环结束

最终交换 pivot (nums[right]=3) 到位置 j
[2, 1, 3, 3, 3, 3, 5, 4] → [2, 1, 3, 3, 3, 3, 4, 5]
                              ↑        ↑     ↑
                            i=2        j=6  (pivot最终位置)

返回 [i=2, j=6],表示 [2,6] 范围内的元素都等于 pivot(闭区间,两端都包含)
完整代码实现

实现要点

  • 使用随机 pivot避免最坏情况
  • 将 pivot 交换到 left 位置,简化分区逻辑
  • 使用迭代而非递归,空间更优
  • 三指针分区:lt(小于区域边界)、i(扫描指针)、gt(大于区域边界)
class Solution {
    private final Random random = new Random();
   
    public int findKthLargest(int[] nums, int k) {
        int n = nums.length;
        // 将第 k 大转换为第 (n-k) 小(0-indexed)
        int target = n - k;
        int left = 0, right = n - 1;
        
        // 迭代版本的快速选择(避免递归栈空间)
        while (left <= right) {
            // 三路分区,返回等于 pivot 的范围 [lt, gt](闭区间)
            int[] p = partition3(nums, left, right);
            int lt = p[0]; // 等于 pivot 区域的起始索引
            int gt = p[1]; // 等于 pivot 区域的结束索引
            
            if (target < lt) {
                // target 在左半部分(< pivot)
                right = lt - 1;
            } else if (target > gt) {
                // target 在右半部分(> pivot)
                left = gt + 1;
            } else {
                // target 在等于 pivot 的范围内,找到了!
                return nums[lt];
            }
        }
        return -1; // 不应该到达这里
    }

    // 三路分区(Dutch National Flag Algorithm)
    // 将数组分成三部分:[< pivot] [= pivot] [> pivot]
    // 返回:等于 pivot 的元素的起始和结束位置 [lt, gt](闭区间)
    private int[] partition3(int[] nums, int left, int right) {
        // 随机选择 pivot,避免最坏情况
        int pivotIndex = left + random.nextInt(right - left + 1);
        int pivot = nums[pivotIndex];
        
        // 将 pivot 交换到 left 位置,简化分区逻辑
        swap(nums, left, pivotIndex);
        
        // 初始化三个指针:
        // lt: [left+1, lt-1] 是 < pivot 的区域(lt 指向第一个 = pivot 的位置)
        // i:  扫描指针,当前检查的位置
        // gt: [gt+1, right] 是 > pivot 的区域(gt 指向最后一个 = pivot 的位置)
        int lt = left;        // 小于区域的右边界(不包含),等于区域的起始位置
        int i = left + 1;     // 当前扫描位置,从 left+1 开始
        int gt = right;       // 大于区域的左边界(不包含),等于区域的结束位置
        
        // 扫描未处理的区域 [i, gt]
        while (i <= gt) {
            if (nums[i] < pivot) {
                // 小于 pivot:交换到左区域
                // 交换后,nums[lt] 是之前的小元素,nums[i] 是原来 lt 位置的值
                // lt++ 和 i++ 都移动,因为交换后 i 位置的值已经处理过
                swap(nums, i++, lt++);
            } else if (nums[i] > pivot) {
                // 大于 pivot:交换到右区域
                // 交换后,nums[gt] 是之前的大元素,nums[i] 是交换来的未知值
                // 只移动 gt--,i 不移动,因为需要检查交换来的元素
                swap(nums, i, gt--);
            } else {
                // 等于 pivot:已经在正确位置,继续扫描
                i++;
            }
        }
        
        // 分区完成后的状态:
        // [left+1, lt-1] 是 < pivot 的元素
        // [lt, gt] 是 = pivot 的元素(闭区间)
        // [gt+1, right] 是 > pivot 的元素
        // 注意:left 位置的 pivot 在分区过程中被交换到 [lt, gt] 范围内的某个位置
        
        // 返回等于 pivot 的范围(闭区间)
        return new int[]{lt, gt};
    }

    private void swap(int[] nums, int i, int j) {
        int tmp = nums[i];
        nums[i] = nums[j];
        nums[j] = tmp;
    }
}

// time: O(n) 平均时间,O(n²) 最坏情况(但三路分区使最坏情况很少发生)
// space: O(1) 额外空间,递归栈 O(log n) 平均,O(n) 最坏
为什么三路分区能处理重复元素?

场景:所有元素相等 [5, 5, 5, 5, 5], k = 3

  1. 三路分区后:所有元素都在 = pivot 区域
  2. 返回范围 [0, 4](闭区间)
  3. k=2 在范围内(0 <= 2 <= 4)→ 直接返回,无需递归!

为什么使用 k <= pivotEnd 而不是 k < pivotEnd

关键原因:三路分区返回的范围是闭区间 [pivotStart, pivotEnd],两端都包含。

详细解释

  1. 循环结束后,j 指向第一个 > pivot 区域的位置(或 j = k+1,此时所有元素已处理)
  2. 我们执行 swap(nums, j, right),将 pivot(原来在 nums[right])交换到位置 j
  3. 交换后,nums[j] 也等于 pivot
  4. 所以 [i, j] 范围内的所有元素(包括索引 j)都等于 pivot
  5. 因此判断时必须使用 k <= pivotEnd 来包含索引 pivotEnd(即 j)本身

示例验证

分区后:[2, 1, 3, 3, 3, 3, 4, 5]
        < pivot    = pivot    > pivot
索引:   0  1  2  3  4  5  6  7
                    ↑        ↑
                  i=2      j=6 (pivotEnd)

返回 [2, 6],表示索引 2 到 6(包含)都等于 pivot
如果 k=6,应该被包含在范围内,所以需要 k <= 6

对比标准分区

  • 标准分区:所有元素分到一边,每次只减少一个元素 → O(n²)
  • 三路分区:所有相等的元素被识别为一个整体,立即返回 → O(n)
复杂度分析
  • 时间复杂度
    • 平均:O(n) - 每次分区大约消除一半元素
    • 最坏:O(n²) - 但三路分区使最坏情况很少发生(所有元素相等时是 O(n))
  • 空间复杂度:O(1) 额外空间,递归栈 O(log n) 平均
方法选择建议
  • 面试/实践:优先使用最小堆,简单稳定,易于解释
  • 学习算法:实现三路分区,理解分区思想
  • 性能优化:三路分区在重复元素多时更优

208. 实现Trie(前缀树)

LT.208. Implement Trie (Prefix Tree)

Trie(前缀树)是一种树形数据结构,用于高效存储和检索字符串集合。它的核心思想是利用字符串的公共前缀来减少存储空间,并支持快速的前缀匹配。

思考过程

  1. 节点结构:每个节点包含一个子节点数组(26个字母)和一个布尔标志(表示是否为单词结尾)
  2. 插入操作:从根节点开始,沿着字符路径创建节点,到达单词末尾时标记为结束
  3. 查找操作:从根节点开始,沿着字符路径查找,检查是否存在完整的单词或前缀
  4. 辅助方法find 方法统一处理路径查找,searchstartsWith 复用该方法

核心思想

  • 共享前缀:具有相同前缀的单词共享路径,节省空间
  • 路径表示字符:从根到某个节点的路径表示一个字符串前缀
  • 结束标志isEnd 标志区分完整单词和前缀
class Trie {
    // 根节点(不存储字符,作为所有单词的起始点)
    private final TrieNode root;

    public Trie() {
        root = new TrieNode();
    }
    
    // 插入单词到 Trie 中
    public void insert(String word) {
        TrieNode cur = root;
        // 沿着单词的每个字符路径向下
        for (char c : word.toCharArray()) {
            int idx = c - 'a'; // 将字符转换为索引('a'=0, 'b'=1, ...)
            // 如果当前字符对应的子节点不存在,创建新节点
            if (cur.children[idx] == null) {
                cur.children[idx] = new TrieNode();
            }
            // 移动到子节点
            cur = cur.children[idx];
        }
        // 标记单词结束
        cur.isEnd = true;
    }
    
    // 查找完整的单词(必须是一个完整的单词,不能只是前缀)
    public boolean search(String word) {
        TrieNode node = find(word);
        // 找到节点且该节点标记为单词结尾
        return node != null && node.isEnd;
    }
    
    // 检查是否存在以给定前缀开头的单词
    public boolean startsWith(String prefix) {
        // 只要找到对应的节点路径即可(不需要是完整单词)
        return find(prefix) != null;
    }

    // 辅助方法:查找字符串对应的节点
    // 如果路径存在返回对应节点,否则返回 null
    private TrieNode find(String s) {
        TrieNode cur = root;
        for (char c : s.toCharArray()) {
            int idx = c - 'a';
            // 如果路径中断,返回 null
            if (cur.children[idx] == null) return null;
            cur = cur.children[idx];
        }
        return cur;
    }

    // Trie 节点定义
    private static class TrieNode {
        // 子节点数组,每个索引对应一个字母('a' 到 'z')
        private TrieNode[] children = new TrieNode[26];
        // 标记当前节点是否为某个单词的结尾
        private boolean isEnd = false;
    }
}

/**
 * Your Trie object will be instantiated and called as such:
 * Trie obj = new Trie();
 * obj.insert(word);
 * boolean param_2 = obj.search(word);
 * boolean param_3 = obj.startsWith(prefix);
 */

// 时间复杂度:
//   insert: O(m), m 是单词长度
//   search: O(m), m 是单词长度
//   startsWith: O(m), m 是前缀长度
// 空间复杂度:O(ALPHABET_SIZE × N × M),N 是单词数量,M 是平均单词长度

207. 课程表

LT.207. Course Schedule

这道题本质上是判断有向图是否存在环。如果能完成所有课程,说明图中没有环(可以进行拓扑排序)。

思考过程

  1. 问题转换:课程依赖关系构成有向图,判断是否存在环
  2. BFS 方法(拓扑排序):从入度为 0 的节点开始,逐步移除节点,如果所有节点都能被访问,说明无环
  3. DFS 方法(状态标记):使用三种状态标记节点,如果在 DFS 过程中遇到 "visiting" 状态的节点,说明存在环

核心思想

  • 拓扑排序:有向无环图(DAG)可以进行拓扑排序
  • 入度统计:BFS 方法通过统计入度,从没有依赖的节点开始处理
  • 状态标记:DFS 方法通过状态标记(0=未访问, 1=访问中, 2=已访问)检测后向边(back edge)

方法一:BFS + 拓扑排序(Kahn's Algorithm)

核心思想:从入度为 0 的节点开始,逐步移除节点并更新其邻居的入度,如果所有节点都能被访问,说明无环。

class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        // 构建邻接表表示的有向图
        List<List<Integer>> graph = new ArrayList<>();
        for (int i = 0; i < numCourses; i++) {
            graph.add(new ArrayList<>());
        }
        // 统计每个节点的入度
        int[] indegree = new int[numCourses];
        
        // 构建图和入度数组
        for (int[] prerequisite : prerequisites) {
            int to = prerequisite[0];   // 目标课程
            int from = prerequisite[1]; // 先修课程
            graph.get(from).add(to);    // from -> to 的边
            indegree[to]++;             // to 的入度加 1
        }

        // 将所有入度为 0 的节点加入队列(没有依赖的课程)
        Deque<Integer> queue = new ArrayDeque<>();
        for (int i = 0; i < numCourses; i++) {
            if (indegree[i] == 0) {
                queue.offer(i);
            }
        }

        // BFS:从入度为 0 的节点开始,逐步移除节点
        int visited = 0;
        while (!queue.isEmpty()) {
            int cur = queue.poll();
            visited++;
            // 遍历当前节点的所有邻居
            for (int next : graph.get(cur)) {
                // 减少邻居的入度
                if (--indegree[next] == 0) {
                    // 如果邻居的入度变为 0,加入队列
                    queue.offer(next);
                }
            }
        }
        // 如果所有节点都被访问,说明无环,可以完成所有课程
        return visited == numCourses;
    }
}

// time: O(V + E), V 是课程数,E 是依赖关系数
// space: O(V + E), 图的空间和队列的空间

方法二:DFS + 状态标记(Cycle Detection)

核心思想:使用三种状态标记节点,在 DFS 过程中如果遇到 "visiting" 状态的节点,说明存在后向边(back edge),即存在环。

状态定义

  • 0 (UNVISITED): 未访问
  • 1 (VISITING): 正在访问(在递归栈中)
  • 2 (VISITED): 已访问(已完成 DFS)

关键洞察:如果在 DFS 过程中遇到状态为 VISITING 的节点,说明存在后向边,即存在环。

class Solution {
    private static final int UNVISITED = 0;
    private static final int VISITING = 1;
    private static final int VISITED = 2;
    
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        // 构建邻接表
        List<List<Integer>> graph = new ArrayList<>();
        for (int i = 0; i < numCourses; i++) {
            graph.add(new ArrayList<>());
        }
        for (int[] prerequisite : prerequisites) {
            int to = prerequisite[0];
            int from = prerequisite[1];
            graph.get(from).add(to);
        }
        
        // 状态数组:0=未访问, 1=访问中, 2=已访问
        int[] state = new int[numCourses];
        
        // 对每个节点进行 DFS
        for (int i = 0; i < numCourses; i++) {
            if (state[i] == UNVISITED) {
                if (hasCycle(graph, state, i)) {
                    return false; // 发现环,无法完成
                }
            }
        }
        return true; // 无环,可以完成
    }
    
    // DFS 检测是否存在环
    private boolean hasCycle(List<List<Integer>> graph, int[] state, int node) {
        // 如果节点正在访问中,说明存在后向边(back edge),存在环
        if (state[node] == VISITING) {
            return true;
        }
        // 如果节点已访问,说明这个分支已经检查过,无环
        if (state[node] == VISITED) {
            return false;
        }
        
        // 标记为访问中(进入递归栈)
        state[node] = VISITING;
        
        // 递归访问所有邻居
        for (int next : graph.get(node)) {
            if (hasCycle(graph, state, next)) {
                return true; // 发现环
            }
        }
        
        // 标记为已访问(离开递归栈)
        state[node] = VISITED;
        return false; // 无环
    }
}

// time: O(V + E), V 是课程数,E 是依赖关系数
// space: O(V + E), 图的空间和递归栈的空间

DFS 方法的工作原理

示例:课程 0 -> 1 -> 2 -> 0(存在环)

DFS(0):
  state[0] = VISITING
  DFS(1):
    state[1] = VISITING
    DFS(2):
      state[2] = VISITING
      DFS(0):
        state[0] == VISITING → 发现环!返回 true

两种方法对比

方法思路优势适用场景
BFS (拓扑排序)从入度为 0 的节点开始,逐步移除直观,易于理解需要拓扑排序结果时
DFS (状态标记)检测后向边判断环代码简洁,空间可能更优只需要判断是否有环时

复杂度分析

  • 时间复杂度:O(V + E),需要遍历所有节点和边
  • 空间复杂度:O(V + E),存储图和辅助数组/栈

206. 反转链表

LT.206. Reverse Linked List

这道题的核心思想是使用双指针(或三指针)逐个反转链表的连接方向。

思考过程

  1. 迭代方法:使用 prevcurnext 三个指针,逐个反转每个节点的 next 指针
  2. 递归方法:递归到链表末尾,然后从后往前反转每个节点
  3. 关键点:在反转 cur.next 之前,需要先保存 cur.next,否则会丢失后续节点

核心思想

  • 三指针技巧prev(前一个节点)、cur(当前节点)、next(下一个节点)
  • 逐个反转:每次循环反转一个节点的 next 指针
  • 边界处理:初始时 prev = null,最终 prev 成为新的头节点

方法一:迭代(推荐)

class Solution {
    public ListNode reverseList(ListNode head) {
        ListNode prev = null;  // 前一个节点,初始为 null
        ListNode cur = head;  // 当前节点,从 head 开始
        
        while (cur != null) {
            // 先保存下一个节点,避免丢失
            ListNode next = cur.next;
            // 反转当前节点的 next 指针
            cur.next = prev;
            // 移动指针:prev 和 cur 都向前移动
            prev = cur;
            cur = next;
        }
        // prev 最终指向原链表的最后一个节点,即反转后的头节点
        return prev;
    }
}
/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */

// time: O(n), n 是链表长度
// space: O(1), 只使用了常数额外空间

执行过程可视化

初始:NULL  1 → 2 → 3 → 4 → NULL
      ↑    ↑
     prev cur

第1步:保存 next = 2
      反转:NULL ← 1    2 → 3 → 4 → NULL
            ↑     ↑    ↑
           prev  cur  next
      移动:NULL ← 1    2 → 3 → 4 → NULL
                 ↑     ↑
                prev  cur

第2步:保存 next = 3
      反转:NULL ← 1 ← 2    3 → 4 → NULL
                 ↑    ↑    ↑
                prev cur  next
      移动:NULL ← 1 ← 2    3 → 4 → NULL
                      ↑     ↑
                     prev  cur

...继续直到 cur == null

最终:NULL ← 1 ← 2 ← 3 ← 4
                     prev (新头节点)

方法二:递归

核心思想:递归到链表末尾,然后从后往前反转每个节点。

class Solution {
    public ListNode reverseList(ListNode head) {
        //  base case:空链表或只有一个节点
        if (head == null || head.next == null) {
            return head;
        }
        
        // 递归反转后续链表,返回新的头节点
        ListNode newHead = reverseList(head.next);
        
        // 反转当前节点和下一个节点的连接
        head.next.next = head;
        head.next = null;
        
        return newHead;
    }
}

// time: O(n), n 是链表长度
// space: O(n), 递归栈的深度

递归过程可视化

原链表:1 → 2 → 3 → 4 → NULL

递归调用栈:
reverseList(1)
  reverseList(2)
    reverseList(3)
      reverseList(4) → 返回 4(base case)
    反转:4 → 3,返回 4
  反转:4 → 3 → 2,返回 4
反转:4 → 3 → 2 → 1,返回 4

最终:NULL ← 1 ← 2 ← 3 ← 4
                    newHead

两种方法对比

方法时间复杂度空间复杂度优势
迭代O(n)O(1)空间效率高,适合长链表
递归O(n)O(n)代码简洁,但可能栈溢出

推荐:优先使用迭代方法,空间效率更高。

200. 岛屿数量

LT.200. Number of Islands

这道题的核心思想是使用 DFS 或 BFS 遍历网格,将连通的 '1' 区域标记为已访问,统计有多少个独立的连通区域。

思考过程

  1. 问题转换:网格中的 '1' 表示陆地,连通的 '1' 组成一个岛屿
  2. 遍历策略:遍历整个网格,遇到 '1' 时,使用 DFS/BFS 标记所有连通的 '1'
  3. 标记方法:将访问过的 '1' 改为 '0'("沉没"岛屿),避免重复计算
  4. 计数:每次遇到新的 '1',说明发现一个新岛屿,计数加 1

核心思想

  • 连通性:上下左右相邻的 '1' 属于同一个岛屿
  • 标记访问:通过修改原数组标记已访问("沉没"技术),无需额外空间
  • DFS 递归:从每个 '1' 开始,递归标记所有连通的 '1'

DFS 方法(推荐)

class Solution {
    public int numIslands(char[][] grid) {
        int m = grid.length;
        int n = grid[0].length;
        int count = 0;
        
        // 遍历整个网格
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (grid[i][j] == '1') {
                    // 发现新的岛屿,计数加 1
                    count++;
                    // 使用 DFS 标记所有连通的 '1'("沉没"整个岛屿)
                    dfs(grid, i, j);
                }
            }
        }
        return count;
    }

    // DFS:标记所有与 (i, j) 连通的 '1'
    private void dfs(char[][] grid, int i, int j) {
        int m = grid.length;
        int n = grid[0].length;
        
        // 边界检查:越界则返回
        if (i < 0 || i >= m || j < 0 || j >= n) return;
        // 如果不是 '1'(可能是 '0' 或已访问),返回
        if (grid[i][j] != '1') return;
        
        // 标记为已访问("沉没"这个位置)
        grid[i][j] = '0';
        
        // 递归访问四个方向:上、下、左、右
        dfs(grid, i + 1, j); //        dfs(grid, i - 1, j); //        dfs(grid, i, j + 1); //        dfs(grid, i, j - 1); //    }
}

// time: O(m × n), m 和 n 是网格的行数和列数
// space: O(m × n), 最坏情况下递归栈的深度(整个网格都是 '1')

执行过程示例

初始网格:
1 1 0 0 0
1 1 0 0 0
0 0 1 0 0
0 0 0 1 1

第1步:发现 (0,0) = '1',count = 1
DFS(0,0) → 标记所有连通的 '1':
0 0 0 0 0  (第一个岛屿被"沉没")
0 0 0 0 0
0 0 1 0 0
0 0 0 1 1

第2步:发现 (2,2) = '1',count = 2
DFS(2,2) → 标记:
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0  (第二个岛屿被"沉没")
0 0 0 1 1

第3步:发现 (3,3) = '1',count = 3
DFS(3,3) → 标记:
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0  (第三个岛屿被"沉没")

返回 3

关键技巧 - "沉没"技术

  • 将访问过的 '1' 改为 '0',这样在后续遍历中不会重复计算
  • 无需额外的 visited 数组,节省空间
  • 修改原数组是安全的,因为题目只要求计数,不要求保留原数组

复杂度分析

  • 时间复杂度:O(m × n),每个格子最多访问一次
  • 空间复杂度:O(m × n),最坏情况下递归栈的深度(整个网格都是 '1' 时,形成一条长链)

优化提示:如果网格很大,可以考虑使用 BFS(迭代)来避免递归栈溢出。

198. 打家劫舍

LT.198. House Robber

这道题的核心思想是使用动态规划,对于每个房子,我们有两个选择:偷或不偷,选择收益更大的方案。

思考过程

  1. 定义状态dp[i] 表示前 i 个房子能获得的最大金额
  2. 状态转移:对于第 i 个房子,有两种选择:
    • 不偷:dp[i] = dp[i-1](保持前 i-1 个房子的最大金额)
    • 偷:dp[i] = dp[i-2] + nums[i](前 i-2 个房子的最大金额 + 当前房子金额)
    • 取两者最大值
  3. 边界情况:前 0 个房子为 0,前 1 个房子为 nums[0]

核心思想

  • 最优子结构:前 i 个房子的最优解依赖于前 i-1 和前 i-2 个房子的最优解
  • 状态转移方程dp[i] = max(dp[i-1], dp[i-2] + nums[i])
  • 空间优化:由于只依赖前两个状态,可以用 O(1) 空间

方法一:标准 DP(你的实现)

你的代码是正确的!可以稍微简化边界处理。

class Solution {
    public int rob(int[] nums) {
        // 边界情况处理
        if (nums.length == 1) {
            return nums[0];
        }
        if (nums.length == 2) {
            return Math.max(nums[0], nums[1]);
        }
        
        // dp[i] 表示前 i 个房子能获得的最大金额
        int[] dp = new int[nums.length];
        dp[0] = nums[0];
        dp[1] = Math.max(nums[0], nums[1]);
        
        // 对于每个房子,选择偷或不偷
        for (int i = 2; i < nums.length; i++) {
            // 不偷当前房子:保持前 i-1 个房子的最大金额
            // 偷当前房子:前 i-2 个房子的最大金额 + 当前房子金额
            dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
        }
        
        return dp[nums.length - 1];
    }
}

// time: O(n), n 是房子数量
// space: O(n), dp 数组的空间

改进建议

  1. 简化边界处理:可以统一处理,让代码更简洁
  2. 空间优化:只依赖前两个状态,可以优化到 O(1) 空间

方法二:简化版本(统一边界处理)

class Solution {
    public int rob(int[] nums) {
        int n = nums.length;
        if (n == 0) return 0;
        if (n == 1) return nums[0];
        
        int[] dp = new int[n];
        dp[0] = nums[0];
        dp[1] = Math.max(nums[0], nums[1]);
        
        for (int i = 2; i < n; i++) {
            dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
        }
        
        return dp[n - 1];
    }
}

方法三:空间优化版本(O(1) 空间)

核心思想:由于 dp[i] 只依赖 dp[i-1]dp[i-2],可以用两个变量代替整个数组。

class Solution {
    public int rob(int[] nums) {
        int n = nums.length;
        if (n == 0) return 0;
        if (n == 1) return nums[0];
        
        // 用两个变量代替 dp 数组
        int prev2 = nums[0];           // dp[i-2]
        int prev1 = Math.max(nums[0], nums[1]); // dp[i-1]
        
        for (int i = 2; i < n; i++) {
            // 计算 dp[i]
            int cur = Math.max(prev1, prev2 + nums[i]);
            // 更新状态:向前移动
            prev2 = prev1;
            prev1 = cur;
        }
        
        return prev1;
    }
}

// time: O(n)
// space: O(1), 只使用了常数额外空间

空间优化过程可视化

标准 DP:
dp[0] = 2
dp[1] = 7
dp[2] = max(dp[1]=7, dp[0]+9=11) = 11
dp[3] = max(dp[2]=11, dp[1]+3=10) = 11
dp[4] = max(dp[3]=11, dp[2]+1=12) = 12

空间优化(只保留前两个状态):
prev2 = 2, prev1 = 7
i=2: cur = max(7, 2+9) = 11, prev2=7, prev1=11
i=3: cur = max(11, 7+3) = 11, prev2=11, prev1=11
i=4: cur = max(11, 11+1) = 12, prev2=11, prev1=12

代码评价

  • 正确性:你的实现完全正确,逻辑清晰
  • 边界处理:显式处理了边界情况,代码安全
  • 💡 可优化点
    • 边界处理可以统一(但你的方式更清晰)
    • 可以优化到 O(1) 空间(但 O(n) 空间也完全可接受)

推荐

  • 面试/学习:你的实现很好,清晰易懂
  • 性能优化:如果需要 O(1) 空间,使用方法三

169. 多数元素

LT.169. Majority Element

这道题的核心思想是使用 Boyer-Moore 投票算法(Boyer-Moore Voting Algorithm),在 O(n) 时间和 O(1) 空间内找到出现次数超过一半的元素。

思考过程

  1. 问题理解:多数元素出现次数 > n/2,意味着它比其他所有元素的总和还要多
  2. 投票思想:将多数元素看作 +1,其他元素看作 -1,最终和一定 > 0
  3. 抵消策略:不同的元素互相抵消,最后剩下的候选者就是多数元素

核心思想 - Boyer-Moore 投票算法

  • 维护候选者和计数:遍历数组,维护一个候选者 candidate 和它的计数 count
  • 投票规则
    • 如果 count == 0:选择当前元素作为新的候选者,count = 1
    • 如果当前元素 == 候选者:count++(支持票)
    • 如果当前元素 != 候选者:count--(反对票,抵消)
  • 最终结果:遍历结束后,候选者就是多数元素

为什么这个算法有效?

关键洞察:多数元素的数量 > n/2,意味着:

  • 即使所有其他元素都"反对"它,它仍然会有剩余的支持票
  • 不同元素之间的抵消不会影响多数元素的最终优势

可视化示例

数组:[2, 2, 1, 1, 1, 2, 2]
多数元素:2(出现 4 次,> 7/2 = 3.5)

遍历过程:
i=0: num=2, count=0 → candidate=2, count=1
i=1: num=2, candidate=2 → count=2
i=2: num=1, candidate=2 → count=1 (抵消)
i=3: num=1, candidate=2 → count=0 (抵消)
i=4: num=1, count=0 → candidate=1, count=1 (重新选择)
i=5: num=2, candidate=1 → count=0 (抵消)
i=6: num=2, count=0 → candidate=2, count=1 (重新选择)

最终:candidate=2 ✓

另一种理解方式 - 配对抵消

  • 将数组中的元素两两配对
  • 如果两个元素不同,就抵消掉(相当于 count--
  • 如果两个元素相同,就保留(相当于 count++
  • 最后剩下的元素就是多数元素
class Solution {
    public int majorityElement(int[] nums) {
        int candidate = 0;  // 候选者
        int count = 0;      // 候选者的计数
        
        for (int num : nums) {
            if (count == 0) {
                // 当前没有候选者,选择当前元素作为候选者
                candidate = num;
                count = 1;
            } else if (candidate == num) {
                // 当前元素与候选者相同,增加支持票
                count++;
            } else {
                // 当前元素与候选者不同,抵消一票
                count--;
            }
        }
        
        // 遍历结束后,candidate 就是多数元素
        // 注意:题目保证多数元素一定存在,所以不需要验证
        return candidate;
    }
}

// time: O(n), 一次遍历
// space: O(1), 只使用了常数额外空间

其他解法对比

方法二:哈希表计数

class Solution {
    public int majorityElement(int[] nums) {
        Map<Integer, Integer> map = new HashMap<>();
        for (int num : nums) {
            map.put(num, map.getOrDefault(num, 0) + 1);
            if (map.get(num) > nums.length / 2) {
                return num;
            }
        }
        return -1;
    }
}
// time: O(n)
// space: O(n), 哈希表的空间

方法三:排序

class Solution {
    public int majorityElement(int[] nums) {
        Arrays.sort(nums);
        return nums[nums.length / 2]; // 中间元素一定是多数元素
    }
}
// time: O(n log n)
// space: O(1) 或 O(n)(取决于排序实现)

方法对比

方法时间复杂度空间复杂度特点
Boyer-Moore 投票O(n)O(1)✅ 最优,一次遍历,无需额外空间
哈希表O(n)O(n)需要额外空间存储计数
排序O(n log n)O(1) 或 O(n)时间复杂度较高

关键要点

  • Boyer-Moore 算法是这道题的最优解
  • 一次遍历,无需额外空间
  • 核心思想:不同元素互相抵消,多数元素最终会胜出
  • ⚠️ 前提条件:题目保证多数元素一定存在(出现次数 > n/2)

复杂度分析

  • 时间复杂度:O(n),一次遍历数组
  • 空间复杂度:O(1),只使用了两个变量 candidatecount

238. 除自身以外数组的乘积

LT.238. Product of Array Except Self

这道题的核心思想是使用前缀积后缀积,在 O(n) 时间和 O(1) 额外空间(不包括输出数组)内计算每个位置除自身外的所有元素乘积。

思考过程

  1. 问题理解:对于位置 i,需要计算 nums[0] * nums[1] * ... * nums[i-1] * nums[i+1] * ... * nums[n-1]
  2. 分解问题:可以拆分为两部分:
    • 左半部分nums[0] * nums[1] * ... * nums[i-1](前缀积)
    • 右半部分nums[i+1] * ... * nums[n-1](后缀积)
  3. 两遍遍历
    • 第一遍:从左到右,计算每个位置左侧所有元素的乘积(前缀积)
    • 第二遍:从右到左,计算每个位置右侧所有元素的乘积(后缀积),并乘到结果中

核心思想

  • 前缀积res[i] = nums[0] * nums[1] * ... * nums[i-1]
  • 后缀积right = nums[i+1] * nums[i+2] * ... * nums[n-1]
  • 最终结果res[i] = 前缀积 * 后缀积

关键技巧

  • 使用输出数组存储前缀积,节省空间
  • 第二遍遍历时,用变量 right 动态计算后缀积,边计算边更新结果
  • 这样只需要 O(1) 额外空间(不包括输出数组)

可视化示例

输入:nums = [1, 2, 3, 4]

第一遍(从左到右,计算前缀积):
i=0: res[0] = 1 (没有左侧元素)
i=1: res[1] = res[0] * nums[0] = 1 * 1 = 1
i=2: res[2] = res[1] * nums[1] = 1 * 2 = 2
i=3: res[3] = res[2] * nums[2] = 2 * 3 = 6

此时 res = [1, 1, 2, 6](前缀积)

第二遍(从右到左,计算后缀积并更新结果):
i=3: right = 1, res[3] = 6 * 1 = 6, right = 1 * 4 = 4
i=2: right = 4, res[2] = 2 * 4 = 8, right = 4 * 3 = 12
i=1: right = 12, res[1] = 1 * 12 = 12, right = 12 * 2 = 24
i=0: right = 24, res[0] = 1 * 24 = 24, right = 24 * 1 = 24

最终结果:[24, 12, 8, 6]

验证:
res[0] = 2 * 3 * 4 = 24 ✓
res[1] = 1 * 3 * 4 = 12 ✓
res[2] = 1 * 2 * 4 = 8 ✓
res[3] = 1 * 2 * 3 = 6 ✓
class Solution {
    public int[] productExceptSelf(int[] nums) {
        int n = nums.length;
        int[] res = new int[n];
        
        // 第一遍:从左到右,计算前缀积
        // res[i] = nums[0] * nums[1] * ... * nums[i-1]
        res[0] = 1; // 第一个元素没有左侧元素,前缀积为 1
        for (int i = 1; i < n; i++) {
            // 当前位置的前缀积 = 前一个位置的前缀积 * 前一个位置的元素值
            res[i] = res[i - 1] * nums[i - 1];
        }
        
        // 第二遍:从右到左,计算后缀积并更新结果
        // right 表示当前位置右侧所有元素的乘积
        int right = 1; // 最后一个元素没有右侧元素,后缀积初始为 1
        for (int i = n - 1; i >= 0; i--) {
            // 将后缀积乘到结果中:res[i] = 前缀积 * 后缀积
            res[i] *= right;
            // 更新后缀积:将当前元素乘到 right 中,为下一个位置准备
            right *= nums[i];
        }
        
        return res;
    }
}

// time: O(n), 两次遍历数组
// space: O(1), 只使用了常数额外空间(不包括输出数组 res)

其他解法对比

方法二:使用左右两个数组(更直观但需要额外空间)

class Solution {
    public int[] productExceptSelf(int[] nums) {
        int n = nums.length;
        int[] left = new int[n];   // 前缀积数组
        int[] right = new int[n];  // 后缀积数组
        int[] res = new int[n];
        
        // 计算前缀积
        left[0] = 1;
        for (int i = 1; i < n; i++) {
            left[i] = left[i - 1] * nums[i - 1];
        }
        
        // 计算后缀积
        right[n - 1] = 1;
        for (int i = n - 2; i >= 0; i--) {
            right[i] = right[i + 1] * nums[i + 1];
        }
        
        // 合并结果
        for (int i = 0; i < n; i++) {
            res[i] = left[i] * right[i];
        }
        
        return res;
    }
}
// time: O(n)
// space: O(n), 需要额外的 left 和 right 数组

方法三:使用除法(不推荐,需要处理 0)

// 不推荐:需要处理 0 的情况,且题目要求不能使用除法

方法对比

方法时间复杂度空间复杂度特点
你的方法(两遍遍历)O(n)O(1)✅ 最优,使用输出数组存储前缀积,变量存储后缀积
左右两个数组O(n)O(n)更直观,但需要额外空间
除法O(n)O(1)❌ 不适用,题目不允许使用除法,且需要处理 0

关键要点

  • 两遍遍历:第一遍计算前缀积,第二遍计算后缀积并更新结果
  • 空间优化:使用输出数组存储前缀积,用变量动态计算后缀积
  • 核心思想:将问题分解为前缀积和后缀积两部分
  • 边界处理:第一个元素的前缀积为 1,最后一个元素的后缀积为 1

复杂度分析

  • 时间复杂度:O(n),两次遍历数组,每次 O(n)
  • 空间复杂度:O(1),只使用了常数额外空间(不包括输出数组 res,因为题目要求输出数组不算额外空间)

155. 最小栈

LT.155. Min Stack

这道题的核心思想是使用辅助栈minStack)来跟踪每个状态下的最小值,使得所有操作都能在 O(1) 时间内完成。

思考过程

  1. 问题理解:需要实现一个栈,支持常规的 pushpoptop 操作,同时支持 O(1) 时间获取最小值
  2. 关键挑战:当栈顶元素被弹出后,最小值可能会改变,需要快速找到新的最小值
  3. 解决方案:使用辅助栈 minStack,在每个状态下都存储当前的最小值

核心思想 - 辅助栈方法

  • 主栈 stack:存储所有元素
  • 辅助栈 minStack:存储每个状态下的最小值
  • 同步操作:每次 push 时,将当前最小值(包括新元素)存入 minStack;每次 pop 时,同时弹出两个栈
  • 获取最小值:直接返回 minStack 的栈顶元素

为什么这个设计有效?

关键洞察minStack 的栈顶始终是当前状态下所有元素的最小值。

  • 当新元素入栈时,如果它比当前最小值小,它成为新的最小值;否则保持原来的最小值
  • 当元素出栈时,minStack 同步出栈,栈顶自然恢复到之前状态的最小值

可视化示例

操作序列:push(3), push(2), push(1), push(4), pop(), getMin(), pop(), getMin()

初始状态:
stack:     []
minStack:  []

push(3):
stack:     [3]
minStack:  [3]     ← 当前最小值是 3

push(2):
stack:     [3, 2]
minStack:  [3, 2]  ← 当前最小值是 min(3,2) = 2

push(1):
stack:     [3, 2, 1]
minStack:  [3, 2, 1]  ← 当前最小值是 min(3,2,1) = 1

push(4):
stack:     [3, 2, 1, 4]
minStack:  [3, 2, 1, 1]  ← 当前最小值仍然是 min(3,2,1,4) = 1

pop() (弹出 4):
stack:     [3, 2, 1]
minStack:  [3, 2, 1]  ← 恢复,当前最小值是 1

getMin() → 1

pop() (弹出 1):
stack:     [3, 2]
minStack:  [3, 2]  ← 恢复,当前最小值是 2

getMin() → 2
class MinStack {
    // 主栈:存储所有元素
    private Deque<Integer> stack = new ArrayDeque<>();
    // 辅助栈:存储每个状态下的最小值
    private Deque<Integer> minStack = new ArrayDeque<>();

    public MinStack() {
        // 构造函数:不需要初始化,Deque 已经初始化
    }
    
    public void push(int val) {
        // 将元素入主栈
        stack.push(val);
        
        // 将当前最小值入辅助栈
        // 如果 minStack 为空,当前元素就是最小值
        // 否则,取新元素和当前最小值的较小者
        if (minStack.isEmpty()) {
            minStack.push(val);
        } else {
            minStack.push(Math.min(val, minStack.peek()));
        }
    }
    
    public void pop() {
        // 同步弹出两个栈
        // 主栈弹出元素,辅助栈弹出对应的最小值
        stack.pop();
        minStack.pop();
    }
    
    public int top() {
        // 返回主栈的栈顶元素(不移除)
        return stack.peek();
    }
    
    public int getMin() {
        // 返回辅助栈的栈顶元素,即当前状态下的最小值
        return minStack.peek();
    }
}

/**
 * Your MinStack object will be instantiated and called as such:
 * MinStack obj = new MinStack();
 * obj.push(val);
 * obj.pop();
 * int param_3 = obj.top();
 * int param_4 = obj.getMin();
 */

// time: O(1) 所有操作都是 O(1)
// space: O(n), 需要额外的 minStack 存储最小值

其他解法对比

方法二:存储差值(空间优化,但实现复杂)

// 使用一个栈存储 (val - min) 的差值,用一个变量存储当前最小值
// 空间复杂度仍然是 O(n),但常数更小
// 实现复杂,容易出错,不推荐

方法对比

方法时间复杂度空间复杂度特点
辅助栈(你的方法)O(1)O(n)✅ 推荐,实现简单,思路清晰
存储差值O(1)O(n)空间常数更小,但实现复杂,容易出错

关键要点

  • 辅助栈同步minStackstack 同步操作,保持相同大小
  • 存储当前最小值:每次 push 时,minStack 存储的是"包含当前元素在内的最小值"
  • O(1) 操作:所有操作都是 O(1),符合题目要求
  • 实现简单:思路直观,代码易维护

复杂度分析

  • 时间复杂度:O(1),所有操作(pushpoptopgetMin)都是 O(1)
  • 空间复杂度:O(n),需要额外的 minStack 存储 n 个最小值

设计模式

  • 辅助数据结构:使用额外的数据结构(minStack)来支持高效操作
  • 同步维护:主栈和辅助栈同步操作,保证状态一致

152. 乘积最大子数组

LT.152. Maximum Product Subarray

这道题的核心思想是使用动态规划,同时维护最大值和最小值,因为负数会将最小值变成最大值。

思考过程

  1. 问题理解:找到连续子数组,使得乘积最大
  2. 关键挑战:负数会让乘积的符号翻转,最小值可能在下一次遇到负数时变成最大值
  3. 解决方案:同时维护以当前位置结尾的最大乘积和最小乘积

核心思想 - 为什么需要同时跟踪 min 和 max?

关键洞察:乘法与加法不同,符号会影响结果。

  • 当遇到正数时:max = max * nummin = min * num
  • 当遇到负数时:max = min * num(负数让最小值变最大值),min = max * num(负数让最大值变最小值)
  • 因此,我们需要同时维护两个状态,并在每一步都考虑所有可能性

状态定义

  • max:以当前位置结尾的最大乘积
  • min:以当前位置结尾的最小乘积

状态转移方程: 对于位置 i,考虑三种情况:

  1. 从当前位置重新开始:num
  2. 继承之前最大值并继续:prevMax * num
  3. 继承之前最小值并继续:prevMin * num(负数时这个会成为最大值)
max[i] = max(num[i], prevMax * num[i], prevMin * num[i])
min[i] = min(num[i], prevMax * num[i], prevMin * num[i])

可视化示例

输入:nums = [2, 3, -2, 4]

i=0: num=2
  max = 2, min = 2
  res = 2

i=1: num=3
  prevMax = 2, prevMin = 2
  max = max(3, 2*3, 2*3) = max(3, 6, 6) = 6
  min = min(3, 2*3, 2*3) = min(3, 6, 6) = 3
  res = max(6, 2) = 6

i=2: num=-2
  prevMax = 6, prevMin = 3
  max = max(-2, 6*(-2), 3*(-2)) = max(-2, -12, -6) = -2
  min = min(-2, 6*(-2), 3*(-2)) = min(-2, -12, -6) = -12
  res = max(-2, 6) = 6

i=3: num=4
  prevMax = -2, prevMin = -12
  max = max(4, -2*4, -12*4) = max(4, -8, -48) = 4
  min = min(4, -2*4, -12*4) = min(4, -8, -48) = -48
  res = max(4, 6) = 6

最终结果:6(来自子数组 [2, 3])

另一个例子(展示负数的作用)

输入:nums = [-2, 3, -4]

i=0: num=-2
  max = -2, min = -2
  res = -2

i=1: num=3
  prevMax = -2, prevMin = -2
  max = max(3, -2*3, -2*3) = max(3, -6, -6) = 3
  min = min(3, -2*3, -2*3) = min(3, -6, -6) = -6
  res = max(3, -2) = 3

i=2: num=-4
  prevMax = 3, prevMin = -6
  max = max(-4, 3*(-4), -6*(-4)) = max(-4, -12, 24) = 24 ✓
  min = min(-4, 3*(-4), -6*(-4)) = min(-4, -12, 24) = -12
  res = max(24, 3) = 24

最终结果:24(来自子数组 [3, -4],但实际上是 [-2, 3, -4] 全部)
注意:当遇到 -4 时,之前的最小值 -6 乘以 -4 变成了最大值 24
class Solution {
    public int maxProduct(int[] nums) {
        // 初始化:第一个元素的状态
        int min = nums[0];  // 以当前位置结尾的最小乘积
        int max = nums[0];  // 以当前位置结尾的最大乘积
        int res = nums[0];  // 全局最大乘积

        for (int i = 1; i < nums.length; i++) {
            int num = nums[i];
            // 保存前一个状态(因为在计算新状态时会被覆盖)
            int prevMin = min;
            int prevMax = max;
            
            // 更新最大值:考虑三种情况
            // 1. 从当前位置重新开始:num
            // 2. 继承之前最大值:prevMax * num
            // 3. 继承之前最小值:prevMin * num(负数时这个可能成为最大值)
            max = Math.max(num, Math.max(prevMin * num, prevMax * num));
            
            // 更新最小值:同样考虑三种情况
            // 1. 从当前位置重新开始:num
            // 2. 继承之前最大值:prevMax * num(负数时这个可能成为最小值)
            // 3. 继承之前最小值:prevMin * num
            min = Math.min(num, Math.min(prevMin * num, prevMax * num));
            
            // 更新全局最大值
            res = Math.max(max, res);
        }

        return res;
    }
}

// time: O(n), 一次遍历数组
// space: O(1), 只使用了常数额外空间

其他解法对比

方法二:暴力法(不推荐)

// 枚举所有子数组,计算乘积
// time: O(n²), space: O(1)
// 超时

方法三:DP 数组版本(更直观但空间更差)

class Solution {
    public int maxProduct(int[] nums) {
        int n = nums.length;
        int[] maxDp = new int[n];  // 最大乘积数组
        int[] minDp = new int[n];  // 最小乘积数组
        
        maxDp[0] = nums[0];
        minDp[0] = nums[0];
        int res = nums[0];
        
        for (int i = 1; i < n; i++) {
            maxDp[i] = Math.max(nums[i], Math.max(maxDp[i-1] * nums[i], minDp[i-1] * nums[i]));
            minDp[i] = Math.min(nums[i], Math.min(maxDp[i-1] * nums[i], minDp[i-1] * nums[i]));
            res = Math.max(res, maxDp[i]);
        }
        
        return res;
    }
}
// time: O(n)
// space: O(n), 需要两个数组

方法对比

方法时间复杂度空间复杂度特点
你的方法(DP + 变量)O(n)O(1)✅ 最优,空间优化
DP 数组版本O(n)O(n)更直观,但需要额外空间
暴力法O(n²)O(1)❌ 超时

关键要点

  • 双状态维护:同时跟踪 maxmin,因为负数会让符号翻转
  • 三种选择:每个位置可以选择重新开始、继承最大值或继承最小值
  • 空间优化:只使用两个变量,不需要 DP 数组
  • 边界处理:从第一个元素开始初始化

为什么不能像最大子数组和那样只维护 max?

最大子数组和(Kadane's Algorithm)可以只维护最大值,因为:

  • 如果当前和变负,就重新开始:curSum = max(nums[i], curSum + nums[i])

但乘积不同:

  • 负数会让符号翻转:-2 * -4 = 8(两个负数相乘变正数)
  • 当前的最小值在遇到下一个负数时可能成为最大值
  • 因此必须同时维护两个状态

复杂度分析

  • 时间复杂度:O(n),一次遍历数组
  • 空间复杂度:O(1),只使用了常数额外空间(minmaxres 等变量)

148. 排序链表

LT.148. Sort List

这道题的核心思想是使用归并排序(Merge Sort),结合快慢指针找到链表中点,然后递归地排序左右两部分,最后合并。

思考过程

  1. 分治思想:将链表分成两半,分别排序,然后合并
  2. 找中点:使用快慢指针找到链表中点
  3. 递归排序:对左右两部分递归调用 sortList
  4. 合并有序链表:使用双指针合并两个有序链表

核心思想

  • 归并排序:分而治之,先分后合
  • 快慢指针找中点:fast 走两步,slow 走一步,fast 到达末尾时 slow 在中点
  • 合并两个有序链表:使用 dummy 节点简化边界处理

快慢指针找中点的两种模式

为什么需要正确找中点?

在归并排序中,正确分割链表至关重要:

  • 如果分割不均匀,可能导致栈溢出(递归深度过大)
  • 如果分割点选择错误,可能导致无限递归或排序错误

Pattern A:左中点模式(你的实现)

slow = head
fast = head

while (fast.next != null && fast.next.next != null) {
    slow = slow.next
    fast = fast.next.next
}
mid = slow.next
slow.next = null

特点

  • slow 停在前半部分的最后一个节点(左中点)
  • mid = slow.next后半部分的起始节点
  • 分割后:[head, slow][mid, tail]

示例分析

偶数长度(4 个节点):1 -> 2 -> 3 -> 4 -> NULL

初始:
slow = 1, fast = 1

第1步:fast.next = 2, fast.next.next = 3(存在)
slow = 2, fast = 3

第2步:fast.next = 4, fast.next.next = NULL(不存在,退出)
slow = 2(前半部分最后一个节点)
mid = slow.next = 3(后半部分起始节点)

分割结果:
左半部分:1 -> 2 -> NULL
右半部分:3 -> 4 -> NULL
奇数长度(5 个节点):1 -> 2 -> 3 -> 4 -> 5 -> NULL

初始:
slow = 1, fast = 1

第1步:fast.next = 2, fast.next.next = 3(存在)
slow = 2, fast = 3

第2步:fast.next = 4, fast.next.next = 5(存在)
slow = 3, fast = 5

第3步:fast.next = NULL(不存在,退出)
slow = 3(前半部分最后一个节点,也是中间节点)
mid = slow.next = 4(后半部分起始节点)

分割结果:
左半部分:1 -> 2 -> 3 -> NULL
右半部分:4 -> 5 -> NULL

Pattern B:右中点模式

slow = head
fast = head.next

while (fast != null && fast.next != null) {
    slow = slow.next
    fast = fast.next.next
}
mid = slow.next
slow.next = null

特点

  • slow 停在前半部分的最后一个节点(右中点)
  • mid = slow.next后半部分的起始节点
  • 分割后:[head, slow][mid, tail]

示例分析

偶数长度(4 个节点):1 -> 2 -> 3 -> 4 -> NULL

初始:
slow = 1, fast = 2

第1步:fast = 2, fast.next = 3(存在)
slow = 2, fast = 4

第2步:fast = 4, fast.next = NULL(不存在,退出)
slow = 2(前半部分最后一个节点)
mid = slow.next = 3(后半部分起始节点)

分割结果:
左半部分:1 -> 2 -> NULL
右半部分:3 -> 4 -> NULL
奇数长度(5 个节点):1 -> 2 -> 3 -> 4 -> 5 -> NULL

初始:
slow = 1, fast = 2

第1步:fast = 2, fast.next = 3(存在)
slow = 2, fast = 4

第2步:fast = 4, fast.next = 5(存在)
slow = 3, fast = 5.next = NULL
(注意:fast.next.next = 5.next = NULL,所以 fast 变成 NULL)

第3步:检查条件 fast != null && fast.next != null
fast = NULL(退出)
slow = 3(前半部分最后一个节点)
mid = slow.next = 4(后半部分起始节点)

分割结果:
左半部分:1 -> 2 -> 3 -> NULL
右半部分:4 -> 5 -> NULL

重要发现:对于奇数长度,两种模式结果相同!

让我用一个更明显的例子来展示差异:

两种模式的实际差异

经过仔细分析,我发现:

  1. 偶数长度时,两种模式结果相同

    • 4 个节点:都是 [1,2][3,4]
    • 6 个节点:都是 [1,2,3][4,5,6]
  2. 奇数长度时,两种模式结果也相同

    • 5 个节点:都是 [1,2,3][4,5]
    • 7 个节点:都是 [1,2,3,4][5,6,7]

为什么看起来相同?

两种模式虽然初始位置和循环条件不同,但最终 slow 都会停在前半部分的最后一个节点,所以分割结果相同。

真正的差异在哪里?

差异在于执行过程适用场景

模式fast 初始位置循环条件特点适用场景
Pattern Aheadfast.next != null && fast.next.next != nullfast 停在最后一个或倒数第二个节点✅ 归并排序(你的实现)
Pattern Bhead.nextfast != null && fast.next != nullfast 可能停在 NULL✅ 回文链表检测

为什么 Pattern A 更适合归并排序?

  1. 更安全的边界检查fast.next.next 的检查确保 fast 能安全地走两步,避免空指针异常
  2. 代码更清晰:循环条件更直观,容易理解
  3. 实际效果相同:两种模式的分割结果相同,但 Pattern A 的边界处理更安全

关键点总结

  • 两种模式都安全:都能正确找到中点并分割链表
  • Pattern A 更适合归并排序:分割更均匀,递归更平衡
  • 分割后必须断开slow.next = null 是关键,否则会导致无限递归
class Solution {
    public ListNode sortList(ListNode head) {
        // 边界情况:空链表或只有一个节点,已经有序
        if (head == null || head.next == null) return head;
        
        // 使用快慢指针找到链表中点(Pattern A:左中点模式)
        ListNode slow = head;
        ListNode fast = head;
        // 循环条件:确保 fast 能走两步
        while (fast.next != null && fast.next.next != null) {
            slow = slow.next;      // slow 走一步
            fast = fast.next.next; // fast 走两步
        }
        // slow 停在前半部分的最后一个节点
        ListNode mid = slow.next;  // mid 是后半部分的起始节点
        slow.next = null;          // 断开链表,分成两部分
        
        // 递归排序左右两部分
        ListNode left = sortList(head);   // 排序左半部分 [head, slow]
        ListNode right = sortList(mid);   // 排序右半部分 [mid, tail]
        
        // 合并两个有序链表
        return merge(left, right);
    }

    // 合并两个有序链表
    private ListNode merge(ListNode l1, ListNode l2) {
        // 使用 dummy 节点简化边界处理
        ListNode dummy = new ListNode(0);
        ListNode cur = dummy;
        
        // 双指针合并:选择较小的节点加入结果链表
        while (l1 != null && l2 != null) {
            if (l1.val <= l2.val) {
                cur.next = l1;
                l1 = l1.next;
            } else {
                cur.next = l2;
                l2 = l2.next;
            }
            cur = cur.next;
        }
        
        // 将剩余部分连接到结果链表
        cur.next = l1 != null ? l1 : l2;
        
        return dummy.next;
    }
}
/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */

// time: O(n log n), 归并排序的时间复杂度
// space: O(log n), 递归栈的深度(平衡分割时)

执行过程可视化

输入:4 -> 2 -> 1 -> 3 -> NULL

第1层递归:
  找中点:slow = 2, mid = 1
  分割:left = [4, 2], right = [1, 3]
  
  递归左半部分 [4, 2]:
    找中点:slow = 4, mid = 2
    分割:left = [4], right = [2]
    合并:[2, 4]
  
  递归右半部分 [1, 3]:
    找中点:slow = 1, mid = 3
    分割:left = [1], right = [3]
    合并:[1, 3]
  
  合并 [2, 4] 和 [1, 3]:
    结果:[1, 2, 3, 4]

其他解法对比

方法二:转换为数组排序(不推荐,不符合要求)

// 将链表转换为数组,排序后再转回链表
// time: O(n log n)
// space: O(n), 需要额外数组空间
// 不符合 O(1) 空间复杂度的要求

方法对比

方法时间复杂度空间复杂度特点
归并排序(你的方法)O(n log n)O(log n)✅ 最优,符合要求
转换为数组O(n log n)O(n)❌ 需要额外空间

关键要点

  • 快慢指针找中点:Pattern A 更适合归并排序,分割更均匀
  • 必须断开链表slow.next = null 是关键,否则会导致无限递归
  • 递归终止条件:空链表或只有一个节点时直接返回
  • 合并有序链表:使用 dummy 节点简化边界处理

复杂度分析

  • 时间复杂度:O(n log n),归并排序的标准时间复杂度
  • 空间复杂度:O(log n),递归栈的深度(平衡分割时,最坏情况 O(n))

146. LRU缓存

LT.146. LRU Cache

这道题的核心思想是使用哈希表 + 双向链表来实现 LRU(Least Recently Used)缓存。哈希表提供 O(1) 的查找,双向链表提供 O(1) 的插入和删除。

思考过程

  1. 数据结构选择
    • HashMap:存储 key 到 Node 的映射,O(1) 查找
    • 双向链表:维护访问顺序,最近访问的在头部,最久未访问的在尾部
    • Dummy 节点:使用 headtail 作为哨兵节点,简化边界处理
  2. 核心操作
    • get(key):如果存在,移动到头部(标记为最近使用)
    • put(key, value):如果存在则更新并移动到头部;如果不存在则添加到头部,如果容量超限则删除尾部节点

核心思想

  • 最近访问在头部:每次访问或更新节点时,都将其移动到链表头部
  • 最久未访问在尾部:当容量满时,删除尾部节点
  • 双向链表:支持 O(1) 的节点删除和插入(需要知道前驱节点)

双向链表操作详解

数据结构示意图

head (dummy) <-> node1 <-> node2 <-> node3 <-> tail (dummy)

每个节点有两个指针:prev(前驱)和 next(后继)

操作一:addToHead(node) - 将节点添加到头部

目标:将新节点插入到 head 之后,成为第一个实际节点

步骤详解

初始状态:
head <-> node1 <-> node2 <-> tail
      要插入的 node

步骤1:设置 node 的 prev 指向 head
node.prev = head
head <-> node  (node.prev = head)
        <-> node1 <-> node2 <-> tail

步骤2:设置 node 的 next 指向 head.next(即原来的第一个节点)
node.next = head.next
head <-> node -> node1 <-> node2 <-> tail
        <-       ↑
        (node.prev = head, node.next = node1)

步骤3:让 node1 的 prev 指向 node
head.next.prev = node
head <-> node <-> node1 <-> node2 <-> tail
        <-      <-      <- 

步骤4:让 head 的 next 指向 node
head.next = node
head -> node <-> node1 <-> node2 <-> tail
     <-      <-      <- 

最终状态:
head <-> node <-> node1 <-> node2 <-> tail

代码对应

private void addToHead(Node node) {
    node.prev = head;              // 步骤1
    node.next = head.next;         // 步骤2
    head.next.prev = node;         // 步骤3
    head.next = node;              // 步骤4
}

操作二:removeNode(node) - 从链表中删除节点

目标:将节点从链表中移除,但不删除节点本身

步骤详解

初始状态:
head <-> node1 <-> node <-> node2 <-> tail
                要删除的 node

步骤1:让 node 的前驱节点的 next 指向 node 的后继节点
node.prev.next = node.next
head <-> node1 -> node -> node2 <-> tail
        <-       <-      <-
(node1.next 现在指向 node2,跳过了 node)

步骤2:让 node 的后继节点的 prev 指向 node 的前驱节点
node.next.prev = node.prev
head <-> node1 <-> node2 <-> tail
        <-      <-
(node2.prev 现在指向 node1)

最终状态(node 已经从链表中移除):
head <-> node1 <-> node2 <-> tail

注意:node 的 prev 和 next 指针仍然保留,但不再在链表中

代码对应

private void removeNode(Node node) {
    node.prev.next = node.next;    // 步骤1
    node.next.prev = node.prev;    // 步骤2
}

操作三:moveToHead(node) - 将节点移动到头部

目标:先将节点从当前位置移除,然后添加到头部

步骤详解

初始状态:
head <-> node1 <-> node <-> node2 <-> tail
                要移动的 node

步骤1:removeNode(node) - 从当前位置移除
head <-> node1 <-> node2 <-> tail
                node(已脱离链表,但 prev 和 next 仍然保留)

步骤2:addToHead(node) - 添加到头部
head <-> node <-> node1 <-> node2 <-> tail
     <-      <-      <-

最终状态:
head <-> node <-> node1 <-> node2 <-> tail

代码对应

private void moveToHead(Node node) {
    removeNode(node);    // 步骤1:移除
    addToHead(node);     // 步骤2:添加到头部
}

操作四:removeTail() - 删除尾部节点

目标:删除最后一个实际节点(tail.prev),并返回该节点

步骤详解

初始状态:
head <-> node1 <-> node2 <-> node3 <-> tail
                                  要删除的节点(tail.prev)

步骤1:获取尾部节点
Node node = tail.prev
node 指向 node3

步骤2:removeNode(node) - 移除节点
head <-> node1 <-> node2 <-> tail
                       node3(已移除)

步骤3:返回节点(用于从 HashMap 中删除对应的 key)
return node

最终状态:
head <-> node1 <-> node2 <-> tail

代码对应

private Node removeTail() {
    Node node = tail.prev;        // 步骤1:获取尾部节点
    removeNode(node);             // 步骤2:移除
    return node;                  // 步骤3:返回
}

完整操作序列示例

容量 capacity = 2

初始状态:
head <-> tail
HashMap: {}

操作1:put(1, 10)
----------------------------------------
addToHead(new Node(1, 10)):
head <-> [1,10] <-> tail
HashMap: {1 -> [1,10]}

操作2:put(2, 20)
----------------------------------------
addToHead(new Node(2, 20)):
head <-> [2,20] <-> [1,10] <-> tail
HashMap: {1 -> [1,10], 2 -> [2,20]}

操作3:get(1)
----------------------------------------
从 HashMap 找到 [1,10]
moveToHead([1,10]):
  1. removeNode([1,10]):
     head <-> [2,20] <-> tail
  2. addToHead([1,10]):
     head <-> [1,10] <-> [2,20] <-> tail
HashMap: {1 -> [1,10], 2 -> [2,20]}
返回:10

操作4:put(3, 30)
----------------------------------------
HashMap 中没有 3,需要添加
addToHead(new Node(3, 30)):
head <-> [3,30] <-> [1,10] <-> [2,20] <-> tail
HashMap: {1 -> [1,10], 2 -> [2,20], 3 -> [3,30]}

容量超限(size = 3 > capacity = 2):
removeTail() -> [2,20]
head <-> [3,30] <-> [1,10] <-> tail
HashMap.remove(2)
HashMap: {1 -> [1,10], 3 -> [3,30]}

最终状态:
head <-> [3,30] <-> [1,10] <-> tail
HashMap: {1 -> [1,10], 3 -> [3,30]}
class LRUCache {

    private final int capacity;              // 缓存容量
    private final Map<Integer, Node> cache;  // HashMap:key -> Node 映射,O(1) 查找
    private final Node head;                 // 哨兵节点:链表的虚拟头节点
    private final Node tail;                 // 哨兵节点:链表的虚拟尾节点

    public LRUCache(int capacity) {
        this.capacity = capacity;
        cache = new HashMap<>();
        // 创建两个哨兵节点,简化边界处理
        head = new Node();
        tail = new Node();
        // 初始化:head 和 tail 互相指向
        head.next = tail;
        tail.prev = head;
    }
    
    public int get(int key) {
        Node node = cache.get(key);
        if (node == null) {
            // key 不存在,返回 -1
            return -1;
        }
        // key 存在,将其移动到头部(标记为最近使用)
        moveToHead(node);
        return node.value;
    }
    
    public void put(int key, int value) {
        Node node = cache.get(key);
        if (node != null) {
            // key 已存在,更新值并移动到头部
            node.value = value;
            moveToHead(node);
        } else {
            // key 不存在,创建新节点并添加到头部
            node = new Node(key, value);
            addToHead(node);
            cache.put(key, node);

            // 如果容量超限,删除尾部节点(最久未使用)
            if (cache.size() > capacity) {
                Node removed = removeTail();
                cache.remove(removed.key);  // 从 HashMap 中删除对应的 key
            }
        }
    }

    // 将节点添加到头部(head 之后)
    // 步骤:1. node.prev = head
    //      2. node.next = head.next
    //      3. head.next.prev = node
    //      4. head.next = node
    private void addToHead(Node node) {
        node.prev = head;              // 步骤1:设置 node 的前驱
        node.next = head.next;         // 步骤2:设置 node 的后继
        head.next.prev = node;         // 步骤3:更新原第一个节点的前驱
        head.next = node;              // 步骤4:更新 head 的后继
    }

    // 从链表中移除节点(但不删除节点本身)
    // 步骤:1. node.prev.next = node.next
    //      2. node.next.prev = node.prev
    private void removeNode(Node node) {
        node.prev.next = node.next;    // 步骤1:前驱指向后继
        node.next.prev = node.prev;    // 步骤2:后继指向前驱
    }

    // 将节点移动到头部
    // 先移除,再添加到头部
    private void moveToHead(Node node) {
        removeNode(node);    // 从当前位置移除
        addToHead(node);     // 添加到头部
    }

    // 删除尾部节点(最久未使用的节点)
    // 返回被删除的节点,用于从 HashMap 中删除对应的 key
    private Node removeTail() {
        Node node = tail.prev;  // 尾部节点是 tail.prev
        removeNode(node);       // 移除节点
        return node;            // 返回节点(用于删除 HashMap 中的 key)
    }

    // 双向链表节点
    static class Node {
        int key;       // 存储 key,用于从 HashMap 中删除
        int value;     // 存储 value
        Node prev;     // 前驱节点
        Node next;     // 后继节点

        Node() {}  // 无参构造(用于创建哨兵节点)
        Node(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }
}

/**
 * Your LRUCache object will be instantiated and called as such:
 * LRUCache obj = new LRUCache(capacity);
 * int param_1 = obj.get(key);
 * obj.put(key,value);
 */

// time: O(1), 所有操作都是 O(1)
// space: O(capacity), HashMap 和双向链表都最多存储 capacity 个节点

关键要点

  • 双向链表:支持 O(1) 的节点删除(需要知道前驱节点)
  • 哨兵节点headtail 简化边界处理,避免空指针检查
  • HashMap 存储 key:Node 中存储 key,用于从 HashMap 中删除
  • 移动操作moveToHead = removeNode + addToHead

复杂度分析

  • 时间复杂度:O(1),所有操作(getputaddToHeadremoveNodemoveToHeadremoveTail)都是 O(1)
  • 空间复杂度:O(capacity),HashMap 和双向链表都最多存储 capacity 个节点

142. 环形链表II

LT.142. Linked List Cycle II

这道题的核心思想是使用Floyd 判圈算法(Floyd Cycle Detection Algorithm),也称为"龟兔赛跑算法"。算法分为两个阶段:第一阶段检测是否有环,第二阶段找到环的入口。

思考过程

  1. 阶段一:检测环:使用快慢指针,slow 每次走 1 步,fast 每次走 2 步
    • 如果链表中没有环:fast 会先走到 null,不会相遇
    • 如果链表中有环:fast 必然会追上 slow,最终两者会相遇
  2. 阶段二:寻找环入口:当两个指针相遇后,从 head 和相遇点分别出发,每次各走 1 步,最终会在环入口相遇

核心思想 - 数学证明

假设:

  • L = 从 head 到环入口的距离
  • C = 环的长度
  • x = 从环入口到相遇点的距离

当 slow 和 fast 相遇时:

  • slow 走的距离:L + x
  • fast 走的距离:L + x + nC(n 是 fast 在环中多走的圈数)

由于 fast 的速度是 slow 的 2 倍:

2(L + x) = L + x + nC
2L + 2x = L + x + nC
L + x = nC
L = nC - x = (n-1)C + (C - x)

这意味着:

  • 从 head 到环入口的距离 = 从相遇点到环入口的距离(可能加上整数倍的环长)
  • 因此,从 head 和相遇点同时出发,每次各走 1 步,最终会在环入口相遇

可视化示例

  graph TB
    H[head] --> N1[节点1]
    N1 --> N2[节点2]
    N2 --> E[环入口]
    E --> N3[节点3]
    N3 --> N4[节点4]
    N4 --> N5[节点5]
    N5 --> E
    
    style H fill:#e1f5ff
    style E fill:#ffcccc
    style N1 fill:#fff4e6
    style N2 fill:#fff4e6
    style N3 fill:#fff4e6
    style N4 fill:#fff4e6
    style N5 fill:#fff4e6

阶段一:检测环(快慢指针相遇)

初始状态:slow 和 fast 都在 head

  graph TB
    H[head] --> N1[节点1]
    N1 --> N2[节点2]
    N2 --> E[环入口]
    E --> N3[节点3]
    N3 --> N4[节点4]
    N4 --> N5[节点5]
    N5 --> E
    
    S[slow = head]
    F[fast = head]
    
    style H fill:#e1f5ff
    style E fill:#ffcccc
    style S fill:#ccffcc
    style F fill:#ccffcc

相遇点:slow 和 fast 在环中相遇

  graph TB
    H1[head] --> N1[节点1]
    N1 --> N2[节点2]
    N2 --> E1[环入口]
    E1 --> N3[节点3]
    N3 --> MEET[相遇点]
    MEET --> N4[节点5]
    N4 --> E1
    
    style MEET fill:#ffcccc
    style E1 fill:#ffcccc

阶段二:寻找环入口(两个指针从 head 和相遇点出发)

阶段二开始:p1 从 head 出发,p2 从相遇点出发

  graph TB
    H[head] --> N1[节点1]
    N1 --> N2[节点2]
    N2 --> E[环入口]
    E --> N3[节点3]
    N3 --> MEET[相遇点]
    MEET --> N4[节点5]
    N4 --> E
    
    P1[p1 = head]
    P2[p2 = 相遇点]
    
    style E fill:#ffcccc
    style MEET fill:#fff4e6
    style P1 fill:#ccffcc
    style P2 fill:#ccffcc

最终相遇在环入口:p1 和 p2 在环入口相遇

  graph TB
    H1[head] --> N1[节点1]
    N1 --> N2[节点2]
    N2 --> E[环入口]
    E --> N3[节点3]
    N3 --> MEET1[相遇点]
    MEET1 --> N4[节点5]
    N4 --> E
    
    P3[p1 和 p2<br/>在环入口相遇]
    
    style E fill:#ffcccc
    style P3 fill:#ccffcc
public class Solution {
    public ListNode detectCycle(ListNode head) {
        ListNode slow = head;
        ListNode fast = head;

        // 阶段一:检测是否有环
        while (fast != null && fast.next != null) {
            slow = slow.next;        // slow 每次走 1 步
            fast = fast.next.next;   // fast 每次走 2 步

            // 如果 slow 和 fast 相遇,说明存在环
            if (slow == fast) {
                // 阶段二:寻找环入口
                // 从 head 和相遇点分别出发,每次各走 1 步
                ListNode p1 = head;
                ListNode p2 = fast;  // p2 从相遇点出发
                
                // 当 p1 和 p2 相遇时,就是环的入口
                while (p1 != p2) {
                    p1 = p1.next;
                    p2 = p2.next;
                }
                return p1; // 返回环入口
            }
        }

        // 如果 fast 走到 null,说明没有环
        return null;
    }
}
/**
 * Definition for singly-linked list.
 * class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) {
 *         val = x;
 *         next = null;
 *     }
 * }
 */

// time: O(n), n 是链表长度
// space: O(1), 只使用了常数额外空间

执行过程可视化

示例链表:head -> 1 -> 2 -> 3 -> 4 -> 5 -> 3 (形成环)
                         ↑________________|

阶段一:检测环
----------------------------------------------------------
初始:slow = head, fast = head

第1步:slow = 1, fast = 2
第2步:slow = 2, fast = 4
第3步:slow = 3, fast = 3 (相遇!)

相遇点在节点 3,说明存在环

阶段二:寻找环入口
----------------------------------------------------------
初始:p1 = head, p2 = 相遇点(节点3)

第1步:p1 = 1, p2 = 4
第2步:p1 = 2, p2 = 5
第3步:p1 = 3, p2 = 3 (相遇!)

p1 和 p2 在节点 3 相遇,节点 3 就是环入口
返回节点 3

关键要点

  • Floyd 判圈算法:使用快慢指针检测环
  • 两阶段算法:先检测环,再找入口
  • 数学证明:L = nC - x,从 head 到入口的距离 = 从相遇点到入口的距离
  • 时间复杂度:O(n),每个节点最多被访问两次

复杂度分析

  • 时间复杂度:O(n),最坏情况下需要遍历整个链表
  • 空间复杂度:O(1),只使用了常数额外空间(两个指针)

139. 单词拆分

LT.139. Word Break

这道题的核心思想是使用动态规划,判断字符串 s 是否能被 wordDict 中的单词完全拆分。

思考过程

  1. 问题理解:判断字符串 s 是否能被拆分成 wordDict 中的单词
  2. 子问题分解:如果 s[0..i] 能被拆分,那么 s[0..j] 也能被拆分(j < i),且 s[j..i] 是一个单词
  3. DP 状态定义dp[i] 表示 s[0..i-1](前 i 个字符)是否能被拆分

核心思想 - DP 状态转移

DP 状态定义

  • dp[i] = true 表示字符串 s 的前 i 个字符(即 s[0..i-1])可以被 wordDict 中的单词拆分
  • dp[0] = true 表示空字符串可以被拆分(基础情况)

索引含义

  • i:当前考虑的位置,表示前 i 个字符(s[0..i-1]
    • i 的范围:1n(包含)
    • dp[i] 表示 s[0..i-1] 是否能被拆分
  • j:分割点,表示前 j 个字符(s[0..j-1])已经被拆分
    • j 的范围:0i-1
    • dp[j] 表示 s[0..j-1] 是否能被拆分
    • s.substring(j, i) 表示从位置 ji-1 的子串

状态转移方程

dp[i] = true 当且仅当存在 j (0 <= j < i) 使得:
  - dp[j] == true(前 j 个字符可以被拆分)
  - s.substring(j, i) 在 wordDict 中(剩余部分是一个单词)

可视化示例

示例:s = "leetcode", wordDict = ["leet", "code"]

字符串索引: 0  1  2  3  4  5  6  7
            l  e  e  t  c  o  d  e
           [0][1][2][3][4][5][6][7]

DP 数组索引:0  1  2  3  4  5  6  7  8
            [空][l][le][lee][leet][leetc][leetco][leetcod][leetcode]

初始状态:
dp[0] = true  (空字符串可以被拆分)

i=1: 检查 s[0..0] = "l"
  j=0: dp[0]=true, s[0..0]="l" 不在字典中
  dp[1] = false

i=2: 检查 s[0..1] = "le"
  j=0: dp[0]=true, s[0..1]="le" 不在字典中
  j=1: dp[1]=false, 跳过
  dp[2] = false

i=3: 检查 s[0..2] = "lee"
  j=0: dp[0]=true, s[0..2]="lee" 不在字典中
  j=1,2: dp[1]=false, dp[2]=false, 跳过
  dp[3] = false

i=4: 检查 s[0..3] = "leet"
  j=0: dp[0]=true, s[0..3]="leet" 在字典中!✓
  dp[4] = true

i=5: 检查 s[0..4] = "leetc"
  j=0: dp[0]=true, s[0..4]="leetc" 不在字典中
  j=1,2,3: dp[1]=false, dp[2]=false, dp[3]=false, 跳过
  j=4: dp[4]=true, s[4..4]="c" 不在字典中
  dp[5] = false

i=6: 检查 s[0..5] = "leetco"
  j=0: dp[0]=true, s[0..5]="leetco" 不在字典中
  j=1,2,3: dp[1]=false, dp[2]=false, dp[3]=false, 跳过
  j=4: dp[4]=true, s[4..5]="co" 不在字典中
  j=5: dp[5]=false, 跳过
  dp[6] = false

i=7: 检查 s[0..6] = "leetcod"
  j=0: dp[0]=true, s[0..6]="leetcod" 不在字典中
  j=1,2,3: dp[1]=false, dp[2]=false, dp[3]=false, 跳过
  j=4: dp[4]=true, s[4..6]="cod" 不在字典中
  j=5,6: dp[5]=false, dp[6]=false, 跳过
  dp[7] = false

i=8: 检查 s[0..7] = "leetcode"
  j=0: dp[0]=true, s[0..7]="leetcode" 不在字典中
  j=1,2,3: dp[1]=false, dp[2]=false, dp[3]=false, 跳过
  j=4: dp[4]=true, s[4..7]="code" 在字典中!✓
  dp[8] = true

最终结果:dp[8] = true,字符串可以被拆分

关键理解

  • i 表示位置dp[i] 表示前 i 个字符(s[0..i-1])是否能被拆分
  • j 表示分割点:尝试所有可能的分割点 j,检查:
    • j 个字符是否能被拆分(dp[j] == true
    • ji-1 的子串是否是一个单词(s.substring(j, i) 在字典中)
  • 一旦找到有效的分割点,就可以 break:因为只需要知道能否拆分,不需要找到所有拆分方式
class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        int n = s.length();
        // 使用 HashSet 提高查找效率,O(1) 查找
        Set<String> words = new HashSet<>(wordDict);
        // dp[i] 表示 s 的前 i 个字符(s[0..i-1])是否能被 wordDict 拆分
        boolean[] dp = new boolean[n + 1];
        // 基础情况:空字符串可以被拆分
        dp[0] = true;

        // i 表示当前考虑的位置,从 1 到 n
        // dp[i] 表示前 i 个字符(s[0..i-1])是否能被拆分
        for (int i = 1; i <= n; i++) {
            // j 表示分割点,尝试所有可能的分割位置(0 到 i-1)
            // 检查前 j 个字符是否能被拆分,且 s[j..i-1] 是否是一个单词
            for (int j = 0; j < i; j++) {
                // 如果前 j 个字符能被拆分,且 s[j..i-1] 在字典中
                if (dp[j] && words.contains(s.substring(j, i))) {
                    dp[i] = true;
                    // 找到一种拆分方式即可,提前退出
                    break;
                }
            }
        }

        // dp[n] 表示整个字符串 s 是否能被拆分
        return dp[n];
    }
}

// time: O(n²), n 是字符串长度,外层循环 n 次,内层循环最多 n 次
// space: O(n), dp 数组的空间,加上 HashSet 的空间 O(m),m 是字典大小

执行过程可视化(简化版)

s = "leetcode", wordDict = ["leet", "code"]

i=1: 检查 "l"
  j=0: dp[0]=true, "l" 不在字典 → dp[1]=false

i=2: 检查 "le"
  j=0: dp[0]=true, "le" 不在字典 → dp[2]=false

i=3: 检查 "lee"
  j=0: dp[0]=true, "lee" 不在字典 → dp[3]=false

i=4: 检查 "leet"
  j=0: dp[0]=true, "leet" 在字典 ✓ → dp[4]=true

i=5: 检查 "leetc"
  j=0: dp[0]=true, "leetc" 不在字典
  j=4: dp[4]=true, "c" 不在字典 → dp[5]=false

i=6: 检查 "leetco"
  j=0: dp[0]=true, "leetco" 不在字典
  j=4: dp[4]=true, "co" 不在字典 → dp[6]=false

i=7: 检查 "leetcod"
  j=0: dp[0]=true, "leetcod" 不在字典
  j=4: dp[4]=true, "cod" 不在字典 → dp[7]=false

i=8: 检查 "leetcode"
  j=0: dp[0]=true, "leetcode" 不在字典
  j=4: dp[4]=true, "code" 在字典 ✓ → dp[8]=true

返回:dp[8] = true

关键要点

  • DP 状态dp[i] 表示前 i 个字符是否能被拆分
  • 索引含义
    • i:当前考虑的位置,表示前 i 个字符
    • j:分割点,尝试所有可能的分割位置
  • 状态转移dp[i] = true 当且仅当存在 j 使得 dp[j] == trues[j..i-1] 在字典中
  • 优化:使用 HashSet 提高查找效率,找到有效分割点后 break

复杂度分析

  • 时间复杂度:O(n²),外层循环 n 次,内层循环最多 n 次,substring 操作 O(n)
  • 空间复杂度:O(n + m),dp 数组 O(n),HashSet O(m)(m 是字典大小)

141. 环形链表

LT.141. Linked List Cycle

这道题是问题 142 的简化版本,只需要判断链表中是否存在环,不需要找到环的入口。同样使用Floyd 判圈算法(快慢指针)。

思考过程

  1. 快慢指针:slow 每次走 1 步,fast 每次走 2 步
  2. 检测相遇:如果链表中有环,fast 必然会追上 slow,两者会相遇
  3. 无环情况:如果链表中没有环,fast 会先走到 null

核心思想

  • 有环:fast 和 slow 都会进入环,fast 速度更快,最终会追上 slow
  • 无环:fast 会先到达链表末尾(null),不会相遇

可视化示例

情况一:有环

初始状态:slow 和 fast 都在 head

  graph TB
    H[head] --> N1[节点1]
    N1 --> N2[节点2]
    N2 --> N3[节点3]
    N3 --> N4[节点4]
    N4 --> N5[节点5]
    N5 --> N3
    
    S[slow = head]
    F[fast = head]
    
    style S fill:#ccffcc
    style F fill:#ccffcc

相遇点:slow 和 fast 在环中相遇,返回 true

  graph TB
    H1[head] --> N1[节点1]
    N1 --> N2[节点2]
    N2 --> N3[节点3]
    N3 --> MEET[相遇点]
    MEET --> N4[节点5]
    N4 --> N3
    
    RESULT[返回 true]
    
    style MEET fill:#ffcccc
    style RESULT fill:#ccffcc

情况二:无环

初始状态:slow 和 fast 都在 head

  graph TB
    H[head] --> N1[节点1]
    N1 --> N2[节点2]
    N2 --> N3[节点3]
    N3 --> N4[节点4]
    N4 --> NULL[NULL]
    
    S[slow = head]
    F[fast = head]
    
    style S fill:#ccffcc
    style F fill:#ccffcc

fast 到达 NULL:fast 先到达末尾,返回 false

  graph TB
    H1[head] --> N1[节点1]
    N1 --> N2[节点2]
    N2 --> N3[节点3]
    N3 --> N4[节点4]
    N4 --> NULL[NULL]
    
    F[fast = NULL]
    RESULT[返回 false]
    
    style NULL fill:#ffcccc
    style RESULT fill:#ffcccc
public class Solution {
    public boolean hasCycle(ListNode head) {
        ListNode slow = head;
        ListNode fast = head;

        // 使用快慢指针检测环
        while (fast != null && fast.next != null) {
            slow = slow.next;        // slow 每次走 1 步
            fast = fast.next.next;   // fast 每次走 2 步

            // 如果 slow 和 fast 相遇,说明存在环
            if (slow == fast) {
                return true;
            }
        }

        // 如果 fast 走到 null,说明没有环
        return false;
    }
}
/**
 * Definition for singly-linked list.
 * class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) {
 *         val = x;
 *         next = null;
 *     }
 * }
 */

// time: O(n), n 是链表长度
// space: O(1), 只使用了常数额外空间

执行过程可视化

情况一:有环
链表:head -> 1 -> 2 -> 3 -> 4 -> 5 -> 3 (形成环)
                    ↑________________|

初始:slow = head, fast = head

第1步:slow = 1, fast = 2
第2步:slow = 2, fast = 4
第3步:slow = 3, fast = 3 (相遇!)
返回:true

情况二:无环
链表:head -> 1 -> 2 -> 3 -> 4 -> NULL

初始:slow = head, fast = head

第1步:slow = 1, fast = 2
第2步:slow = 2, fast = 4
第3步:slow = 3, fast = NULL (fast.next 为 null,退出循环)
返回:false

其他解法对比

方法二:哈希表(HashSet)

public class Solution {
    public boolean hasCycle(ListNode head) {
        Set<ListNode> visited = new HashSet<>();
        ListNode cur = head;
        
        while (cur != null) {
            if (visited.contains(cur)) {
                return true; // 遇到已访问的节点,说明有环
            }
            visited.add(cur);
            cur = cur.next;
        }
        
        return false; // 遍历完没有重复,说明无环
    }
}
// time: O(n)
// space: O(n), 需要存储所有节点

方法对比

方法时间复杂度空间复杂度特点
快慢指针(你的方法)O(n)O(1)✅ 最优,无需额外空间
哈希表O(n)O(n)需要额外空间存储节点

关键要点

  • Floyd 判圈算法:使用快慢指针检测环
  • 有环必相遇:如果存在环,fast 必然会追上 slow
  • 无环检测:fast 会先到达 null,循环退出
  • 空间优化:O(1) 空间,比哈希表方法更优

复杂度分析

  • 时间复杂度:O(n),最坏情况下需要遍历整个链表
  • 空间复杂度:O(1),只使用了常数额外空间(两个指针)
https://rainyctl.cc/posts/feed.xml