LeetCode 88. 合并两个有序数组 C 解题分析

LeetCode 88. 合并两个有序数组 C 解题分析

题目: 88. 合并两个有序数组

语言: C

调研日期: 2026-10-14

代码来源: 用户于 2026-10-14 在本对话中粘贴的方法一与方法二

结论: 方法一将数值 0 错当作空位,违反题目约束,且最坏为 O((m+n)n);方法二是正确的逆向归并,时间 O(m+n)、额外空间 O(1)。可进一步简化为标准的三个下标版本。

1. 题意和真正的输入模型

题目要求将两个按非递减 顺序排列的数组合并到 nums1,最终结果直接写回 nums1。其中:

参数 含义
nums1[0..m-1] 第一个有序数组的有效元素
nums1[m..m+n-1] 仅是预留容量,不是数据
nums2[0..n-1] 第二个有序数组的有效元素
m、n 两段有效数据的长度

官方约束允许元素落在 [-10^9, 10^9],所以 0 可以是任一数组中的合法数据 。nums1 尾部初始为 0 只是题目示例中的存储占位,不能作为算法的空槽标记。

复制代码
nums1  [ A0 | A1 | ... | A(m-1) | capacity | ... | capacity ]
index     0                        m-1          m             m+n-1

nums2  [ B0 | B1 | ... | B(n-1) ]
index     0                   n-1

本题的核心不是"找到值为 0 的位置",而是:在不覆盖 nums1 尚未比较的有效元素的前提下,完成两个有序区间的归并。

2. 方法一:从前向后交换并用 0 标识空位

原代码:

复制代码
void merge(int* nums1, int nums1Size, int m,
           int* nums2, int nums2Size, int n)
{
    // 已经排好序,并且 nums1 一定长于 nums2;
    if (n != 0) {
        int flag = 0;
        for (int i = 0; i < m + n; i++) {
            for (int j = 0; j < n; j++) {
                if (*(nums1 + i) == 0) {
                    if (*(nums2 + j) != 0) {
                        *(nums1 + i) = *(nums2 + j);
                        *(nums2 + j) = 0;
                        break;
                    } else {
                        continue;
                    }
                }

                if (*(nums2 + j) < *(nums1 + i)) {
                    flag = *(nums2 + j);
                    *(nums2 + j) = *(nums1 + i);
                    *(nums1 + i) = flag;
                    break;
                }
            }
        }
    }
}

2.1 做对的部分

  • 代码尝试维持 nums1 从左到右的顺序。

  • 通过交换把较小的 nums2[j] 放入当前位置,思路接近选择式归并。

  • 对 LeetCode 的数值范围而言,临时变量 flag 使用 int 不会单独造成截断。

2.2 正确性错误:0 不是哨兵值

代码将 nums1[i] == 0 解释为空槽,并将 nums2[j] == 0 跳过;两种解释都不符合题意。

最小反例:

复制代码
nums1 = [1, 0, 0], m = 1
nums2 = [-1, 0],   n = 2
正确结果 = [-1, 0, 1]

执行关键步骤:

复制代码
i = 0: -1 < 1,交换
nums1 = [-1, 0, 0], nums2 = [1, 0]

i = 1: nums1[1] == 0,被当作空槽,填入 nums2[0] = 1
nums1 = [-1, 1, 0], nums2 = [0, 0]

i = 2: nums2 中的两个 0 都被跳过
结果 = [-1, 1, 0],不是非递减序列

因此,方法一并不是只在边界上不够优雅,而是对合法输入会产生错误结果。

2.3 复杂度和接口问题

项目 结论
时间复杂度 最坏 O((m+n)n);每个 nums1 位置都可能扫描整个 nums2
额外空间 O(1)
是否修改 nums2 是;在线评测通常不检查,但这不是必要副作用
空槽判定 错误,应由 m 和 n 决定有效范围
注释 nums1 的长度是 m+n,所以不一定严格长于 nums2;m=0 时两者长度相等

即使将 0 哨兵问题修复,前向写入仍有覆盖未处理的 nums1 元素的风险。若继续使用"移位"保存元素,需要额外移动一段区间,复杂度通常会进一步恶化。

3. 方法二:从后向前归并

原代码:

