45.跳跃游戏II

目标

给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]。

每个元素 nums[i] 表示从索引 i 向后跳转的最大长度。换句话说,如果你在 nums[i] 处,你可以跳转到任意 nums[i + j] 处:

  • 0 <= j <= nums[i]
  • i + j < n

返回到达 nums[n - 1] 的最小跳跃次数。生成的测试用例可以到达 nums[n - 1]。

示例 1:

输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。
     从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。

示例 2:

输入: nums = [2,3,0,1,4]
输出: 2

说明:

  • 1 <= nums.length <= 10^4
  • 0 <= nums[i] <= 1000
  • 题目保证可以到达 nums[n-1]

思路

数组 nums 的元素表示可以向后跳跃的最大长度,求从 0 跳到 n - 1 所需的最小跳跃次数。

定义 dp[i] 表示到达下标 i 所需的最小跳跃次数,状态转移方程为 dp[i + k] = min(dp[i] + 1),其中 k ∈ [1, nums[i]]

代码


/**
 * @date 2024-03-07 17:14
 */
public class CanJumpII45 {

    public int jump(int[] nums) {
        int n = nums.length;
        int[] dp = new int[n];
        Arrays.fill(dp, 0x3f3f3f);
        dp[0] = 0;
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j < n && j <= i + nums[i]; j++) {
                dp[j] = Math.min(dp[j], dp[i] + 1);
            }
        }
        return dp[n - 1];
    }

}

性能

2412.完成所有交易的初始最少钱数

目标

给你一个下标从 0 开始的二维整数数组 transactions,其中transactions[i] = [costi, cashbacki] 。

数组描述了若干笔交易。其中每笔交易必须以 某种顺序 恰好完成一次。在任意一个时刻,你有一定数目的钱 money ,为了完成交易 i ,money >= costi 这个条件必须为真。执行交易后,你的钱数 money 变成 money - costi + cashbacki 。

请你返回 任意一种 交易顺序下,你都能完成所有交易的最少钱数 money 是多少。

示例 1:

输入:transactions = [[2,1],[5,0],[4,2]]
输出:10
解释:
刚开始 money = 10 ,交易可以以任意顺序进行。
可以证明如果 money < 10 ,那么某些交易无法进行。

示例 2:

输入:transactions = [[3,0],[0,3]]
输出:3
解释:
- 如果交易执行的顺序是 [[3,0],[0,3]] ,完成所有交易需要的最少钱数是 3 。
- 如果交易执行的顺序是 [[0,3],[3,0]] ,完成所有交易需要的最少钱数是 0 。
所以,刚开始钱数为 3 ,任意顺序下交易都可以全部完成。

说明:

  • 1 <= transactions.length <= 10^5
  • transactions[i].length == 2
  • 0 <= costi, cashbacki <= 10^9

提示:

  • Split transactions that have cashback greater or equal to cost apart from transactions that have cashback less than cost. You will always earn money in the first scenario.
  • For transactions that have cashback greater or equal to cost, sort them by cost in descending order.
  • For transactions that have cashback less than cost, sort them by cashback in ascending order.

思路

有若干笔交易 transactionstransactions[i] = [costi, cashbacki] 表示每笔交易的现金支出为 costi,收入为 cashbacki。求以任意顺序完成交易所需的最少现金 money,完成交易 i 需要满足 money >= costi

注意题目求的是任意顺序下都能完成所有交易的最少现金数量,而不是求所有交易顺序中,能够保证完成所有交易的现金最小值。

看了提示,说是任意顺序,其实就是求启动资金的最大值。总的思路是这样的:

  • 先赔后赚,将交易划分成两部分,一部分赔钱一部分赚钱
  • 对于赔钱的交易,优先按回款从小到大排序,这样为了完成所有交易所需的钱最多
  • 对于赚钱的交易,按支出顺序从大到小排序,只要能完成最大支出的交易,那么后续交易也一定能完成

代码


/**
 * @date 2025-01-25 20:42
 */
public class MinimumMoney2412 {

