Skip to content

前端 JS/TS 算法:分治 (Divide & Conquer) 指南

本文档旨在为前端开发者提供一份关于分治(Divide & Conquer)算法思想的完整、实用的指南,从核心原理、经典实现到在前端框架中的应用,全面覆盖该模式在 JS/TS 中的实践。

一、 引言 / 场景动机

分治是算法设计中的一种基本策略,也是许多高效算法(如归并排序、快速排序)的基石。它将一个难以直接解决的大问题,分解成一些规模较小的相同问题,以便各个击破,分而治之。

前端核心应用场景:

  • React Fiber 架构:React 的核心协调算法(Reconciliation)就是分治思想的体现。它将更新整个组件树的"大任务"分解为对每个 Fiber 节点(组件实例)的"小任务",这些小任务可以被中断、排定优先级和恢复,最终实现异步可中断的渲染。
  • 数组排序 Array.prototype.sort():现代浏览器 V8 引擎中的 sort 实现(Timsort)是一种混合排序算法,其核心之一就是归并排序,而归并排序是分治的经典应用。
  • 并行处理:在 Web Workers 中,可以将一个大数据集(如图像处理、大规模计算)分割成多个部分,分发给不同的 Worker 并行处理,最后将结果合并,这也是一种分治思想的实践。
  • 数据可视化:在处理海量数据点进行可视化时,可以使用四叉树或八叉树等数据结构,通过分治思想将空间划分为象限,快速检索和聚合区域内的数据。

二、 核心思想

分治算法的范式包含三个核心步骤:

  1. 分解 (Divide):将原问题分解为一系列与原问题结构相似、但规模更小的子问题。
  2. 解决 (Conquer):递归地解决这些子问题。当子问题足够小时(即达到"基本情况"或"终止条件"),直接求解。
  3. 合并 (Combine):将子问题的解合并,构成原问题的解。

关键特征:分治算法分解出的子问题通常是相互独立的,即它们不包含公共的子问题。这也是它与动态规划的主要区别之一。

三、 算法原理:以归并排序为例

归并排序(Merge Sort)是完美诠释分治思想的经典算法。

[8, 3, 1, 7, 0, 10, 2] 为例:

第 1 步:分解 (Divide)

此过程通过递归不断将数组对半分割,直到每个子数组只剩下一个元素。

text
[8, 3, 1, 7, 0, 10, 2]
       /        \
[8, 3, 1, 7]  [0, 10, 2]
   /    \        /   \
[8, 3] [1, 7]  [0, 10] [2]
 / \    / \     /  \    |
[8] [3] [1] [7] [0] [10] [2]  <-- 达到基本情况 (Base Case)

第 2 步:解决 (Conquer)

"解决"步骤实际上发生在递归的最深处。当子问题规模小到只有 1 个元素时(如 [8][3]),它已经被认为是天然有序的,可以直接返回。这是递归的终止条件,也是"解决"这个最小问题的过程。

第 3 步:合并 (Combine)

这是分治算法最关键的一步。合并操作从最小的、已排序的子数组开始,逐层向上,将两个有序子数组合并成一个新的有序大数组

第 1 层合并:

  • merge([8], [3]) -> [3, 8]
  • merge([1], [7]) -> [1, 7]
  • merge([0], [10]) -> [0, 10]
  • [2] 保持不变,因为它没有配对。

第 2 层合并 (重点):

  • merge([3, 8], [1, 7]) -> [1, 3, 7, 8]
    • 比较 31,取 1。新数组 [1]
    • 比较 37,取 3。新数组 [1, 3]
    • 比较 87,取 7。新数组 [1, 3, 7]
    • 数组剩余的 8 追加。最终得到 [1, 3, 7, 8]
  • merge([0, 10], [2]) -> [0, 2, 10]
    • 比较 02,取 0。新数组 [0]
    • 比较 102,取 2。新数组 [0, 2]
    • 数组剩余的 10 追加。最终得到 [0, 2, 10]

最终合并:

  • merge([1, 3, 7, 8], [0, 2, 10]) -> [0, 1, 2, 3, 7, 8, 10]
    • 比较 1 vs 0 -> 取 0。新数组: [0]
    • 比较 1 vs 2 -> 取 1。新数组: [0, 1]
    • 比较 3 vs 2 -> 取 2。新数组: [0, 1, 2]
    • 比较 3 vs 10 -> 取 3。新数组: [0, 1, 2, 3]
    • 比较 7 vs 10 -> 取 7。新数组: [0, 1, 2, 3, 7]
    • 比较 8 vs 10 -> 取 8。新数组: [0, 1, 2, 3, 7, 8]
    • 第一个数组已处理完,将第二个数组剩余的 [10] 直接追加。
    • 得到最终有序结果: [0, 1, 2, 3, 7, 8, 10]