复制代码
void merge(int* nums1, int nums1Size, int m,
           int* nums2, int nums2Size, int n)
{
    // 已经排好序,并且 nums1 一定长于 nums2;
    int m1 = m;
    int n1 = n;

    if (n != 0) {
        for (int i = m + n; i >= 1; i--) {
            if (n1 >= 1 && m1 >= 1) {
                if (*(nums2 + n1 - 1) >= *(nums1 + m1 - 1)) {
                    *(nums1 + i - 1) = *(nums2 + n1 - 1);
                    n1 -= 1;
                } else if (*(nums2 + n1 - 1) < *(nums1 + m1 - 1)) {
                    *(nums1 + i - 1) = *(nums1 + m1 - 1);
                    m1 -= 1;
                }
            } else {
                if (m1 == 0 && n1 == 0) {
                    break;
                } else if (m1 == 0) {
                    *(nums1 + i - 1) = *(nums2 + n1 - 1);
                    n1 -= 1;
                } else if (n1 == 0) {
                    *(nums1 + i - 1) = *(nums1 + m1 - 1);
                    m1 -= 1;
                }
            }
        }
    }
}

3.1 正确性结论

方法二的核心方向是正确的。它从两个有效区间的末尾取出较大值,写入 nums1 的最右空位。因为写入位置总在尚未读取的 nums1[0..m1-1] 右侧,所以不会覆盖仍需要比较的元素。

复制代码
flowchart TD
    A[i = m - 1, j = n - 1, k = m + n - 1] --> B{j >= 0?}
    B -- 否 --> R[结束: 剩余 nums1 已在正确位置]
    B -- 是 --> C{i >= 0 且 nums1[i] > nums2[j]?}
    C -- 是 --> D[nums1[k] = nums1[i]; i--; k--]
    C -- 否 --> E[nums1[k] = nums2[j]; j--; k--]
    D --> B
    E --> B

可用如下循环不变量证明:

每轮开始时,nums1[k+1..m+n-1] 已包含两个数组中最大的、数量为 m+n-1-k 的元素,且已经按非递减顺序排列;nums1[0..m1-1] 和 nums2[0..n1-1] 是尚未合并的两个有序前缀。

若 nums1[m1-1] 更大,则它必是所有未处理元素的最大值,写到 nums1[k];否则写入 nums2[n1-1]。每次至少缩小一个未处理区间,因此不变量保持。n1 == 0 时,剩下的 nums1 前缀已在最终位置,无需复制。

3.2 可改进之处

方法二没有发现题意错误,但控制流可以更短、更直接:

现有写法 改进原因
m1、n1 保存的是剩余元素个数 使用末尾下标 i、j 更贴近数组访问,不需要反复写 -1
for (i = m+n; i >= 1; i--) 写指针 k 更清晰,且循环只需要在 nums2 尚有元素时继续
if (n != 0) 冗余;while (j >= 0) 自然处理 n == 0
else if (a < b) 跟在 if (a >= b) 后 条件互补,可直接写 else
n1 == 0 时复制剩余 nums1 安全但不必要;这些元素本来就在最终位置
nums1Size、nums2Size 未使用 LeetCode C 签名允许;若本地启用严格警告,可显式 (void) 忽略

时间复杂度为 O(m+n),额外空间为 O(1)。在当前题目中元素只被比较和复制,不进行数值加减乘除,因此 int 的值域不会带来算术溢出问题。

4. 推荐提交版本

复制代码
void merge(int* nums1, int nums1Size, int m,
           int* nums2, int nums2Size, int n)
{
    (void)nums1Size;
    (void)nums2Size;

    int i = m - 1;
    int j = n - 1;
    int k = m + n - 1;

    while (j >= 0) {
        if (i >= 0 && nums1[i] > nums2[j]) {
            nums1[k--] = nums1[i--];
        } else {
            nums1[k--] = nums2[j--];
        }
    }
}

选择 nums2[j] 处理相等值是正确的。题目只要求最终值按非递减顺序排列,并未要求来源稳定性;从右向左写入时,任意一致的相等值处理策略都能保持正确性。

5. 回归测试

nums1, m nums2, n 期望 nums1 覆盖点
[1,2,3,0,0,0], 3 [2,5,6], 3 [1,2,2,3,5,6] 官方常规示例、交错插入
[1], 1 [], 0 [1] n == 0
[0], 0 [1], 1 [1] m == 0,尾部 0 不是数据
[1,0,0], 1 [-1,0], 2 [-1,0,1] 方法一的 0 哨兵反例
[0,0,0,0], 2 [0,0], 2 [0,0,0,0] 两边都有合法 0
[-3,-1,0,0,0], 2 [-2,2,3], 3 [-3,-2,-1,2,3] 负数和正数
[4,5,6,0,0,0], 3 [1,2,3], 3 [1,2,3,4,5,6] nums2 全部更小
[1,2,3,0,0,0], 3 [4,5,6], 3 [1,2,3,4,5,6] nums2 全部更大

6. 方法比较与最终建议

