前端 JS/TS 算法:分治 (Divide & Conquer) 指南
本文档旨在为前端开发者提供一份关于分治(Divide & Conquer)算法思想的完整、实用的指南,从核心原理、经典实现到在前端框架中的应用,全面覆盖该模式在 JS/TS 中的实践。
一、 引言 / 场景动机
分治是算法设计中的一种基本策略,也是许多高效算法(如归并排序、快速排序)的基石。它将一个难以直接解决的大问题,分解成一些规模较小的相同问题,以便各个击破,分而治之。
前端核心应用场景:
- React Fiber 架构:React 的核心协调算法(Reconciliation)就是分治思想的体现。它将更新整个组件树的"大任务"分解为对每个 Fiber 节点(组件实例)的"小任务",这些小任务可以被中断、排定优先级和恢复,最终实现异步可中断的渲染。
- 数组排序
Array.prototype.sort()
:现代浏览器 V8 引擎中的sort
实现(Timsort)是一种混合排序算法,其核心之一就是归并排序,而归并排序是分治的经典应用。 - 并行处理:在 Web Workers 中,可以将一个大数据集(如图像处理、大规模计算)分割成多个部分,分发给不同的 Worker 并行处理,最后将结果合并,这也是一种分治思想的实践。
- 数据可视化:在处理海量数据点进行可视化时,可以使用四叉树或八叉树等数据结构,通过分治思想将空间划分为象限,快速检索和聚合区域内的数据。
二、 核心思想
分治算法的范式包含三个核心步骤:
- 分解 (Divide):将原问题分解为一系列与原问题结构相似、但规模更小的子问题。
- 解决 (Conquer):递归地解决这些子问题。当子问题足够小时(即达到"基本情况"或"终止条件"),直接求解。
- 合并 (Combine):将子问题的解合并,构成原问题的解。
关键特征:分治算法分解出的子问题通常是相互独立的,即它们不包含公共的子问题。这也是它与动态规划的主要区别之一。
三、 算法原理:以归并排序为例
归并排序(Merge Sort)是完美诠释分治思想的经典算法。
以 [8, 3, 1, 7, 0, 10, 2]
为例:
第 1 步:分解 (Divide)
此过程通过递归不断将数组对半分割,直到每个子数组只剩下一个元素。
[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]
- 比较
3
和1
,取1
。新数组[1]
。 - 比较
3
和7
,取3
。新数组[1, 3]
。 - 比较
8
和7
,取7
。新数组[1, 3, 7]
。 - 数组剩余的
8
追加。最终得到[1, 3, 7, 8]
。
- 比较
merge([0, 10], [2])
->[0, 2, 10]
- 比较
0
和2
,取0
。新数组[0]
。 - 比较
10
和2
,取2
。新数组[0, 2]
。 - 数组剩余的
10
追加。最终得到[0, 2, 10]
。
- 比较
最终合并:
merge([1, 3, 7, 8], [0, 2, 10])
->[0, 1, 2, 3, 7, 8, 10]
- 比较
1
vs0
-> 取0
。新数组:[0]
- 比较
1
vs2
-> 取1
。新数组:[0, 1]
- 比较
3
vs2
-> 取2
。新数组:[0, 1, 2]
- 比较
3
vs10
-> 取3
。新数组:[0, 1, 2, 3]
- 比较
7
vs10
-> 取7
。新数组:[0, 1, 2, 3, 7]
- 比较
8
vs10
-> 取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)
的时间复杂度,非常适合用来教学分治。
/**
* 归并排序的合并步骤
* @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)
快速排序通常比归并排序更快(因为其常数因子更小),但它是不稳定的,且有最坏情况。
/**
* 快速排序
* @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 泛型封装
上面的 mergeSort
和 quickSort
实现已经是泛型的了。一个好的实践是将其封装成一个可配置的工具函数。
// 使用示例
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>
)的数量。
/**
* 计算指定标签的元素数量
* @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
属性的总和。
/**
* 嵌套对象类型
*/
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)
这是一个更复杂的经典分治问题,它展示了"合并"步骤可以非常巧妙。问题是:给定一个包含正数和负数的数组,找到一个连续子数组,使其和最大。
/**
* 计算跨越中点的最大子数组和
* @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 题目
- 22. Generate Parentheses (经典分治/回溯)
- 53. Maximum Subarray (可用分治求解)
- 95. Unique Binary Search Trees II
- 241. Different Ways to Add Parentheses
2. 可视化网站
3. 经典文章
- React Fiber 架构深入解析 (Fiber 架构原始设计文档)
十四、 FAQ
分治和动态规划有什么区别?
- 分治 (D&C):子问题是相互独立的。例如,归并排序中,排序左半部分和排序右半部分互不影响。
- 动态规划 (DP):子问题是相互重叠、相互依赖的。DP 通常会用一个表来存储子问题的解,避免重复计算。斐波那契数列是典型的 DP 问题。
递归太深导致栈溢出怎么办?
- 尾递归优化: 如果递归是尾调用,一些语言环境支持优化,不会增加调用栈。但 JS 引擎目前对此支持有限。
- 改写为迭代: 任何递归都可以用栈或队列改写为迭代形式,但代码通常会更复杂。对于前端常见的场景,除非处理极端数据,否则一般无需担心。
分治总比暴力解法好吗?
- 不一定。分治算法的递归和合并步骤有固定开销。如果问题规模非常小,直接的暴力解法可能因为开销更小而更快。这也是 Timsort 这类混合算法存在的原因。