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),只使用了常数额外空间(两个指针)
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),只使用了常数额外空间(两个指针)
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 是字典大小)
136. 只出现一次的数字
这道题的核心思想是使用**异或运算(XOR)**的性质。异或运算具有以下特性,使得它非常适合解决这个问题。
思考过程:
- 问题理解:数组中除了一个元素只出现一次,其他元素都出现两次,找出只出现一次的元素
- 关键洞察:相同的数字异或结果为 0,0 与任何数异或结果不变
- 解决方案:将数组中所有元素异或起来,最终结果就是只出现一次的元素
核心思想 - 异或运算的性质:
异或运算的特性:
- 相同数异或为 0:
a ^ a = 0 - 0 与任何数异或不变:
0 ^ a = a - 满足交换律和结合律:
a ^ b ^ c = (a ^ b) ^ c = a ^ (b ^ c)
为什么异或能找出只出现一次的数字?
假设数组是 [a, b, c, a, b],其中 c 只出现一次:
res = 0
res = res ^ a = a
res = res ^ b = a ^ b
res = res ^ c = a ^ b ^ c
res = res ^ a = a ^ b ^ c ^ a = (a ^ a) ^ b ^ c = 0 ^ b ^ c = b ^ c
res = res ^ b = b ^ c ^ b = (b ^ b) ^ c = 0 ^ c = c
最终结果:c ✓
关键洞察:
- 出现两次的元素会互相抵消(
a ^ a = 0) - 0 与只出现一次的元素异或,结果就是该元素本身(
0 ^ c = c) - 异或满足交换律和结合律,所以元素的顺序不影响最终结果
可视化示例:
示例:nums = [2, 1, 4, 2, 4]
只出现一次的数字:1
初始:res = 0
第1步:res = 0 ^ 2 = 2
二进制:0000 ^ 0010 = 0010
第2步:res = 2 ^ 1 = 3
二进制:0010 ^ 0001 = 0011
第3步:res = 3 ^ 4 = 7
二进制:0011 ^ 0100 = 0111
第4步:res = 7 ^ 2 = 5
二进制:0111 ^ 0010 = 0101
验证:7 = 0111, 2 = 0010
0111 ^ 0010 = 0101 (二进制异或:相同为0,不同为1)
第5步:res = 5 ^ 4 = 1
二进制:0101 ^ 0100 = 0001
验证:5 = 0101, 4 = 0100
0101 ^ 0100 = 0001
最终结果:res = 1 ✓
另一种理解方式:
由于异或满足交换律,可以重新排列:
2 ^ 1 ^ 4 ^ 2 ^ 4
= (2 ^ 2) ^ (4 ^ 4) ^ 1
= 0 ^ 0 ^ 1
= 1
所有出现两次的元素互相抵消,只留下出现一次的元素。
// time: O(n), n 是数组长度,需要遍历一次数组
// space: O(1), 只使用了常数额外空间
执行过程可视化:
示例:nums = [4, 1, 2, 1, 2]
只出现一次的数字:4
初始:res = 0
遍历过程:
num = 4: res = 0 ^ 4 = 4
num = 1: res = 4 ^ 1 = 5
num = 2: res = 5 ^ 2 = 7
num = 1: res = 7 ^ 1 = 6
num = 2: res = 6 ^ 2 = 4
最终结果:res = 4 ✓
验证(重新排列):
4 ^ 1 ^ 2 ^ 1 ^ 2
= (1 ^ 1) ^ (2 ^ 2) ^ 4
= 0 ^ 0 ^ 4
= 4 ✓
其他解法对比:
方法二:哈希表(HashSet)
// time: O(n)
// space: O(n), 需要额外的 HashSet 空间
方法三:哈希表(HashMap 计数)
// time: O(n)
// space: O(n), 需要额外的 HashMap 空间
方法对比:
| 方法 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| 异或运算(你的方法) | O(n) | O(1) | ✅ 最优,无需额外空间 |
| HashSet | O(n) | O(n) | 需要额外空间存储元素 |
| HashMap 计数 | O(n) | O(n) | 需要额外空间存储计数 |
关键要点:
- ✅ 异或运算的性质:相同数异或为 0,0 与任何数异或不变
- ✅ 交换律和结合律:元素顺序不影响最终结果
- ✅ 自动抵消:出现两次的元素会互相抵消(
a ^ a = 0) - ✅ 空间优化:O(1) 空间,比哈希表方法更优
为什么异或运算这么神奇?
- 相同数抵消:
a ^ a = 0,所以出现两次的元素会互相抵消 - 0 不改变结果:
0 ^ a = a,所以 0 不会影响只出现一次的元素 - 交换律:
a ^ b = b ^ a,所以可以任意重新排列元素 - 结合律:
(a ^ b) ^ c = a ^ (b ^ c),所以可以任意组合
复杂度分析:
- 时间复杂度:O(n),需要遍历一次数组
- 空间复杂度:O(1),只使用了常数额外空间(变量
res)
647. 回文子串
LT.647. Palindromic Substrings
这道题的核心思想是使用中心扩展算法(Center Expansion),从每个可能的中心位置向两边扩展,统计所有回文子串的数量。
思考过程:
- 问题理解:统计字符串中所有回文子串的数量(包括单个字符)
- 关键洞察:每个回文子串都有一个中心,可以从中心向两边扩展
- 两种情况:
- 奇数长度:中心是一个字符(如 "aba",中心是 'b')
- 偶数长度:中心是两个字符之间(如 "abba",中心在两个 'b' 之间)
- 解决方案:遍历每个可能的中心位置,向两边扩展,统计回文子串
核心思想 - 中心扩展算法:
中心扩展的特点:
- 奇数长度回文:中心在单个字符上,如 "aba",中心在索引 1 的 'b'
- 偶数长度回文:中心在两个字符之间,如 "abba",中心在索引 1 和 2 之间
- 扩展规则:从中心向两边同时扩展,如果左右字符相同,则继续扩展;否则停止
为什么需要两种扩展?
回文串有两种可能的形式:
- 奇数长度:
a b a(中心在字符 'b' 上) - 偶数长度:
a b b a(中心在两个 'b' 之间)
因此需要两种扩展方式:
expand(i, i):以字符i为中心,检查奇数长度回文expand(i, i + 1):以字符i和i + 1之间为中心,检查偶数长度回文
可视化示例:
示例:s = "abc"
所有回文子串:a, b, c(共 3 个)
i=0 (字符 'a'):
奇数扩展:expand(0, 0)
left=0, right=0: 'a' == 'a' ✓, count=1
left=-1, right=1: 越界,停止
返回 1
偶数扩展:expand(0, 1)
left=0, right=1: 'a' == 'b' ✗, 停止
返回 0
i=1 (字符 'b'):
奇数扩展:expand(1, 1)
left=1, right=1: 'b' == 'b' ✓, count=1
left=0, right=2: 'a' == 'c' ✗, 停止
返回 1
偶数扩展:expand(1, 2)
left=1, right=2: 'b' == 'c' ✗, 停止
返回 0
i=2 (字符 'c'):
奇数扩展:expand(2, 2)
left=2, right=2: 'c' == 'c' ✓, count=1
left=1, right=3: 越界,停止
返回 1
偶数扩展:expand(2, 3)
left=2, right=3: 越界(right >= n),停止
返回 0
总计数:1 + 0 + 1 + 0 + 1 + 0 = 3 ✓
更复杂的示例:
示例:s = "aaa"
所有回文子串:a, a, a, aa, aa, aaa(共 6 个)
i=0 (字符 'a'):
奇数扩展:expand(0, 0)
left=0, right=0: 'a' == 'a' ✓, count=1
left=-1, right=1: 越界,停止
返回 1(找到 "a")
偶数扩展:expand(0, 1)
left=0, right=1: 'a' == 'a' ✓, count=1
left=-1, right=2: 越界,停止
返回 1(找到 "aa")
i=1 (字符 'a'):
奇数扩展:expand(1, 1)
left=1, right=1: 'a' == 'a' ✓, count=1
left=0, right=2: 'a' == 'a' ✓, count=2
left=-1, right=3: 越界,停止
返回 2(找到 "a", "aaa")
偶数扩展:expand(1, 2)
left=1, right=2: 'a' == 'a' ✓, count=1
left=0, right=3: 越界,停止
返回 1(找到 "aa")
i=2 (字符 'a'):
奇数扩展:expand(2, 2)
left=2, right=2: 'a' == 'a' ✓, count=1
left=1, right=3: 越界,停止
返回 1(找到 "a")
偶数扩展:expand(2, 3)
left=2, right=3: 越界,停止
返回 0
总计数:1 + 1 + 2 + 1 + 1 + 0 = 6 ✓
执行过程可视化:
示例:s = "aba"
所有回文子串:a, b, a, aba(共 4 个)
i=0 (字符 'a'):
奇数扩展:expand(0, 0)
初始:left=0, right=0, count=0
第1次:s[0]=='a'==s[0] ✓, count=1, left=-1, right=1
检查:left=-1 < 0,停止
返回 1(找到 "a")
偶数扩展:expand(0, 1)
初始:left=0, right=1, count=0
第1次:s[0]=='a'==s[1]=='b' ✗,停止
返回 0
i=1 (字符 'b'):
奇数扩展:expand(1, 1)
初始:left=1, right=1, count=0
第1次:s[1]=='b'==s[1] ✓, count=1, left=0, right=2
第2次:s[0]=='a'==s[2]=='a' ✓, count=2, left=-1, right=3
检查:left=-1 < 0,停止
返回 2(找到 "b", "aba")
偶数扩展:expand(1, 2)
初始:left=1, right=2, count=0
第1次:s[1]=='b'==s[2]=='a' ✗,停止
返回 0
i=2 (字符 'a'):
奇数扩展:expand(2, 2)
初始:left=2, right=2, count=0
第1次:s[2]=='a'==s[2] ✓, count=1, left=1, right=3
检查:right=3 >= n=3,停止
返回 1(找到 "a")
偶数扩展:expand(2, 3)
初始:left=2, right=3, count=0
检查:right=3 >= n=3,停止
返回 0
总计数:1 + 0 + 2 + 0 + 1 + 0 = 4 ✓
其他解法对比:
方法二:动态规划
// time: O(n²)
// space: O(n²), 需要 dp 数组
方法对比:
| 方法 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| 中心扩展(你的方法) | O(n²) | O(1) | ✅ 最优空间复杂度,思路直观 |
| 动态规划 | O(n²) | O(n²) | 需要额外空间存储状态 |
关键要点:
- ✅ 中心扩展算法:从每个可能的中心向两边扩展
- ✅ 两种中心:单个字符(奇数长度)和两个字符之间(偶数长度)
- ✅ 扩展规则:左右字符相同时继续扩展,否则停止
- ✅ 空间优化:O(1) 空间,比动态规划方法更优
- ✅ 边界检查:
left >= 0 && right < n防止越界
为什么这种方法有效?
-
完备性:每个回文子串都有一个中心,所有可能的中心都被考虑到了
- 奇数长度:n 个中心位置(每个字符)
- 偶数长度:n-1 个中心位置(每两个字符之间)
- 总共:n + (n-1) = 2n-1 个中心位置
-
正确性:从中心向两边扩展,只统计满足回文条件的子串
- 如果
s[left] == s[right],则s[left..right]是回文 - 继续扩展可以找到更长的回文子串
- 如果
-
避免重复:每个中心独立扩展,不会重复计数
expand(i, i)统计以字符 i 为中心的所有奇数长度回文expand(i, i+1)统计以 i 和 i+1 之间为中心的所有偶数长度回文
复杂度分析:
- 时间复杂度:O(n²)
- 外层循环:O(n),遍历每个可能的中心位置
expand函数:最坏情况下每个中心扩展 O(n) 次(如 "aaa...aaa")- 总体:O(n) × O(n) = O(n²)
- 空间复杂度:O(1),只使用了常数额外空间(变量
count)
128. 最长连续序列
LT.128. Longest Consecutive Sequence
这道题的核心思想是使用哈希集合(HashSet)来存储所有数字,然后只从每个连续序列的最小数字开始扩展,这样确保每个数字最多被访问一次,时间复杂度为 O(n)。
思考过程:
- 问题理解:找出数组中最长连续数字序列的长度(不要求元素在原数组中是连续的)
- 关键洞察:
- 使用 HashSet 存储所有数字,实现 O(1) 查找
- 只从每个连续序列的最小数字开始扩展(即
num - 1不在集合中) - 这样可以确保每个数字只被访问一次
- 解决方案:遍历 HashSet,对于每个可能的起始数字(
num - 1不在集合中),向右扩展直到序列结束
核心思想 - 只从最小数字开始扩展:
为什么只从最小数字开始?
每个连续序列都有一个最小数字。如果我们只从这个最小数字开始扩展,那么:
- 序列中的其他数字:它们的
num - 1都在集合中,所以不会作为起始点 - 避免重复计算:每个连续序列只会被计算一次,从它的最小数字开始
- 保证线性时间:每个数字最多被访问两次(检查是否起始点 + 扩展时访问一次)
可视化示例:
示例:nums = [100, 4, 200, 1, 3, 2]
连续序列:
- [1, 2, 3, 4]:长度为 4
- [100]:长度为 1
- [200]:长度为 1
HashSet = {100, 4, 200, 1, 3, 2}
遍历过程:
num = 100:
检查:set.contains(100 - 1) = set.contains(99) = false ✓
100 是起始点([100] 的最小数字)
扩展:cur = 100
- set.contains(101) = false,停止
长度:len = 1
maxLen = max(0, 1) = 1
num = 4:
检查:set.contains(4 - 1) = set.contains(3) = true ✗
4 不是起始点(3 在集合中,说明 4 不是最小数字)
跳过,继续下一个
num = 200:
检查:set.contains(200 - 1) = set.contains(199) = false ✓
200 是起始点([200] 的最小数字)
扩展:cur = 200
- set.contains(201) = false,停止
长度:len = 1
maxLen = max(1, 1) = 1
num = 1:
检查:set.contains(1 - 1) = set.contains(0) = false ✓
1 是起始点([1, 2, 3, 4] 的最小数字)
扩展:cur = 1
- set.contains(2) = true ✓, len = 2, cur = 2
- set.contains(3) = true ✓, len = 3, cur = 3
- set.contains(4) = true ✓, len = 4, cur = 4
- set.contains(5) = false,停止
长度:len = 4
maxLen = max(1, 4) = 4
num = 3:
检查:set.contains(3 - 1) = set.contains(2) = true ✗
3 不是起始点(2 在集合中)
跳过,继续下一个
num = 2:
检查:set.contains(2 - 1) = set.contains(1) = true ✗
2 不是起始点(1 在集合中)
跳过,继续下一个
最终结果:maxLen = 4 ✓
为什么时间复杂度是 O(n)?
这是这道题最关键的洞察。虽然看起来有两层循环,但实际上每个数字最多被访问常数次:
- HashSet 构建:O(n) 时间
- 外层循环:遍历 HashSet 中的 n 个数字,O(n)
- 起始点检查:每个数字检查一次
set.contains(num - 1),O(1),总共 O(n) - 扩展过程:每个数字最多参与一次扩展(当它作为某个序列的一部分时)
关键证明:每个数字最多被访问 O(1) 次
-
情况1:数字是某个序列的最小数字
- 在外层循环中访问一次(检查
num - 1) - 在扩展过程中访问一次(从它开始扩展)
- 总共:2 次
- 在外层循环中访问一次(检查
-
情况2:数字不是序列的最小数字(
num - 1在集合中)- 在外层循环中访问一次(检查
num - 1,发现存在,跳过) - 在扩展过程中访问一次(当从最小数字扩展时遇到它)
- 总共:2 次
- 在外层循环中访问一次(检查
重要观察:
- 每个连续序列只被计算一次(从最小数字开始)
- 序列中的每个数字最多被访问两次(检查 + 扩展)
- 因此总时间复杂度为 O(n)
更详细的分析:
示例:nums = [3, 1, 2, 5, 4]
连续序列:[1, 2, 3, 4, 5],长度为 5
HashSet = {3, 1, 2, 5, 4}
访问计数:
数字 1:
- 外层循环:检查 set.contains(0) = false,是起始点(访问1次)
- 扩展过程:从 1 开始扩展(访问1次)
- 总计:2 次
数字 2:
- 外层循环:检查 set.contains(1) = true,不是起始点,跳过(访问1次)
- 扩展过程:从 1 扩展时访问 2(访问1次)
- 总计:2 次
数字 3:
- 外层循环:检查 set.contains(2) = true,不是起始点,跳过(访问1次)
- 扩展过程:从 1 扩展时访问 3(访问1次)
- 总计:2 次
数字 4:
- 外层循环:检查 set.contains(3) = true,不是起始点,跳过(访问1次)
- 扩展过程:从 1 扩展时访问 4(访问1次)
- 总计:2 次
数字 5:
- 外层循环:检查 set.contains(4) = true,不是起始点,跳过(访问1次)
- 扩展过程:从 1 扩展时访问 5(访问1次)
- 总计:2 次
总访问次数:5 个数字 × 2 次 = 10 次 = O(n) ✓
// time: O(n), 虽然有两层循环,但每个数字最多被访问 2 次(检查 + 扩展)
// space: O(n), HashSet 存储所有数字
执行过程可视化:
示例:nums = [0, 3, 7, 2, 5, 8, 4, 6, 0, 1]
连续序列:
- [0, 1, 2, 3, 4, 5, 6, 7, 8]:长度为 9
- 0 出现两次,但不影响结果
HashSet = {0, 3, 7, 2, 5, 8, 4, 6, 1}
遍历过程(只展示起始点的扩展):
num = 0:
检查:set.contains(-1) = false ✓
扩展:[0, 1, 2, 3, 4, 5, 6, 7, 8]
长度:9
maxLen = 9
其他数字(3, 7, 2, 5, 8, 4, 6, 1):
它们的 num - 1 都在集合中,跳过
最终结果:maxLen = 9 ✓
其他解法对比:
方法二:排序(不推荐)
// time: O(n log n), 排序需要 O(n log n)
// space: O(1), 如果排序算法是原地的
方法对比:
| 方法 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| HashSet(你的方法) | O(n) | O(n) | ✅ 最优时间复杂度,思路巧妙 |
| 排序 | O(n log n) | O(1) | 简单但时间复杂度更高 |
关键要点:
- ✅ 只从最小数字开始扩展:确保每个连续序列只被计算一次
- ✅ HashSet 优化查找:O(1) 时间检查数字是否存在
- ✅ 线性时间复杂度:每个数字最多被访问 2 次(检查起始点 + 扩展时访问)
- ✅ 避免重复计算:通过
!set.contains(num - 1)条件确保只从最小数字开始
为什么每个数字最多访问 2 次?
-
第一次访问:在外层循环中,检查
num - 1是否在集合中- 如果不在:
num是起始点,开始扩展 - 如果在:
num不是起始点,跳过
- 如果不在:
-
第二次访问:如果
num是某个序列的一部分(但不是起始点)- 在扩展过程中会被访问(当从最小数字扩展时遇到它)
-
起始点的数字:会被访问两次
- 第一次:检查起始点条件
- 第二次:从它开始扩展(它自己)
关键洞察总结:
- 每个连续序列只有一个起始点(最小数字)
- 每个数字最多属于一个连续序列
- 每个序列只被计算一次(从起始点开始)
- 因此总时间复杂度为 O(n)
复杂度分析:
- 时间复杂度:O(n)
- HashSet 构建:O(n)
- 外层循环:O(n),遍历 HashSet 中的 n 个数字
- 起始点检查:每个数字一次,O(1) 时间,总共 O(n)
- 扩展过程:每个数字最多参与一次扩展,总共 O(n)
- 总体:O(n) + O(n) + O(n) = O(n)
- 空间复杂度:O(n),HashSet 存储所有数字
124. 二叉树中的最大路径和
LT.124. Binary Tree Maximum Path Sum
这道题的核心思想是使用后序遍历(Post-order Traversal),从下往上计算每个节点能够贡献的最大路径和。关键在于区分"向上返回的单边路径"和"以当前节点为转折点的完整路径"。
思考过程:
- 问题理解:找到二叉树中任意节点到任意节点的路径,使得路径上节点值的和最大
- 关键洞察:
- 路径可以在任意节点"转折"(经过该节点)
- 对于每个节点,可以有两种路径:
- 向上返回的路径:只能是一条单边路径(不能分叉),供父节点使用
- 以当前节点为转折点的路径:可以包含左右子树(
node.val + left + right)
- 解决方案:使用后序遍历,自底向上计算每个节点的最大贡献
核心思想 - 后序遍历 + 路径贡献:
关键区分:
dfs返回值:从当前节点向上能提供的最大单边路径(只能选左边或右边,不能同时选)- 全局最大值更新:以当前节点为转折点的完整路径(
node.val + left + right)
为什么使用后序遍历?
后序遍历(左右根)能够保证在计算当前节点时,左右子树的结果已经计算好了,可以自底向上逐步构建答案。
为什么 dfs 只返回单边路径?
因为路径向上传递时,只能是一条路径,不能分叉。如果同时选择左右子树,路径就会"分叉",无法向上传递。
可视化示例:
示例树:
-10
/ \
9 20
/ \
15 7
后序遍历过程:
节点 9:
left = dfs(9.left) = dfs(null) = 0
right = dfs(9.right) = dfs(null) = 0
curPath = 9 + 0 + 0 = 9
maxSum = max(MIN_VALUE, 9) = 9
返回:9 + max(0, 0) = 9(向上只能返回单边路径)
节点 15:
left = dfs(15.left) = 0
right = dfs(15.right) = 0
curPath = 15 + 0 + 0 = 15
maxSum = max(9, 15) = 15
返回:15 + max(0, 0) = 15
节点 7:
left = dfs(7.left) = 0
right = dfs(7.right) = 0
curPath = 7 + 0 + 0 = 7
maxSum = max(15, 7) = 15
返回:7 + max(0, 0) = 7
节点 20:
left = dfs(20.left) = dfs(15) = 15(15的贡献)
right = dfs(20.right) = dfs(7) = 7(7的贡献)
curPath = 20 + 15 + 7 = 42(以20为转折点的完整路径)
maxSum = max(15, 42) = 42 ✓
返回:20 + max(15, 7) = 35(向上只能返回单边路径:20->15 或 20->7)
节点 -10:
left = dfs(-10.left) = dfs(9) = 9
right = dfs(-10.right) = dfs(20) = 35(20返回的单边路径)
curPath = -10 + 9 + 35 = 34
maxSum = max(42, 34) = 42
返回:-10 + max(9, 35) = 25(向上返回 -10->20->15)
最终结果:maxSum = 42(路径:15 -> 20 -> 7)
为什么要 Math.max(x, 0)?
如果子树的贡献是负数,加上它只会让路径变小,所以应该舍弃(视为0)。这样保证向上返回的贡献值至少是当前节点本身的值。
示例:节点值为负数
5
/ \
-3 4
如果节点 -3 的贡献是 -3:
left = -3(负数)
curPath = 5 + (-3) + 4 = 6
如果舍弃负数(视为0):
left = max(-3, 0) = 0
curPath = 5 + 0 + 4 = 9 ✓(更优)
执行过程可视化:
示例树:
-10
/ \
9 20
/ \
15 7
后序遍历过程(自底向上):
1. 访问节点 9(叶子节点)
left = 0, right = 0
curPath = 9 + 0 + 0 = 9
maxSum = max(MIN_VALUE, 9) = 9
返回:9 + max(0, 0) = 9
2. 访问节点 15(叶子节点)
left = 0, right = 0
curPath = 15 + 0 + 0 = 15
maxSum = max(9, 15) = 15
返回:15 + max(0, 0) = 15
3. 访问节点 7(叶子节点)
left = 0, right = 0
curPath = 7 + 0 + 0 = 7
maxSum = max(15, 7) = 15
返回:7 + max(0, 0) = 7
4. 访问节点 20(内部节点)
left = 15(来自节点15的返回值)
right = 7(来自节点7的返回值)
curPath = 20 + 15 + 7 = 42(以20为转折点)
maxSum = max(15, 42) = 42 ✓
返回:20 + max(15, 7) = 35(向上只能返回单边)
5. 访问节点 -10(根节点)
left = 9(来自节点9的返回值)
right = 35(来自节点20的返回值)
curPath = -10 + 9 + 35 = 34
maxSum = max(42, 34) = 42
返回:-10 + max(9, 35) = 25
最终结果:maxSum = 42(路径:15 -> 20 -> 7)
关键要点:
- 后序遍历:保证在处理当前节点时,左右子树的结果已计算好
- 返回值 vs 全局最大值:
- 返回值:单边路径(供父节点使用)
- 全局最大值:完整路径(以当前节点为转折点)
- 负数处理:
Math.max(x, 0)舍弃负贡献 - 路径不能分叉:向上返回时只能选择左或右
面试常见追问:
Q1:为什么不能返回左右之和?
👉 因为向上只能是一条路径,不能分叉
如果返回 node.val + left + right,路径就会在节点处"分叉",无法向上传递。父节点无法同时使用左右两条路径,所以只能返回单边路径的最大值。
示例:
A
/ \
B C
如果 B 返回 left + right(分叉路径),A 无法使用
A 只能选择:A + max(B的贡献, C的贡献)
Q2:为什么要 Math.max(x, 0)?
👉 因为负贡献只会让路径变小
如果子树的路径和为负数,加上它只会降低整体路径和。因此,当贡献为负时应该舍弃(视为0),只使用当前节点本身的值。
示例:
节点 5,左子树贡献 -3,右子树贡献 4
如果不舍弃负数:
curPath = 5 + (-3) + 4 = 6
返回:5 + max(-3, 4) = 9
如果舍弃负数(视为0):
curPath = 5 + 0 + 4 = 9 ✓(更优)
返回:5 + max(0, 4) = 9
复杂度分析:
- 时间复杂度:O(n),每个节点访问一次
- 空间复杂度:O(h),递归栈高度
- 最坏情况:O(n)(树退化为链表)
- 平衡树:O(log n)
322. 零钱兑换
TODO: Add detailed solution.
494. 目标和
这道题的核心思想是将目标和问题转换为子集和问题(0-1背包),使用动态规划求解。
思考过程:
- 问题理解:给数组中的每个数字添加
+或-,使得表达式结果等于target - 关键洞察:将问题转换为:找到子集,使得子集和等于
(sum + target) / 2 - 转换原理:
- 设正数子集和为
pos,负数子集和为neg - 有:
pos - neg = target且pos + neg = sum - 解得:
pos = (sum + target) / 2
- 设正数子集和为
- 解决方案:使用 DP 统计有多少种方式可以组成和为
pos的子集
核心思想 - 问题转换:
原始问题:
- 给每个数字添加
+或-,使得表达式结果等于target - 例如:
nums = [1,1,1,1,1],target = 3- 一种方案:
+1 +1 +1 +1 -1 = 3
- 一种方案:
转换后的子集和问题:
- 设正数子集和为
pos,负数子集和为neg - 约束条件:
pos - neg = target(表达式结果)pos + neg = sum(所有数字的和)
- 解方程组:
pos - neg = target pos + neg = sum ──────────────── 2 * pos = sum + target pos = (sum + target) / 2 - 因此,问题转换为:找到有多少个子集,其和为
(sum + target) / 2
边界条件检查:
sum < Math.abs(target):无法达到目标(所有数字都取同号也无法达到)(sum + target) % 2 != 0:pos不是整数,无解
DP 状态定义:
dp[j]表示:有多少种方式可以组成和为j的子集dp[0] = 1:不选任何数字,和为 0,有 1 种方式(基础情况)
状态转移方程:
对于每个数字 num:
对于 j 从 p 到 num(倒序):
dp[j] += dp[j - num]
为什么倒序遍历?
- 这是 0-1 背包问题的经典优化
- 正序遍历会导致同一个数字被使用多次(完全背包)
- 倒序遍历保证每个数字只使用一次(0-1 背包)
可视化示例:
示例:nums = [1,1,1,1,1], target = 3
步骤 1:计算 sum 和 p
sum = 1 + 1 + 1 + 1 + 1 = 5
p = (5 + 3) / 2 = 4
问题转换为:找到有多少个子集,其和为 4
步骤 2:初始化 DP
dp[0] = 1 (不选任何数字,和为 0,有 1 种方式,这是基础情况)
dp[1..4] = 0
步骤 3:处理每个数字(倒序更新)
处理 num = 1:
j=4: dp[4] += dp[3] = 0 + 0 = 0
j=3: dp[3] += dp[2] = 0 + 0 = 0
j=2: dp[2] += dp[1] = 0 + 0 = 0
j=1: dp[1] += dp[0] = 0 + 1 = 1
结果:dp = [1, 1, 0, 0, 0]
处理 num = 1(第二个):
j=4: dp[4] += dp[3] = 0 + 0 = 0
j=3: dp[3] += dp[2] = 0 + 0 = 0
j=2: dp[2] += dp[1] = 0 + 1 = 1
j=1: dp[1] += dp[0] = 1 + 1 = 2
结果:dp = [1, 2, 1, 0, 0]
处理 num = 1(第三个):
j=4: dp[4] += dp[3] = 0 + 0 = 0
j=3: dp[3] += dp[2] = 0 + 1 = 1
j=2: dp[2] += dp[1] = 1 + 2 = 3
j=1: dp[1] += dp[0] = 2 + 1 = 3
结果:dp = [1, 3, 3, 1, 0]
处理 num = 1(第四个):
j=4: dp[4] += dp[3] = 0 + 1 = 1
j=3: dp[3] += dp[2] = 1 + 3 = 4
j=2: dp[2] += dp[1] = 3 + 3 = 6
j=1: dp[1] += dp[0] = 3 + 1 = 4
结果:dp = [1, 4, 6, 4, 1]
处理 num = 1(第五个):
j=4: dp[4] += dp[3] = 1 + 4 = 5
j=3: dp[3] += dp[2] = 4 + 6 = 10
j=2: dp[2] += dp[1] = 6 + 4 = 10
j=1: dp[1] += dp[0] = 4 + 1 = 5
结果:dp = [1, 5, 10, 10, 5]
最终结果:dp[4] = 5
表示有 5 种方式可以组成和为 4 的子集
对应原问题:有 5 种方式可以达到 target = 3
// time: O(n * p),n 是数组长度,p = (sum + target) / 2
// space: O(p),DP 数组的空间
关键要点:
- ✅ 问题转换:将目标和问题转换为子集和问题(0-1 背包)
- ✅ 数学推导:
pos = (sum + target) / 2,其中pos是正数子集和 - ✅ 边界检查:
sum < Math.abs(target)或(sum + target) % 2 != 0时无解 - ✅ 倒序遍历:保证每个数字只使用一次(0-1 背包特性)
- ✅ DP 状态:
dp[j]表示组成和为j的子集数量
为什么需要倒序遍历?
这是 0-1 背包问题的经典优化。如果正序遍历,同一个数字可能被使用多次:
正序遍历(错误):
处理 num = 1:
j=1: dp[1] += dp[0] = 1
j=2: dp[2] += dp[1] = 1 (使用了同一个 1 两次!)
倒序遍历(正确):
处理 num = 1:
j=2: dp[2] += dp[1] = 0 (dp[1] 还是旧值)
j=1: dp[1] += dp[0] = 1 (每个数字只使用一次)
复杂度分析:
- 时间复杂度:O(n × p),其中
n是数组长度,p = (sum + target) / 2 - 空间复杂度:O(p),DP 数组的空间
461. 汉明距离
这道题的核心思想是使用**异或运算(XOR)**找出两个数字二进制表示中不同的位,然后统计这些不同位的数量。
思考过程:
- 问题理解:汉明距离是两个整数二进制表示中不同位的数量
- 关键洞察:异或运算可以找出两个数字中不同的位(
x ^ y的结果中,1 的个数就是汉明距离) - 解决方案:先对两个数字进行异或,然后统计结果中 1 的个数
核心思想 - 异或运算找不同位:
异或运算的特性:
- 相同位异或为 0:
0 ^ 0 = 0,1 ^ 1 = 0 - 不同位异或为 1:
0 ^ 1 = 1,1 ^ 0 = 1
为什么异或能找出不同位?
x = 1 (二进制: 0001)
y = 4 (二进制: 0100)
x ^ y = 0001 ^ 0100 = 0101
↑ ↑
不同 不同
结果中 1 的个数 = 2,这就是汉明距离
可视化示例:
示例:x = 1, y = 4
x 的二进制:0001
y 的二进制:0100
↑↑
不同位(第0位和第2位)
x ^ y = 0001 ^ 0100 = 0101
第0位:1 ^ 0 = 1
第1位:0 ^ 0 = 0
第2位:0 ^ 1 = 1
第3位:0 ^ 0 = 0
结果:0101 中有 2 个 1,汉明距离 = 2
方法一:逐位检查(你的实现):
// time: O(1),最多检查 32 位(int 是 32 位)
// space: O(1),只使用了常数额外空间
执行过程可视化:
示例:x = 1, y = 4
步骤 1:计算异或
xor = 1 ^ 4 = 5 (二进制: 0101)
count = 0
步骤 2:检查每一位
迭代 1:
xor = 5 (0101)
xor & 1 = 0101 & 0001 = 0001 = 1
count += 1 → count = 1
xor >>= 1 → xor = 2 (0010)
迭代 2:
xor = 2 (0010)
xor & 1 = 0010 & 0001 = 0000 = 0
count += 0 → count = 1
xor >>= 1 → xor = 1 (0001)
迭代 3:
xor = 1 (0001)
xor & 1 = 0001 & 0001 = 0001 = 1
count += 1 → count = 2
xor >>= 1 → xor = 0 (0000)
迭代 4:
xor = 0,循环结束
最终结果:count = 2 ✓
方法二:使用 Integer.bitCount()(推荐,更简洁):
Java 提供了内置方法 Integer.bitCount() 来统计整数中 1 的个数,这是最优化的实现:
// time: O(1),Integer.bitCount() 使用位操作优化,时间复杂度为 O(1)
// space: O(1)
为什么推荐 Integer.bitCount()?
- 更简洁:一行代码解决问题
- 更高效:
Integer.bitCount()内部使用了优化的位操作算法(如 Brian Kernighan 算法),比逐位检查更快 - 更易读:代码意图清晰,不需要手动实现位计数逻辑
Integer.bitCount() 的内部实现:
Integer.bitCount() 使用了高效的位操作技巧,类似于 Brian Kernighan 算法:
// Integer.bitCount() 的简化版本(展示原理)
public static int
方法三:Brian Kernighan 算法(优化版逐位检查):
// time: O(k),k 是 1 的个数,比方法一更高效
// space: O(1)
Brian Kernighan 算法的优势:
- 更高效:只遍历 1 的个数次,而不是所有位
- 核心技巧:
xor & (xor - 1)可以清除最低位的 1
执行过程可视化(Brian Kernighan):
示例:xor = 5 (0101)
迭代 1:
xor = 5 (0101)
count = 1
xor - 1 = 4 (0100)
xor & (xor - 1) = 0101 & 0100 = 0100 = 4
xor = 4
迭代 2:
xor = 4 (0100)
count = 2
xor - 1 = 3 (0011)
xor & (xor - 1) = 0100 & 0011 = 0000 = 0
xor = 0,循环结束
最终结果:count = 2 ✓
方法对比:
| 方法 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| Integer.bitCount()(推荐) | O(1) | O(1) | ✅ 最简洁,使用内置优化 |
| Brian Kernighan 算法 | O(k) | O(1) | ✅ 高效,k 是 1 的个数 |
| 逐位检查(你的方法) | O(1) | O(1) | 直观易懂,但效率略低 |
关键要点:
- ✅ 异或运算找不同:
x ^ y的结果中,1 的个数就是汉明距离 - ✅ Integer.bitCount():Java 内置方法,最简洁高效
- ✅ Brian Kernighan 算法:优化的位计数方法,只遍历 1 的个数次
- ✅ 逐位检查:直观易懂,适合理解原理
复杂度分析:
- 时间复杂度:
- 方法一(逐位检查):O(1),最多检查 32 位
- 方法二(Integer.bitCount()):O(1),使用优化的位操作
- 方法三(Brian Kernighan):O(k),k 是 1 的个数
- 空间复杂度:O(1),所有方法都只使用常数额外空间
448. 找到所有数组中消失的数字
TODO: Add detailed solution.
438. 找到字符串中所有字母异位词
TODO: Add detailed solution.
437. 路径总和III
TODO: Add detailed solution.
416. 分割等和子集
这道题可以转换为 0-1 背包问题。判断是否可以将数组分割成两个子集,使得两个子集的元素和相等,等价于从数组中选取一些元素,使得它们的和等于数组总和的一半。
关键点:
- 如果数组总和
sum是奇数,直接返回false,因为无法分成两个和相等的整数子集。 - 目标和
target = sum / 2。 - 定义
dp[j]表示是否可以从数组中选出一些数字,使得它们的和恰好为j。 - 状态转移方程:
dp[j] = dp[j] || dp[j - num],其中num是当前遍历到的数字。
406. 根据身高重建队列
Queue Reconstruction by Height
这是一个贪心算法问题。
核心思想:
- 排序:首先按照身高
h降序排列。如果身高相同,则按照k值升序排列。- 为什么这样排序?因为高个子的人在队列中看不见比他矮的人,所以高个子先站好位置,后面插入的矮个子不会影响高个子的
k值(因为矮个子对高个子来说是"看不见"的)。
- 为什么这样排序?因为高个子的人在队列中看不见比他矮的人,所以高个子先站好位置,后面插入的矮个子不会影响高个子的
- 插入:遍历排序后的数组,根据每个人的
k值,将其插入到结果列表的第k个位置。- 因为我们是从高到矮处理,当前处理的人比已经入队的人都矮(或身高相同但
k更大),所以直接插到索引k的位置是正确的。
- 因为我们是从高到矮处理,当前处理的人比已经入队的人都矮(或身高相同但
399. 除法求值
这道题可以将变量视为图中的节点,除法关系视为有向边及其权重。求 a / b 等价于在图中寻找从节点 a 到节点 b 的路径,并将路径上的权重相乘。
核心思想:
- 建图:
- 如果
a / b = v,则添加一条从a到b的边,权重为v。 - 同时添加一条从
b到a的边,权重为1.0 / v。
- 如果
- DFS 搜索:
- 对于每个查询
(a, b),从a开始进行 DFS 搜索b。 - 维护
visited集合防止环路。 - 如果找到
b,返回路径权重的乘积;否则返回-1.0。 - 如果起点或终点不在图中,直接返回
-1.0。
- 对于每个查询