【每日算法】LeetCode 279. 完全平方数(动态规划)

对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。------ 算法:资深前端开发者的进阶引擎

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 通用解题模板或思路

此类"最少组合"问题(如完全平方数、零钱兑换)可遵循以下模板:

  1. 识别问题类型:最小个数、无限使用元素,考虑动态规划或 BFS。
  2. 动态规划模板
    • 定义 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. 单词拆分:字符串分割问题,类似组合优化。
相关推荐
小北方城市网2 小时前
第7课:Vue 3应用性能优化与进阶实战——让你的应用更快、更流畅
前端·javascript·vue.js·ai·性能优化·正则表达式·json
向下的大树2 小时前
React 环境搭建 + 完整 Demo 教程
前端·react.js·前端框架
IT_陈寒2 小时前
Python性能翻倍的5个隐藏技巧:让你的代码跑得比同事快50%
前端·人工智能·后端
Можно2 小时前
GET与POST深度解析:区别、适用场景与dataType选型指南
前端·javascript
scx201310042 小时前
20251201换根DP总结
算法·动态规划·换根dp
爱上妖精的尾巴2 小时前
5-41 WPS JS宏 数组迭代基础测试与双数组迭代的使用方法测试
前端·javascript·wps
zd2005722 小时前
STREAMS指南:环境及宿主相关微生物组研究中的技术报告标准
人工智能·python·算法
Tisfy2 小时前
“豆包聊天搜索” —— 直接在Chrome等浏览器地址栏开启对话
前端·chrome·豆包
Data_agent2 小时前
京东商品价格历史信息API使用指南
java·大数据·前端·数据库·python