从合并两个链表到 K 个链表:分治思想的递进与堆优化

文章目录

从合并两个链表到 K 个链表:分治思想的递进与堆优化

在链表操作中,"合并有序链表" 是一个经典问题。从最初的 "合并两个升序链表",到进阶的 "合并 K 个升序链表",解法思路的演变不仅体现了算法效率的优化,更蕴含了 "分治思想" 从特殊到一般的应用逻辑。本文将一步步拆解问题,从基础解法到高效优化,带你理解如何用分治思维解决复杂问题。

题目链接

一、问题引入:从简单场景开始

1.1 合并两个升序链表

先从最基础的问题入手:给定两个升序链表,如何合并为一个升序链表?

示例:

plaintext 复制代码
输入:l1 = 1->4->5, l2 = 1->3->4
输出:1->1->3->4->4->5
解法思路:双指针遍历

合并两个有序链表的核心是 "比较与拼接":

  • 用两个指针分别遍历两个链表,每次取当前值较小的节点接入结果链表;
  • 当一个链表遍历结束后,直接拼接另一个链表的剩余部分。

为了简化头节点的处理,通常会引入 "哨兵节点"(dummy node),避免空指针判断的繁琐。

代码实现:
cpp 复制代码
struct ListNode {
    int val;
    ListNode *next;
    ListNode() : val(0), next(nullptr) {}
    ListNode(int x) : val(x), next(nullptr) {}
};

ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
    // 哨兵节点,简化头节点处理
    ListNode* dummy = new ListNode();
    ListNode* cur = dummy;  // 游标指针,指向结果链表的尾部
    
    // 双指针遍历两个链表,取较小值拼接
    while (l1 && l2) {
        if (l1->val < l2->val) {
            cur->next = l1;  // 接入l1的当前节点
            l1 = l1->next;   // l1指针后移
        } else {
            cur->next = l2;  // 接入l2的当前节点
            l2 = l2->next;   // l2指针后移
        }
        cur = cur->next;  // 结果链表尾部后移
    }
    
    // 拼接剩余节点(其中一个链表已遍历完)
    cur->next = l1 ? l1 : l2;
    
    ListNode* result = dummy->next;
    delete dummy;  // 释放哨兵节点,避免内存泄漏
    return result;
}
复杂度分析:
  • 时间复杂度:O (M+N),其中 M、N 分别为两个链表的长度(每个节点最多被访问一次)。
  • 空间复杂度:O (1),仅使用常数级额外空间(哨兵节点可释放,不算入有效空间)。

1.2 扩展:合并 K 个升序链表

当问题从 "2 个" 扩展到 "K 个",情况变得复杂:给定 K 个升序链表,如何合并为一个升序链表?

示例:

plaintext 复制代码
输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:1->1->2->3->4->4->5->6

最直接的思路是 "复用合并两个链表的逻辑",但如何高效复用呢?

二、初步尝试:迭代两两合并

2.1 思路分析

既然能合并两个链表,那么可以依次将 K 个链表合并到一个结果链表中

  1. 初始化结果链表为 null;
  2. 遍历链表数组,每次将当前结果链表与下一个链表合并,更新结果;
  3. 最终得到合并后的总链表。
代码实现:
cpp 复制代码
ListNode* mergeKLists_Iterative(vector<ListNode*>& lists) {
    if (lists.empty()) return nullptr;  // 空数组直接返回null
    
    ListNode* result = nullptr;
    for (ListNode* list : lists) {
        // 每次合并当前结果与下一个链表
        result = mergeTwoLists(result, list);
    }
    return result;
}

2.2 问题:效率低下

假设 K 个链表的总节点数为 T(每个链表平均长度为 T/K),分析时间复杂度:

  • 第 1 次合并:结果链表长度为 0 + len (lists [0]) → O (len (lists [0]));
  • 第 2 次合并:结果链表长度为 len (lists [0]) + len (lists [1]) → O (len (lists [0]) + len (lists [1]));
  • ...
  • 第 K 次合并:结果链表长度为前 K-1 个链表的总长度 → O (T)。

总时间复杂度为 O (T + T/2 + T/3 + ... + T/K) ≈ O (K*T),当 K 较大时(如 K=10^4),效率极低。

三、优化思路:分治思想的应用

3.1 为什么分治能优化?

分治思想的核心是 "将大问题拆解为小问题,再合并小问题的解"。对于 K 个链表的合并:

  • 若 K=1,直接返回该链表;
  • 若 K>1,将链表数组分成左右两部分,分别合并这两部分得到两个链表,再合并这两个链表即可。

