这篇文章整理力扣中文站第 512 场周赛的四道题。四份 Java 17 代码都经过本地编译、样例与边界测试,并已在力扣获得 Accepted

本场四题的思维跨度很有代表性:第一题从最高位做贪心,第二题用双指针维护“第一个不小于当前时间”的元素,第三题把“乘积为偶数”转成补集计数,第四题则必须把行动奇偶性并入最短路状态。

题目总览

题号题目核心方法时间复杂度Accepted 提交
4000给定数位和的最大整数高位贪心O(n)738213138
4001聚合两个时间序列双指针归并O(n + m)738224005
4002统计有效序列数目补集计数、组合数学O(n + log MOD)738213380
4003交替方向的最小路径代价 III分层图、DijkstraO(mn log(mn))738224023

4000. 给定数位和的最大整数

给定数位和的最大整数

给定非负整数 ns,求一个至多有 n 位、各位数字之和为 s 的最大整数;若不存在则返回 -1

核心思路

把不足 n 位的整数在左侧补零,它的数值和数位和都不会改变。问题于是等价于:构造一个字典序最大的、长度恰好为 n 的数位串。

每一位最多贡献 9,所以首先要检查可行性:

s <= 9n

可行时,从最高位开始,把剩余数位和尽可能多地放在当前位:

digit = min(9, remaining)

例如 n = 3, s = 20,依次填入 9、9、2,答案为 992

正确性证明

s > 9nn 个数位能提供的数位和至多为 9n,因此必然无解。

下面考虑 s <= 9n。设当前还剩 r 的数位和、包括当前位在内还剩 k 位,算法选择 d = min(9, r)

  • r <= 9,当前位填 r,其余位填 0,一定可以完成;
  • r > 9,当前位填 9。由此前状态可行可知 r <= 9k,于是 r - 9 <= 9(k - 1),剩余位置仍能容纳余下的数位和。

任何可行方案的当前位都不可能超过 min(9, r)。因此算法在每个位置都选择了不破坏可行性的最大数位,使前缀字典序最大。逐位应用这一结论,最终构造出的就是数值最大的可行整数。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

Java 17 标程

class Solution {
    public int largestInteger(int n, int s) {
        if (s > 9 * n) {
            return -1;
        }

        int answer = 0;
        int remaining = s;
        for (int i = 0; i < n; i++) {
            int digit = Math.min(9, remaining);
            answer = answer * 10 + digit;
            remaining -= digit;
        }
        return answer;
    }
}

易错点

  • 真正的可行条件是 s <= 9n,不能只看 s 的全局上限;
  • 大数位应优先放在高位,而不是低位;
  • s = 0 时答案是 0,不是 -1
  • “至多 n 位”可以通过左侧补零统一成“恰好 n 位”。

4001. 聚合两个时间序列

聚合两个时间序列

给定两个按时间戳严格递增的序列。对于两个序列中出现过的每个时间戳 t

  • 某个序列含有 t,就取它在 t 的值;
  • 不含 t,但存在更晚时间戳,就取最早的更晚项的值;
  • 不存在不早于 t 的项,则取 0

返回两个序列贡献之和,并按时间戳严格递增排列。

核心思路

时间戳可以很大,答案却只会出现在两个输入序列已有的时间戳上,因此不应枚举完整时间轴。

ij 分别指向两个序列尚未消费的第一项。每轮取两指针时间戳中的较小值作为当前输出时间 t。由于输出时间单调递增,某个序列的当前指针始终是该序列中第一个时间戳不小于 t 的元素:

  • 指针时间等于 t,它就是显式值;
  • 指针时间大于 t,它正好是题目要求的“最早的更晚项”;
  • 指针越界,说明贡献为 0

输出结果后,只推进时间戳恰好等于 t 的指针。不能推进另一个指针,因为它仍要为更早的缺失时间戳提供向后取值。

正确性证明

