快速排序与递归思维:从分治策略到数组扁平化——面试必考算法全解析

快速排序与递归思维:从分治策略到数组扁平化------面试必考算法全解析

本文深入解析快速排序的分治策略与双指针原地交换原理,对比递归与迭代的本质差异,并通过数组扁平化实战巩固递归思维。一文掌握前端面试中最常考的排序算法与递归技巧。


前言

排序算法是计算机科学的基石,而快速排序(Quick Sort)是其中最经典、最高效的算法之一。从面试考场到生产环境,从数组排序到 DOM 树遍历,快速排序的分治思想无处不在。

本文将基于实际代码,系统讲解快速排序的核心原理、递归与迭代的对比,以及递归思维在前端开发中的实战应用。


一、排序算法概览:从 O(n²) 到 O(n log n)

1.1 常见排序算法对比

算法 时间复杂度 空间复杂度 稳定性 核心思想
冒泡排序 O(n²) O(1) ✅ 稳定 两两相邻比较,交换位置
选择排序 O(n²) O(1) ❌ 不稳定 选择最小元素放到当前位置
插入排序 O(n²) O(1) ✅ 稳定 与前面元素比较,找到合适位置插入
快速排序 O(n log n) O(log n) ❌ 不稳定 分治策略,基准值分区
归并排序 O(n log n) O(n) ✅ 稳定 分治策略,合并有序数组

💡 为什么快排是面试高频考点? 因为它综合考察了分治思想、双指针技巧、递归实现,且在实际应用中性能优异。

1.2 排序算法的稳定性

稳定性定义:相等元素的相对位置在排序后是否保持不变。

css 复制代码
原始数组:[3(a), 1, 3(b), 2]
              ↑        ↑
            两个相等的 3,用下标区分

稳定排序后:[1, 2, 3(a), 3(b)]  ← 3(a) 仍在 3(b) 前面 ✅

不稳定排序后:[1, 2, 3(b), 3(a)]  ← 3(a) 和 3(b) 位置颠倒了 ❌

快排为什么不稳定?

快排的分区过程中,相等元素可能跨越基准值交换位置,导致相对顺序改变。


二、快速排序:分治策略的经典应用

2.1 核心思想

快速排序基于 分治策略(Divide and Conquer)

scss 复制代码
分(Divide):选择基准值 pivot,将数组分为左右两部分
  ┌─────────────────────────────────────┐
  │  [小于pivot]  pivot  [大于pivot]    │
  └─────────────────────────────────────┘

治(Conquer):递归地对左右两部分分别排序
  quickSort(左半部分)
  quickSort(右半部分)

合(Combine):原地排序,无需额外合并步骤

2.2 分治 vs 递归

概念 定义 关系
递归 函数自身调用自身 代码实现方式
分治 把大问题分解为互相独立的小问题 算法思想

💡 关键洞察:分治是思想,递归是实现方式。绝大多数分治算法用递归实现,但也可以用迭代(栈模拟)实现。

2.3 快排的核心:partition 分区函数

javascript 复制代码
/**
 * 分区函数:将数组分为 [小于pivot] 和 [大于pivot] 两部分
 * @param {number[]} nums - 待排序数组
 * @param {number} left - 左边界索引
 * @param {number} right - 右边界索引
 * @returns {number} - 基准值的最终位置
 */
function partition(nums, left, right) {
  let i = left, j = right;  // 左右双指针

  while (i < j) {
    // 右指针向左移动:找第一个小于基准值的元素
    while (i < j && nums[j] >= nums[left]) {
      j--;
    }

    // 左指针向右移动:找第一个大于基准值的元素
    while (i < j && nums[i] <= nums[left]) {
      i++;
    }

    // 交换左右指针指向的元素
    [nums[i], nums[j]] = [nums[j], nums[i]];
  }

  // 将基准值交换到分界位置
  [nums[i], nums[left]] = [nums[left], nums[i]];

  return i;  // 返回基准值的最终位置
}

2.4 partition 执行流程图解

css 复制代码
初始数组:[2, 4, 1, 0, 3, 5]
          ↑
        left=0 (基准值=2)

第1步:右指针 j 向左找小于 2 的元素
  [2, 4, 1, 0, 3, 5]
              ↑
              j=3 (nums[3]=0 < 2) ✓

第2步:左指针 i 向右找大于 2 的元素
  [2, 4, 1, 0, 3, 5]
     ↑
     i=1 (nums[1]=4 > 2) ✓

第3步:交换 i 和 j 指向的元素
  [2, 0, 1, 4, 3, 5]
     ↑     ↑
     i     j

