[DFS BFS 动态规划] 279. 完全平方数(回溯法DFS → 动态规划、BFS、数学定理+排除法)
279. 完全平方数
题目链接:https://leetcode-cn.com/problems/perfect-squares/
分类:
- 回溯法:DFS思想,一趟DFS返回一个划分方案所需要的最少完全平方数个数(思路1);
- 动态规划:辅助数组优化回溯法,dp[i] = 数字 i 可拆分的最少完全平方数个数(思路2);
- BFS:用BFS树来理解,数字看做BFS树的节点,所有 <= 父节点的完全平方数看做边,父节点 - 边的结果作为父节点的孩子节点(思路3);
- 数学:排除法、四平方定理、 n = (4^a)*(8b+7)则n至少可以拆分成4个数的平方和(思路4)。
思路1:回溯法DFS(暴力解)
对于数字n,我们枚举所有小于n的完全平方数,拿n与这些完全平方数依次相减。
一趟DFS就是选择一个完全平方数和n相减,然后拿得到的相减结果传入下一层递归,直到n==0,这一趟DFS就是对n的一种划分方案,每一趟DFS都会返回一个划分方案所使用的元素个数。
我们取所有划分方案所使用的完全平方数个数的最小值,返回即可。
设计递归函数:
-
返回值:返回对n划分的最少完全平方数个数;
-
递归出口:如果n == 0,返回0
-
递归主体:如果n != 0,for循环枚举所有小于n的完全平方数,以每个完全平方数 i 为起点进行一趟DFS,把 n - i 传入下一层递归,递归函数返回的是划分 n - i 所需的最少完全平方数个数,再加上这一层所做的划分(n - i)也使用了一个完全平方数,就得到n的其中一个划分方案的最少完全平方数个数。
取n的所有划分方案里完全平方数最少的数值返回。
class Solution {
public int numSquares(int n) {
return dfs(n);
}
//dfs回溯法递归实现
public int dfs(int n){
if(n == 0) return 0;
int min = Integer.MAX_VALUE;
for(int i = 1; i * i <= n; i++){
//寻找所有划分方案里完全平方数个数的最小值
min = Math.min(min, dfs(n - i * i) + 1);
}
return min;
}
}
- 存在的问题:效率过低,用例[55]超时。
思路2:动态规划(推荐)
创建一个dp数组存放组成每个数字的最少完全平方数个数。
dp数组初始化:
置所有小于n的完全平方数对应的dp值=1,例如:dp[1]=1,dp[4]=1,dp[9]=1,…
dp数组的构造过程:
例如:n=12,要求dp[12]
其中,小于n的完全平方数有1,4,9,先拆分得:12=9+3,dp[9]+dp[3]=1+3=4,置min=4(计算dp[12]时dp[3]已经计算完毕);
选择下一个拆分方案:12=4+8,dp[4]+dp[8]=3,3<min,所以更新min=3;
选择下一个拆分方案:12=1+11,dp[1]+dp[11] > min,所以min保持不变;
所有小于12的完全平方数都处理完毕,取其中的最小值min作为dp[12]的值。
class Solution {
public int numSquares(int n) {
int[] dp = new int[n + 1];
//dp数组初始化
for(int i = 1; i * i <= n; i++){
dp[i * i] = 1;
}
//构造dp数组
for(int i = 1; i <= n; i++){
//寻找<=i的所有完全平方数
int min = Integer.MAX_VALUE;
for(int j = 1; j * j <= i; j++){
min = Math.min(dp[j * j] + dp[i - j * j], min);
}
dp[i] = min;
}
return dp[n];
}
}
- 时间复杂度:O(N√N),为求dp[n]需要处理dp[i=1~n]共O(N),对于每个dp[i],都遍历 <= i 的所有完全平方数,所以需要O(√N),所以整体时间复杂度为O(N√N);
- 空间复杂度:O(N)
思路3:BFS(推荐)
思路1是DFS,每一趟DFS在当前递归层选择一个完全平方数与n相减,进入下一层递归又选择一个完全平方数与新的n相减,直到n==0才得到n的一种划分方案。
而BFS树如下图:(图片来源:详细通俗的思路分析,多解法 by windliang)
BFS树中,把各个数字看做BFS树的节点,各个边就是拿<=n的所有完全平方数分别与n相减,相减的结果作为当前节点的孩子节点。
灰色节点表示该数字曾经出现过,但之前处理该数字时并没有得到最优解,所以再次遇到该数字时可以直接跳过。同一个数字只处理一次,达到剪枝的效果。
寻找完全平方数最少的划分方案就是做BFS,寻找最早出现n = 0的所在层数。
BFS的实现需要用到队列,且可能在BFS过程中得到重复的n,所以再使用一个set存放出现过的数字以便去重剪枝。
class Solution {
public int numSquares(int n) {
Queue<Integer> queue = new LinkedList<>();
Set<Integer> set = new HashSet<>();
queue.offer(n);
set.add(n);
int level = 0;//记录当前遍历节点所在的层数,即已使用的完全平方数个数
//BFS流程
while(!queue.isEmpty()){
int size = queue.size();
level++;
//处理当前层的所有节点
for(int i = 0; i < size; i++){
int num = queue.poll();
//每个数字都与所有<=它的完全平方数相减,相减结果作为孩子节点加入队列
for(int j = 1; j * j <= n; j++){
int temp = num - j * j;
//如果num减为0,当前所在层数就是最少的完全平方数个数
if(temp == 0) return level;
//如果num曾经出现过,说明它一定不是最佳路径上的节点,直接跳过
if(set.contains(temp)) continue;
queue.offer(temp);
set.add(temp);
}
}
}
return -1;
}
}
思路4:数学(记下结论就行)
四平方定理:任何正整数都一定能拆分成不超过4个数的平方和,所以组成一个正整数的最少完全平方数个数只可能是1,2,3,4中的一个。
因此我们可以用排除法找出最终的答案:
- 判断num本身是不是某个数的平方,如果是,则答案就是1;如果不是,答案剩下2,3,4
- 还存在另一个定理:如果一个数 = (4^a)*(8b+7),那么它至少可以拆分4个数的平方和,所以如果num = (4^a)*(8b+7),那么答案就是4,如果不是,答案剩下2,3;
- 如果答案是2,则n=a2+b2,我们可以枚举所有小于n的完全平方数作为a,判断n-a^2是不是另一个数的平方,如果是则答案为2;
- 排除了其他三种可能,则答案只可能是3.
如何判断一个数num是不是某个数的平方?
使用sqrt计算num的平方根,并将平方根从float转成int,如果num不是平方数,则得到的平方根从float转int时就会存在精度丢失,我们再判断平方根*平方根是否==num,如果相等,说明num是某个数的平方,如果不是,说明num不是某个数的平方。
//数学法(其他思路的代码见笔记)
class Solution {
public int numSquares(int n) {
//判断答案是否为1
if(isSquare(n)) return 1;
//判断答案是否为4
int temp = n;
while(temp % 4 == 0){
temp /= 4;
}
if(temp % 8 == 7) return 4;
//判断答案是否为2
for(int i = 1; i * i <= n; i++){
if(isSquare(n - i * i)) return 2;
}
//最后只剩下3
return 3;
}
//判断一个数是不是某个数的平方
public boolean isSquare(int num){
int sqrt = (int)Math.sqrt(num);
return sqrt * sqrt == num;
}
}