通过这个自底向上、层层合并的过程,原先完全无序的数组最终变得完全有序。

四、 复杂度分析

  • 时间复杂度: O(n log n) (以归并排序为例)

    • log n: 分解过程需要 log n 层递归。
    • n: 在每一层,合并所有子问题的总时间复杂度为 O(n)
    • 相乘得到 O(n log n)
  • 空间复杂度: O(n)O(log n)

    • 归并排序: 需要一个额外的 O(n) 空间来存储合并过程中的临时数组。
    • 快速排序: 空间复杂度取决于递归深度,平均为 O(log n),最坏为 O(n)

五、 基准实现

1. 归并排序 (Merge Sort)

归并排序是稳定的,且严格保证 O(n log n) 的时间复杂度,非常适合用来教学分治。

typescript
/**
 * 归并排序的合并步骤
 * @param left 左数组
 * @param right 右数组
 * @param comparator 比较器
 * @returns 合并后的数组
 */
const merge = <T>(left: T[], right: T[], comparator: (a: T, b: T) => number): T[] => {
  const result: T[] = [];
  let i = 0;
  let j = 0;

  while (i < left.length && j < right.length) {
    if (comparator(left[i], right[j]) <= 0) {
      result.push(left[i++]);
    } else {
      result.push(right[j++]);
    }
  }

  return result.concat(left.slice(i)).concat(right.slice(j));
}

/**
 * 归并排序主函数
 * @param arr 数组
 * @param comparator 比较器
 * @returns 排序后的数组
 */
const mergeSort = <T>(arr: T[], comparator: (a: T, b: T) => number): T[] => {
  // 基本情况
  if (arr.length <= 1) {
    return arr;
  }

  // 1. 分解
  const mid = Math.floor(arr.length / 2);
  const left = arr.slice(0, mid);
  const right = arr.slice(mid);

  // 2. 解决 & 3. 合并
  return merge(mergeSort(left, comparator), mergeSort(right, comparator), comparator);
}

2. 快速排序 (Quick Sort)

快速排序通常比归并排序更快(因为其常数因子更小),但它是不稳定的,且有最坏情况。

typescript
/**
 * 快速排序
 * @param arr 数组
 * @param comparator 比较器
 * @returns 排序后的数组
 */
const quickSort = <T>(arr: T[], comparator: (a: T, b: T) => number): T[] => {
  if (arr.length <= 1) {
    return arr;
  }

  // 1. 分解 (围绕 pivot 分区)
  const pivot = arr[arr.length - 1];
  const left: T[] = [];
  const right: T[] = [];

  for (let i = 0; i < arr.length - 1; i++) {
    if (comparator(arr[i], pivot) < 0) {
      left.push(arr[i]);
    } else {
      right.push(arr[i]);
    }
  }

  // 2. 解决 & 3. 合并
  return [...quickSort(left, comparator), pivot, ...quickSort(right, comparator)];
}

六、 更多应用场景

  • 二分查找: 严格来说,它是一种减治算法(每次排除一半),是分治思想的特例。
  • 最大子数组问题: 寻找数组中和最大的连续子数组。
  • 最近点对问题: 在平面上 n 个点中寻找距离最近的两个点。
  • 矩阵乘法 (Strassen 算法): 优于传统 O(n³) 的矩阵乘法算法。
  • 树的遍历: 对树的遍历(前序、中序、后序)可以看作是对树进行分治:处理根节点(解决),然后递归处理左子树和右子树(分解)。

七、 TypeScript 泛型封装

上面的 mergeSortquickSort 实现已经是泛型的了。一个好的实践是将其封装成一个可配置的工具函数。

typescript
// 使用示例
interface Product {
  id: number;
  price: number;
  name: string;
}

const products: Product[] = [
  { id: 1, name: 'Laptop', price: 1200 },
  { id: 2, name: 'Mouse', price: 25 },
  { id: 3, name: 'Keyboard', price: 75 },
  { id: 4, name: 'Monitor', price: 300 },
];