第4步:继续移动指针
  i=2 (nums[2]=1 < 2) → i++ → i=3
  j=3 (nums[3]=4 > 2) → j-- → j=2

  i=3, j=2 → i >= j,循环结束

第5步:将基准值交换到分界位置
  [1, 0, 2, 4, 3, 5]
        ↑
      pivot=2 的最终位置

结果:左半部分 [1, 0] 都 < 2,右半部分 [4, 3, 5] 都 > 2

2.5 完整快速排序实现

javascript 复制代码
function partition(nums, left, right) {
  let i = left, j = right;

  while (i < j) {
    // 右指针:找小于基准值的元素
    while (i < j && nums[j] >= nums[left]) {
      j--;
    }
    // 左指针:找大于基准值的元素
    while (i < j && nums[i] <= nums[left]) {
      i++;
    }
    // 交换
    [nums[i], nums[j]] = [nums[j], nums[i]];
  }

  // 基准值归位
  [nums[i], nums[left]] = [nums[left], nums[i]];
  return i;
}

function quickSort(nums, left, right) {
  // 退出条件:只有一个元素或无效区间
  if (left >= right) {
    return;
  }

  // 分区,获取基准值位置
  const pivot = partition(nums, left, right);

  // 递归排序左半部分
  quickSort(nums, left, pivot - 1);

  // 递归排序右半部分
  quickSort(nums, pivot + 1, right);
}

// 测试
const arr = [2, 4, 1, 0, 3, 5];
quickSort(arr, 0, arr.length - 1);
console.log(arr);  // [0, 1, 2, 3, 4, 5]

2.6 快排为什么快?

优化点 说明 效果
基准值选择 每次分区确定一个元素的最终位置 O(log n) 层递归
原地交换 不创建新数组,直接交换元素 O(1) 额外空间
双指针 左右同时扫描,减少遍历次数 一次遍历完成分区

时间复杂度分析

scss 复制代码
最好情况:每次基准值都是中位数
  层数:log n
  每层工作量:O(n)
  总复杂度:O(n log n)

最坏情况:数组已有序,每次基准值都是最小/最大值
  层数:n
  每层工作量:O(n)
  总复杂度:O(n²)

平均情况:O(n log n)

三、递归思维:从求和到数组扁平化

3.1 递归的本质

递归是函数自身调用自身的编程技巧,包含两个核心要素:

scss 复制代码
递归公式:f(n) = f(n-1) + n  (大问题分解为小问题)
退出条件:f(1) = 1            (防止无限递归)

递归的执行过程

ini 复制代码
sum(5)
  = sum(4) + 5
      = sum(3) + 4
          = sum(2) + 3
              = sum(1) + 2
                  = 1  ← 退出条件
              = 1 + 2 = 3
          = 3 + 3 = 6
      = 6 + 4 = 10
  = 10 + 5 = 15

3.2 迭代 vs 递归:求 1+2+...+n

迭代实现
javascript 复制代码
function sumIterative(n) {
  let total = 0;
  for (let i = 1; i <= n; i++) {
    total += i;
  }
  return total;
}

// 执行:循环累加,直观易懂
// sumIterative(5) → 0+1+2+3+4+5 = 15
递归实现
javascript 复制代码
function sumRecursive(n) {
  // 退出条件
  if (n === 1) {
    return 1;
  }
  // 递归公式
  return sumRecursive(n - 1) + n;
}

// 执行:自顶向下分解,再自底向上合并
// sumRecursive(5) → sum(4)+5 → sum(3)+4 → sum(2)+3 → sum(1)+2 → 1

对比

维度 迭代 递归
代码风格 命令式,显式循环 声明式,接近数学定义
空间复杂度 O(1) O(n)(调用栈)
适用场景 简单累加 树形结构、分治算法
可读性 直观 抽象,需理解递归思想

3.3 递归实战:数组扁平化

ES6 内置方法
javascript 复制代码
const arr = [1, [2, [3, 4, [5, 6]]]];

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

// 传入深度参数
console.log(arr.flat(2));     // [1, 2, 3, 4, [5, 6]]

// Infinity:扁平化所有层级
console.log(arr.flat(Infinity));  // [1, 2, 3, 4, 5, 6]
递归实现
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, 不是数组 → result=[1, 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]
  。         │           → return [5, 6]
  。         └── result=[3, 4].concat([5, 6]) = [3, 4, 5, 6]
  。         → return [3, 4, 5, 6]
  └── result=[1, 2].concat([3, 4, 5, 6]) = [1, 2, 3, 4, 5, 6]
  → return [1, 2, 3, 4, 5, 6]