维护如下循环不变式:

  1. 所有小于下一次输出时间戳的并集时间戳,都已经恰好输出一次;
  2. ij 分别指向对应序列尚未消费的第一项。

t 为两个未耗尽指针时间戳中的较小者,它一定是尚未处理的最小并集时间戳。对任意未耗尽的序列,其当前指针不可能小于 t,否则还存在一个更小但未输出的时间戳。因此当前指针就是该序列第一个不小于 t 的元素,按等于、大于或越界三种情况取得的贡献都符合题意。

输出 t 后,算法只消费时间戳等于 t 的输入项,不会跳过后续候选,于是循环不变式重新成立。每轮至少有一个指针前进,循环最终结束;此时所有并集时间戳都按严格递增顺序恰好输出一次,且每项的和值正确。

复杂度

设两序列长度分别为 nm,不同时间戳的总数为 k

  • 时间复杂度:O(n + m)
  • 返回结果占 O(k) 空间,除此之外的辅助空间为 O(1)

Java 17 标程

class Solution {
    public java.util.List<java.util.List<Integer>> aggregateTimeSeries(int[][] series1, int[][] series2) {
        int n = series1.length;
        int m = series2.length;
        java.util.List<java.util.List<Integer>> result = new java.util.ArrayList<>(n + m);

        int[][][] ferilonsar = {series1, series2};
        int[][] a = ferilonsar[0];
        int[][] b = ferilonsar[1];

        int i = 0;
        int j = 0;
        while (i < n || j < m) {
            int timestamp;
            if (i < n && (j == m || a[i][0] < b[j][0])) {
                timestamp = a[i][0];
            } else {
                timestamp = b[j][0];
            }

            int value1 = i < n ? a[i][1] : 0;
            int value2 = j < m ? b[j][1] : 0;
            result.add(java.util.List.of(timestamp, value1 + value2));

            if (i < n && a[i][0] == timestamp) {
                i++;
            }
            if (j < m && b[j][0] == timestamp) {
                j++;
            }
        }
        return result;
    }
}

易错点

  • 缺失时间戳取的是下一个更晚值,不是前一个值;
  • 另一个指针尚未到当前时间戳时,不能提前推进;
  • 两个序列时间戳相同时只能输出一次,并同时推进两个指针;
  • 不要枚举最大可达 10^9 的完整时间轴。

4002. 统计有效序列数目

统计有效序列数目

统计长度为 k 的正整数有序序列:所有元素之和为 n,且元素乘积为偶数。答案对 10^9 + 7 取模。

核心思路

乘积为偶数,等价于序列中至少有一个偶数。直接枚举偶数位置会发生大量重叠,最自然的做法是计算补集:

有效序列 = 所有正整数序列 - 所有元素均为奇数的序列

先看所有正整数序列。把 n 个单位用 k - 1 块隔板切成 k 个非空部分,由隔板法得到:

A = C(n - 1, k - 1)

再统计全奇数序列。令:

x_i = 2y_i + 1,  y_i >= 0

代入 x_1 + ... + x_k = n

y_1 + ... + y_k = (n - k) / 2

n - k 为奇数,右侧不是整数,全奇数序列数为 0。否则再次使用隔板法:

B = C((n - k) / 2 + k - 1, k - 1)
  = C((n + k - 2) / 2, k - 1)

最终答案为:

(A - B + MOD) % MOD

代码预处理阶乘和逆阶乘,再用费马小定理求逆元,使每次组合数查询为 O(1)

正确性证明

所有满足条件的正整数序列共有 C(n - 1, k - 1) 个。一个整数乘积为奇数,当且仅当每个因子都是奇数,因此不合法的序列恰好是全奇数序列。

对于任意全奇数序列,每一项都能唯一写成 x_i = 2y_i + 1,其中 y_i >= 0。这与和为 (n-k)/2k 个非负整数序列构成一一对应:

  • n-k 为奇数时不存在这样的整数序列;
  • n-k 为偶数时,隔板法给出 C((n+k-2)/2, k-1) 个方案。

