深入理解递归与快速排序 —— 从基础入门到手写实现

深入理解递归与快速排序 ------ 从基础入门到手写实现 🚀

学习日志 | 算法与数据结构 | 前端基本功


📖 写在前面

算法是程序员的灵魂。在日常开发中,我们或许不会天天手写排序、倒腾递归,但当你遇到深拷贝树形结构遍历组件递归渲染等场景时,递归与分治的思想无处不在。

本文是一篇学习日志 ,记录了笔者系统学习「递归」与「快速排序」的过程。文章从最基础的递归概念出发,逐步深入到快速排序的分治思想,配合完整可运行的代码,力求让你读完后能够:

  • ✅ 彻底搞懂递归的三要素与执行流程
  • ✅ 手写数组扁平化(flatten)的递归实现
  • ✅ 理解冒泡 / 选择 / 插入排序的优缺点
  • ✅ 手写快速排序并讲清楚分区函数的每一行
  • ✅ 厘清「递归」与「分治」这两个易混淆概念的区别

一、递归:从入门到精通

1.1 什么是递归?

递归(Recursion) 是一种编程技巧,指函数在执行过程中调用自身。它能够将复杂问题自顶向下地拆解为规模更小的同类子问题,直到问题简单到可以直接求解。

举个最经典的例子:求 1 + 2 + 3 + ... + n 的和

1.2 迭代 vs 递归 --- 两种思路解决同一问题

迭代解法(循环累加)
javascript 复制代码
// 迭代:自底向上,逐个累加
function sum(n) {
    let total = 0;
    for (let i = 1; i <= n; i++) {
        total += i;
    }
    return total;
}

console.log(sum(100)); // 5050

迭代的思路很直观:从 1 开始,一个一个往上加,直到 n。执行流程是一条直线,时间复杂度 O(n),空间复杂度 O(1)。

递归解法(函数自调用)
javascript 复制代码
// 递归:自顶向下,缩小问题规模
function sum(n) {
    if (n === 1) return 1;          // 退出条件(递归出口)
    return n + sum(n - 1);          // 递归公式
}

console.log(sum(100)); // 5050

递归的思路则完全不同:它假设 sum(n-1) 已经有人帮你算好了,你只需要把 n 加上去就行。

执行过程像一棵树:

scss 复制代码
sum(5)
  → 5 + sum(4)
         → 4 + sum(3)
                → 3 + sum(2)
                       → 2 + sum(1)
                              → 1   ← 到达出口,开始"归"
                       → 2 + 1 = 3
                → 3 + 3 = 6
         → 4 + 6 = 10
  → 5 + 10 = 15

1.3 递归的核心三要素

任何一个递归函数,都必须具备以下三个要素,缺一不可:

要素 说明 本例中的体现
递归公式 如何将大问题分解为子问题 f(n) = n + f(n-1)
退出条件 子问题缩小到何时直接返回(防止死循环) f(1) = 1
相同子问题 每一层子问题的求解方式完全一致 每一层都是 "当前值 + 剩余和"

⚠️ 没有退出条件 → 栈溢出(Stack Overflow),这是新手最容易犯的错误。

1.4 递归的执行模型:递→归 两阶段

理解递归的关键在于理解调用栈(Call Stack):

scss 复制代码
阶段一【递 --- 压栈】
sum(5) 入栈 → sum(4) 入栈 → sum(3) 入栈 → sum(2) 入栈 → sum(1) 入栈
                                                              ↓ 到达出口
阶段二【归 --- 弹栈】
sum(1)=1 出栈 → sum(2)=3 出栈 → sum(3)=6 出栈 → sum(4)=10 出栈 → sum(5)=15 出栈

「递」 是不断将问题规模缩小的过程(函数不断入栈),「归」 是拿到最小问题的答案后逐层返回合并的过程(函数不断弹栈)。


1.5 实战:数组扁平化(Array Flatten)

数组扁平化是递归的经典应用场景,也是前端面试高频手写题。

需求描述
javascript 复制代码
const arr = [1, [2, [3, 4, [5, 6]]]];
// 期望输出: [1, 2, 3, 4, 5, 6]

将一个嵌套多层的数组"拍平"为一维数组。

方案一:ES6 原生方法
javascript 复制代码
const arr = [1, [2, [3, 4, [5, 6]]]];

