题目描述
原题链接: LeetCode.410 分割数组的最大值
解题思路
- 分割的是连续子数组, 所以模拟分割求子数组之和可以利用前缀和计算;
- 设前缀和数组preSume[i]含义为[0,..,i]范围元素之和, 某个子数组subArray[i, j]的元素和就是preSum[j] - preSum[i];
- 求K个子数组元素和的最大值能转换为前K-1个子数组和的最小值与第K个子数组和之间的较小值, 能转化为更小规模问题就可以考虑用动态规划来解决问题了:
- 递推数组dp[i][j]定义为[0,..,i]范围元素划分为j+1个子数组的元素和最小值, 其它初始赋值为最大值;
- 初始化dp[i][0]的值就是前缀和preSum[i]的值;
- 前m-1个子数组总共至少要有m-1个元素才能划分出来, 所以第K个子数组要从[m-1, length - 1]范围枚举来递推, 即 \(dp[i][m] = Math.min(dp[i][m], Math.max(dp[j][m - 1], preSum[j] - preSum[i])), j \in [m - 1, length)\)
- 时间复杂度O(k\(n^2\)), 空间复杂度O(kn);
- 这种求某些最大值的最小答案问题还可以考虑二分查找法, 一般也是最优解法:
- 首先确定答案可能的范围, 显然每个元素单独作为一个元素时对应的整个原始数组元素最大值是答案下限, 所有元素作为一个数组的原始数组累加和是答案上限, 即left = max, right = sum;
- 再来确定单调性, 假定按照某个临界值来划分子数组的数量超过了K, 说明临界值设的太小需要往较大的范围尝试, 反之则说明一定能划分出不超过该临界值的K个连续子数组;
- 判断函数的逻辑也可以直接确定, 即给定子数组元素和不超过mid的情况下, 能否划分出不超过K个的子数组;
- 在[max, sum]范围内不断二分求得能划分出不超过K个子数组的mid最小值就是答案;
- 设数组和为S, 时间复杂度O(nlogS), 空间复杂度O(1);
解题代码
-
动态规划版本
/** * 动态规划 * 执行用时: 84 ms , 在所有 Java 提交中击败了 18.46% 的用户 * 内存消耗: 40.71 MB , 在所有 Java 提交中击败了 14.80% 的用户 */ public int splitArray(int[] nums, int k) { // dp[i][j]表示[0,i]分为j+1个子数组的元素和最大值中的最小值 int len = nums.length; int[][] dp = new int[len][k]; dp[0][0] = nums[0]; for (int i = 1; i < len; i++) { dp[i][0] = dp[i - 1][0] + nums[i]; } for (int i = 0; i < len; i++) { for (int j = 1; j < k; j++) { dp[i][j] = Integer.MAX_VALUE; } } for (int m = 1; m < k; m++) { // m+1个子数组至少需要[0,m]共m+1个元素才能划分出来 for (int i = m; i < len; i++) { // 以[0,j]划分m个子数组, [j+1,i]为第m+1个子数组, 依次求j=m-1 ~ j=i进行划分的情况下各个子数组最大值的最小值 for (int j = m - 1; j < i; j++) { dp[i][m] = Math.min(dp[i][m], Math.max(dp[j][m - 1], dp[i][0] - dp[j][0])); } } } return dp[len - 1][k - 1]; }
-
个人喜好用的二分查找写法:
/** * 用可读性更高的另一种二分写法, 用来水篇博客 * 执行用时: 0 ms , 在所有 Java 提交中击败了 100.00% 的用户 * 内存消耗: 40.25 MB , 在所有 Java 提交中击败了 64.60% 的用户 */ public int splitArray(int[] nums, int k) { int left = 0, right = 0; for (int num : nums) { right += num; left = Math.max(num, left); } int ans = -1; while (left <= right) { int mid = left + ((right - left) >> 1); // 判断数组和都不超过mid的情况下能不能分成K个连续子数组 if (splitMoreThanK(nums, mid, k)) { left = mid + 1; } else { ans = mid; right = mid - 1; } } return ans; } private boolean splitMoreThanK(int[] nums, int sumLimit, int k) { int cnt = 1, s = 0; for (int num : nums) { if (s + num > sumLimit) { cnt++; s = num; if (cnt > k) { break; } } else { s += num; } } return cnt > k; }
-
更简洁些的二分写法:
/** * 二分查找 * 执行用时: 0 ms , 在所有 Java 提交中击败了 100.00% 的用户 * 内存消耗: 40.09 MB , 在所有 Java 提交中击败了 95.39% 的用户 */ public int splitArrayBinarySearch(int[] nums, int k) { int max = 0, sum = 0; for (int num : nums) { max = Math.max(max, num); sum += num; } // 子数组元素和最大值的最小数范围在[max, sum]之内 int small = max, big = sum; while (small < big) {// 二分找到符合条件的最小值 int mid = small + (big - small) / 2; int cnt = splitCnt(nums, mid); if (cnt > k) { // 划分的子数组个数超过了k, 说明当前目标值小了需要扩大 small = mid + 1; } else { // 缩小目标值尝试找到能满足元素和最大值的最小情况 big = mid; } } return small; } /** * @param sumLimit 划分连续子数组的元素和最大值限制 * @return 满足元素和不超过指定值条件下的子数组个数 */ private int splitCnt(int[] nums, int sumLimit) { int cnt = 1, sum = 0; for (int num : nums) { if (sum + num > sumLimit) {// 当前连续子数组元素和已经超过限制, 要划分出新的子数组 sum = 0; cnt++; } sum += num; } return cnt; }