    public long minimumMoney(int[][] transactions) {
        Arrays.sort(transactions, (a, b) -> {
            int diffa = a[1] - a[0];
            int diffb = b[1] - b[0];
            return diffa - diffb;
        });
        int cut = 0;
        for (int[] transaction : transactions) {
            if (transaction[0] <= transaction[1]) {
                break;
            }
            cut++;
        }
        int n = transactions.length;
        int[][] a = new int[cut][];
        int[][] b = new int[n - cut][];
        System.arraycopy(transactions, 0, a, 0, cut);
        System.arraycopy(transactions, cut, b, 0, n - cut);
        Arrays.sort(a, (x, y) -> x[1] - y[1]);
        Arrays.sort(b, (x, y) -> y[0] - x[0]);
        long res = 0;
        long remainder = 0;
        for (int[] item : a) {
            if (remainder < item[0]) {
                res += item[0] - remainder;
            }
            remainder = item[1];
        }
        if (b.length > 0 && remainder < b[0][0]) {
            res += b[0][0] - remainder;
        }
        return res;
    }

}

性能

1561.你可以获得的最大硬币数目

目标

有 3n 堆数目不一的硬币,你和你的朋友们打算按以下方式分硬币:

  • 每一轮中,你将会选出 任意 3 堆硬币(不一定连续)。
  • Alice 将会取走硬币数量最多的那一堆。
  • 你将会取走硬币数量第二多的那一堆。
  • Bob 将会取走最后一堆。
  • 重复这个过程,直到没有更多硬币。

给你一个整数数组 piles ,其中 piles[i] 是第 i 堆中硬币的数目。

返回你可以获得的最大硬币数目。

示例 1:

输入:piles = [2,4,1,2,7,8]
输出:9
解释:选出 (2, 7, 8) ,Alice 取走 8 枚硬币的那堆,你取走 7 枚硬币的那堆,Bob 取走最后一堆。
选出 (1, 2, 4) , Alice 取走 4 枚硬币的那堆,你取走 2 枚硬币的那堆,Bob 取走最后一堆。
你可以获得的最大硬币数目:7 + 2 = 9.
考虑另外一种情况,如果选出的是 (1, 2, 8) 和 (2, 4, 7) ,你就只能得到 2 + 4 = 6 枚硬币,这不是最优解。

示例 2:

输入:piles = [2,4,5]
输出:4

示例 3:

输入:piles = [9,8,7,6,5,1,2,3,4]
输出:18

说明:

  • 3 <= piles.length <= 10^5
  • piles.length % 3 == 0
  • 1 <= piles[i] <= 10^4

思路

3n 堆硬币,每次操作可以任选其中3堆,Alice 取走数量最多的那堆硬币,我们取走硬币数量第二多的,Bob 取走剩下的。求我们能获得的最大硬币数目。

为了使我们获取的硬币数量最多,Bob 每次只能取数量最少的那堆,将数量多的留下。根据这种贪心策略,我们可以将每堆硬币按个数从小到大排序,从倒数第二个开始每隔两个向前取,同时 Bob 从第一个往后挨个取,直到这两个指针相遇。

代码


/**
 * @date 2025-01-22 8:50
 */
public class MaxCoins1561 {

    public int maxCoins(int[] piles) {
        Arrays.sort(piles);
        int res = 0;
        int n = piles.length;
        int k = 0;
        for (int i = n - 2; i > k; i -= 2, k++) {
            res += piles[i];
        }
        return res;
    }
}

性能

3219.切蛋糕的最小总开销II

目标

有一个 m x n 大小的矩形蛋糕,需要切成 1 x 1 的小块。

给你整数 m ,n 和两个数组:

  • horizontalCut 的大小为 m - 1 ,其中 horizontalCut[i] 表示沿着水平线 i 切蛋糕的开销。
  • verticalCut 的大小为 n - 1 ,其中 verticalCut[j] 表示沿着垂直线 j 切蛋糕的开销。

一次操作中,你可以选择任意不是 1 x 1 大小的矩形蛋糕并执行以下操作之一:

  1. 沿着水平线 i 切开蛋糕,开销为 horizontalCut[i] 。
  2. 沿着垂直线 j 切开蛋糕,开销为 verticalCut[j] 。

每次操作后,这块蛋糕都被切成两个独立的小蛋糕。

每次操作的开销都为最开始对应切割线的开销,并且不会改变。

请你返回将蛋糕全部切成 1 x 1 的蛋糕块的 最小 总开销。

示例 1:

输入:m = 3, n = 2, horizontalCut = [1,3], verticalCut = [5]
输出:13
解释:
沿着垂直线 0 切开蛋糕,开销为 5 。
沿着水平线 0 切开 3 x 1 的蛋糕块,开销为 1 。
沿着水平线 0 切开 3 x 1 的蛋糕块,开销为 1 。
沿着水平线 1 切开 2 x 1 的蛋糕块,开销为 3 。
沿着水平线 1 切开 2 x 1 的蛋糕块,开销为 3 。
总开销为 5 + 1 + 1 + 3 + 3 = 13 。

