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