从周赛中学算法-2023上

从周赛中学算法-2023上

https://leetcode.cn/circle/discuss/v2RXSN/

文章目录

  • 从周赛中学算法-2023上
  • 一、技巧类
    • [2730. 找到最长的半重复子字符串](https://leetcode.cn/problems/find-the-longest-semi-repetitive-substring/)
    • [2698. 求一个整数的惩罚数](https://leetcode.cn/problems/find-the-punishment-number-of-an-integer/)
    • [2563. 统计公平数对的数目](https://leetcode.cn/problems/count-the-number-of-fair-pairs/)【二分查找应用题】
    • [2653. 滑动子数组的美丽值](https://leetcode.cn/problems/sliding-subarray-beauty/)
    • [2718. 查询后矩阵的和](https://leetcode.cn/problems/sum-of-matrix-after-queries/)
    • [2576. 求出最多标记下标](https://leetcode.cn/problems/find-the-maximum-number-of-marked-indices/)
    • [2537. 统计好子数组的数目](https://leetcode.cn/problems/count-the-number-of-good-subarrays/)
    • [2602. 使数组元素全部相等的最少操作次数](https://leetcode.cn/problems/minimum-operations-to-make-all-array-elements-equal/)
    • [2594. 修车的最少时间](https://leetcode.cn/problems/minimum-time-to-repair-cars/)
    • [2681. 英雄的力量](https://leetcode.cn/problems/power-of-heroes/)【贡献法】
    • [2555. 两个线段获得的最多奖品](https://leetcode.cn/problems/maximize-win-from-two-segments/)
    • [2560. 打家劫舍 IV](https://leetcode.cn/problems/house-robber-iv/)
    • [2616. 最小化数对的最大差值](https://leetcode.cn/problems/minimize-the-maximum-difference-of-pairs/)
      • 二分答案题单
    • [2528. 最大化城市的最小供电站数目](https://leetcode.cn/problems/maximize-the-minimum-powered-city/)
    • [2565. 最少得分子序列](https://leetcode.cn/problems/subsequence-with-the-minimum-score/)
    • [2552. 统计上升四元组](https://leetcode.cn/problems/count-increasing-quadruplets/)
  • 二、动态规划
    • [2606. 找到最大开销的子字符串](https://leetcode.cn/problems/find-the-substring-with-maximum-cost/)
    • [2707. 字符串中的额外字符](https://leetcode.cn/problems/extra-characters-in-a-string/)
    • [2585. 获得分数的方法数](https://leetcode.cn/problems/number-of-ways-to-earn-points/)
    • [2547. 拆分数组的最小代价](https://leetcode.cn/problems/minimum-cost-to-split-an-array/)
    • [2597. 美丽子集的数目](https://leetcode.cn/problems/the-number-of-beautiful-subsets/)
    • [2581. 统计可能的树根数目](https://leetcode.cn/problems/count-number-of-possible-root-nodes/)
    • [2646. 最小化旅行的价格总和](https://leetcode.cn/problems/minimize-the-total-price-of-the-trips/)
    • [2719. 统计整数数目](https://leetcode.cn/problems/count-of-integers/)
    • [2713. 矩阵中严格递增的单元格数](https://leetcode.cn/problems/maximum-strictly-increasing-cells-in-a-matrix/)
    • [2538. 最大价值和与最小价值和的差值](https://leetcode.cn/problems/difference-between-maximum-and-minimum-price-sum/)
    • [2572. 无平方子集计数](https://leetcode.cn/problems/count-the-number-of-square-free-subsets/)
    • [2742. 给墙壁刷油漆](https://leetcode.cn/problems/painting-the-walls/)
    • [2617. 网格图中最少访问的格子数](https://leetcode.cn/problems/minimum-number-of-visited-cells-in-a-grid/)
  • 三、图论
    • [2641. 二叉树的堂兄弟节点 II](https://leetcode.cn/problems/cousins-in-binary-tree-ii/)
    • [2684. 矩阵中移动的最大次数](https://leetcode.cn/problems/maximum-number-of-moves-in-a-grid/)
    • [2685. 统计完全连通分量的数量](https://leetcode.cn/problems/count-the-number-of-complete-components/)
    • [2642. 设计可以求最短路径的图类](https://leetcode.cn/problems/design-graph-with-shortest-path-calculator/)
    • [2608. 图中的最短环](https://leetcode.cn/problems/shortest-cycle-in-a-graph/)
    • [2662. 前往目标的最小代价](https://leetcode.cn/problems/minimum-cost-of-a-path-with-special-roads/)
    • [2577. 在网格图中访问一个格子的最少时间](https://leetcode.cn/problems/minimum-time-to-visit-a-cell-in-a-grid/)
    • [2603. 收集树中金币](https://leetcode.cn/problems/collect-coins-in-a-tree/)
    • [2699. 修改图中的边权](https://leetcode.cn/problems/modify-graph-edge-weights/)

一、技巧类

技巧指一些比较套路的算法,包括双指针、滑动窗口、二分答案、前缀和、差分、回溯、前后缀分解、二进制枚举、贡献法等。

这些技巧相对容易掌握,无论是求职面试还是周赛,都是经常考察的,可以优先学习这些内容。

题目 难度 备注
2730. 找到最长的半重复子字符串 1502 双指针
2698. 求一个整数的惩罚数 1679 回溯
2563. 统计公平数对的数目 1721 排序+二分查找
2653. 滑动子数组的美丽值 1785 滑动窗口
2718. 查询后矩阵的和 1769 倒序处理
2576. 求出最多标记下标 1843 二分答案/双指针
2537. 统计好子数组的数目 1892 双指针
2602. 使数组元素全部相等的最少操作次数 1903 排序+前缀和+二分查找
2594. 修车的最少时间 1915 二分答案
2681. 英雄的力量 2060 贡献法
2555. 两个线段获得的最多奖品 2081 双指针
2560. 打家劫舍 IV 2081 二分答案+DP/贪心
2616. 最小化数对的最大差值 2155 二分答案+贪心
2528. 最大化城市的最小供电站数目 2236 二分答案+前缀和+差分+贪心
2565. 最少得分子序列 2432 前后缀分解
2552. 统计上升四元组 2433 有技巧的枚举

2730. 找到最长的半重复子字符串

难度中等9

给你一个下标从 0 开始的字符串 s ,这个字符串只包含 09 的数字字符。

如果一个字符串 t 中至多有一对相邻字符是相等的,那么称这个字符串 t半重复的 。例如,00100020200123200254944 是半重复字符串,而 001010221101234883 不是。

请你返回 s 中最长 半重复 子字符串的长度。

一个 子字符串 是一个字符串中一段连续 非空 的字符。

示例 1:

输入:s = "52233"
输出:4
解释:最长半重复子字符串是 "5223" ,子字符串从 i = 0 开始,在 j = 3 处结束。

示例 2:

输入:s = "5494"
输出:4
解释:s 就是一个半重复字符串,所以答案为 4 。

示例 3:

输入:s = "1111111"
输出:2
解释:最长半重复子字符串是 "11" ,子字符串从 i = 0 开始,在 j = 1 处结束。

提示:

  • 1 <= s.length <= 50
  • '0' <= s[i] <= '9'

题解:同向双指针

class Solution {
    public int longestSemiRepetitiveSubstring(String s) {
        int ans = 1;
        int left = 0;
        int cnt = 0; // cnt 记录窗口内半重复字符的出现次数
        for(int right = 1; right < s.length(); right++){
            if(s.charAt(right) == s.charAt(right-1))
                cnt += 1;
            while(cnt > 1){
                left += 1;
                if(s.charAt(left) == s.charAt(left-1))
                    cnt -= 1;
            }
            ans = Math.max(ans, right - left + 1);
        }
        return ans;
    }
}

2698. 求一个整数的惩罚数

难度中等21

给你一个正整数 n ,请你返回 n惩罚数

n惩罚数 定义为所有满足以下条件 i 的数的平方和:

  • 1 <= i <= n
  • i * i 的十进制表示的字符串可以分割成若干连续子字符串,且这些子字符串对应的整数值之和等于 i

示例 1:

输入:n = 10
输出:182
解释:总共有 3 个整数 i 满足要求:
- 1 ,因为 1 * 1 = 1
- 9 ,因为 9 * 9 = 81 ,且 81 可以分割成 8 + 1 。
- 10 ,因为 10 * 10 = 100 ,且 100 可以分割成 10 + 0 。
因此,10 的惩罚数为 1 + 81 + 100 = 182

示例 2:

输入:n = 37
输出:1478
解释:总共有 4 个整数 i 满足要求:
- 1 ,因为 1 * 1 = 1
- 9 ,因为 9 * 9 = 81 ,且 81 可以分割成 8 + 1 。
- 10 ,因为 10 * 10 = 100 ,且 100 可以分割成 10 + 0 。
- 36 ,因为 36 * 36 = 1296 ,且 1296 可以分割成 1 + 29 + 6 。
因此,37 的惩罚数为 1 + 81 + 100 + 1296 = 1478

提示:

  • 1 <= n <= 1000
class Solution {
    public int punishmentNumber(int n) {
        int ans = 0;
        for(int i = 1; i <= n; i++){
            if(dfs(0, i, String.valueOf(i*i))) ans += i * i;
        }
        return ans;
    }

    public boolean dfs(int i, int sum, String s){
        if(sum == 0 && i == s.length()) return true;
        if(sum < 0 || i == s.length()) return false;
        boolean res = false;
        for(int j = i; j < s.length() && !res; j++){
            res |= dfs(j+1, sum - Integer.valueOf(s.substring(i, j+1)), s);
        } 
        return res;
    }
}

python

class Solution:
    def punishmentNumber(self, n: int) -> int:
        def dfs(i, sum: int, s: str) -> bool:
            if sum == 0 and i == len(s): return True
            if sum < 0 or i == len(s): return False
            for j in range(i, len(s)):
                if dfs(j+1, sum - int(s[i:j+1]), s):
                    return True
            return False

        ans = 0
        for num in range(1, n+1):
            if dfs(0, num, str(num * num)):
                ans += num * num
        return ans

2563. 统计公平数对的数目【二分查找应用题】

难度中等34

给你一个下标从 0 开始、长度为 n 的整数数组 nums ,和两个整数 lowerupper ,返回 公平数对的数目

如果 (i, j) 数对满足以下情况,则认为它是一个 公平数对

  • 0 <= i < j < n,且
  • lower <= nums[i] + nums[j] <= upper

示例 1:

输入:nums = [0,1,7,4,4,5], lower = 3, upper = 6
输出:6
解释:共计 6 个公平数对:(0,3)、(0,4)、(0,5)、(1,3)、(1,4) 和 (1,5) 。

示例 2:

输入:nums = [1,7,9,2,5], lower = 11, upper = 11
输出:1
解释:只有单个公平数对:(2,3) 。

提示:

  • 1 <= nums.length <= 105
  • nums.length == n
  • -109 <= nums[i] <= 109
  • -109 <= lower <= upper <= 109
class Solution {
    // 可以看到要求与元素顺序无关,可以先排序
    // 枚举左端点,寻找右端点区间
    // lower <= nums[i] + nums[j] <= upper
    // lower - nums[i] <= nums[j] <= upper - nums[i]
    public long countFairPairs(int[] nums, int lower, int upper) {
        Arrays.sort(nums);
        long ans = 0l;
        int n = nums.length;
        // 0, 1, 4, 4, 5, 7
        for(int i = 0; i < n; i++){
            // left : 在[i+1,n)区间中找到第一个大于等于lower - nums[i]的元素下标
            int left = lowerBound(nums, i+1, lower - nums[i]);
            // right : 在[i+1,n)区间中找到最后一个大于upper - nums[i]的元素下标
            //		最后一个大于upper - nums[i] = 大于等于upper - nums[i]+1的第一个元素下标 - 1
            int right = lowerBound(nums, i+1, upper - nums[i] + 1) - 1;
            ans += right - left + 1; 
            //	
        }
        return ans;
    }

    // lower_bound : 返回大于等于key的第一个元素下标
    public int lowerBound(int[] nums, int left, int target){
        int right = nums.length;
        while(left < right){
            int mid = (left + right) >> 1;
            if(nums[mid] < target) left = mid + 1;
            else right = mid;
        }
        return left;
    }
}

python

class Solution:
    def countFairPairs(self, nums: List[int], lower: int, upper: int) -> int:
        nums.sort()
        ans = 0
        for i, x in enumerate(nums):
            left = bisect_left(nums, lower - x, i+1, len(nums))
            right = bisect_left(nums, upper - x + 1, i+1, len(nums)) - 1
            ans += right - left + 1
        return ans

2653. 滑动子数组的美丽值

难度中等27

给你一个长度为 n 的整数数组 nums ,请你求出每个长度为 k 的子数组的 美丽值

一个子数组的 美丽值 定义为:如果子数组中第 x 小整数负数 ,那么美丽值为第 x 小的数,否则美丽值为 0

请你返回一个包含 n - k + 1 个整数的数组,依次 表示数组中从第一个下标开始,每个长度为 k 的子数组的 美丽值

  • 子数组指的是数组中一段连续 非空 的元素序列。

示例 1:

输入:nums = [1,-1,-3,-2,3], k = 3, x = 2
输出:[-1,-2,-2]
解释:总共有 3 个 k = 3 的子数组。
第一个子数组是 [1, -1, -3] ,第二小的数是负数 -1 。
第二个子数组是 [-1, -3, -2] ,第二小的数是负数 -2 。
第三个子数组是 [-3, -2, 3] ,第二小的数是负数 -2 。

示例 2:

输入:nums = [-1,-2,-3,-4,-5], k = 2, x = 2
输出:[-1,-2,-3,-4]
解释:总共有 4 个 k = 2 的子数组。
[-1, -2] 中第二小的数是负数 -1 。
[-2, -3] 中第二小的数是负数 -2 。
[-3, -4] 中第二小的数是负数 -3 。
[-4, -5] 中第二小的数是负数 -4 。

示例 3:

输入:nums = [-3,1,2,-3,0,-3], k = 2, x = 1
输出:[-3,0,-3,-3,-3]
解释:总共有 5 个 k = 2 的子数组。
[-3, 1] 中最小的数是负数 -3 。
[1, 2] 中最小的数不是负数,所以美丽值为 0 。
[2, -3] 中最小的数是负数 -3 。
[-3, 0] 中最小的数是负数 -3 。
[0, -3] 中最小的数是负数 -3 。

提示:

  • n == nums.length
  • 1 <= n <= 105
  • 1 <= k <= n
  • 1 <= x <= k
  • -50 <= nums[i] <= 50
class Solution {
    // 注意到值域 -50 <= nums[i] <= 50,可以使用计数排序思想来寻找第 x 小的整数
    // 从小到大遍历窗口内的计数数组,当遍历个数 >= x 时,说明找到了第x小的数
    public int[] getSubarrayBeauty(int[] nums, int k, int x) {
        int n = nums.length;
        int[] ans = new int[n-k+1];
        int[] cnt = new int[105];
        int diff = 50; // 因为取值有负数,所以统一加上偏移量
        for(int i = 0; i < k-1; i++)
            cnt[nums[i] + diff] += 1;

        for(int i = k-1; i < n; i++){
            cnt[nums[i] + diff] += 1;
            int t = 0, j = 0; // t 从小到大统计窗口内元素的出现个数; j第x小的元素在cnt数组中的下标
            for(; j <= 50; j++){
                t += cnt[j];
                if(t >= x) break;
            }
            cnt[nums[i - k + 1] + diff] -= 1;

            ans[i - k + 1] = j - diff < 0 ? j - diff : 0;
        }
        return ans;
    }
}

python

2718. 查询后矩阵的和

难度中等29

给你一个整数 n 和一个下标从 0 开始的 二维数组 queries ,其中 queries[i] = [typei, indexi, vali]

一开始,给你一个下标从 0 开始的 n x n 矩阵,所有元素均为 0 。每一个查询,你需要执行以下操作之一:

  • 如果 typei == 0 ,将第 indexi 行的元素全部修改为 vali ,覆盖任何之前的值。
  • 如果 typei == 1 ,将第 indexi 列的元素全部修改为 vali ,覆盖任何之前的值。

请你执行完所有查询以后,返回矩阵中所有整数的和。

class Solution {
    // 如果操作的是行,那么需要知道:
    //      1. 这一行之前有没有被操作过,
    //      2. 这一行有多少列之前操作过
    public long matrixSumQueries(int n, int[][] queries) {
        long ans = 0;
        Set<Integer>[] vis = new Set[]{new HashSet<>(), new HashSet<>()};
        for(int i = queries.length-1; i >= 0; i--){
            int[] q = queries[i];
            int type = q[0], index = q[1], val = q[2];
            if(!vis[type].contains(index)){ // 后面(>i)没有对这一行/列的操作
                // 这一行/列还剩下 n-vis[type^1].size() 个可以填入的格子
                ans += (long)(n - vis[type ^ 1].size()) * val;
                vis[type].add(index);
            }
        }
        return ans;
    }
}

2576. 求出最多标记下标

难度中等40

给你一个下标从 0 开始的整数数组 nums

一开始,所有下标都没有被标记。你可以执行以下操作任意次:

  • 选择两个 互不相同且未标记 的下标 ij ,满足 2 * nums[i] <= nums[j] ,标记下标 ij

请你执行上述操作任意次,返回 nums 中最多可以标记的下标数目。

示例 1:

输入:nums = [3,5,2,4]
输出:2
解释:第一次操作中,选择 i = 2 和 j = 1 ,操作可以执行的原因是 2 * nums[2] <= nums[1] ,标记下标 2 和 1 。
没有其他更多可执行的操作,所以答案为 2 。

示例 2:

输入:nums = [9,2,5,4]
输出:4
解释:第一次操作中,选择 i = 3 和 j = 0 ,操作可以执行的原因是 2 * nums[3] <= nums[0] ,标记下标 3 和 0 。
第二次操作中,选择 i = 1 和 j = 2 ,操作可以执行的原因是 2 * nums[1] <= nums[2] ,标记下标 1 和 2 。
没有其他更多可执行的操作,所以答案为 4 。

示例 3:

输入:nums = [7,6,8]
输出:0
解释:没有任何可以执行的操作,所以答案为 0 。

提示:

  • 1 <= nums.length <= 105
  • 1 <= nums[i] <= 109

方法一:同向双指针

  • 我们需要用左半部分的数,去匹配右半部分的数。
class Solution {
    public int maxNumOfMarkedIndices(int[] nums) {
        int n = nums.length;
        Arrays.sort(nums);
        int left = 0, right = n / 2;
        int ans = 0;
        while(left < n / 2 && right < n){
            if((long)(2 * nums[left]) <= (long)nums[right]){
                left += 1;
                right += 1;
                ans += 2;
            }else right += 1;
        }
        return ans;
    }
}

python

class Solution:
    def maxNumOfMarkedIndices(self, nums: List[int]) -> int:
        nums.sort()
        n = len(nums)
        ans = 0
        left, right = 0, n // 2
        while left < n // 2 and right < n:
            if (nums[left] * 2) <= nums[right]:
                left += 1
                right += 1
                ans += 2
            else:
                right += 1
        return ans

方法二:二分答案

class Solution {
    /**
    二分答案:如果可以匹配 k 对,那么也可以匹配 
    public int maxNumOfMarkedIndices(int[] nums) {
        Arrays.sort(nums);
        int n = nums.length;
        int left = -1, right = n / 2;
        while(left < right){
            int mid = (left + right + 1) >> 1;
            if(check(nums, mid)) left = mid;
            else right = mid - 1;
        }
        return right * 2;
    }
    
    public boolean check(int[] nums, int k){
        int n = nums.length;
        for(int i = 0; i < k; i++){
            if((long)(2 * nums[i]) > (long)(nums[n - k + i]))
                return false;
        }
        return true;
    }
}

python

class Solution:
    def maxNumOfMarkedIndices(self, nums: List[int]) -> int:
        nums.sort()
        left, right = 0, len(nums) // 2 + 1
        while left + 1 < right:
            k = (left + right) // 2
            if all(nums[i] * 2 <= nums[i - k] for i in range(k)):
                left = k
            else:
                right = k
        return left * 2

2537. 统计好子数组的数目

难度中等34

给你一个整数数组 nums 和一个整数 k ,请你返回 nums 子数组的数目。

一个子数组 arr 如果有 至少 k 对下标 (i, j) 满足 i < jarr[i] == arr[j] ,那么称它是一个 子数组。

子数组 是原数组中一段连续 非空 的元素序列。

示例 1:

输入:nums = [1,1,1,1,1], k = 10
输出:1
解释:唯一的好子数组是这个数组本身。

示例 2:

输入:nums = [3,1,4,3,2,2,4], k = 2
输出:4
解释:总共有 4 个不同的好子数组:
- [3,1,4,3,2,2] 有 2 对。
- [3,1,4,3,2,2,4] 有 3 对。
- [1,4,3,2,2,4] 有 2 对。
- [4,3,2,2,4] 有 2 对。

提示:

  • 1 <= nums.length <= 105
  • 1 <= nums[i], k <= 109

方法一:双指针

关键是窗口中的对数如何计算

  • 如果当前窗口中已经有c个元素x了,再来一个x,会增加c对
  • 如果当前窗口中已经有c个元素x了,去掉一个x,会减少c-1对
class Solution {
    // 子数组统计问题,常用双指针(不定长滑动窗口)实现
    public long countGood(int[] nums, int k) {
        Map<Integer, Integer> cnt = new HashMap<>();
        long ans = 0;
        int left = 0, pairs = 0;
        for(int x : nums){
            pairs += cnt.getOrDefault(x, 0);
            cnt.merge(x, 1, Integer::sum); // 移入右端点
            // [0,right], [1,right], ..., [left, right] 这些都是好子数组,一共有 left+1 个。
            ans += left;
            while(pairs >= k){
                pairs -= cnt.merge(nums[left++], -1, Integer::sum);
                ans++;
            }
        }
        return ans;
    }
}

写法二:

class Solution {
    // 子数组统计问题,常用双指针(不定长滑动窗口)实现
    public long countGood(int[] nums, int k) {
        Map<Integer, Integer> cnt = new HashMap<>();
        long ans = 0;
        int left = 0, pairs = 0;
        for(int x : nums){
            pairs += cnt.getOrDefault(x, 0);
            cnt.merge(x, 1, Integer::sum); // 移入右端点
            while(pairs - cnt.get(nums[left]) + 1 >= k){
                pairs -= cnt.merge(nums[left++], -1, Integer::sum); // 移出左端点
            }
            if(pairs >= k) ans += left + 1;
        }
        return ans;
    }
}

2602. 使数组元素全部相等的最少操作次数

难度中等25

给你一个正整数数组 nums

同时给你一个长度为 m 的整数数组 queries 。第 i 个查询中,你需要将 nums 中所有元素变成 queries[i] 。你可以执行以下操作 任意 次:

  • 将数组里一个元素 增大 或者 减小 1

请你返回一个长度为 m 的数组 answer ,其中 answer[i]是将 nums 中所有元素变成 queries[i]最少 操作次数。

注意,每次查询后,数组变回最开始的值。

示例 1:

输入:nums = [3,1,6,8], queries = [1,5]
输出:[14,10]
解释:第一个查询,我们可以执行以下操作:
- 将 nums[0] 减小 2 次,nums = [1,1,6,8] 。
- 将 nums[2] 减小 5 次,nums = [1,1,1,8] 。
- 将 nums[3] 减小 7 次,nums = [1,1,1,1] 。
第一个查询的总操作次数为 2 + 5 + 7 = 14 。
第二个查询,我们可以执行以下操作:
- 将 nums[0] 增大 2 次,nums = [5,1,6,8] 。
- 将 nums[1] 增大 4 次,nums = [5,5,6,8] 。
- 将 nums[2] 减小 1 次,nums = [5,5,5,8] 。
- 将 nums[3] 减小 3 次,nums = [5,5,5,5] 。
第二个查询的总操作次数为 2 + 4 + 1 + 3 = 10 。

示例 2:

输入:nums = [2,9,6,3], queries = [10]
输出:[20]
解释:我们可以将数组中所有元素都增大到 10 ,总操作次数为 8 + 1 + 4 + 7 = 20 。

提示:

  • n == nums.length
  • m == queries.length
  • 1 <= n, m <= 105
  • 1 <= nums[i], queries[i] <= 109

题解:排序 + 前缀和 + 二分

class Solution {
    // 排序 + 前缀和 + 二分
    public List<Long> minOperations(int[] nums, int[] queries) {
        List<Long> ans = new ArrayList<>();
        Arrays.sort(nums);
        int n = nums.length;
        long[] sum = new long[n+1];
        for(int i = 0; i < n; i++)
            sum[i+1] = sum[i] + nums[i];
        for(int q : queries){
            int left = 0, right = n;
            // 二分找到第一个大于等于 q 的元素下标
            while(left < right){
                int mid = (left + right) >> 1;
                if(nums[mid] < q) left = mid + 1;
                else right = mid;
            }
            // 在left=right左边的都比q小,右边的都比q大
            // 用目标和-前缀和求左边的操作数 用前缀和-目标和求右边的操作数
            long curl = (long)q * (long)right - sum[right]; //左边: 目标和 - 前缀和
            long curr = sum[n] - sum[right] - (long)q * (long)(n-right); // 右边:前缀和 - 目标和
            ans.add(curl + curr);
        }   
        return ans;
    }
}

2594. 修车的最少时间

难度中等22

给你一个整数数组 ranks ,表示一些机械工的 能力值ranksi 是第 i 位机械工的能力值。能力值为 r 的机械工可以在 r * n2 分钟内修好 n 辆车。

同时给你一个整数 cars ,表示总共需要修理的汽车数目。

请你返回修理所有汽车 最少 需要多少时间。

**注意:**所有机械工可以同时修理汽车。

示例 1:

输入:ranks = [4,2,3,1], cars = 10
输出:16
解释:
- 第一位机械工修 2 辆车,需要 4 * 2 * 2 = 16 分钟。
- 第二位机械工修 2 辆车,需要 2 * 2 * 2 = 8 分钟。
- 第三位机械工修 2 辆车,需要 3 * 2 * 2 = 12 分钟。
- 第四位机械工修 4 辆车,需要 1 * 4 * 4 = 16 分钟。
16 分钟是修理完所有车需要的最少时间。

示例 2:

输入:ranks = [5,1,8], cars = 6
输出:16
解释:
- 第一位机械工修 1 辆车,需要 5 * 1 * 1 = 5 分钟。
- 第二位机械工修 4 辆车,需要 1 * 4 * 4 = 16 分钟。
- 第三位机械工修 1 辆车,需要 8 * 1 * 1 = 8 分钟。
16 分钟时修理完所有车需要的最少时间。

提示:

  • 1 <= ranks.length <= 105
  • 1 <= ranks[i] <= 100
  • 1 <= cars <= 106

题解:二分答案

class Solution {
    public long repairCars(int[] ranks, int cars) {
        long left = 0, right = (long)1e18;
        while(left < right){
            long mid = (left + right) >> 1;
            if(!check(ranks, cars, mid)) left = mid + 1;
            else right = mid;
        }
        return left;
    }

    // n 个机械工能否在 times 时间内修好 cars 辆车
    // 在timee时间内,查看每个机械工最多能修多少辆车,求sum,查看能否超过cars
    public boolean check(int[] ranks, int cars, long times){
        int cnt = 0;
        for(int rank : ranks){
            cnt += (int)Math.sqrt(times / rank);
            if(cnt >= cars) return true;
        }
        return false;
    }
}

2681. 英雄的力量【贡献法】

难度困难16

给你一个下标从 0 开始的整数数组 nums ,它表示英雄的能力值。如果我们选出一部分英雄,这组英雄的 力量 定义为:

  • i0i1 ,… ik 表示这组英雄在数组中的下标。那么这组英雄的力量为 max(nums[i0],nums[i1] ... nums[ik])2 * min(nums[i0],nums[i1] ... nums[ik])

请你返回所有可能的 非空 英雄组的 力量 之和。由于答案可能非常大,请你将结果对 109 + 7 取余。

示例 1:

输入:nums = [2,1,4]
输出:141
解释:
第 1 组:[2] 的力量为 22 * 2 = 8 。
第 2 组:[1] 的力量为 12 * 1 = 1 。
第 3 组:[4] 的力量为 42 * 4 = 64 。
第 4 组:[2,1] 的力量为 22 * 1 = 4 。
第 5 组:[2,4] 的力量为 42 * 2 = 32 。
第 6 组:[1,4] 的力量为 42 * 1 = 16 。
第 7 组:[2,1,4] 的力量为 42 * 1 = 16 。
所有英雄组的力量之和为 8 + 1 + 64 + 4 + 32 + 16 + 16 = 141 。

示例 2:

输入:nums = [1,1,1]
输出:7
解释:总共有 7 个英雄组,每一组的力量都是 1 。所以所有英雄组的力量之和为 7 。

提示:

  • 1 <= nums.length <= 105
  • 1 <= nums[i] <= 109

题解:

元素的顺序不影响答案,先排序。

枚举 x=nums[i] 作为最大值。

x 一个数的力量为 x^ 3 。(单个数的力量是自身的三次方)

其余不超过 nums[i] 的数字作为最小值时,贡献是多少?

例如有 a,b,c,d,e 五个数,且顺序从小到大。

枚举到 d 时:

  • a 作为最小值,中间的 b 和 c 可选可不选,一共有 2^2 种方案,所以 a 的贡献是 a⋅2^2

  • b 作为最小值,中间的 c 可选可不选,一共有 2^ 1种方案,所以 b 的贡献是 b⋅2^1

  • c 作为最小值,一共有 2^0 种方案,所以 c 的贡献是 c⋅2^0

这些元素作为最小值的贡献为a⋅2^2 + b⋅2^1 + c⋅2^0,记作 s。

那么 d 及其左侧元素对答案的贡献为d^3 +d^2⋅s=d^2⋅(d+s)

继续,枚举到 e 时:a,b,c,d 作为最小值的贡献为a⋅2^3+b⋅2^2+c⋅2^1+d⋅2^0 = 2⋅s+d

上式为新的 s,即s=2⋅s+nums[i]

利用这一递推式,就可以 O(1) 地计算出排序后的每个 nums[i] 左侧元素作为最小值的贡献。

class Solution {
    /**
    贡献法,
     */
    private static final int MOD = (int)1e9+7;
    public int sumOfPower(int[] nums) {
        Arrays.sort(nums);
        long ans = 0, s = 0;
        for(long x : nums){
            ans = (ans + x*x % MOD *(x + s)) % MOD; // 中间模一次防止溢出
            s = (s * 2 + x) % MOD;
        }
        return (int)ans;
    }
}

专题训练:贡献法

  • 907. 子数组的最小值之和
  • 1856. 子数组最小乘积的最大值
  • 2104. 子数组范围和
  • 2281. 巫师的总力量和

2555. 两个线段获得的最多奖品

难度中等34

X轴 上有一些奖品。给你一个整数数组 prizePositions ,它按照 非递减 顺序排列,其中 prizePositions[i] 是第 i 件奖品的位置。数轴上一个位置可能会有多件奖品。再给你一个整数 k

你可以选择两个端点为整数的线段。每个线段的长度都必须是 k 。你可以获得位置在任一线段上的所有奖品(包括线段的两个端点)。注意,两个线段可能会有相交。

  • 比方说 k = 2 ,你可以选择线段 [1, 3][2, 4] ,你可以获得满足 1 <= prizePositions[i] <= 3 或者 2 <= prizePositions[i] <= 4 的所有奖品 i 。

请你返回在选择两个最优线段的前提下,可以获得的 最多 奖品数目。

示例 1:

输入:prizePositions = [1,1,2,2,3,3,5], k = 2
输出:7
解释:这个例子中,你可以选择线段 [1, 3] 和 [3, 5] ,获得 7 个奖品。

示例 2:

输入:prizePositions = [1,2,3,4], k = 0
输出:2
解释:这个例子中,一个选择是选择线段 [3, 3] 和 [4, 4] ,获得 2 个奖品。

提示:

  • 1 <= prizePositions.length <= 105
  • 1 <= prizePositions[i] <= 109
  • 0 <= k <= 109
  • prizePositions 有序非递减。

题解:https://leetcode.cn/problems/maximize-win-from-two-segments/solution/tong-xiang-shuang-zhi-zhen-ji-lu-di-yi-t-5hlh/

class Solution {
    public int maximizeWin(int[] prizePositions, int k) {
        int ans = 0, n = prizePositions.length;
        // 定义pre[i+1] 表示以p[i]为线段右端点时覆盖了至多多少奖品
        // 初始pre[0] = 0
        //      转移:pre[right+1] = max(pre[right], right-left+1)
        // 那么答案(两条线段覆盖多少奖品)为 max(right - left + 1 + pre[left])
        int[] pre = new int[n + 1];
        int left = 0;
        for(int right = 0; right < n; right++){
            while(prizePositions[left] < prizePositions[right]-k)
                left++;
            ans = Math.max(ans, right - left + 1 + pre[left]);
            pre[right+1] = Math.max(pre[right], right - left + 1);
        }
        return ans;
    }
}

python

class Solution:
    def maximizeWin(self, prizePositions: List[int], k: int) -> int:
        ans, n = 0, len(prizePositions)
        pre = [0 for _ in range(n+1)]
        left = 0
        for right in range(n):
            while prizePositions[left] < prizePositions[right] - k:
                left += 1
            ans = max(ans, right - left + 1 + pre[left])
            pre[right+1] = max(pre[right], right - left + 1)
        return ans

2560. 打家劫舍 IV

难度中等54

沿街有一排连续的房屋。每间房屋内都藏有一定的现金。现在有一位小偷计划从这些房屋中窃取现金。

由于相邻的房屋装有相互连通的防盗系统,所以小偷 不会窃取相邻的房屋

小偷的 窃取能力 定义为他在窃取过程中能从单间房屋中窃取的 最大金额

给你一个整数数组 nums 表示每间房屋存放的现金金额。形式上,从左起第 i 间房屋中放有 nums[i] 美元。

另给你一个整数 k ,表示窃贼将会窃取的 最少 房屋数。小偷总能窃取至少 k 间房屋。

返回小偷的 最小 窃取能力。

示例 1:

输入:nums = [2,3,5,9], k = 2
输出:5
解释:
小偷窃取至少 2 间房屋,共有 3 种方式:
- 窃取下标 0 和 2 处的房屋,窃取能力为 max(nums[0], nums[2]) = 5 。
- 窃取下标 0 和 3 处的房屋,窃取能力为 max(nums[0], nums[3]) = 9 。
- 窃取下标 1 和 3 处的房屋,窃取能力为 max(nums[1], nums[3]) = 9 。
因此,返回 min(5, 9, 9) = 5 。

示例 2:

输入:nums = [2,7,9,3,1], k = 2
输出:2
解释:共有 7 种窃取方式。窃取能力最小的情况所对应的方式是窃取下标 0 和 4 处的房屋。返回 max(nums[0], nums[4]) = 2 。

提示:

  • 1 <= nums.length <= 105
  • 1 <= nums[i] <= 109
  • 1 <= k <= (nums.length + 1)/2

方法一:二分答案 + DP

class Solution {
    public int minCapability(int[] nums, int k) {
        int mx = 0;
        for(int num : nums) mx = Math.max(mx, num);
        int left = 0, right = mx;
        while(left < right){
            int mid = (left + right) >> 1;
            // 如果不能偷mid金额,那么(0,mid]都不能偷,left = mid + 1
            if(!check(nums, k, mid)) left = mid + 1;
            else right = mid;
        }
        return right;
    }

    public boolean check(int[] nums, int k, int maxval){
        int n = nums.length;
        // 定义 f[i] 表示 前i间 能偷的最少房屋数
        int[] f = new int[n+2];
        for(int i = 0; i < n; i++){
            f[i+2] = Math.max(f[i+1], f[i] + (nums[i] <= maxval ? 1 : 0));
            if(f[i+1] >= k)
                return true;
        }
        return f[n+1] >= k;
    }
}

2616. 最小化数对的最大差值

难度中等13

给你一个下标从 0 开始的整数数组 nums 和一个整数 p 。请你从 nums 中找到 p 个下标对,每个下标对对应数值取差值,你需要使得这 p 个差值的 最大值 最小。同时,你需要确保每个下标在这 p 个下标对中最多出现一次。

对于一个下标对 ij ,这一对的差值为 |nums[i] - nums[j]| ,其中 |x| 表示 x绝对值

请你返回 p 个下标对对应数值 最大差值最小值

示例 1:

输入:nums = [10,1,2,7,1,3], p = 2
输出:1
解释:第一个下标对选择 1 和 4 ,第二个下标对选择 2 和 5 。
最大差值为 max(|nums[1] - nums[4]|, |nums[2] - nums[5]|) = max(0, 1) = 1 。所以我们返回 1 。

示例 2:

输入:nums = [4,2,1,2], p = 1
输出:0
解释:选择下标 1 和 3 构成下标对。差值为 |2 - 2| = 0 ,这是最大差值的最小值。

提示:

  • 1 <= nums.length <= 105
  • 0 <= nums[i] <= 109
  • 0 <= p <= (nums.length)/2
class Solution {
    // 最小化最大值 ==> 二分答案
    public int minimizeMax(int[] nums, int p) {
        Arrays.sort(nums);
        int left = 0, right = (int)1e9;
        while(left < right){
            int mid = (left + right) >> 1;
            if(!check(nums, p, mid)) left = mid + 1;
            else right = mid;
        }
        return right;
    }
    
    // 查看是否存在 p对 最大差值不超过 k 的数对
    public boolean check(int[] nums, int p, int k){
        int cnt = 0;
        int i = 1, n = nums.length;
        while(i < n){
            if(nums[i] - nums[i-1] <= k){
                i += 2;
                cnt += 1;
            }else i += 1;
            if(cnt == p) return true;
        }
        return cnt >= p;
    }  
}

二分答案题单

  • 875. 爱吃香蕉的珂珂
  • 1283. 使结果不超过阈值的最小除数
  • 2187. 完成旅途的最少时间
  • 2226. 每个小孩最多能分到多少糖果
  • 1870. 准时到达的列车最小时速
  • 1011. 在 D 天内送达包裹的能力
  • 2064. 分配给商店的最多商品的最小值
  • 1760. 袋子里最少数目的球
  • 1482. 制作 m 束花所需的最少天数
  • 1642. 可以到达的最远建筑
  • 1898. 可移除字符的最大数目
  • 778. 水位上升的泳池中游泳
  • 2258. 逃离火灾

最小化最大值

  • 2439. 最小化数组中的最大值
  • 2513. 最小化两个数组中的最大值
  • 2560. 打家劫舍 IV
  • 2616. 最小化数对的最大差值

最大化最小值

  • 1552. 两球之间的磁力
  • 2517. 礼盒的最大甜蜜度
  • 2528. 最大化城市的最小供电站数目

2528. 最大化城市的最小供电站数目

难度困难28

给你一个下标从 0 开始长度为 n 的整数数组 stations ,其中 stations[i] 表示第 i 座城市的供电站数目。

每个供电站可以在一定 范围 内给所有城市提供电力。换句话说,如果给定的范围是 r ,在城市 i 处的供电站可以给所有满足 |i - j| <= r0 <= i, j <= n - 1 的城市 j 供电。

  • |x| 表示 x绝对值 。比方说,|7 - 5| = 2|3 - 10| = 7

一座城市的 电量 是所有能给它供电的供电站数目。

政府批准了可以额外建造 k 座供电站,你需要决定这些供电站分别应该建在哪里,这些供电站与已经存在的供电站有相同的供电范围。

给你两个整数 rk ,如果以最优策略建造额外的发电站,返回所有城市中,最小供电站数目的最大值是多少。

k 座供电站可以建在多个城市。

示例 1:

输入:stations = [1,2,4,5,0], r = 1, k = 2
输出:5
解释:
最优方案之一是把 2 座供电站都建在城市 1 。
每座城市的供电站数目分别为 [1,4,4,5,0] 。
- 城市 0 的供电站数目为 1 + 4 = 5 。
- 城市 1 的供电站数目为 1 + 4 + 4 = 9 。
- 城市 2 的供电站数目为 4 + 4 + 5 = 13 。
- 城市 3 的供电站数目为 5 + 4 = 9 。
- 城市 4 的供电站数目为 5 + 0 = 5 。
供电站数目最少是 5 。
无法得到更优解,所以我们返回 5 。

示例 2:

输入:stations = [4,4,4,4], r = 0, k = 3
输出:4
解释:
无论如何安排,总有一座城市的供电站数目是 4 ,所以最优解是 4 。

提示:

  • n == stations.length
  • 1 <= n <= 105
  • 0 <= stations[i] <= 105
  • 0 <= r <= n - 1
  • 0 <= k <= 109
class Solution {
    public long maxPower(int[] stations, int r, int k) {
        long left = 0, right = (long)1e15;
        while (left < right) {
            long mid = (left + right + 1) >> 1;
            if (!check(stations, r, k, mid))
                right = mid - 1;
            else
                left = mid;
        }
        return right;
    }
    
    // 将 i 位置供电站stations[i] 化为 区间[i-r, i+r] 的供电站数量,使用差分数组来维护区间更新后的值
    // 查看在 至多增加k个供电站 的条件下,每个城市最小供电站数目能否>=mnpower
    // 特别的,从左往右遍历,当 i-r 位置城市供电站数目 < mnpower时,则需要在 r位置 建造供电站
    public boolean check(int[] stations, int r, int k, long mnpower) {
        int n = stations.length;
        long cnt = 0; // 额外建造供电站的数量
        long[] diff = new long[n + r + 1];
        for (int i = 0; i < r; i++) {
            diff[0] += stations[i];
            diff[i + r + 1] -= stations[i];
        }
        long s = 0; // 差分数组的当前值(第 i-r 位置上供电站的数目)
        for (int i = r; i < n + r; i++) {
            if (i < n) {
                diff[i - r] += stations[i];
                diff[i + r + 1] -= stations[i];
            }
            s += diff[i - r]; // 恢复 i-r 位置供电站数量
            if (s < mnpower) {
                long add = mnpower - s;
                cnt += add;
                if (cnt > k)
                    return false;
                s += add;
                if (i < n)
                    diff[i + r + 1] -= add;
            }
        }
        return true;
    }
}

2565. 最少得分子序列

难度困难27

给你两个字符串 st

你可以从字符串 t 中删除任意数目的字符。

如果没有从字符串 t 中删除字符,那么得分为 0 ,否则:

  • left 为删除字符中的最小下标。
  • right 为删除字符中的最大下标。

字符串的得分为 right - left + 1

请你返回使 t 成为 s 子序列的最小得分。

一个字符串的 子序列 是从原字符串中删除一些字符后(也可以一个也不删除),剩余字符不改变顺序得到的字符串。(比方说 "ace""***a\***b***c\***d***e\***" 的子序列,但是 "aec" 不是)。

示例 1:

输入:s = "abacaba", t = "bzaa"
输出:1
解释:这个例子中,我们删除下标 1 处的字符 "z" (下标从 0 开始)。
字符串 t 变为 "baa" ,它是字符串 "abacaba" 的子序列,得分为 1 - 1 + 1 = 1 。
1 是能得到的最小得分。

示例 2:

输入:s = "cde", t = "xyz"
输出:3
解释:这个例子中,我们将下标为 0, 1 和 2 处的字符 "x" ,"y" 和 "z" 删除(下标从 0 开始)。
字符串变成 "" ,它是字符串 "cde" 的子序列,得分为 2 - 0 + 1 = 3 。
3 是能得到的最小得分。

提示:

  • 1 <= s.length, t.length <= 105
  • st 都只包含小写英文字母。
class Solution {
    public int minimumScore(String S, String T) {
        char[] s = S.toCharArray(), t = T.toCharArray();
        int n = s.length, m = t.length;
        // 定义 pre[i] 为 s[:i] 对应的 t 的最长前缀的结束下标。
        // 定义 suf[i] 为 s[i:] 对应的 t 的最长后缀的开始下标。
        int[] suf = new int[n+1];
        suf[n] = m;
        for(int i = n-1, j = m-1; i >= 0; --i){
            if(j >= 0 && s[i] == t[j]) --j;
            suf[i] = j + 1;
        }
        int ans = suf[0]; // 删除t[:suf[0]]
        if(ans == 0) return 0;
        // 代码实现时,使用变量 j 来代替pre数组
        for(int i = 0, j = 0; i < n; i++){
            if(s[i] == t[j]) // 注意 j 不会等于 m,因为上面 suf[0]>0 表示 t 不是 s 的子序列
                ans = Math.min(ans, suf[i+1] - ++j); // ++j 后,删除 t[j:suf[i+1]]
        }
        return ans;
    }
}

2552. 统计上升四元组

难度困难24

给你一个长度为 n 下标从 0 开始的整数数组 nums ,它包含 1n 的所有数字,请你返回上升四元组的数目。

如果一个四元组 (i, j, k, l) 满足以下条件,我们称它是上升的:

  • 0 <= i < j < k < l < n
  • nums[i] < nums[k] < nums[j] < nums[l]

示例 1:

输入:nums = [1,3,2,4,5]
输出:2
解释:
- 当 i = 0 ,j = 1 ,k = 2 且 l = 3 时,有 nums[i] < nums[k] < nums[j] < nums[l] 。
- 当 i = 0 ,j = 1 ,k = 2 且 l = 4 时,有 nums[i] < nums[k] < nums[j] < nums[l] 。
没有其他的四元组,所以我们返回 2 。

示例 2:

输入:nums = [1,2,3,4]
输出:0
解释:只存在一个四元组 i = 0 ,j = 1 ,k = 2 ,l = 3 ,但是 nums[j] < nums[k] ,所以我们返回 0 。

提示:

  • 4 <= nums.length <= 4000
  • 1 <= nums[i] <= nums.length
  • nums 中所有数字 互不相同nums 是一个排列。
class Solution {
    // nums[i] < nums[k] < nums[j] < nums[l] 
    // 枚举 j 和 k 这两个中间的,会更容易计算。
    // 需要计算:
    //      在 k 右侧的比 nums[j] 大的元素个数,记作 great[k][nums[j]];
    //      在 j 左侧的比 nums[k] 小的元素个数,记作 less[j][nums[k]]。
    //  对于固定的 j 和 k,根据乘法原理,对答案的贡献为less[j][nums[k]]⋅great[k][nums[j]]
    public long countQuadruplets(int[] nums) {
        int n = nums.length;
        int[][] great = new int[n][n+1];
        // 倒序遍历nums, 设x < nums[k+1], 对于 x,大于它的数的个数+1,即 great[k][x]+1
        for(int k = n-2; k >= 2; k--){
            great[k] = great[k+1].clone();
            for(int x = nums[k+1] - 1; x > 0; x--)
                great[k][x]++; // x < nums[k+1],对于 x,大于它的数的个数 +1
        }
        long ans = 0;
        int[] less = new int[n+1];
        for(int j = 1; j < n-2; j++){
            for(int x = nums[j-1]+1; x <= n; x++)
                less[x]++; // x > nums[j-1],对于 x,小于它的数的个数 +1
            for (int k = j + 1; k < n - 1; k++)
                if (nums[j] > nums[k])
                    ans += less[nums[k]] * great[k][nums[j]];
        }
        return ans;
    }
}

二、动态规划

周赛常客。如果你很难想出状态转移方程,以及递推的顺序,可以先从记忆化搜索开始思考,然后转换到递推上。

记忆化搜索像是自动挡,无需思考递推顺序,边界条件也容易确认;而递推像是手动挡,需要仔细确认递推的顺序以及初始值。但记忆化搜索并不是万能的,某些题目递推的写法可以结合数据结构等来优化时间复杂度。

题目 难度 备注
2606. 找到最大开销的子字符串 1422 最大子数组和
2707. 字符串中的额外字符 1736 线性 DP
2585. 获得分数的方法数 1910 背包
2547. 拆分数组的最小代价 2020 划分型 DP
2597. 美丽子集的数目 2023 回溯/动态规划
2581. 统计可能的树根数目 2228 换根 DP
2646. 最小化旅行的价格总和 2238 树形 DP
2719. 统计整数数目 2355 数位 DP
2713. 矩阵中严格递增的单元格数 2387 DP+优化
2538. 最大价值和与最小价值和的差值 2398 树形 DP
2572. 无平方子集计数 2420 0-1背包/子集状压DP
2742. 给墙壁刷油漆 2425 线性DP/0-1背包
2617. 网格图中最少访问的格子数 2581 线段树/单调栈优化DP

2606. 找到最大开销的子字符串

难度中等7

给你一个字符串 s ,一个字符 互不相同 的字符串 chars 和一个长度与 chars 相同的整数数组 vals

子字符串的开销 是一个子字符串中所有字符对应价值之和。空字符串的开销是 0

字符的价值 定义如下:

  • 如果字符不在字符串 chars 中,那么它的价值是它在字母表中的位置(下标从 1 开始)。
    • 比方说,'a' 的价值为 1'b' 的价值为 2 ,以此类推,'z' 的价值为 26
  • 否则,如果这个字符在 chars 中的位置为 i ,那么它的价值就是 vals[i]

请你返回字符串 s 的所有子字符串中的最大开销。

示例 1:

输入:s = "adaa", chars = "d", vals = [-1000]
输出:2
解释:字符 "a" 和 "d" 的价值分别为 1 和 -1000 。
最大开销子字符串是 "aa" ,它的开销为 1 + 1 = 2 。
2 是最大开销。

示例 2:

输入:s = "abc", chars = "abc", vals = [-1,-1,-1]
输出:0
解释:字符 "a" ,"b" 和 "c" 的价值分别为 -1 ,-1 和 -1 。
最大开销子字符串是 "" ,它的开销为 0 。
0 是最大开销。

提示:

  • 1 <= s.length <= 105
  • s 只包含小写英文字母。
  • 1 <= chars.length <= 26
  • chars 只包含小写英文字母,且 互不相同
  • vals.length == chars.length
  • -1000 <= vals[i] <= 1000
class Solution {
    // 子数组的DP问题(代表:53. 最大子数组和)
    public int maximumCostSubstring(String s, String chars, int[] vals) {
        Map<Character, Integer> cost = new HashMap<>();
        for(int i = 0; i < chars.length(); i++){
            cost.put(chars.charAt(i), vals[i]);
        }
        for(int i = 0; i < 26; i++){
            if(cost.containsKey((char)(i + 'a'))) continue;
            cost.put((char)(i + 'a'), i+1);
        }
        int ans = 0;
        int cur = 0;
        for(int i = 0; i < s.length(); i++){
            cur = Math.max(cur + cost.get(s.charAt(i)), 0);
            ans = Math.max(ans, cur);
        }
        return ans;
    }
}

2707. 字符串中的额外字符

难度中等22

给你一个下标从 0 开始的字符串 s 和一个单词字典 dictionary 。你需要将 s 分割成若干个 互不重叠 的子字符串,每个子字符串都在 dictionary 中出现过。s 中可能会有一些 额外的字符 不在任何子字符串中。

请你采取最优策略分割 s ,使剩下的字符 最少

示例 1:

输入:s = "leetscode", dictionary = ["leet","code","leetcode"]
输出:1
解释:将 s 分成两个子字符串:下标从 0 到 3 的 "leet" 和下标从 5 到 8 的 "code" 。只有 1 个字符没有使用(下标为 4),所以我们返回 1 。

示例 2:

输入:s = "sayhelloworld", dictionary = ["hello","world"]
输出:3
解释:将 s 分成两个子字符串:下标从 3 到 7 的 "hello" 和下标从 8 到 12 的 "world" 。下标为 0 ,1 和 2 的字符没有使用,所以我们返回 3 。

提示:

  • 1 <= s.length <= 50
  • 1 <= dictionary.length <= 50
  • 1 <= dictionary[i].length <= 50
  • dictionary[i]s 只包含小写英文字母。
  • dictionary 中的单词互不相同。
class Solution {
    Set<String> set;
    String s;
    int[] cache;
    public int minExtraChar(String s, String[] dictionary) {
        set = new HashSet<>();
        for(String d : dictionary) set.add(d);
        this.s = s;
        int n = s.length();
        cache = new int[n+1];
        Arrays.fill(cache, -1);    
        return dfs(n);
    }

    // 定义 dfs(i) 表示分割 s[:i] 剩下的最少字符
    // 转移 对于 第 i 个字符,有选和不选两种情况
    //     选(分割): dfs(i) = dfs(j) , s[j:i] 在字典中
    //     不选(不分割)): dfs(i) = dfs(i-1)+1
    //  取所有情况最小值
    //  递归边界: dfs(0) = 0
    //  递归入口: dfs(len(s))
    public int dfs(int i){
        if(i == 0) return 0;
        if(cache[i] >= 0) return cache[i];
        int res = i; // 最坏情况是s[:i]都不选,最大值即i
        // 分割
        for(int j = 0; j < i; j++){
            if(set.contains(s.substring(j, i)))
                res = Math.min(res, dfs(j));
        }
        // 不分割
        res = Math.min(res, dfs(i-1)+1);
        return cache[i] = res;
    }
}

记忆化搜索转递推

class Solution {
    public int minExtraChar(String s, String[] dictionary) {
        Set<String> set = new HashSet<>();
        for(String d : dictionary) set.add(d);
        int n = s.length();
        int[] f = new int[n+1];
        for(int i = 0; i <= n; i++)
            f[i] = i; // 最坏情况是s[:i]都不选,最大值即i
        for(int i = 0; i <= n; i++){
            // 分割
            for(int j = 0; j < i; j++){
                if(set.contains(s.substring(j, i)))
                    f[i] = Math.min(f[i], f[j]);
            }
            // 不分割
            if(i > 0)
                f[i] = Math.min(f[i], f[i-1]+1);
        }
        return f[n];
    }
}

2585. 获得分数的方法数

难度困难24

考试中有 n 种类型的题目。给你一个整数 target 和一个下标从 0 开始的二维整数数组 types ,其中 types[i] = [counti, marksi] 表示第 i 种类型的题目有 counti 道,每道题目对应 marksi 分。

返回你在考试中恰好得到 target 分的方法数。由于答案可能很大,结果需要对 109 +7 取余。

注意,同类型题目无法区分。

  • 比如说,如果有 3 道同类型题目,那么解答第 1 和第 2 道题目与解答第 1 和第 3 道题目或者第 2 和第 3 道题目是相同的。

示例 1:

输入:target = 6, types = [[6,1],[3,2],[2,3]]
输出:7
解释:要获得 6 分,你可以选择以下七种方法之一:
- 解决 6 道第 0 种类型的题目:1 + 1 + 1 + 1 + 1 + 1 = 6
- 解决 4 道第 0 种类型的题目和 1 道第 1 种类型的题目:1 + 1 + 1 + 1 + 2 = 6
- 解决 2 道第 0 种类型的题目和 2 道第 1 种类型的题目:1 + 1 + 2 + 2 = 6
- 解决 3 道第 0 种类型的题目和 1 道第 2 种类型的题目:1 + 1 + 1 + 3 = 6
- 解决 1 道第 0 种类型的题目、1 道第 1 种类型的题目和 1 道第 2 种类型的题目:1 + 2 + 3 = 6
- 解决 3 道第 1 种类型的题目:2 + 2 + 2 = 6
- 解决 2 道第 2 种类型的题目:3 + 3 = 6

示例 2:

输入:target = 5, types = [[50,1],[50,2],[50,5]]
输出:4
解释:要获得 5 分,你可以选择以下四种方法之一:
- 解决 5 道第 0 种类型的题目:1 + 1 + 1 + 1 + 1 = 5
- 解决 3 道第 0 种类型的题目和 1 道第 1 种类型的题目:1 + 1 + 1 + 2 = 5
- 解决 1 道第 0 种类型的题目和 2 道第 1 种类型的题目:1 + 2 + 2 = 5
- 解决 1 道第 2 种类型的题目:5

示例 3:

输入:target = 18, types = [[6,1],[3,2],[2,3]]
输出:1
解释:只有回答所有题目才能获得 18 分。

提示:

  • 1 <= target <= 1000
  • n == types.length
  • 1 <= n <= 50
  • types[i].length == 2
  • 1 <= counti, marksi <= 50
class Solution {
    private static final int MOD = (int)1e9+7;
    int[][] types, cache;
    public int waysToReachTarget(int target, int[][] types) {
        this.types = types;
        int n = types.length;
        cache = new int[n][target+1];
        for(int i = 0; i < n; i++)
            Arrays.fill(cache[i], -1);
        return dfs(n-1, target);
    }

    // 定义 dfs(i, score) 表示解答前 i 种题目,得分为 score 的方法数
    // 转移 枚举第 i 种题目答对 1题....counti题
    // 递归边界 
    //      dfs(i < 0, score = 0) = 1
    //      dfs(i < 0, score != 0) = 0
    //      if(score < 0) return 0;
    // 递归入口 dfs(len(types)-1, target)
    public int dfs(int i, int score){
        if(i < 0 && score == 0) return 1;
        if(i < 0 && score != 0) return 0;
        if(score < 0) return 0;
        if(cache[i][score] >= 0) return cache[i][score];
        int res = 0;
        int count = types[i][0], mark = types[i][1];
        for(int k = 0; k <= count; k++){
            res = (res + dfs(i-1, score - (k*mark))) % MOD;
        }
        return cache[i][score] = res % MOD;
    }
}

记忆化搜索转递推

class Solution {
    /** 一比一翻译 递归入口:dfs(n-1, target)
    		==> 最外层循环 for i in range(n),第二层循环 for score in range(target+1)
    			计算状态时还有一层循环 for k in range(count)
    	把题目看成物品,把得分看成背包,则问题转化为,选择k个物品,塞入背包中,恰好装满的方案数
    */
    private static final int MOD = (int)1e9+7;
    public int waysToReachTarget(int target, int[][] types) {
        int n = types.length;
        int[][] f = new int[n+1][target+1];
        f[0][0] = 1; // 恰好得到 target 分的方法数,只有f[0][0]才计算方案
        for(int i = 0; i < n; i++){ // 先遍历物品
            int count = types[i][0], mark = types[i][1];
            for(int j = 0; j <= target; j++){ // 再遍历背包
                for(int k = 0; k <= count && (k * mark) <= j; k++){
                    f[i+1][j] = (f[i+1][j] + f[i][j - k * mark]) % MOD;
                }
            }
        }
        return f[n][target];
    }
}

2547. 拆分数组的最小代价

难度困难25

给你一个整数数组 nums 和一个整数 k

将数组拆分成一些非空子数组。拆分的 代价 是每个子数组中的 重要性 之和。

trimmed(subarray) 作为子数组的一个特征,其中所有仅出现一次的数字将会被移除。

  • 例如,trimmed([3,1,2,4,3,4]) = [3,4,3,4]

子数组的 重要性 定义为 k + trimmed(subarray).length

  • 例如,如果一个子数组是 [1,2,3,3,3,4,4]trimmed([1,2,3,3,3,4,4]) = [3,3,3,4,4] 。这个子数组的重要性就是 k + 5

找出并返回拆分 nums 的所有可行方案中的最小代价。

子数组 是数组的一个连续 非空 元素序列。

示例 1:

输入:nums = [1,2,1,2,1,3,3], k = 2
输出:8
解释:将 nums 拆分成两个子数组:[1,2], [1,2,1,3,3]
[1,2] 的重要性是 2 + (0) = 2 。
[1,2,1,3,3] 的重要性是 2 + (2 + 2) = 6 。
拆分的代价是 2 + 6 = 8 ,可以证明这是所有可行的拆分方案中的最小代价。

示例 2:

输入:nums = [1,2,1,2,1], k = 2
输出:6
解释:将 nums 拆分成两个子数组:[1,2], [1,2,1] 。
[1,2] 的重要性是 2 + (0) = 2 。
[1,2,1] 的重要性是 2 + (2) = 4 。
拆分的代价是 2 + 4 = 6 ,可以证明这是所有可行的拆分方案中的最小代价。

示例 3:

输入:nums = [1,2,1,2,1], k = 5
输出:10
解释:将 nums 拆分成一个子数组:[1,2,1,2,1].
[1,2,1,2,1] 的重要性是 5 + (3 + 2) = 10 。
拆分的代价是 10 ,可以证明这是所有可行的拆分方案中的最小代价。

提示:

  • 1 <= nums.length <= 1000
  • 0 <= nums[i] < nums.length
  • 1 <= k <= 109
class Solution {
    // 划分出第一个子数组,问题变成一个规模更小的子问题
    // 由于[划分出长为 x 和 y 的子数组]和[划分出长为 y 和 x 的子数组]之后,剩余的子问题是相同的,因此这题适合用动态规划解决。

    /*
    * 划分型dp(优化) + 实时维护频率数组 state 和 出现 > 1 次的元素个数 trim
    * 定义:f[i] 表示 [0,i-1] 的子数组的拆分数组的最小代价
    * 状态转移:f[i] = k + min{f[j] + trim}  , j=[0,i-1]
    * 答案:f[n]
    */
    public int minCost(int[] nums, int k) {
        int n = nums.length;
        // 定义 f[i] 表示拆分数组 nums[:i] 的最小代价
        int[] f = new int[n+1];
        int[] state = new int[n];
        for(int i = 0; i < n; i++){
            Arrays.fill(state, 0);
            int trim = 0, mn = Integer.MAX_VALUE;
            for(int j = i; j >= 0; j--){
                int x = nums[j];
                if(state[x] == 0){ // 首次出现
                    state[x] = 1;
                }else if(state[x] == 1){ // 第二次出现(不再唯一)
                    state[x] = 2;
                    trim += 2;
                }else{
                    trim += 1;
                }
                mn = Math.min(mn, f[j] + trim); // unique唯一元素不加入重要性的统计中
            }
            f[i+1] = k + mn; // mn 为拆分
            
        }
        return f[n];
    }
}

2597. 美丽子集的数目

难度中等31

给你一个由正整数组成的数组 nums 和一个 整数 k

如果 nums 的子集中,任意两个整数的绝对差均不等于 k ,则认为该子数组是一个 美丽 子集。

返回数组 nums非空美丽 的子集数目。

nums 的子集定义为:可以经由 nums 删除某些元素(也可能不删除)得到的一个数组。只有在删除元素时选择的索引不同的情况下,两个子集才会被视作是不同的子集。

示例 1:

输入:nums = [2,4,6], k = 2
输出:4
解释:数组 nums 中的美丽子集有:[2], [4], [6], [2, 6] 。
可以证明数组 [2,4,6] 中只存在 4 个美丽子集。

示例 2:

输入:nums = [1], k = 1
输出:1
解释:数组 nums 中的美丽数组有:[1] 。
可以证明数组 [1] 中只存在 1 个美丽子集。 

提示:

  • 1 <= nums.length <= 20
  • 1 <= nums[i], k <= 1000

方法一:回溯 + 转换

在枚举 78.子集 的基础上加个判断。

在选择 x = nums[i] 的时候,如果之前选过 x+kx-k,则不能选,否则可以选。

  • 枚举 i 选还是不选
class Solution:
    # 枚举i选还是不选
    def beautifulSubsets(self, nums: List[int], k: int) -> int:
        ans = -1 # 去掉空集
        cnt = [0] * (max(nums) + 2 * k)
        
        def dfs(i: int) -> None:
            if i == len(nums):
                nonlocal ans
                ans += 1
                return
            # 不选
            dfs(i+1) 
            # 选
            x = nums[i]
            if cnt[x - k] == 0 and cnt[x + k] == 0:
                cnt[x] += 1
                dfs(i + 1)
                cnt[x] -= 1
        
        dfs(0)
        return ans
  • 枚举第 i 位选哪个
class Solution:
    # 枚举选哪个
    def beautifulSubsets(self, nums: List[int], k: int) -> int:
        ans = -1 # 去掉空集
        cnt = [0] * (max(nums) + 2 * k)
        
        def dfs(i: int) -> None:
            nonlocal ans
            ans += 1
            if i == len(nums):
                return
            for j in range(i, len(nums)):
                x = nums[j]
                if cnt[x - k] == 0 and cnt[x + k] == 0:
                    cnt[x] += 1
                    dfs(j+1)
                    cnt[x] -= 1
        
        dfs(0)
        return ans

2581. 统计可能的树根数目

难度困难34

Alice 有一棵 n 个节点的树,节点编号为 0n - 1 。树用一个长度为 n - 1 的二维整数数组 edges 表示,其中 edges[i] = [ai, bi] ,表示树中节点 aibi 之间有一条边。

Alice 想要 Bob 找到这棵树的根。她允许 Bob 对这棵树进行若干次 猜测 。每一次猜测,Bob 做如下事情:

  • 选择两个 不相等 的整数 uv ,且树中必须存在边 [u, v]
  • Bob 猜测树中 uv父节点

Bob 的猜测用二维整数数组 guesses 表示,其中 guesses[j] = [uj, vj] 表示 Bob 猜 ujvj 的父节点。

Alice 非常懒,她不想逐个回答 Bob 的猜测,只告诉 Bob 这些猜测里面 至少k 个猜测的结果为 true

给你二维整数数组 edges ,Bob 的所有猜测和整数 k ,请你返回可能成为树根的 节点数目 。如果没有这样的树,则返回 0

示例 1:

从周赛中学算法-2023上_第1张图片

输入:edges = [[0,1],[1,2],[1,3],[4,2]], guesses = [[1,3],[0,1],[1,0],[2,4]], k = 3
输出:3
解释:
根为节点 0 ,正确的猜测为 [1,3], [0,1], [2,4]
根为节点 1 ,正确的猜测为 [1,3], [1,0], [2,4]
根为节点 2 ,正确的猜测为 [1,3], [1,0], [2,4]
根为节点 3 ,正确的猜测为 [1,0], [2,4]
根为节点 4 ,正确的猜测为 [1,3], [1,0]
节点 0 ,1 或 2 为根时,可以得到 3 个正确的猜测。

提示:

  • edges.length == n - 1
  • 2 <= n <= 105
  • 1 <= guesses.length <= 105
  • 0 <= ai, bi, uj, vj <= n - 1
  • ai != bi
  • uj != vj
  • edges 表示一棵有效的树。
  • guesses[j] 是树中的一条边。
  • guesses 是唯一的。
  • 0 <= k <= guesses.length
class Solution {
    private List<Integer>[] g;
    private Set<Long> s = new HashSet<>();
    private int k, ans, cnt0;
    public int rootCount(int[][] edges, int[][] guesses, int k) {
        this.k = k;
        g = new ArrayList[edges.length+1];
        Arrays.setAll(g, e -> new ArrayList<>());
        for(int[] e : edges){
            int x = e[0], y = e[1];
            g[x].add(y);
            g[y].add(x); // 建图
        }
        for(int[] e : guesses){ // guesses 转换成哈希表,加快查询速度
            s.add((long) e[0] << 32 | e[1]); // 两个 4 字节数压缩成一个 8 字节数Long
        }
        dfs(0, -1);
        reroot(0,-1,cnt0);
        return ans;
    }

    private void dfs(int x, int fa){
        for(int y : g[x]){
            if(y != fa){
                if(s.contains((long) x << 32 | y)) // 以 0 为根时,猜对了
                    cnt0++;
                dfs(y,x);
            }
        }
    }

    private void reroot(int x, int fa, int cnt){
        if(cnt >= k) ans++;// 此时 cnt 就是以 x 为根时的猜对次数
        for(int y : g[x]){
            if(y != fa){
                int c = cnt;
                if(s.contains((long) x << 32 | y)) c--;// 原来是对的,现在错了
                if(s.contains((long) y << 32 | x)) c++;// 原来是对的,现在错了
                reroot(y,x,c);
            }
        }
    }
}

2646. 最小化旅行的价格总和

难度困难31

现有一棵无向、无根的树,树中有 n 个节点,按从 0n - 1 编号。给你一个整数 n 和一个长度为 n - 1 的二维整数数组 edges ,其中 edges[i] = [ai, bi] 表示树中节点 aibi 之间存在一条边。

每个节点都关联一个价格。给你一个整数数组 price ,其中 price[i] 是第 i 个节点的价格。

给定路径的 价格总和 是该路径上所有节点的价格之和。

另给你一个二维整数数组 trips ,其中 trips[i] = [starti, endi] 表示您从节点 starti 开始第 i 次旅行,并通过任何你喜欢的路径前往节点 endi

在执行第一次旅行之前,你可以选择一些 非相邻节点 并将价格减半。

返回执行所有旅行的最小价格总和。

示例 1:

img

输入:n = 4, edges = [[0,1],[1,2],[1,3]], price = [2,2,10,6], trips = [[0,3],[2,1],[2,3]]
输出:23
解释:
上图表示将节点 2 视为根之后的树结构。第一个图表示初始树,第二个图表示选择节点 0 、2 和 3 并使其价格减半后的树。
第 1 次旅行,选择路径 [0,1,3] 。路径的价格总和为 1 + 2 + 3 = 6 。
第 2 次旅行,选择路径 [2,1] 。路径的价格总和为 2 + 5 = 7 。
第 3 次旅行,选择路径 [2,1,3] 。路径的价格总和为 5 + 2 + 3 = 10 。
所有旅行的价格总和为 6 + 7 + 10 = 23 。可以证明,23 是可以实现的最小答案。

示例 2:

img

输入:n = 2, edges = [[0,1]], price = [2,2], trips = [[0,0]]
输出:1
解释:
上图表示将节点 0 视为根之后的树结构。第一个图表示初始树,第二个图表示选择节点 0 并使其价格减半后的树。 
第 1 次旅行,选择路径 [0] 。路径的价格总和为 1 。 
所有旅行的价格总和为 1 。可以证明,1 是可以实现的最小答案。

提示:

  • 1 <= n <= 50
  • edges.length == n - 1
  • 0 <= ai, bi <= n - 1
  • edges 表示一棵有效的树
  • price.length == n
  • price[i] 是一个偶数
  • 1 <= price[i] <= 1000
  • 1 <= trips.length <= 100
  • 0 <= starti, endi <= n - 1

方法一:树形DP,暴力DFS每一条路径,计算每一个节点的经过次数,然后写一个打家劫舍

class Solution {
    /** 
    1. 计算每个点经过的次数 cnt(贡献法思想:计算每个点对答案能贡献多少)
    2. 写一个树形DP求答案,知道了每个点会被经过多少次,把price[i] 更新成 price[i]*cnt[i]
            问题就变成了计算减半后的price[i]之和的最小值
    随便从一个点出发DFS,对节点 x 及其儿子 y,分类讨论:
        如果 price[x] 不变,那么 price[y] 可以减半,也可以不变,取这两种情况的最小值
        如果 price[x] 减半,那么 price[y] 只能不变
    因此,子树 x 需要返回两个值:
        price[x] 不变时的子树 x 的最小价值总和
        price[x] 减半时的子树 x 的最小价值总和
     */  
    private List<Integer>[] g;
    private int[] price, cnt;
    private int end;
    public int minimumTotalPrice(int n, int[][] edges, int[] price, int[][] trips) {
        g = new ArrayList[n];
        Arrays.setAll(g, e -> new ArrayList<>());
        for(int[] e : edges){
            int x = e[0], y = e[1];
            g[x].add(y);
            g[y].add(x);
        }
        this.price = price;
        cnt = new int[n]; // 每个节点在路径上经过的次数
        for(int[] t : trips){
            end = t[1];
            path(t[0], -1);
        }
        int[] p = dfs(0, -1);
        return Math.min(p[0], p[1]);
    }

    // 找到从 x 到 end 的路径,将经过的节点次数cnt[i] + 1
    public boolean path(int x, int fa){
        if(x == end){ // 到达终点(注意树只有唯一的一条简单路径)
            ++cnt[x]; // 统计从 start 到 end 的路径上的点经过了多少次
            return true; // 找到终点
        }
        for(int y : g[x])
            if(y != fa && path(y, x)){
                cnt[x]++; // 统计从 start 到 end 的路径上的点经过了多少次
                return true; // 找到终点
            }
        return false;
    }

    // 类似 337. 打家劫舍Ⅲ
    // 返回 (price[x] 不变时的子树 x 的最小价值总和, price[x] 减半时的子树 x 的最小价值总和)
    public int[] dfs(int x, int fa){
        int notHalve = price[x] * cnt[x]; // x 不变
        int halve = notHalve / 2; // x 减半
        for(int y : g[x]){
            if(y != fa){
                int[] p = dfs(y, x); // 计算 y 不变/减半的最小价值总和
                notHalve += Math.min(p[0], p[1]); // x 不变,那么 y 可以不变,可以减半,取这两种情况的最小值
                halve += p[0]; // x 减半,那么 y 只能不变
            }
        }
        return new int[]{notHalve, halve};
    }
}

方法二:树上差分 + Tarjan离线

2719. 统计整数数目

难度困难25

给你两个数字字符串 num1num2 ,以及两个整数 max_summin_sum 。如果一个整数 x 满足以下条件,我们称它是一个好整数:

  • num1 <= x <= num2
  • min_sum <= digit_sum(x) <= max_sum.

请你返回好整数的数目。答案可能很大,请返回答案对 109 + 7 取余后的结果。

注意,digit_sum(x) 表示 x 各位数字之和。

示例 1:

输入:num1 = "1", num2 = "12", min_num = 1, max_num = 8
输出:11
解释:总共有 11 个整数的数位和在 1 到 8 之间,分别是 1,2,3,4,5,6,7,8,10,11 和 12 。所以我们返回 11 。

示例 2:

输入:num1 = "1", num2 = "5", min_num = 1, max_num = 5
输出:5
解释:数位和在 1 到 5 之间的 5 个整数分别为 1,2,3,4 和 5 。所以我们返回 5 。

提示:

  • 1 <= num1 <= num2 <= 1022
  • 1 <= min_sum <= max_sum <= 400

题解:数位DP:dfs(nums2) - dfs(nums1-1)

class Solution {
    private static final int MOD = (int)1e9 + 7;
    int min_sum, max_sum;
    int[][] cache;
    public int count(String num1, String num2, int min_sum, int max_sum) {
        this.min_sum = min_sum;
        this.max_sum = max_sum;
        int m = num2.toCharArray().length;
        cache = new int[m][max_sum + 1];
        for(int i = 0; i < m; i++){
            Arrays.fill(cache[i], -1);
        }
        char[] s2 = num2.toCharArray();
        int a = dfs(0, 0, true, s2);
        for(int i = 0; i < m; i++){
            Arrays.fill(cache[i], -1);
        }
        char[] s1 = num1.toCharArray();
        s1[s1.length - 1]--;
        int b = dfs(0, 0, true, s1);
        return (a - b + MOD) % MOD;
    }

    public int dfs(int i, int sum, boolean isLimit, char[] s){
        if(i == s.length){
            return sum >= min_sum ? 1 : 0;
        }
        if(!isLimit && cache[i][sum] >= 0) return cache[i][sum];
        int res = 0;
        int up = isLimit ? s[i] - '0' : 9;
        for(int d = 0; d <= up; d++){
            if((sum + d) <= max_sum)
                res = (res + dfs(i+1, sum+d, isLimit & (d == up), s)) % MOD;
        }
        if(!isLimit) cache[i][sum] = res % MOD;
        return res % MOD;
    }
}

2713. 矩阵中严格递增的单元格数

难度困难34

给你一个下标从 1 开始、大小为 m x n 的整数矩阵 mat,你可以选择任一单元格作为 起始单元格

从起始单元格出发,你可以移动到 同一行或同一列 中的任何其他单元格,但前提是目标单元格的值 严格大于 当前单元格的值。

你可以多次重复这一过程,从一个单元格移动到另一个单元格,直到无法再进行任何移动。

请你找出从某个单元开始访问矩阵所能访问的 单元格的最大数量

返回一个表示可访问单元格最大数量的整数。

示例 3:

从周赛中学算法-2023上_第2张图片

输入:mat = [[3,1,6],[-9,5,7]]
输出:4
解释:上图展示了从第 2 行、第 1 列的单元格开始,可以访问 4 个单元格。可以证明,无论从哪个单元格开始,最多只能访问 4 个单元格,因此答案是 4 。  

提示:

  • m == mat.length
  • n == mat[i].length
  • 1 <= m, n <= 105
  • 1 <= m * n <= 105
  • -105 <= mat[i][j] <= 105
class Solution {
    /**
    从格子 mat[i][j] 出发往其他格子能走多少 等价于 从其他格子走到 mat[i][j] 能走多少
    ==> 定义 f[i][j] 表示到达mat[i][j]是所访问的单元格的最大数量
    在枚举转移来源时,不需要知道具体从哪个单元格转移过来,只需要知道转移来源的f的最大值是多少

    按照元素值从小到大计算,那么第 i 行比 mat[i][j] 小的f值都算出来了,比 mat[i][j] 大的还没计算
    ==> 对于第 i 行,相当于取这一行的 f 值的最大值,作为转移来源的值
        用长为 m 的数组rowmax维护每一行的 f 值的最大值
        用长为 n 的数组colmax维护每一列的 f 值的最大值
    转移方程: f[i][j] = max(rowmax[i], colmax[j]) + 1
    细节:  代码实现时,f[i][j] 可以忽略,因为只需要每行每列的 f 值的最大值
            对于相同元素,全部计算出最大值后,再更新到rowmax和colmax中
     */
    public int maxIncreasingCells(int[][] mat) {
        Map<Integer, List<int[]>> g = new TreeMap<>();
        int m = mat.length, n = mat[0].length;
        for (int i = 0; i < m; i++)
            for (int j = 0; j < n; j++)
                // 相同元素放在同一组,统计位置
                g.computeIfAbsent(mat[i][j], k -> new ArrayList<>()).add(new int[]{i, j});

        int ans = 0;
        int[] rowMax = new int[m], colMax = new int[n];
        for(List<int[]> pos : g.values()){
            int[] mx = new int[pos.size()]; // 先把最大值算出来,再更新 rowMax 和 colMax
            for(int i = 0; i < pos.size(); i++){
                // 因为是从小到大计算
                //  所以可以保证 rowmax[i] 和 colmax[j] 中转移来源的值一定 < 当前位置pos[i][j]的值
                mx[i] = Math.max(rowMax[pos.get(i)[0]], colMax[pos.get(i)[1]]) + 1;
                ans = Math.max(ans, mx[i]);
            }
            for(int k = 0; k < pos.size(); k++){
                int i = pos.get(k)[0], j = pos.get(k)[1];
                rowMax[i] = Math.max(rowMax[i], mx[k]); // 更新第 i 行的最大 f 值
                colMax[j] = Math.max(colMax[j], mx[k]); // 更新第 j 列的最大 f 值
            }
        }
        return ans;
    }
}

2538. 最大价值和与最小价值和的差值

难度困难38

给你一个 n 个节点的无向无根图,节点编号为 0n - 1 。给你一个整数 n 和一个长度为 n - 1 的二维整数数组 edges ,其中 edges[i] = [ai, bi] 表示树中节点 aibi 之间有一条边。

每个节点都有一个价值。给你一个整数数组 price ,其中 price[i] 是第 i 个节点的价值。

一条路径的 价值和 是这条路径上所有节点的价值之和。

你可以选择树中任意一个节点作为根节点 root 。选择 root 为根的 开销 是以 root 为起点的所有路径中,价值和 最大的一条路径与最小的一条路径的差值。

请你返回所有节点作为根节点的选择中,最大开销 为多少。

示例 1:

img

输入:n = 6, edges = [[0,1],[1,2],[1,3],[3,4],[3,5]], price = [9,8,7,6,10,5]
输出:24
解释:上图展示了以节点 2 为根的树。左图(红色的节点)是最大价值和路径,右图(蓝色的节点)是最小价值和路径。
- 第一条路径节点为 [2,1,3,4]:价值为 [7,8,6,10] ,价值和为 31 。
- 第二条路径节点为 [2] ,价值为 [7] 。
最大路径和与最小路径和的差值为 24 。24 是所有方案中的最大开销。

提示:

  • 1 <= n <= 105
  • edges.length == n - 1
  • 0 <= ai, bi <= n - 1
  • edges 表示一棵符合题面要求的树。
  • price.length == n
  • 1 <= price[i] <= 105

题解:0x3f:https://leetcode.cn/problems/difference-between-maximum-and-minimum-price-sum/solution/by-endlesscheng-5l70/

1、由于价值都是正数,因此价值和最小的一条路径一定只有一个点。

2、根据提示 1,「价值和最大的一条路径与最小的一条路径的差值」等价于「去掉路径的一个端点」

3、由于价值都是正数,一条路径能延长就尽量延长,这样路径和就越大,那么最优是延长到叶子。根据提示 2,问题转换成去掉一个叶子后的最大路径和(这里的叶子严格来说是度为 1 的点,因为根的度数也可能是 1)。

4、最大路径和是一个经典树形 DP 问题,类似「树的直径」。由于我们需要去掉一个叶子,那么可以让子树返回两个值:

  • 带叶子的最大路径和;
  • 不带叶子的最大路径和。

对于当前节点,它有多颗子树,我们一颗颗 DFS,假设当前 DFS 完了其中一颗子树,它返回了「当前带叶子的路径和」和「当前不带叶子的路径和」,那么答案有两种情况:

  • 前面最大带叶子的路径和 + 当前不带叶子的路径和;
  • 前面最大不带叶子的路径和 + 当前带叶子的路径和;

然后更新「最大带叶子的路径和」和「最大不带叶子的路径和」。

最后返回「最大带叶子的路径和」和「最大不带叶子的路径和」,用来供父节点计算。

class Solution {
    List<Integer>[] g;
    int n;
    long res;
    int[] price;
    public long maxOutput(int n, int[][] edges, int[] price) {
        this.n = n;
        g = new ArrayList[n];
        Arrays.setAll(g, e -> new ArrayList<>());
        for(int[] e : edges){
            int x = e[0], y = e[1];
            g[x].add(y);
            g[y].add(x);
        }
        this.price = price;
        dfs(0, -1);
        return res;
    }

    // 返回带叶子的最大路径和,不带叶子的最大路径和
    public long[] dfs(int x, int fa){
        // s1 带叶子的最大路径和 ; s2 不带叶子的最大路径和
        long p = price[x], maxS1 = p, maxS2 = 0;
        for(int y : g[x]){
            if(y != fa){
                long[] result = dfs(y, x);
                long s1 = result[0], s2 = result[1];
                // 前面最大带叶子的路径和 + 当前不带叶子的路径和
                // 前面最大不带叶子的路径和 + 当前带叶子的路径和
                res = Math.max(res, Math.max(maxS1 + s2, maxS2 + s1));
                // 这里加上 p 是因为 x 必然不是叶子
                maxS1 = Math.max(maxS1, s1 + p);
                maxS2 = Math.max(maxS2, s2 + p);
            }
        }
        return new long[]{maxS1, maxS2};
    }
}

2572. 无平方子集计数

难度中等31

给你一个正整数数组 nums

如果数组 nums 的子集中的元素乘积是一个 无平方因子数 ,则认为该子集是一个 无平方 子集。

无平方因子数 是无法被除 1 之外任何平方数整除的数字。

返回数组 nums无平方非空 的子集数目。因为答案可能很大,返回对 109 + 7 取余的结果。

nums非空子集 是可以由删除 nums 中一些元素(可以不删除,但不能全部删除)得到的一个数组。如果构成两个子集时选择删除的下标不同,则认为这两个子集不同。

示例 1:

输入:nums = [3,4,4,5]
输出:3
解释:示例中有 3 个无平方子集:
- 由第 0 个元素 [3] 组成的子集。其元素的乘积是 3 ,这是一个无平方因子数。
- 由第 3 个元素 [5] 组成的子集。其元素的乘积是 5 ,这是一个无平方因子数。
- 由第 0 个和第 3 个元素 [3,5] 组成的子集。其元素的乘积是 15 ,这是一个无平方因子数。
可以证明给定数组中不存在超过 3 个无平方子集。

示例 2:

输入:nums = [1]
输出:1
解释:示例中有 1 个无平方子集:
- 由第 0 个元素 [1] 组成的子集。其元素的乘积是 1 ,这是一个无平方因子数。
可以证明给定数组中不存在超过 1 个无平方子集。

提示:

  • 1 <= nums.length <= 1000
  • 1 <= nums[i] <= 30
class Solution {
    /**
    把每个无平方因字数 SF 对应的质因子集合,表示成一个二进制数   
    枚举子集的元素乘积,把乘积看成一个背包,装能够组成元素乘积的数字
     */
    private static final int[] PRIMES = new int[]{2, 3, 5, 7, 11, 13, 17, 19, 23, 29}; // 给出小于等于 30 的质数集合
    private static final int MOD = (int) 1e9 + 7, MX = 30, N_PRIMES = PRIMES.length, M = 1 << N_PRIMES;
    private static final int[] SF_TO_MASK = new int[MX + 1]; // SF_TO_MASK[i] 为 i 的质因子集合(用二进制表示)


    // 对于每个整数,将其质因数集合用二进制表示出来
    // SF_TO_MASK[i] 的二进制第 j 位为 1 表示 i 可以被 PRIMES[j] 整除
    // SF_TO_MASK[i] 的值为 -1 表示 i 存在平方因子
    static {
        for (int i = 2; i <= MX; ++i) // 值域:1 <= nums[i] <= 30
            for (int j = 0; j < N_PRIMES; ++j) {
                int p = PRIMES[j];
                if (i % p == 0) { // p 是 i 的因子
                    if (i % (p * p) == 0) { // 有平方因子
                        SF_TO_MASK[i] = -1;
                        break;
                    }
                    SF_TO_MASK[i] |= 1 << j; // 把 j 加到集合中
                }
            }
    }

    public int squareFreeSubsets(int[] nums) {
        var f = new int[M]; // f[j] 表示恰好组成质数集合 j 的方案数
        f[0] = 1; // 质数集合是空集的方案数为 1
        for (int x : nums) { // 
            int mask = SF_TO_MASK[x]; // 计算当前数的质因数集合
            if (mask >= 0) // x 是 SF , 当当前数不包含平方因子时才进行更新
                for (int j = M - 1; j >= mask; --j)
                    if ((j | mask) == j)  // mask 是 j 的子集, 如果 j 包含 mask 的质因数集合,则可以更新 f[j]
                        // 把mask装到能容纳 mask 的背包 j 中
                        f[j] = (f[j] + f[j ^ mask]) % MOD; // 不选 mask + 选 mask
        }
        // f 数组中的所有值加起来就是无平方非空子集的总数,因为空集不算,所以要减去 1
        var ans = 0L;
        for (int v : f) ans += v;
        return (int) ((ans - 1) % MOD); // -1 去掉空集(nums 的空子集)
    }
}

2742. 给墙壁刷油漆

难度困难25

给你两个长度为 n 下标从 0 开始的整数数组 costtime ,分别表示给 n 堵不同的墙刷油漆需要的开销和时间。你有两名油漆匠:

  • 一位需要 付费 的油漆匠,刷第 i 堵墙需要花费 time[i] 单位的时间,开销为 cost[i] 单位的钱。
  • 一位 免费 的油漆匠,刷 任意 一堵墙的时间为 1 单位,开销为 0 。但是必须在付费油漆匠 工作 时,免费油漆匠才会工作。

请你返回刷完 n 堵墙最少开销为多少。

示例 1:

输入:cost = [1,2,3,2], time = [1,2,3,2]
输出:3
解释:下标为 0 和 1 的墙由付费油漆匠来刷,需要 3 单位时间。同时,免费油漆匠刷下标为 2 和 3 的墙,需要 2 单位时间,开销为 0 。总开销为 1 + 2 = 3 。

示例 2:

输入:cost = [2,3,4,2], time = [1,1,1,1]
输出:4
解释:下标为 0 和 3 的墙由付费油漆匠来刷,需要 2 单位时间。同时,免费油漆匠刷下标为 1 和 2 的墙,需要 2 单位时间,开销为 0 。总开销为 2 + 2 = 4 。

提示:

  • 1 <= cost.length <= 500
  • cost.length == time.length
  • 1 <= cost[i] <= 106
  • 1 <= time[i] <= 500
class Solution {
    // 付费的油漆匠 选或不选,只有一个付费油漆匠,time[i]需要加起来
    // 如果第 i 面墙分配给免费的油漆匠,那么消耗 1 单位的时间
    // 定义dfs(i,j):刷完0 ~ i 的墙,且当前累计付费时间为j(已经赊给免费的时间),最少开销为多少
    //      付费:dfs(i,j) = dfs(i-1, j+time[i]) + cost[i]
    //      免费:dfs(i,j) = dfs(i-1, j-1)
    //  转移:dfs(i,j) = min(dfs(i-1, j+time[i])+cost[i], dfs(i-1, j-1))
    // 递归边界:dfs(-1, <0) = inf ; dfs(i,j) = 0 if j > i(时间超过了剩下需要刷的墙)
    // 递归入口:dfs(n-1, 0)
    int[] cost, time;
    int[][] cache;
    int n;
    public int paintWalls(int[] cost, int[] time) {
        this.cost = cost; 
        this.time = time;
        n = cost.length;
        cache = new int[n][2*n+1]; // 免费时长可以为负数,因此需要加偏移量
        for(int i = 0; i < n; i++)
            Arrays.fill(cache[i], -1);
        return dfs(n-1, n); // 偏移量防止负数
    }
    
    // 免费时长为j,刷前i片墙需要的最小花费
    public int dfs(int i, int j){
        if(i < j-n) return 0; // 剩余所有墙都可以由免费油漆工刷
        if(i < 0) return Integer.MAX_VALUE / 2;
        if(cache[i][j] >= 0) return cache[i][j];
        // 免费油漆工刷 dfs(i-1, j-1)
        // 付费油漆工刷 dfs(i-1, j+time[i])+cost[i]
        return cache[i][j] = Math.min(dfs(i-1, j+time[i])+cost[i], dfs(i-1, j-1));
    }
}

2617. 网格图中最少访问的格子数

难度困难17

给你一个下标从 0 开始的 m x n 整数矩阵 grid 。你一开始的位置在 左上角 格子 (0, 0)

当你在格子 (i, j) 的时候,你可以移动到以下格子之一:

  • 满足 j < k <= grid[i][j] + j 的格子 (i, k) (向右移动),或者
  • 满足 i < k <= grid[i][j] + i 的格子 (k, j) (向下移动)。

请你返回到达 右下角 格子 (m - 1, n - 1) 需要经过的最少移动格子数,如果无法到达右下角格子,请你返回 -1

示例 1:

从周赛中学算法-2023上_第3张图片

输入:grid = [[3,4,2,1],[4,2,3,1],[2,1,0,0],[2,4,0,0]]
输出:4
解释:上图展示了到达右下角格子经过的 4 个格子。

提示:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 105
  • 1 <= m * n <= 105
  • 0 <= grid[i][j] < m * n
  • grid[m - 1][n - 1] == 0
class Solution {
    /** 
    (0, 0) -> (0, 1) -> (1, 1)
    (0, 0) -> (1, 0) -> (1, 1)
    有重叠子问题 -> 用动态规划来优化
    dfs(0, 0) -> dfs(x, y)

    g = grid[x][y]
    dfs(i, j) 枚举k,计算: min(dfs(i, k), k in [j+1,j+g])
                    计算: min(dfs(k, j), k in [i+1,i+g])
    优化前: O(mn log(m+n))
    
    f[i][j] = min {
                    min(f[i][k], k in [j+1,j+g])
                    min(f[k][j], k in [i+1,i+g])
                }
    i和j倒序枚举

    从右往左计算的时候,如果计算出f[i][j] <= 之前的数,那么之前的数就没用了
    ==> 单调栈    从底往顶,值和下标不断变大的单调栈
    */
    public int minimumVisitedCells(int[][] grid) {
        int m = grid.length, n = grid[0].length, mn = 0;
        List<int[]>[] colSt = new ArrayList[n]; // 每列的单调栈
        Arrays.setAll(colSt, e -> new ArrayList<int[]>());
        for (int i = m - 1; i >= 0; --i) {
            var st = new ArrayList<int[]>(); // 当前行的单调栈
            for (int j = n - 1; j >= 0; --j) {
                var st2 = colSt[j];
                mn = Integer.MAX_VALUE;
                int g = grid[i][j];
                if (i == m - 1 && j == n - 1) // 特殊情况:已经是终点
                    mn = 0;
                else if (g > 0) {
                    // 在单调栈上二分
                    int k = search(st, j + g);
                    if (k < st.size()) mn = Math.min(mn, st.get(k)[0]);
                    k = search(st2, i + g);
                    if (k < st2.size()) mn = Math.min(mn, st2.get(k)[0]);
                }
                if (mn == Integer.MAX_VALUE) continue;

                ++mn; // 加上 (i,j) 这个格子
                // 插入单调栈
                while (!st.isEmpty() && mn <= st.get(st.size() - 1)[0])
                    st.remove(st.size() - 1);
                st.add(new int[]{mn, j});
                while (!st2.isEmpty() && mn <= st2.get(st2.size() - 1)[0])
                    st2.remove(st2.size() - 1);
                st2.add(new int[]{mn, i});
            }
        }
        return mn < Integer.MAX_VALUE ? mn : -1;
    }

    // 见 https://www.bilibili.com/video/BV1AP41137w7/
    private int search(List<int[]> st, int target) {
        int left = -1, right = st.size(); // 开区间 (left, right)
        while (left + 1 < right) { // 区间不为空
            int mid = (left + right) >>> 1;
            if (st.get(mid)[1] > target) left = mid; // 范围缩小到 (mid, right)
            else right = mid; // 范围缩小到 (left, mid)
        }
        return right;
    }
}

三、图论

包含 DFS、BFS、拓扑排序、最短路等。

题目 难度 备注
2641. 二叉树的堂兄弟节点 II 1677 BFS
2684. 矩阵中移动的最大次数 1626 多源BFS/网格图DP
2685. 统计完全连通分量的数量 1769 DFS
2642. 设计可以求最短路径的图类 1811 Dijkstra/Floyd 模板
2608. 图中的最短环 1904 BFS
2662. 前往目标的最小代价 2154 Dijkstra
2577. 在网格图中访问一个格子的最少时间 2382 Dijkstra/二分答案+BFS
2603. 收集树中金币 2712 拓扑排序
2699. 修改图中的边权 2874 Dijkstra

2641. 二叉树的堂兄弟节点 II

难度中等9

给你一棵二叉树的根 root ,请你将每个节点的值替换成该节点的所有 堂兄弟节点值的和

如果两个节点在树中有相同的深度且它们的父节点不同,那么它们互为 堂兄弟

请你返回修改值之后,树的根 root

注意,一个节点的深度指的是从树根节点到这个节点经过的边数。

示例 1:

从周赛中学算法-2023上_第4张图片

输入:root = [5,4,9,1,10,null,7]
输出:[0,0,0,7,7,null,11]
解释:上图展示了初始的二叉树和修改每个节点的值之后的二叉树。
- 值为 5 的节点没有堂兄弟,所以值修改为 0 。
- 值为 4 的节点没有堂兄弟,所以值修改为 0 。
- 值为 9 的节点没有堂兄弟,所以值修改为 0 。
- 值为 1 的节点有一个堂兄弟,值为 7 ,所以值修改为 7 。
- 值为 10 的节点有一个堂兄弟,值为 7 ,所以值修改为 7 。
- 值为 7 的节点有两个堂兄弟,值分别为 1 和 10 ,所以值修改为 11 。

提示:

  • 树中节点数目的范围是 [1, 105]
  • 1 <= Node.val <= 104

题解:站在父亲的视角上修改孩子的值 ==> BFS

class Solution {
    public TreeNode replaceValueInTree(TreeNode root) {
        if(root == null) return root;
        root.val = 0;
        List<TreeNode> dq = new ArrayList<>();
        dq.add(root);
        while(dq.size() > 0){
            List<TreeNode> cur = new ArrayList<>();
            int sum = 0;
            for(TreeNode t : dq){
                if(t.left != null){
                    sum += t.left.val;
                    cur.add(t.left);
                }
                if(t.right != null){
                    sum += t.right.val;
                    cur.add(t.right);
                }
            }
            for(TreeNode t : dq){
                int tot = (t.left == null ? 0 : t.left.val) + (t.right == null ? 0 : t.right.val); 
                if(t.left != null) t.left.val = sum - tot;
                if(t.right != null) t.right.val = sum - tot;
            }
            dq = cur;
        }
        return root;
    }
}

python

class Solution:
    def replaceValueInTree(self, root: Optional[TreeNode]) -> Optional[TreeNode]:   
        if not root: return root
        root.val = 0
        q = [root]
        while q:
            cur = []
            s = 0
            for t in q:
                if t.left:
                    s += t.left.val
                    cur.append(t.left)
                if t.right:
                    s += t.right.val
                    cur.append(t.right)
            for t in q:
                cs = (t.left.val if t.left else 0) + (t.right.val if t.right else 0)
                if t.left: t.left.val = s - cs
                if t.right: t.right.val = s - cs
            q = cur
        return root

2684. 矩阵中移动的最大次数

难度中等10

给你一个下标从 0 开始、大小为 m x n 的矩阵 grid ,矩阵由若干 整数组成。

你可以从矩阵第一列中的 任一 单元格出发,按以下方式遍历 grid

  • 从单元格 (row, col) 可以移动到 (row - 1, col + 1)(row, col + 1)(row + 1, col + 1) 三个单元格中任一满足值 严格 大于当前单元格的单元格。

返回你在矩阵中能够 移动最大 次数。

示例 1:

输入:grid = [[2,4,3,5],[5,4,9,3],[3,4,2,11],[10,9,13,15]]
输出:3
解释:可以从单元格 (0, 0) 开始并且按下面的路径移动:
- (0, 0) -> (0, 1).
- (0, 1) -> (1, 2).
- (1, 2) -> (2, 3).
可以证明这是能够移动的最大次数。

提示:

  • m == grid.length
  • n == grid[i].length
  • 2 <= m, n <= 1000
  • 4 <= m * n <= 105
  • 1 <= grid[i][j] <= 106

方法一:网格图DP

记忆化搜索

class Solution:
    def maxMoves(self, grid: List[List[int]]) -> int:
        m, n = len(grid), len(grid[0])

        @cache
        def dfs(i, j: int) -> int:
            if j == n-1:
                return 0
            res = 0
            for k in i-1, i, i+1:
                if 0 <= k < m and grid[k][j+1] > grid[i][j]:
                    res = max(res, dfs(k, j+1) + 1)
            return res
        
        return max(dfs(k, 0) for k in range(m))

方法二:多源BFS

class Solution:
    # 多源BFS,每轮向右搜索一列
    def maxMoves(self, grid: List[List[int]]) -> int:
        m, n = len(grid), len(grid[0])
        q = range(m) # 从第0列开始,共m行个元素
        vis = [-1] * m
        for j in range(n - 1): # 遍历每一列
            tmp = q
            q = []
            for i in tmp: # 每一列中还存在的行元素 i
                for k in i - 1, i, i + 1:
                    if 0 <= k < m and vis[k] != j and grid[k][j+1] > grid[i][j]:
                        vis[k] = j # 时间戳标记,避免重复创建 vis 数组
                        q.append(k)
            if len(q) == 0:
                return j
        return n-1

2685. 统计完全连通分量的数量

难度中等15

给你一个整数 n 。现有一个包含 n 个顶点的 无向 图,顶点按从 0n - 1 编号。给你一个二维整数数组 edges 其中 edges[i] = [ai, bi] 表示顶点 aibi 之间存在一条 无向 边。

返回图中 完全连通分量 的数量。

如果在子图中任意两个顶点之间都存在路径,并且子图中没有任何一个顶点与子图外部的顶点共享边,则称其为 连通分量

如果连通分量中每对节点之间都存在一条边,则称其为 完全连通分量

提示:

  • 1 <= n <= 50
  • 0 <= edges.length <= n * (n - 1) / 2
  • edges[i].length == 2
  • 0 <= ai, bi <= n - 1
  • ai != bi
  • 不存在重复的边

题解:如何在一个dfs中计算完全联通分量的边个数和点个数?

class Solution:
    # 完全联通分量,点的个数和边的个数的关系:e = v*(v-1) / 2
    def countCompleteComponents(self, n: int, edges: List[List[int]]) -> int:
        g = [[] for _ in range(n)]
        for x, y in edges:
            g[x].append(y)
            g[y].append(x)

        def dfs(x: int) -> int:
            vis[x] = True
            nonlocal v, e
            v += 1
            e += len(g[x])
            for y in g[x]:
                if not vis[y]:
                    dfs(y)

        vis = [False] * n
        ans = 0
        for i in range(n):
            if not vis[i]:
                e = v = 0
                dfs(i)
                # 由于上面统计的时候,一条边统计了两次,所以代码中的判断条件是 e == v*(v-1)
                ans += e == v*(v-1)
        return ans

2642. 设计可以求最短路径的图类

难度困难5

给你一个有 n 个节点的 有向带权 图,节点编号为 0n - 1 。图中的初始边用数组 edges 表示,其中 edges[i] = [fromi, toi, edgeCosti] 表示从 fromitoi 有一条代价为 edgeCosti 的边。

请你实现一个 Graph 类:

  • Graph(int n, int[][] edges) 初始化图有 n 个节点,并输入初始边。
  • addEdge(int[] edge) 向边集中添加一条边,其中 edge = [from, to, edgeCost] 。数据保证添加这条边之前对应的两个节点之间没有有向边。
  • int shortestPath(int node1, int node2) 返回从节点 node1node2 的路径 最小 代价。如果路径不存在,返回 -1 。一条路径的代价是路径中所有边代价之和。

提示:

  • 1 <= n <= 100
  • 0 <= edges.length <= n * (n - 1)
  • edges[i].length == edge.length == 3
  • 0 <= fromi, toi, from, to, node1, node2 <= n - 1
  • 1 <= edgeCosti, edgeCost <= 106
  • 图中任何时候都不会有重边和自环。
  • 调用 addEdge 至多 100 次。
  • 调用 shortestPath 至多 100 次。

方法一:Dijkstra算法

class Graph {

    private static final int INF = Integer.MAX_VALUE / 2;
    int n;
    int[][] g;
    
    public Graph(int n, int[][] edges) {
        this.n = n;
        g = new int[n][n];
        for(int i = 0; i < n; i++) Arrays.fill(g[i], INF);
        for(int[] e : edges){
            int from = e[0], to = e[1], cost = e[2];
            g[from][to] = cost;
        }
    }
    
    public void addEdge(int[] edge) {
        int from = edge[0], to = edge[1], cost = edge[2];
        g[from][to] = cost;
    }
    
    public int shortestPath(int node1, int node2) {
        int[] dis = new int[n];
        Arrays.fill(dis, INF);
        dis[node1] = 0;
        boolean[] used = new boolean[n];
        for(int i = 0; i < n; i++){
            int x = -1;
            for(int y = 0; y < n; y++){
                if(!used[y] && (x == -1 || dis[y] < dis[x])){
                    x = y;
                }
            }
            used[x] = true;
            for(int y = 0; y < n; y++){
                dis[y] = Math.min(dis[y], dis[x] + g[x][y]);
            }
        }
        return dis[node2] == INF ? -1 : dis[node2];
    }
}

方法二:Floyd算法

class Graph {
    /*
    Floyd定义:
    f[k][i][j] 表示除了i 和 j 之外,从 i 到 j 的路径中间点上至多为 k 的时候,从 i 到 j 的最短路的长度
    
    分类讨论:
        从 i 到 j 的最短路中间至多为 k-1 ==>
        从 i 到 j 的最短路中间至多为 k,说明 k 一定是中间节点
        f[k][i][j] = min(f[k-1][i][j], f[k-1][i][k] + f[k-1][k][j]) 
        维度优化: f[i][j] = min(f[i][j], f[i][k] + f[k][j])

        提问: 为什么维度优化,这样做还是对的? - k表示路径中间至多为k,不包含端点

    */

    private static final int INF = Integer.MAX_VALUE / 3;
    int n;
    int[][] g;
    
    public Graph(int n, int[][] edges) {
        this.n = n;
        g = new int[n][n];
        for(int i = 0; i < n; i++){
            // 邻接矩阵(初始化为无穷大,表示 i 到 j 没有边)
            Arrays.fill(g[i], INF);
            g[i][i] = 0; // i->i 的路径初始化为0
        }
        for(int[] e : edges){
            int from = e[0], to = e[1], cost = e[2];
            g[from][to] = cost;
        }
        // Floyd维护最短路径
        for(int k = 0; k < n; k++){
            for(int i = 0; i < n; i++){
                for(int j = 0; j < n; j++){
                    g[i][j] = Math.min(g[i][j], g[i][k] + g[k][j]); 
                }
            }
        }
    }
    
    public void addEdge(int[] edge) {
        int x = edge[0], y = edge[1], cost = edge[2];
        if(cost >= g[x][y]){
            return; // 无需更新,因为目前从 x->y 最短路比新加入的点小
        }
        // 更新i->j的路径,从新加入的节点处转移
        for(int i = 0; i < n; i++){
            for(int j = 0; j < n; j++){
                //  原来: i -> j
                //  更新后: i -> x -> y -> j
                g[i][j] = Math.min(g[i][j], g[i][x] + cost + g[y][j]);
            }
        }
    }
    
    public int shortestPath(int start, int end) {
        int ans = g[start][end];
        return ans < INF/ 3 ? ans : -1; 
    }
}

2608. 图中的最短环

难度困难14

现有一个含 n 个顶点的 双向 图,每个顶点按从 0n - 1 标记。图中的边由二维整数数组 edges 表示,其中 edges[i] = [ui, vi] 表示顶点 uivi 之间存在一条边。每对顶点最多通过一条边连接,并且不存在与自身相连的顶点。

返回图中 最短 环的长度。如果不存在环,则返回 -1

是指以同一节点开始和结束,并且路径中的每条边仅使用一次。

示例 1:

从周赛中学算法-2023上_第5张图片

输入:n = 7, edges = [[0,1],[1,2],[2,0],[3,4],[4,5],[5,6],[6,3]]
输出:3
解释:长度最小的循环是:0 -> 1 -> 2 -> 0 

提示:

  • 2 <= n <= 1000
  • 1 <= edges.length <= 1000
  • edges[i].length == 2
  • 0 <= ui, vi < n
  • ui != vi
  • 不存在重复的边
class Solution {
    /**
    环:从点 a 到点 b,有两条不同的简单路径,这两条简单路径就组成了一个环

    BFS枚举起点 start,如果找到从 start 出发的两条不同的简单路径,能到达同一个点
        那么当前的环就是最小的,后面继续找 只能找到更大的包含 start 的环
        环长为 dis[a] + dis[b] + 1

    问:为什么说发现一个已经入队的点,就说明有环?
    答:这说明到同一个点有两条不同的路径,这两条路径组成了一个环。
     */
    List<Integer>[] g;
    int[] dis; // dis[i] 表示从 start 到 i 的最短路长度
    public int findShortestCycle(int n, int[][] edges) {
        g = new ArrayList[n];
        Arrays.setAll(g, e -> new ArrayList<>());
        for(int[] e : edges){
            int x = e[0], y = e[1];
            g[x].add(y);
            g[y].add(x);
        }
        dis = new int[n];

        int ans = Integer.MAX_VALUE;
        for(int i = 0; i < n; i++){ // 枚举每个起点跑 BFS
            ans = Math.min(ans, bfs(i));
        }
        return ans < Integer.MAX_VALUE ? ans : -1;
    }

    public int bfs(int start){
        int ans = Integer.MAX_VALUE;
        Arrays.fill(dis, -1);
        dis[start] = 0;
        Deque<int[]> dq = new ArrayDeque<>(); // 点,上一个点 (记录一下是谁让我入队的,以此来区分是否是不同路径)
        dq.add(new int[]{start, -1});
        while(!dq.isEmpty()){
            int[] p = dq.poll();
            int x = p[0], fa = p[1];
            for(int y : g[x]){
                if(dis[y] < 0){ // 第一次遇到
                    dis[y] = dis[x] + 1;
                    dq.add(new int[]{y, x});
                }else if(y != fa)
                    ans = Math.min(ans, dis[x] + dis[y] + 1);
            }
        }
        return ans;
    }
}

2662. 前往目标的最小代价

难度中等28

给你一个数组 start ,其中 start = [startX, startY] 表示你的初始位置位于二维空间上的 (startX, startY) 。另给你一个数组 target ,其中 target = [targetX, targetY] 表示你的目标位置 (targetX, targetY)

从位置 (x1, y1) 到空间中任一其他位置 (x2, y2) 的代价是 |x2 - x1| + |y2 - y1|

给你一个二维数组 specialRoads ,表示空间中存在的一些特殊路径。其中 specialRoads[i] = [x1i, y1i, x2i, y2i, costi] 表示第 i 条特殊路径可以从 (x1i, y1i)(x2i, y2i) ,但成本等于 costi 。你可以使用每条特殊路径任意次数。

返回从 (startX, startY)(targetX, targetY) 所需的最小代价。

示例 1:

输入:start = [1,1], target = [4,5], specialRoads = [[1,2,3,3,2],[3,4,4,5,1]]
输出:5
解释:从 (1,1) 到 (4,5) 的最优路径如下:
- (1,1) -> (1,2) ,移动的代价是 |1 - 1| + |2 - 1| = 1 。
- (1,2) -> (3,3) ,移动使用第一条特殊路径,代价是 2 。
- (3,3) -> (3,4) ,移动的代价是 |3 - 3| + |4 - 3| = 1.
- (3,4) -> (4,5) ,移动使用第二条特殊路径,代价是 1 。
总代价是 1 + 2 + 1 + 1 = 5 。
可以证明无法以小于 5 的代价完成从 (1,1) 到 (4,5) 。

示例 2:

输入:start = [3,2], target = [5,7], specialRoads = [[3,2,3,4,4],[3,3,5,5,5],[3,4,5,6,6]]
输出:7
解释:最优路径是不使用任何特殊路径,直接以 |5 - 3| + |7 - 2| = 7 的代价从初始位置到达目标位置。

提示:

  • start.length == target.length == 2
  • 1 <= startX <= targetX <= 105
  • 1 <= startY <= targetY <= 105
  • 1 <= specialRoads.length <= 200
  • specialRoads[i].length == 5
  • startX <= x1i, x2i <= targetX
  • startY <= y1i, y2i <= targetY
  • 1 <= costi <= 105
class Solution {
    public int minimumCost(int[] start, int[] target, int[][] specialRoads) {
        long t = (long) target[0] << 32 | target[1];
        // 由于 1 <= startY <= targetY <= 105 ,用map来记录 距离dis数组 和 访问vis数组
        Map<Long, Integer> dis = new HashMap<>(); 
        dis.put(t, Integer.MAX_VALUE);
        dis.put((long) start[0] << 32 | start[1], 0);
        Set<Long> vis = new HashSet<>();
        for(;;){ // dijkstra 算法,找到dis中最短的点,用该点更新到其他点的距离
            long v = -1;
            int dv = -1;
            for(Map.Entry<Long, Integer> e : dis.entrySet()){
                if(!vis.contains(e.getKey()) && (dv < 0 || e.getValue() < dv)){
                    v = e.getKey();
                    dv = e.getValue();
                }
            }
            if(v == t) return dv; // 找到了到终点的最短路
            vis.add(v);
            int vx = (int) (v >> 32), vy = (int) (v & Integer.MAX_VALUE);
            // 用 点v 更新到终点的最短路
            dis.merge(t, dv + (target[0] - vx + target[1] - vy), Math::min);
            // 用 点v 更新到其他点的距离
            for(int[] r : specialRoads){
                //从位置 (x1, y1) 到空间中任一其他位置 (x2, y2) 的代价是 |x2 - x1| + |y2 - y1| 
                int d = dv + Math.abs(r[0] - vx) + Math.abs(r[1] - vy) + r[4];
                long w = (long) r[2] << 32 | r[3];
                if(d < dis.getOrDefault(w, Integer.MAX_VALUE))
                    dis.put(w, d);
            }
        }
    }
}

2577. 在网格图中访问一个格子的最少时间

难度困难32

给你一个 m x n 的矩阵 grid ,每个元素都为 非负 整数,其中 grid[row][col] 表示可以访问格子 (row, col)最早 时间。也就是说当你访问格子 (row, col) 时,最少已经经过的时间为 grid[row][col]

你从 最左上角 出发,出发时刻为 0 ,你必须一直移动到上下左右相邻四个格子中的 任意 一个格子(即不能停留在格子上)。每次移动都需要花费 1 单位时间。

请你返回 最早 到达右下角格子的时间,如果你无法到达右下角的格子,请你返回 -1

示例 1:

从周赛中学算法-2023上_第6张图片

输入:grid = [[0,1,3,2],[5,1,2,5],[4,3,8,6]]
输出:7
解释:一条可行的路径为:
- 时刻 t = 0 ,我们在格子 (0,0) 。
- 时刻 t = 1 ,我们移动到格子 (0,1) ,可以移动的原因是 grid[0][1] <= 1 。
- 时刻 t = 2 ,我们移动到格子 (1,1) ,可以移动的原因是 grid[1][1] <= 2 。
- 时刻 t = 3 ,我们移动到格子 (1,2) ,可以移动的原因是 grid[1][2] <= 3 。
- 时刻 t = 4 ,我们移动到格子 (1,1) ,可以移动的原因是 grid[1][1] <= 4 。
- 时刻 t = 5 ,我们移动到格子 (1,2) ,可以移动的原因是 grid[1][2] <= 5 。
- 时刻 t = 6 ,我们移动到格子 (1,3) ,可以移动的原因是 grid[1][3] <= 6 。
- 时刻 t = 7 ,我们移动到格子 (2,3) ,可以移动的原因是 grid[2][3] <= 7 。
最终到达时刻为 7 。这是最早可以到达的时间。

提示:

  • m == grid.length
  • n == grid[i].length
  • 2 <= m, n <= 1000
  • 4 <= m * n <= 105
  • 0 <= grid[i][j] <= 105
  • grid[0][0] == 0

题解:https://leetcode.cn/problems/minimum-time-to-visit-a-cell-in-a-grid/solution/er-fen-da-an-bfspythonjavacgo-by-endless-j10w/

class Solution {
    // 无法到达右下角的情况:grid[0][1] > 1 或者 grid[1][0] > 1.
    // 通过反复横跳, 出发时间应该为偶数
    // 到达一个格子的时间的奇偶性是不变的,即 dis[i][j] 应该和 i+j 的奇偶性相同
    private final static int[][] dirts = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
    public int minimumTime(int[][] grid) {
        int m = grid.length, n = grid[0].length;
        if(grid[0][1] > 1 && grid[1][0] > 1)
            return -1;
        int[][] dis = new int[m][n];
        for(int i = 0; i < m; i++)
            Arrays.fill(dis[i], Integer.MAX_VALUE);
        dis[0][0] = 0;
        // dis x y : 按距离排序,确保每次从优先队列中取出的都是当前能够达到的最短路
        PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[0] - b[0]);
        pq.add(new int[]{0, 0, 0});
        for(;;){ // 只要等待,就一定可以到达终点
            int[] p = pq.poll();
            int d = p[0], i = p[1], j = p[2];
            if(d > dis[i][j]) continue;
            if(i == m-1 && j == n-1) // 找到终点,此时 d 一定是最短路
                return d;
            for(int[] q : dirts){ // 枚举周围四个格子
                int x = i + q[0], y = j + q[1];
                if(0 <= x && x < m && 0 <= y && y < n){
                    int nd = Math.max(d+1, grid[x][y]);
                    nd += (nd - x - y) % 2; // nd 必须和 x+y 同奇偶
                    if(nd < dis[x][y]){
                        dis[x][y] = nd; // 更新最短路
                        pq.add(new int[]{nd, x, y});
                    }
                }
            }
        }
    }
}

2603. 收集树中金币

难度困难33

给你一个 n 个节点的无向无根树,节点编号从 0n - 1 。给你整数 n 和一个长度为 n - 1 的二维整数数组 edges ,其中 edges[i] = [ai, bi] 表示树中节点 aibi 之间有一条边。再给你一个长度为 n 的数组 coins ,其中 coins[i] 可能为 0 也可能为 11 表示节点 i 处有一个金币。

一开始,你需要选择树中任意一个节点出发。你可以执行下述操作任意次:

  • 收集距离当前节点距离为 2 以内的所有金币,或者
  • 移动到树中一个相邻节点。

你需要收集树中所有的金币,并且回到出发节点,请你返回最少经过的边数。

如果你多次经过一条边,每一次经过都会给答案加一。

示例 1:

从周赛中学算法-2023上_第7张图片

输入:coins = [1,0,0,0,0,1], edges = [[0,1],[1,2],[2,3],[3,4],[4,5]]
输出:2
解释:从节点 2 出发,收集节点 0 处的金币,移动到节点 3 ,收集节点 5 处的金币,然后移动回节点 2 。

提示:

  • n == coins.length
  • 1 <= n <= 3 * 104
  • 0 <= coins[i] <= 1
  • edges.length == n - 1
  • edges[i].length == 2
  • 0 <= ai, bi < n
  • ai != bi
  • edges 表示一棵合法的树。
class Solution {
    // 拓扑排序,去掉没有金币的叶子
    // 性质:如果所有在叶子上的金币都收集到了,那么顺路可以把不在叶子上的金币也收集到
    // 再次拓扑排序,去掉两轮叶子
    public int collectTheCoins(int[] coins, int[][] edges) {
        int n = coins.length;
        List<Integer>[] g = new ArrayList[n];
        Arrays.setAll(g, e -> new ArrayList<>());
        int[] deg = new int[n];
        for(int[] e : edges){
            int x = e[0], y = e[1];
            g[x].add(y);
            g[y].add(x);
            deg[x]++;
            deg[y]++;
        }

        int left_edges = n-1; // 剩余叶子的个数
        // 用拓扑排序「剪枝」:去掉没有金币的子树
        Deque<Integer> dq = new ArrayDeque<>();
        for(int i = 0; i < n; i++)
            if(deg[i] == 1 && coins[i] == 0) // 无金币叶子
                dq.add(i);
        while(!dq.isEmpty()){
            int x = dq.poll();
            left_edges -= 1;
            for(int y : g[x]){
                if(--deg[y] == 1 && coins[y] == 0)
                    dq.add(y);
            }
        }

        // 再次拓扑排序,去掉两轮叶子,剩下的节点都是必须走的节点
        for(int i = 0; i < n; i++)
            if(deg[i] == 1 && coins[i] == 1) // 有金币的叶子
                dq.add(i);
        left_edges -= dq.size();
        if(dq.size() <= 1) return 0; // 至多一个有金币的叶子,直接收集
        while(!dq.isEmpty()){
            int x = dq.poll();
            for(int y : g[x]){
                if(--deg[y] == 1){ // 如果有金币的叶子的邻居变成叶子了,就把它也去掉
                    left_edges -= 1;
                }
            }
        }
        return Math.max(left_edges * 2, 0); // 每条剩余的边走两次 = 节点走两次
    }
}

2699. 修改图中的边权

难度困难102

给你一个 n 个节点的 无向带权连通 图,节点编号为 0n - 1 ,再给你一个整数数组 edges ,其中 edges[i] = [ai, bi, wi] 表示节点 aibi 之间有一条边权为 wi 的边。

部分边的边权为 -1wi = -1),其他边的边权都为 数(wi > 0)。

你需要将所有边权为 -1 的边都修改为范围 [1, 2 * 109] 中的 正整数 ,使得从节点 source 到节点 destination最短距离 为整数 target 。如果有 多种 修改方案可以使 sourcedestination 之间的最短距离等于 target ,你可以返回任意一种方案。

如果存在使 sourcedestination 最短距离为 target 的方案,请你按任意顺序返回包含所有边的数组(包括未修改边权的边)。如果不存在这样的方案,请你返回一个 空数组

**注意:**你不能修改一开始边权为正数的边。

示例 1:

从周赛中学算法-2023上_第8张图片

输入:n = 5, edges = [[4,1,-1],[2,0,-1],[0,3,-1],[4,3,-1]], source = 0, destination = 1, target = 5
输出:[[4,1,1],[2,0,1],[0,3,3],[4,3,1]]
解释:上图展示了一个满足题意的修改方案,从 0 到 1 的最短距离为 5 。

提示:

  • 1 <= n <= 100
  • 1 <= edges.length <= n * (n - 1) / 2
  • edges[i].length == 3
  • 0 <= ai, bi < n
  • wi = -1 或者 1 <= wi <= 107
  • ai != bi
  • 0 <= source, destination < n
  • source != destination
  • 1 <= target <= 109
  • 输入的图是连通图,且没有自环和重边。

https://leetcode.cn/problems/modify-graph-edge-weights/solution/xiang-xi-fen-xi-liang-ci-dijkstrachou-mi-gv1m/

class Solution {
    /**
    无解的情况:将边权为-1的视为1,如果一次最短路下,从start到end的距离 > target,则一定无解
    有解的情况,需要思考两个事情:
        1. 按照什么顺序修改 边权为-1 的边
        2. 如何使用代码实现
     */
    public int[][] modifiedGraphEdges(int n, int[][] edges, int source, int destination, int target) {
        List<int[]> g[] = new ArrayList[n];
        Arrays.setAll(g, e -> new ArrayList<>());
        for (int i = 0; i < edges.length; i++) {
            int x = edges[i][0], y = edges[i][1];
            g[x].add(new int[]{y, i});
            g[y].add(new int[]{x, i}); // 建图,额外记录边的编号
        }

        var dis = new int[n][2];
        for (int i = 0; i < n; i++)
            if (i != source)
                dis[i][0] = dis[i][1] = Integer.MAX_VALUE;

        dijkstra(g, edges, destination, dis, 0, 0);
        int delta = target - dis[destination][0];
        if (delta < 0) // -1 全改为 1 时,最短路比 target 还大
            return new int[][]{};

        dijkstra(g, edges, destination, dis, delta, 1);
        if (dis[destination][1] < target) // 最短路无法再变大,无法达到 target
            return new int[][]{};

        for (var e : edges)
            if (e[2] == -1) // 剩余没修改的边全部改成 1
                e[2] = 1;
        return edges;
    }

    // 朴素 Dijkstra 算法
    // 这里 k 表示第一次/第二次
    private void dijkstra(List<int[]> g[], int[][] edges, int destination, int[][] dis, int delta, int k) {
        int n = g.length;
        var vis = new boolean[n];
        for (; ; ) {
            // 找到当前最短路,去更新它的邻居的最短路
            // 根据数学归纳法,dis[x][k] 一定是最短路长度
            int x = -1;
            for (int i = 0; i < n; ++i)
                if (!vis[i] && (x < 0 || dis[i][k] < dis[x][k]))
                    x = i;
            if (x == destination) // 起点 source 到终点 destination 的最短路已确定
                return;
            vis[x] = true; // 标记,在后续的循环中无需反复更新 x 到其余点的最短路长度
            for (var e : g[x]) {
                int y = e[0], eid = e[1];
                int wt = edges[eid][2];
                if (wt == -1)
                    wt = 1; // -1 改成 1
                if (k == 1 && edges[eid][2] == -1) {
                    // 第二次 Dijkstra,改成 w
                    int w = delta + dis[y][0] - dis[x][1];
                    if (w > wt)
                        edges[eid][2] = wt = w; // 直接在 edges 上修改
                }
                // 更新最短路
                dis[y][k] = Math.min(dis[y][k], dis[x][k] + wt);
            }
        }
    }
}

你可能感兴趣的:(#,周赛分类练习题,算法)