1. 顺序表基础与LeetCode经典题型解析
顺序表作为线性表最基础的存储结构,其核心特点是元素在内存中的物理顺序与逻辑顺序一致。这种连续存储的特性使得顺序表在随机访问时具有O(1)的时间复杂度优势,但在插入删除操作时需要移动大量元素,时间复杂度为O(n)。理解顺序表的这些特性,是解决LeetCode相关题目的关键。
在LeetCode题库中,顺序表相关题目占比超过30%,其中27题"移除元素"和88题"合并两个有序数组"是最具代表性的基础题型。这两道题目看似简单,却涵盖了顺序表操作的三大核心考点:元素遍历、原地修改和双指针技巧。通过这两道题,我们可以掌握顺序表处理的基础范式。
提示:虽然现代编程语言都内置了动态数组(如Java的ArrayList、C++的vector),但理解底层数组操作原理对算法能力提升至关重要。面试中面试官往往要求用基础数组实现这些操作。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. LeetCode27:移除元素深度剖析
2.1 问题描述与暴力解法
题目要求:给定一个数组nums和一个值val,需要原地移除所有数值等于val的元素,并返回新数组的长度。不能使用额外的数组空间,必须仅使用O(1)的额外空间并原地修改输入数组。
示例:
code复制输入:nums = [3,2,2,3], val = 3
输出:2, nums = [2,2]
最直观的暴力解法是遍历数组,遇到等于val的元素时,将其后面的所有元素向前移动一位。这种方法时间复杂度为O(n²),空间复杂度为O(1)。虽然满足题目空间要求,但效率显然不够理想。
java复制// 暴力解法示例
public int removeElement(int[] nums, int val) {
int size = nums.length;
for (int i = 0; i < size; i++) {
if (nums[i] == val) {
for (int j = i + 1; j < size; j++) {
nums[j - 1] = nums[j];
}
i--; // 因为下标i以后的元素都向前移动了一位,所以i也要向前移动一位
size--; // 此时数组大小减1
}
}
return size;
}
2.2 双指针法的优化实现
更高效的解法是使用快慢双指针:
- 快指针fast:寻找新数组的元素,即不等于val的元素
- 慢指针slow:指向新数组最后一个元素的下一个位置
java复制// 双指针解法
public int removeElement(int[] nums, int val) {
int slow = 0;
for (int fast = 0; fast < nums.length; fast++) {
if (nums[fast] != val) {
nums[slow] = nums[fast];
slow++;
}
}
return slow;
}
这种方法时间复杂度降至O(n),空间复杂度保持O(1)。关键在于理解双指针的移动逻辑:
- 当fast遇到非val元素时,将其复制到slow位置,然后两者都前进
- 当fast遇到val元素时,仅fast前进
- 最终slow的值就是新数组的长度
注意:在实际面试中,面试官可能会追问"如果val元素很少时如何优化"。此时可以考虑另一种双指针实现:当遇到val时,将当前元素与最后一个元素交换,并减少数组长度。这种方法在val元素较少时更高效。
3. LeetCode88:合并两个有序数组的多种解法
3.1 问题描述与初步思路
题目要求:给定两个按非递减顺序排列的整数数组nums1和nums2,将nums2合并到nums1中,使nums1成为一个有序数组。nums1有足够的空间(≥m+n)来容纳nums2中的元素。
示例:
code复制输入:nums1 = [1,2,3,0,0,0], m = 3
nums2 = [2,5,6], n = 3
输出:[1,2,2,3,5,6]
最直接的思路是将nums2追加到nums1后面然后排序,时间复杂度O((m+n)log(m+n)),但这没有利用数组已经有序的特性。
3.2 双指针从前往后合并
更优的方法是使用双指针,分别指向nums1和nums2的开头,比较后将较小元素放入临时数组:
java复制public void merge(int[] nums1, int m, int[] nums2, int n) {
int[] temp = new int[m + n];
int p1 = 0, p2 = 0;
int cur;
while (p1 < m || p2 < n) {
if (p1 == m) {
cur = nums2[p2++];
} else if (p2 == n) {
cur = nums1[p1++];
} else if (nums1[p1] < nums2[p2]) {
cur = nums1[p1++];
} else {
cur = nums2[p2++];
}
temp[p1 + p2 - 1] = cur;
}
System.arraycopy(temp, 0, nums1, 0, m + n);
}
这种方法时间复杂度O(m+n),但需要O(m+n)的额外空间,不完全符合题目对nums1有足够空间的暗示。
3.3 逆向双指针的原地合并
最优解法是从后向前处理,利用nums1后半部分的空闲空间:
java复制public void merge(int[] nums1, int m, int[] nums2, int n) {
int p1 = m - 1, p2 = n - 1;
int tail = m + n - 1;
while (p1 >= 0 || p2 >= 0) {
if (p1 == -1) {
nums1[tail--] = nums2[p2--];
} else if (p2 == -1) {
nums1[tail--] = nums1[p1--];
} else if (nums1[p1] > nums2[p2]) {
nums1[tail--] = nums1[p1--];
} else {
nums1[tail--] = nums2[p2--];
}
}
}
这种方法时间复杂度O(m+n),空间复杂度O(1),完美满足题目要求。关键点在于:
- 从nums1和nums2的末尾开始比较
- 将较大元素放入nums1的尾部
- 指针从后向前移动
4. 顺序表操作的核心技巧与面试要点
4.1 双指针法的变体与应用
双指针技巧在顺序表操作中有多种变体:
- 同向双指针(快慢指针):如LeetCode27
- 对向双指针:如二分查找、两数之和
- 滑动窗口:解决子数组/子串问题
在面试中,双指针问题通常会考察:
- 指针移动条件的把握
- 边界条件的处理
- 时间复杂度的分析
4.2 原地修改的常见模式
原地修改是顺序表问题的核心难点,常见模式包括:
- 覆盖写入(如LeetCode27)
- 元素交换(如快速排序的partition)
- 填充空闲位置(如LeetCode88的逆向双指针)
实战经验:在处理原地修改问题时,一定要先考虑元素移动的顺序,避免覆盖还需要使用的数据。可以画图辅助理解元素移动过程。
4.3 顺序表相关的高频面试题
除了本文分析的两道题目,顺序表相关的高频面试题还包括:
- 删除排序数组中的重复项(LeetCode26)
- 移动零(LeetCode283)
- 两数之和II(LeetCode167)
- 盛最多水的容器(LeetCode11)
- 三数之和(LeetCode15)
这些题目都可以用双指针技巧高效解决,建议作为延伸练习。
5. 从题目到实战:工程中的顺序表优化
5.1 动态数组的实现原理
虽然我们使用基础数组解决算法题,但工程中更多使用动态数组(如Java的ArrayList)。理解其扩容机制对写出高效代码很重要:
java复制// 简化的ArrayList扩容逻辑
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5倍扩容
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
elementData = Arrays.copyOf(elementData, newCapacity);
}
动态数组的扩容成本很高(需要复制所有元素),因此在知道数据量大小的情况下,应该预先分配足够容量。
5.2 顺序表与链表的性能对比
虽然顺序表随机访问高效,但在以下场景链表可能更合适:
- 频繁在头部插入/删除
- 数据量非常大且变化频繁
- 内存碎片化严重的环境
在Java中,ArrayList和LinkedList的选择需要根据具体操作特点决定。
5.3 算法题与工程实践的差异
算法题中的顺序表操作往往追求极致的时间和空间复杂度,而工程实践中还需要考虑:
- 代码可读性和可维护性
- 异常处理和边界条件
- 内存局部性和缓存友好性
- 多线程环境下的安全性
例如,工程中可能更倾向于使用System.arraycopy()而不是手动循环移动元素,因为JVM对其有特殊优化。
