题目
给定一个非负整数数组和一个整数 m
,你需要将这个数组分成 m
个非空的连续子数组。设计一个算法使得这 m
个子数组各自和的最大值最小。
注意:
数组长度 n
满足以下条件:
1 ≤ n ≤ 1000
1 ≤ m ≤ min(50, n)
示例:
输入:
nums = [7,2,5,10,8]
m = 2
输出:
18
解释:
一共有四种方法将nums分割为2个子数组。
其中最好的方式是将其分为[7,2,5] 和 [10,8],
因为此时这两个子数组各自的和的最大值为18,在所有情况中最小。
解法一:二分法
解题思路
- 各分组的最大值的最小者
val
肯定在数组nums
的最大值max
,以及nums
数组所有元素之和sums
之间。所以可以在[max, sums)
之间任取一个值val
作为最大值中的最小值。 - 遍历数组
nums
,并且根据最小值val
进行分组,如果值nums[i]
加入到当前分组中使得该分组的和sum
大于val
,说明nums[i]
不属于该分组,则从i
的位置重新分组,sum
的值重置为nums[i]
- 假如最后的分组个数小于等于
m
个,说明val
的值取大了,还可以更小。否则,说明val
值选小了,应该更大。
图解
此时 max = 10
; sums = 32
。
-
选取
[10, 32)
的中间值21
作为各分组内部之和的最小值。遍历数组,以最小值21
作为分组条件,此时可以分成如下:
-
此时满足分组个数小于等于
m
,所以21
的值取大了,还可能更小,所以变成在[10, 21]
中重新取最小值,此时选择15
。同理分组变成如下:
-
此时分组个数为
3
,大于m
,所以15
选小了。重新变成(15, 21]
之间选值,这一步选18
,又可以分成
-
此时分组个数为
2
,选值区间又变成(15, 18]
,选取17
。
-
选值区间变成
[18, 18]
,此时退出循环。 -
输出
18
,即各数组之和的最小值。
代码实现
/**
* @param {number[]} nums
* @param {number} m
* @return {number}
*/
const splitArray = function (nums, m) {
let max = 0;
let sums = 0;
// 获取nums的最大元素max 和 数组元素之和sums
for (let i = 0, len = nums.length; i < len; ++i) {
if (max < nums[i]) {
max = nums[i];
}
sums += nums[i];
}
// 二分法在[max, nums)的选取假设最大值中的最小值val
while (max < sums) {
let mid = Math.floor((max + sums) / 2);
if (checkVal(nums, mid, m)) {
sums = mid - 1;
} else {
max = mid + 1;
}
}
return max;
};
// 判断选取的val值能否满足分组个数小于m个
const checkVal = (nums, val, m) => {
let pieces = 1;
let sum = 0;
for (let i = 0, len = nums.length; i < len; ++i) {
sum += nums[i];
if (sum > val) {
++pieces;
sum = nums[i];
}
}
return pieces <= m;
};
复杂度分析
时间复杂度:O(nlog(sum−max))
,其中 sum
表示数组 nums
中所有元素的和,max
表示数组所有元素的最大值。每次二分查找时,需要对数组进行一次遍历,时间复杂度为 O(n)
,因此总时间复杂度是 O(nlog(sum−max))
。
空间复杂度:O(1)
。
解法二:动态规划
解题思路(@坑人的小书童的思路,我没想到这种解法)
假设 dp[i][j]
表示前 i
个数分成 j
个分组后,各分组之和的最小值。假设改变状态的位置为 k
,即 k
属于最后一个分段。那么其状态转移 dp[i][j]
要存放的值是上一个位置的结果 dp[i-k][j-1]
与最后一个分段(k, i)
之间元素和中的较大值,即转移方程如下:
k
的取值范围在 j-1
(至少前面有 j-1
个分组,每个分组一个元素)到 i
(最后一个元素就是和的最小值),即最后一段(k, i)
的长度为 1
~ i-j+1
。
- 每增加一个元素遍历
m
进行分割,得到每个分割段最大值 - 再将所有分割组合得到的最大值存放到
dp
中,如果之前该位置出现过较小的结果则不替换
边界
dp[0][0]
,0
个数分成0
段默认0
dp[0][0]
被默认占用则dp
需要声明成[len+1][m+1]
的数组- 在分割
nums
是逐个增加元素,存在m
大于当前给定数组的情况,此时遍历分割时,j
的边界为m
与i
中较小的值
代码实现
/**
* @param {number[]} nums
* @param {number} m
* @return {number}
*/
const splitArray = function (nums, m) {
let len = nums.length,
sumList = Array(len + 1).fill(0),
dp = Array.from({
length: len + 1 }, () =>
Array(m + 1).fill(Number.MAX_VALUE)
);
// 逐位增加,反面后面根据区间求区间和
for (let i = 0; i < len; i++) {
sumList[i + 1] = sumList[i] + nums[i];
}
// 默认值
dp[0][0] = 0;
for (let i = 1; i <= len; i++) {
for (let j = 1; j <= Math.min(m, i); j++) {
// 前i个数分成j段
for (let x = j - 1; x < i; x++) {
// x最后一段的起点
// perv本轮分割完成 分段中最大的和
let prev = Math.max(dp[x][j - 1], sumList[i] - sumList[x]);
// 该分割情况下最大分段和的最小值
dp[i][j] = Math.min(prev, dp[i][j]);
}
}
}
return dp[len][m];
};
复杂度分析
时间复杂度:O(n^2m)
,其中 n
是数组的长度,m
是分成的非空的连续子数组的个数。总状态数为 O(nm)
,状态转移时间复杂度 O(n)
,所以总时间复杂度为 O(n^2m)
。
空间复杂度:O(nm)
,为动态规划数组的开销。