通过分治,每次合并的规模减半,最终需要合并的次数从 K 次降为 logK 次,大幅减少重复操作。

3.2 分治流程示例

以 K=4 个链表为例:

plaintext 复制代码
初始链表:[l0, l1, l2, l3]
第1次分治:合并 [l0, l1] → m0;合并 [l2, l3] → m1
第2次分治:合并 [m0, m1] → 最终结果

3.3 代码实现:递归分治

cpp 复制代码
class Solution {
public:
    // 合并两个链表(复用之前的实现)
    ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
        if (!l1) return l2;
        if (!l2) return l1;
        
        ListNode* dummy = new ListNode();
        ListNode* cur = dummy;
        while (l1 && l2) {
            if (l1->val < l2->val) {
                cur->next = l1;
                l1 = l1->next;
            } else {
                cur->next = l2;
                l2 = l2->next;
            }
            cur = cur->next;
        }
        cur->next = l1 ? l1 : l2;
        
        ListNode* res = dummy->next;
        delete dummy;
        return res;
    }
    
    // 分治递归函数:合并 [left, right] 范围内的链表
    ListNode* merge(vector<ListNode*>& lists, int left, int right) {
        if (left == right) {  // 递归终止:只有一个链表
            return lists[left];
        }
        if (left > right) {  // 空范围(如偶数拆分时可能出现)
            return nullptr;
        }
        // 中间位置,拆分左右两部分
        int mid = left + (right - left) / 2;
        // 递归合并左半部分和右半部分,再合并结果
        return mergeTwoLists(merge(lists, left, mid), merge(lists, mid + 1, right));
    }
    
    // 入口函数
    ListNode* mergeKLists(vector<ListNode*>& lists) {
        if (lists.empty()) return nullptr;
        return merge(lists, 0, lists.size() - 1);
    }
};

3.4 复杂度分析

  • 时间复杂度:O (TlogK)。

    每次递归将问题规模减半,递归深度为 logK;每一层的总合并操作需要遍历所有 T 个节点,因此总复杂度为 TlogK。

  • 空间复杂度:O (logK)。递归调用栈的深度为 logK(忽略结果链表本身的存储空间)。

四、解法三:优先队列(最小堆)解法(另一种高效思路)

4.1 思路分析

合并 K 个链表的核心问题是:如何快速从 K 个链表的当前头节点中找到最小值。优先队列(最小堆)正好解决这个问题:

  • 堆的顶部始终是最小元素,可在 O (1) 时间内获取;
  • 插入和删除操作的时间复杂度为 O (logK)(堆的大小不超过 K)。

具体步骤:

  1. 初始化最小堆,将 K 个链表的非空头节点入堆;
  2. 每次从堆顶取出最小节点,接入结果链表;
  3. 若取出的节点有下一个节点,将其入堆;
  4. 重复步骤 2-3,直到堆为空,此时所有节点均已合并。

4.2 代码实现

cpp 复制代码
#include <queue>
#include <vector>
using namespace std;

class Solution {
public:
    ListNode* mergeKLists(vector<ListNode*>& lists) {
        // 定义最小堆的比较器(C++默认是最大堆,需自定义为最小堆)
        auto cmp = [](ListNode* a, ListNode* b) {
            return a->val > b->val;  // 值大的节点优先级低,堆顶为最小值
        };
        // 初始化最小堆,底层容器为vector,比较器为cmp
        priority_queue<ListNode*, vector<ListNode*>, decltype(cmp)> minHeap(cmp);
        
        // 所有链表的头节点入堆(跳过空链表)
        for (ListNode* list : lists) {
            if (list) {  // 避免空指针入堆
                minHeap.push(list);
            }
        }
        
        // 哨兵节点,简化结果链表的构建
        ListNode* dummy = new ListNode();
        ListNode* cur = dummy;
        
        // 循环取堆顶最小节点,构建结果链表
        while (!minHeap.empty()) {
            // 取出当前最小节点
            ListNode* smallest = minHeap.top();
            minHeap.pop();
            
            // 接入结果链表
            cur->next = smallest;
            cur = cur->next;
            
            // 若该节点有下一个节点,入堆参与后续比较
            if (smallest->next) {
                minHeap.push(smallest->next);
            }
        }
        
        ListNode* result = dummy->next;
        delete dummy;
        return result;
    }
};