示例 2:

输入:m = 2, n = 2, horizontalCut = [7], verticalCut = [4]
输出:15
解释:
沿着水平线 0 切开蛋糕,开销为 7 。
沿着垂直线 0 切开 1 x 2 的蛋糕块,开销为 4 。
沿着垂直线 0 切开 1 x 2 的蛋糕块,开销为 4 。
总开销为 7 + 4 + 4 = 15 。

说明:

  • 1 <= m, n <= 10^5
  • horizontalCut.length == m - 1
  • verticalCut.length == n - 1
  • 1 <= horizontalCut[i], verticalCut[i] <= 10^3

思路

有一块 m x n 的蛋糕,horizontalCut[i] 表示水平切第 i 行的开销,verticalCut[i] 表示垂直切第 i 列的开销。求将蛋糕切成 1 x 1 小块的最小代价。

切蛋糕的最小总开销I 相比数据范围扩大了,返回值是 long 型。

代码


/**
 * @date 2024-12-26 16:38
 */
public class MinimumCost3219 {

    public long minimumCost(int m, int n, int[] horizontalCut, int[] verticalCut) {
        int horizontalPart = m;
        int verticalPart = n;
        Arrays.sort(horizontalCut);
        Arrays.sort(verticalCut);
        int h = 0;
        int v = 0;
        long res = 0;
        while (h < m - 1 || v < n - 1) {
            int hcost = h >= m - 1 ? Integer.MAX_VALUE : horizontalCut[h];
            int vcost = v >= n - 1 ? Integer.MAX_VALUE : verticalCut[v];
            if (hcost < vcost) {
                res += verticalPart * hcost;
                horizontalPart--;
                h++;
            } else {
                res += horizontalPart * vcost;
                verticalPart--;
                v++;
            }
        }
        return res;
    }

}

性能

3218.切蛋糕的最小总开销 I

目标

有一个 m x n 大小的矩形蛋糕,需要切成 1 x 1 的小块。

给你整数 m ,n 和两个数组:

  • horizontalCut 的大小为 m - 1 ,其中 horizontalCut[i] 表示沿着水平线 i 切蛋糕的开销。
  • verticalCut 的大小为 n - 1 ,其中 verticalCut[j] 表示沿着垂直线 j 切蛋糕的开销。

一次操作中,你可以选择任意不是 1 x 1 大小的矩形蛋糕并执行以下操作之一:

  1. 沿着水平线 i 切开蛋糕,开销为 horizontalCut[i] 。
  2. 沿着垂直线 j 切开蛋糕,开销为 verticalCut[j] 。

每次操作后,这块蛋糕都被切成两个独立的小蛋糕。

每次操作的开销都为最开始对应切割线的开销,并且不会改变。

请你返回将蛋糕全部切成 1 x 1 的蛋糕块的 最小 总开销。

示例 1:

输入:m = 3, n = 2, horizontalCut = [1,3], verticalCut = [5]
输出:13
解释:
沿着垂直线 0 切开蛋糕,开销为 5 。
沿着水平线 0 切开 3 x 1 的蛋糕块,开销为 1 。
沿着水平线 0 切开 3 x 1 的蛋糕块,开销为 1 。
沿着水平线 1 切开 2 x 1 的蛋糕块,开销为 3 。
沿着水平线 1 切开 2 x 1 的蛋糕块,开销为 3 。
总开销为 5 + 1 + 1 + 3 + 3 = 13 。

示例 2:

输入:m = 2, n = 2, horizontalCut = [7], verticalCut = [4]
输出:15
解释:
沿着水平线 0 切开蛋糕,开销为 7 。
沿着垂直线 0 切开 1 x 2 的蛋糕块,开销为 4 。
沿着垂直线 0 切开 1 x 2 的蛋糕块,开销为 4 。
总开销为 7 + 4 + 4 = 15 。

说明:

  • 1 <= m, n <= 20
  • horizontalCut.length == m - 1
  • verticalCut.length == n - 1
  • 1 <= horizontalCut[i], verticalCut[i] <= 10^3

思路

有一块 m x n 的蛋糕,horizontalCut[i] 表示水平切第 i 行的开销,verticalCut[i] 表示垂直切第 i 列的开销。求将蛋糕切成 1 x 1 小块的最小代价。

需要注意每次切的蛋糕必须是整块的,并不能将几块蛋糕排到一起切。

