题目描述

✅ LCP 12. 小张刷题计划

image-20260929105616954

image-20260929105617136

题意分析

按原顺序在 m 天内完成所有题目,一道题不能拆到不同天,每天最多可以求助一次,免去某一道题的耗时。要求最小化所有天中的最大实际耗时,允许提前完成后不再做题。

一天完成的题目必然是一个连续段。对确定的一段,求助最耗时的题一定最优,因此它的实际代价是 sum - max,其中 sum 为这段总耗时,max 为其中最大耗时。

解法:二分答案 + 贪心分段

核心思路

[!blue]

先把求最优值转成判定:给定单日上限 limit,能否将全部题目分成不超过 m 个代价都不超过上限的连续段?上限越大,原本可行的分段仍然可行,所以可行性具有单调性,可以二分最小可行上限。

判定时用 days 记录已经开始的天数,sum、maxVal 记录当天连续段的总和与最大值。加入一道耗时为 t 的题后,先更新这两个量,再计算 sum - maxVal。新题可能成为当天最大值并被免去,不能只把它加到旧的实际耗时上。

向一个段加入非负耗时的题,其代价不会下降:若新题没有成为最大值,代价增加 t;若它成为最大值,原来的最大值重新计入代价。因此一旦加入当前题超限,再加入后面的题也无法挽救这一天,可以把前面的最长合法前缀作为当天内容,从当前题开始新的一天。

这样贪心分段得到的天数最少。任意其他合法方案的第一天都不能超过贪心选出的最长前缀;把它延长到贪心终点,第一天仍合法,后续各段只需删掉被提前完成的题。删除元素不会增加一段的 sum - max,整段被删空时还可以省去一天,所以总天数不会增加。对剩余题目重复这个调整,就得到贪心方案。

换天时要令 sum = t、maxVal = t,让触发超限的当前题成为新一天的第一题。单题可以直接求助,代价为零,所以新段一定合法;如果天数已经超过 m,贪心的最少天数也超限,立即判定失败。

二分初始范围为零到总耗时。零可能是答案,总耗时则一定足够。当前 mid 可行时,继续向左寻找更小上限;不可行时,只能增大上限。闭区间搜索结束后,left 正是第一个可行值。

解题步骤

  1. 建立零到总耗时的二分范围。
  2. 给定上限,逐题维护当天总和与最大值。
  3. 超限则增加天数,并以当前题重新初始化当天状态。
  4. 根据天数是否超过 m 收缩范围,返回最小可行上限。

当 m 不小于题目数量时,每天只做一题并求助,答案为零,现有判定会自然得到这个结果。等于 limit 的一天仍然合法,只在严格超限时换天。题目总耗时最多为 $10^9$,总和与二分边界均可使用 int。

代码实现

class Solution {
    public int minTime(int[] time, int m) {
        int left = 0;
        int right = 0;

        for (int t : time) {
            right += t;
        }

        // 闭区间二分最小的可行上限。
        while (left <= right) {
            int mid = left + (right - left) / 2;

            if (canFinish(time, m, mid)) {
                right = mid - 1;
            } else {
                left = mid + 1;
            }
        }

        return left;
    }

    // 上限为 limit 时,贪心地每天尽量多做,看天数是否不超过 m。
    private boolean canFinish(int[] time, int m, int limit) {
        int days = 1;
        int sum = 0;
        int maxVal = 0;

        for (int t : time) {
            sum += t;
            maxVal = Math.max(maxVal, t);

            // 当天代价 = 总和 - 最大值(求助掉最耗时的那道)。
            if (sum - maxVal > limit) {
                days++;

                if (days > m) {
                    return false;
                }

                // 这道题推到新的一天,成为新段的首个元素。
                sum = t;
                maxVal = t;
            }
        }

        return true;
    }
}
func minTime(time []int, m int) int {
    left, right := 0, 0
    for _, t := range time {
        right += t
    }

    // 闭区间二分最小的可行上限。
    for left <= right {
        mid := left + (right-left)/2
        if canFinish(time, m, mid) {
            right = mid - 1
        } else {
            left = mid + 1
        }
    }
    return left
}

// 上限为 limit 时,贪心地每天尽量多做,看天数是否不超过 m。
func canFinish(time []int, m int, limit int) bool {
    days := 1
    sum := 0
    maxVal := 0
    for _, t := range time {
        sum += t
        if t > maxVal {
            maxVal = t
        }
        // 当天代价 = 总和 - 最大值(求助掉最耗时的那道)。
        if sum-maxVal > limit {
            days++
            if days > m {
                return false
            }
            // 这道题推到新的一天,成为新段的首个元素。
            sum = t
            maxVal = t
        }
    }
    return true
}

复杂度分析

  • 时间复杂度:$O(n\log(S+2))$,S 为总耗时,每次判定线性扫描,包含初始统计与零上界情况。
  • 空间复杂度:$O(1)$。

关键点总结

[!green]

  • 每天的代价是总和减最大值。
  • 判断上限时尽量延长当前连续段,得到最少天数。
  • 当前题导致超限后,应放入下一天而不是丢弃。

易错点总结

[!yellow]

  • 不更新最大值就判断新段代价:新题可能成为应免去的最大项。
  • 换天时把总和与最大值都清零:当前题被遗漏。
  • 等于上限也强制换天:拒绝合法分段。
  • 下界从一开始:每天一题时答案可能为零。

相似题目

题目 难度 关联与区别
410. 分割数组的最大值 困难 同样最小化连续分段的最大代价,本题每段可免去最大一项,判定费用变为sum-max。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2025/76854380
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!