4.3 关键细节解析

  • 最小堆的实现 :C++ 的 priority_queue 默认是最大堆,通过自定义比较器 cmp(返回 a->val > b->val),使堆顶始终为最小元素。
  • 空链表处理:入堆前判断链表是否为空,避免空指针访问。
  • 节点更新:每次取出节点后,若其有后续节点,立即入堆,保证堆中始终包含所有链表的 "当前最小节点"。

4.4 复杂度分析

  • 时间复杂度:O (TlogK)。

    每个节点入堆和出堆各一次,共 T 次操作;每次堆操作的时间为 O (logK)(堆的大小不超过 K),因此总复杂度为 T

    logK。

  • 空间复杂度:O (K)。堆中最多同时存储 K 个节点(每个链表一个当前头节点)

五、总结:从特殊到一般的思维迁移

  1. 基础是关键:合并 K 个链表的所有解法都依赖于 "合并两个链表" 的基础逻辑,掌握基础才能进阶。
  2. 效率优化的核心 :从 O (KT) 到 O (TlogK) 的突破,本质是减少了 "找最小值" 的重复操作 ------ 分治通过减少合并次数,优先队列通过数据结构直接优化查找过程。
  3. 算法选择:实际应用中,分治和优先队列都是高效解法,可根据场景选择(如递归深度限制、内存限制等)。

六、 补充

优先队列:定义、原理与实战案例

优先队列是一种特殊的队列结构,它打破了普通队列 "先进先出(FIFO)" 的规则,而是按照元素的优先级决定出队顺序 ------ 优先级高的元素始终先出队。它在算法优化、任务调度等场景中应用广泛,是解决 "动态选最优" 问题的核心工具。

一、什么是优先队列?
1.1 核心定义

优先队列(Priority Queue)是一种抽象数据结构,其核心特性是:

  • 存储元素:每个元素都带有一个 "优先级"(可通过元素自身值或自定义规则确定);
  • 出队规则 :每次出队的不是队列头部的元素,而是当前所有元素中优先级最高的元素
  • 入队规则:元素按正常顺序插入,但插入后会自动调整结构,确保 "优先级最高元素在顶端" 的特性。

它本质是 "队列的抽象功能" 与 "堆(Heap)的底层实现" 的结合 ------ 堆是实现优先队列的高效数据结构(时间复杂度最优),因此通常说 "优先队列" 时,默认指 "基于堆实现的优先队列"。

1.2 底层实现:为什么用堆?

优先队列的核心需求是 "快速找最优(最大 / 最小)" 和 "快速插入新元素",而堆(完全二叉树结构)恰好满足这两个需求:

  • 找最优 :堆的顶端(根节点)就是优先级最高的元素,访问时间复杂度 O(1)
  • 插入元素 :新元素插入堆尾后,通过 "上浮" 调整到正确位置,时间复杂度 O(logN)(N 为元素总数);
  • 删除最优元素 :删除根节点后,用堆尾元素补位并 "下沉" 调整,时间复杂度 O(logN)

若用数组或链表实现优先队列,"找最优" 需遍历所有元素(O (N)),效率远低于堆,因此堆是优先队列的标准实现方式。

1.3 两种常见类型

根据 "优先级高" 的定义不同,优先队列分为两类:

类型 核心特性 堆的实现方式 应用场景举例
最大优先队列 优先级最高 = 元素值最大 最大堆(根是最大值) 任务调度(优先级数值大的任务先执行)
最小优先队列 优先级最高 = 元素值最小 最小堆(根是最小值) 合并有序链表、最短路径算法

注意:优先级规则可自定义(如 "任务截止时间早的优先级高"),不一定依赖元素自身值。

案例

下面通过几个具体的 C++ 代码案例,展示优先队列(std::priority_queue)在不同场景下的使用方法,涵盖基础操作、自定义优先级、经典算法应用等。

一、优先队列基础操作(最大堆与最小堆)

C++ 标准库的 std::priority_queue 默认是最大堆 (优先级最高的元素是最大值),通过自定义比较器可实现最小堆

cpp 复制代码
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

