文章目录
- 343.整数拆分
- 思路1:递归法(最直观的想法)
- 递归思路
- 普通递归写法
- 注意点:max的嵌套
- 普通递归存在的问题
- 记忆化搜索+递归写法
- 时间复杂度
- 递归解法总结
- 思路2:动态规划(注意递推的理解)
- 确认DP数组含义
- 递推公式
- 初始化
- 遍历顺序
- DP写法完整版
- dp[i]求最大值为什么还要再写一遍
- 时间复杂度
- 总结:递归和DP递推的区别
- 动态规划和递归
- 状态转移方程的进一步理解
- 递归法与递推的区别
343.整数拆分
给定一个正整数 n
,将其拆分为 k
个 正整数 的和( k >= 2
),并使这些整数的乘积最大化。
返回 你可以获得的最大乘积 。
示例 1:
输入: n = 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1。
示例 2:
输入: n = 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
提示:
- 2 <= n <= 58
思路1:递归法(最直观的想法)
这个问题的目标是找出一个整数n的最大乘积,通过将其拆分为至少两个正整数的和。也就是说,可以出现多个正整数的情况。
递归思路
最直观的思路,我们要找的是一个整数n的最大乘积,可以将这个整数拆分为i和n-i(1 <= i < n)。对于每个i,我们可以选择将n-i继续拆分,也可以选择不再拆分。如果只拆成两个,就是i*(n-i)
。如果拆成多个,就是i*dfs(n-i)
。
所以对于每个i,我们需要比较i*(n-i)
和i*dfs(n-i)
的值,取所有结果中的最大值。
普通递归写法
- 注意找result的最大值,max的比较里一定要加上result本身,要存储for循环遍历中所有的最大值
- 注意(n-i)这样的情况要加括号,不然会发生逻辑错误
class Solution {
public:
int dfs(int n){
//递归终止条件:输入为1,也就是已经拆到1了,直接返回
if(n==1) return 1;
int result=0;
for(int i=1;i<n;i++){
//(n-i)加括号
result = max(result,max(i*(n-i),i*dfs(n-i)));//找到result的最大值,i*n-i是拆两个,i*dfs(n-i)是拆多个
}
return result;
}
int integerBreak(int n) {
return dfs(n);
}
};
注意点:max的嵌套
max()
函数在c++标准库中定义的版本只接受两个参数。也就是说max()
函数在每次调用时只比较两个值,因此,如果要比较三个或更多的值,需要将max()
函数嵌套在一起使用。例如下面:
result = max(result,max(i*n-i,i*dfs(n-i));
max的三个元素比较的嵌套,嵌套任意两个都可以。无论如何嵌套max()
函数,只要确保所有的数值都被比较,就能得到相同的结果。
result = max(i*n-i,max(result,i*dfs(n-i)));//这样写也可以正确运行,也是比较这三个数字
普通递归存在的问题
用上面的写法来写,小用例可以通过,但是较大的用例会发生超时。
这是因为普通递归的思路,我们会多次计算相同值的最大乘积。例如下图情况:
- n/2之后的情况,
i
和(n-i)
的情况是重复的 - dfs内部拆分的时候,类似5 4这样比较小的数字,会被多次拆分,例如我们要拆分9的时候,dfs(5)会拆一次,拆分8的时候,dfs(5)还是会再拆一次(图中绿色横线表示)。比较小的数字,每次拆分大的数字都会重新拆分,同样的拆分操作就会在多次递归调用中重复进行,导致大量的重复计算。
即使我们令i<=2/n做了优化,避免了后面一半的重复,依然会发生超时。
class Solution {
public:
int dfs(int n){
//递归终止条件:输入为1,也就是已经拆到1了,直接返回
if(n==1) return 1;
int result=0;
for(int i=1;i<=n/2;i++){
result = max(result,max(i*(n-i),i*dfs(n-i)));//找到result的最大值,i*n-i是拆两个,i*dfs(n-i)是拆多个
}
return result;
}
int integerBreak(int n) {
return dfs(n);
}
};
记忆化搜索+递归写法
为优化时间复杂度,我们采用记忆化搜索来防止同一个数字被重复拆分。
记忆化搜索原理是使用一个数组memo,用来保存每个整数的dfs拆分结果。当我们要计算一个数的dfs(n)(也就是这个数字拆分后的最大乘积)时,首先检查memo数组,如果已经计算过,就直接返回,否则计算出结果后保存在memo数组中。
这么做可以防止类似dfs(5)在多个较大整数拆分的时候被重新拆的情况。
- 如果memo[n]已经有数值了,不用拆分,直接返回当前的记忆数值(历史拆分结果)
- memo[n]没有数值,才进行拆分的逻辑
class Solution {
public:
//创建记忆数组,初始化为0
vector<int>memo;
int dfs(int n){
//递归终止条件:已经for结束存在memo里面了
//如果memo[n]已经有数值了,不用拆分,直接返回当前的数值(历史拆分结果)
if(memo[n]!=0) return memo[n];
int result=0;
for(int i=1;i<=n/2;i++){
result = max(result,max(i*(n-i),i*dfs(n-i)));//找到result的最大值,i*n-i是拆两个,i*dfs(n-i)是拆多个
}
memo[n]=result;
return result;
}
int integerBreak(int n) {
//memo初始化
memo = vector<int>(n+1,0);//注意memo的重新赋值
return dfs(n);
}
};
进行了记忆化搜索的优化,递归写法就能通过这道题了。
时间复杂度
时间复杂度:O(n²)。递归树的深度为n,每一层最多需要做n次操作(在for循环中比较和更新结果),所以时间复杂度为O(n²)。
空间复杂度:O(n)。我们使用了一个大小为n的一维数组memo来存储已经计算过的结果,所以空间复杂度为O(n)。
递归解法总结
递归做法更加直观也更好理解,核心就是result = max(result, i*(n-i), i*dfs(n-i))
,枚举从1–n的所有i和n-i的乘积结果,找到最大值。(实际上枚举1–n/2即可,i和n-i后半段是重复的)
但是这种做法,dfs(n-i)的拆分里会有大量的重复拆分,例如dfs(5)这个数值,遍历到dfs(9),dfs(8),dfs(7)的时候都会再拆一遍,造成大量时间消耗,会超时。
因此需要靠记忆化搜索进行防止重复拆分的操作,用memo数组存下每一次拆分的最终结果dfs(n)。递归之前检查如果memo数组有这个dfs(n),直接返回memo存的结果。
dfs(n)的含义就是n拆分后得到的因子乘积最大值结果。
思路2:动态规划(注意递推的理解)
动态规划的思想,是当前状态全部由之前的状态推出来。也就是说,如果要获得最大乘积,我们需要让i(1<i<n)
范围内的所有数字,都满足乘积最大化的需求。
确认DP数组含义
DP解法中,dp[i]的含义和递归的dfs(n)是一样的,都是表示数字i/n当前拆分乘积的最大值。i表示从1开始到n所有的数字。
递推公式
递推公式的思想和递归也是相同的。从1遍历j,有两种渠道得到dp[i]。一个是j * (i - j) 直接相乘。一个是j * dp[i - j],相当于是拆分(i - j)。
因此递推公式为:dp[i]=max(dp[i],j*(i-j),j*dp[i-j])
。这个递推公式的含义是,对于i<n范围的所有i,都要满足得到的dp[i]是乘积最大值,这样的话, 遍历到i=n的时候,dp[n]才能是最大值。
初始化
从dp[i]的定义来说,dp[0],dp[1] 是没有意义的数值,是不需要初始化的。因为0和1没有能够拆分相加=i的因子。而且n是正整数,不需要考虑n=0的情况。题目提示里给出了n>=2。
因此,我们只需要初始化dp[2]=1,因为i=2的时候,最大乘积为1(2拆成1+1)。
遍历顺序
这里的遍历顺序仍然是正序,因为需要基于前面的状态来计算当前的状态。
让所有的i都满足输出dp[i]是最大乘积,遍历逻辑如下:
//i初始值是2,第一个从3开始
for(int i=3;i<=n;i++){
//i的因子从1开始
for(int j=1;j<=i-1;j++){
//和递归写法很像,都是取i情况下的最大值
dp[i]=max(dp[i],max(j*(i-j),j*dp[i-j]));
}
}
//结果就是dp[n]
return dp[n];
DP写法完整版
class Solution {
public:
int integerBreak(int n) {
//DP数组建立,注意数组本身容量赋值
vector<int>dp(n+1,0);
//初始化
dp[2]=1;
for(int i=3;i<=n;i++){
for(int j=1;j<=i-1;j++){
dp[i]=max(dp[i],max(j*(i-j),j*dp[i-j]));
}
}
return dp[n];
}
};
dp[i]求最大值为什么还要再写一遍
因为在递推公式推导的过程中,每次计算dp[i],需要取所有dp[i]中的最大值。
和下面引入result的写法是一样的。result完全可以用dp[i]来代替
class Solution {
public:
int integerBreak(int n) {
//DP数组建立,注意数组本身容量赋值
vector<int>dp(n+1,0);
//初始化
dp[2]=1;
for(int i=3;i<=n;i++){
//记录dp[i]最大值
int result=0;
for(int j=1;j<=i-1;j++){
result=max(result,max(j*(i-j),j*dp[i-j]));
}
//最大值存在dp[i]里
dp[i]=result;
}
return dp[n];
}
};
时间复杂度
时间复杂度:O(n²)。因为有两层for循环,每一层最多需要做n次操作(比较和更新dp[i]),所以时间复杂度为O(n²)。
空间复杂度:O(n)。我们使用了一个大小为n的一维数组dp来存储所有状态的结果,所以空间复杂度为O(n)。
总的来说,这两种方法的时间复杂度和空间复杂度都相同,但实际运行效率可能因为具体实现(例如递归的开销、记忆化搜索的查找开销等)和语言特性等因素有所不同。
总结:递归和DP递推的区别
本题中,递归解法和DP解法的核心思路是一样的,都是找到一个整数可以拆分为的两个数的最大乘积,这两个数可以是一个具体的整数和一个可能继续拆分的整数。
不同的是,递归解法是从上到下,对每个整数,都尝试所有可能的拆分方式,然后选择最大的乘积;而DP解法是从下到上,对每个整数,根据已经计算出的较小整数的最大乘积,来推导出当前整数的最大乘积。
动态规划和递归
动态规划(DP)是一种以空间换时间的算法设计技术,通过存储和重用过去的结果,避免了重复计算,从而提高了算法的效率。DP是一种特殊的递推,通常我们会找到一个或一组状态转移方程,用于描述问题之间的关系,然后从基本情况(初始状态)开始,按照这个(这些)方程递推出所有的状态。
递归是一种算法设计技术,它通过将问题分解为更小的子问题来解决问题,而这些子问题与原问题具有相同的形式。通常,递归需要一个或多个基本情况来停止递归。当递归处理的子问题有重叠时(即在递归过程中多次求解相同的子问题),使用递归可能会导致效率低下。这时就可以使用动态规划或记忆化搜索来改进。
状态转移方程的进一步理解
动态规划的状态转移方程,就是在找一个适用于从小到大所有元素的公式,这个公式可以把一个大问题转化为几个小问题的关系。
在整数拆分这个问题中,状态转移方程为:dp[i] = max(j*(i-j), j*dp[i-j])
,这个方程表示,对于一个正整数i,我们要么选择将它拆分为j和i-j(不再拆分i-j),要么选择将它拆分为j和以某种方式继续拆分i-j得到的最大乘积。然后我们遍历所有的j(1 <= j <= i-1),找到能使这个乘积最大的j,即为dp[i]
的值。这样我们就从小到大,依次求出了所有整数的最大乘积。
递归法与递推的区别
DP的从下到上推算,就是从小到大的每一个i,都要推出最大的i的结果,这样到n的时候dp[n]也是最大的。
而递归从上到下,就是从最大的n开始分割,然后递归的寻找所有可能的拆分方式,也就是从大到小一直枚举所有的可能的乘积情况。然后在这些情况中选择乘积最大的。在这个过程中,由于涉及到重复的子问题,所以我们使用记忆化搜索,也就是用一个数组来保存已经计算过的结果,避免了重复计算。这是一种从上到下的方法,我们是从最大的问题开始,然后逐渐深入到更小的子问题。
两者的区别在于求解的顺序和重复子问题的处理方式,但是解决的问题和达到的目的是相同的。