完全平方数
题目
Leetcode
给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, …)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。
示例 1:
输入: n = 12
输出: 3
解释: 12 = 4 + 4 + 4.
示例 2:
输入: n = 13
输出: 2
解释: 13 = 4 + 9.
解决方案
递归暴力破解法
这个解法会超出时间限制。但是后续的算法是对该算法的一个提升。
原理:
n
u
m
S
q
u
a
r
e
s
(
n
)
=
min
(
n
u
m
S
q
u
a
r
e
(
n
−
s
q
u
a
r
e
)
+
1
)
numSquares(n) = \min(numSquare(n-square)+1)
numSquares(n)=min(numSquare(n−square)+1)
square是所有的平方数。即1,4,9,16····
这个公式很好理解,例如我们想要求13的最小完全平方的个数,那么
13 = 12 + 1,即我们只需要知道12的最少完全平方个数在加上1
13 = 9 + 4, 即我们需要求9的最少完全平方个数在加1
13 = 4 + 9 …略
13可以改成上述三个组合(第二个加数必须是完全平方数k,第一个加数是13-k算出来的),然后比较上面3个结果,选择最小的。
显然这是个递归的问题。
class Solution {
private static int[] squareNums ;
private boolean isCalcuSquare = false;
public int numSquares(int n) {
// 暴力破解递归法
if(!isCalcuSquare) {
// +1是为了和index对应,好理解。
squareNums = new int[(int)Math.sqrt(n) + 1];
for(int i = 0; i<squareNums.length; i++) {
squareNums[i] = i*i;
}
isCalcuSquare = true;
}
//递归出口
for(int square : squareNums) {
if(square == n) {
return 1;
}
}
int ans = Integer.MAX_VALUE;
// 不要遍历0,因为里面存的是0,造成死循环
for(int i=1; i<squareNums.length; i++) {
if( n < squareNums[i]) {
// 如果当前计算的数小于square,就退出
continue;
}
int newAns = numSquares(n-squareNums[i]) + 1;
ans = Math.min(ans, newAns);
}
return ans;
}
}
动态规划
解决递归运行速度慢的一个思路就是使用DP(动态规划)。基本思想是将中间的结果存储起来,下次使用的时候就不需要重新进行计算。是利用空间来换取时间的思想。
递归做了很多的重复计算。
我们把算过的存在一个数组中,那么下次在使用时就可以直接读取,避免了再次递归。
利用的公式仍然为
n
u
m
S
q
u
a
r
e
s
(
n
)
=
min
(
n
u
m
S
q
u
a
r
e
(
n
−
s
q
u
a
r
e
)
+
1
)
numSquares(n) = \min(numSquare(n-square)+1)
numSquares(n)=min(numSquare(n−square)+1)
square是所有的平方数。即1,4,9,16····
/**
参考:https://leetcode-cn.com/problems/perfect-squares/solution/wan-quan-ping-fang-shu-by-leetcode/
*/
class Solution {
public int numSquares(int n) {
// 用来存储结果,+1为了让index和结果对应
int [] ansArray = new int[n+1];
// 初始值设置为最大
Arrays.fill(ansArray,Integer.MAX_VALUE);
ansArray[0] = 0;
// 初始化平方数组
// +1 为了对齐
int[] squareNums = new int[(int)Math.sqrt(n) + 1];
for(int i = 0; i<squareNums.length; i++){
squareNums[i] = i*i;
}
// 从头开始计算 结果数组
for(int i = 1; i<=n; i++) {
for(int j=1; j<squareNums.length;j++) {
// 要计算的结果比平方数小,则结束平方数
int square = squareNums[j];
if(i<square ) {
break;
}
/* 关键步骤 */
// 目标值:i
// square一定小于等于i,所以 i-square的值,在先前的迭代中一定已经计算过了,直接使用就可以
int newNums = ansArray[i - square] + 1; //在i,j都等1时,这句话会取到ansArray[0],所以置零
// 将最小的存到结果中。
ansArray[i] = Math.min(ansArray[i], newNums);
}
}
return ansArray[n];
}
}
注意:
- 计算n时,实际上是从1开始计算,一直计算到n,把n之前的所有结果都算了一遍,在算i时,会用到i之前的结果。并保存到了ansArray中。
- 时间复杂度:外循环n次,内循环需要次数最多为i = n时,是 n \sqrt n n次,所以最终时间复杂度 O ( n n ) O(n\sqrt n) O(nn)
- 空间复杂度:n+ n \sqrt n n 长度数组,故空间复杂度为 O ( n ) O(n) O(n)
递归贪心
主要思想是构造一个函数isDivied(n, count)。这个函数用来判定n是否能够通过count个平方数来构造。如果有这个函数,我们就从count=1开始,最先返回true的count值就是我们的结果。
isDivied通过递归设计。如果count == 1,那么n只有可能是平方数,否则不可分。这也是递归的出口。 当count != 1时,我们就开始尝试减去1个平方数,同时count-1来继续调用isDivied。
简而言之,就先看1个行不行,如果不行,在这个基础上两个行不行,所有的两个都不行,那么再尝试三个。
如图,count=1直接返回,count=2时,递归两层,count=3时,递归三层
class Solution {
// 存储平方数。这里使用集合,查找比较快
Set<Integer> squareNums = new HashSet<>();
public int numSquares(int n) {
//清空
this.squareNums.clear();
//初始化平方数
for(int i = 1; i*i <= n; i++){
this.squareNums.add(i*i);
}
// 从1开始遍历。1代表本身就是平方数,2表示可由两个平方数组成。以此类推
int count = 1;
for(; count <=n; count++){
if(isDivided(n,count)){
//如果可以被整分,则就是结果。
return count;
}
}
return count;
}
private boolean isDivided(int n, int count){
//递归出口
if (count==1){
return squareNums.contains(n);
}
// 递归调用,确定count-1是否可分。
for(Integer square: squareNums){
if(square > n){
continue;
}
if(isDivided(n-square, count-1)){
return true;
}
//不能这么写,因为往下还有多种情况,这样只是在返回第一个square的情况。
//return isDivided(n-square, count-1);
}
return false;
}
}
BFS
上述的递归构建了递归树,实际上我们想要寻找层数最少的目标。可以使用广度优先遍历。
class Solution {
// 存储平方数。这里使用集合,查找比较快
public int numSquares(int n) {
List<Integer> squareNums = new ArrayList<>();
Queue<Integer> queue = new LinkedList<>();
queue.add(n);
//初始化平方数
for(int i = 1; i*i<=n; i++){
squareNums.add(i*i);
}
int level = 0;
while(!queue.isEmpty()){
int size = queue.size();
level++;
// 遍历层
while(size>0){
size --;
int tar = queue.poll();
for(int square : squareNums){
if(square > tar){
break;
}
if(square == tar){
return level;
}
queue.add(tar-square);
}
}
}
return level;
}
}
queue使用链表运行时间很长,在leetcode上提交:
通过 261 ms 262.2 MB Java
而按照官方的思路,使用HashSet来存储queue,时间差了很多:
通过 47 ms 39.9 MB Java
// HashSet作为队列
class Solution {
// 存储平方数。这里使用集合,查找比较快
public int numSquares(int n) {
List<Integer> squareNums = new ArrayList<>();
Set<Integer> queue = new HashSet<>();
queue.add(n);
//初始化平方数
for(int i = 1; i*i<=n; i++){
squareNums.add(i*i);
}
int level = 0;
while(!queue.isEmpty()){
Set<Integer> nextQ = new HashSet<>();
level++;
// 遍历层
for(int tar : queue){
for(int square : squareNums){
if(square > tar){
break;
}
if(square == tar){
return level;
}
nextQ.add(tar-square);
}
}
queue = nextQ;
}
return level;
}
}
之所以时间和内存差距这么大,是因为HashSet保证了节点唯一不重复,而LinkedList的队列中会有重复的节点,如图上的第三层,里面的节点7就出现了两次,也就会被重复计算。该现象在数字越大越明显。