子问题视角:分治与动态规划

分治算法

它是子问题视角的最简形态

两种思维里说过,子问题视角的做法是 让递归函数返回一个值,用子问题的答案拼出原问题的答案。

分治就是这句话的直接实现,只不过把「拼」这一步单独拎出来命名了:

  1. 分解 —— 把问题切成若干个规模更小、结构相同的子问题
  2. 解决 —— 递归地解决它们(base case 直接返回)
  3. 合并 —— 把子问题的答案拼成原问题的答案

⭐ 三步里只有第三步需要动脑。分解通常就是对半砍,解决是递归调用, 真正决定这道题难不难的是「怎么合并」。看到一道分治题卡住了, 九成是卡在合并上,不是卡在怎么分。

归并排序:合并是主戏

function mergeSort(nums) {
  if (nums.length <= 1) return nums;         // base case

  const mid = nums.length >> 1;
  const left = mergeSort(nums.slice(0, mid));   // 分解 + 解决
  const right = mergeSort(nums.slice(mid));

  return merge(left, right);                    // 合并
}

function merge(a, b) {
  const res = [];
  let i = 0, j = 0;
  while (i < a.length && j < b.length) {
    if (a[i] <= b[j]) res.push(a[i++]);      // ⚠️ 这个 = 号见下
    else res.push(b[j++]);
  }
  while (i < a.length) res.push(a[i++]);
  while (j < b.length) res.push(b[j++]);
  return res;
}

分解只有一行 mid,合并占了整个 merge 函数 —— 这就是「合并才是主戏」。

🚨 a[i] <= b[j] 里的等号决定了排序稳不稳定

稳定排序指的是:值相等的元素,排序后的相对顺序和排序前一样。

a 是左半段、b 是右半段。两个值相等时:

  • 写 <= —— 左半段的先出来,原来的先后顺序保住了,稳定
  • 写 < —— 右半段的先出来,相等元素被调了个个儿,不稳定

⚠️ 这个 bug 用数字数组永远测不出来 —— 3 和 3 交换了位置你也看不见。 只有排序的是对象(按某个字段排)时才会暴露,而那时候你多半已经忘了这一行。

📌 排数字随便写,排对象一定用 <=。JavaScript 内置的 Array.prototype.sort 在现代引擎里是稳定的(ES2019 起写进规范),自己写 merge 时别把这个性质弄丢了。

快速排序:合并是空的

function quickSort(nums, lo = 0, hi = nums.length - 1) {
  if (lo >= hi) return nums;

  const p = partition(nums, lo, hi);   // 先干活
  quickSort(nums, lo, p - 1);          // 再分解
  quickSort(nums, p + 1, hi);

  return nums;                          // 合并:什么都不用做
}

function partition(nums, lo, hi) {
  const pivot = nums[hi];
  let i = lo;
  for (let j = lo; j < hi; j++) {
    if (nums[j] < pivot) {
      [nums[i], nums[j]] = [nums[j], nums[i]];
      i++;
    }
  }
  [nums[i], nums[hi]] = [nums[hi], nums[i]];
  return i;
}

⭐ 拿它跟归并对照,能看出一个很漂亮的对称:

干活的位置 合并
归并排序 后序(子问题解决后) 是主戏
快速排序 前序(子问题解决前) 空的

这正是前中后序那三个时刻在排序算法上的体现。 归并先分后治,快排先治后分 —— 两者加起来,恰好把「递归的两个位置」都用上了。

快速选择:只递归一半

快排每次 partition 之后,pivot 就落在它最终该在的位置上了。 如果只想要第 k 大的那个数,根本不用把两边都排好 —— 看 pivot 的下标落在目标的哪一侧,只递归那一侧:

function findKthLargest(nums, k) {
  const target = nums.length - k;          // 第 k 大 = 升序里的下标 n-k
  let lo = 0, hi = nums.length - 1;
  while (true) {
    const p = partition(nums, lo, hi);     // 复用上面那个 partition
    if (p === target) return nums[p];
    if (p < target) lo = p + 1;            // 目标在右边,左边整段丢掉
    else hi = p - 1;
  }
}

[3,2,1,5,6,4]、k=2 → 5。

⭐ 丢掉一半带来的差别是量级上的:每层的规模是 n、n/2、n/4…, 加起来是 O(n),而不是排序的 n log n。

实测比较次数(数的是 partition 里 nums[j] < pivot 的执行次数; 取中位数、找中位数最能体现平均行为;随机数据跑 201 轮取中位数, 随机源用可播种的 mulberry32(7000+t) —— Math.random 播不了种, 用它报出来的单个数字读者永远核对不了):

数据形状 n=1000 n=4000
随机 3153 次(3.15n) 13100 次(3.27n)

⭐ 倍数在两个规模上都在 3.2n 附近,不随 n 增长 —— 这就是 O(n)。 (上表 3.15n 和 3.27n 那点差别在噪声里,不是「涨了」,见下。)

