LeetCode 完全平方数问题:递归解法中移除内层循环的正确实现

沈亦白
沈亦白
发布时间:2026-08-25| 89人浏览过
原创
完全平方数记忆化递归中的边界顺序与缓存命中关系示意图
递归入口顺序怎么检查把基础情况、越界返回和缓存读取放进同一张编辑卡片,帮助读者快速检查递归入口顺序。
完全平方数问题中,递归去掉 for 循环后两条分支如何覆盖完整搜索空间的示意图
两条递归分支,哪里会漏解用纸面流程和便签卡片说明“跳过当前平方数”与“重复选当前平方数”两条分支的区别。

前言

在完全平方数这道题里,把递归里的 for 循环改成“取 / 不取”两条分支,看起来只是换个写法,实际上很容易把状态空间写坏。本文会顺着漏解、误判边界和缓存失效这三个高频问题拆解,并给出一份可直接对照检查的修正版实现,帮助你判断这种“去循环递归”到底哪里能改、哪里不能动。

在 LeetCode 279“完全平方数”里,很多人会先写出带 for 的递归枚举,再尝试把循环改造成“取 / 不取”两条递归分支。难点不在于语法替换,而在于状态是否还能覆盖所有合法组合、边界是否会提前截断,以及缓存有没有真正参与计算。下面就按这三个问题拆开讲,并给出一份可以安全移除内层循环的记忆化递归实现。

题目目标与改写递归的难点

LeetCode 279. Perfect Squares 要求求出组成正整数 n 所需的最少完全平方数个数。比如 12 = 4 + 4 + 4,结果就是 3。

常规递归解法通常会写一个循环,枚举所有满足 i * i <= n 的平方数,再递归求剩余部分的最优解。有人会进一步想:既然循环本质上也是在做选择,那能不能用额外状态 ind 表示当前考虑的平方根,把枚举过程改写成两条分支?

这个方向本身没有问题,但一旦把显式循环展开成递归,就必须重新确认三件事:

  • 当前状态能不能表达“同一个平方数可重复使用”;
  • 基础情况的判断顺序会不会把正确答案挡掉;
  • 记忆化是否和新的状态定义保持一致。

如果这三点里有一处处理错了,程序即使能跑,也很容易得到错误结果,或者直接因为指数级递归超时。

移除内层循环时最常见的三个错误

1. 状态设计错了,导致平方数不能重复使用

最关键的问题,往往出在“取当前平方数”这一步。

错误写法类似这样:

dfs(num - ind*ind, ind+1)

这表示一旦选了 ind²,下一步就强制去看更大的平方数。问题是题目允许同一个平方数重复使用,比如 12 = 4 + 4 + 4 是合法解。如果第一次取了 4 之后就把 ind 推到下一个位置,那么后面就再也不能继续选 4 了,状态空间会被错误压缩,直接漏解。

正确思路是:当你选择当前平方数后,下一层仍然要保留当前索引,也就是继续允许重复拿同一个平方数。

dfs(num - ind*ind, ind)

这里的含义不是“卡在当前位置不动”,而是“当前值还能继续被选”。这是把循环改写成分支递归时最容易忽略、但影响最大的地方。

2. 边界判断顺序不当,可能把 num === 0 判丢

第二个常见错误,是把边界写成先判断索引是否越界,再判断 num === 0。

例如先写:

if (ind > sqrt(num)) return Infinity;
if (num === 0) return 0;

这种顺序会带来一个问题:当递归已经刚好把 num 减到 0 时,本应立刻返回 0,表示已经完成组合;但如果你先做了依赖 num 的范围判断,就可能提前命中“无解”分支,返回 Infinity,把本来成立的答案覆盖掉。

因此,num === 0 必须作为最高优先级基础情况,优先返回。

此外,用:

ind * ind > num

来代替:

ind > Math.sqrt(num)

会更稳妥。一方面不需要浮点运算,另一方面也少了一次函数调用,逻辑上更直接。

3. 声明了缓存,却没有真正记忆化

第三类问题是形式上有缓存对象,比如:

const hash = {}

