From dcd3e136ec97412418ac13192d2ad5a206a2d305 Mon Sep 17 00:00:00 2001 From: yasinshaw Date: Thu, 5 Mar 2026 12:23:56 +0800 Subject: [PATCH] vault backup: 2026-03-05 12:23:56 --- .obsidian/workspace.json | 16 +- 16-LeetCode Hot 100/三数之和.md | 313 ++++++++ 16-LeetCode Hot 100/两数相加.md | 227 ++++++ .../删除链表的倒数第N个结点.md | 691 ++++++++++++++++++ 16-LeetCode Hot 100/无重复字符的最长子串.md | 246 +++++++ 16-LeetCode Hot 100/电话号码的字母组合.md | 647 ++++++++++++++++ 16-LeetCode Hot 100/盛最多水的容器.md | 439 +++++++++++ 7 files changed, 2571 insertions(+), 8 deletions(-) create mode 100644 16-LeetCode Hot 100/三数之和.md create mode 100644 16-LeetCode Hot 100/两数相加.md create mode 100644 16-LeetCode Hot 100/删除链表的倒数第N个结点.md create mode 100644 16-LeetCode Hot 100/无重复字符的最长子串.md create mode 100644 16-LeetCode Hot 100/电话号码的字母组合.md create mode 100644 16-LeetCode Hot 100/盛最多水的容器.md diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json index f675c0f..ef835a7 100644 --- a/.obsidian/workspace.json +++ b/.obsidian/workspace.json @@ -196,6 +196,13 @@ }, "active": "fcbc762a80282002", "lastOpenFiles": [ + "16-LeetCode Hot 100/删除链表的倒数第N个结点.md", + "16-LeetCode Hot 100/电话号码的字母组合.md", + "16-LeetCode Hot 100/盛最多水的容器.md", + "16-LeetCode Hot 100/三数之和.md", + "16-LeetCode Hot 100/无重复字符的最长子串.md", + "16-LeetCode Hot 100/两数相加.md", + "16-LeetCode Hot 100", "00-项目概述/项目概述.md", "00-项目概述", "questions/04-消息队列/消息队列_RocketMQ_Kafka.md", @@ -217,20 +224,13 @@ "questions/15-简历面试/场景设计题.md", "questions/15-简历面试/个人发展题.md", "questions/15-简历面试/离职原因与动机.md", - "questions/15-简历面试/项目深挖题.md", - "questions/15-简历面试/README.md", - "questions/15-简历面试/薪资谈判.md", "questions/15-简历面试", - "questions/01-分布式系统/分布式锁.md", - "questions/14-Web3与区块链/README.md", - "questions/14-Web3与区块链/简历项目Web3迁移.md", "questions/14-Web3与区块链", "12-面试技巧", "08-算法与数据结构", "questions/13-Golang语言", "questions/12-面试技巧", "questions/11-运维", - "questions/10-中间件", - "questions/09-网络与安全" + "questions/10-中间件" ] } \ No newline at end of file diff --git a/16-LeetCode Hot 100/三数之和.md b/16-LeetCode Hot 100/三数之和.md new file mode 100644 index 0000000..667f92c --- /dev/null +++ b/16-LeetCode Hot 100/三数之和.md @@ -0,0 +1,313 @@ +# 三数之和 (3Sum) + +LeetCode 15. Medium + +## 题目描述 + +给你一个整数数组 `nums`,判断是否存在三元组 `[nums[i], nums[j], nums[k]]` 满足 `i != j`、`i != k` 且 `j != k`,同时还满足 `nums[i] + nums[j] + nums[k] == 0`。 + +请你返回所有和为 0 且不重复的三元组。 + +**注意**:答案中不可以包含重复的三元组。 + +**示例 1**: +``` +输入:nums = [-1,0,1,2,-1,-4] +输出:[[-1,-1,2],[-1,0,1]] +解释: +nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 +nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 +nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 +不同的三元组是 [-1,0,1] 和 [-1,-1,2] +注意,输出的顺序和三元组的顺序并不重要。 +``` + +**示例 2**: +``` +输入:nums = [0,1,1] +输出:[] +解释:唯一可能的三元组和不为 0 +``` + +**示例 3**: +``` +输入:nums = [0,0,0] +输出:[[0,0,0]] +解释:唯一可能的三元组和为 0 +``` + +## 解题思路 + +### 核心思想 +**排序 + 双指针**:先排序,固定第一个数,再用双指针找后两个数。 + +### 算法流程 +1. **排序数组**:便于去重和双指针操作 +2. **遍历第一个数**: + - 跳过重复元素 + - 如果当前数 > 0,直接退出(后面都 > 0) +3. **双指针找后两个数**: + - left = i + 1, right = len(nums) - 1 + - 根据 sum 与 0 的关系移动指针 + - 跳过重复元素 + +### 复杂度分析 +- **时间复杂度**:O(n²),排序 O(n log n) + 双指针 O(n²) +- **空间复杂度**:O(1),不考虑结果存储 + +--- + +## Go 解法 + +```go +func threeSum(nums []int) [][]int { + result := [][]int{} + n := len(nums) + + // 排序 + sort.Ints(nums) + + for i := 0; i < n-2; i++ { + // 去重:跳过重复的第一个数 + if i > 0 && nums[i] == nums[i-1] { + continue + } + + // 优化:如果最小数 > 0,后面不可能有解 + if nums[i] > 0 { + break + } + + // 双指针 + left, right := i+1, n-1 + for left < right { + sum := nums[i] + nums[left] + nums[right] + + if sum == 0 { + result = append(result, []int{nums[i], nums[left], nums[right]}) + + // 去重:跳过重复的 left + for left < right && nums[left] == nums[left+1] { + left++ + } + // 去重:跳过重复的 right + for left < right && nums[right] == nums[right-1] { + right-- + } + + // 移动指针 + left++ + right-- + } else if sum < 0 { + // 和太小,left 向右移 + left++ + } else { + // 和太大,right 向左移 + right-- + } + } + } + + return result +} +``` + +### Go 代码要点 +1. `sort.Ints()` 排序 +2. `append()` 添加结果 +3. 多重去重逻辑 + +--- + +## Java 解法 + +```java +class Solution { + public List> threeSum(int[] nums) { + List> result = new ArrayList<>(); + Arrays.sort(nums); + + for (int i = 0; i < nums.length - 2; i++) { + // 去重:跳过重复的第一个数 + if (i > 0 && nums[i] == nums[i - 1]) { + continue; + } + + // 优化:如果最小数 > 0,后面不可能有解 + if (nums[i] > 0) { + break; + } + + // 双指针 + int left = i + 1, right = nums.length - 1; + while (left < right) { + int sum = nums[i] + nums[left] + nums[right]; + + if (sum == 0) { + result.add(Arrays.asList(nums[i], nums[left], nums[right])); + + // 去重:跳过重复的 left + while (left < right && nums[left] == nums[left + 1]) { + left++; + } + // 去重:跳过重复的 right + while (left < right && nums[right] == nums[right - 1]) { + right--; + } + + left++; + right--; + } else if (sum < 0) { + left++; + } else { + right--; + } + } + } + + return result; + } +} +``` + +### Java 代码要点 +1. `Arrays.sort()` 排序 +2. `Arrays.asList()` 创建列表 +3. `ArrayList` 存储结果 + +--- + +## 图解过程 + +``` +数组: [-4, -1, -1, 0, 1, 2] + ↑ ↑ ↑ + i left right + +第一轮: i = 0, nums[i] = -4 + left = 1, right = 5 + sum = -4 + (-1) + 2 = -3 < 0 + left++ + + left = 2, right = 5 + sum = -4 + (-1) + 2 = -3 < 0 + left++ + + left = 3, right = 5 + sum = -4 + 0 + 2 = -2 < 0 + left++ + + left = 4, right = 5 + sum = -4 + 1 + 2 = -1 < 0 + left++ + left >= right, 退出 + +第二轮: i = 1, nums[i] = -1 + left = 2, right = 5 + sum = -1 + (-1) + 2 = 0 ✓ + 结果: [-1, -1, 2] + + left = 3, right = 4 + sum = -1 + 0 + 1 = 0 ✓ + 结果: [-1, 0, 1] + +第三轮: i = 2, nums[i] = -1 (重复,跳过) +第四轮: i = 3, nums[i] = 0 > 0, 退出 + +最终结果: [[-1,-1,2], [-1,0,1]] +``` + +--- + +## 进阶问题 + +### Q1: 如果是四数之和? +**方法**:在三层循环 + 双指针,时间 O(n³) + +```go +func fourSum(nums []int, target int) [][]int { + result := [][]int{} + sort.Ints(nums) + n := len(nums) + + for i := 0; i < n-3; i++ { + if i > 0 && nums[i] == nums[i-1] { + continue + } + + for j := i + 1; j < n-2; j++ { + if j > i+1 && nums[j] == nums[j-1] { + continue + } + + left, right := j+1, n-1 + for left < right { + sum := nums[i] + nums[j] + nums[left] + nums[right] + + if sum == target { + result = append(result, []int{nums[i], nums[j], nums[left], nums[right]}) + + for left < right && nums[left] == nums[left+1] { + left++ + } + for left < right && nums[right] == nums[right-1] { + right-- + } + + left++ + right-- + } else if sum < target { + left++ + } else { + right-- + } + } + } + } + + return result +} +``` + +### Q2: 如果数组很大,如何优化? +**优化**: +1. 提前终止:`nums[i] * 3 > target`(正数情况) +2. 二分查找:确定第二个数后,二分查找后两个 +3. 哈希表:空间换时间 + +--- + +## P7 加分项 + +### 深度理解 +- **排序的作用**:去重 + 双指针基础 +- **双指针原理**:利用有序性,单向移动 +- **去重策略**:多处去重,确保结果唯一 + +### 实战扩展 +- **大数据场景**:外部排序 + 分段处理 +- **分布式场景**:MapReduce 框架 +- **业务场景**:推荐系统、用户画像匹配 + +### 变形题目 +1. [16. 最接近的三数之和](https://leetcode.cn/problems/3sum-closest/) +2. [18. 四数之和](https://leetcode.cn/problems/4sum/) +3. [259. 较小的三数之和](https://leetcode.cn/problems/3sum-smaller/) + +--- + +## 总结 + +这道题的核心是: +1. **排序**:为双指针和去重创造条件 +2. **固定一个数**:将问题转化为两数之和 +3. **双指针**:根据 sum 与 target 的关系移动指针 +4. **多重去重**:i、left、right 都要跳过重复元素 + +**易错点**: +- 忘记排序 +- 去重逻辑不完整 +- left 和 right 的移动条件 +- 优化提前终止的条件 + +**最优解法**:排序 + 双指针,时间 O(n²),空间 O(1) diff --git a/16-LeetCode Hot 100/两数相加.md b/16-LeetCode Hot 100/两数相加.md new file mode 100644 index 0000000..c1eb603 --- /dev/null +++ b/16-LeetCode Hot 100/两数相加.md @@ -0,0 +1,227 @@ +# 两数相加 (Add Two Numbers) + +LeetCode 2. Medium + +## 题目描述 + +给你两个 **非空** 的链表,表示两个非负的整数。它们每位数字都是按照 **逆序** 的方式存储的,并且每个节点只能存储 **一位** 数字。 + +请你将两个数相加,并以相同形式返回一个表示和的链表。 + +你可以假设除了数字 0 之外,这两个数都不会以 0 开头。 + +**示例 1**: +``` +输入:l1 = [2,4,3], l2 = [5,6,4] +输出:[7,0,8] +解释:342 + 465 = 807 +``` + +**示例 2**: +``` +输入:l1 = [0], l2 = [0] +输出:[0] +``` + +**示例 3**: +``` +输入:l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9] +输出:[8,9,9,9,0,0,0,1] +``` + +## 解题思路 + +### 核心思想 +模拟竖式加法,同时遍历两个链表,逐位相加并处理进位。 + +### 算法流程 +1. 初始化哑节点和进位 carry = 0 +2. 同时遍历两个链表: + - 计算当前位的和:sum = l1.val + l2.val + carry + - 更新进位:carry = sum / 10 + - 创建新节点:sum % 10 +3. 处理最后的进位 +4. 返回结果链表 + +### 复杂度分析 +- **时间复杂度**:O(max(m, n)),m 和 n 分别为两个链表的长度 +- **空间复杂度**:O(1),不考虑结果链表的空间 + +--- + +## Go 解法 + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ +func addTwoNumbers(l1 *ListNode, l2 *ListNode) *ListNode { + // 哑节点,简化边界处理 + dummy := &ListNode{Val: 0} + current := dummy + carry := 0 + + // 同时遍历两个链表 + for l1 != nil || l2 != nil { + // 获取当前位的值(如果链表已结束则为0) + x, y := 0, 0 + if l1 != nil { + x = l1.Val + l1 = l1.Next + } + if l2 != nil { + y = l2.Val + l2 = l2.Next + } + + // 计算和与进位 + sum := x + y + carry + carry = sum / 10 + + // 创建新节点 + current.Next = &ListNode{Val: sum % 10} + current = current.Next + } + + // 处理最后的进位 + if carry > 0 { + current.Next = &ListNode{Val: carry} + } + + return dummy.Next +} +``` + +### Go 代码要点 +1. 使用哑节点简化头节点处理 +2. 注意 Go 的 nil 判断 +3. 整数除法和取模:`sum / 10` 和 `sum % 10` + +--- + +## Java 解法 + +```java +/** + * 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; } + * } + */ +class Solution { + public ListNode addTwoNumbers(ListNode l1, ListNode l2) { + // 哑节点 + ListNode dummy = new ListNode(0); + ListNode current = dummy; + int carry = 0; + + // 同时遍历两个链表 + while (l1 != null || l2 != null) { + // 获取当前位的值 + int x = (l1 != null) ? l1.val : 0; + int y = (l2 != null) ? l2.val : 0; + + // 计算和与进位 + int sum = x + y + carry; + carry = sum / 10; + + // 创建新节点 + current.next = new ListNode(sum % 10); + current = current.next; + + // 移动指针 + if (l1 != null) l1 = l1.next; + if (l2 != null) l2 = l2.next; + } + + // 处理最后的进位 + if (carry > 0) { + current.next = new ListNode(carry); + } + + return dummy.next; + } +} +``` + +### Java 代码要点 +1. 三元运算符处理空指针 +2. 对象引用操作(current.next) +3. 注意 null 判断 + +--- + +## 进阶问题 + +### Q1: 如果链表是正序存储的,如何解决? +**方法**:反转链表 + 上述算法,或使用栈 + +```go +func addTwoNumbersReverse(l1, l2 *ListNode) *ListNode { + // 反转链表 + l1 = reverseList(l1) + l2 = reverseList(l2) + + // 使用相同算法相加 + result := addTwoNumbers(l1, l2) + + // 反转结果 + return reverseList(result) +} + +func reverseList(head *ListNode) *ListNode { + var prev *ListNode + for head != nil { + next := head.Next + head.Next = prev + prev = head + head = next + } + return prev +} +``` + +### Q2: 如何处理多个链表相加? +**方法**:逐个相加或使用优先队列(最小堆) + +--- + +## P7 加分项 + +### 深度理解 +- **链表操作**:指针移动、内存管理 +- **边界处理**:不同长度链表、最后进位 +- **哑节点技巧**:简化代码逻辑 + +### 实战扩展 +- **大数据场景**:链表表示超大整数相加 +- **分布式场景**:MapReduce 实现大数相加 +- **面试追问**:时间复杂度能否优化?如何测试? + +### 变形题目 +1. [445. 两数相加 II](https://leetcode.cn/problems/add-two-numbers-ii/) - 链表正序存储 +2. [链表相加系列](https://leetcode.cn/tag/linked-list/) - 更多链表操作 + +--- + +## 总结 + +这道题的核心是: +1. **模拟加法**:逐位相加,处理进位 +2. **链表遍历**:同时遍历两条链表 +3. **边界处理**:不同长度、最后进位 + +**易错点**: +- 忘记处理最后的进位 +- 链表长度不一致时的处理 +- 哑节点的使用(简化代码) + +**最优解法**:一次遍历,时间 O(max(m,n)),空间 O(1) diff --git a/16-LeetCode Hot 100/删除链表的倒数第N个结点.md b/16-LeetCode Hot 100/删除链表的倒数第N个结点.md new file mode 100644 index 0000000..093e9d6 --- /dev/null +++ b/16-LeetCode Hot 100/删除链表的倒数第N个结点.md @@ -0,0 +1,691 @@ +# 删除链表的倒数第N个结点 (Remove Nth Node From End of List) + +## 题目描述 + +给你一个链表,删除链表的倒数第 `n` 个结点,并且返回链表的头结点。 + +### 示例 + +**示例 1:** +``` +输入:head = [1,2,3,4,5], n = 2 +输出:[1,2,3,5] +``` + +**示例 2:** +``` +输入:head = [1], n = 1 +输出:[] +``` + +**示例 3:** +``` +输入:head = [1,2], n = 1 +输出:[1] +``` + +### 约束条件 + +- 链表中结点的数目为 `sz` +- `1 <= sz <= 30` +- `0 <= Node.val <= 100` +- `1 <= n <= sz` + +### 进阶 + +你能尝试使用一趟扫描实现吗? + +## 解题思路 + +### 方法一:双指针法(推荐) + +**核心思想:**使用两个指针 `fast` 和 `slow`,`fast` 先移动 `n` 步,然后 `fast` 和 `slow` 一起移动,直到 `fast` 到达链表末尾。此时 `slow` 指向要删除结点的前一个结点。 + +**算法步骤:** +1. 创建哑结点 `dummy`,指向链表头 +2. 初始化 `fast` 和 `slow` 指针都指向 `dummy` +3. `fast` 先移动 `n + 1` 步 +4. `fast` 和 `slow` 同时移动,直到 `fast` 为 `nil` +5. 此时 `slow.next` 就是要删除的结点,执行 `slow.next = slow.next.next` +6. 返回 `dummy.next` + +**为什么移动 n + 1 步?** +- 这样 `slow` 最终会停在要删除结点的前一个结点 +- 方便删除操作:`slow.next = slow.next.next` + +### 方法二:计算长度法 + +**核心思想:**先遍历链表计算长度,然后计算要删除的正数位置,再遍历到该位置删除结点。 + +**算法步骤:** +1. 遍历链表,计算长度 `length` +2. 要删除的正数位置为 `length - n` +3. 创建哑结点 `dummy`,指向链表头 +4. 遍历到第 `length - n - 1` 个结点 +5. 删除下一个结点 +6. 返回 `dummy.next` + +### 方法三:栈法 + +**核心思想:**将所有结点压入栈中,然后弹出 `n` 个结点,栈顶就是要删除结点的前一个结点。 + +**算法步骤:** +1. 创建哑结点 `dummy` +2. 将所有结点压入栈 +3. 弹出 `n` 个结点 +4. 栈顶结点的 `next` 指向要删除结点的 `next` +5. 返回 `dummy.next` + +## 代码实现 + +### Go 实现(双指针法) + +```go +package main + +import "fmt" + +// ListNode 链表结点定义 +type ListNode struct { + Val int + Next *ListNode +} + +func removeNthFromEnd(head *ListNode, n int) *ListNode { + // 创建哑结点,处理删除头结点的特殊情况 + dummy := &ListNode{0, head} + fast, slow := dummy, dummy + + // fast 先移动 n + 1 步 + for i := 0; i <= n; i++ { + fast = fast.Next + } + + // fast 和 slow 一起移动,直到 fast 为 nil + for fast != nil { + fast = fast.Next + slow = slow.Next + } + + // 删除 slow 的下一个结点 + slow.Next = slow.Next.Next + + return dummy.Next +} + +// 辅助函数:创建链表 +func createList(nums []int) *ListNode { + dummy := &ListNode{} + current := dummy + for _, num := range nums { + current.Next = &ListNode{num, nil} + current = current.Next + } + return dummy.Next +} + +// 辅助函数:打印链表 +func printList(head *ListNode) { + current := head + for current != nil { + fmt.Printf("%d", current.Val) + if current.Next != nil { + fmt.Printf(" -> ") + } + current = current.Next + } + fmt.Println() +} + +// 测试用例 +func main() { + // 测试用例1 + head1 := createList([]int{1, 2, 3, 4, 5}) + fmt.Print("输入: ") + printList(head1) + fmt.Printf("n = 2\n") + result1 := removeNthFromEnd(head1, 2) + fmt.Print("输出: ") + printList(result1) + + // 测试用例2: 删除头结点 + head2 := createList([]int{1}) + fmt.Print("\n输入: ") + printList(head2) + fmt.Printf("n = 1\n") + result2 := removeNthFromEnd(head2, 1) + fmt.Print("输出: ") + printList(result2) + + // 测试用例3: 删除最后一个结点 + head3 := createList([]int{1, 2}) + fmt.Print("\n输入: ") + printList(head3) + fmt.Printf("n = 1\n") + result3 := removeNthFromEnd(head3, 1) + fmt.Print("输出: ") + printList(result3) + + // 测试用例4: 长链表 + head4 := createList([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}) + fmt.Print("\n输入: ") + printList(head4) + fmt.Printf("n = 5\n") + result4 := removeNthFromEnd(head4, 5) + fmt.Print("输出: ") + printList(result4) +} +``` + +### Java 实现(双指针法) + +```java +public class RemoveNthFromEnd { + + // 链表结点定义 + public static class ListNode { + int val; + ListNode next; + ListNode() {} + ListNode(int val) { this.val = val; } + ListNode(int val, ListNode next) { this.val = val; this.next = next; } + } + + public ListNode removeNthFromEnd(ListNode head, int n) { + // 创建哑结点,处理删除头结点的特殊情况 + ListNode dummy = new ListNode(0, head); + ListNode fast = dummy; + ListNode slow = dummy; + + // fast 先移动 n + 1 步 + for (int i = 0; i <= n; i++) { + fast = fast.next; + } + + // fast 和 slow 一起移动,直到 fast 为 null + while (fast != null) { + fast = fast.next; + slow = slow.next; + } + + // 删除 slow 的下一个结点 + slow.next = slow.next.next; + + return dummy.next; + } + + // 辅助函数:创建链表 + private ListNode createList(int[] nums) { + ListNode dummy = new ListNode(); + ListNode current = dummy; + for (int num : nums) { + current.next = new ListNode(num); + current = current.next; + } + return dummy.next; + } + + // 辅助函数:打印链表 + private void printList(ListNode head) { + ListNode current = head; + while (current != null) { + System.out.print(current.val); + if (current.next != null) { + System.out.print(" -> "); + } + current = current.next; + } + System.out.println(); + } + + // 测试用例 + public static void main(String[] args) { + RemoveNthFromEnd solution = new RemoveNthFromEnd(); + + // 测试用例1 + ListNode head1 = solution.createList(new int[]{1, 2, 3, 4, 5}); + System.out.print("输入: "); + solution.printList(head1); + System.out.println("n = 2"); + ListNode result1 = solution.removeNthFromEnd(head1, 2); + System.out.print("输出: "); + solution.printList(result1); + + // 测试用例2: 删除头结点 + ListNode head2 = solution.createList(new int[]{1}); + System.out.print("\n输入: "); + solution.printList(head2); + System.out.println("n = 1"); + ListNode result2 = solution.removeNthFromEnd(head2, 1); + System.out.print("输出: "); + solution.printList(result2); + + // 测试用例3: 删除最后一个结点 + ListNode head3 = solution.createList(new int[]{1, 2}); + System.out.print("\n输入: "); + solution.printList(head3); + System.out.println("n = 1"); + ListNode result3 = solution.removeNthFromEnd(head3, 1); + System.out.print("输出: "); + solution.printList(result3); + + // 测试用例4: 长链表 + ListNode head4 = solution.createList(new int[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); + System.out.print("\n输入: "); + solution.printList(head4); + System.out.println("n = 5"); + ListNode result4 = solution.removeNthFromEnd(head4, 5); + System.out.print("输出: "); + solution.printList(result4); + } +} +``` + +### Go 实现(计算长度法) + +```go +func removeNthFromEndByLength(head *ListNode, n int) *ListNode { + if head == nil { + return nil + } + + // 计算链表长度 + length := 0 + current := head + for current != nil { + length++ + current = current.Next + } + + // 要删除的正数位置 + pos := length - n + + // 创建哑结点 + dummy := &ListNode{0, head} + current = dummy + + // 移动到要删除结点的前一个结点 + for i := 0; i < pos; i++ { + current = current.Next + } + + // 删除结点 + current.Next = current.Next.Next + + return dummy.Next +} +``` + +### Java 实现(栈法) + +```java +import java.util.Stack; + +public ListNode removeNthFromEndByStack(ListNode head, int n) { + // 创建哑结点 + ListNode dummy = new ListNode(0, head); + + // 将所有结点压入栈 + Stack stack = new Stack<>(); + ListNode current = dummy; + while (current != null) { + stack.push(current); + current = current.next; + } + + // 弹出 n 个结点 + for (int i = 0; i < n; i++) { + stack.pop(); + } + + // 栈顶就是要删除结点的前一个结点 + ListNode prev = stack.peek(); + prev.next = prev.next.next; + + return dummy.next; +} +``` + +## 复杂度分析 + +### 双指针法 + +- **时间复杂度:** O(L) + - 其中 L 是链表长度 + - 只需遍历链表一次 + +- **空间复杂度:** O(1) + - 只使用了常数级别的额外空间 + - 只需要几个指针变量 + +### 计算长度法 + +- **时间复杂度:** O(L) + - 第一次遍历计算长度:O(L) + - 第二次遍历删除结点:O(L) + - 总时间复杂度:O(2L) = O(L) + +- **空间复杂度:** O(1) + - 只使用了常数级别的额外空间 + +### 栈法 + +- **时间复杂度:** O(L) + - 需要遍历链表一次 + +- **空间复杂度:** O(L) + - 需要额外的栈空间存储所有结点 + +## 进阶问题 + +### Q1: 如果链表是循环链表,应该如何处理? + +**A:** 需要先判断是否为循环链表,如果是,需要找到尾结点并断开循环。 + +```go +func removeNthFromEndCircular(head *ListNode, n int) *ListNode { + if head == nil { + return nil + } + + // 检测是否有环 + hasCycle := detectCycle(head) + if !hasCycle { + return removeNthFromEnd(head, n) + } + + // 如果有环,需要先找到环的入口和长度 + // 然后根据 n 的值决定如何删除 + // 这是一个复杂的问题,需要更多边界条件处理 + + return head +} + +func detectCycle(head *ListNode) bool { + slow, fast := head, head + for fast != nil && fast.Next != nil { + slow = slow.Next + fast = fast.Next.Next + if slow == fast { + return true + } + } + return false +} +``` + +### Q2: 如果要求删除前 n 个结点,应该如何修改? + +**A:** 直接遍历到第 n-1 个结点,然后删除后续所有结点。 + +```go +func removeFirstN(head *ListNode, n int) *ListNode { + if n <= 0 { + return head + } + + dummy := &ListNode{0, head} + current := dummy + + // 移动到第 n 个结点的前一个结点 + for i := 0; i < n && current != nil; i++ { + current = current.Next + } + + if current != nil { + current.Next = nil + } + + return dummy.Next +} +``` + +### Q3: 如果链表很长,如何优化内存使用? + +**A:** 使用双指针法是最优的,因为它不需要额外的空间。另外,可以考虑使用尾递归优化(如果语言支持)。 + +## P7 加分项 + +### 1. 深度理解:为什么需要哑结点? + +**哑结点的作用:** +1. **统一处理:** 避免单独处理删除头结点的特殊情况 +2. **简化边界条件:** 当要删除的是头结点时,普通方法需要特殊处理 +3. **代码简洁:** 使用哑结点后,删除操作统一为 `prev.next = prev.next.next` + +**没有哑结点的问题:** +```go +// 没有哑结点的版本(需要特殊处理删除头结点) +func removeNthFromEndWithoutDummy(head *ListNode, n int) *ListNode { + length := 0 + current := head + for current != nil { + length++ + current = current.Next + } + + if n == length { + // 要删除的是头结点,特殊处理 + return head.Next + } + + pos := length - n + current = head + for i := 0; i < pos-1; i++ { + current = current.Next + } + current.Next = current.Next.Next + + return head +} +``` + +### 2. 实战扩展:链表操作的通用技巧 + +#### 技巧1:快慢指针的应用 + +- **找中点:** fast 移动 2 步,slow 移动 1 步 +- **找倒数第 k 个:** fast 先移动 k 步 +- **检测环:** fast 移动 2 步,slow 移动 1 步 + +```go +// 找链表中点 +func findMiddle(head *ListNode) *ListNode { + slow, fast := head, head + for fast != nil && fast.Next != nil { + slow = slow.Next + fast = fast.Next.Next + } + return slow +} + +// 检测环 +func hasCycle(head *ListNode) bool { + slow, fast := head, head + for fast != nil && fast.Next != nil { + slow = slow.Next + fast = fast.Next.Next + if slow == fast { + return true + } + } + return false +} +``` + +#### 技巧2:虚拟头结点的使用 + +- **统一操作:** 避免边界条件判断 +- **简化代码:** 使删除、插入操作更简洁 +- **常见场景:** 删除操作、插入操作 + +### 3. 变形题目 + +#### 变形1:删除链表中的重复元素 + +**LeetCode 83:** 删除排序链表中的重复元素,使得每个元素只出现一次。 + +```go +func deleteDuplicates(head *ListNode) *ListNode { + if head == nil { + return nil + } + + current := head + for current.Next != nil { + if current.Val == current.Next.Val { + current.Next = current.Next.Next + } else { + current = current.Next + } + } + + return head +} +``` + +#### 变形2:删除链表中的所有重复元素 + +**LeetCode 82:** 删除排序链表中所有重复的元素,只保留原始链表中没有重复出现的数字。 + +```go +func deleteDuplicatesAll(head *ListNode) *ListNode { + dummy := &ListNode{0, head} + prev := dummy + + for prev.Next != nil { + curr := prev.Next + // 检查是否有重复 + if curr.Next != nil && curr.Val == curr.Next.Val { + // 跳过所有重复的值 + val := curr.Val + for curr != nil && curr.Val == val { + curr = curr.Next + } + prev.Next = curr + } else { + prev = prev.Next + } + } + + return dummy.Next +} +``` + +#### 变形3:旋转链表 + +**LeetCode 61:** 将链表每个节点向右移动 k 个位置。 + +```go +func rotateRight(head *ListNode, k int) *ListNode { + if head == nil || k == 0 { + return head + } + + // 计算链表长度并连接成环 + length := 1 + tail := head + for tail.Next != nil { + tail = tail.Next + length++ + } + tail.Next = head + + // 计算新的尾结点位置 + k = k % length + stepsToNewTail := length - k + newTail := head + for i := 1; i < stepsToNewTail; i++ { + newTail = newTail.Next + } + + newHead := newTail.Next + newTail.Next = nil + + return newHead +} +``` + +### 4. 优化技巧 + +#### 优化1:一次遍历删除多个结点 + +如果需要删除多个位置的结点,可以在一次遍历中完成。 + +```go +func removeNodes(head *ListNode, positions []int) *ListNode { + dummy := &ListNode{0, head} + posMap := make(map[int]bool) + for _, pos := range positions { + posMap[pos] = true + } + + prev := dummy + curr := head + index := 1 + + for curr != nil { + if posMap[index] { + prev.Next = curr.Next + } else { + prev = curr + } + curr = curr.Next + index++ + } + + return dummy.Next +} +``` + +#### 优化2:递归解法(优雅但可能栈溢出) + +```go +func removeNthFromEndRecursive(head *ListNode, n int) *ListNode { + counter := 0 + return removeHelper(head, &counter, n) +} + +func removeHelper(node *ListNode, counter *int, n int) *ListNode { + if node == nil { + return nil + } + + node.Next = removeHelper(node.Next, counter, n) + *counter++ + + if *counter == n { + return node.Next + } + + return node +} +``` + +### 5. 实际应用场景 + +- **LRU 缓存:** 删除最近最少使用的数据 +- **浏览器历史记录:** 删除特定位置的历史记录 +- **文本编辑器:** 撤销操作(删除最近的修改) +- **任务队列:** 删除超时或取消的任务 + +### 6. 面试技巧 + +**面试官可能会问:** +1. "为什么选择双指针法而不是计算长度法?" +2. "如果链表很长,递归解法会有什么问题?" +3. "如何证明你的算法是正确的?" + +**回答要点:** +1. 双指针法只需一次遍历,代码简洁,空间复杂度低 +2. 递归可能导致栈溢出,对于长链表不推荐 +3. 可以通过画图、举例、边界条件分析来证明正确性 + +### 7. 相关题目推荐 + +- LeetCode 19: 删除链表的倒数第 N 个结点(本题) +- LeetCode 61: 旋转链表 +- LeetCode 83: 删除排序链表中的重复元素 +- LeetCode 82: 删除排序链表中的所有重复元素 +- LeetCode 206: 反转链表 +- LeetCode 142: 环形链表 II diff --git a/16-LeetCode Hot 100/无重复字符的最长子串.md b/16-LeetCode Hot 100/无重复字符的最长子串.md new file mode 100644 index 0000000..c3dd47e --- /dev/null +++ b/16-LeetCode Hot 100/无重复字符的最长子串.md @@ -0,0 +1,246 @@ +# 无重复字符的最长子串 (Longest Substring Without Repeating Characters) + +LeetCode 3. Medium + +## 题目描述 + +给定一个字符串 `s` ,请你找出其中不含有重复字符的 **最长子串** 的长度。 + +**示例 1**: +``` +输入: s = "abcabcbb" +输出: 3 +解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。 +``` + +**示例 2**: +``` +输入: s = "bbbbb" +输出: 1 +解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。 +``` + +**示例 3**: +``` +输入: s = "pwwkew" +输出: 3 +解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。 + 请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。 +``` + +## 解题思路 + +### 核心思想 +使用**滑动窗口**(Sliding Window)+ **哈希表**记录字符位置。 + +### 算法流程 +1. 维护一个窗口 [left, right] +2. 使用哈希表记录每个字符最后一次出现的位置 +3. 遍历字符串: + - 如果当前字符在窗口内出现,移动 left 到重复字符的下一位 + - 更新哈希表和最大长度 + +### 复杂度分析 +- **时间复杂度**:O(n),n 为字符串长度 +- **空间复杂度**:O(min(m, n)),m 为字符集大小 + +--- + +## Go 解法 + +```go +func lengthOfLongestSubstring(s string) int { + // 记录字符最后出现的位置 + charIndex := make(map[rune]int) + maxLength := 0 + left := 0 + + for right, char := range s { + // 如果字符已存在且在窗口内,移动左边界 + if idx, ok := charIndex[char]; ok && idx >= left { + left = idx + 1 + } + + // 更新字符位置 + charIndex[char] = right + + // 更新最大长度 + if right - left + 1 > maxLength { + maxLength = right - left + 1 + } + } + + return maxLength +} +``` + +### Go 代码要点 +1. 使用 `range` 遍历字符串,自动处理 Unicode +2. `map[rune]int` 记录字符索引 +3. 条件判断:`idx >= left` 确保在窗口内 + +--- + +## Java 解法 + +```java +class Solution { + public int lengthOfLongestSubstring(String s) { + // 记录字符最后出现的位置 + Map charIndex = new HashMap<>(); + int maxLength = 0; + int left = 0; + + for (int right = 0; right < s.length(); right++) { + char char = s.charAt(right); + + // 如果字符已存在且在窗口内,移动左边界 + if (charIndex.containsKey(char) && charIndex.get(char) >= left) { + left = charIndex.get(char) + 1; + } + + // 更新字符位置 + charIndex.put(char, right); + + // 更新最大长度 + maxLength = Math.max(maxLength, right - left + 1); + } + + return maxLength; + } +} +``` + +### Java 代码要点 +1. `HashMap` 记录字符索引 +2. `charAt()` 遍历字符串 +3. `Math.max()` 更新最大值 + +--- + +## 图解过程 + +``` +字符串: "abcabcbb" + +步骤1: [a]bcabcbb + left=0, right=0, maxLength=1 + +步骤2: [a,b]cabcbb + left=0, right=1, maxLength=2 + +步骤3: [a,b,c]abcbb + left=0, right=2, maxLength=3 + +步骤4: a[b,c,a]bcbb (发现重复,left移动) + left=1, right=3, maxLength=3 + +步骤5: ab[c,a,b]cbb (发现重复,left移动) + left=2, right=4, maxLength=3 + +步骤6: abc[a,b,c]bb (发现重复,left移动) + left=3, right=5, maxLength=3 + +步骤7: abca[b,c,b]b (发现重复,left移动) + left=4, right=6, maxLength=3 + +步骤8: abcab[c,b,b] (发现重复,left移动) + left=5, right=7, maxLength=3 + +结果: maxLength = 3 +``` + +--- + +## 进阶问题 + +### Q1: 如何返回最长子串本身? +```go +func longestSubstring(s string) string { + charIndex := make(map[rune]int) + maxLength := 0 + left := 0 + start := 0 // 记录起始位置 + + for right, char := range s { + if idx, ok := charIndex[char]; ok && idx >= left { + left = idx + 1 + } + charIndex[char] = right + + if right - left + 1 > maxLength { + maxLength = right - left + 1 + start = left + } + } + + return s[start : start+maxLength] +} +``` + +### Q2: 如果字符集有限(如只有小写字母),如何优化? +**优化**:使用数组代替哈希表 + +```go +func lengthOfLongestSubstring(s string) int { + charIndex := [128]int{} // ASCII 字符集 + for i := range charIndex { + charIndex[i] = -1 + } + + maxLength := 0 + left := 0 + + for right := 0; right < len(s); right++ { + char := s[right] + if charIndex[char] >= left { + left = charIndex[char] + 1 + } + charIndex[char] = right + maxLength = max(maxLength, right-left+1) + } + + return maxLength +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} +``` + +--- + +## P7 加分项 + +### 深度理解 +- **滑动窗口**:维护动态窗口,左边界根据重复字符调整 +- **哈希表优化**:数组 vs HashMap,时间/空间权衡 +- **边界处理**:重复字符在窗口外的情况 + +### 实战扩展 +- **流式数据**:处理超大字符串或流式输入 +- **多线程**:分段计算后合并 +- **业务场景**:日志去重、用户行为分析 + +### 变形题目 +1. [159. 至多包含两个不同字符的最长子串](https://leetcode.cn/problems/longest-substring-with-at-most-two-distinct-characters/) +2. [340. 至多包含 K 个不同字符的最长子串](https://leetcode.cn/problems/longest-substring-with-at-most-k-distinct-characters/) + +--- + +## 总结 + +这道题的核心是: +1. **滑动窗口**:动态调整窗口边界 +2. **哈希表**:记录字符位置,快速判断重复 +3. **双指针**:left 和 right 指针协同移动 + +**易错点**: +- 忘记判断重复字符是否在窗口内(`idx >= left`) +- 更新 left 的时机 +- 数组越界(使用数组代替哈希表时) + +**最优解法**:滑动窗口 + 哈希表,时间 O(n),空间 O(min(m, n)) diff --git a/16-LeetCode Hot 100/电话号码的字母组合.md b/16-LeetCode Hot 100/电话号码的字母组合.md new file mode 100644 index 0000000..64043a3 --- /dev/null +++ b/16-LeetCode Hot 100/电话号码的字母组合.md @@ -0,0 +1,647 @@ +# 电话号码的字母组合 (Letter Combinations of a Phone Number) + +## 题目描述 + +给定一个仅包含数字 `2-9` 的字符串,返回所有它能表示的字母组合。答案可以按 **任意顺序** 返回。 + +给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。 + +``` +2: abc +3: def +4: ghi +5: jkl +6: mno +7: pqrs +8: tuv +9: wxyz +``` + +### 示例 + +**示例 1:** +``` +输入:digits = "23" +输出:["ad","ae","af","bd","be","bf","cd","ce","cf"] +``` + +**示例 2:** +``` +输入:digits = "" +输出:[] +``` + +**示例 3:** +``` +输入:digits = "2" +输出:["a","b","c"] +``` + +### 约束条件 + +- `0 <= digits.length <= 4` +- `digits[i]` 是范围 `['2', '9']` 的一个数字。 + +## 解题思路 + +### 方法一:回溯法(推荐) + +**核心思想:**使用回溯算法遍历所有可能的字母组合。每次递归处理一个数字,尝试该数字对应的所有字母。 + +**算法步骤:** +1. 建立数字到字母的映射表 +2. 如果输入为空,直接返回空数组 +3. 使用回溯函数生成组合: + - 当前索引等于 `digits` 长度时,将当前组合加入结果 + - 否则,遍历当前数字对应的所有字母,递归处理下一个数字 + +### 方法二:队列迭代法 + +**核心思想:**使用队列逐层构建所有可能的组合。每次处理一个数字,将队列中所有组合与该数字对应的所有字母组合。 + +**算法步骤:** +1. 建立数字到字母的映射表 +2. 初始化队列为空字符串 +3. 对于每个数字: + - 取出队列中所有现有组合 + - 将每个组合与当前数字对应的所有字母拼接 + - 将新组合放回队列 +4. 返回队列中的所有组合 + +### 方法三:递归分治法 + +**核心思想:**将问题分解为子问题。对于 `digits = "23"`,先处理 `"2"` 得到 `["a","b","c"]`,再处理 `"3"` 得到 `["d","e","f"]`,最后组合所有可能。 + +## 代码实现 + +### Go 实现(回溯法) + +```go +package main + +import ( + "fmt" +) + +func letterCombinations(digits string) []string { + if digits == "" { + return []string{} + } + + // 数字到字母的映射 + phoneMap := map[byte]string{ + '2': "abc", + '3': "def", + '4': "ghi", + '5': "jkl", + '6': "mno", + '7': "pqrs", + '8': "tuv", + '9': "wxyz", + } + + result := []string{} + current := []byte{} + + var backtrack func(index int) + backtrack = func(index int) { + if index == len(digits) { + // 将当前组合加入结果 + result = append(result, string(current)) + return + } + + // 获取当前数字对应的所有字母 + letters := phoneMap[digits[index]] + for i := 0; i < len(letters); i++ { + // 选择当前字母 + current = append(current, letters[i]) + // 递归处理下一个数字 + backtrack(index + 1) + // 撤销选择(回溯) + current = current[:len(current)-1] + } + } + + backtrack(0) + return result +} + +// 测试用例 +func main() { + // 测试用例1 + digits1 := "23" + fmt.Printf("输入: %s\n", digits1) + fmt.Printf("输出: %v\n", letterCombinations(digits1)) + + // 测试用例2 + digits2 := "" + fmt.Printf("\n输入: %s\n", digits2) + fmt.Printf("输出: %v\n", letterCombinations(digits2)) + + // 测试用例3 + digits3 := "2" + fmt.Printf("\n输入: %s\n", digits3) + fmt.Printf("输出: %v\n", letterCombinations(digits3)) + + // 测试用例4: 最长输入 + digits4 := "9999" + fmt.Printf("\n输入: %s\n", digits4) + fmt.Printf("输出长度: %d\n", len(letterCombinations(digits4))) +} +``` + +### Java 实现(回溯法) + +```java +import java.util.ArrayList; +import java.util.List; + +public class LetterCombinations { + + public List letterCombinations(String digits) { + List result = new ArrayList<>(); + if (digits == null || digits.length() == 0) { + return result; + } + + // 数字到字母的映射 + String[] phoneMap = { + "", // 0 + "", // 1 + "abc", // 2 + "def", // 3 + "ghi", // 4 + "jkl", // 5 + "mno", // 6 + "pqrs", // 7 + "tuv", // 8 + "wxyz" // 9 + }; + + StringBuilder current = new StringBuilder(); + backtrack(digits, 0, phoneMap, current, result); + return result; + } + + private void backtrack(String digits, int index, String[] phoneMap, + StringBuilder current, List result) { + if (index == digits.length()) { + result.add(current.toString()); + return; + } + + // 获取当前数字对应的所有字母 + int digit = digits.charAt(index) - '0'; + String letters = phoneMap[digit]; + + for (int i = 0; i < letters.length(); i++) { + // 选择当前字母 + current.append(letters.charAt(i)); + // 递归处理下一个数字 + backtrack(digits, index + 1, phoneMap, current, result); + // 撤销选择(回溯) + current.deleteCharAt(current.length() - 1); + } + } + + // 测试用例 + public static void main(String[] args) { + LetterCombinations solution = new LetterCombinations(); + + // 测试用例1 + String digits1 = "23"; + System.out.println("输入: " + digits1); + System.out.println("输出: " + solution.letterCombinations(digits1)); + + // 测试用例2 + String digits2 = ""; + System.out.println("\n输入: " + digits2); + System.out.println("输出: " + solution.letterCombinations(digits2)); + + // 测试用例3 + String digits3 = "2"; + System.out.println("\n输入: " + digits3); + System.out.println("输出: " + solution.letterCombinations(digits3)); + + // 测试用例4: 最长输入 + String digits4 = "9999"; + System.out.println("\n输入: " + digits4); + System.out.println("输出长度: " + solution.letterCombinations(digits4).size()); + } +} +``` + +### Go 实现(队列迭代法) + +```go +func letterCombinationsIterative(digits string) []string { + if digits == "" { + return []string{} + } + + phoneMap := map[string]string{ + "2": "abc", + "3": "def", + "4": "ghi", + "5": "jkl", + "6": "mno", + "7": "pqrs", + "8": "tuv", + "9": "wxyz", + } + + // 初始化队列 + queue := []string{""} + + for _, digit := range digits { + letters := phoneMap[string(digit)] + newQueue := []string{} + + // 取出队列中所有组合,与当前字母组合 + for _, combination := range queue { + for i := 0; i < len(letters); i++ { + newCombination := combination + string(letters[i]) + newQueue = append(newQueue, newCombination) + } + } + + queue = newQueue + } + + return queue +} +``` + +### Java 实现(队列迭代法) + +```java +public List letterCombinationsIterative(String digits) { + List result = new ArrayList<>(); + if (digits == null || digits.length() == 0) { + return result; + } + + String[] phoneMap = { + "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz" + }; + + // 初始化队列 + List queue = new ArrayList<>(); + queue.add(""); + + for (int i = 0; i < digits.length(); i++) { + int digit = digits.charAt(i) - '0'; + String letters = phoneMap[digit]; + List newQueue = new ArrayList<>(); + + // 取出队列中所有组合,与当前字母组合 + for (String combination : queue) { + for (int j = 0; j < letters.length(); j++) { + newQueue.add(combination + letters.charAt(j)); + } + } + + queue = newQueue; + } + + return queue; +} +``` + +## 复杂度分析 + +### 回溯法 + +- **时间复杂度:** O(3^m × 4^n) + - 其中 m 是对应 3 个字母的数字个数(2, 3, 4, 5, 6, 8) + - n 是对应 4 个字母的数字个数(7, 9) + - 最坏情况:所有数字都是 7 或 9,时间复杂度为 O(4^n) + - 最好情况:所有数字都是 2 或 3,时间复杂度为 O(3^n) + +- **空间复杂度:** O(m + n) + - 其中 m 是输入数字的长度(递归栈深度) + - n 是所有可能组合的总数 + - 需要存储结果数组,空间复杂度为 O(3^m × 4^n) + +### 队列迭代法 + +- **时间复杂度:** O(3^m × 4^n) + - 与回溯法相同,需要遍历所有可能的组合 + +- **空间复杂度:** O(3^m × 4^n) + - 需要存储所有中间结果和最终结果 + +## 进阶问题 + +### Q1: 如果数字字符串包含 '0' 和 '1',应该如何处理? + +**A:** '0' 和 '1' 不对应任何字母,可以跳过或返回空字符串。 + +```go +// Go 版本:跳过 0 和 1 +func letterCombinationsWithZero(digits string) []string { + if digits == "" { + return []string{} + } + + phoneMap := map[byte]string{ + '0': "", + '1': "", + '2': "abc", + // ... 其他映射 + } + + // 在回溯时,如果当前数字没有对应字母,直接跳过 + var backtrack func(index int) + backtrack = func(index int) { + if index == len(digits) { + if len(current) > 0 { // 确保至少有一个字母 + result = append(result, string(current)) + } + return + } + + letters := phoneMap[digits[index]] + if letters == "" { + // 跳过没有字母的数字 + backtrack(index + 1) + } else { + for i := 0; i < len(letters); i++ { + current = append(current, letters[i]) + backtrack(index + 1) + current = current[:len(current)-1] + } + } + } + + backtrack(0) + return result +} +``` + +### Q2: 如果要求结果按字典序排序,应该如何实现? + +**A:** 在生成所有组合后,使用排序算法对结果进行排序。 + +```go +import "sort" + +func letterCombinationsSorted(digits string) []string { + result := letterCombinations(digits) + sort.Strings(result) + return result +} +``` + +### Q3: 如果只要求返回第 k 个组合(从 1 开始),应该如何优化? + +**A:** 可以直接计算第 k 个组合,无需生成所有组合。 + +```go +func getKthCombination(digits string, k int) string { + if digits == "" || k <= 0 { + return "" + } + + phoneMap := map[byte]string{ + '2': "abc", + '3': "def", + '4': "ghi", + '5': "jkl", + '6': "mno", + '7': "pqrs", + '8': "tuv", + '9': "wxyz", + } + + result := make([]byte, len(digits)) + k-- // 转换为从 0 开始 + + for i := 0; i < len(digits); i++ { + letters := phoneMap[digits[i]] + count := len(letters) + + // 计算当前位置应该选择哪个字母 + index := k % count + result[i] = letters[index] + + // 更新 k + k /= count + } + + return string(result) +} +``` + +## P7 加分项 + +### 1. 深度理解:回溯法的本质 + +**回溯法 = 暴力搜索 + 剪枝** + +- **暴力搜索:**遍历所有可能的解空间 +- **剪枝:**在搜索过程中跳过不可能的解 + +**回溯法的三个关键要素:** +1. **路径:**已经做出的选择 +2. **选择列表:**当前可以做的选择 +3. **结束条件:**到达决策树底层,无法再做选择 + +**回溯法框架:** + +```go +func backtrack(路径, 选择列表) { + if 满足结束条件 { + result = append(result, 路径) + return + } + + for 选择 in 选择列表 { + // 做选择 + 路径.add(选择) + backtrack(路径, 选择列表) + // 撤销选择 + 路径.remove(选择) + } +} +``` + +### 2. 实战扩展:通用组合问题 + +#### 例子:生成所有有效的 IP 地址 + +**LeetCode 93:** 给定一个只包含数字的字符串,复原它并返回所有可能的 IP 地址格式。 + +```go +func restoreIpAddresses(s string) []string { + result := []string{} + if len(s) < 4 || len(s) > 12 { + return result + } + + current := []string{} + + var backtrack func(start int) + backtrack = func(start int) { + // 已经有 4 段,且用完了所有字符 + if len(current) == 4 { + if start == len(s) { + result = append(result, strings.Join(current, ".")) + } + return + } + + // 尝试取 1-3 个字符 + for i := 1; i <= 3 && start+i <= len(s); i++ { + segment := s[start : start+i] + + // 检查是否有效的 IP 段 + if (i > 1 && segment[0] == '0') || // 不能有前导 0 + (i == 3 && segment > "255") { // 不能大于 255 + continue + } + + current = append(current, segment) + backtrack(start + i) + current = current[:len(current)-1] + } + } + + backtrack(0) + return result +} +``` + +### 3. 变形题目 + +#### 变形1:带权重的字母组合 + +每个数字对应字母,但字母有不同的权重(频率),要求按权重排序返回组合。 + +#### 变形2:键盘路径 + +给定两个数字,返回从第一个数字的字母到第二个数字的字母的所有路径。 + +#### 变形3:有效单词组合 + +给定数字字符串和单词列表,返回所有能组成的有效单词组合。 + +```go +func letterCombinationsValidWords(digits string, wordList []string) []string { + allCombinations := letterCombinations(digits) + wordSet := make(map[string]bool) + for _, word := range wordList { + wordSet[word] = true + } + + result := []string{} + for _, combo := range allCombinations { + if wordSet[combo] { + result = append(result, combo) + } + } + + return result +} +``` + +### 4. 优化技巧 + +#### 优化1:提前终止 + +如果当前组合不可能形成有效解,提前终止递归。 + +```go +func letterCombinationsPrune(digits string) []string { + // 预先计算每个数字的字母数量 + letterCount := map[byte]int{ + '2': 3, '3': 3, '4': 3, '5': 3, + '6': 3, '7': 4, '8': 3, '9': 4, + } + + // 计算总组合数 + totalCombinations := 1 + for _, digit := range digits { + totalCombinations *= letterCount[digit] + } + + // 如果组合数过多,可以提前返回 + if totalCombinations > 10000 { + return []string{} // 或者返回部分结果 + } + + return letterCombinations(digits) +} +``` + +#### 优化2:并行处理 + +对于长数字字符串,可以并行处理不同分支。 + +```go +func letterCombinationsParallel(digits string) []string { + if len(digits) <= 2 { + return letterCombinations(digits) + } + + // 分割任务 + mid := len(digits) / 2 + leftDigits := digits[:mid] + rightDigits := digits[mid:] + + // 并行处理 + leftCh := make(chan []string, 1) + rightCh := make(chan []string, 1) + + go func() { + leftCh <- letterCombinations(leftDigits) + }() + + go func() { + rightCh <- letterCombinations(rightDigits) + }() + + leftCombinations := <-leftCh + rightCombinations := <-rightCh + + // 合并结果 + result := []string{} + for _, left := range leftCombinations { + for _, right := range rightCombinations { + result = append(result, left+right) + } + } + + return result +} +``` + +### 5. 实际应用场景 + +- **短信验证码:** 生成验证码的所有可能组合 +- **密码破解:** 暴力破解基于数字密码的字母组合 +- **自动补全:** 输入部分数字时,提示所有可能的单词 +- **数据压缩:** 使用数字编码代替字母组合 + +### 6. 面试技巧 + +**面试官可能会问:** +1. "回溯法和递归有什么区别?" +2. "如何优化空间复杂度?" +3. "如果输入非常长,如何处理?" + +**回答要点:** +1. 回溯法是递归的一种特殊形式,强调在搜索过程中撤销选择 +2. 使用迭代法可以减少递归栈空间 +3. 考虑分治、并行处理或者只返回部分结果 + +### 7. 相关题目推荐 + +- LeetCode 17: 电话号码的字母组合(本题) +- LeetCode 22: 括号生成 +- LeetCode 39: 组合总和 +- LeetCode 46: 全排列 +- LeetCode 77: 组合 +- LeetCode 78: 子集 +- LeetCode 93: 复原 IP 地址 diff --git a/16-LeetCode Hot 100/盛最多水的容器.md b/16-LeetCode Hot 100/盛最多水的容器.md new file mode 100644 index 0000000..449cc3d --- /dev/null +++ b/16-LeetCode Hot 100/盛最多水的容器.md @@ -0,0 +1,439 @@ +# 盛最多水的容器 (Container With Most Water) + +## 题目描述 + +给定一个长度为 `n` 的整数数组 `height`。有 `n` 条垂直线,第 `i` 条线的两个端点是 `(i, 0)` 和 `(i, height[i])`。 + +找出两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。 + +返回容器可以储存的最大水量。 + +**说明:**你不能倾斜容器。 + +### 示例 + +**示例 1:** +``` +输入:[1,8,6,2,5,4,8,3,7] +输出:49 +解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。 +``` + +**示例 2:** +``` +输入:[1,1] +输出:1 +``` + +### 约束条件 + +- `n == height.length` +- `2 <= n <= 10^5` +- `0 <= height[i] <= 10^4` + +## 解题思路 + +### 方法一:双指针法(最优解) + +**核心思想:**使用两个指针,一个在数组开头,一个在数组末尾。每次移动较短的指针向中间靠拢。 + +**为什么这样做?** +- 容器的容量由 `min(height[left], height[right]) * (right - left)` 决定 +- 如果移动较高的指针,宽度减小,高度只能保持不变或减小,容量一定不会增大 +- 如果移动较短的指针,虽然宽度减小,但可能会找到更高的线,从而增大容量 + +**算法步骤:** +1. 初始化 `left = 0`,`right = len(height) - 1`,`maxArea = 0` +2. 当 `left < right` 时: + - 计算当前面积:`area = min(height[left], height[right]) * (right - left)` + - 更新 `maxArea = max(maxArea, area)` + - 如果 `height[left] < height[right]`,则 `left++`,否则 `right--` +3. 返回 `maxArea` + +### 方法二:暴力枚举(不推荐) + +枚举所有可能的线对,计算每对线构成的容器容量,取最大值。时间复杂度 O(n²),会超时。 + +## 代码实现 + +### Go 实现 + +```go +package main + +import ( + "fmt" +) + +func maxArea(height []int) int { + left, right := 0, len(height)-1 + maxArea := 0 + + for left < right { + // 计算当前面积 + width := right - left + h := height[left] + if height[right] < h { + h = height[right] + } + area := width * h + + // 更新最大面积 + if area > maxArea { + maxArea = area + } + + // 移动较短的指针 + if height[left] < height[right] { + left++ + } else { + right-- + } + } + + return maxArea +} + +// 测试用例 +func main() { + // 测试用例1 + height1 := []int{1, 8, 6, 2, 5, 4, 8, 3, 7} + fmt.Printf("输入: %v\n", height1) + fmt.Printf("输出: %d\n", maxArea(height1)) // 期望输出: 49 + + // 测试用例2 + height2 := []int{1, 1} + fmt.Printf("\n输入: %v\n", height2) + fmt.Printf("输出: %d\n", maxArea(height2)) // 期望输出: 1 + + // 测试用例3: 递增序列 + height3 := []int{1, 2, 3, 4, 5} + fmt.Printf("\n输入: %v\n", height3) + fmt.Printf("输出: %d\n", maxArea(height3)) // 期望输出: 6 + + // 测试用例4: 递减序列 + height4 := []int{5, 4, 3, 2, 1} + fmt.Printf("\n输入: %v\n", height4) + fmt.Printf("输出: %d\n", maxArea(height4)) // 期望输出: 6 + + // 测试用例5: 包含0 + height5 := []int{0, 2} + fmt.Printf("\n输入: %v\n", height5) + fmt.Printf("输出: %d\n", maxArea(height5)) // 期望输出: 0 +} +``` + +### Java 实现 + +```java +public class ContainerWithMostWater { + + public int maxArea(int[] height) { + int left = 0; + int right = height.length - 1; + int maxArea = 0; + + while (left < right) { + // 计算当前面积 + int width = right - left; + int h = Math.min(height[left], height[right]); + int area = width * h; + + // 更新最大面积 + maxArea = Math.max(maxArea, area); + + // 移动较短的指针 + if (height[left] < height[right]) { + left++; + } else { + right--; + } + } + + return maxArea; + } + + // 测试用例 + public static void main(String[] args) { + ContainerWithMostWater solution = new ContainerWithMostWater(); + + // 测试用例1 + int[] height1 = {1, 8, 6, 2, 5, 4, 8, 3, 7}; + System.out.println("输入: [1, 8, 6, 2, 5, 4, 8, 3, 7]"); + System.out.println("输出: " + solution.maxArea(height1)); // 期望输出: 49 + + // 测试用例2 + int[] height2 = {1, 1}; + System.out.println("\n输入: [1, 1]"); + System.out.println("输出: " + solution.maxArea(height2)); // 期望输出: 1 + + // 测试用例3: 递增序列 + int[] height3 = {1, 2, 3, 4, 5}; + System.out.println("\n输入: [1, 2, 3, 4, 5]"); + System.out.println("输出: " + solution.maxArea(height3)); // 期望输出: 6 + + // 测试用例4: 递减序列 + int[] height4 = {5, 4, 3, 2, 1}; + System.out.println("\n输入: [5, 4, 3, 2, 1]"); + System.out.println("输出: " + solution.maxArea(height4)); // 期望输出: 6 + + // 测试用例5: 包含0 + int[] height5 = {0, 2}; + System.out.println("\n输入: [0, 2]"); + System.out.println("输出: " + solution.maxArea(height5)); // 期望输出: 0 + } +} +``` + +## 复杂度分析 + +### 双指针法 +- **时间复杂度:** O(n) + - 只需遍历数组一次,每次移动一个指针 + - 指针最多移动 n 次 + +- **空间复杂度:** O(1) + - 只使用了常数级别的额外空间 + - 只需要几个变量存储指针和最大面积 + +### 暴力枚举 +- **时间复杂度:** O(n²) + - 需要枚举所有可能的线对 + - 共有 n(n-1)/2 种组合 + +- **空间复杂度:** O(1) + - 只需要常数级别的额外空间 + +## 进阶问题 + +### Q1: 如果可以倾斜容器,问题会如何变化? +**A:** 如果可以倾斜容器,问题会变得复杂得多。需要考虑水的倾斜角度和容器的几何形状,这涉及到更多的物理和几何计算。 + +### Q2: 如果需要返回构成最大容器的两条线的索引,应该如何修改? +**A:** 在更新 `maxArea` 的同时,记录当前的 `left` 和 `right` 索引。 + +```go +// Go 版本 +func maxAreaWithIndex(height []int) (int, int, int) { + left, right := 0, len(height)-1 + maxArea, bestL, bestR := 0, 0, 0 + + for left < right { + h := height[left] + if height[right] < h { + h = height[right] + } + area := (right - left) * h + + if area > maxArea { + maxArea = area + bestL, bestR = left, right + } + + if height[left] < height[right] { + left++ + } else { + right-- + } + } + + return maxArea, bestL, bestR +} +``` + +### Q3: 如果数组中有负数,应该如何处理? +**A:** 如果高度可以为负数,需要先过滤掉负数或取绝对值。通常物理意义上的高度不应为负,但如果题目允许,可以这样处理: + +```go +// 处理负数:取绝对值 +func maxAreaWithNegative(height []int) int { + left, right := 0, len(height)-1 + maxArea := 0 + + for left < right { + h := height[left] + if height[right] < h { + h = height[right] + } + h = abs(h) // 取绝对值 + area := (right - left) * h + maxArea = max(maxArea, area) + + if abs(height[left]) < abs(height[right]) { + left++ + } else { + right-- + } + } + + return maxArea +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} +``` + +## P7 加分项 + +### 1. 深度理解:为什么双指针法一定正确? + +**数学证明:** + +假设当前指针在 `left` 和 `right`,且 `height[left] < height[right]`。 + +我们要证明:移动 `right` 指针一定不会得到更大的面积。 + +- 当前面积:`S1 = height[left] * (right - left)` +- 移动 `right` 后的面积:`S2 = min(height[left], height[right-1]) * (right - 1 - left)` +- 由于 `height[left] < height[right]`,且 `right-1 < right` +- 所以 `S2 <= height[left] * (right - 1 - left) < height[left] * (right - left) = S1` + +因此,移动较高的指针不会得到更大的面积。 + +### 2. 实战扩展:接雨水问题 (Trapping Rain Water) + +**LeetCode 42:** 给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。 + +```go +func trap(height []int) int { + if len(height) < 3 { + return 0 + } + + left, right := 0, len(height)-1 + leftMax, rightMax := 0, 0 + water := 0 + + for left < right { + if height[left] < height[right] { + if height[left] >= leftMax { + leftMax = height[left] + } else { + water += leftMax - height[left] + } + left++ + } else { + if height[right] >= rightMax { + rightMax = height[right] + } else { + water += rightMax - height[right] + } + right-- + } + } + + return water +} +``` + +**核心区别:** +- 盛水容器:找两条线构成最大面积 +- 接雨水:计算所有能接的雨水总量 + +### 3. 变形题目 + +#### 变形1:盛最多水的容器 II(允许倾斜) + +如果允许容器倾斜,最大水量取决于两条线之间的最小距离和角度。 + +#### 变形2:三维盛水 + +给定一个 m × n 的矩阵,每个格子表示高度,找出能盛最多水的四个角构成的容器。 + +#### 变形3:动态盛水 + +容器的高度会随时间变化,求某个时间段内能盛的最大水量。 + +### 4. 优化技巧 + +#### 优化1:提前终止 + +如果当前可能的面积(即使宽度最大)已经小于 `maxArea`,可以提前终止。 + +```go +func maxAreaOptimized(height []int) int { + left, right := 0, len(height)-1 + maxArea := 0 + + for left < right { + area := (right - left) * min(height[left], height[right]) + maxArea = max(maxArea, area) + + // 提前终止:如果当前宽度已经很窄,可能无法超过maxArea + if right-left <= maxArea/max(height[left], height[right]) { + break + } + + if height[left] < height[right] { + left++ + } else { + right-- + } + } + + return maxArea +} +``` + +#### 优化2:跳过明显不可能的线 + +如果移动后的线高度比移动前还低,可以继续移动直到找到更高的线。 + +```go +func maxAreaSkip(height []int) int { + left, right := 0, len(height)-1 + maxArea := 0 + + for left < right { + area := (right - left) * min(height[left], height[right]) + maxArea = max(maxArea, area) + + if height[left] < height[right] { + oldLeft := left + left++ + // 跳过比oldLeft还低的线 + for left < right && height[left] <= height[oldLeft] { + left++ + } + } else { + oldRight := right + right-- + // 跳过比oldRight还低的线 + for left < right && height[right] <= height[oldRight] { + right-- + } + } + } + + return maxArea +} +``` + +### 5. 实际应用场景 + +- **水库设计:** 计算水库的最大蓄水量 +- **城市规划:** 确定建筑物之间的最佳距离以最大化绿化面积 +- **数据压缩:** 在某些压缩算法中寻找最优的分段点 + +### 6. 面试技巧 + +**面试官可能会问:** +1. "为什么双指针法一定能找到最优解?" +2. "如果数组有 10^8 个元素,你的算法还能用吗?" +3. "如何证明你的算法是正确的?" + +**回答要点:** +1. 给出数学证明(如上所述) +2. 讨论算法的局限性,考虑分布式处理 +3. 提供正确的证明思路 + +### 7. 相关题目推荐 + +- LeetCode 42: 接雨水 +- LeetCode 11: 盛最多水的容器(本题) +- LeetCode 84: 柱状图中最大的矩形 +- LeetCode 85: 最大矩形