🚨 那个「趋近 3.39n」的趋势是真的,但它小于测量噪声

理论渐近值是 3.39n,有限 n 下偏低。6 组种子各跑 201 轮取均值:

n 1000 4000 16000 64000
6 组均值 3.18n 3.23n 3.25n 3.27n
单组种子的跨度 0.16 0.23 0.27 0.11

趋势确实单调,可同一个 n 上换组种子就能得到 3.12n ~ 3.39n —— 单组种子的跨度比整个趋势的跨度(3.18 → 3.27,只有 0.09)还大。

🚨 所以拿单个种子测出来的「它涨了」或「它跌了」没有任何信息量。 我第一次就是这么写错的:随手一组种子在 n=16000 量到 3.32n, 顺势写下「正往上靠」—— 换成正文这组种子是 3.12n,比 n=4000 那格还低。

⭐ 噪声是可以花时间买下来的,但得先知道自己在跟噪声比: n=4000 上把轮数从 201 加到 5001,三组种子的跨度从 0.147 收到 0.018。 📌 判据:报「随 n 变化的趋势」之前,先量一次同一个 n 上换种子的跨度。 跨度盖过趋势,这个趋势就还没测出来。

⚠️ 随机数据每一轮都不同,所以这行报的是中位数,而只有分位数是稳的: n=1000 的那 201 轮里 p10 = 2211、p90 = 4628。 🚨 别报 min/max —— 换一组种子它们就变(8 组种子实测 min 在 9991610、 max 在 58157900 之间跳)。而真正的下界是确定的 n−1: 第一次 partition 就撞上 target,一次就返回,此后再无比较。 3000 轮里撞到过 2 次。📌 报一个抽样出来的最小值,会把这个确定的下界盖掉。

下面那两行已排序的数据则完全确定,你跑出来会是一模一样的数。

🚨 但最坏情况是 O(n²),而「最坏输入」平淡得吓人

上面那个 partition 固定取 nums[hi] 当 pivot。 如果数组已经有序,每次切出来的都是空的一半,规模只减 1 不减半:

数据形状 n=1000 n=4000
随机(中位数) 3,153(3.15n) 13,100(3.27n)
已升序 374,750(375n) 5,999,000(1500n)
已降序 499,500(500n) 7,998,000(2000n)

⭐ 升序和降序差 1.33 倍 —— 因为切的方向不一样

已排序那两行都有闭式解,不用实测也能验。把每次 partition 的区间记下来就看清了:

已升序   partition 调用 500 次   [0,999] [0,998] [0,997] … [0,500] ← 命中
         pivot 恒是最大值 → 返回 hi → 只从右端切,走到 hi==target 就停
         Σ_{j=500}^{999} j = 374,750 ✓

已降序   partition 调用 1000 次  [0,999] [1,999] [1,998] [2,998] … [500,500]
         pivot 交替是最小值/最大值 → 返回 lo 和 hi 交替 → 两端轮流逼近,
         一次都没提前命中,一直做到区间只剩 1 个元素
         Σ_{j=1}^{999} j = 499,500 ✓

🚨 499,500 恰好等于 n(n−1)/2,也就是整个 quickSort 的最坏比较次数 —— 但这是巧合,两者机制不同。 quickSort 是切掉一个再递归剩下全部; 这里是快速选择两端交替逼近、p === target 那个提前返回一次都没触发。 📌 数值撞上了就顺手写因果,是这类实测最容易出的错。两边都记一次账才分得清。

⚠️ 关键是看倍数那一列,不是看绝对值: 随机数据 3.15n → 3.27n(不涨,差别在分位数宽度里), 已排序 375n → 1500n(n 翻两番,倍数也翻两番)。 ⭐ 倍数随 n 线性增长,就是 O(n²) 的指纹 —— 这个读法比记住某个具体数字有用得多。

📌 而「已排序」不是什么刁钻构造,它是最常见的输入之一。 判题机卡这道题用的就是它。

随机化 pivot:一行代码,省下的倍数随 n 线性增长

进 partition 之前先随机挑一个元素换到末尾:

const r = lo + Math.floor(Math.random() * (hi - lo + 1));
[nums[r], nums[hi]] = [nums[hi], nums[r]];   // 就这一行
const p = partition(nums, lo, hi);

在同样的已升序输入上实测(随机版 201 轮取中位数,同上用 mulberry32(7000+t)):

n 固定末尾 pivot 随机 pivot(中位数) 省
500 93,625 次 1,605 次 58 倍
1000 374,750 次 3,244 次 116 倍
2000 1,499,500 次 6,517 次 230 倍
4000 5,999,000 次 13,073 次 459 倍
8000 23,998,000 次 26,188 次 916 倍

🚨 别记「省 N 倍」这个数字 —— 它不是常数,实测 ≈ n/8:n 翻倍,「省」也翻倍。 这一节以前的标题写着「实测省 495 倍」,而表里从来没有 495 这个数 (那时表只有 1000 和 4000 两行,116 倍和 455 倍)。 ⭐ 这正是上一节那条读法的反例,而且是自己打自己: 倍数随 n 线性增长恰恰说明左边那列是 O(n²)、右边是 O(n) —— 把它压成一个数字,就把结论本身丢掉了。

