11、链表的合并与拆分:合并两个有序链表、拆分链表为奇偶两部分

链表操作里,合并和拆分是两种非常经典的操作。我个人觉得,这两个操作特别能检验你对指针操作的理解深度。说白了,就是看你能不能把链表节点像搭积木一样,拆开再拼回去。

我在项目中遇到过好几次这样的场景:系统里有两个传感器数据流,各自按时间戳排好了序,需要合并成一个完整的数据流。还有一次是做数据分包,需要把一个链表按奇偶位置拆成两个独立的链表。嗯,今天我们就来聊聊这两个操作。

11.1 合并两个有序链表

合并两个有序链表,目标很明确:把两个已经排好序的链表,合并成一个新的有序链表。你想想看,这就像把两副按大小排好的扑克牌,合并成一整副。

常见的做法有两种:一种是新建一个链表,把节点一个个插进去;另一种是原地合并,不申请新节点。我个人更推荐第二种,因为嵌入式环境里内存很宝贵。

11.1.1 原地合并的思路

核心思路其实很简单:用两个指针分别遍历两个链表,每次比较当前节点的值,把较小的那个节点摘下来,接到新链表的尾部。

这里有个小技巧:我习惯用一个虚拟头节点(dummy head)来简化操作。这样就不用单独处理第一个节点为空的情况了。

// 合并两个有序链表,返回合并后的头指针
Node* mergeSortedLists(Node* head1, Node* head2) {
    Node dummy;          // 虚拟头节点,不存储有效数据
    Node* tail = &dummy; // tail始终指向新链表的最后一个节点
    dummy.next = NULL;

    while (head1 != NULL && head2 != NULL) {
        if (head1->data <= head2->data) {
            tail->next = head1;  // 把head1节点接过来
            head1 = head1->next; // head1指针后移
        } else {
            tail->next = head2;
            head2 = head2->next;
        }
        tail = tail->next; // tail始终指向最后一个节点
    }

    // 把剩下的节点直接接上
    if (head1 != NULL) {
        tail->next = head1;
    } else {
        tail->next = head2;
    }

    return dummy.next; // 虚拟头节点的下一个节点就是真正的头
}

关键点:这个算法的时间复杂度是 O(n+m),空间复杂度是 O(1)。没有申请任何新节点,只是改变了指针的指向。

我的习惯:写这种代码时,我一般会在纸上先画一遍指针的变化图。画清楚了再写代码,基本不会出错。

11.1.2 递归写法

其实合并有序链表也可以用递归写,代码会更简洁。不过我个人不太推荐在嵌入式里用递归,因为递归深度不可控,容易爆栈。

// 递归版本,仅供参考
Node* mergeRecursive(Node* head1, Node* head2) {
    if (head1 == NULL) return head2;
    if (head2 == NULL) return head1;

    if (head1->data <= head2->data) {
        head1->next = mergeRecursive(head1->next, head2);
        return head1;
    } else {
        head2->next = mergeRecursive(head1, head2->next);
        return head2;
    }
}

曾经踩过的坑:我曾经在一个深度优先搜索的算法里用了递归,结果链表长度超过1000时直接栈溢出了。从那以后,我在嵌入式代码里基本不用递归,除非我能确定递归深度不超过几十层。

11.2 拆分链表为奇偶两部分

拆分链表,就是把一个链表按某种规则分成两个。最常见的规则是按位置:奇数位置的节点组成一个链表,偶数位置的节点组成另一个链表。

你想想看,这就像排队报数,喊1的站左边,喊2的站右边。

11.2.1 奇偶拆分的实现

实现思路也很直接:用两个指针分别遍历奇数位置和偶数位置。每次把奇数位置的节点摘下来,接到奇数链表尾部;再把偶数位置的节点摘下来,接到偶数链表尾部。