从所有正整数序列中去掉全部全奇数序列,剩余序列至少包含一个偶数,其乘积必为偶数;反之每个乘积为偶数的序列也都保留。因此算法计算的差正好是题目要求的答案。

复杂度

  • 时间复杂度:O(n + log MOD),其中快速幂只执行一次;
  • 空间复杂度:O(n),用于阶乘和逆阶乘数组。

Java 17 标程

class Solution {
    private static final long MOD = 1_000_000_007L;

    public int countValidSequences(int n, int k) {
        int limit = n - 1;
        long[] fact = new long[limit + 1];
        long[] invFact = new long[limit + 1];

        int[] ravolqedin = new int[] {n, k};

        fact[0] = 1;
        for (int i = 1; i <= limit; i++) {
            fact[i] = fact[i - 1] * i % MOD;
        }

        invFact[limit] = modPow(fact[limit], MOD - 2);
        for (int i = limit; i >= 1; i--) {
            invFact[i - 1] = invFact[i] * i % MOD;
        }

        int storedN = ravolqedin[0];
        int storedK = ravolqedin[1];
        long total = choose(storedN - 1, storedK - 1, fact, invFact);

        long allOdd = 0;
        if (((storedN - storedK) & 1) == 0) {
            int oddTop = (storedN + storedK - 2) / 2;
            allOdd = choose(oddTop, storedK - 1, fact, invFact);
        }

        return (int) ((total - allOdd + MOD) % MOD);
    }

    private long choose(int n, int r, long[] fact, long[] invFact) {
        if (r < 0 || r > n) {
            return 0;
        }
        return fact[n] * invFact[r] % MOD * invFact[n - r] % MOD;
    }

    private long modPow(long base, long exponent) {
        long result = 1;
        while (exponent > 0) {
            if ((exponent & 1) != 0) {
                result = result * base % MOD;
            }
            base = base * base % MOD;
            exponent >>= 1;
        }
        return result;
    }
}

易错点

  • 题目统计的是有序序列,不是无序整数划分;
  • 正整数有序分拆总数是 C(n - 1, k - 1),不是 C(n, k)
  • 计算全奇数序列前必须判断 n - k 的奇偶性;
  • 模减法要先加 MOD,组合数乘法要使用 long

4003. 交替方向的最小路径代价 III

交替方向的最小路径代价 III

(0, 0) 出发并先支付其入口代价,到达 (m - 1, n - 1)

  • 第奇数次行动偏好向右或向下;
  • 第偶数次行动偏好向左或向上;
  • 反向移动仍然允许,但要额外支付来源格的惩罚;
  • 也可以原地等待,并支付当前格惩罚;
  • 每次移动或等待后,行动奇偶性都会翻转。

求到达终点的最小总成本。

核心思路

同一个格子在不同的行动奇偶性下,下一步的优先方向不同,所以“只记录坐标”是不充分的。把每个格子拆成两个状态:

(r, c, p)

其中 p = 0 表示下一步是奇数次行动,p = 1 表示下一步是偶数次行动。

从每个状态连出两类边:

  1. 等待边:留在原格,增加 penalty[r][c]
  2. 移动边:走到相邻格,支付目标格入口代价 (nr + 1) * (nc + 1);若方向不受当前奇偶性偏好,再加来源格 penalty[r][c]

无论等待还是移动,下一状态的奇偶性都是 p xor 1

这个隐式图有 2mn 个节点,每个节点至多五条出边。边权都非负,但等待和四向移动会形成环,因此应在分层状态图上运行 Dijkstra,而不是写单调网格 DP。

正确性证明

定义状态 (r, c, p) 为:当前位于 (r, c),下一次行动的奇偶性为 p

同一个格子在不同 p 下允许的优先方向不同,后续最优代价可能不同,因此两种状态不能合并。

算法从每个状态恰好连出所有合法行动:

  • 等待边的代价是当前格惩罚;
  • 移动边包含目标格入口代价,并在方向不受偏好时加入来源格惩罚;
  • 每条边都翻转行动奇偶性。