我们应该先沿着代价最大的位置切吗?比如大小为 2 x 100 的蛋糕,水平切的代价为 99,垂直切每一列的代价为 100。先切每一列代价为 99 * 100,然后对切开的 100 块水平切 100 次,代价为 100 * 99,总代价为 2 * 99 * 100。如果先水平切一次,代价为 99。然后需要垂直切 2 * 99 次,代价为 2 * 99 * 100,总代价为 99 + 2 * 99 * 100。这种贪心策略应该是可行的,因为每切一次块数会增加,现在不切代价大的,后面再切的时候代价会增加。

选择沿某一水平或垂直线切割时,需要记录水平 和 垂直方向蛋糕块的数量,用来计算代价。每切一次,横向与纵向的蛋糕块就会增加一个。

刚开始不确定能否使用贪心策略,考虑如何表示哪些切过了,哪些没切,卡了很久。关键点是如何将问题抽象建模,使用分治思想,不要面向过程。每切一次之后,问题转化为切剩余蛋糕的子问题。我们可以使用记忆化搜索解空间,求出最小值。

代码


/**
 * @date 2024-12-25 10:41
 */
public class MinimumCost3218 {

    public int minimumCost_v1(int m, int n, int[] horizontalCut, int[] verticalCut) {
        int horizontalCutPart = 1;
        int verticalCutPart = 1;
        Arrays.sort(horizontalCut);
        Arrays.sort(verticalCut);
        int h = horizontalCut.length - 1;
        int v = verticalCut.length - 1;
        int res = 0;
        while (h >= 0 || v >= 0) {
            if (h < 0){
                res += horizontalCutPart * verticalCut[v];
                v--;
                continue;
            }
            if (v < 0){
                res += verticalCutPart * horizontalCut[h];
                h--;
                continue;
            }
            if (horizontalCut[h] > verticalCut[v]) {
                res += verticalCutPart * horizontalCut[h];
                horizontalCutPart++;
                h--;
            } else if (horizontalCut[h] <= verticalCut[v]) {
                res += horizontalCutPart * verticalCut[v];
                verticalCutPart++;
                v--;
            }
        }

        return res;
    }

    int[] rowCost;
    int[] colCost;
    int[][][][] mem;

    public int minimumCost(int m, int n, int[] horizontalCut, int[] verticalCut) {
        this.rowCost = horizontalCut;
        this.colCost = verticalCut;
        mem = new int[m + 1][m + 1][n + 1][n + 1];
        // rowStart rowEnd colStart colEnd 表示蛋糕的边界
        //   0   1   2   3   4   5  ... n
        // 0  ———————————————————
        //   |   |   |   |   |   |
        // 1  ———————————————————
        //   |   |   |   |   |   |
        // 2  ———————————————————
        //   |   |   |   |   |   |
        // 3 ———————————————————
        //   |   |   |   |   |   |
        // 4 ———————————————————
        //   |   |   |   |   |   |
        // 5  ———————————————————
        // ...
        // m
        return dfs(0, m, 0, n);
    }

    public int dfs(int rowStart, int rowEnd, int colStart, int colEnd) {
        if (rowEnd - rowStart == 1 && colEnd - colStart == 1) {
            return 0;
        }
        int res = Integer.MAX_VALUE;
        for (int i = rowStart + 1; i < rowEnd; i++) {
            if (mem[rowStart][i][colStart][colEnd] == 0) {
                mem[rowStart][i][colStart][colEnd] = dfs(rowStart, i, colStart, colEnd);
            }
            if (mem[i][rowEnd][colStart][colEnd] == 0) {
                mem[i][rowEnd][colStart][colEnd] = dfs(i, rowEnd, colStart, colEnd);
            }
            res = Math.min(res, rowCost[i - 1] + mem[rowStart][i][colStart][colEnd] + mem[i][rowEnd][colStart][colEnd]);
        }
        for (int i = colStart + 1; i < colEnd; i++) {
            if (mem[rowStart][rowEnd][colStart][i] == 0) {
                mem[rowStart][rowEnd][colStart][i] = dfs(rowStart, rowEnd, colStart, i);
            }
            if (mem[rowStart][rowEnd][i][colEnd] == 0) {
                mem[rowStart][rowEnd][i][colEnd] = dfs(rowStart, rowEnd, i, colEnd);
            }
            res = Math.min(res, colCost[i - 1] + mem[rowStart][rowEnd][colStart][i] + mem[rowStart][rowEnd][i][colEnd]);
        }
        return res;
    }

}

性能

1705.吃苹果的最大数目

目标