// flat() 默认只扁平化一层
console.log(arr.flat());       // [1, 2, [3, 4, [5, 6]]]

// 传入 Infinity 表示扁平化所有层
console.log(arr.flat(Infinity)); // [1, 2, 3, 4, 5, 6]

Array.prototype.flat(depth) 是 ES2019 引入的方法:

  • depth 参数指定展开的深度,默认值为 1
  • 传入 Infinity 可将任意深度的嵌套数组完全扁平化
  • 它和 map / reduce 一样,属于函数式编程风格的数组方法
方案二:手写递归实现
javascript 复制代码
const flatten = (arr) => {
    let result = [];
    arr.forEach((item) => {
        if (Array.isArray(item)) {
            // 当前元素是数组 → 递归扁平化并合并结果
            result = result.concat(flatten(item));
        } else {
            // 当前元素不是数组 → 直接加入结果
            result.push(item);
        }
    });
    return result;
};

const arr = [1, [2, [3, 4, [5, 6]]]];
console.log(flatten(arr)); // [1, 2, 3, 4, 5, 6]
代码逐行解析
ini 复制代码
flatten([1, [2, [3, 4, [5, 6]]]])
  │
  ├─ item=1, 不是数组 → result=[1]
  ├─ item=[2, [3, 4, [5,6]]], 是数组 → 递归调用 flatten([2, [3, 4, [5,6]]])
  │    │
  │    ├─ item=2, 不是数组 → result=[2]
  │    └─ item=[3, 4, [5,6]], 是数组 → 递归调用 flatten([3, 4, [5,6]])
  │         │
  │         ├─ item=3, 不是数组 → result=[3]
  │         ├─ item=4, 不是数组 → result=[3, 4]
  │         └─ item=[5, 6], 是数组 → 递归调用 flatten([5, 6])
  │              │
  │              ├─ item=5, 不是数组 → result=[5]
  │              └─ item=6, 不是数组 → result=[5, 6]
  │              → 返回 [5, 6]
  │         → concat → [3, 4, 5, 6]
  │    → concat → [2, 3, 4, 5, 6]
  └─ concat → [1, 2, 3, 4, 5, 6]

💡 性能优化提示 :上面的实现使用了 concat(每次创建一个新数组),如果数组非常深或元素非常多,可以用尾递归优化或改用迭代+栈的方式来避免栈溢出。


二、排序算法全景概览

在深入快速排序之前,我们先简单回顾三种基础排序算法,理解它们的共性 O(n²) 复杂度,才能体会快速排序为什么叫"快"排。

2.1 冒泡排序(Bubble Sort)

核心思想:相邻元素两两比较,较大的向后"冒泡",每轮确定一个最大值。

css 复制代码
[5, 3, 8, 1]  →  [3, 5, 1, 8]  →  [3, 1, 5, 8]  →  [1, 3, 5, 8]
  • 时间复杂度:O(n²)
  • 空间复杂度:O(1)
  • 稳定排序 ✅(相等元素不交换)

2.2 选择排序(Selection Sort)

核心思想:每轮从未排序部分选出最小的元素,放到已排序部分的末尾。

css 复制代码
[5, 3, 8, 1]
→ 找到最小 1,与第一位交换:[1, 3, 8, 5]
→ 找到最小 3(从第二位开始),已在正确位置
→ 找到最小 5,与第三位交换:[1, 3, 5, 8]
  • 时间复杂度:O(n²)
  • 空间复杂度:O(1)
  • 不稳定排序 ❌(跨越式交换可能打乱相等元素的相对顺序)

2.3 插入排序(Insertion Sort)

核心思想:从第二个元素开始,逐个向前比较并插入到正确位置,类似打扑克牌时整理手牌。

css 复制代码
[5, 3, 8, 1]
→ 3 与前面的 5 比较,插入前面:[3, 5, 8, 1]
→ 8 与前面比较,已在正确位置:[3, 5, 8, 1]
→ 1 与前面比较,插入最前面:[1, 3, 5, 8]
  • 时间复杂度:O(n²)(最好情况 O(n),即数组已近有序)
  • 空间复杂度:O(1)
  • 稳定排序 ✅

2.4 三种算法对比

