更新时间:2025-03-29
- LeetCode题解专栏:实战算法解题 (专栏)
- 技术博客总目录:计算机技术系列目录页
优先整理热门100及面试150,不定期持续更新,欢迎关注!
21. 合并两个有序链表
将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
示例 1:
输入:l1 = [1,2,4], l2 = [1,3,4]
输出:[1,1,2,3,4,4]
示例 2:
输入:l1 = [], l2 = []
输出:[]
示例 3:
输入:l1 = [], l2 = [0]
输出:[0]
提示:
两个链表的节点数目范围是 [0, 50]
-100 <= Node.val <= 100
l1 和 l2 均按 非递减顺序 排列
方法一:迭代法
使用迭代法合并两个有序链表,通过创建一个 哑节点(dummy node) 作为新链表的起始点,逐步比较两个链表的当前节点值,将较小的节点连接到新链表中,直到其中一个链表遍历完毕,然后将剩余链表直接链接到新链表的尾部。
- 初始化:创建哑节点
dummy
和当前指针curr
,初始时curr
指向dummy
。 - 遍历比较:
- 当两个链表均不为空时,比较它们的当前节点值。
- 将较小的节点链接到
curr.next
,并移动对应的链表指针。 - 移动
curr
到下一个位置。
- 处理剩余节点:将非空链表直接链接到
curr.next
。 - 返回结果:返回
dummy.next
作为合并后的链表头节点。
代码实现(Java):
class Solution {
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
ListNode dummy = new ListNode(-1);
ListNode curr = dummy;
while (list1 != null && list2 != null) {
if (list1.val <= list2.val) {
curr.next = list1;
list1 = list1.next;
} else {
curr.next = list2;
list2 = list2.next;
}
curr = curr.next;
}
curr.next = list1 != null ? list1 : list2;
return dummy.next;
}
}
复杂度分析:
- 时间复杂度:
O(m + n)
,其中m
和n
分别为两个链表的长度。 - 空间复杂度:
O(1)
,仅使用固定大小的额外空间。
方法二:递归法
递归法通过比较两个链表头节点的值,将较小节点的 next
指针指向剩余链表合并的结果,递归终止条件为其中一个链表为空时直接返回另一个链表。
- 终止条件:
- 若
list1
为空,返回list2
。 - 若
list2
为空,返回list1
。
- 若
- 递归合并:
- 比较两个链表头节点的值,较小节点的
next
指向递归合并后的结果。 - 返回较小的节点作为当前子链表的头节点。
- 比较两个链表头节点的值,较小节点的
代码实现(Java):
class Solution {
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
if (list1 == null) return list2;
if (list2 == null) return list1;
if (list1.val <= list2.val) {
list1.next = mergeTwoLists(list1.next, list2);
return list1;
} else {
list2.next = mergeTwoLists(list1, list2.next);
return list2;
}
}
}
复杂度分析:
- 时间复杂度:
O(m + n)
,递归遍历所有节点。 - 空间复杂度:
O(m + n)
,递归调用栈的深度最大为两链表长度之和。
对比总结
方法 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
迭代法 | 空间复杂度低,无递归开销 | 代码相对较长 | 处理大规模链表时更优 |
递归法 | 代码简洁,逻辑清晰 | 空间复杂度高,可能栈溢出 | 链表较短或对代码简洁性要求高时 |
22. 括号生成
数字 n
代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
示例 1:
输入:n = 3
输出:["((()))","(()())","(())()","()(())","()()()"]
示例 2:
输入:n = 1
输出:["()"]
提示:
1 <= n <= 8
方法一:回溯法(深度优先搜索)
通过递归生成所有可能的有效括号组合,优先添加左括号并在条件允许时添加右括号,确保右括号数量不超过左括号。
代码实现(Java):
public class Solution {
public List<String> generateParenthesis(int n) {
List<String> res = new ArrayList<>();
backtrack(res, new StringBuilder(), 0, 0, n);
return res;
}
private void backtrack(List<String> res, StringBuilder current, int left, int right, int n) {
if (current.length() == 2 * n) {
res.add(current.toString());
return;
}
if (left < n) {
current.append('(');
backtrack(res, current, left + 1, right, n);
current.deleteCharAt(current.length() - 1); // 回溯
}
if (right < left) {
current.append(')');
backtrack(res, current, left, right + 1, n);
current.deleteCharAt(current.length() - 1); // 回溯
}
}
}
复杂度分析:
- 时间复杂度:
O(4^n / √n)
,由卡塔兰数公式决定,每个组合的生成时间为O(1)
均摊。 - 空间复杂度:
O(n)
,递归栈深度最大为2n
,字符串长度最多为2n
。
方法二:动态规划(递推构造)
利用已知较小规模的结果递推生成较大规模的结果,将问题分解为内部和外部括号组合的拼接。
代码实现(Java):
public class Solution {
public List<String> generateParenthesis(int n) {
List<List<String>> dp = new ArrayList<>();
dp.add(List.of("")); // dp[0] 初始化为空字符串
for (int i = 1; i <= n; i++) {
List<String> current = new ArrayList<>();
for (int k = 0; k < i; k++) {
// 内部 k 对括号,外部 i-1-k 对括号
for (String inner : dp.get(k)) {
for (String outer : dp.get(i - 1 - k)) {
current.add("(" + inner + ")" + outer);
}
}
}
dp.add(current);
}
return dp.get(n);
}
}
复杂度分析:
- 时间复杂度:
O(n^2 * C(n))
,C(n)
为卡塔兰数,需要多层嵌套循环。 - 空间复杂度:
O(n * C(n))
,存储所有中间结果。
方法三:迭代法(广度优先搜索)
用队列保存中间状态,逐步扩展每个可能的括号组合,直到达到目标长度。
代码实现(Java):
public class Solution {
static class Node {
String str;
int left;
int right;
public Node(String s, int l, int r) {
str = s;
left = l;
right = r;
}
}
public List<String> generateParenthesis(int n) {
List<String> res = new ArrayList<>();
Queue<Node> queue = new LinkedList<>();
queue.offer(new Node("", 0, 0));
while (!queue.isEmpty()) {
Node node = queue.poll();
if (node.str.length() == 2 * n) {
res.add(node.str);
continue;
}
if (node.left < n) {
queue.offer(new Node(node.str + "(", node.left + 1, node.right));
}
if (node.right < node.left) {
queue.offer(new Node(node.str + ")", node.left, node.right + 1));
}
}
return res;
}
}
复杂度分析:
- 时间复杂度:
O(4^n / √n)
,与回溯法相同。 - 空间复杂度:
O(4^n / √n)
,队列中存储所有中间状态的字符串。
对比总结
- 回溯法 是最高效的实现,直接通过剪枝避免无效路径,代码简洁。
- 动态规划 思路巧妙,但空间占用较大,适合研究问题分解的规律。
- 迭代法(BFS) 无递归栈溢出风险,适合需要迭代实现的场景。
23. 合并 K 个升序链表
给你一个链表数组,每个链表都已经按升序排列。
请你将所有链表合并到一个升序链表中,返回合并后的链表。
示例 1:
输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
解释:链表数组如下,
[1->4->5, 1->3->4, 2->6]
将它们合并到一个有序链表中得到。
1->1->2->3->4->4->5->6
示例 2:
输入:lists = []
输出:[]
示例 3:
输入:lists = [[]]
输出:[]
提示:
k == lists.length
0 <= k <= 10^4
0 <= lists[i].length <= 500
-10^4 <= lists[i][j] <= 10^4
lists[i] 按 升序 排列
lists[i].length 的总和不超过 10^4
方法一:分治合并(递归)
将链表数组递归地分成两半,分别合并左右两半,再将结果合并。利用分治策略降低时间复杂度,每次合并两个有序链表。
- 递归终止:当链表数组为空或只有一个链表时返回。
- 分治处理:找到中间位置,递归合并左右两半。
- 合并结果:将递归得到的两个有序链表合并。
代码实现(Java):
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) return null;
return merge(lists, 0, lists.length - 1);
}
private ListNode merge(ListNode[] lists, int start, int end) {
if (start == end) return lists[start];
int mid = start + (end - start) / 2;
ListNode left = merge(lists, start, mid);
ListNode right = merge(lists, mid + 1, end);
return mergeTwoLists(left, right);
}
private ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0);
ListNode curr = dummy;
while (l1 != null && l2 != null) {
if (l1.val < l2.val) {
curr.next = l1;
l1 = l1.next;
} else {
curr.next = l2;
l2 = l2.next;
}
curr = curr.next;
}
curr.next = (l1 != null) ? l1 : l2;
return dummy.next;
}
}
复杂度分析:
- 时间复杂度:
O(nk logk)
,其中n
是平均链表长度,k
是链表数量。 - 空间复杂度:
O(logk)
,递归调用栈的深度。
方法二:分治合并(迭代)
通过迭代方式逐步合并相邻链表,避免递归栈开销。每次将链表两两合并,直到只剩一个链表。
- 初始化处理:直接处理链表数组。
- 逐步合并:每次合并相邻两个链表,缩小数组范围。
- 最终合并:循环直到数组只剩一个链表。
代码实现(Java):
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) return null;
int k = lists.length;
while (k > 1) {
int idx = 0;
for (int i = 0; i < k; i += 2) {
ListNode l1 = lists[i];
ListNode l2 = (i + 1 < k) ? lists[i + 1] : null;
lists[idx++] = mergeTwoLists(l1, l2);
}
k = idx;
}
return lists[0];
}
private ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0);
ListNode curr = dummy;
while (l1 != null && l2 != null) {
if (l1.val < l2.val) {
curr.next = l1;
l1 = l1.next;
} else {
curr.next = l2;
l2 = l2.next;
}
curr = curr.next;
}
curr.next = (l1 != null) ? l1 : l2;
return dummy.next;
}
}
复杂度分析:
- 时间复杂度:
O(nk logk)
。 - 空间复杂度:
O(1)
,无额外递归栈空间。
方法三:优先队列(最小堆)
利用最小堆维护当前所有链表的最小节点。每次取出堆顶节点,将其下一节点加入堆中,直到堆为空。
- 初始化堆:将所有非空链表头节点加入堆。
- 构建结果:不断取出堆顶节点,链接到结果链表。
- 维护堆:将取出节点的下一节点入堆。
代码实现(Java):
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) return null;
PriorityQueue<ListNode> heap = new PriorityQueue<>((a, b) -> a.val - b.val);
for (ListNode node : lists) {
if (node != null) heap.offer(node);
}
ListNode dummy = new ListNode(0);
ListNode curr = dummy;
while (!heap.isEmpty()) {
ListNode minNode = heap.poll();
curr.next = minNode;
curr = curr.next;
if (minNode.next != null) {
heap.offer(minNode.next);
}
}
return dummy.next;
}
}
复杂度分析:
- 时间复杂度:
O(nk logk)
。 - 空间复杂度:
O(k)
,堆存储最多k
个节点。
对比总结
方法 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
分治合并(递归) | 代码简洁,逻辑清晰 | 递归栈空间O(logk) | 常规场景,k较小 |
分治合并(迭代) | 无栈溢出风险,空间最优 | 修改原数组结构 | k较大的场景,空间敏感 |
优先队列 | 实现简单,直观 | 堆空间O(k) | k较小的场景,链表较短 |
24. 两两交换链表中的节点
给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。
示例 1:
输入:head = [1,2,3,4]
输出:[2,1,4,3]
示例 2:
输入:head = []
输出:[]
示例 3:
输入:head = [1]
输出:[1]
提示:
链表中节点的数目在范围 [0, 100] 内
0 <= Node.val <= 100
方法:迭代法
通过迭代遍历链表,每次交换相邻两个节点。使用哑节点简化头节点处理,维护前驱指针current
,每次交换current
后的两个节点,并更新current
的位置。
- 初始化哑节点:避免处理头节点交换的特殊情况。
- 遍历条件:当前节点后存在两个可交换节点。
- 节点交换:
- 保存当前两个节点及后续节点。
- 调整指针指向完成交换。
- 移动前驱指针:前驱指针移动到已交换对的第二个节点,继续后续操作。
代码实现(Java):
class Solution {
public ListNode swapPairs(ListNode head) {
ListNode dummy = new ListNode(-1);
dummy.next = head;
ListNode current = dummy;
while (current.next != null && current.next.next != null) {
// 获取要交换的两个节点
ListNode first = current.next;
ListNode second = first.next;
ListNode next = second.next;
// 交换节点
current.next = second;
second.next = first;
first.next = next;
// 移动current指针到已交换对的第二个节点,作为下一轮的前驱
current = first;
}
return dummy.next;
}
}
复杂度分析
- 时间复杂度:
O(n)
,每个节点仅遍历一次。 - 空间复杂度:
O(1)
,仅使用常量额外空间。
25. K 个一组翻转链表
给你链表的头节点 head
,每 k
个节点一组进行翻转,请你返回修改后的链表。
k
是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k
的整数倍,那么请将最后剩余的节点保持原有顺序。
你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。
示例 1:
输入:head = [1,2,3,4,5], k = 2
输出:[2,1,4,3,5]
示例 2:
输入:head = [1,2,3,4,5], k = 3
输出:[3,2,1,4,5]
提示:
链表中的节点数目为 n
1 <= k <= n <= 5000
0 <= Node.val <= 1000
方法一:迭代法
通过迭代遍历链表,每次处理k个节点组成的子链表。使用哑节点简化头节点处理,维护前驱指针pre
,每次找到当前组的首尾节点后进行反转,并重新连接链表。
- 初始化哑节点:避免处理头节点反转的特殊情况。
- 寻找当前组的尾节点:通过循环k次定位当前组的结束位置。
- 反转子链表:将当前组的k个节点反转,返回新的头和尾。
- 重新连接链表:将前驱节点连接到反转后的头,反转后的尾连接到下一组的头。
- 更新指针:将前驱指针移动到当前组的尾,继续处理后续节点。
代码实现(Java):
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode pre = dummy;
ListNode end = dummy;
while (end.next != null) {
// 定位当前组的尾节点
for (int i = 0; i < k; i++) {
end = end.next;
if (end == null) return dummy.next; // 不足k个直接返回
}
// 记录关键节点
ListNode start = pre.next;
ListNode nextGroup = end.next;
end.next = null; // 断开当前组
// 反转当前组并连接
pre.next = reverse(start);
start.next = nextGroup; // 原start变为当前组的尾
// 更新指针
pre = start;
end = pre;
}
return dummy.next;
}
// 反转链表并返回新头节点
private ListNode reverse(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
return prev;
}
}
复杂度分析:
- 时间复杂度:
O(n)
,每个节点被处理两次(遍历和反转)。 - 空间复杂度:
O(1)
,仅使用常量额外空间。
方法二:递归法
递归处理每个分组,先判断剩余节点是否足够k个,若足够则反转前k个节点,递归处理后续链表,并将当前尾节点与后续结果连接。
- 检查剩余长度:遍历
k
次判断是否足够反转。 - 反转当前组:反转前
k
个节点。 - 递归后续链表:将当前尾节点的
next
指向递归处理后的结果。 - 返回新头节点:当前组的头节点变为反转后的首节点。
代码实现(Java):
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
ListNode curr = head;
int count = 0;
// 检查是否有足够k个节点
while (curr != null && count < k) {
curr = curr.next;
count++;
}
if (count == k) { // 足够k个则反转
ListNode reversedHead = reverse(head, k);
head.next = reverseKGroup(curr, k); // 原head变为当前组的尾
return reversedHead;
}
return head; // 不足k个直接返回
}
// 反转前k个节点
private ListNode reverse(ListNode head, int k) {
ListNode prev = null;
ListNode curr = head;
while (k-- > 0) {
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
return prev;
}
}
复杂度分析:
- 时间复杂度:
O(n)
,每个节点被处理一次。 - 空间复杂度:
O(n/k)
,递归栈深度为分组数。
26. 删除有序数组中的重复项
给你一个 非严格递增排列 的数组 nums
,请你 原地 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。元素的 相对顺序 应该保持 一致 。然后返回 nums
中唯一元素的个数。
考虑 nums
的唯一元素的数量为 k
,你需要做以下事情确保你的题解可以被通过:
- 更改数组
nums
,使nums
的前k
个元素包含唯一元素,并按照它们最初在nums
中出现的顺序排列。nums
的其余元素与nums
的大小不重要。 - 返回
k
。
系统会用下面的代码来测试你的题解:
int[] nums = [...]; // 输入数组
int[] expectedNums = [...]; // 长度正确的期望答案
int k = removeDuplicates(nums); // 调用
assert k == expectedNums.length;
for (int i = 0; i < k; i++) {
assert nums[i] == expectedNums[i];
}
如果所有断言都通过,那么您的题解将被 通过。
示例 1:
输入:nums = [1,1,2]
输出:2, nums = [1,2,_]
解释:函数应该返回新的长度 2 ,并且原数组 nums 的前两个元素被修改为 1, 2 。不需要考虑数组中超出新长度后面的元素。
示例 2:
输入:nums = [0,0,1,1,1,2,2,3,3,4]
输出:5, nums = [0,1,2,3,4]
解释:函数应该返回新的长度 5 , 并且原数组 nums 的前五个元素被修改为 0, 1, 2, 3, 4 。不需要考虑数组中超出新长度后面的元素。
提示:
1 <= nums.length <= 3 * 10^4
-10^4 <= nums[i] <= 10^4
nums 已按
非严格递增
排列
方法:双指针法
使用快慢双指针,快指针遍历数组,慢指针记录不重复元素的位置。当遇到不重复元素时,将其移至慢指针的下一个位置,并更新慢指针。由于数组已排序,只需比较相邻元素即可。
- 处理空数组的特殊情况。
- 初始化慢指针
slow
为0。 - 快指针
fast
从1开始遍历数组:- 当
nums[fast] != nums[slow]
,移动慢指针并更新其值。
- 当
- 返回
slow + 1
作为唯一元素的个数。
代码实现(Java):
class Solution {
public int removeDuplicates(int[] nums) {
if (nums.length == 0) return 0;
int slow = 0;
for (int fast = 1; fast < nums.length; fast++) {
if (nums[fast] != nums[slow]) {
slow++;
nums[slow] = nums[fast];
}
}
return slow + 1;
}
}
复杂度分析:
- 时间复杂度:
O(n)
,只需一次遍历数组。 - 空间复杂度:
O(1)
,原地修改,仅使用常数空间。
27. 移除元素
给你一个数组 nums
和一个值 val
,你需要 原地 移除所有数值等于 val
的元素。元素的顺序可能发生改变。然后返回 nums
中与 val
不同的元素的数量。
假设 nums
中不等于 val
的元素数量为 k
,要通过此题,您需要执行以下操作:
更改 nums
数组,使 nums
的前 k
个元素包含不等于 val
的元素。nums
的其余元素和 nums
的大小并不重要。
返回 k
。
评测机将使用以下代码测试您的解决方案:
int[] nums = [...]; // 输入数组
int val = ...; // 要移除的值
int[] expectedNums = [...]; // 长度正确的预期答案。
int k = removeElement(nums, val); // 调用你的实现
assert k == expectedNums.length;
sort(nums, 0, k); // 排序 nums 的前 k 个元素
for (int i = 0; i < actualLength; i++) {
assert nums[i] == expectedNums[i];
}
如果所有的断言都通过,你的解决方案将会 通过。
示例 1:
输入:nums = [3,2,2,3], val = 3
输出:2, nums = [2,2,_,_]
解释:你的函数函数应该返回 k = 2, 并且 nums 中的前两个元素均为 2。
你在返回的 k 个元素之外留下了什么并不重要(因此它们并不计入评测)。
示例 2:
输入:nums = [0,1,2,2,3,0,4,2], val = 2
输出:5, nums = [0,1,4,0,3,_,_,_]
解释:你的函数应该返回 k = 5,并且 nums 中的前五个元素为 0,0,1,3,4。
注意这五个元素可以任意顺序返回。
你在返回的 k 个元素之外留下了什么并不重要(因此它们并不计入评测)。
提示:
0 <= nums.length <= 100
0 <= nums[i] <= 50
0 <= val <= 100
方法:快慢双指针法
使用快慢指针遍历数组,快指针检查每个元素是否等于目标值。当遇到不等于目标值的元素时,将其复制到慢指针位置,并移动慢指针。最终慢指针的位置即为新数组长度。
- 初始化慢指针:
index
记录有效元素位置,初始为0。 - 快指针遍历:快指针
i
遍历数组,遇到非目标值时,复制到index
位置并移动慢指针。 - 返回结果:慢指针位置即为新长度。
代码实现(Java):
class Solution {
public int removeElement(int[] nums, int val) {
int index = 0;
for (int i = 0; i < nums.length; i++) {
if (nums[i] != val) {
nums[index++] = nums[i];
}
}
return index;
}
}
复杂度分析:
- 时间复杂度:
O(n)
,仅需一次遍历。 - 空间复杂度:
O(1)
,原地修改数组,无需额外空间。
声明
- 本文版权归
优快云
用户Allen Wurlitzer
所有,遵循CC-BY-SA
协议发布,转载请注明出处。- 本文题目来源
力扣-LeetCode
,著作权归领扣网络
所有。商业转载请联系官方授权,非商业转载请注明出处。