有一棵特殊的苹果树,一连 n 天,每天都可以长出若干个苹果。在第 i 天,树上会长出 apples[i] 个苹果,这些苹果将会在 days[i] 天后(也就是说,第 i + days[i] 天时)腐烂,变得无法食用。也可能有那么几天,树上不会长出新的苹果,此时用 apples[i] == 0 且 days[i] == 0 表示。

你打算每天 最多 吃一个苹果来保证营养均衡。注意,你可以在这 n 天之后继续吃苹果。

给你两个长度为 n 的整数数组 days 和 apples ,返回你可以吃掉的苹果的最大数目。

示例 1:

输入:apples = [1,2,3,5,2], days = [3,2,1,4,2]
输出:7
解释:你可以吃掉 7 个苹果:
- 第一天,你吃掉第一天长出来的苹果。
- 第二天,你吃掉一个第二天长出来的苹果。
- 第三天,你吃掉一个第二天长出来的苹果。过了这一天,第三天长出来的苹果就已经腐烂了。
- 第四天到第七天,你吃的都是第四天长出来的苹果。

示例 2:

输入:apples = [3,0,0,0,0,2], days = [3,0,0,0,0,2]
输出:5
解释:你可以吃掉 5 个苹果:
- 第一天到第三天,你吃的都是第一天长出来的苹果。
- 第四天和第五天不吃苹果。
- 第六天和第七天,你吃的都是第六天长出来的苹果。

说明:

  • apples.length == n
  • days.length == n
  • 1 <= n <= 2 * 10^4
  • 0 <= apples[i], days[i] <= 2 * 10^4
  • 只有在 apples[i] = 0 时,days[i] = 0 才成立

思路

有一颗特殊的苹果树,第 i 天会结出 apples[i] 个果子,这些果子将在第 i + days[i] 天腐烂。求每天吃一个未腐烂的苹果,最多可以吃几个。

我们的贪心策略是优先吃快腐烂的苹果,首先将当天结果的苹果个数以及腐烂时间放入最小堆,获取堆顶最近要腐烂的苹果,判断是否已经腐烂,如果没有将苹果个数减一,如果数量减为 0 或者已经腐烂,将其从堆中移出。

代码


/**
 * @date 2024-12-24 10:35
 */
public class EatenApples1705 {

    public int eatenApples_v2(int[] apples, int[] days) {
        int n = apples.length;
        PriorityQueue<int[]> q = new PriorityQueue<>((a, b) -> a[1] - b[1]);
        int res = 0;
        for (int day = 0; day < n || !q.isEmpty(); day++) {
            if (day < n && apples[day] > 0){
                q.offer(new int[]{apples[day], day + days[day]});
            }
            while (!q.isEmpty()) {
                int[] applesInfo = q.peek();
                if (applesInfo[0] == 0 || applesInfo[1] == day) {
                    q.poll();
                    continue;
                }
                if (applesInfo[1] > day && applesInfo[0] > 0) {
                    applesInfo[0]--;
                    res++;
                    break;
                }
            }
        }
        return res;
    }

}

性能

1338.数组大小减半

目标

给你一个整数数组 arr。你可以从中选出一个整数集合,并删除这些整数在数组中的每次出现。

返回 至少 能删除数组中的一半整数的整数集合的最小大小。

示例 1:

输入:arr = [3,3,3,3,5,5,5,2,2,7]
输出:2
解释:选择 {3,7} 使得结果数组为 [5,5,5,2,2]、长度为 5(原数组长度的一半)。
大小为 2 的可行集合有 {3,5},{3,2},{5,2}。
选择 {2,7} 是不可行的,它的结果数组为 [3,3,3,3,5,5,5],新数组长度大于原数组的二分之一。

示例 2:

输入:arr = [7,7,7,7,7,7]
输出:1
解释:我们只能选择集合 {7},结果数组为空。

说明:

  • 1 <= arr.length <= 10^5
  • arr.length 为偶数
  • 1 <= arr[i] <= 10^5

思路

从整数数组中选出一个元素集合,使该集合中元素在原数组中的出现次数超过原数组长度的一半,求集合大小的最小值。

统计每个元素的出现次数,将出现次数从大到小排序,然后开始选元素直到满足题目条件。

代码


/**
 * @date 2024-12-15 0:17
 */
public class MinSetSize1338 {

    public int minSetSize(int[] arr) {
        int n = arr.length;
        int[] cnt = new int[100001];
        for (int i : arr) {
            cnt[i]++;
        }
        PriorityQueue<Integer> q = new PriorityQueue<>((a, b) -> b - a);
        for (int i : cnt) {
            if (i > 0) {
                q.offer(i);
            }
        }
        int res = 0;
        int l = 0;
        while (!q.isEmpty()) {
            l += q.poll();
            res++;
            if (l >= n / 2) {
                break;
            }
        }
        return res;
    }
}

