三道经典数组题:从暴力到最优的算法思维

数组是算法中最基础也最重要的数据结构之一。很多看似简单的题目,背后都藏着从"能跑通"到"跑得快"的思维跃迁。今天通过三道数组相关的经典题目,聊聊如何先写出最容易想到的解法,再一步步逼近最优解。


题一:在有序数组中搜索插入位置

问题描述:给定一个按升序排列的数组和一个目标值,如果目标值存在于数组中,返回它的索引;如果不存在,返回它应该被插入的位置,以保证数组仍然有序。要求算法的时间复杂度为对数级别。

最容易想到的方法:线性扫描

看到"查找"两个字,很多人的第一反应是从头到尾遍历一遍:

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)

最优解:二分查找

题目特意强调了"对数级别"的时间复杂度,这其实是一个强烈的提示:数组已经有序,为什么不利用这个特性呢?

有序数组的查找,天然适合二分查找。我们维护两个指针 leftright,每次取中间元素与目标值比较:

  • 如果中间元素等于目标值,直接返回索引;

  • 如果中间元素大于目标值,说明目标值(或插入位置)在左半部分;

  • 如果中间元素小于目标值,说明目标值(或插入位置)在右半部分。

循环结束时,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 尾部的空闲空间,避免了额外数组的开销。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值