对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。------ 算法:资深前端开发者的进阶引擎
LeetCode 279. 完全平方数
1. 题目描述
给定一个正整数 n,你需要找到若干个完全平方数(例如 1, 4, 9, 16, ...),使得它们的和等于 n。要求返回组成和的最少完全平方数个数。
示例:
- 输入:
n = 12,输出:3,解释:12 = 4 + 4 + 4 - 输入:
n = 13,输出:2,解释:13 = 4 + 9
注意:
1 <= n <= 10^4- 完全平方数可以重复使用。
2. 问题分析
这个问题本质上是一个组合优化问题,类似于前端的资源打包或性能优化中寻找最小依赖集合的场景。我们可以从以下角度分析:
- 数学建模 :将
n分解为完全平方数的和,求最小个数。这类似于无限背包问题,其中物品是完全平方数,背包容量是n。 - 图论视角 :将每个整数视为节点,如果两个整数相差一个完全平方数,则存在一条边。问题转化为从节点
0到节点n的最短路径问题,适合用 BFS 解决。 - 前端类比:如同优化页面加载时间,需要组合最小化的资源块(完全平方数)来达到目标(n)。
关键点:完全平方数无限可用,求最小数量,因此动态规划是高效解法。
3. 解题思路
我们介绍四种思路,从暴力到最优,并分析复杂度。最优解是动态规划,时间复杂度 O(n√n),空间 O(n),易于实现且高效。
3.1 思路一:暴力递归(回溯)
尝试所有可能的完全平方数组合,递归搜索最小个数。但会超时,仅用于理解问题。
复杂度:时间复杂度 O(2^n),空间复杂度 O(n)(递归栈深度)。
3.2 思路二:记忆化搜索(自顶向下动态规划)
在暴力递归基础上,用备忘录存储中间结果,避免重复计算。
复杂度:时间复杂度 O(n√n),空间复杂度 O(n)。
3.3 思路三:动态规划(自底向上,最优解)
定义 dp[i] 为组成整数 i 所需的最少完全平方数个数。状态转移:dp[i] = min(dp[i - j*j] + 1),其中 j*j <= i。
复杂度:时间复杂度 O(n√n),空间复杂度 O(n)。
3.4 思路四:BFS(广度优先搜索)
将问题视为最短路径搜索,从 0 开始,每次加上一个完全平方数,直到达到 n,记录步数。
复杂度:时间复杂度 O(n√n),空间复杂度 O(n)。
4. 各思路代码实现(JavaScript)
以下用 JavaScript 实现,附带详细注释和步骤说明。前端开发者可结合异步编程思维理解 BFS 的队列操作。
4.1 暴力递归(仅参考,不可行)
javascript
/**
* 暴力递归:尝试所有完全平方数组合,返回最小个数
* @param {number} n - 目标正整数
* @return {number} - 最少完全平方数个数
*/
var numSquares = function(n) {
// 辅助递归函数
const dfs = (remain) => {
// 基础情况:如果 remain 为 0,不需要任何数
if (remain === 0) return 0;
let minCount = Infinity;
// 尝试所有可能的完全平方数
for (let j = 1; j * j <= remain; j++) {
const square = j * j;
// 递归计算剩余部分
const count = 1 + dfs(remain - square);
minCount = Math.min(minCount, count);
}
return minCount;
};
return dfs(n);
};
// 注意:此代码在 n 较大时会超时,仅用于教学理解。
4.2 记忆化搜索
javascript
/**
* 记忆化搜索:自顶向下动态规划,避免重复计算
* @param {number} n - 目标正整数
* @return {number} - 最少完全平方数个数
*/
var numSquares = function(n) {
// 备忘录,存储已计算的结果
const memo = new Array(n + 1).fill(-1);
// 递归函数,带记忆化
const dfs = (remain) => {
// 基础情况
if (remain === 0) return 0;
// 如果已计算,直接返回
if (memo[remain] !== -1) return memo[remain];
let minCount = Infinity;
// 尝试所有完全平方数
for (let j = 1; j * j <= remain; j++) {
const square = j * j;
minCount = Math.min(minCount, 1 + dfs(remain - square));
}
// 存储结果到备忘录
memo[remain] = minCount;
return minCount;
};
return dfs(n);
};
// 步骤分解:
// 1. 初始化备忘录 memo,-1 表示未计算。
// 2. 递归计算每个 remain,先查备忘录。
// 3. 遍历 j*j <= remain,递归子问题。
// 4. 存储结果,避免重复计算。
4.3 动态规划(最优解)
javascript
/**
* 动态规划:自底向上迭代,高效求解
* @param {number} n - 目标正整数
* @return {number} - 最少完全平方数个数
*/
var numSquares = function(n) {
// 步骤1:创建 dp 数组,dp[i] 表示组成 i 的最少完全平方数个数
// 初始化为 Infinity,因为要求最小值
const dp = new Array(n + 1).fill(Infinity);
// 基础情况:组成 0 需要 0 个完全平方数
dp[0] = 0;
// 步骤2:遍历从 1 到 n 的每个数字
for (let i = 1; i <= n; i++) {
// 步骤3:对于每个 i,尝试所有可能的完全平方数 j*j
for (let j = 1; j * j <= i; j++) {
const square = j * j;
// 状态转移方程:dp[i] = min(dp[i], dp[i - square] + 1)
dp[i] = Math.min(dp[i], dp[i - square] + 1);
}
}
// 步骤4:返回结果
return dp[n];
};
// 复杂度分析:
// - 外层循环 O(n),内层循环 O(√n),总时间 O(n√n)
// - 空间 O(n) 用于 dp 数组
// 前端类比:类似于计算缓存最小资源包,类似 Vue/React 的依赖优化。
4.4 BFS 实现
javascript
/**
* BFS:将问题视为图的最短路径搜索
* @param {number} n - 目标正整数
* @return {number} - 最少完全平方数个数
*/
var numSquares = function(n) {
// 步骤1:生成所有小于等于 n 的完全平方数
const squares = [];
for (let i = 1; i * i <= n; i++) {
squares.push(i * i);
}
// 步骤2:初始化 BFS 队列和访问集合
// 队列元素格式:[当前和, 步数]
const queue = [[0, 0]]; // 从和 0 开始,步数 0
const visited = new Set([0]); // 避免重复访问,提升性能
// 步骤3:BFS 遍历
while (queue.length > 0) {
const [currentSum, steps] = queue.shift(); // 出队,先进先出
// 步骤4:尝试加上每个完全平方数
for (let square of squares) {
const newSum = currentSum + square;
// 如果达到目标,返回步数+1
if (newSum === n) {
return steps + 1;
}
// 如果超过 n,停止当前循环(因为 squares 是排序的)
if (newSum > n) {
break;
}
// 如果新和未访问过,加入队列和访问集
if (!visited.has(newSum)) {
visited.add(newSum);
queue.push([newSum, steps + 1]);
}
}
}
// 理论上不会到达这里,因为总有解(至少可以用 1 相加)
return -1;
};
// 步骤分解:
// 1. 生成平方数列表,类似预处理资源。
// 2. BFS 队列模拟异步任务队列,确保最短路径。
// 3. visited 集合防止重复计算,类似前端防抖。
// 类比:如同路由跳转寻找最少中间页面的场景。
5. 各实现思路的复杂度、优缺点对比
以下表格对比四种思路,帮助前端开发者根据场景选择:
| 思路 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 暴力递归 | O(2^n) | O(n) 递归栈 | 简单直观,易理解 | 超时,效率极低 | 仅教学,小 n 测试 |
| 记忆化搜索 | O(n√n) | O(n) | 避免重复计算,自顶向下逻辑清晰 | 递归开销,可能栈溢出 | 中等 n,递归思维练习 |
| 动态规划 | O(n√n) | O(n) | 迭代高效,代码简洁,最优解 | 需要 O(n) 空间 | 大多数场景,推荐使用 |
| BFS | O(n√n) | O(n) | 最短路径直观,适合图论思维 | 队列操作开销,常数较大 | 需要最短路径视角时 |
总结对比:动态规划在时间和空间上平衡最好,是标准解法;BFS 提供了另一种视角,但实际性能稍差。前端开发中,动态规划类似缓存计算结果,优化渲染性能。
6. 总结
6.1 通用解题模板或思路
此类"最少组合"问题(如完全平方数、零钱兑换)可遵循以下模板:
- 识别问题类型:最小个数、无限使用元素,考虑动态规划或 BFS。
- 动态规划模板 :
- 定义
dp[i]为达到目标i的最优值。 - 初始化
dp[0] = 0或其他基础值。 - 状态转移:
dp[i] = min(dp[i - 候选值] + 1),遍历所有候选。 - 返回
dp[n]。
- 定义
6.2 LeetCode 类似题目
- LeetCode 322. 零钱兑换:几乎相同问题,但候选是硬币而非平方数。
- LeetCode 377. 组合总和 IV:求组合总数,但可转换为动态规划。
- LeetCode 70. 爬楼梯:简单版动态规划,步长为 1 或 2。
- LeetCode 139. 单词拆分:字符串分割问题,类似组合优化。