int main() {
    // 1. 默认最大堆(元素类型为int,底层容器vector,比较器less<int>)
    priority_queue<int> max_heap;
    max_heap.push(3);
    max_heap.push(1);
    max_heap.push(4);
    max_heap.push(2);
    cout << "最大堆元素出队顺序:";
    while (!max_heap.empty()) {
        cout << max_heap.top() << " ";  // 每次取最大值
        max_heap.pop();
    }
    // 输出:4 3 2 1


    // 2. 最小堆(需自定义比较器greater<int>)
    priority_queue<int, vector<int>, greater<int>> min_heap;
    min_heap.push(3);
    min_heap.push(1);
    min_heap.push(4);
    min_heap.push(2);
    cout << "\n最小堆元素出队顺序:";
    while (!min_heap.empty()) {
        cout << min_heap.top() << " ";  // 每次取最小值
        min_heap.pop();
    }
    // 输出:1 2 3 4

    return 0;
}

关键说明

  • priority_queue 模板参数:priority_queue<元素类型, 底层容器, 比较器>,底层容器默认是 vector
  • 比较器 less<int>() 对应最大堆(a < bb 优先级高);
  • 比较器 greater<int>() 对应最小堆(a > bb 优先级高)。
二、自定义元素类型与优先级(结构体案例)

当元素是自定义结构体时,需通过重载运算符自定义比较器指定优先级规则。

案例:任务调度(优先级高的任务先执行)
cpp 复制代码
#include <iostream>
#include <queue>
#include <string>
using namespace std;

// 任务结构体:包含任务名和优先级(数值越大优先级越高)
struct Task {
    string name;
    int priority;

    Task(string n, int p) : name(n), priority(p) {}
};

// 自定义比较器(用于最大堆,优先级高的任务先出队)
// 规则:若a.priority < b.priority,则b优先级更高,应排在堆顶
struct TaskCmp {
    bool operator()(const Task& a, const Task& b) {
        return a.priority < b.priority;  // 最大堆(优先级高的在前)
    }
};

int main() {
    // 优先队列存储Task,使用自定义比较器TaskCmp
    priority_queue<Task, vector<Task>, TaskCmp> task_queue;

    // 插入任务
    task_queue.emplace("紧急修复", 10);  // emplace直接构造对象,更高效
    task_queue.emplace("日常维护", 5);
    task_queue.emplace("数据备份", 3);
    task_queue.emplace("系统升级", 8);

    // 执行任务(按优先级从高到低)
    cout << "任务执行顺序:\n";
    while (!task_queue.empty()) {
        Task t = task_queue.top();
        task_queue.pop();
        cout << "任务:" << t.name << ",优先级:" << t.priority << endl;
    }
    /* 输出:
    任务:紧急修复,优先级:10
    任务:系统升级,优先级:8
    任务:日常维护,优先级:5
    任务:数据备份,优先级:3
    */

    return 0;
}

关键说明

  • 自定义比较器是一个结构体,需重载 operator(),返回 bool 类型;
  • 比较逻辑:return a.priority < b.priority 表示 "b 优先级高于 a",因此堆顶是优先级最高的元素;
  • 若要实现 "优先级低的任务先执行"(最小堆),只需将比较器改为 return a.priority > b.priority
三、算法应用:合并 K 个升序链表

这是优先队列的经典应用,用最小堆快速获取 K 个链表中的最小节点。

cpp 复制代码
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

// 链表节点定义
struct ListNode {
    int val;
    ListNode *next;
    ListNode(int x) : val(x), next(nullptr) {}
};

class Solution {
public:
    ListNode* mergeKLists(vector<ListNode*>& lists) {
        // 自定义比较器:最小堆(val小的节点优先级高)
        auto cmp = [](ListNode* a, ListNode* b) {
            return a->val > b->val;  // 注意:这里用>表示最小堆
        };
        // 优先队列存储链表节点指针,使用自定义lambda比较器
        priority_queue<ListNode*, vector<ListNode*>, decltype(cmp)> min_heap(cmp);

        // 初始化:将所有非空链表的头节点入堆
        for (ListNode* list : lists) {
            if (list != nullptr) {  // 跳过空链表
                min_heap.push(list);
            }
        }

        // 哨兵节点,简化结果链表的构建
        ListNode* dummy = new ListNode(0);
        ListNode* cur = dummy;

        // 循环取堆顶最小节点,构建结果链表
        while (!min_heap.empty()) {
            ListNode* smallest = min_heap.top();  // 取当前最小节点
            min_heap.pop();

            cur->next = smallest;  // 接入结果链表
            cur = cur->next;

            // 若该节点有下一个节点,入堆参与后续比较
            if (smallest->next != nullptr) {
                min_heap.push(smallest->next);
            }
        }

        ListNode* result = dummy->next;
        delete dummy;  // 释放哨兵节点,避免内存泄漏
        return result;
    }
};

