LeetCode算法日记 - Day 66: 衣橱整理、斐波那契数(含记忆化递归与动态规划总结)
目录
1. 衣橱整理
1.1 题目解析
1.2 解法
1.3 代码实现
2. 斐波那契数
2.1 题目解析
2.2 解法
2.3 代码实现
1. 衣橱整理
https://leetcode.cn/problems/ji-qi-ren-de-yun-dong-fan-wei-lcof/
家居整理师将待整理衣橱划分为 m x n
的二维矩阵 grid
,其中 grid[i][j]
代表一个需要整理的格子。整理师自 grid[0][0]
开始 逐行逐列 地整理每个格子。
整理规则为:在整理过程中,可以选择 向右移动一格 或 向下移动一格,但不能移动到衣柜之外。同时,不需要整理 digit(i) + digit(j) > cnt
的格子,其中 digit(x)
表示数字 x
的各数位之和。
请返回整理师 总共需要整理多少个格子。
示例 1:
输入:m = 4, n = 7, cnt = 5 输出:18
提示:
1 <= n, m <= 100
0 <= cnt <= 20
1.1 题目解析
题目本质
这是一个图的可达性遍历问题。本质是从起点 (0,0) 出发,在满足约束条件下,统计所有能够到达的格子数量。
常规解法
最直观的想法是遍历整个 m×n 矩阵的每个格子,判断每个格子 (i,j) 是否满足 digit(i) + digit(j) ≤ cnt,满足条件就计数。
问题分析
常规解法忽略了一个关键约束——只能从 (0,0) 开始,且只能向右或向下移动。这意味着即使某个格子 (i,j) 满足数位和条件,但如果从起点无法到达它(路径上被其他不满足条件的格子阻断),也不能计入结果。例如:如果 (1,0) 不满足条件,那么第一列所有 i>0 的格子都无法到达。
思路转折
要想准确计数 → 必须考虑可达性 → 使用 DFS/BFS 从起点搜索。关键点:
-
从 (0,0) 开始探索
-
每次只能向右或向下移动
-
访问新格子前先判断数位和条件
-
用 vis 数组避免重复访问
1.2 解法
算法思想
使用深度优先搜索(DFS)从起点 (0,0) 开始遍历,每个格子只能向右 (i, j+1) 或向下 (i+1, j) 移动。对于新格子 (x, y),当且仅当满足以下条件时才访问:
-
边界条件:0 ≤ x < m, 0 ≤ y < n
-
未访问过:vis[x][y] == false
-
数位和条件:digit(x) + digit(y) ≤ cnt
数位和计算公式:digit(x) = Σ(x 的每一位数字),例如 digit(13) = 1 + 3 = 4。
i)初始化:创建 vis[m][n] 访问标记数组,result 计数器置 0
ii)DFS 递归过程:
-
标记当前格子为已访问:vis[si][sj] = true
-
将当前格子计入结果:result++
-
遍历两个方向(右、下):
-
计算新坐标 (x, y)
-
判断边界、访问状态、数位和条件
-
满足条件则递归调用 dfs(x, y)
-
iii)数位和计算:循环提取数字的每一位(num % 10),累加后除以 10
iv)返回结果:DFS 结束后返回 result
易错点
-
方向数组错误:题目要求"向右或向下",应该是 dx={1,0}, dy={0,1},不要写成 {1,-1} 或 {0,1}, {1,0} 等错误组合
-
数位和计算错误:必须用 num % 10 取个位数字
-
起点未计数:必须在 DFS 开始时对当前格子计数(result++),不能只在递归后计数,否则会漏掉起点
-
重复标记:在递归调用前就要标记 vis[x][y] = true(或在 DFS 入口标记),避免重复访问
-
边界判断顺序:先判断边界条件,再判断 vis 和数位和,避免数组越界
1.3 代码实现
class Solution {int[] dx = {1, 0}; // 向下、向右int[] dy = {0, 1};int m, n, cnt;boolean[][] vis;int result;public int wardrobeFinishing(int _m, int _n, int _cnt) {m = _m;n = _n;cnt = _cnt;vis = new boolean[m][n];result = 0;dfs(0, 0);return result;}public void dfs(int si, int sj) {vis[si][sj] = true;result++; // 计数当前格子for (int i = 0; i < 2; i++) {int x = si + dx[i];int y = sj + dy[i];// 边界检查 + 访问检查 + 数位和检查if (x >= 0 && x < m && y >= 0 && y < n && !vis[x][y]) {int sum = get(x) + get(y);if (sum <= cnt) {dfs(x, y); // 递归访问}}}}// 计算数字各位数字之和public int get(int num) {int sum = 0;while (num > 0) {sum += num % 10; // 取个位num = num / 10; // 去掉个位}return sum;}
}
复杂度分析
-
时间复杂度:O(m × n)。每个格子最多访问一次,计算数位和的复杂度为 O(log num),由于 m, n ≤ 100,数位和计算可视为 O(1),总体最多访问 m×n 个格子
-
空间复杂度:O(m × n)。vis 数组占用 O(m × n) 空间,递归调用栈最深为 O(m + n),在最坏情况下沿着边界走到 (m-1, n-1),总空间复杂度为 O(m × n)
2. 斐波那契数
https://leetcode.cn/problems/fibonacci-number/description/
斐波那契数 (通常用 F(n)
表示)形成的序列称为 斐波那契数列 。该数列由 0
和 1
开始,后面的每一项数字都是前面两项数字的和。也就是:
F(0) = 0,F(1) = 1 F(n) = F(n - 1) + F(n - 2),其中 n > 1
给定 n
,请计算 F(n)
。
示例 1:
输入:n = 2 输出:1 解释:F(2) = F(1) + F(0) = 1 + 0 = 1
示例 2:
输入:n = 3 输出:2 解释:F(3) = F(2) + F(1) = 1 + 1 = 2
示例 3:
输入:n = 4 输出:3 解释:F(4) = F(3) + F(2) = 2 + 1 = 3
提示:
0 <= n <= 30
2.1 题目解析
题目本质
这是一个经典的递推序列计算问题,本质是"根据已知项推导第n项"。每一项都依赖前两项,形成一个链式依赖关系。
常规解法
最直观的想法就是直接按照定义写递归:F(n) = F(n-1) + F(n-2)。看起来代码简洁优雅,直接翻译数学定义。
问题分析
但直接递归会产生大量重复计算。比如计算F(5)时,F(3)会被计算多次(通过F(5)→F(4)→F(3)和F(5)→F(3)两条路径)。时间复杂度是O(2^n),当n=30时会有上亿次计算,完全无法接受。
思路转折:要想高效 → 必须避免重复计算 → 有三条路:
-
记忆化递归:用数组缓存已计算的值,每个F(i)只算一次
-
动态规划:自底向上迭代,从F(0)、F(1)开始逐步推到F(n)
-
空间优化:发现每次只需要前两个数,可以用滚动变量代替数组
这样就能把O(2^n)降到O(n),把指数级灾难变成线性小菜一碟。
2.2 解法
算法思想
记忆化递归(Memoization),用一个数组memo存储已计算过的斐波那契数。递归时先查表,命中则直接返回;未命中则计算后存入表中。核心公式:
F(0) = 0, F(1) = 1
F(n) = F(n-1) + F(n-2) (n > 1)
i)初始化备忘录数组memo[31],所有值填充为-1(表示未计算)
ii)递归函数dfs(n):
-
边界条件:如果n是0或1,直接返回n
-
查表:如果memo[n] != -1,说明已计算过,直接返回缓存值
-
递归计算:memo[n] = dfs(n-1) + dfs(n-2)
-
返回结果
易错点:
-
数组大小要开到n的最大值+1(这里是31),否则索引越界
-
初始化标记值要用-1而不是0,因为F(0)本身就是0,会导致混淆
-
边界条件要同时处理n=0和n=1,缺一不可
2.3 代码实现
class Solution {int[] memo;public int fib(int n) {memo = new int[31];Arrays.fill(memo, -1); return dfs(n);}public int dfs(int n){if(n == 0 || n ==1) return n;if(memo[n] != -1){return memo[n];}memo[n] = dfs(n-1) + dfs(n-2);return memo[n];}
}
复杂度分析:
-
时间复杂度:O(n)。每个斐波那契数F(i)只会计算一次,共n+1个数。
-
空间复杂度:O(n)。备忘录数组占用O(n)空间,递归调用栈深度为O(n)。
3. 记忆化递归 vs 动态规划
一体两面的优化艺术、同根同源,两者本质上是完全相同的优化思想——都是通过"空间换时间"来避免重复计算。核心都是:
-
识别出子问题的重叠性
-
用表格(数组/哈希表)存储已计算的结果
-
后续遇到相同子问题直接查表返回
可以说:记忆化递归 = 递归 + 缓存,动态规划 = 迭代 + 缓存。它们只是实现形式不同,算法本质完全一致。
核心区别:自顶向下 vs 自底向上
记忆化递归(Memoization) | 动态规划(DP) | |
---|---|---|
执行方向 | 自顶向下(Top-Down) | 自底向上(Bottom-Up) |
思维方式 | 从目标出发,递归分解问题 | 从基础情况出发,逐步构建 |
计算范围 | 只计算需要的子问题(按需计算) | 计算所有子问题(全面覆盖) |
实现方式 | 递归函数 + 备忘录数组 | 循环迭代 + DP数组 |
调用栈 | 有递归栈开销(O(n)深度) | 无递归栈开销 |
代码风格 | 更接近数学定义,直观 | 需要理解状态转移,稍抽象 |
形象理解:
-
记忆化递归:像从山顶往下走,遇到岔路就分叉探索,走过的路做标记不再重复。代码直观易写,按需计算省空间,但有递归栈开销风险。
-
动态规划:像从山脚往上爬,一步步搭建台阶,最终到达山顶。性能更优更稳定,易于空间优化,但需要提前设计状态转移逻辑。