题目描述
给定一个正整数n
,求n
可以被划分成若干个正整数之和的不同方式的总数。划分中的数需要满足非递增顺序,即n = n1 + n2 + ... + nk
且n1 ≥ n2 ≥ ... ≥ nk
,k ≥ 1
。
输入格式
单行输入,包含一个正整数n
。
输出格式
单行输出,包含一个整数,表示n
的不同划分方式的总数。由于答案可能非常大,需要对10^9+7
取模后输出。
数据范围
1 ≤ n ≤ 1000
输入样例
5
输出样例
7
题解思路
方法一:深度优先搜索 (DFS) + 记忆化
核心思想:
这种方法使用递归的方式来遍历所有可能的整数划分方案,并使用记忆化来存储已经计算过的结果,从而避免重复计算。
状态表示:
f[i][j]
表示使用前i
个整数(1
到i
)凑成总和为j
的方案数。
状态转移:
-
如果不使用整数
i
,则问题变为在前i-1
个整数中凑成j
的方案数,即f[i-1][j]
。 -
如果使用整数
i
,则问题变为在前i
个整数中凑成j-i
的方案数,即f[i][j-i]
。
因此,f[i][j] = f[i-1][j] + f[i][j-i]
。
边界条件:
-
f[i][0] = 1
,因为凑成总和为0
只有一种方案,即不使用任何整数。 -
f[0][j] = 0
,没有整数可以用来凑成非零总和。
实现:
使用二维数组f[i][j]
表示使用前i
个整数构成总和j
的方案数。通过递归调用dfs(i, j)
探索所有可能的划分,其中i
表示当前考虑的整数,j
表示剩余需要凑成的总和。
import java.util.*;
public class Main {
static int N = 1010, mod = (int)1e9 + 7;
static int[][] f = new int[N][N]; // 记忆化数组,存储中间结果
public static void main(String[] args) {
Scanner sca = new Scanner(System.in);
int n = sca.nextInt();
System.out.println(dfs(n, n)); // 从最大数开始进行DFS搜索
}
public static int dfs(int i, int j) {
if (j == 0) return 1; // 如果凑成的总和为0,则认为找到了一种方案
if (j < 0 || i < 1) return 0; // 如果总和变为负数或没有数可用,则无法凑成
if (f[i][j] != 0) return f[i][j]; // 如果这个状态已经计算过,直接返回结果,避免重复计算
// 状态转移:不使用当前数i或使用当前数i
return f[i][j] = (dfs(i - 1, j) + dfs(i, j - i)) % mod;
}
}
方法二:动态规划(一维数组)
核心思想:
这种方法使用一维数组f[j]
来存储凑成总和为j
的方案数,通过迭代的方式更新f[j]
的值。
状态表示:
f[j]
表示凑成总和为j
的方案数。
f[i][j] = f[i - 1][j] + f[i - 1][j - i] + f[i - 1][j - i * 2] +...+f[i - 1][j - i * n]
f[i][j - i] = f[i - 1][j - i] + f[i - 1][j - i * 2] +...+f[i - 1][j - i * n]
综上: f[i][j] = f[i- 1][j] + f[i][j - i]
进一步: f[j] = f[j] + f[j - i]
状态转移:
对于每个整数i
(从1
到n
),更新f[j]
为f[j] + f[j-i]
,表示可以通过添加i
到已有的凑成j-i
的方案中来得到凑成j
的新方案。
初始化:
-
f[0] = 1
,表示凑成总和为0
的方案数为1
。
实现:
初始化f[0] = 1
,然后对于每个整数i
(从1
到n
),依次更新f[j]
为f[j] + f[j-i]
,表示可以通过将i
加到总和为j-i
的方案中来得到总和为j
的方案。
import java.util.*;
public class Main {
static int N = 1010, mod = (int)1e9 + 7;
static int[] f = new int[N]; // 一维DP数组,f[j]表示凑成总和为j的方案数
public static void main(String[] args) {
Scanner sca = new Scanner(System.in);
int n = sca.nextInt();
f[0] = 1; // 初始化,凑成总和为0的方案数为1
for (int i = 1; i <= n; i++) { // 遍历所有的数
for (int j = i; j <= n; j++) { // 更新凑成总和为j的方案数
f[j] = (f[j] + f[j - i]) % mod; // 状态转移方程
}
}
System.out.println(f[n]); // 输出凑成总和为n的方案数
}
}
方法三:动态规划(二维数组)
核心思想:
这种方法使用二维数组f[i][j]
来表示在前i
个整数中使用恰好j
个整数凑成的方案数。它考虑了每个整数使用次数的限制。
状态表示:
f[i][j]
表示在前i
个整数中使用恰好j
个整数凑成的方案数。
状态转移:
-
如果最后一个数至少为
1
,则从f[i-1][j-1]
转移而来,表示从前i-1
个整数中使用j-1
个整数的方案数中再加上1
。 -
如果最后一个数大于
1
,则从f[i-j][j]
转移而来,表示从前i-j
个整数中使用j
个整数的方案数中再减去j
以保持总数不变。
初始化:
-
f[0][0] = 1
,表示没有整数用来凑成总和0
的方案数为1
。
实现:
通过考虑最后一个数的值,将状态转移分为两部分:最后一个数至少为1
的情况(从f[i-1][j-1]
转移)和最后一个数大于1
的情况(从f[i-j][j]
转移)。
import java.util.*;
public class Main {
static int N = 1010, mod = (int)1e9 + 7;
static int[][] f = new int[N][N]; // 二维DP数组,f[i][j]表示在前i个数中使用恰好j个数凑成的方案数
public static void main(String[] args) {
Scanner sca = new Scanner(System.in);
int n = sca.nextInt();
f[0][0] = 1; // 初始化,没有数用来凑成总和0的方案数为1
for (int i = 1; i <= n; i++) { // 遍历所有的数
for (int j = 1; j <= i; j++) { // 遍历可能使用的数的数量
f[i][j] = (f[i - 1][j - 1] + f[i - j][j]) % mod; // 状态转移方程
}
}
int res = 0;
for (int i = 1; i <= n; i++) res = (res + f[n][i]) % mod; // 累加所有使用不同数量的数凑成总和为n的方案数
System.out.println(res);
}
}
方法四:评论区大哥的思路
思路:
将问题转化为完全背包问题的形式,用f[i][j]
表示在前i
个数里凑成总和为j
的方案数。状态转移包含选择i
和不选择i
两种情况,避免直接考虑选择i
的次数。
状态f(i, j)
表示在前i
个数里凑成总和为j
的方案数。这里有两个选择:
-
选择
i
:如果选择了i
,则问题转化为了“在前i
个数里凑成j-i
的方案数”,因为我们已经使用了一个i
。 -
不选
i
:如果不选择i
,则问题转化为“在前i-1
个数里凑成j
的方案数”。
这种状态表示和转移的方法,简化了问题的复杂度,因为它不需要直接考虑“选择了几个i
”这个问题。相反,这个信息被隐式地包含在状态转移过程中。
实现:
状态转移方程可以写为:f(i, j) = f(i-1, j) + f(i, j-i)
。这个方程反映了两种选择——不选择i
(f(i-1, j)
)和选择i
(f(i, j-i)
)。
隐式考虑选择数量
提到的关键点是,在从f(i, j-i)
转移过来的时候,已经隐式地考虑了是否选择i
。这意味着,即使我们没有显式地追踪每个i
被选择的次数,这些信息仍然被包含在了动态规划的过程中。这种方法避免了复杂的数学推导和额外的计算,使得整个解法更加高效和直观。
对应的完全背包问题
在完全背包问题中,每种物品可以被无限次选择,而整数划分问题可以被看作是一种特殊的完全背包问题,其中“物品”的大小和价值都是其索引值。这种解法将整数划分问题视为每个数可以被无限次选择的背包问题,从而简化了问题的求解过程。
import java.util.*;
public class Main {
static int N = 1010, mod = (int)1e9 + 7;
static int[][] f = new int[N][N]; // 二维DP数组,f[i][j]表示在前i个数里凑成j的方案数
public static void main(String[] args) {
Scanner sca = new Scanner(System.in);
int n = sca.nextInt();
// 初始化,凑成总和为0的方案数为1
for (int i = 0; i <= n; i++) {
f[i][0] = 1;
}
// 动态规划填表
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
f[i][j] = f[i - 1][j]; // 不选i的情况
if (j >= i) {
f[i][j] = (f[i][j] + f[i][j - i]) % mod; // 选择i的情况
}
}
}
// 结果是f[n][n],因为我们要在前n个数里凑成总和为n的方案数
System.out.println(f[n][n]);
}
}
QA
1.“为什么方案四的初始化和同样是二维的方法三不一样?”
方法三和方法四虽然都使用了二维动态规划数组,但它们的状态定义和状态转移方程有所不同,这导致了它们的初始化方式也不同。
方法三的核心思想:
在方法三中,f[i][j]
表示的是在前i
个整数中使用恰好j
个整数凑成的方案数。这里的状态转移考虑的是最后一个数至少为1
(从f[i-1][j-1]
转移)和最后一个数大于1
(从f[i-j][j]
转移)。因此,方法三的初始化主要集中在f[0][0] = 1
上,这表示没有使用任何整数凑成总和为0
的方案数为1
。其他状态默认为0
,因为在没有开始分配数字之前,没有有效的划分方法。
方法四的核心思想:
在方法四中,f[i][j]
表示在前i
个整数里凑成总和为j
的方案数。这里的状态转移更直接一些:考虑选择或不选择当前的整数i
。因此,方法四的初始化需要确保f[i][0] = 1
对所有i
成立,这表示用任意个数凑成总和为0
的方案数总是1
(即不使用任何数的方案)。这样的初始化为后续的状态转移提供了基础,确保了从f[i][j-i]
(即选择i
)的转移是有效的。
不同之处:
-
方法三关注的是“使用恰好
j
个整数”的方案数,其初始化主要是设置没有使用任何整数凑成总和为0
的基础情况。 -
方法四则是更灵活地考虑“在前
i
个整数中凑成总和为j
”的方案数,其初始化需要确保任意使用i
个数(包括0
个数)凑成0
总和的方案数为1
,以便为状态转移提供正确的起点。
这种初始化的差异直接反映了两种方法在状态表示和状态转移上的不同思路和侧重点。
2.“那这么看来,方法四和方法一的思想有共同点咯?”
方法四和方法一(深度优先搜索 + 记忆化)在思想上确实有一定的共同点。它们都涉及到考虑“选择”和“不选择”当前整数的思路,但在实现方式和细节上有所区别。
方法一(DFS + 记忆化)的共同点:
-
选择与否: 在DFS方法中,对于每个整数
i
,我们都面临一个选择:是将i
包含在当前的整数划分中,还是不包含i
。这与方法四中的思想相似,即每次状态转移时考虑“选择i
”和“不选择i
”两种情况。 -
递归实现: 方法一通过递归函数实现选择与否的决策过程,每一层的递归都对应于一个决策点,这在概念上与方法四中通过迭代进行的状态转移相似。
-
记忆化: 方法一中的记忆化用于存储和重用已经计算过的状态,这样可以避免重复计算相同状态。虽然方法四是迭代实现的,但其使用的动态规划数组本质上也是一种记忆化手段,存储中间结果以供后续使用。
方法四(改进的动态规划)的区别:
-
迭代 vs. 递归: 尽管两种方法在概念上有相似之处,但方法四通过迭代的方式填充动态规划表,而方法一使用递归和记忆化的方式解决问题。迭代通常在空间效率上更优,且易于理解和实现。
-
状态定义: 方法四中的状态定义
f[i][j]
更加直观,表示在前i
个整数中凑成总和为j
的方案数,无需递归地考虑每个整数的包含与否,而是通过迭代直接更新状态。 -
效率和实用性: 方法四的动态规划通常在实际应用中更高效,特别是对于较大的
n
值。DFS + 记忆化在某些情况下可能会遇到栈溢出的问题,而且递归的开销通常比迭代大。
总的来说,尽管方法一(DFS + 记忆化)和方法四(改进的动态规划)在解题思想上有共通之处,即考虑每个数的“选择”和“不选择”,但它们在实现方式、效率和适用场景上各有特点和优势。