问题背景
有一根长度为
n
n
n 个单位的木棍,棍上从
0
0
0 到
n
n
n 标记了若干位置。例如,长度为
6
6
6 的棍子可以标记如下:
给你一个整数数组
c
u
t
s
cuts
cuts,其中
c
u
t
s
[
i
]
cuts[i]
cuts[i] 表示你需要将棍子切开的位置。
你可以按顺序完成切割,也可以根据需要更改切割的顺序。
每次切割的成本都是当前要切割的棍子的长度,切棍子的总成本是历次切割成本的总和。对棍子进行切割将会把一根木棍分成两根较小的木棍(这两根木棍的长度和就是切割前木棍的长度)。请参阅示例以获得更直观的解释。
返回切棍子的 最小总成本 。
示例
输入:
n
=
7
n = 7
n=7,
c
u
t
s
=
[
1
,
3
,
4
,
5
]
cuts = [1,3,4,5]
cuts=[1,3,4,5]
输出:
16
16
16
解释:按
[
1
,
3
,
4
,
5
]
[1, 3, 4, 5]
[1,3,4,5] 的顺序切割的情况如下所示:
第一次切割长度为
7
7
7 的棍子,成本为
7
7
7。第二次切割长度为
6
6
6 的棍子(即第一次切割得到的第二根棍子),第三次切割为长度
4
4
4 的棍子,最后切割长度为
3
3
3 的棍子。总成本为
7
+
6
+
4
+
3
=
20
7 + 6 + 4 + 3 = 20
7+6+4+3=20。
而将切割顺序重新排列为
[
3
,
5
,
1
,
4
]
[3, 5, 1, 4]
[3,5,1,4] 后,总成本为
7
+
4
+
3
+
2
=
16
7 + 4 + 3 + 2 = 16
7+4+3+2=16。
数据约束
- 2 ≤ n ≤ 1 0 6 2 \le n \le 10 ^ 6 2≤n≤106
- 1 ≤ c u t s . l e n g t h ≤ m i n ( n − 1 , 100 ) 1 \le cuts.length \le min(n - 1, 100) 1≤cuts.length≤min(n−1,100)
- 1 ≤ c u t s [ i ] ≤ n − 1 1 \le cuts[i] \le n - 1 1≤cuts[i]≤n−1
- c u t s cuts cuts数组中所有整数都互不相同
解题过程
第一次做区间 DP 的题,这一篇基本上就是对 灵神题解 的解释了。
首先根据题目的意思,考虑切一刀的情况。整个问题的成本等于两个子问题的成本和这一次切割的成本之和。
记一根棍子的状态为
d
f
s
(
i
,
j
)
dfs(i, j)
dfs(i,j),
i
i
i 和
j
j
j 分别表示它的两个端点。如果在位置
k
k
k 处切割,那么两个子问题的成本可以分别描述为
d
f
s
(
i
,
k
)
dfs(i, k)
dfs(i,k)、
d
f
s
(
k
,
j
)
dfs(k, j)
dfs(k,j)。
而切割的成本,可以用
c
u
t
s
[
j
]
−
c
u
t
s
[
i
]
cuts[j] - cuts[i]
cuts[j]−cuts[i] 来刻画。但是
c
u
t
s
cuts
cuts 数组中只包含切割点,没有办法表示初始状态的棍子,所以考虑在数组的头尾分别添加两个元素
0
0
0 和
n
n
n,来表示一开始棍子的两个端点。
到这里,状态转移方程就可以写成
d
f
s
(
i
,
j
)
=
d
f
s
(
i
,
k
)
+
d
f
s
(
k
,
j
)
+
c
u
t
s
[
j
]
−
c
u
t
s
[
i
]
dfs(i, j) = dfs(i, k) + dfs(k, j) + cuts[j] - cuts[i]
dfs(i,j)=dfs(i,k)+dfs(k,j)+cuts[j]−cuts[i]。
递归入口 是
d
f
s
(
0
,
m
−
1
)
dfs(0, m - 1)
dfs(0,m−1),表示整个问题的成本,也是答案。
递归边界 是
i
+
1
=
=
j
i + 1 == j
i+1==j,这时的棍子中间没有切割点,问题的成本为
0
0
0(注意:这里的
i
i
i 和
j
j
j 是下标)。
最后定义一个 m e m o memo memo 数组用于记忆化搜索,防止递归过深炸内存。
递归的做法实现完成之后,可以把它等效地翻译成递推。
答案为
d
p
[
0
]
[
m
−
1
]
dp[0][m - 1]
dp[0][m−1],来自递归入口
d
f
s
(
0
,
m
−
1
)
dfs(0, m - 1)
dfs(0,m−1)。
初始值为
d
p
[
i
]
[
i
+
1
]
=
0
dp[i][i + 1] = 0
dp[i][i+1]=0,来自递归边界
d
f
s
(
i
,
i
+
1
)
=
0
dfs(i, i + 1) = 0
dfs(i,i+1)=0。
具体实现
递归
class Solution {
public int minCost(int n, int[] cuts) {
// 定义新数组,添加初始状态的端点
int m = cuts.length + 2;
int[] newCuts = new int[m];
Arrays.sort(cuts); // 题中没说 cuts 数组有序,需要先排序
System.arraycopy(cuts, 0, newCuts, 1, m - 2);
newCuts[m - 1] = n;
int[][] memo = new int[m][m]; // 定义 memo 数组用于记忆化搜索
return dfs(0, m - 1, newCuts, memo); // 递归入口,也是答案
}
private int dfs(int i, int j, int[] cuts, int[][] memo) {
// 递归边界,没有切割点返回 0
if(i + 1 == j) {
return 0;
}
// 已经存储过的答案直接返回
if(memo[i][j] > 0) {
return memo[i][j];
}
// 在当前问题中寻找最小值
int res = Integer.MAX_VALUE;
for(int k = i + 1; k < j; k++) {
res = Math.min(res, dfs(i, k, cuts, memo) + dfs(k, j, cuts, memo));
}
// 记忆当前问题的结果并返回
return memo[i][j] = res + cuts[j] - cuts[i];
}
}
递推
class Solution {
public int minCost(int n, int[] cuts) {
// 定义新数组,添加初始状态的端点
int m = cuts.length + 2;
int[] newCuts = new int[m];
Arrays.sort(cuts); // 题中没说 cuts 数组有序,需要先排序
System.arraycopy(cuts, 0, newCuts, 1, m - 2);
newCuts[m - 1] = n;
// 定义状态转移数组 dp
int[][] dp = new int[m][m];
// 计算 dp[i][j] 需要先确定 dp[i][k] 且 k 比 i 大,i 需要倒序枚举
for(int i = m - 3; i >= 0; i--) {
// 计算 dp[i][j] 需要先确定 dp[i][k] 且 k 比 i 大,j 需要正序枚举
for(int j = i + 2; j < m; j++) {
int res = Integer.MAX_VALUE;
// 在当前问题中寻找最小值
for(int k = i + 1; k < j; k++) {
res = Math.min(res, dp[i][k] + dp[k][j]);
}
// 记录当前状态
dp[i][j] = res + newCuts[j] - newCuts[i];
}
}
return dp[0][m - 1];
}
}
梳理总结
复杂度分析
灵神的分析里说动态规划的时间复杂度等于状态数量与计算单个状态所需时间的乘积。
在这一题中,状态可以由棍子的两个端点位置来决定,从
m
e
m
o
memo
memo 这个二维数组的规模可以明确地看到它大致是
O
(
m
2
)
O(m ^ 2)
O(m2) 这个量级;而计算单个状态所需时间,主要集中在寻找最小值这个循环的过程中,它大致是
O
(
m
)
O(m)
O(m) 这个量级。
排序的复杂度是
O
(
N
l
o
g
N
)
O(NlogN)
O(NlogN)(注意:
m
=
N
+
2
m = N + 2
m=N+2),因此综合来看,时间复杂度是
O
(
m
3
)
O(m ^ 3)
O(m3)。
所需的额外空间等于状态的数量,在这里表现为
m
e
m
o
memo
memo 数组的大小,空间复杂度是
O
(
m
2
)
O(m ^ 2)
O(m2)。
翻译成递推之后,状态转移方程没有发生变化,实际上时空复杂度也是一致的。
记忆化搜索
其实就是把已经计算完成的答案存储起来,如果后续还有用到,直接从存储的结果中调用。
要注意的是实现记忆化搜索时,一定不能将存储初始化为可能的结果(主要是要注意
0
0
0 是不是可能的结果)。在本题中,所有切割的成本都是正整数,初始化为零即可。