1. LeetCode热题100解析(四):数组与链表高频算法精讲
作为程序员面试的"金标准",LeetCode题目一直是技术求职路上的必经关卡。今天我将分享自己在刷题过程中总结的10道高频数组和链表题目解法,这些题目来自LeetCode热题100榜单,覆盖了旋转数组、螺旋矩阵、环形链表等经典问题。不同于简单的代码展示,我会重点讲解解题思路的演进过程、不同解法的性能对比,以及实际面试中的考察重点。
2. 数组类问题解析
2.1 轮转数组的三种解法比较
轮转数组(189题)要求将数组元素向右移动k个位置。初学者最容易想到的是使用额外数组的解法:
java复制public void rotate(int[] nums, int k) {
int[] clone = nums.clone();
for (int i = 0; i < nums.length; i++) {
int index = (i + k) % nums.length;
nums[index] = clone[i];
}
}
这种解法时间复杂度O(n),空间复杂度O(n)。面试官通常会追问能否优化空间复杂度,这就引出了经典的"三次反转法":
java复制public void rotate(int[] nums, int k) {
k = k % nums.length; // 处理k大于数组长度的情况
reverse(nums, 0, nums.length - 1);
reverse(nums, 0, k - 1);
reverse(nums, k, nums.length - 1);
}
private void reverse(int[] nums, int start, int end) {
while(start < end) {
int temp = nums[start];
nums[start] = nums[end];
nums[end] = temp;
start++;
end--;
}
}
关键点:三次反转法将空间复杂度降为O(1),是面试中的加分项。注意要处理k大于数组长度的情况,否则会导致数组越界。
2.2 除自身以外数组乘积的巧妙解法
238题要求计算数组中每个元素除自身外的乘积,且不能用除法。这个限制让很多初学者束手无策,其实可以通过前缀积和后缀积的组合来解决:
java复制public int[] productExceptSelf(int[] nums) {
int[] left = new int[nums.length];
left[0] = 1;
for (int i = 1; i < nums.length; i++) {
left[i] = nums[i - 1] * left[i - 1];
}
int right = 1;
int[] answer = new int[nums.length];
for (int i = nums.length - 1; i >= 0; i--) {
answer[i] = left[i] * right;
right *= nums[i];
}
return answer;
}
这个解法的时间复杂度是O(n),空间复杂度优化到O(1)(不考虑输出数组)。在实际编码时要注意:
- 初始化left数组的第一个元素为1(因为第一个元素没有前缀)
- 后缀积可以用一个变量right动态计算,避免使用额外数组
- 最后的结果是前缀积和后缀积的乘积
3. 矩阵类问题精解
3.1 矩阵置零的空间优化技巧
73题要求将矩阵中0元素所在的行和列都置零。最直观的解法是使用两个标记数组:
java复制public void setZeroes(int[][] matrix) {
boolean[] row = new boolean[matrix.length];
boolean[] col = new boolean[matrix[0].length];
for (int i = 0; i < matrix.length; i++) {
for (int j = 0; j < matrix[0].length; j++) {
if (matrix[i][j] == 0) {
row[i] = true;
col[j] = true;
}
}
}
for (int i = 0; i < matrix.length; i++) {
for (int j = 0; j < matrix[0].length; j++) {
if (row[i] || col[j]) {
matrix[i][j] = 0;
}
}
}
}
这个解法的时间复杂度是O(mn),空间复杂度是O(m+n)。面试中可能会被要求进一步优化空间复杂度,这时可以用矩阵的第一行和第一列作为标记:
java复制public void setZeroes(int[][] matrix) {
boolean firstRowZero = false;
boolean firstColZero = false;
// 检查第一行是否有0
for (int j = 0; j < matrix[0].length; j++) {
if (matrix[0][j] == 0) {
firstRowZero = true;
break;
}
}
// 检查第一列是否有0
for (int i = 0; i < matrix.length; i++) {
if (matrix[i][0] == 0) {
firstColZero = true;
break;
}
}
// 使用第一行和第一列记录0的位置
for (int i = 1; i < matrix.length; i++) {
for (int j = 1; j < matrix[0].length; j++) {
if (matrix[i][j] == 0) {
matrix[i][0] = 0;
matrix[0][j] = 0;
}
}
}
// 根据标记置零
for (int i = 1; i < matrix.length; i++) {
for (int j = 1; j < matrix[0].length; j++) {
if (matrix[i][0] == 0 || matrix[0][j] == 0) {
matrix[i][j] = 0;
}
}
}
// 处理第一行
if (firstRowZero) {
for (int j = 0; j < matrix[0].length; j++) {
matrix[0][j] = 0;
}
}
// 处理第一列
if (firstColZero) {
for (int i = 0; i < matrix.length; i++) {
matrix[i][0] = 0;
}
}
}
3.2 螺旋矩阵的边界处理技巧
54题要求按顺时针螺旋顺序返回矩阵中的所有元素。这类问题的关键是处理好边界条件和方向转换:
java复制public List<Integer> spiralOrder(int[][] matrix) {
List<Integer> res = new ArrayList<>();
if (matrix == null || matrix.length == 0) return res;
int top = 0, bottom = matrix.length - 1;
int left = 0, right = matrix[0].length - 1;
while (true) {
// 从左到右遍历上边
for (int i = left; i <= right; i++) res.add(matrix[top][i]);
if (++top > bottom) break;
// 从上到下遍历右边
for (int i = top; i <= bottom; i++) res.add(matrix[i][right]);
if (--right < left) break;
// 从右到左遍历下边
for (int i = right; i >= left; i--) res.add(matrix[bottom][i]);
if (--bottom < top) break;
// 从下到上遍历左边
for (int i = bottom; i >= top; i--) res.add(matrix[i][left]);
if (++left > right) break;
}
return res;
}
这个解法的关键在于使用四个变量(top,bottom,left,right)来标记当前未遍历的边界,每次完成一个方向的遍历后就收缩对应的边界。当边界交叉时循环结束。
4. 链表类问题详解
4.1 环形链表检测的两种方法
142题要求检测链表是否有环并返回环的起点。最直观的方法是使用HashSet:
java复制public ListNode detectCycle(ListNode head) {
Set<ListNode> visited = new HashSet<>();
while (head != null) {
if (visited.contains(head)) {
return head;
}
visited.add(head);
head = head.next;
}
return null;
}
这种方法时间复杂度O(n),空间复杂度O(n)。面试中更受青睐的是Floyd判圈算法(快慢指针):
java复制public ListNode detectCycle(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) { // 相遇点
ListNode ptr = head;
while (ptr != slow) {
ptr = ptr.next;
slow = slow.next;
}
return ptr;
}
}
return null;
}
Floyd算法分为两个阶段:
- 快慢指针找相遇点(如果存在环)
- 从相遇点和链表头同时出发,再次相遇点即为环的起点
这个算法的空间复杂度优化到O(1),是面试中的加分项。
4.2 两数相加的链表处理技巧
2题要求用链表表示的两个数字相加。处理这类问题时需要注意:
- 链表是逆序存储的,正好方便从低位开始相加
- 需要考虑进位问题
- 两个链表长度可能不同
java复制public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0);
ListNode curr = dummy;
int carry = 0;
while (l1 != null || l2 != null || carry != 0) {
int sum = carry;
if (l1 != null) {
sum += l1.val;
l1 = l1.next;
}
if (l2 != null) {
sum += l2.val;
l2 = l2.next;
}
carry = sum / 10;
curr.next = new ListNode(sum % 10);
curr = curr.next;
}
return dummy.next;
}
这个解法的时间复杂度是O(max(m,n)),空间复杂度也是O(max(m,n))(用于存储结果)。在实际编码中,使用哑节点(dummy node)可以简化链表头部的处理。
5. 算法优化与面试技巧
5.1 从暴力解法到最优解的思考过程
在面试中,展示思考过程比直接给出最优解更重要。以"删除链表的倒数第N个节点"(19题)为例,可以这样展示思考过程:
- 第一想法:遍历链表记录所有节点,删除目标节点后重新构建链表
java复制public ListNode removeNthFromEnd(ListNode head, int n) {
List<ListNode> nodes = new ArrayList<>();
ListNode curr = head;
while (curr != null) {
nodes.add(curr);
curr = curr.next;
}
int index = nodes.size() - n;
if (index == 0) return head.next;
nodes.get(index-1).next = nodes.get(index).next;
return head;
}
- 优化思路:使用双指针,只需一次遍历
java复制public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode first = dummy, second = dummy;
// 先移动第一个指针n+1步
for (int i = 0; i <= n; i++) {
first = first.next;
}
// 同时移动两个指针
while (first != null) {
first = first.next;
second = second.next;
}
second.next = second.next.next;
return dummy.next;
}
这种分步骤展示思考过程的方法,能让面试官看到你的问题解决能力。
5.2 递归与迭代的选择策略
有些链表问题既可以用递归也可以用迭代解决。以"两两交换链表节点"(24题)为例:
递归解法:
java复制public ListNode swapPairs(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode newHead = head.next;
head.next = swapPairs(newHead.next);
newHead.next = head;
return newHead;
}
迭代解法:
java复制public ListNode swapPairs(ListNode head) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode prev = dummy;
while (head != null && head.next != null) {
ListNode first = head;
ListNode second = head.next;
// 交换节点
prev.next = second;
first.next = second.next;
second.next = first;
// 更新指针
prev = first;
head = first.next;
}
return dummy.next;
}
选择依据:
- 递归代码简洁,但空间复杂度O(n)(调用栈)
- 迭代代码稍复杂,但空间复杂度O(1)
- 面试中可以先给出递归解法,再优化为迭代解法
6. 高频考点与常见错误
6.1 数组与矩阵问题的常见陷阱
-
边界条件处理不足:
- 旋转数组时未处理k大于数组长度的情况
- 矩阵遍历时行列索引混淆
- 螺旋矩阵的奇数行/列中心点遗漏
-
空间复杂度优化不足:
- 过度依赖额外存储空间
- 未能利用输入数据本身进行标记
-
原地修改错误:
- 修改数据后影响后续判断
- 未保留原始数据导致计算错误
6.2 链表问题的调试技巧
- 使用哑节点简化头节点处理
- 在循环中打印关键变量值(如指针位置、节点值)
- 绘制链表图示辅助理解指针移动
- 特别注意.next操作可能导致的指针丢失
- 处理空链表或单节点链表的边界情况
7. 刷题建议与学习路径
根据我个人的刷题经验,对于数组和链表类题目,建议按照以下顺序学习:
-
掌握基本操作:
- 数组的遍历、插入、删除
- 链表的遍历、节点操作
-
学习经典算法:
- 双指针技巧(快慢指针、左右指针)
- 滑动窗口
- 前缀和与差分数组
-
攻克特定题型:
- 旋转、翻转类问题
- 矩阵遍历与变换
- 链表反转与节点交换
-
优化与总结:
- 分析时间/空间复杂度
- 比较不同解法的优劣
- 总结解题模板
在实际刷题过程中,我建议每道题至少尝试两种解法,并记录解题思路和遇到的坑。这样在面试中即使遇到变形题,也能快速找到解决方向。