在算法学习的过程中,我们常常会遇到两类典型问题:一类是组合搜索类问题,比如“三数之和”;另一类则是递归结构问题,如“斐波那契数列”。这两类问题看似毫无关联,实则共同揭示了算法设计中的核心思想——如何通过合理的策略避免重复计算、提升效率,并写出健壮、可维护的代码。本文将结合这两个经典问题,深入剖析排序 + 双指针、递归、记忆化缓存以及闭包等关键技术点。
一、三数之和:排序 + 双指针的优雅解法
1.1 问题背景
LeetCode 第 15 题“三数之和”要求:给定一个整数数组 nums,判断是否存在三个元素 a, b, c,使得 a + b + c = 0。需要找出所有满足条件且不重复的三元组。
暴力解法的时间复杂度为 ,显然无法通过大规模测试用例。而借助排序 + 双指针的策略,我们可以将时间复杂度优化至 。
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 朴素递归的陷阱
斐波那契数列定义如下:
- (当 )
最直观的实现是递归:
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)); // 实际上几乎无法返回
这种写法虽然简洁,但存在两大致命问题:
- 指数级时间复杂度 :每个
fib(n)会分裂为两个子问题,形成庞大的递归树,大量重复计算(如fib(5)会被计算数十次)。 - 栈溢出风险:当
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)); // 快速返回正确结果
此方法将时间复杂度降至 ,但引入了全局变量 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,实现持久化缓存,同时对外完全隐藏实现细节。
三、总结:算法思维与工程实践的融合
无论是“三数之和”还是“斐波那契”,它们都体现了算法设计中的通用原则:
-
避免重复劳动
- 三数之和通过排序 + 跳过重复值,避免生成重复三元组;
- 斐波那契通过缓存,避免重复计算子问题。
-
利用数据结构特性
- 排序后的数组支持双指针高效搜索;
- 哈希表(对象)提供 的缓存读写。
-
平衡时间与空间
- 用 额外空间换取 到 的时间飞跃;
- 这正是“空间换时间”思想的经典体现。
-
代码封装与可维护性
- 使用闭包隐藏内部状态,提升函数的健壮性和复用性;
- 避免全局变量,符合现代 JavaScript 工程规范。
结语
算法不仅是面试题,更是解决实际问题的工具。当我们面对复杂问题时,不妨先思考:是否存在重复子结构?能否通过预处理(如排序)简化问题?是否可以用缓存避免冗余计算?这些问题的答案,往往就藏在像“三数之和”和“斐波那契”这样的经典题目之中。
掌握这些模式,不仅能写出高效的代码,更能培养出清晰的工程思维——这,才是算法学习的真正价值所在。