// 按价格排序
const priceComparator = (a: Product, b: Product) => a.price - b.price;
const sortedByPrice = mergeSort([...products], priceComparator);
console.log(sortedByPrice);

// 按名称排序
const nameComparator = (a: Product, b: Product) => a.name.localeCompare(b.name);
const sortedByName = mergeSort([...products], nameComparator);
console.log(sortedByName);

关键点:始终传入数组的副本 ([...products]) 进行排序,避免修改原始数据,这在函数式编程和现代前端框架中是最佳实践。

八、 关键要点 & 递归陷阱

  • 递归基 (Base Case): 必须定义一个明确的终止条件(如 arr.length <= 1),否则会导致无限递归和栈溢出。这是分治算法正确性的首要保证。
  • 栈溢出风险 (Stack Overflow): 对于层级非常深的数据结构或巨大的数组,纯粹的递归实现可能耗尽调用栈空间。虽然在前端不常见,但在 Node.js 中处理海量数据时需要注意。此时可考虑将递归改写为迭代实现。
  • 数据不变性 (Immutability): 如上所示,在分解步骤中(如使用 slice)或合并步骤中创建新的数据结构,而不是原地修改输入,能避免副作用,使函数更纯粹、更易于测试和推理。

九、 性能考量

  • 算法开销: 分治算法的递归和合并步骤会带来一定的性能开销。对于非常小规模的数据(例如,少于 10-20 个元素),简单粗暴的插入排序可能比归并/快速排序更快。
  • 混合算法: 这也是为什么现代 Array.prototype.sort 实现(如 Timsort)采用混合策略:当待排序的数组分区小到一定程度时,它会切换到插入排序来处理,以获得最佳综合性能。

十、 与浏览器/框架 API 对照

1. Array.prototype.sort()

这是分治思想在前端最常见、最直接的体现。如前所述,V8 和其他现代 JS 引擎中的 sort 方法,都基于分治策略的排序算法(如 Timsort = 归并排序 + 插入排序)来实现其高效和稳定性。

2. React Fiber 协调

React 的核心渲染/更新机制是分治思想在工程领域的绝佳应用。

  • 分解 (Divide): React 不再一次性递归遍历整个组件树。而是将更新任务分解成一个个独立的"工作单元"(Fiber)。
  • 解决 (Conquer): requestIdleCallback (或其 polyfill) 调度器在浏览器空闲时,逐个处理这些 Fiber 单元,计算出 DOM 差异。这个过程是可中断的。
  • 合并 (Combine): 所有工作单元处理完毕后(或者达到截止时间),React 会一次性地将所有计算出的变更提交(commit)到 DOM,完成最终的渲染。

十一、 Demo 示例

Demo 1: DOM 元素计数

使用分治思想递归地计算一个 DOM 节点下特定标签(如 <div>)的数量。

typescript
/**
 * 计算指定标签的元素数量
 * @param element 目标元素
 * @param tagName 标签名
 * @returns 指定标签的元素数量
 */
const countTag = (element: HTMLElement, tagName: string): number => {
  let count = 0;
  // 1. 解决 (当前节点)
  if (element.tagName.toLowerCase() === tagName.toLowerCase()) {
    count = 1;
  }

  // 2. 分解 & 3. 合并
  for (const child of Array.from(element.children)) {
    count += countTag(child as HTMLElement, tagName);
  }

  return count;
}

// --- 使用 ---
// const container = document.getElementById('my-container');
// if (container) {
//   console.log(`Found ${countTag(container, 'div')} div elements.`);
// }

Demo 2: 深度嵌套对象求和

计算一个可能深度嵌套的对象中所有 value 属性的总和。

typescript

/**
 * 嵌套对象类型
 */
interface NestedObject {
  value?: number;
  children?: NestedObject[];
}

/** 
 * 计算嵌套对象的值总和
 * @param obj 嵌套对象
 * @returns 值总和
 */
const sumValues = (obj: NestedObject): number => {
  // 1. 解决 (当前对象)
  let total = obj.value || 0;

  // 2. 分解 (子对象) & 3. 合并
  if (obj.children) {
    total += obj.children
      .map(child => sumValues(child)) // 递归解决
      .reduce((sum, current) => sum + current, 0); // 合并结果
  }

  return total;
}

// --- 使用 ---
const data: NestedObject = {
  children: [
    { value: 10 },
    { children: [{ value: 20 }, { value: 30 }] },
    { value: 40 },
  ],
};
console.log(sumValues(data)); // 输出: 100

