写在前面
在算法的世界里,有些问题看似简单,却藏着精妙的设计哲学;有些解法表面相似,实则因数据结构的差异而大相径庭。
“合并两个有序序列”正是这样一类经典问题——它频繁出现在面试中,既考察你对基础数据结构的理解,也检验你对算法思想的灵活运用。无论是数组还是链表,题目都只有一句话:“把两个排好序的结构合并成一个有序的整体。”但真正动手实现时,你会发现:数组要往后填,链表要往前接;一个怕覆盖,一个怕断链。
本文将带你深入对比两道高频算法题目——「合并两个有序数组」与「合并两个有序链表」,从问题本质出发,剖析为何相同的归并思想,在不同数据结构上需要截然不同的实现策略。通过理解背后的“为什么”,你不仅能记住解法,更能举一反三,从容应对各种变形题。
合并两个有序数组:
我们当然可以采用暴力方法——把 nums2 直接复制到 nums1 的末尾,再对整个数组排序。但这样不仅忽略了两个数组原本有序这一关键信息,还会导致时间复杂度上升到 O((m+n)log(m+n)),在数据量较大时极易超时。
因此,更高效的做法是利用双指针从后往前(倒序)合并。那么,如果反过来,采用正向(从左往右)的双指针策略,会有什么问题呢?
问题在于:nums1 的前 m 个位置存储着有效数据,而它的后 n 个位置才是预留的空位。如果我们从左往右合并,一旦需要将 nums2 中较小的元素插入到 nums1 的前面,就会覆盖掉 nums1 中尚未处理的原始元素。例如,若 nums2[0] < nums1[0],直接写入 nums1[0] 就会永久丢失原来的 nums1[0],而这个值可能在后续比较中仍然需要使用。
换句话说,正向合并会破坏 nums1 中还未被处理的有效数据,导致结果错误。而倒序合并巧妙地避开了这个问题——它从 nums1 的尾部开始填充,充分利用了末尾的空闲空间,确保在移动任何元素之前,目标位置都是“安全”的,不会覆盖仍有用的数据。
因此,倒序三指针法不仅时间复杂度最优(O(m+n)),而且空间复杂度为 O(1),是解决此问题的最佳策略。
我们初始化三个指针:
-
i = m - 1,指向nums1中有效元素的最后一个位置; -
j = n - 1,指向nums2的最后一个元素; -
k = m + n - 1,指向合并后数组nums1的最终末尾位置。接下来,我们从后往前进行合并:
不断比较nums1[i]和nums2[j]的大小,将较大的那个放到nums1[k]的位置。 -
如果
i >= 0且nums1[i] > nums2[j],就把nums1[i]放入nums1[k],然后i--、k--; -
否则(包括
nums1[i] <= nums2[j]或i < 0的情况),就把nums2[j]放入nums1[k],然后j--、k--。
特别地,当两个元素相等时,优先放入 nums2[j]。这样做在所有元素都相等的极端情况下,可以尽早消耗完 nums2,减少不必要的判断和移动,略微提升效率。
我们持续这个过程,直到 j < 0 —— 也就是说,nums2 中的所有元素都已经成功合并进 nums1。
这时可能会有疑问:如果 nums1 中还有剩余元素(即 i >= 0)怎么办?
其实无需处理。因为这些剩下的元素本来就在 nums1 的前部,而它们的目标位置恰好就是当前位置(前面没有被覆盖,后面也已填满更大的数)。例如,若 nums1 = [1,2,3,*,*,*],nums2 = [4,5,6],那么在整个过程中 nums1 的 [1,2,3] 根本不需要移动,只需把 nums2 的元素依次填到后面即可。一旦 nums2 合并完成,整个数组自然有序。
因此,只要确保 nums2 全部填入,合并就完成了——这是一个巧妙的优化点。
完整的代码如下:
/**
* @param {number[]} nums1
* @param {number} m
* @param {number[]} nums2
* @param {number} n
* @return {void} Do not return anything, modify nums1 in-place instead.
*/
var merge = function(nums1, m, nums2, n) {
let i = m - 1, j = n - 1;
let k = m + n - 1;
while(i >= 0 && j >= 0){
if(nums1[i] >= nums2[j]){
nums1[k] = nums1[i];
i--;
}else{
nums1[k] = nums2[j];
j--;
}
k--;
}
while(j >= 0){
nums1[k] = nums2[j];
k--;
j--;
}
};
合并两个有序链表
链表与数组不同,它仅通过头节点提供访问入口,无法像数组那样随机访问元素,因此不能像合并两个有序数组那样采用从后往前的方式合并两个有序链表。
这里我采用了哨兵节点(虚拟头节点)配合尾插法的策略来合并两个有序链表——这种写法借鉴了合并两个有序数组时“双指针逐个比较、顺序归并”的思想,但针对链表无法随机访问、只能从头遍历的特性做了适配。通过引入哨兵节点,我们避免了对头节点插入的特殊判断,。如果你对哨兵节点的作用还不熟悉,欢迎参考我的文章:链表三板斧:双指针、虚拟头节点与反转套路全解析
我们依次比较 list1 和 list2 当前节点的值:
- 若
list1的节点值更小,则将其接入新链表的尾部,并将list1向后移动一位; - 若
list2的节点值更小,则执行相同操作,将list2接入并后移; - 若两者相等,按约定优先选择
list2的节点接入(当然选list1也可以,不影响正确性,这里仅为统一逻辑)。
重复这一过程,直到其中一个链表遍历完毕。
此时,另一个链表可能仍有未处理的剩余节点。由于这些节点本身已有序,可直接将整段剩余链表拼接到结果链表的末尾。
最终,新链表的真正头节点即为哨兵节点(虚拟头节点)的 next,将其返回即可。
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} list1
* @param {ListNode} list2
* @return {ListNode}
*/
var mergeTwoLists = function(list1, list2) {
let dummy = new ListNode(-1);
let cur = dummy;
while(list1 && list2){
if(list1.val <= list2.val){
cur.next = list1;
list1 = list1.next;
} else {
cur.next = list2;
list2 = list2.next;
}
cur = cur.next;
}
cur.next = list1 || list2;
return dummy.next;
};
总结
合并两个有序序列是算法中非常经典的一类问题,无论是数组还是链表,其核心思想都源于归并排序中的“归并”步骤:利用两个序列本身有序的特性,通过双指针逐个比较、顺序合并,从而在线性时间内完成任务。
然而,数据结构的底层特性决定了实现细节的巨大差异:
- 对于数组(如 LeetCode 88 题) ,由于
nums1的后半部分预留了空位,我们无法像链表那样动态拼接节点。若采用正向合并,会覆盖尚未处理的有效数据;因此必须采用从后往前的倒序三指针法,巧妙利用尾部空闲空间,做到原地、高效、安全地合并,时间复杂度为 O(m+n),空间复杂度为 O(1)。 - 对于链表(如 LeetCode 21 题) ,虽然不能随机访问、也无法“预留空间”,但其动态链接的特性允许我们直接调整指针指向。通过引入哨兵节点(虚拟头节点)+ 尾插法,我们统一了头节点插入的逻辑,避免了繁琐的边界判断,代码简洁且鲁棒性强。整个过程同样是线性时间 O(m+n),且无需额外存储空间(仅用常数个指针)。
这两道题看似相似,实则体现了算法设计中“因地制宜”的智慧:同样的归并思想,在不同数据结构上需要不同的工程实现。掌握这种“抽象思想 + 具体适配”的能力,正是应对各类算法面试题的关键。
小贴士:
- 数组合并 → 想“倒着填”;
- 链表合并 → 想“哨兵 + 接尾巴”。
熟练运用这些套路,不仅能写出正确代码,更能写出清晰、健壮、高效的代码。
希望这篇对比解析能帮你打通“有序合并”这一高频考点。如果你觉得有收获,欢迎点赞、收藏,也别忘了看看我那篇更深入的链表三板斧:双指针、虚拟头节点与反转套路全解析 —— 链表问题,其实没那么可怕!