性能

2931.购买物品的最大开销

目标

给你一个下标从 0 开始大小为 m * n 的整数矩阵 values ,表示 m 个不同商店里 m * n 件不同的物品。每个商店有 n 件物品,第 i 个商店的第 j 件物品的价值为 values[i][j] 。除此以外,第 i 个商店的物品已经按照价值非递增排好序了,也就是说对于所有 0 <= j < n - 1 都有 values[i][j] >= values[i][j + 1]

每一天,你可以在一个商店里购买一件物品。具体来说,在第 d 天,你可以:

  • 选择商店 i 。
  • 购买数组中最右边的物品 j ,开销为 values[i][j] * d 。换句话说,选择该商店中还没购买过的物品中最大的下标 j ,并且花费 values[i][j] * d 去购买。

注意,所有物品都视为不同的物品。比方说如果你已经从商店 1 购买了物品 0 ,你还可以在别的商店里购买其他商店的物品 0 。

请你返回购买所有 m * n 件物品需要的 最大开销 。

示例 1:

输入:values = [[8,5,2],[6,4,1],[9,7,3]]
输出:285
解释:第一天,从商店 1 购买物品 2 ,开销为 values[1][2] * 1 = 1 。
第二天,从商店 0 购买物品 2 ,开销为 values[0][2] * 2 = 4 。
第三天,从商店 2 购买物品 2 ,开销为 values[2][2] * 3 = 9 。
第四天,从商店 1 购买物品 1 ,开销为 values[1][1] * 4 = 16 。
第五天,从商店 0 购买物品 1 ,开销为 values[0][1] * 5 = 25 。
第六天,从商店 1 购买物品 0 ,开销为 values[1][0] * 6 = 36 。
第七天,从商店 2 购买物品 1 ,开销为 values[2][1] * 7 = 49 。
第八天,从商店 0 购买物品 0 ,开销为 values[0][0] * 8 = 64 。
第九天,从商店 2 购买物品 0 ,开销为 values[2][0] * 9 = 81 。
所以总开销为 285 。
285 是购买所有 m * n 件物品的最大总开销。

示例 2:

输入:values = [[10,8,6,4,2],[9,7,5,3,2]]
输出:386
解释:第一天,从商店 0 购买物品 4 ,开销为 values[0][4] * 1 = 2 。
第二天,从商店 1 购买物品 4 ,开销为 values[1][4] * 2 = 4 。
第三天,从商店 1 购买物品 3 ,开销为 values[1][3] * 3 = 9 。
第四天,从商店 0 购买物品 3 ,开销为 values[0][3] * 4 = 16 。
第五天,从商店 1 购买物品 2 ,开销为 values[1][2] * 5 = 25 。
第六天,从商店 0 购买物品 2 ,开销为 values[0][2] * 6 = 36 。
第七天,从商店 1 购买物品 1 ,开销为 values[1][1] * 7 = 49 。
第八天,从商店 0 购买物品 1 ,开销为 values[0][1] * 8 = 64 。
第九天,从商店 1 购买物品 0 ,开销为 values[1][0] * 9 = 81 。
第十天,从商店 0 购买物品 0 ,开销为 values[0][0] * 10 = 100 。
所以总开销为 386 。
386 是购买所有 m * n 件物品的最大总开销。

说明:

  • 1 <= m == values.length <= 10
  • 1 <= n == values[i].length <= 10^4
  • 1 <= values[i][j] <= 10^6
  • values[i] 按照非递增顺序排序。

思路

m 个商店,每个商店里有 n 个商品。values[i][j] 表示商店 i 中商品 j 的价值,values[i] 非严格递减。从第一天开始,在第 d 天,我们可以选择一个商店 i,花费 d * values[i][j] 购买剩余的商品中价值中最小的商品 j。返回购买完所有商店的所有商品所需的最大开销。

要使开销最大,我们应该优先购买价值最小的商品,因为开销有天数加成,价值越大,在后面买翻倍后开销更大。由于每个商店里的商品是非严格递减的,每次从所有商店末尾取价值最小的商品,实际上就是按照所有商店的所有商品的价值从小到大取。

我们可以使用最小堆维护每个商店的最后一个商品,堆中元素记录商店编号 i,与商品下标 j

如果是求最小开销呢?