// 辅助函数:打印链表
void printList(ListNode* head) {
    while (head != nullptr) {
        cout << head->val << "->";
        head = head->next;
    }
    cout << "nullptr" << endl;
}

int main() {
    // 构建测试用例:3个升序链表
    ListNode* l1 = new ListNode(1);
    l1->next = new ListNode(4);
    l1->next->next = new ListNode(5);

    ListNode* l2 = new ListNode(1);
    l2->next = new ListNode(3);
    l2->next->next = new ListNode(4);

    ListNode* l3 = new ListNode(2);
    l3->next = new ListNode(6);

    vector<ListNode*> lists = {l1, l2, l3};

    Solution sol;
    ListNode* merged = sol.mergeKLists(lists);
    cout << "合并后的链表:";
    printList(merged);  // 输出:1->1->2->3->4->4->5->6->nullptr

    return 0;
}

关键说明

  • 用 lambda 表达式作为比较器,需用 decltype(cmp) 声明类型,并在构造队列时传入 cmp
  • 堆中始终存储各链表的 "当前头节点",每次取出最小值后,将其下一个节点入堆,确保堆中始终有候选的最小节点;
  • 时间复杂度 O (T log K)(T 为总节点数,K 为链表数),效率远高于暴力法。
四、算法应用:数据流中的第 K 大元素

用大小为 K 的最小堆维护 "前 K 大元素",堆顶即为第 K 大元素。

cpp 复制代码
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

class KthLargest {
private:
    int k;
    priority_queue<int, vector<int>, greater<int>> min_heap;  // 最小堆,大小不超过k

public:
    KthLargest(int k, vector<int>& nums) : k(k) {
        // 初始化:将所有元素入堆,再弹出多余元素,保留前k大
        for (int num : nums) {
            min_heap.push(num);
            if (min_heap.size() > k) {
                min_heap.pop();  // 超过k个元素时,弹出最小值(保证堆内是前k大)
            }
        }
    }

    int add(int val) {
        // 新元素入堆
        min_heap.push(val);
        // 若堆大小超过k,弹出最小值
        if (min_heap.size() > k) {
            min_heap.pop();
        }
        // 堆顶即为当前第k大元素
        return min_heap.top();
    }
};

int main() {
    vector<int> nums = {4, 5, 8, 2};
    KthLargest kthLargest(3, nums);  // 初始化:找第3大元素

    cout << kthLargest.add(3) << endl;  // 插入3后,前3大是4、5、8 → 输出4
    cout << kthLargest.add(5) << endl;  // 插入5后,前3大是5、5、8 → 输出5
    cout << kthLargest.add(10) << endl; // 插入10后,前3大是5、8、10 → 输出5?不,是8?
    // 修正:插入10后堆内元素为5、8、10,堆顶是5?不,最小堆的堆顶是5,但第3大是5?
    // 哦不,第3大是排序后第3个元素:[2,3,4,5,5,8,10] → 第3大是5?不,是从大到小排:10,8,5,5,4,3,2 → 第3大是5。
    // 因此输出5是对的。

    return 0;
}

关键说明

  • 堆的大小始终保持为 K,插入新元素后若超过 K,则弹出最小值,确保堆内是当前 "前 K 大元素";
  • 堆顶是这 K 个元素中的最小值,即整个数据流中的第 K 大元素;
  • 每次插入和查询的时间复杂度为 O (log K),适合高频插入场景。
相关推荐
又见野草5 小时前
软件设计师知识点总结:数据结构与算法(超级详细)
数据结构·算法·排序算法
曹牧8 小时前
C#:数组不能使用Const修饰符
java·数据结构·算法
大数据张老师8 小时前
数据结构——拓扑排序
数据结构
草莓工作室9 小时前
数据结构10:树和二叉树
数据结构
当战神遇到编程11 小时前
链表的概念和单向链表的实现
数据结构·链表
INGNIGHT11 小时前
单词搜索 II · Word Search II
数据结构·c++·算法
QuantumLeap丶13 小时前
《数据结构:从0到1》-06-单链表&双链表
数据结构·算法
violet-lz13 小时前
数据结构八大排序:快速排序-挖坑法(递归与非递归)及其优化
数据结构
Mrliu__14 小时前
Python数据结构(七):Python 高级排序算法:希尔 快速 归并
数据结构·python·排序算法