整数划分问题
整数划分是数论中的一个概念,它指的是将一个正整数写成一系列正整数之和的方式。在整数划分问题中,这些正整数的顺序不被考虑,即只关注组成部分的数字而不关注它们的排列顺序。
例如,整数4可以有以下五种划分方式:
- 4
- 3 + 1
- 2 + 2
- 2 + 1 + 1
- 1 + 1 + 1 + 1
可以看出,比如划分2+1+1和1+2+1在整数划分中被认为是相同的,因为它们包含的数字是一样的。
整数划分问题在组合数学中有着重要的地位,与之相关的还有很多有趣的问题和定理,例如,它和分拆函数(partition function)紧密相关,分拆函数用来表示一个正整数的划分数目。整数划分还与费马的大定理、杨辉三角、生成函数等数学领域有所交叉。
研究整数划分不仅仅是纯粹数学的兴趣,它在计算机科学、物理学(比如在统计力学的模型中)等领域也有广泛的应用。
动态规划是解决整数划分问题的一种有效方法,它通过将问题分解成较小的子问题来解决大问题,并存储子问题的解以避免重复计算。
在整数划分问题中,我们可以用动态规划自底向上构建一个表格,记录从最小的整数开始到目标整数的所有划分情况。如果学过y总的课就知道这一步其实就是在进行集合划分,或者说状态转移,即我们需要求解的数据,可以由那些情况转移而来。
状态表示: 设 dp[n][k]
表示整数 n
使用不大于 k
的数的划分方式数量。
状态转移方程(集合划分):
dp[n][k] = dp[n][k-1] + dp[n-k][k]
这个方程的含义是:整数 n
的划分可以分为两类:
- 不包含任何
k
的划分。 - 包含至少一个
k
的划分。
对于第一类,即整数 n
的所有不包含 k
的划分数量,这就相当于 dp[n][k-1]
。对于第二类,我们可以先取一个 k
,然后计算剩余部分 n-k
的划分数量,这就是 dp[n-k][k]
。
基于上述思路,动态规划的过程如下:
-
初始化:没有初始化的思路就看看转移方程,最初始是什么样子
dp[1][1] = dp[1][0] + dp[0][1]
,然后在思考一下这些代表的含义:dp[1][1]即整数1,使用不大于1的整数,的可能划分方法数量
。dp[1][0]即整数1,使用不大于0的整数,的可能划分方法数量
。dp[0][1]即整数0,使用不大于1的整数,的可能划分方法数量
。显然dp[1][0]
不存在,即为0。dp[0][1]
永远为1,即不选这一种情况。同理得出dp[0][i]=1
,这就是初始化。 -
填表:按照从小到大的顺序填写
dp
表。对于每个n
(从1
到目标整数)和每个k
(从1
到n
),根据状态转移方程计算dp[n][k]
的值。 -
结果获取:整数
n
的划分数量等于dp[n][n]
,因为这表示使用不大于n
的所有正整数的划分方式数量。
举例来说,如果我们要计算 4
的划分数量,我们会计算出以下的 dp
表:
n\k | 1 2 3 4
----------------
1 | 1 1 1 1
2 | 1 2 2 2
3 | 1 2 3 3
4 | 1 3 4 5
从表中可以看出,整数 4
的划分数量是 5
,对应于 dp[4][4]
的值。
通过这种自底向上的方式,动态规划不仅能够解决整数划分问题,还能够有效地处理更复杂的变体,例如限制划分中数字的最大值、最小值或者特定的数字集合等。
让我们再来看看状态转移这个方程:dp[n][k] = dp[n][k-1] + dp[n-k][k]
。思考一下能不能优化?
交换一下n,k
试试:dp[k][n] = dp[k-1][n] + dp[k][n-k]
。有没有发现它的第一维,分别是k,k-1,k
,在二维表格中,第一维其实就是每一层嘛,也就是说第k层
的至多用到了k-1层
和k层
的数据,咱们是不是可以使用滚动数组来优化嘞。
C++代码如下:
#include <iostream>
#include <vector>
using namespace std;
const int MOD = 1000000007;//这个看题目要求,有些划分种数太大会让你取模的
int main() {
int n;
cin >> n;
// 创建一个一维数组,因为每个状态dp[i]只依赖于dp[i]和dp[i-j]的值
vector<int> dp(n + 1, 0);
// 初始化,只有一种方法划分出和为0
dp[0] = 1;
// 动态规划填表过程
for (int i = 1; i <= n; ++i) {
for (int j = i; j <= n; ++j) {
// 状态转移方程
dp[j] = (dp[j] + dp[j - i]) % MOD;
}
}
// 输出结果
cout << dp[n] << endl;
return 0;
}
看到这里,相信你会经历这样一个过程:先是觉得状态表示、初始化啥的有点抽象,然后再看到代码又觉得特别简单。但这显然还没有达到我的目的,如果仅是这样也不值得专门写一篇文章了,诸位请往后看。
我若说这其实是一个完全背包问题
阁下又该如何应对呢?
假设我有一个背包容量为n,物品体积分别为1,2,3~n。不考虑价值,只考虑放满这个背包的物品拿法。每个物品无限个,可以重复拿。
能来看整数划分问题想必对经典的背包问题应当有所了解(若是不清楚可以移步我的另一篇文章背包问题之完全背包),接着往下看~
状态表示: 设 f[i][j]
表示从前i
个物品(数字)中选,总和(数字和)恰好为j
的所有选法。
状态转移方程(集合划分):
f[i][j] = f[i-1][j] + dp[i][j-i]
和上面或者说完全背包一样,第f[i][j]
这个状态,可以由——第i
个数字,不选
或者至少选一个
,这两种状态转移过来。
C++代码
#include <iostream>
using namespace std;
const int MOD = 1000000007;
const int N=1010;
int n;
int f[N][N];
int main() {
cin>>n;
for(int i=1;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];
if(j>=i) f[i][j]+=f[i][j-i];
f[i][j]%=MOD;
}
}
cout<<f[n][n];
return 0;
}
初始化: 前i
个数字中选,总和恰好为0的选法,一个不选就行,即存在一种方法。for(int i=1;i<=n;i++) f[i][0]=1;
再以二维的视角来理解一下这个初始化,其实就是把这张二维表格的第一列初始化了,为啥嘞?你看状态转移方程,每次只会用到(i,j)
上方即(i-1,j)
以及左侧的(i,j-i)
这两个数据。这也算是从含义和计算顺序两个角度来理解初始化叭。
总之,把这个当作完全背包来看待我是万万没想到啊~这也是我为什么特意写一篇文章的原因了。属于是对以前学过的东西活学活用麻了,y总tql。