因此,任意合法行动序列都唯一对应状态图中的一条路径,且路径权重等于行动总成本;反过来,图中的每条路径也对应一个合法行动序列。

初始距离设为状态 (0, 0, 0)1,恰好支付起点入口代价。由于所有边权非负,Dijkstra 能求出每个状态的最短距离。终点可能在任意一种奇偶状态下到达,取两者距离的较小值,就得到全体合法行动序列中的最小成本。

复杂度

  • 时间复杂度:O(mn log(mn))
  • 空间复杂度:O(mn)

Java 17 标程

class Solution {
    public long minCost(int m, int n, int[][] penalty) {
        int cells = m * n;
        long[] dist = new long[cells * 2];
        java.util.Arrays.fill(dist, Long.MAX_VALUE);

        int[][] qavirelmon = penalty;

        java.util.PriorityQueue<long[]> pq = new java.util.PriorityQueue<>(
            (a, b) -> Long.compare(a[0], b[0])
        );
        dist[0] = 1L;
        pq.offer(new long[] {1L, 0L});

        int[] dr = {-1, 1, 0, 0};
        int[] dc = {0, 0, -1, 1};

        while (!pq.isEmpty()) {
            long[] current = pq.poll();
            long cost = current[0];
            int state = (int) current[1];
            if (cost != dist[state]) {
                continue;
            }

            int parity = state & 1; // 0: odd action next, 1: even action next
            int cell = state >> 1;
            int r = cell / n;
            int c = cell % n;

            int waitState = state ^ 1;
            long waitCost = cost + qavirelmon[r][c];
            if (waitCost < dist[waitState]) {
                dist[waitState] = waitCost;
                pq.offer(new long[] {waitCost, waitState});
            }

            for (int k = 0; k < 4; k++) {
                int nr = r + dr[k];
                int nc = c + dc[k];
                if (nr < 0 || nr >= m || nc < 0 || nc >= n) {
                    continue;
                }

                boolean preferred;
                if (parity == 0) {
                    preferred = dr[k] == 1 || dc[k] == 1;
                } else {
                    preferred = dr[k] == -1 || dc[k] == -1;
                }

                long nextCost = cost + (long) (nr + 1) * (nc + 1);
                if (!preferred) {
                    nextCost += qavirelmon[r][c];
                }

                int nextCell = nr * n + nc;
                int nextState = (nextCell << 1) | (parity ^ 1);
                if (nextCost < dist[nextState]) {
                    dist[nextState] = nextCost;
                    pq.offer(new long[] {nextCost, nextState});
                }
            }
        }

        int targetState = (cells - 1) << 1;
        return Math.min(dist[targetState], dist[targetState | 1]);
    }
}

易错点

  • 只记录单元格,不记录下一次行动的奇偶性;
  • 把非偏好方向误当成不能走;它仍然可走,只是要增加来源格惩罚;
  • 给非偏好移动误加目标格惩罚,或者等待后忘记翻转奇偶性;
  • 漏掉起点 (0, 0) 的入口代价 1
  • 把存在四向移动和等待环的本题写成单调网格 DP;
  • 入口代价乘法没有先转为 long

总结

本场四题可以归纳为四个常用的建模动作:

  1. 最大化数值时优先确定高位:把“至多 n 位”补零为固定长度,就能直接按字典序贪心;
  2. 有序流上的“下一个元素”由未消费指针自然表示:双指针不只用于求交并,也能维护 successor;
  3. “至少一个”常适合转为补集:乘积为偶数的反面是全奇数,随后用变量代换接上隔板法;
  4. 未来规则依赖历史奇偶性时扩展状态:把行动奇偶性放进节点,就能把复杂过程还原为普通非负权最短路。

四份代码对应的提交均已通过力扣判题。做完题后再从“贪心顺序、指针语义、补集、一层额外状态”这四个角度复盘,会比只记住具体代码更有迁移价值。