LeetCode Hot 100
引言
LeetCode Hot 100 是 LeetCode 上最热门的 100 道题目,涵盖了算法和数据结构的核心知识点。这份清单作为刷题的参考和进度跟踪。
题目按 Hot 100 的原始顺序排列,每道题标注了难度等级(E=Easy, M=Medium, H=Hard)和主要技术点,方便快速定位和复习。
统计
- 总数: 100 题
- 难度分布:
- Easy: 约 20 题
- Medium: 约 60 题
- Hard: 约 20 题
- 主要技术点: 数组、链表、树、动态规划、回溯、双指针、滑动窗口、栈、队列、图等
题目列表
详细题解
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
/**
* 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。
思考过程:
- 边界情况:如果当前节点为
null,或者当前节点就是p或q,直接返回当前节点 - 递归查找:分别在左子树和右子树中查找 p 和 q
- 判断结果:
- 如果左右子树都找到了节点(
left != null && right != null),说明当前节点就是 LCA(p 和 q 分别在左右子树) - 如果只有一边找到了,说明 p 和 q 都在那一边的子树中,返回那边找到的结果
- 如果两边都没找到,返回
null
- 如果左右子树都找到了节点(
关键洞察:LCA 一定是第一个能让左右子树都返回非 null 的节点。如果只有一边返回非 null,说明 LCA 在那边的子树中;如果两边都返回非 null,当前节点就是 LCA。
/**
* 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
这道题的核心思想是使用快慢指针找到链表中点,然后反转后半部分,再比较前半部分和反转后的后半部分。
思考过程:
- 使用快慢指针找到中点:fast 每次走两步,slow 每次走一步
- 反转后半部分链表:从
slow.next开始反转 - 比较两部分:前半部分和反转后的后半部分逐一比较
关键点:快慢指针在不同长度下的位置:
-
偶数长度(如
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 != null但fast.next.next == null) - 奇数长度时,fast 停在最后一个节点(
fast.next == null)
/**
* 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. 每日温度
这道题的核心思想是使用单调栈(单调递减栈)来找到每个温度右侧第一个比它高的温度。
思考过程:
- 使用单调递减栈:栈中存储索引,从栈底到栈顶对应的温度递减
- 遍历温度数组:对于每个温度,如果它比栈顶索引对应的温度高,说明找到了栈顶元素右侧第一个更高的温度
- 更新答案:计算索引差
i - j,即需要等待的天数 - 维护单调性:弹出所有比当前温度低的栈顶元素,然后压入当前索引
关键洞察:
- 栈中存储的是"等待答案"的索引
- 当遇到更高的温度时,可以为栈中所有比当前温度低的元素提供答案
- 因为栈是单调递减的,所以弹出的顺序是合理的(从栈顶开始)
// time: O(n), 每个元素最多进出栈一次
// space: O(n), 栈的空间最多为 n
226. 翻转二叉树
这道题的核心思想是使用递归自底向上翻转每个节点的左右子树。
思考过程:
- 边界情况:如果当前节点为
null,直接返回null - 递归翻转:先递归翻转左子树和右子树,得到翻转后的子树
- 交换左右子树:将翻转后的左子树赋给右子树,翻转后的右子树赋给左子树
- 返回当前节点:返回翻转后的根节点
关键洞察:翻转二叉树就是翻转每个节点的左右子树。递归地先翻转子树,再交换当前节点的左右子树,自然就能翻转整棵树。
变量命名改进:使用 invertedLeft 和 invertedRight 能更清晰地表达这些是翻转后的子树,避免与 root.left、root.right 混淆。
/**
* 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. 最大正方形
这道题的核心思想是使用动态规划(二维 DP)来找到矩阵中最大的由 '1' 组成的正方形。
思考过程:
- 定义状态:
dp[i][j]表示以matrix[i-1][j-1]为右下角的最大正方形的边长 - 使用 (i+1, j+1) 索引:为了避免边界判断,dp 数组使用 (m+1) x (n+1) 大小
- 状态转移:如果当前位置是 '1',取上方、左方、左上方三个方向的最小值再加 1
- 更新最大值:遍历过程中记录最大边长
- 返回面积:返回
maxSide * maxSide
关键洞察 - 为什么用 min:
- 要形成一个
k x k的正方形,需要上方、左方、左上方三个方向都能形成至少(k-1) x (k-1)的正方形 - 取三者最小值,相当于取三个方向中能扩展的最小"半径"
- 类似于木桶效应:最短的板决定了桶的容量
技术要点:
- 二维 DP:使用二维数组存储子问题结果
- 最优子结构:以某个位置为右下角的正方形大小依赖于其上方、左方、左上方的结果
- 边界处理:使用 (m+1) x (n+1) 的 dp 数组,让索引从 1 开始,避免边界判断
// 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)可以高效处理重复元素。
思考过程:
- 最小堆方法:维护大小为 k 的最小堆,堆顶即为第 k 大元素
- 快速选择方法:使用 partition 将数组分成三部分,根据 pivot 位置决定递归方向
- 三路分区的优势:当数组中有大量重复元素时,标准分区会导致 TLE,三路分区可以高效处理
方法一:最小堆(推荐,简单稳定)
核心思想:维护大小为 k 的最小堆,堆中存储当前见过的 k 个最大元素,堆顶即为第 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的区域(闭区间)
分区过程:
- 随机选择 pivot 并交换到
left位置(简化逻辑) - 如果
nums[i] < pivot: 交换到左区域,i++,lt++ - 如果
nums[i] = pivot: 已经在正确位置,i++ - 如果
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(大于区域边界)
// time: O(n) 平均时间,O(n²) 最坏情况(但三路分区使最坏情况很少发生)
// space: O(1) 额外空间,递归栈 O(log n) 平均,O(n) 最坏
为什么三路分区能处理重复元素?
场景:所有元素相等 [5, 5, 5, 5, 5], k = 3
- 三路分区后:所有元素都在
= pivot区域 - 返回范围
[0, 4](闭区间) - k=2 在范围内(0 <= 2 <= 4)→ 直接返回,无需递归!
为什么使用 k <= pivotEnd 而不是 k < pivotEnd?
关键原因:三路分区返回的范围是闭区间 [pivotStart, pivotEnd],两端都包含。
详细解释:
- 循环结束后,
j指向第一个> pivot区域的位置(或j = k+1,此时所有元素已处理) - 我们执行
swap(nums, j, right),将 pivot(原来在nums[right])交换到位置j - 交换后,
nums[j]也等于 pivot - 所以
[i, j]范围内的所有元素(包括索引j)都等于 pivot - 因此判断时必须使用
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(前缀树)是一种树形数据结构,用于高效存储和检索字符串集合。它的核心思想是利用字符串的公共前缀来减少存储空间,并支持快速的前缀匹配。
思考过程:
- 节点结构:每个节点包含一个子节点数组(26个字母)和一个布尔标志(表示是否为单词结尾)
- 插入操作:从根节点开始,沿着字符路径创建节点,到达单词末尾时标记为结束
- 查找操作:从根节点开始,沿着字符路径查找,检查是否存在完整的单词或前缀
- 辅助方法:
find方法统一处理路径查找,search和startsWith复用该方法
核心思想:
- 共享前缀:具有相同前缀的单词共享路径,节省空间
- 路径表示字符:从根到某个节点的路径表示一个字符串前缀
- 结束标志:
isEnd标志区分完整单词和前缀
/**
* 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. 课程表
这道题本质上是判断有向图是否存在环。如果能完成所有课程,说明图中没有环(可以进行拓扑排序)。
思考过程:
- 问题转换:课程依赖关系构成有向图,判断是否存在环
- BFS 方法(拓扑排序):从入度为 0 的节点开始,逐步移除节点,如果所有节点都能被访问,说明无环
- DFS 方法(状态标记):使用三种状态标记节点,如果在 DFS 过程中遇到 "visiting" 状态的节点,说明存在环
核心思想:
- 拓扑排序:有向无环图(DAG)可以进行拓扑排序
- 入度统计:BFS 方法通过统计入度,从没有依赖的节点开始处理
- 状态标记:DFS 方法通过状态标记(0=未访问, 1=访问中, 2=已访问)检测后向边(back edge)
方法一:BFS + 拓扑排序(Kahn's Algorithm)
核心思想:从入度为 0 的节点开始,逐步移除节点并更新其邻居的入度,如果所有节点都能被访问,说明无环。
// 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 的节点,说明存在后向边,即存在环。
// 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. 反转链表
这道题的核心思想是使用双指针(或三指针)逐个反转链表的连接方向。
思考过程:
- 迭代方法:使用
prev、cur、next三个指针,逐个反转每个节点的next指针 - 递归方法:递归到链表末尾,然后从后往前反转每个节点
- 关键点:在反转
cur.next之前,需要先保存cur.next,否则会丢失后续节点
核心思想:
- 三指针技巧:
prev(前一个节点)、cur(当前节点)、next(下一个节点) - 逐个反转:每次循环反转一个节点的
next指针 - 边界处理:初始时
prev = null,最终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 (新头节点)
方法二:递归
核心思想:递归到链表末尾,然后从后往前反转每个节点。
// 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. 岛屿数量
这道题的核心思想是使用 DFS 或 BFS 遍历网格,将连通的 '1' 区域标记为已访问,统计有多少个独立的连通区域。
思考过程:
- 问题转换:网格中的 '1' 表示陆地,连通的 '1' 组成一个岛屿
- 遍历策略:遍历整个网格,遇到 '1' 时,使用 DFS/BFS 标记所有连通的 '1'
- 标记方法:将访问过的 '1' 改为 '0'("沉没"岛屿),避免重复计算
- 计数:每次遇到新的 '1',说明发现一个新岛屿,计数加 1
核心思想:
- 连通性:上下左右相邻的 '1' 属于同一个岛屿
- 标记访问:通过修改原数组标记已访问("沉没"技术),无需额外空间
- DFS 递归:从每个 '1' 开始,递归标记所有连通的 '1'
DFS 方法(推荐):
// 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. 打家劫舍
这道题的核心思想是使用动态规划,对于每个房子,我们有两个选择:偷或不偷,选择收益更大的方案。
思考过程:
- 定义状态:
dp[i]表示前 i 个房子能获得的最大金额 - 状态转移:对于第 i 个房子,有两种选择:
- 不偷:
dp[i] = dp[i-1](保持前 i-1 个房子的最大金额) - 偷:
dp[i] = dp[i-2] + nums[i](前 i-2 个房子的最大金额 + 当前房子金额) - 取两者最大值
- 不偷:
- 边界情况:前 0 个房子为 0,前 1 个房子为
nums[0]
核心思想:
- 最优子结构:前 i 个房子的最优解依赖于前 i-1 和前 i-2 个房子的最优解
- 状态转移方程:
dp[i] = max(dp[i-1], dp[i-2] + nums[i]) - 空间优化:由于只依赖前两个状态,可以用 O(1) 空间
方法一:标准 DP(你的实现)
你的代码是正确的!可以稍微简化边界处理。
// time: O(n), n 是房子数量
// space: O(n), dp 数组的空间
改进建议:
- 简化边界处理:可以统一处理,让代码更简洁
- 空间优化:只依赖前两个状态,可以优化到 O(1) 空间
方法二:简化版本(统一边界处理)
方法三:空间优化版本(O(1) 空间)
核心思想:由于 dp[i] 只依赖 dp[i-1] 和 dp[i-2],可以用两个变量代替整个数组。
// 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. 多数元素
这道题的核心思想是使用 Boyer-Moore 投票算法(Boyer-Moore Voting Algorithm),在 O(n) 时间和 O(1) 空间内找到出现次数超过一半的元素。
思考过程:
- 问题理解:多数元素出现次数 > n/2,意味着它比其他所有元素的总和还要多
- 投票思想:将多数元素看作 +1,其他元素看作 -1,最终和一定 > 0
- 抵消策略:不同的元素互相抵消,最后剩下的候选者就是多数元素
核心思想 - 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++) - 最后剩下的元素就是多数元素
// time: O(n), 一次遍历
// space: O(1), 只使用了常数额外空间
其他解法对比:
方法二:哈希表计数
// time: O(n)
// space: O(n), 哈希表的空间
方法三:排序
// 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),只使用了两个变量
candidate和count
238. 除自身以外数组的乘积
LT.238. Product of Array Except Self
这道题的核心思想是使用前缀积和后缀积,在 O(n) 时间和 O(1) 额外空间(不包括输出数组)内计算每个位置除自身外的所有元素乘积。
思考过程:
- 问题理解:对于位置
i,需要计算nums[0] * nums[1] * ... * nums[i-1] * nums[i+1] * ... * nums[n-1] - 分解问题:可以拆分为两部分:
- 左半部分:
nums[0] * nums[1] * ... * nums[i-1](前缀积) - 右半部分:
nums[i+1] * ... * nums[n-1](后缀积)
- 左半部分:
- 两遍遍历:
- 第一遍:从左到右,计算每个位置左侧所有元素的乘积(前缀积)
- 第二遍:从右到左,计算每个位置右侧所有元素的乘积(后缀积),并乘到结果中
核心思想:
- 前缀积:
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 ✓
// time: O(n), 两次遍历数组
// space: O(1), 只使用了常数额外空间(不包括输出数组 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. 最小栈
这道题的核心思想是使用辅助栈(minStack)来跟踪每个状态下的最小值,使得所有操作都能在 O(1) 时间内完成。
思考过程:
- 问题理解:需要实现一个栈,支持常规的
push、pop、top操作,同时支持 O(1) 时间获取最小值 - 关键挑战:当栈顶元素被弹出后,最小值可能会改变,需要快速找到新的最小值
- 解决方案:使用辅助栈
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
/**
* 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) | 空间常数更小,但实现复杂,容易出错 |
关键要点:
- ✅ 辅助栈同步:
minStack与stack同步操作,保持相同大小 - ✅ 存储当前最小值:每次
push时,minStack存储的是"包含当前元素在内的最小值" - ✅ O(1) 操作:所有操作都是 O(1),符合题目要求
- ✅ 实现简单:思路直观,代码易维护
复杂度分析:
- 时间复杂度:O(1),所有操作(
push、pop、top、getMin)都是 O(1) - 空间复杂度:O(n),需要额外的
minStack存储 n 个最小值
设计模式:
- 辅助数据结构:使用额外的数据结构(
minStack)来支持高效操作 - 同步维护:主栈和辅助栈同步操作,保证状态一致
152. 乘积最大子数组
LT.152. Maximum Product Subarray
这道题的核心思想是使用动态规划,同时维护最大值和最小值,因为负数会将最小值变成最大值。
思考过程:
- 问题理解:找到连续子数组,使得乘积最大
- 关键挑战:负数会让乘积的符号翻转,最小值可能在下一次遇到负数时变成最大值
- 解决方案:同时维护以当前位置结尾的最大乘积和最小乘积
核心思想 - 为什么需要同时跟踪 min 和 max?
关键洞察:乘法与加法不同,符号会影响结果。
- 当遇到正数时:
max = max * num,min = min * num - 当遇到负数时:
max = min * num(负数让最小值变最大值),min = max * num(负数让最大值变最小值) - 因此,我们需要同时维护两个状态,并在每一步都考虑所有可能性
状态定义:
max:以当前位置结尾的最大乘积min:以当前位置结尾的最小乘积
状态转移方程:
对于位置 i,考虑三种情况:
- 从当前位置重新开始:
num - 继承之前最大值并继续:
prevMax * num - 继承之前最小值并继续:
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
// time: O(n), 一次遍历数组
// space: O(1), 只使用了常数额外空间
其他解法对比:
方法二:暴力法(不推荐)
// 枚举所有子数组,计算乘积
// time: O(n²), space: O(1)
// 超时
方法三:DP 数组版本(更直观但空间更差)
// time: O(n)
// space: O(n), 需要两个数组
方法对比:
| 方法 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| 你的方法(DP + 变量) | O(n) | O(1) | ✅ 最优,空间优化 |
| DP 数组版本 | O(n) | O(n) | 更直观,但需要额外空间 |
| 暴力法 | O(n²) | O(1) | ❌ 超时 |
关键要点:
- ✅ 双状态维护:同时跟踪
max和min,因为负数会让符号翻转 - ✅ 三种选择:每个位置可以选择重新开始、继承最大值或继承最小值
- ✅ 空间优化:只使用两个变量,不需要 DP 数组
- ✅ 边界处理:从第一个元素开始初始化
为什么不能像最大子数组和那样只维护 max?
最大子数组和(Kadane's Algorithm)可以只维护最大值,因为:
- 如果当前和变负,就重新开始:
curSum = max(nums[i], curSum + nums[i])
但乘积不同:
- 负数会让符号翻转:
-2 * -4 = 8(两个负数相乘变正数) - 当前的最小值在遇到下一个负数时可能成为最大值
- 因此必须同时维护两个状态
复杂度分析:
- 时间复杂度:O(n),一次遍历数组
- 空间复杂度:O(1),只使用了常数额外空间(
min、max、res等变量)
148. 排序链表
这道题的核心思想是使用归并排序(Merge Sort),结合快慢指针找到链表中点,然后递归地排序左右两部分,最后合并。
思考过程:
- 分治思想:将链表分成两半,分别排序,然后合并
- 找中点:使用快慢指针找到链表中点
- 递归排序:对左右两部分递归调用
sortList - 合并有序链表:使用双指针合并两个有序链表
核心思想:
- 归并排序:分而治之,先分后合
- 快慢指针找中点:fast 走两步,slow 走一步,fast 到达末尾时 slow 在中点
- 合并两个有序链表:使用 dummy 节点简化边界处理
快慢指针找中点的两种模式
为什么需要正确找中点?
在归并排序中,正确分割链表至关重要:
- 如果分割不均匀,可能导致栈溢出(递归深度过大)
- 如果分割点选择错误,可能导致无限递归或排序错误
Pattern A:左中点模式(你的实现)
slow
特点:
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
特点:
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
重要发现:对于奇数长度,两种模式结果相同!
让我用一个更明显的例子来展示差异:
两种模式的实际差异:
经过仔细分析,我发现:
-
偶数长度时,两种模式结果相同:
- 4 个节点:都是
[1,2]和[3,4] - 6 个节点:都是
[1,2,3]和[4,5,6]
- 4 个节点:都是
-
奇数长度时,两种模式结果也相同:
- 5 个节点:都是
[1,2,3]和[4,5] - 7 个节点:都是
[1,2,3,4]和[5,6,7]
- 5 个节点:都是
为什么看起来相同?
两种模式虽然初始位置和循环条件不同,但最终 slow 都会停在前半部分的最后一个节点,所以分割结果相同。
真正的差异在哪里?
差异在于执行过程和适用场景:
| 模式 | fast 初始位置 | 循环条件 | 特点 | 适用场景 |
|---|---|---|---|---|
| Pattern A | head | fast.next != null && fast.next.next != null | fast 停在最后一个或倒数第二个节点 | ✅ 归并排序(你的实现) |
| Pattern B | head.next | fast != null && fast.next != null | fast 可能停在 NULL | ✅ 回文链表检测 |
为什么 Pattern A 更适合归并排序?
- 更安全的边界检查:
fast.next.next的检查确保 fast 能安全地走两步,避免空指针异常 - 代码更清晰:循环条件更直观,容易理解
- 实际效果相同:两种模式的分割结果相同,但 Pattern A 的边界处理更安全
关键点总结:
- ✅ 两种模式都安全:都能正确找到中点并分割链表
- ✅ Pattern A 更适合归并排序:分割更均匀,递归更平衡
- ✅ 分割后必须断开:
slow.next = null是关键,否则会导致无限递归
/**
* 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缓存
这道题的核心思想是使用哈希表 + 双向链表来实现 LRU(Least Recently Used)缓存。哈希表提供 O(1) 的查找,双向链表提供 O(1) 的插入和删除。
思考过程:
- 数据结构选择:
- HashMap:存储 key 到 Node 的映射,O(1) 查找
- 双向链表:维护访问顺序,最近访问的在头部,最久未访问的在尾部
- Dummy 节点:使用
head和tail作为哨兵节点,简化边界处理
- 核心操作:
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
操作二: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
操作三: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
操作四: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
完整操作序列示例
容量 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]}
/**
* 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) 的节点删除(需要知道前驱节点)
- ✅ 哨兵节点:
head和tail简化边界处理,避免空指针检查 - ✅ HashMap 存储 key:Node 中存储 key,用于从 HashMap 中删除
- ✅ 移动操作:
moveToHead=removeNode+addToHead
复杂度分析:
- 时间复杂度:O(1),所有操作(
get、put、addToHead、removeNode、moveToHead、removeTail)都是 O(1) - 空间复杂度:O(capacity),HashMap 和双向链表都最多存储
capacity个节点
142. 环形链表II
这道题的核心思想是使用Floyd 判圈算法(Floyd Cycle Detection Algorithm),也称为"龟兔赛跑算法"。算法分为两个阶段:第一阶段检测是否有环,第二阶段找到环的入口。
思考过程:
- 阶段一:检测环:使用快慢指针,slow 每次走 1 步,fast 每次走 2 步
- 如果链表中没有环:fast 会先走到
null,不会相遇 - 如果链表中有环:fast 必然会追上 slow,最终两者会相遇
- 如果链表中没有环:fast 会先走到
- 阶段二:寻找环入口:当两个指针相遇后,从 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
/**
* 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. 单词拆分
这道题的核心思想是使用动态规划,判断字符串 s 是否能被 wordDict 中的单词完全拆分。
思考过程:
- 问题理解:判断字符串
s是否能被拆分成wordDict中的单词 - 子问题分解:如果
s[0..i]能被拆分,那么s[0..j]也能被拆分(j < i),且s[j..i]是一个单词 - 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的范围:1到n(包含)dp[i]表示s[0..i-1]是否能被拆分
j:分割点,表示前j个字符(s[0..j-1])已经被拆分j的范围:0到i-1dp[j]表示s[0..j-1]是否能被拆分s.substring(j, i)表示从位置j到i-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) - 从
j到i-1的子串是否是一个单词(s.substring(j, i)在字典中)
- 前
- 一旦找到有效的分割点,就可以
break:因为只需要知道能否拆分,不需要找到所有拆分方式
// 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] == true且s[j..i-1]在字典中 - ✅ 优化:使用
HashSet提高查找效率,找到有效分割点后break
复杂度分析:
- 时间复杂度:O(n²),外层循环 n 次,内层循环最多 n 次,
substring操作 O(n) - 空间复杂度:O(n + m),
dp数组 O(n),HashSetO(m)(m 是字典大小)
141. 环形链表
这道题是问题 142 的简化版本,只需要判断链表中是否存在环,不需要找到环的入口。同样使用Floyd 判圈算法(快慢指针)。
思考过程:
- 快慢指针:slow 每次走 1 步,fast 每次走 2 步
- 检测相遇:如果链表中有环,fast 必然会追上 slow,两者会相遇
- 无环情况:如果链表中没有环,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
/**
* 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)
// 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),只使用了常数额外空间(两个指针)