从三数之和到斐波那契:深入理解算法优化与编程技巧

53 阅读6分钟

在算法学习的过程中,我们常常会遇到两类典型问题:一类是组合搜索类问题,比如“三数之和”;另一类则是递归结构问题,如“斐波那契数列”。这两类问题看似毫无关联,实则共同揭示了算法设计中的核心思想——如何通过合理的策略避免重复计算、提升效率,并写出健壮、可维护的代码。本文将结合这两个经典问题,深入剖析排序 + 双指针、递归、记忆化缓存以及闭包等关键技术点。


一、三数之和:排序 + 双指针的优雅解法

1.1 问题背景

LeetCode 第 15 题“三数之和”要求:给定一个整数数组 nums,判断是否存在三个元素 a, b, c,使得 a + b + c = 0。需要找出所有满足条件且不重复的三元组。

暴力解法的时间复杂度为 O(n3)O(n^3),显然无法通过大规模测试用例。而借助排序 + 双指针的策略,我们可以将时间复杂度优化至 O(n2)O(n^2)

1.2 核心思路解析

function threeSum(nums) {
  // 先排序
  // sort 是 JS 内置的排序,逻辑上我们关心的是升序
  // a - b < 0 不交换位置,升序排序
  // b - a < 0 交换位置,降序排序
  nums.sort((a, b) => a - b); // 直接修改原数组
  const res = []; // 用于收集结果

  for (let i = 0; i < nums.length - 2; i++) {
    // 有两个指针确定了 i + 1 和 nums.length - 1,所以 i 最多只能到倒数第 3 个位置

    // 跳过重复的起始值
    if (i > 0 && nums[i] === nums[i - 1]) continue;
    // 当 i == 0 时是第一个数字,不需要跳过
    // nums[i] === nums[i - 1] 满足下标各不相同 i, j, k —— 三数不能因值相同而重复
    // 当 i > 0 且当前nums[i] 和 前一个nums[i-1]相等时 说明这个值已经作为第一个数被处理过了 跳过它 避免产生重复三元组
    // 例如 [-1, -1, 0, 1]:第一个 -1 已经找过所有组合,第二个 -1 就不用再试,否则会重复

    // 双指针
    let left = i + 1; // 从第一个数的右边开始
    let right = nums.length - 1; // 从数组末尾开始

    while (left < right) {
      const sum = nums[i] + nums[left] + nums[right];
      if (sum === 0) {
        res.push([nums[i], nums[left], nums[right]]);
        // 继续找
        left++;
        right--;
        // 跳过左边重复值:如果 left 指向的值和上一个一样,跳过,避免重复三元组
        while (left < right && nums[left] === nums[left - 1]) {
          left++;
        }
        // 跳过右边重复值:同理,避免重复
        while (left < right && nums[right] === nums[right + 1]) {
          right--;
        }
      } else if (sum < 0) {
        // 和太小,需要更大的数 → 左指针右移(因为数组已排序,从左往右递增)
        left++;
      } else {
        // 和太大,需要更小的数 → 右指针左移
        right--;
      }
    }
  }
  return res;
}

1.3 关键技术点

  • 排序的作用:不仅便于双指针移动判断方向,更重要的是统一处理重复元素。若不排序,无法有效跳过重复三元组。

  • 去重逻辑

    • 外层循环中,若 nums[i] === nums[i-1],说明以该值为起点的组合已被枚举过,直接跳过。
    • 内层找到一组解后,左右指针同时移动,并跳过各自方向上的重复值。
  • 双指针移动规则

    • sum < 0 → 需要更大值 → left++
    • sum > 0 → 需要更小值 → right--

注意:JavaScript 的 Array.prototype.sort() 默认按字符串比较,因此必须传入 (a, b) => a - b 才能得到正确的数值升序,并且是原地修改。


二、斐波那契数列:从朴素递归到闭包缓存

2.1 朴素递归的陷阱