📌 加了随机化之后,已排序输入的开销(13,073)和随机输入(13,100) 差 0.2% —— 「最坏输入」这个概念被消解掉了。 (两边用同一组种子、同一个口径量的;不同口径混着比会得出别的差值。)

⭐ 注意随机化并没有改变最坏复杂度 —— 理论上仍是 O(n²), 只是让「触发最坏」不再取决于输入长什么样,而取决于随机数。 构造一个能稳定卡住它的输入,从「把数组排个序」变成了「猜中随机种子」。

⚠️ 分治不总是最优解:多数元素的两条路

多数元素(找出现次数 > n/2 的那个) 有一个漂亮的分治解法:左右两半各自的多数元素,必有一个是整体的多数元素。 但它是 O(n log n) 的,而这道题有 O(n) 时间、O(1) 空间的解 —— 摩尔投票:

function majorityElement(nums) {
  let cand = null, count = 0;
  for (const x of nums) {
    if (count === 0) cand = x;               // 票数归零,换一个候选人
    count += (x === cand) ? 1 : -1;          // 同票 +1,异票 -1
  }
  return cand;
}

⭐ 为什么成立:把每个「异票」和一个「同票」成对抵消掉。 众数的数量 > n/2,也就是比其余所有元素加起来还多, 所以无论怎么抵消,最后剩下的一定是它。

🚨 它依赖「众数一定存在」这个前提,而前提被打破时它不会报错。

2 万组随机数组里有 13437 组根本没有众数(长度 1–10、值域 0–3、 mulberry32(2026)),摩尔投票在这 13437 组上 全部返回了数组里确实存在的某个值,一次都没有异常:

[1,2,1,2,1,3,0,3]      → 返回 0   (它只出现了 1 次)
[0,1,0,3,3,0,3,2,0]    → 返回 0   (它只出现了 4 次,n=9 需要 ≥5)

📌 力扣 169 的题面明确保证众数存在,所以上面那段能过。 但换成「可能不存在」的变体(或者你把它抄进真实项目),就必须再扫一遍验证:

const cand = majorityElement(nums);
let c = 0;
for (const x of nums) if (x === cand) c++;
return c > nums.length / 2 ? cand : -1;      // 多一遍 O(n),换来结论可信

⚠️ 这和最近公共祖先那题 是同一类陷阱:解法依赖题面给的前提,而返回值的形状不体现这个依赖 —— 前提不成立时它照样返回一个像模像样的答案。 ⭐ 判据:看到「题目保证……」这类措辞,就要意识到解法可能正在利用它。

至于为什么不用哈希计数(也是 O(n) 时间):20 万个元素、6 万多个不同值时, 哈希表要存 6 万多条,而摩尔投票始终只有两个变量。

主定理:够用的那个版本

递推式长这样时:

T(n) = a · T(n/b) + O(n^d)
       ↑        ↑       ↑
    几个子问题  规模缩小倍数  合并的代价

结论只看 d 和 log_b(a) 谁大:

条件 复杂度 直觉
d > log_b(a) O(n^d) 合并的代价占主导
d = log_b(a) O(n^d · log n) 每层代价相同,共 log n 层
d < log_b(a) O(n^(log_b a)) 叶子数量占主导

归并排序:切成 2 份(a=2)、每份规模减半(b=2)、合并是 O(n)(d=1)。 log₂2 = 1 = d,落在第二行 → O(n log n)。

📌 记不住三行也没关系,记住「比较合并代价和叶子数量,谁大听谁的」就够应付面试。

🚨 分治与动态规划的分界线:子问题重不重叠

这两者的框架长得几乎一样,都是「递归 + 用子问题答案拼」。唯一的区别是:

不同的分支会不会算到同一个子问题?

  • 不会 → 分治。归并排序里,左半段和右半段是完全不同的元素, 它们的子问题没有任何交集,所以不需要备忘录。
  • 会 → 动态规划。斐波那契里 fib(5) 和 fib(4) 都要算 fib(3), 重复计算是指数级的,所以必须记下来。

⭐ 这条判据决定了你要不要加备忘录,而加错的代价是不对称的:

  • 该加没加 → 指数级超时(斐波那契的 O(2ⁿ))
  • 不该加却加了 → 只是浪费一点内存,结果照样对

⚠️ 所以拿不准的时候倾向于加。真正的风险在另一边: 看到「递归 + 返回值」就以为是分治、不去检查重叠性,然后被超时打回来 —— 而超时的报错不会告诉你原因是重叠子问题。

推导路径见动态规划解题框架那篇, 它讲的正是「发现重叠之后该怎么办」。

练习

勾选记录做过哪些,0 / 5 道。进度只存在这台设备的浏览器里;同一道题在别篇勾过,这里也会显示已做。