数组是算法中最基础也最重要的数据结构之一。很多看似简单的题目,背后都藏着从"能跑通"到"跑得快"的思维跃迁。今天通过三道数组相关的经典题目,聊聊如何先写出最容易想到的解法,再一步步逼近最优解。
题一:在有序数组中搜索插入位置
问题描述:给定一个按升序排列的数组和一个目标值,如果目标值存在于数组中,返回它的索引;如果不存在,返回它应该被插入的位置,以保证数组仍然有序。要求算法的时间复杂度为对数级别。
最容易想到的方法:线性扫描
看到"查找"两个字,很多人的第一反应是从头到尾遍历一遍:
int searchInsert(int nums[], int numsSize, int target) {
for (int i = 0; i < numsSize; i++) {
if (nums[i] >= target) {
return i;
}
}
return numsSize;
}
这个思路非常直观:数组是有序的,从左往右找,第一个大于或等于目标值的位置,就是我们要的答案;如果都比目标值小,就放在末尾。
复杂度分析:最坏情况下需要遍历整个数组,时间复杂度是 O(n)。
最优解:二分查找
题目特意强调了"对数级别"的时间复杂度,这其实是一个强烈的提示:数组已经有序,为什么不利用这个特性呢?
有序数组的查找,天然适合二分查找。我们维护两个指针 left 和 right,每次取中间元素与目标值比较:
-
如果中间元素等于目标值,直接返回索引;
-
如果中间元素大于目标值,说明目标值(或插入位置)在左半部分;
-
如果中间元素小于目标值,说明目标值(或插入位置)在右半部分。
循环结束时,left 指针指向的位置就是目标值应该插入的位置。
int searchInsert(int nums[], int numsSize, int target) {
int left = 0;
int right = numsSize - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return left;
}
复杂度分析:每次查找范围缩小一半,时间复杂度为 O(log n),空间复杂度 O(1)。
思维收获
线性扫描忽略了"数组已有序"这个关键条件,而二分查找正是利用有序性来减少搜索空间的典范。遇到有序数据,先想想能不能用二分。
题二:大整数加一
问题描述:给定一个整数数组,数组中的每个元素代表大整数的一位数字,从左到右是高位到低位。这个大整数没有前导零。要求将这个大整数加一,返回结果数组。
最容易想到的方法:转成数字再运算
初学者可能会想:把数组拼成一个整数,加一后再拆回数组。
// 伪代码示意,实际不推荐
long long num = 0;
for (int i = 0; i < digitsSize; i++) {
num = num * 10 + digits[i];
}
num = num + 1;
// 再将 num 拆分成数组...
这个思路在数学上没错,但有一个致命问题:当数组很长时,整数会溢出。比如数组有 100 位,任何内置的整数类型都存不下。题目用数组表示大整数,就是为了规避溢出问题。
最优解:模拟进位
既然不能转数字,那就模拟我们小学时学的竖式加法。加一只影响末尾,从数组最后一位开始:
-
如果当前位小于 9,直接加一并返回,结束;
-
如果当前位是 9,加一后变成 0,并向前一位进 1;
-
如果遍历完所有位都是 9(比如
[9, 9, 9]),最后需要在最前面插入一个 1。
int* plusOne(int digits[], int digitsSize, int* returnSize) {
for (int i = digitsSize - 1; i >= 0; i--) {
if (digits[i] < 9) {
digits[i]++;
*returnSize = digitsSize;
return digits;
}
digits[i] = 0;
}
// 走到这里说明全是9,需要扩容
int* result = (int*)malloc((digitsSize + 1) * sizeof(int));
result[0] = 1;
for (int i = 1; i <= digitsSize; i++) {
result[i] = 0;
}
*returnSize = digitsSize + 1;
return result;
}
复杂度分析:最坏情况下遍历一次数组(比如 999),时间复杂度 O(n)。这是最优的,因为每个元素至少要检查一次。空间复杂度 O(1)(不考虑扩容时的额外空间)。
思维收获
"转数字"的思维被日常编程惯坏了,遇到大数问题要立刻警觉。模拟手工计算的过程,往往是最可靠也最通用的解法。
题三:合并两个有序数组
问题描述:给定两个按非递减顺序排列的整数数组,将第二个数组合并到第一个数组中,使合并后的数组仍然有序。注意第一个数组的初始长度足够容纳两个数组的所有元素,其后半部分用 0 占位。
最容易想到的方法:先合并,再排序
既然第一个数组后面有空位,那直接把第二个数组的元素拷贝过去,然后对整个数组排序:
// 将 nums2 的元素放到 nums1 的尾部
for (int i = 0; i < n; i++) {
nums1[m + i] = nums2[i];
}
// 对 nums1 排序(调用排序函数)
sort(nums1, m + n);
这个方案写起来最快,思路也最直白。
复杂度分析:排序的时间复杂度通常是 O((m+n) log(m+n)),没有利用两个数组"已经有序"的条件。
最优解:从后往前的双指针
两个数组都是有序的,这提示我们可以用归并排序中的合并思想。但这里有个陷阱:如果从头开始比较,把较小的数放到 nums1 前面,可能会覆盖 nums1 中尚未比较的元素。
换个方向思考:既然 nums1 后面有空位,为什么不从尾部开始填充呢?
-
指针
i指向nums1有效元素的末尾(索引m-1); -
指针
j指向nums2的末尾(索引n-1); -
指针
k指向nums1的末尾(索引m+n-1)。
每次比较 nums1[i] 和 nums2[j],把较大的那个放到 nums1[k],然后相应指针前移。
void merge(int nums1[], int nums1Size, int m,
int nums2[], int nums2Size, int n) {
int i = m - 1; // nums1 有效数据末尾
int j = n - 1; // nums2 末尾
int k = m + n - 1; // nums1 末尾
while (i >= 0 && j >= 0) {
if (nums1[i] > nums2[j]) {
nums1[k--] = nums1[i--];
} else {
nums1[k--] = nums2[j--];
}
}
// 如果 nums2 还有剩余,拷贝到前面
while (j >= 0) {
nums1[k--] = nums2[j--];
}
// nums1 剩余元素已经在正确位置,无需处理
}
复杂度分析:每个元素只被访问一次,时间复杂度 O(m+n),空间复杂度 O(1)。相比排序方案,这是一个质的飞跃。
思维收获
当正向操作会破坏已有数据时,不妨考虑逆向思维。从后往前填充,巧妙地利用了 nums1 尾部的空闲空间,避免了额外数组的开销。

321

被折叠的 条评论
为什么被折叠?