Demo 3: 最大子数组问题 (Maximum Subarray Problem)

这是一个更复杂的经典分治问题,它展示了"合并"步骤可以非常巧妙。问题是:给定一个包含正数和负数的数组,找到一个连续子数组,使其和最大。

typescript
/**
 * 计算跨越中点的最大子数组和
 * @param arr 数组
 * @param low 左边界
 * @param mid 中点
 * @param high 右边界
 */
const findMaxCrossingSum = (arr: number[], low: number, mid: number, high: number): number => {
  let leftSum = -Infinity;
  let sum = 0;
  for (let i = mid; i >= low; i--) {
    sum += arr[i];
    if (sum > leftSum) {
      leftSum = sum;
    }
  }

  let rightSum = -Infinity;
  sum = 0;
  for (let i = mid + 1; i <= high; i++) {
    sum += arr[i];
    if (sum > rightSum) {
      rightSum = sum;
    }
  }

  return leftSum + rightSum;
}

/**
 * 寻找最大子数组和
 * @param arr 数组
 * @param low 左边界
 * @param high 右边界
 * @returns 最大子数组的和
 */
const findMaximumSubarray = (arr: number[], low: number, high: number): number => {
  // 基本情况:只有一个元素
  if (low === high) {
    return arr[low];
  }

  // 1. 分解
  const mid = low + ((high - low) >> 1);

  // 2. 解决:递归求解左、右半部分
  const leftMaxSum = findMaximumSubarray(arr, low, mid);
  const rightMaxSum = findMaximumSubarray(arr, mid + 1, high);
  
  // 3. 合并:求解跨越中点的最大子数组和
  const crossingMaxSum = findMaxCrossingSum(arr, low, mid, high);

  // 返回三者中的最大值
  return Math.max(leftMaxSum, rightMaxSum, crossingMaxSum);
}

// --- 使用 ---
// 假设这是某资产在一段时间内的价格变化
const priceChanges = [13, -3, -25, 20, -3, -16, -23, 18, 20, -7, 12, -5, -22, 15, -4, 7];
const maxProfitSum = findMaximumSubarray(priceChanges, 0, priceChanges.length - 1);
console.log(maxProfitSum); // 输出: 43 (对应子数组 [18, 20, -7, 12])

解释: 此算法完美体现了分治思想。最大和的子数组要么完全在左半部分,要么完全在右半部分,要么跨越中点。前两者可以通过递归轻松求解。关键在于"合并"步骤:我们必须单独计算"跨越中点的最大和",这需要从中点分别向左和向右扫描找到各自的最大和,然后相加。最终结果是这三种情况中的最大值。虽然此问题有更优的 O(n) 动态规划解法(Kadane 算法),但它是一个展示复杂"合并"逻辑的绝佳分治范例。

十二、 真实项目示例

  • redux-undo: 这个库通过在 state 中维护一个 past, present, future 的结构来实现撤销/重做。虽然不是直接的 D&C 算法,但其管理状态历史的模式,与分治中处理子问题的思想有相似之处。
  • 富文本编辑器 (如 Quill, Slate): 编辑器的数据模型(Delta 或 Slate 的 Value)通常是树状或线性结构。当进行复杂操作(如粘贴富文本、应用格式)时,编辑器内核会通过分治策略,将操作分解到对数据模型不同部分的修改,最后合并成一个新状态。

十三、 扩展阅读 & 练习

1. LeetCode 题目

2. 可视化网站

3. 经典文章

十四、 FAQ

  • 分治和动态规划有什么区别?

    • 分治 (D&C):子问题是相互独立的。例如,归并排序中,排序左半部分和排序右半部分互不影响。
    • 动态规划 (DP):子问题是相互重叠、相互依赖的。DP 通常会用一个表来存储子问题的解,避免重复计算。斐波那契数列是典型的 DP 问题。
  • 递归太深导致栈溢出怎么办?

    • 尾递归优化: 如果递归是尾调用,一些语言环境支持优化,不会增加调用栈。但 JS 引擎目前对此支持有限。
    • 改写为迭代: 任何递归都可以用栈或队列改写为迭代形式,但代码通常会更复杂。对于前端常见的场景,除非处理极端数据,否则一般无需担心。
  • 分治总比暴力解法好吗?

    • 不一定。分治算法的递归和合并步骤有固定开销。如果问题规模非常小,直接的暴力解法可能因为开销更小而更快。这也是 Timsort 这类混合算法存在的原因。