方案 正确性 时间 空间 建议
方法一:前向交换 + 0 哨兵 错误,0 是合法值 最坏 O((m+n)n) O(1) 不使用
方法二:逆向归并 正确 O(m+n) O(1) 可以提交
推荐版本:简化逆向三指针 正确,控制流更小 O(m+n) O(1) 提交版本

方法二已经抓住本题最重要的算法思想:利用 nums1 末尾的预留空间,从后向前写入最大值。后续需要强化的不是再加分支,而是始终区分"数组中合法的值"和"由长度参数定义的有效区间";这是处理原地数组题时避免哨兵误用的关键。

7. 来源

  1. LeetCode CN 88. 合并两个有序数组: 题意、函数的原地写入要求、m/n 的有效元素语义、值域和 O(m+n) 进阶要求。2026-10-14 访问。

在实现 merge 函数将两个有序数组合并到 nums1 的过程中,程序多次触发 AddressSanitizer: heap-buffer-overflow 报错。报错信息统一指向 solution.c 中 merge 函数内的条件判断语句,典型特征为读取了分配内存块起始位置之前 4 个字节,属于数组下标为负导致的缓冲区下溢。

第一个错误是条件判断顺序错误,这是最核心的问题。代码中写成了先解引用再判断下标合法性,例如将 *(nums2+n1-1) 放在 && 的左侧,把 n1 >= 1 放在右侧。由于 C 语言的 && 运算符从左到右求值,当 n1 等于 0 时,程序会先计算 *(nums2+n1-1),也就是访问 nums2-1,此时内存越界已经发生,右侧的保护条件根本来不及生效。逻辑保护条件不能置于解引用操作之后,必须先判断下标合法性再执行解引用。

第二个错误是变量保护不全面,修正不彻底。在后续修改中虽然把 m1 >= 1 提前,保护了 nums1 的访问安全,但表达式右侧仍然同时访问了 nums2+n1-1。当 n1 等于 0 时,依然会读取 nums2-1,导致相同的越界错误。只要条件表达式中同时出现两个数组的解引用,就必须同时保证双方下标均合法,即 m1 >= 1 且 n1 >= 1。

第三个错误是赋值下标偏移,属于潜在逻辑错误。代码中使用了 *(nums1+i-1) 进行赋值,但循环变量 i 从 m+n-1 开始递减,i 本身就代表当前要填充的目标位置。使用 i-1 会导致写入位置整体偏移,即使没有越界,合并结果也是错误的。写入应针对当前索引 i,即 *(nums1+i),只有读取前一个元素参与比较时才使用 -1,且必须配合边界检查。

第四个错误是边界分支冗余。在 else 分支中嵌套 if(m1==0) 与 if(n1==0) 的判断存在冗余。当 m1 和 n1 中任意一个归零时,前面的 if 和 else if 都不会进入,程序自然落入 else,此时根据谁为零直接赋值即可,逻辑可以更简洁。

通过这次调试可以总结出几点经验。首先,&& 是短路运算符,但左操作数一定会先执行,必须将边界判断放在 && 的最左侧。其次,保护一个数组不能防范另一个数组越界,表达式涉及几个数组就检查几个下标。再次,指针偏移量 -1 是负下标越界的高发区,读前一个元素前先确认下标大于等于 1。最后,目标索引与比较索引容易混淆,赋值用 i,比较用 i-1、m1-1、n1-1。

核心结论是,在 C 语言中任何指针解引用之前必须先完成下标合法性校验,任何 && 复合条件中边界判断必须置于解引用左侧。这是本次调试过程中反复踩坑的根本原因,也是后续编写涉及数组边界操作代码时必须坚守的原则。

相关推荐
inrichtt1 小时前
第七课时:STM32时钟系统揭秘:从零理解时钟树
c语言·数据结构·stm32·单片机·嵌入式硬件
知识分享小能手2 小时前
C学习教程,从入门到精通,C语言指针 —— 详细知识点总结(10)
c语言·开发语言·学习
金融小师妹2 小时前
多因子时间序列分析:美元与美债收益率变化对黄金价格+1.6%的影响
c语言·人工智能·git·决策树·pyqt
垆边人似月.2 小时前
删边使每个连通块权值和为 target
算法·深度优先·图论
xxwl5852 小时前
区域赛算法模版第四章---数据结构
数据结构·c++·算法
额额额对了2 小时前
Linux 驱动开发:基于 platform 总线 + misc 设备的 DS18B20 单总线温度驱动
linux·c语言·开发语言·驱动开发·嵌入式硬件
奶盐cookie3 小时前
内存区域:栈、全局区和静态区
c语言
xxwl5853 小时前
第六章---计算几何
笔记·学习·算法