斐波那契数列定义如下:

  • f(0)=0f(0) = 0
  • f(1)=1f(1) = 1
  • f(n)=f(n1)+f(n2)f(n) = f(n-1) + f(n-2) (当 n2n \geq 2

最直观的实现是递归:

function fib(n) {
  // 退出条件,一定要有
  if (n <= 1) return n;
  // 函数调用自己:递归
  // 递归的公式
  return fib(n - 1) + fib(n - 2);
}

console.log(fib(10)); // 55
// 如果是 100,卡住了 —— 逼近栈内存的极限
console.log(fib(100)); // 实际上几乎无法返回

这种写法虽然简洁,但存在两大致命问题:

  1. 指数级时间复杂度 O(2n)O(2^n) :每个 fib(n) 会分裂为两个子问题,形成庞大的递归树,大量重复计算(如 fib(5) 会被计算数十次)。
  2. 栈溢出风险:当 n 较大时,函数调用栈深度超出限制,导致程序崩溃。

2.2 记忆化优化:空间换时间

为解决重复计算问题,引入缓存(memoization)

const cache = {}; // 空间换时间

function fib(n) {
  if (n in cache) {
    return cache[n];
  }
  // 检查 n 是否已经在缓存中,如果存在,直接返回缓存中的结果,避免重复计算

  if (n <= 1) {
    cache[n] = n;
    return n;
  }
  // 处理递归的基础情况:fib(0)=0, fib(1)=1
  // 同时存入 cache,保证即使多次调用 fib(0) 或 fib(1),也不会重复判断

  const result = fib(n - 1) + fib(n - 2);
  // 由于前面已做缓存检查,fib(n-1) 和 fib(n-2) 的调用中,很多子问题已被缓存
  // 普通递归(无缓存)时间复杂度为 O(2^n),而记忆化后是 O(n)

  cache[n] = result;
  return result;
  // 将本次计算结果存入缓存,并返回
  // 确保后续对相同 n 的调用可直接命中缓存
}

console.log(fib(100)); // 快速返回正确结果

此方法将时间复杂度降至 O(n)O(n),但引入了全局变量 cache,破坏了函数的纯度和封装性。

2.3 闭包封装:IIFE 实现私有缓存

为解决全局污染问题,使用立即执行函数表达式(IIFE) 创建闭包:

const fib = (function () {
  // IIFE:Immediately Invoked Function Expression
  // console.log(111);

  // 这里就形成了闭包,存放着自由变量
  const cache = {};
  // 这个 cache 变量将成为闭包中的自由变量

  // fib 即为这个返回的函数
  return function (n) {
    if (n in cache) {
      return cache[n];
    }
    if (n <= 1) {
      cache[n] = n;
      return n;
    }

    cache[n] = fib(n - 1) + fib(n - 2);
    return cache[n];
  };
})();

console.log(fib(100)); // 安全、高效、无全局污染

闭包机制解析:

  • 内部函数(返回的 function(n))可以访问其外部函数(IIFE)作用域中的 cache
  • 即使 IIFE 执行完毕并出栈,cache 仍被内部函数引用,存储在堆内存中,不会被垃圾回收。
  • 每次调用 fib(n) 都共享同一个 cache,实现持久化缓存,同时对外完全隐藏实现细节。

三、总结:算法思维与工程实践的融合

无论是“三数之和”还是“斐波那契”,它们都体现了算法设计中的通用原则:

  1. 避免重复劳动

    • 三数之和通过排序 + 跳过重复值,避免生成重复三元组;
    • 斐波那契通过缓存,避免重复计算子问题。
  2. 利用数据结构特性

    • 排序后的数组支持双指针高效搜索;
    • 哈希表(对象)提供 O(1)O(1) 的缓存读写。
  3. 平衡时间与空间

    • O(n)O(n) 额外空间换取 O(n2)O(n^2)O(n)O(n) 的时间飞跃;
    • 这正是“空间换时间”思想的经典体现。
  4. 代码封装与可维护性

    • 使用闭包隐藏内部状态,提升函数的健壮性和复用性;
    • 避免全局变量,符合现代 JavaScript 工程规范。

结语

算法不仅是面试题,更是解决实际问题的工具。当我们面对复杂问题时,不妨先思考:是否存在重复子结构?能否通过预处理(如排序)简化问题?是否可以用缓存避免冗余计算?这些问题的答案,往往就藏在像“三数之和”和“斐波那契”这样的经典题目之中。

掌握这些模式,不仅能写出高效的代码,更能培养出清晰的工程思维——这,才是算法学习的真正价值所在。