递归三要素

要素 数组扁平化中的体现
递归公式 flatten(arr) = flatten(子数组) + 非数组元素
退出条件 元素不是数组,直接 push
相同子问题 每个子数组都是同样的扁平化问题

四、面试高频题

Q1:快排的时间复杂度是多少?最坏情况是什么?

:平均时间复杂度 O(n log n),最坏情况 O(n²)。最坏情况发生在数组已有序时,每次基准值都是最小或最大值,导致分区极度不平衡。

优化方案:随机选择基准值,或三数取中法(取左中右三个数的中位数作为基准值)。

Q2:快排为什么不稳定?

:分区过程中,相等元素可能跨越基准值交换位置。例如 [3(a), 2, 3(b)],如果 3(b) 被交换到基准值左边,最终顺序可能变为 [2, 3(b), 3(a)],相等元素的相对位置被颠倒。

Q3:递归和迭代的区别?什么时候用递归?

:递归是函数自身调用,代码简洁但消耗栈空间;迭代是显式循环,空间效率高但代码可能更复杂。处理树形结构、分治算法时,递归更自然直观。

Q4:如何实现一个稳定的快速排序?

:标准快排不稳定,但可以通过以下方式改造:

  1. 使用额外数组存储分区结果(牺牲空间换稳定)
  2. 三路快排:将等于基准值的元素放在中间,不交换相等元素

五、知识图谱

scss 复制代码
快速排序与递归思维
├── 排序算法概览
│   ├── 冒泡/选择/插入排序:O(n²)
│   ├── 快速/归并排序:O(n log n)
│   └── 稳定性概念
├── 快速排序
│   ├── 分治策略(Divide + Conquer + Combine)
│   ├── 分治 vs 递归
│   ├── partition 分区函数
│   │   ├── 双指针扫描
│   │   ├── 原地交换
│   │   └── 基准值归位
│   ├── 完整实现
│   └── 时间复杂度分析
├── 递归思维
│   ├── 递归三要素
│   │   ├── 递归公式
│   │   ├── 退出条件
│   │   └── 相同子问题
│   ├── 迭代 vs 递归
│   └── 递归执行过程
└── 数组扁平化实战
    ├── ES6 flat 方法
    └── 递归实现
        ├── 判断 Array.isArray
        ├── concat 合并
        └── 递归调用

六、总结

本文系统梳理了快速排序与递归思维的核心知识点:

  1. 排序算法从 O(n²) 到 O(n log n) 的进化,体现了算法设计的重要性。
  2. 快速排序基于分治策略,通过 partition 分区函数和双指针原地交换实现高效排序。
  3. 分治是思想,递归是实现方式。理解这一区别,才能灵活运用。
  4. 递归三要素(递归公式、退出条件、相同子问题)是写出正确递归代码的关键。
  5. 数组扁平化是递归思维的典型应用,通过判断元素类型决定是否递归。
  6. 快排的不稳定性源于分区过程中的元素交换,理解这一点有助于选择合适的排序算法。

🚀 学习建议:先理解 partition 的分区原理(画图解),再手写完整快排代码,最后用递归实现数组扁平化巩固思维。面试时,能清晰讲解 partition 的执行过程是加分项。


参考资源


📌 标签:#快速排序 #递归 #分治 #算法 #面试题 #数组扁平化 #双指针 #时间复杂度

💬 互动:你面试时被问过哪些排序算法的问题?欢迎在评论区分享!

相关推荐
小月土星2 小时前
JavaScript 快速排序:从 pivot、双指针到分治思想
javascript·算法·面试
沉默王二2 小时前
Agent底层原理连问8道,从ReAct到记忆压缩,PaiCLI项目实战拆解
面试·agent·ai编程
小月土星2 小时前
JavaScript 递归入门:从 1 到 n 求和,再到数组扁平化
javascript·算法·面试
蝎子莱莱爱打怪2 小时前
XZLL-IM干货系列 04|Netty 长连接实战:Pipeline 怎么排、心跳怎么跳、连接怎么管
后端·微服务·面试
kyriewen16 小时前
别再 console.log 了:5 个 Chrome DevTools 调试技巧,用过就回不去了
前端·javascript·面试
GuWenyue19 小时前
排序效率低?5分钟吃透快速排序,性能飙升至O(nlogn)
前端·javascript·面试
ricardo197319 小时前
React 渲染优化:memo / useMemo / useCallback 的正确姿势与并发模式实战
前端·面试
用户4845262558219 小时前
搜索旋转排序数组:必有一侧是有序的
排序算法