排序算法 最好 平均 最坏 稳定性 核心操作
冒泡排序 O(n) O(n²) O(n²) 稳定 相邻两两交换
选择排序 O(n²) O(n²) O(n²) 不稳定 选最小放最前
插入排序 O(n) O(n²) O(n²) 稳定 向前面找位置
快速排序 O(n log n) O(n log n) O(n²) 不稳定 分区+递归

从 O(n²) 到 O(n log n) 的提升是巨大的------当 n = 1,000,000 时,n² 是 1 万亿,而 n log n 仅约 2000 万,差了 5 个数量级


三、快速排序:分治策略的典范

3.1 核心思想 ------ 分而治之(Divide and Conquer)

快速排序由 Tony Hoare 于 1960 年提出,其核心是分治策略

scss 复制代码
分治三步走:

  ┌───────────────┐     ┌─────────────────┐     ┌────────────────┐
  │  分(Divide)   │ →   │  治(Conquer)    │ →   │  合(Combine)   │
  │ 将大问题拆成  │      │  递归解决子问题 │     │ 合并子问题结果 │
  │  独立小问题   │      │                 │    │                │
  └───────────────┘     └─────────────────┘     └────────────────┘

在快速排序中的具体体现:

markdown 复制代码
1. 分:选取一个基准值(pivot),将数组分成"左小右大"两个子数组
2. 治:对左右两个子数组分别递归进行快速排序
3. 合:由于是原地交换,左子数组 + pivot + 右子数组 天然有序,无需额外合并

快速排序之所以快,靠的是两个关键操作:

  • pivot 基准值 :每次将问题规模对半缩小(log n 层递归)
  • 原地交换(双指针) :每层分区只需 O(n) 的扫描时间

两者相乘 → O(n log n)

3.2 分区函数(Partition)------ 快速排序的灵魂

分区是整个算法最核心也最容易写错的部分。让我们逐行深入分析。

less 复制代码
		 pivot=3
		   ↓
初始:   [3,  2,  1,  5,  6,  4]
	       ↑                   ↑
	     i(left)           j(right)

Step 1: j 从右向左扫描,找比 pivot 小的 → j 停在 1
		[3,  2,  1,  5,  6,  4]
		 ↑       ↑
		 i       j

Step 2: i 从左向右扫描,找比 pivot 大的 → i 就是 3 自己
		注意:nums[i] <= pivot 所以 i 会一直向右走
		[3,  2,  1,  5,  6,  4]
				 ↑
				i,j 相遇!

Step 3: i 和 j 相遇,循环结束
		将 pivot 放到相遇位置:
		[1,  2,  3,  5,  6,  4]
				 ↑
			   pivot 归位!
		左边 [1, 2] < 3,右边 [5, 6, 4] > 3 ✅
完整代码逐行解析
javascript 复制代码
// 快速排序 - 分区函数
function partition(nums, left, right) {
    let i = left;                    // 左指针,从区间最左开始
    let j = right;                   // 右指针,从区间最右开始
    let pivot = nums[left];          // 选取最左元素作为基准值

    while (i < j) {                  // 当左右指针未相遇时继续
        // 右侧找比基准值小的元素
        while (i < j && nums[j] >= pivot) {
            j--;                     // 比基准大或相等 → 继续向左走
        }
        // 左侧找比基准值大的元素
        while (i < j && nums[i] <= pivot) {
            i++;                     // 比基准小或相等 → 继续向右走
        }
        // 交换找到的两个元素(左边大 + 右边小 → 互换)
        if (i < j) {
            [nums[i], nums[j]] = [nums[j], nums[i]];
        }
    }
    // 将基准值放到最终的正确位置
    [nums[left], nums[i]] = [nums[i], nums[left]];
    return i;                        // 返回基准值的最终索引
}

function quickSort(nums, left = 0, right = nums.length - 1) {
    if (left >= right) return;      // 区间长度为 0 或 1 → 已有序,直接返回

    let pivot = partition(nums, left, right);  // 分区,得到基准值位置
    quickSort(nums, left, pivot - 1);          // 递归排序左半部分
    quickSort(nums, pivot + 1, right);         // 递归排序右半部分
}

// 测试
const arr = [3, 2, 1, 5, 6, 4];
quickSort(arr);
console.log(arr); // [1, 2, 3, 4, 5, 6]
分区函数中极易踩的坑 🔥

⚠️ 坑 1 --- 为什么先移动右指针 j,再移动左指针 i?

