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

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


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

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

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

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

复制代码
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 尾部的空闲空间,避免了额外数组的开销。

相关推荐
lzhdim1 小时前
提高 SQL 语句执行速度的方法
java·开发语言·数据库·sql·oracle
JavaPub-rodert1 小时前
王仕宇在 Go Context 如何解决协程泄漏与超时控制
开发语言·后端·golang·iphone·javapub·王仕宇
徐小夕1 小时前
3分钟从想法到Agent上线:我们开源了一款AI可视化工作流“IDE”
前端·算法·github
sel_93 小时前
【强化学习】Hands-on Modern RL项目实践|OPD 算法完整解析
人工智能·深度学习·算法·机器学习·语言模型
ly76893 小时前
JavaScript 从入门到进阶:核心语法、异步编程与工程化实践
开发语言·javascript·ecmascript
Navigator_Z3 小时前
LeetCode //C - 1209. Remove All Adjacent Duplicates in String II
c语言·算法·leetcode
落魄大学生之流水线上谋生计3 小时前
Java锁全面指南:从基础概念到企业级应用
java·开发语言
for_ever_love__3 小时前
python基础语法学习: 闭包
开发语言·python·学习·闭包
ly76893 小时前
Python 全面入门:从核心语法到工程实践
开发语言·python