// 拆分链表为奇偶两部分
// oddHead: 奇数位置链表的头指针
// evenHead: 偶数位置链表的头指针
void splitOddEven(Node* head, Node** oddHead, Node** evenHead) {
    if (head == NULL) {
        *oddHead = NULL;
        *evenHead = NULL;
        return;
    }

    Node oddDummy;   // 奇数链表的虚拟头节点
    Node evenDummy;  // 偶数链表的虚拟头节点
    Node* oddTail = &oddDummy;
    Node* evenTail = &evenDummy;
    oddDummy.next = NULL;
    evenDummy.next = NULL;

    int position = 1; // 位置从1开始计数
    Node* current = head;
    Node* nextNode;

    while (current != NULL) {
        nextNode = current->next; // 先保存下一个节点
        current->next = NULL;     // 断开当前节点

        if (position % 2 == 1) {
            // 奇数位置,接到奇数链表
            oddTail->next = current;
            oddTail = current;
        } else {
            // 偶数位置,接到偶数链表
            evenTail->next = current;
            evenTail = current;
        }

        current = nextNode;
        position++;
    }

    *oddHead = oddDummy.next;
    *evenHead = evenDummy.next;
}

注意:这里我用了两个虚拟头节点,分别管理奇偶链表。这样代码写起来很干净,不用单独处理第一个节点的情况。

11.2.2 另一种拆分方式:按值拆分

有时候我们不是按位置拆分,而是按节点值的大小来拆分。比如把小于某个阈值的节点放到一个链表,大于等于的放到另一个链表。这种操作在数据过滤时很常见。

// 按值拆分:小于threshold的放left,大于等于的放right
void splitByValue(Node* head, int threshold, Node** leftHead, Node** rightHead) {
    Node leftDummy, rightDummy;
    Node* leftTail = &leftDummy;
    Node* rightTail = &rightDummy;
    leftDummy.next = NULL;
    rightDummy.next = NULL;

    Node* current = head;
    Node* nextNode;

    while (current != NULL) {
        nextNode = current->next;
        current->next = NULL;

        if (current->data < threshold) {
            leftTail->next = current;
            leftTail = current;
        } else {
            rightTail->next = current;
            rightTail = current;
        }

        current = nextNode;
    }

    *leftHead = leftDummy.next;
    *rightHead = rightDummy.next;
}

11.3 核心逻辑流程图

下面这张图展示了合并和拆分的核心流程,我建议你仔细看看,理解指针是怎么移动的。

链表合并与拆分核心流程 合并两个有序链表 1 3 5 head1 2 4 6 head2 1 2 3 4 5 6 合并后 拆分链表为奇偶两部分 1 2 3 4 5 原链表 1 3 5 奇数位置 2 4 偶数位置 合并:比较两个链表当前节点值,取较小的接入新链表 拆分:按位置或按值,将节点分配到两个新链表 时间复杂度 O(n) | 空间复杂度 O(1)

11.4 常见问题与避坑指南

操作链表时,有几个地方特别容易出问题。我把自己踩过的坑总结一下,你写代码时多留个心眼。

问题 原因 解决方法
合并后链表成环 最后一个节点的next没有置NULL 每次摘节点时,先把current->next置NULL
拆分后丢失节点 遍历时没有保存下一个节点的指针 先保存nextNode,再操作当前节点
虚拟头节点忘记释放 dummy节点在栈上,但有人习惯用malloc 尽量用栈上的虚拟节点,省去释放的麻烦
递归合并导致栈溢出 链表太长,递归深度过大 用迭代代替递归

曾经踩过的坑:有一次我写合并代码,忘了把摘下来的节点next置NULL。结果合并后的链表里,最后一个节点还指着原来的下一个节点,形成了一个环。调试了整整一个下午才找到问题。嗯,从那以后我每次摘节点都会习惯性地把next置NULL。

11.5 总结

合并和拆分,说白了就是指针的重新排列。你只要记住三点:

  • 用虚拟头节点:简化边界处理,代码更干净
  • 先保存后操作:修改指针前,先保存下一个节点的地址
  • 断开要彻底:摘下来的节点,next一定要置NULL

掌握了这两个操作,你对链表的理解会上一个台阶。后面遇到更复杂的链表操作,比如链表排序、链表反转等,你会发现思路都是相通的。

我的建议:学链表操作,别光看代码。拿张纸,画几个方框代表节点,画箭头代表指针。手动模拟一遍指针的变化过程,比看十遍代码都管用。

公众号:蓝海资料掘金营,微信 deep3321