经常有人问我,分治和归并到底是两个东西还是一个东西。我的回答是:它们是一对黄金搭档。分治是方法论,解决问题时把大任务拆成小任务,再把小任务的结果汇总成大结果;归并是这场拆解之后最经典的合并动作,把两个有序序列合成一个有序序列。如果你正在学算法,或者刷题时被一堆“递归爆栈”“指针越界”折磨,这篇文章就是写给你看的。
我会从分治的底层逻辑讲起,接着把归并排序的代码彻底拆透,再顺手解决几个高频经典问题,最后分享一些进阶玩法和踩坑实录。所有代码用C++写,牵扯到复杂度推导的地方我会算给你看。文章不会太长篇大论讲废话,该给代码给代码,该给经验给经验。
1. 分治思想的底层逻辑:不是简单的“拆了再合”
1.1 分治的核心三件套
分治思想听起来玄乎,本质上就三步:分解、解决、合并。把原来规模为 n 的问题,拆成若干个规模更小的同类子问题;子问题继续递归拆,直到小到可以直接解决;然后逐层返回,把子问题的解合并成原问题的解。
我习惯用一个生活例子来解释。假设你要在一堆扑克牌里找出最大的那一张,正常思路是拿着一张一张比,O(n) 次比较就结束了。分治的思路是:把这堆牌从中间分成两堆,分别找出两堆各自的最大牌,再比较这两张谁更大。你可能会觉得这不是多此一举吗?但注意,当“找出最大值”升级成“给整副牌排序”或者“统计多少对元素是逆序的”,单次遍历解决不了,分治的价值就体现出来了。
分治真正厉害的地方不在“拆”,而在“合”。很多新手只把分治理解成递归 + 折半,然后写出来的代码只是形式上的分治,合并阶段没有任何信息利用,那自然快不起来。归并排序的合并阶段,利用了“两个子数组已经有序”这个已知条件,才能在 O(n) 时间内把两个 n/2 规模的子数组合并成有序数组,从而把整体复杂度做到 O(n log n)。
1.2 为什么分治能跑得比暴力快
这里不得不做一点简单的复杂度推导。以归并排序为例,设 T(n) 是排序 n 个元素所需时间,递归地看:
T(n) = 2T(n/2) + O(n)
意思是:排序 n 个元素,分解成两个 n/2 规模的子问题各花 T(n/2),合并两个有序数组要花 O(n)。展开这个递推式,每一层总的比较工作量都是 O(n),递归深度一共 log2(n) 层,所以 T(n) = O(n log n)。
对比冒泡排序和插入排序的 O(n²),n 从 10 万到 100 万规模时,O(n log n) 和 O(n²) 的差距不是一倍两倍,而是千倍万倍。你想想,如果核心业务接口里有一段 O(n²) 的排序逻辑,数据一涨接口就超时,换成归并或快排,瓶颈往往立刻消失。
主定理把这些规律总结成了公式。形如 T(n) = aT(n/b) + O(n^d) 的递推式,满足条件时复杂度可以直接查表得出。分治算法的场景非常多:归并排序、快速排序、最近点对、快速幂、归并求逆序对,核心都是这套“分解-解决-合并”的思路。
我踩过的一个坑是:分治的子问题必须互相独立,合并代价必须可控。如果子问题之间有大量重叠,强行分治只会浪费递归开销,这时候应该用动态规划或记忆化搜索。反过来,如果合并操作本身就需要 O(n²),那整体复杂度还会被合并拖累,分治的收益也会被抵消。
2. 归并排序:最标准的分治实战
2.1 核心代码逐行拆解
归并排序是分治思想最朴素的实现。我先把完整代码贴出来,再逐段讲为什么这样写。
#include <bits/stdc++.h> using namespace std; void merge(vector<int>& arr, int left, int mid, int right, vector<int>& temp) { int i = left; // 左半部分起点 int j = mid + 1; // 右半部分起点 int k = left; // 临时数组写入位置 // 双指针扫描,谁小谁先进临时数组 while (i <= mid && j <= right) { if (arr[i] <= arr[j]) { temp[k++] = arr[i++]; } else { temp[k++] = arr[j++]; } } // 左边有剩余,直接拷过去 while (i <= mid) { temp[k++] = arr[i++]; } // 右边有剩余,直接拷过去 while (j <= right) { temp[k++] = arr[j++]; } // 把合并结果复制回原数组 for (int idx = left; idx <= right; idx++) { arr[idx] = temp[idx]; } } void mergeSort(vector<int>& arr, int left, int right, vector<int>& temp) { if (left >= right) { return; // 单个元素已经有序 } int mid = left + ((right - left) >> 1); // 防溢出写法 mergeSort(arr, left, mid, temp); mergeSort(arr, mid + 1, right, temp); merge(arr, left, mid, right, temp); }递归的边界是left >= right。当区间里只有一个元素或没有元素时,它天然有序,不需要继续拆。mid的计算我特意用了left + ((right - left) >> 1)而不是(left + right) >> 1,主要防止 left 和 right 都很大时整数溢出。虽然刷题时数据范围可能到不了那个量级,但好习惯要养起来。
合并函数的核心是两个指针 i 和 j,分别指向左右两个子数组的当前元素。谁小就先把谁放进临时数组,然后对应指针往后走。这个“双指针归并”的手法值得背下来,后面求逆序对、求小和问题全都还会用到它。
2.2 稳定性与空间占用
归并排序是稳定的排序算法,这一点和快速排序不一样。代码里我用的是if (arr[i] <= arr[j]),当左右两个元素相等时,优先取左半边的元素放进临时数组。因为左半边的元素在原数组中本来就出现在右半边之前,这样做保证了相等元素的相对顺序不变。
空间占用方面,合并时需要一块长度等于当前区间的临时数组。我是在函数外预先分配好一整块temp,长度和原数组一样,每次合并都复用这块空间。这样做的原因是:如果每次递归都在函数内部新建临时数组,总的空间开销会变成 O(n log n),而且频繁分配内存带来的常数时间非常可观。实测下来,大数据量下每次分配临时数组的版本可能慢上三四倍。
时间上,归并排序的 O(n log n) 是稳定可预期的,不依赖输入数据的初始状态。这一点比快排更让人安心,快排在极端情况下会退化到 O(n²),而归并永远不会。
2.3 归并排序的应用边界
归并排序有一个优势场景常常被忽略:链表排序。数组版的归并需要额外临时数组,但链表版的归并不需要额外空间,只要改指针就能完成合并,空间复杂度直接降到 O(1)。LeetCode 上一堆链表排序题,用归并几乎是常规解法。
数组场景里,如果数据量不大、对稳定性没有特殊要求,大多数时候直接用内置 sort(快排 + 插入排序混合)就够了,常数小、代码简单。但一旦遇到“不仅排序,还需要在排序过程中统计信息”的问题,比如逆序对、小和问题,归并排序就是唯一能同时完成排序和统计的选择。这类问题我在下一节详细展开。
3. 分治经典问题进阶:从排序到统计
3.1 分治法求最大元素位置
先看一个很多人刷题时遇到的第一关:分治法求一个 n 元素数组中最大元素的位置。很多在线实验平台把这道题放在“分治”第一关,因为它逻辑简单、结构清晰。
int getMaxIndex(vector<int>& arr, int left, int right) { if (left == right) { return left; // 只剩一个元素,它自己就是最大值 } int mid = left + ((right - left) >> 1); int leftMaxIdx = getMaxIndex(arr, left, mid); int rightMaxIdx = getMaxIndex(arr, mid + 1, right); // 合并:比较左右两个最大值,返回较大的下标 if (arr[leftMaxIdx] >= arr[rightMaxIdx]) { return leftMaxIdx; } return rightMaxIdx; }注意几点。第一,题目要求返回位置,所以我返回的是下标,不是值。第二,多个最大值同时存在时,我用了>=,保证返回的是“第一个”最大元素的位置,这是很多题目隐含的细节要求。第三,这个算法的时间复杂度是 O(n),因为每一层合并只做一次比较,但递归压栈的深度是 O(log n),也算顺带复习了递归。
有一点我必须说清楚:真正在工程环境中找最大值位置,线性扫描就够了,几行代码搞定:
int maxPos = 0; for (int i = 1; i < n; i++) { if (arr[i] > arr[maxPos]) maxPos = i; }分治版的意义在于教学。它能帮你熟练“把大区间拆成两个小子区间,再合并子区间结果”的模式,为后面更复杂的分治问题打基础。别把精力浪费在纠结“为什么不用遍历”上,把分治模板练熟才是正事。
3.2 逆序对计数
逆序对定义很简单:i < j 时,若 a[i] > a[j],这俩元素构成一个逆序对。暴力算法两两比较,O(n²),数据量一上万就卡死。归并排序版的解法,时间复杂度 O(n log n),原理非常巧妙。
核心思想藏在合并阶段。假设当前需要合并左数组 [left, mid] 和右数组 [mid+1, right],两边各自已经有序。当右数组的指针 j 指向的元素比左数组指针 i 指向的元素小时,说明 a[i..mid] 里所有元素都大于 a[j](因为左数组是有序的,a[i] 已经是左边区间里最小的那个),所以 a[j] 和左数组剩余元素一一构成逆序对,逆序对数量直接累加mid - i + 1。
long long mergeCount(vector<int>& arr, int left, int mid, int right, vector<int>& temp) { int i = left; int j = mid + 1; int k = left; long long invCount = 0; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) { temp[k++] = arr[i++]; } else { // arr[j] 与 a[i..mid] 所有元素都构成逆序对 invCount += (mid - i + 1); temp[k++] = arr[j++]; } } while (i <= mid) { temp[k++] = arr[i++]; } while (j <= right) { temp[k++] = arr[j++]; } for (int idx = left; idx <= right; idx++) { arr[idx] = temp[idx]; } return invCount; } long long mergeSortCount(vector<int>& arr, int left, int right, vector<int>& temp) { if (left >= right) { return 0; } int mid = left + ((right - left) >> 1); long long count = 0; count += mergeSortCount(arr, left, mid, temp); count += mergeSortCount(arr, mid + 1, right, temp); count += mergeCount(arr, left, mid, right, temp); return count; }这里有一个特别容易踩的坑:逆序对数量要开long long,不能开int。一个长度为 100000 的数组,如果完全逆序排列,逆序对数量是 n(n-1)/2,大约 5 × 10^9,早就超出 int 的最大值 2.1 × 10^9 了。我之前因为偷懒用 int 交题,WA 了一次才反应过来,白白浪费十几分钟调试时间。
3.3 小和问题
小和问题和逆序对是同一套模板的两个变体。定义是:数组中每个元素左边所有比它小的元素值之和,累加所有元素就是小和。举个例子,数组 [1, 3, 5, 2, 4],3 左边比它小的有 1,贡献 1;5 左边比它小的有 1 和 3,贡献 4;2 左边比它小的有 1,贡献 1;4 左边比它小的有 1、3、2,贡献 6;总和是 12。
暴力解是 O(n²)。归并解法的视角是反过来的:与其统计每个元素左边有哪些更小值,不如统计每个值作为“更小值”时被多少个右侧元素借用。合并时,如果左数组当前元素 a[i] 小于等于右数组当前元素 a[j],说明 a[i] 比右数组从 j 到 right 的所有元素都小,贡献就是a[i] * (right - j + 1)。
long long mergeSmallSum(vector<int>& arr, int left, int mid, int right, vector<int>& temp) { int i = left; int j = mid + 1; int k = left; long long sum = 0; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) { // arr[i] 小于右数组剩余元素,累加贡献 sum += (long long)arr[i] * (right - j + 1); temp[k++] = arr[i++]; } else { temp[k++] = arr[j++]; } } while (i <= mid) { temp[k++] = arr[i++]; } while (j <= right) { temp[k++] = arr[j++]; } for (int idx = left; idx <= right; idx++) { arr[idx] = temp[idx]; } return sum; }乘法运算这里同样要注意强制转long long,避免两个 int 相乘溢出。这类归并统计问题,只要吃透了逆序对那套“利用有序性批量计算”的思路,基本可以举一反三。
4. 归并的进阶场景:不止于排序
4.1 多路归并与外部排序
归并思想在最基础的排序之外,还有两个经典延伸场景:多路归并和外部排序。先看多路归并。当你有 k 个已经有序的序列,想合并成一个有序序列,两两归并需要做 k-1 次归并。每次归并比较两个序列的头部元素,复杂度可以接受;但当序列数量很多时,每轮寻找 k 个头部中的最小值需要 k-1 次比较,整体效率会下降。
工程上的做法是用一个大小为 k 的堆来维护 k 个序列的当前头部元素,每次弹出最小值所在序列的头部,然后从该序列补充下一个元素进堆。这样每次取最小值的代价从 O(k) 降到 O(log k)。更进一步的数据结构是败者树,专门为多路归并设计,在磁盘外部排序的场景里已经用了很多年。
外部排序处理的是“内存装不下”的数据。假设内存只能放下 100MB,但待排序文件有 10GB。思路是把大文件切成若干小块,每块在内存内排好序写出成临时文件,最后用多路归并把这些有序临时文件边读边合,合并结果直接写到最终输出文件。归并排序在这里不只是算法题了,它直接决定了数据库排序、日志排序这些基础功能的性能。我在实际项目里做过 GB 级日志文件的排序,当时就是用了这个套路,把内排序和外归并拆开处理,稳得很。
4.2 四边形不等式优化DP:分治解法与二分解法
归并能在排序过程中顺带统计信息,已经属于进阶内容,但分治思想还能再往前走一步:优化动态规划。热搜里那个“四边形不等式优化 dp 分治解法 二分解法”,是最容易让初学者懵圈的一类题。
先交代背景。有些 DP 的状态转移形如 dp[i] = min(dp[j] + cost(j, i)),暴力枚举所有 j 是 O(n²)。如果 cost 函数满足四边形不等式,那么 DP 的最优决策点会随 i 单调递增,也就是“决策单调性”。这个性质一起,就能用分治在 O(n log n) 内求解。
分治解法的核心是:递归求解某个区间 [l, r] 的 dp 值时,同时传入一个可能的决策点搜索区间 [optL, optR],每次枚举决策点时只在这个区间里找。算出中点 mid 的最优决策点 optMid 后,递归求解左半区间时搜索区间收缩为 [optL, optMid],递归求解右半区间时搜索区间收缩为 [optMid, optR]。因为决策单调性保证了区间的收缩不会遗漏最优解,总的枚举量被压缩到 O(n log n)。
二分解法的思路是另一条路。既然决策点随 i 单调,就可以逐个确定每个决策点“接管”的状态区间。常见实现是维护一个单调栈或双端队列,每个队列元素保存“决策点 + 它作为最优决策的状态范围”,新决策点加入时用二分找到它接管范围的边界。整体复杂度同样是 O(n log n),但编码细节和分治解法差异很大。
我个人的体会是,如果比赛或面试中遇到这类题,优先考虑分治解法。原因很简单:分治解法的代码模板和归并排序的递归结构相似,思维负担小,边界条件也更直观。二分栈的写法对边界非常敏感,我自己写过几次,每逢“开区间闭区间”“最优值相等时取哪个决策点”这些细节都会卡壳。你需要根据自己对哪种模板更熟悉来做选择。
5. 踩坑实录:分治代码的边界地狱
5.1 递归边界你写对了吗
分治递归最常见的错误就是边界处理。mergeSort里我用的边界是if (left >= right) return;,这个写法做了两件事:区间里有一个元素时返回,区间为空时也返回。有的写法写if (left == right),当调用方不小心传入空区间就会死循环或越界。建议一律写>=,养成习惯。
合并循环里的边界同样要小心。while (i <= mid && j <= right)的两端边界都取等号,因为两个子数组的元素都要被扫描到,不能漏掉最后一个。拷贝回原数组时,循环也是for (int idx = left; idx <= right; idx++),从 left 到 right,不是从 0 开始也不是到 n-1 结束。
5.2 mid 计算的防溢出写法
mid = left + ((right - left) >> 1)这个写法我是强烈推荐的。老写法(left + right) / 2在 left 和 right 都是 2^31 量级时可能溢出成负数,结果完全错误。虽然普通刷题数据一般不会触发,但工程代码里数组索引完全可能很大,一次溢出就是隐蔽的 bug,调试成本极高。新写法把减法优先算了,永远不会溢出。
还有一个小细节:右移一位需要加括号,因为运算符优先级里右移低于加减法。写成left + (right - left) >> 1会变成(left + right - left) >> 1,实际等于right >> 1,直接整段逻辑错乱。
5.3 临时数组的复用与性能
我见过很多初学者喜欢在 merge 函数内部写vector<int> temp(right - left + 1);,逻辑没错,但性能很差。每次合并都触发一次内存分配,递归的每一层都会做很多次分配,总分配次数是 O(n) 级别,而内存分配本身是个昂贵操作。
正确的做法是在mergeSort外层初始化一整个temp,长度等于原数组长度,然后递归过程中所有区间合并共用这块空间。因为合并操作是串行的,同一个位置不会同时被两个合并使用,安全得很。实测对 100 万元素的数组排序,复用临时数组的版本比每次新建的版本快一倍以上,这个优化是白赚的。
5.4 相等元素顺序与稳定性
归并合并时,if (arr[i] <= arr[j])决定了稳定性。写成<会变成不稳定排序,虽然对纯数值排序结果没影响,但如果你排序的是一个对象数组,按某个字段排序,稳定性和不稳定性的结果可能完全不同。举个例子,先按时间排序,再按优先级排序,稳定排序能让相同优先级的元素保留原时间顺序,不稳定排序则可能打乱。
我在实际开发中确实遇到过一次这个需求。按订单创建时间排好序后,需要再按用户等级分组排序,同时保留组内的时间顺序。如果手写的归并排序用的是<,分组后时间顺序就乱了,排查半天才发现是稳定性写错了。
5.5 数据溢出的隐蔽炸弹
归并的统计类问题里,溢出的坑集中出现在两个地方:逆序对数量和小和累加值。逆序对数量最大是 n(n-1)/2,n=10^5 时就达到约 5 × 10^9,必须用long long。小和问题的累加值更夸张,如果一个元素值是 10^9,它在最坏情况下可能被累加 n 次,总和的量级是 10^14,连 int 的一个零头都装不下。
不仅变量类型要注意,乘法的中间结果也要转类型。arr[i] * (right - j + 1)如果两个操作数都是 int,乘法结果直接溢出,赋值给 long long 也救不回来。正确写法是(long long)arr[i] * (right - j + 1),先把一边转成 long long,整个表达式自动提升为 long long 运算。
我在实际解题中多次因为这些问题返工。分治本身不难,难的是各种边界和类型细节。写完代码后一定自己构造几组数据测一下:空数组、单元素、全部相等、完全逆序、完全有序。这些边界案例跑一遍,比你在编译器里反复看代码管用得多。
最后再分享一个小技巧:调试分治代码时,最好加一个打印函数,把每层递归处理的区间 [left, mid, right] 和合并后的数组打印出来。这样你能直观看到递归是否按预期拆解,合并是否真的有序。我过去调试归并二进制转储数据时,靠这个手段十分钟就定位到了问题,省去了两小时的怀疑人生。