因为选择的是 nums[left] 作为基准值。如果先移动 i,当数组已经有序时(如 [1, 2, 3]),i 会一路走到 right,导致 pivot 被放错位置。先移动 j 保证 i 和 j 相遇时的位置一定是 ≤ pivot 的(因为 j 停在小的元素上),交换才正确。

⚠️ 坑 2 --- 内层 while 必须加 i < j 条件

没有 i < j 的话,指针会越界。例如数组中所有元素都比基准值大,j 会一直减到 left 左边。

⚠️ 坑 3 --- 条件是 >=<=,不是 ><

用严格不等号会导致遇到与 pivot 相等的元素时死循环(指针不移动)。

3.3 完整排序流程可视化

[3, 2, 1, 5, 6, 4] 为例,完整追踪一次快排的递归树:

scss 复制代码
quickSort([3, 2, 1, 5, 6, 4], 0, 5)
│
├─ partition → pivot 归位到索引 2,数组变为 [1, 2, 3, 5, 6, 4]
│
├─ 左递归 quickSort([1, 2, 3, 5, 6, 4], 0, 1)
│   │
│   └─ partition → pivot 归位到索引 0,数组变为 [1, 2, 3, 5, 6, 4]
│      ├─ 左递归 quickSort(..., 0, -1) → left >= right → return
│      └─ 右递归 quickSort(..., 1, 1)   → left >= right → return
│
└─ 右递归 quickSort([1, 2, 3, 5, 6, 4], 3, 5)
    │
    └─ partition → pivot 归位到索引 3,数组变为 [1, 2, 3, 4, 5, 6]
       ├─ 左递归 quickSort(..., 3, 2) → left >= right → return
       └─ 右递归 quickSort(..., 4, 5)
           │
           └─ partition → pivot 归位到索引 4,数组变为 [1, 2, 3, 4, 5, 6]
              ├─ 左递归 quickSort(..., 4, 3) → return
              └─ 右递归 quickSort(..., 5, 5) → return

最终结果: [1, 2, 3, 4, 5, 6] ✅

3.4 递归 vs 分治 ------ 这对"孪生兄弟"到底什么关系?

这是一个非常经典的问题,学习算法时几乎人人都困惑过:

维度 递归(Recursion) 分治(Divide & Conquer)
本质 一种编程实现方式(函数调用自身) 一种算法设计思想(分而治之的策略)
核心步骤 递(缩小问题)+ 归(合并结果) 分(拆成子问题)+ 治(递归解决)+ 合(合并结果)
关系 是分治的实现手段 绝大多数情况下用递归来实现
类比 "锤子" --- 工具 "造房子" --- 方法论

💡 一句话总结递归是代码实现方式,分治是算法思想;分治绝大多数用递归实现,但不是所有递归都是分治。

例如:

  • 递归求 sum(n) → 有递归,但不算严格的分治(没有显式的"合"操作,结果直接累加)
  • 快速排序 → 既有递归 (函数调用自身),又体现了完整的分治思想(分+治+合三步齐全)

3.5 快速排序的性能分析

为什么快?------ O(n log n) 的奥秘
scss 复制代码
快排的性能 = 分区层数 × 每层工作量

理想情况(每次 pivot 都恰好是中位数):
  ┌─────────────────────────────────────┐
  │  分区层数:log₂n 层(每层问题减半)  │
  │  每层工作量:n(扫描整个区间一次)   │
  │  总复杂度:O(n log n)               │
  └─────────────────────────────────────┘

最坏情况(每次 pivot 都是最小/最大值,如已有序数组):
  ┌─────────────────────────────────────┐
  │  分区层数:n 层(每次只减少一个元素)│
  │  每层工作量:n                      │
  │  总复杂度:O(n²) ← 退化!           │
  └─────────────────────────────────────┘

💡 规避最坏情况:实际工程中常用「随机选取 pivot」或「三数取中法」(选 left / mid / right 的中位数)来避免退化为 O(n²)。

为什么不稳定?

快速排序是不稳定排序 ,原因是分区过程中的跨越式交换

ini 复制代码
原始:  [5₁, 3, 5₂, 1]    (5₁ 和 5₂ 的相对位置是 5₁ 在 5₂ 前面)
       ↑
     pivot=5₁