如果没有限制购买规则,显然先购买高价值商品花费最小。但是题目规定每一次任选一个商店从其剩余商品中的最后一个(剩余商品中价值最低的)商品购买。

我们应该从 左边 开始优先购买价值 最小 的商品,然后将顺序 反转 就得到了从右购买的序列,按照这个顺序计算购买的开销。

注意:不能从右边选最大的买,比如:

100000 90000 1
100    10    2

如果从右边取最大的,购买顺序为 2 10 100 1 90000 100000,这显然不是最小开销。而从左边取最小的买,顺序为 100 10 2 100000 90000 1,反转得到 1 90000 100000 2 10 100 按此顺序购买花费最小。

代码


/**
 * @date 2024-12-12 14:11
 */
public class MaxSpending2931 {

    /**
     * 13ms
     * 直接合并为一维数组后排序
     * 时间复杂度 O(mnlog(mn))
     * 执行快是因为不用维护堆以及操作堆中数据的值,每次计算的复杂度小于维护数据结构的复杂度
     */
    public long maxSpending_v2(int[][] values) {
        int m = values.length;
        int n = values[0].length;
        long res = 0L;
        long d = 1L;
        int[] v = new int[m * n];
        for (int i = 0; i < m; i++) {
            System.arraycopy(values[i], 0, v, i * n, n);
        }
        Arrays.sort(v);
        for (int i : v) {
            res += d++ * i;
        }
        return res;
    }

    /**
     * 37ms
     * 时间复杂度 O(mnlog(m))
     */
    public long maxSpending(int[][] values) {
        int m = values.length;
        int n = values[0].length;
        PriorityQueue<int[]> q = new PriorityQueue<>(m, (a, b) -> a[0] - b[0]);
        for (int i = 0; i < m; i++) {
            q.offer(new int[]{values[i][n - 1], i, n - 1});
        }
        long res = 0L;
        long d = 1L;
        while (!q.isEmpty()) {
            int[] goods = q.poll();
            int storeIndex = goods[1];
            res += d++ * goods[0];
            int p = --goods[2];
            if (p >= 0) {
                q.offer(new int[]{values[storeIndex][p], storeIndex, p});
            }
        }
        return res;
    }

}

性能

743.网络延迟时间

目标

有 n 个网络节点,标记为 1 到 n。

给你一个列表 times,表示信号经过 有向 边的传递时间。 times[i] = (ui, vi, wi),其中 ui 是源节点,vi 是目标节点, wi 是一个信号从源节点传递到目标节点的时间。

现在,从某个节点 K 发出一个信号。需要多久才能使所有节点都收到信号?如果不能使所有节点收到信号,返回 -1 。

示例 1:

输入:times = [[2,1,1],[2,3,1],[3,4,1]], n = 4, k = 2
输出:2

示例 2:

输入:times = [[1,2,1]], n = 2, k = 1
输出:1

示例 3:

输入:times = [[1,2,1]], n = 2, k = 2
输出:-1

说明:

  • 1 <= k <= n <= 100
  • 1 <= times.length <= 6000
  • times[i].length == 3
  • 1 <= ui, vi <= n
  • ui != vi
  • 0 <= wi <= 100
  • 所有 (ui, vi) 对都 互不相同(即,不含重复边)

思路

有一个 n 个节点的有向图 ,节点标记为 1 ~ n,求从其中某个节点 k 出发访问到所有其它节点的最短时间。

即从 k 出发求出到达所有其它节点的最短路径,然后取其中的最 值。

Floyd 算法的基本思想是动态规划。定义 dp[i][j] 表示从节点 i 到 节点 j 的最短路径,对于所有其它中间节点 m,更新 dp[i][j] = Math.min(dp[i][j], dp[i][m] + dp[m][j]),时间复杂度 O(n^3)。

如果 i -> j 有直接的通路则初始化 dp[i][j] 为路径的权值,否则为 INF

但是本题不需要其它起点的最短路径,因此可以使用 Dijkstra 算法、Bellman-Ford 算法 或者 SPFA 算法。

图的表示可以使用邻接矩阵、邻接表、前向星、链式前向星等结构。

代码


/**
 * @date 2024-11-25 9:08
 */
public class NetworkDelayTime743 {