但递归过程中既不读,也不写。这种写法实际上和没有记忆化没有区别,搜索树会反复计算同一个子问题,复杂度迅速退化到指数级,LeetCode 上基本必然超时。

真正的记忆化至少要做到两件事:

  • 递归开头优先读缓存;
  • 求出最优值后及时写回缓存。

这一点看起来基础,但在“重写递归结构”的场景里很容易漏掉,因为开发者往往把精力都放在分支转移上,忘了让缓存和新的状态定义同步生效。

修正后的记忆化递归实现

把上面三个问题都修正后,可以得到下面这份实现:

var numSquares = function(n) {
const cache = {};

const dfs = (num, ind) => {
if (num < 0) return Infinity;
if (num === 0) return 0; // 必须最先检查!
if (cache[num] !== undefined) return cache[num];

// 避免 Math.sqrt:直接比较平方
if (ind * ind > num) return Infinity;

// 分支1:跳过 ind²,尝试更大的平方数
const n_take = dfs(num, ind + 1);
// 分支2:选用 ind²(可重复),num 减去该值,ind 不变
const take = 1 + dfs(num - ind * ind, ind);

const min = Math.min(n_take, take);
cache[num] = min; // 写入缓存
return min;
};

return dfs(n, 1);
};

这段代码的结构可以直接理解为:

  • dfs(num, ind + 1):跳过当前平方数,去尝试更大的平方数;
  • 1 + dfs(num - ind * ind, ind):选择当前平方数一次,并允许之后继续选择它。

两条分支共同覆盖了“用或不用当前平方数”的完整搜索过程。由于有缓存,同一个 num 对应的最优解不会被反复计算。

为什么这里的缓存通常只需要用 num

这部分最容易引发疑问:既然递归函数有两个参数 (num, ind),为什么缓存却只用 num 做键?

文中的核心判断是:在这个问题设定下,我们从 ind = 1 开始搜索,并且允许重复使用平方数,目标始终是求某个 num 的最少拆分个数。也就是说,真正决定最优解的是“剩余值是多少”,而不是“当前从第几个平方数开始看”。

换句话说,只要搜索过程能够覆盖全部合法平方数组合,那么 dfs(num) 的最优值就应当稳定存在。ind 更像是控制遍历顺序的辅助变量,而不是最终答案的决定性状态。

因此,缓存写成:

cache[num]

通常就够了。这样做的直接收益是:

  • 缓存键更简单;
  • 空间开销更小;
  • 不会因为把遍历控制变量也塞进键里,导致命中率下降。

当然,这个结论成立的前提,是你的递归转移本身已经保证了组合覆盖完整,尤其是“取当前平方数后不推进 ind”这一点必须正确。否则即使缓存键只用 num,也只是把错误结果更快地复用出去。

复杂度、适用场景与一个判断标准

需要说明的是,去掉显式 for 循环,并不代表问题本质变了。它仍然是在完全平方数集合上做搜索,只是把枚举动作改写成了递归分支。

从复杂度角度看,这类记忆化递归通常仍可视为 O(n√n) 量级。相比完全暴力搜索已经好很多,但如果你追求更稳定的性能和更小的常数,标准动态规划往往更直接:

dp[i] = min(dp[i - j*j] + 1)

再进一步,还可以使用数学方法,例如四平方定理和三平方判定。不过对于很多面试和题解场景来说,这份递归版实现依然有价值,因为它能帮助你真正看清“状态设计”和“转移完备性”之间的关系。

判断这种“去循环改递归”写法是否靠谱,可以抓一个很实用的标准:

  • 是否还能重复使用合法元素;
  • 是否先处理成功到达终点的基础情况;
  • 缓存键是否只包含真正影响最优解的状态。

这三项都能答清楚,代码一般就不会偏得太远。

结语

把内层循环移除,并不是单纯把枚举改写成两个递归调用,而是一次完整的状态重建。对于完全平方数问题,真正要守住的点只有几个:选择当前平方数后必须允许重复使用、num === 0 必须优先返回、记忆化必须和状态定义一致。

理解了这几点,再看这类“取 / 不取”递归题,重点就不再是有没有 for 循环,而是搜索空间是否被完整、准确地表达出来。

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。