分区后: [1, 3, 5₂, 5₁]    (5₁ 被换到了后面,相对位置颠倒了!)

相等元素(两个 5)的相对位置在排序后发生了变化,这完美体现了快速排序的不稳定性


四、知识体系总结

4.1 思维导图

scss 复制代码
                        ┌─ 冒泡排序(相邻比较,O(n²))
                        │
            ┌─ 基础排序 ─┼─ 选择排序(选最小放最前,O(n²))
            │           │
            │           └─ 插入排序(向前找位置,O(n²))
            │
排序算法 ───┤           ┌─ 核心:分治策略(分+治+合)
            │           │
            │           ├─ 关键:pivot基准 + 双指针分区
            └─ 快速排序 ─┤
                        ├─ 复杂度:平均 O(n log n),最坏 O(n²)
                        │
                        └─ 特点:原地排序、不稳定排序

            ┌─ 核心三要素:递归公式 + 退出条件 + 相同子问题
            │
递归 ──────┼─ 执行模型:"递"(压栈)→ "归"(弹栈)
            │
            └─ 经典应用:数组扁平化(flatten)、树遍历、深拷贝

4.2 核心要点回顾

主题 核心要点
递归三要素 递归公式 + 退出条件 + 相同子问题
递归执行 递(压栈缩小问题)→ 归(弹栈合并结果)
数组扁平化 遍历 → 判断是否为数组 → 是则递归 / 否则 push
快排核心 选 pivot → 双指针分区(左小右大)→ 递归左右
快排复杂度 平均 O(n log n),最坏 O(n²)(可通过随机 pivot 规避)
快排稳定性 不稳定(相等元素相对位置可能颠倒)
递归 vs 分治 递归是代码实现方式(工具),分治是算法思想(方法论)

4.3 延伸思考

  1. 快速排序 vs 归并排序:都是分治思想的代表,归并排序是稳定排序但需要 O(n) 额外空间,快速排序不稳定但是原地排序。什么场景选哪个?

  2. 尾递归优化 :本文的 flattenquickSort 都不是尾递归。如何改写为尾递归形式?尾递归在 JavaScript 引擎中的支持情况如何?

  3. 数组扁平化的其他实现 :除了递归,还可以用 reduce + 递归、Generator、栈迭代等方式实现 flatten,各自的优缺点是什么?

  4. 实际应用场景

    • 递归:Vue/React 的虚拟 DOM diff、路由嵌套渲染、文件目录遍历
    • 快速排序:数据库索引构建、V8 引擎中 Array.prototype.sort 的实现(小数组用插入排序,大数组用快排)

🎯 写在最后

从最基础的递归求和,到数组扁平化的手写实现,再到快速排序中精巧的分区函数设计------这条学习路径让我们看到:

好的算法设计,往往只需要改动一个小细节,就能将时间复杂度从 O(n²) 优化到 O(n log n)。

理解算法不是为了默写,而是为了在遇到实际问题时,能够从这些经典的「思维模板」中汲取灵感。当下次你面对一个带层级的数据结构、一个需要分而治之的业务场景时,希望递归与分治的思想能自然地浮现在你的脑海中。


📝 本文为个人学习日志,如有疏漏欢迎讨论交流。代码均可直接复制运行验证。

🕐 2026年6月 整理记录

相关推荐
爱勇宝1 小时前
淡泊名利之前,先承认我们都很焦虑
前端·后端·程序员
bonechips1 小时前
LLM 的无状态:从 HTTP 协议到对话上下文工程
前端·javascript
杨利杰YJlio1 小时前
Codex桌面客户端上手:项目、插件与自动化实战
前端·后端
胡志辉1 小时前
从 prototype 到 V8,看懂 JavaScript 原型链
前端·javascript
ricardo19731 小时前
React 渲染优化:memo / useMemo / useCallback 的正确姿势与并发模式实战
前端·面试
ClouGence1 小时前
零代码自动化测试:手把手教你录出一条能反复用的测试用例
前端·测试
skiyee1 小时前
🔥UniApp 仅需 5 行代码!实现所有页面中控制应用主题变化
前端·微信小程序
LaiYoung_1 小时前
🎁 送你一套超好用超实用的 FE AI-Coding Skills
前端·人工智能·开源
幼儿园技术家2 小时前
实现 GEO 监控:从多引擎探测到优化闭环
前端·后端