    public int networkDelayTime(int[][] times, int n, int k) {
        int[][] dp = new int[n + 1][n + 1];
        for (int[] cost : dp) {
            Arrays.fill(cost, 20000);
        }
        for (int[] edge : times) {
            dp[edge[0]][edge[1]] = edge[2];
        }
        for (int m = 1; m <= n; m++) {
            for (int i = 1; i <= n; i++) {
                for (int j = 1; j <= n; j++) {
                    if (i == j || i == m || j == m) {
                        continue;
                    }
                    dp[i][j] = Math.min(dp[i][j], dp[i][m] + dp[m][j]);
                }
            }
        }
        int res = -1;
        for (int i = 1; i <= n; i++) {
            if (i == k) {
                continue;
            }
            res = Math.max(dp[k][i], res);
        }
        return res == 20000 ? -1 : res;
    }

}

性能

3244.新增道路查询后的最短距离II

目标

给你一个整数 n 和一个二维整数数组 queries。

有 n 个城市,编号从 0 到 n - 1。初始时,每个城市 i 都有一条单向道路通往城市 i + 1( 0 <= i < n - 1)。

queries[i] = [ui, vi] 表示新建一条从城市 ui 到城市 vi 的单向道路。每次查询后,你需要找到从城市 0 到城市 n - 1 的最短路径的长度。

所有查询中不会存在两个查询都满足 queries[i][0] < queries[j][0] < queries[i][1] < queries[j][1]

返回一个数组 answer,对于范围 [0, queries.length - 1] 中的每个 i,answer[i] 是处理完前 i + 1 个查询后,从城市 0 到城市 n - 1 的最短路径的长度。

示例 1:

输入: n = 5, queries = [[2, 4], [0, 2], [0, 4]]
输出: [3, 2, 1]
解释:
新增一条从 2 到 4 的道路后,从 0 到 4 的最短路径长度为 3。
新增一条从 0 到 2 的道路后,从 0 到 4 的最短路径长度为 2。
新增一条从 0 到 4 的道路后,从 0 到 4 的最短路径长度为 1。

示例 2:

输入: n = 4, queries = [[0, 3], [0, 2]]
输出: [1, 1]
解释:
新增一条从 0 到 3 的道路后,从 0 到 3 的最短路径长度为 1。
新增一条从 0 到 2 的道路后,从 0 到 3 的最短路径长度仍为 1。

说明:

  • 3 <= n <= 10^5
  • 1 <= queries.length <= 10^5
  • queries[i].length == 2
  • 0 <= queries[i][0] < queries[i][1] < n
  • 1 < queries[i][1] - queries[i][0]
  • 查询中不存在重复的道路。
  • 不存在两个查询都满足 i != j 且 queries[i][0] < queries[j][0] < queries[i][1] < queries[j][1]

思路

n 个城市,刚开始每一个城市 i 有一条单项道路通向城市 i + 1,有一个二维数组 queriesqueries[i] 表示增加一条从 queries[i][0]queries[i][1] 的单项道路,返回 answer 数组,answer[i] 表示增加了 queries[i] 之后从城市 0 到城市 n - 1 的最短路径。增加的路径保证不会出现相交的情况。

比昨天的题 3243.新增道路查询后的最短距离I 多了一个条件,新增的路径不会交叉。昨天首先考虑的就是今天这个思路,因为起点与终点是固定的,可以通过查询区间的关系来减小最短路径。当时考虑的差分数组解决不了区间包含的关系。

定义区间数组 intervalinterval[i] = j, 表示 i -> j 有直达道路。如果发现查询的路径 [l, r] 已经被包含,直接略过。否则,循环记录区间 (l, r) 内的路径数,保存下一跳的城市 next = interval[l],将 interval[l] 置为 r,l = next 直到 l >= r。注意最后一跳到 r 是没有计数的,相当于减去了将前面多余的步数,与直达的效果一样。 interval[l] = r 是第一次进入循环必须进行的操作,幸运的是它并不影响我们标记其它区间内的元素,当有更大的查询路径时,直接在 l 处就跳过了。

核心点是如何表示区间,如何判断区间是否重合,如何针对大区间减少的路径计数。

代码


/**
 * @date 2024-11-20 10:10
 */
public class ShortestDistanceAfterQueries3244 {

    public int[] shortestDistanceAfterQueries(int n, int[][] queries) {
        int ql = queries.length;
        int[] res = new int[ql];
        int[] interval = new int[n - 1];
        Arrays.setAll(interval, i -> i + 1);
        int shortestPath = n - 1;
        for (int i = 0; i < ql; i++) {
            int l = queries[i][0];
            int r = queries[i][1];
            while (interval[l] < r) {
                int next = interval[l];
                interval[l] = r;
                l = next;
                shortestPath--;
            }
            res[i] = shortestPath;
        }
        return res;
    }

}

性能