1534.统计好三元组

目标

给你一个整数数组 arr ,以及 a、b、c 三个整数。请你统计其中好三元组的数量。

如果三元组 (arr[i], arr[j], arr[k]) 满足下列全部条件,则认为它是一个 好三元组 。

  • 0 <= i < j < k < arr.length
  • |arr[i] - arr[j]| <= a
  • |arr[j] - arr[k]| <= b
  • |arr[i] - arr[k]| <= c

其中 |x| 表示 x 的绝对值。

返回 好三元组的数量 。

示例 1:

输入:arr = [3,0,1,1,9,7], a = 7, b = 2, c = 3
输出:4
解释:一共有 4 个好三元组:[(3,0,1), (3,0,1), (3,1,1), (0,1,1)] 。

示例 2:

输入:arr = [1,1,2,2,3], a = 0, b = 0, c = 1
输出:0
解释:不存在满足所有条件的三元组。

说明:

  • 3 <= arr.length <= 100
  • 0 <= arr[i] <= 1000
  • 0 <= a, b, c <= 1000

思路

返回数组中的好三元组的数量,所谓好三元组就是前两个元素值差的绝对值小于 a,后两个元素元素值差的绝对值小于 b,首尾元素差的绝对值小于 c。

数据量不大可以直接暴力解。

网友提出了另一种解法,枚举 j、k,确定 i 的范围,然后使用前缀和快速获取范围内的元素个数。

代码


/**
 * @date 2025-04-14 0:06
 */
public class CountGoodTriplets1534 {

    public int countGoodTriplets(int[] arr, int a, int b, int c) {
        int n = arr.length;
        int res = 0;
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j < n; j++) {
                for (int k = j + 1; k < n; k++) {
                    if (Math.abs(arr[i] - arr[j]) <= a && Math.abs(arr[j] - arr[k]) <= b && Math.abs(arr[i] - arr[k]) <= c) {
                        res++;
                    }
                }
            }
        }
        return res;
    }
}

性能

2588.统计美丽子数组数目

目标

给你一个下标从 0 开始的整数数组nums 。每次操作中,你可以:

  • 选择两个满足 0 <= i, j < nums.length 的不同下标 i 和 j 。
  • 选择一个非负整数 k ,满足 nums[i] 和 nums[j] 在二进制下的第 k 位(下标编号从 0 开始)是 1 。
  • 将 nums[i] 和 nums[j] 都减去 2^k 。

如果一个子数组内执行上述操作若干次后,该子数组可以变成一个全为 0 的数组,那么我们称它是一个 美丽 的子数组。

请你返回数组 nums 中 美丽子数组 的数目。

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

示例 1:

输入:nums = [4,3,1,2,4]
输出:2
解释:nums 中有 2 个美丽子数组:[3,1,2] 和 [4,3,1,2,4] 。
- 按照下述步骤,我们可以将子数组 [3,1,2] 中所有元素变成 0 :
  - 选择 [3, 1, 2] 和 k = 1 。将 2 个数字都减去 21 ,子数组变成 [1, 1, 0] 。
  - 选择 [1, 1, 0] 和 k = 0 。将 2 个数字都减去 20 ,子数组变成 [0, 0, 0] 。
- 按照下述步骤,我们可以将子数组 [4,3,1,2,4] 中所有元素变成 0 :
  - 选择 [4, 3, 1, 2, 4] 和 k = 2 。将 2 个数字都减去 22 ,子数组变成 [0, 3, 1, 2, 0] 。
  - 选择 [0, 3, 1, 2, 0] 和 k = 0 。将 2 个数字都减去 20 ,子数组变成 [0, 2, 0, 2, 0] 。
  - 选择 [0, 2, 0, 2, 0] 和 k = 1 。将 2 个数字都减去 21 ,子数组变成 [0, 0, 0, 0, 0] 。

示例 2:

输入:nums = [1,10,4]
输出:0
解释:nums 中没有任何美丽子数组。

说明:

  • 1 <= nums.length <= 10^5
  • 0 <= nums[i] <= 10^6

思路

有一个下标从 0 开始的数组 nums,每次操作可以任选两个不同下标的元素,如果它们在二进制下的第 k 位是 1,那么将这两个元素减去 2^k,即把这两个元素的第 k 位置 0。如果对 nums 的子数组执行任意次操作后可以将子数组所有元素变为 0,称该子数组为 美丽子数组。返回数组 nums 的美丽子数组数目。

通过分析可以知道,累加美丽子数组中所有元素在二进制下每个bit位上 1 的个数,可以发现每个位置上 1 的个数都是偶数。可以通过异或运算来判断是否是美丽子数组。

代码


/**
 * @date 2025-03-06 8:37
 */
public class BeautifulSubarrays2588 {

    public long beautifulSubarrays(int[] nums) {
        int n = nums.length;
        int[] prefix = new int[n + 1];
        Map<Integer, List<Integer>> map = new HashMap<>();
        map.computeIfAbsent(prefix[0], x -> new ArrayList<>()).add(0);
        for (int i = 1; i <= n; i++) {
            prefix[i] = nums[i - 1] ^ prefix[i - 1];
            map.computeIfAbsent(prefix[i], x -> new ArrayList<>()).add(i);
        }
        long res = 0;
        for (List<Integer> value : map.values()) {
            int size = value.size();
            res += (size - 1L) * size / 2;
        }
        return res;
    }

}

性能

2209.用地毯覆盖后的最少白色砖块

目标

给你一个下标从 0 开始的 二进制 字符串 floor ,它表示地板上砖块的颜色。

  • floor[i] = '0' 表示地板上第 i 块砖块的颜色是 黑色 。
  • floor[i] = '1' 表示地板上第 i 块砖块的颜色是 白色 。

同时给你 numCarpets 和 carpetLen 。你有 numCarpets 条 黑色 的地毯,每一条 黑色 的地毯长度都为 carpetLen 块砖块。请你使用这些地毯去覆盖砖块,使得未被覆盖的剩余 白色 砖块的数目 最小 。地毯相互之间可以覆盖。

请你返回没被覆盖的白色砖块的 最少 数目。

示例 1:

输入:floor = "10110101", numCarpets = 2, carpetLen = 2
输出:2
解释:
上图展示了剩余 2 块白色砖块的方案。
没有其他方案可以使未被覆盖的白色砖块少于 2 块。

示例 2:

输入:floor = "11111", numCarpets = 2, carpetLen = 3
输出:0
解释:
上图展示了所有白色砖块都被覆盖的一种方案。
注意,地毯相互之间可以覆盖。

说明:

  • 1 <= carpetLen <= floor.length <= 1000
  • floor[i] 要么是 '0' ,要么是 '1' 。
  • 1 <= numCarpets <= 1000

思路

floor.length 块一字排列的砖,floor[i] 的值表示砖的颜色,0 代表黑色,1 代表白色。另有 numCarpets 条长度为 carpetLen 的地毯。求使用地毯覆盖砖块剩余 白色 砖块的最小数目。

假设白色砖块有 k 个,那么可行的方案数有 C(k, numCarpets) 种,即选 k 块白砖为起点覆盖地毯。

//todo

代码

性能

2218.从栈中取出K个硬币的最大面值和

目标

一张桌子上总共有 n 个硬币 栈 。每个栈有 正整数 个带面值的硬币。

每一次操作中,你可以从任意一个栈的 顶部 取出 1 个硬币,从栈中移除它,并放入你的钱包里。

给你一个列表 piles ,其中 piles[i] 是一个整数数组,分别表示第 i 个栈里 从顶到底 的硬币面值。同时给你一个正整数 k ,请你返回在 恰好 进行 k 次操作的前提下,你钱包里硬币面值之和 最大为多少 。

示例 1:

输入:piles = [[1,100,3],[7,8,9]], k = 2
输出:101
解释:
上图展示了几种选择 k 个硬币的不同方法。
我们可以得到的最大面值为 101 。

示例 2:

输入:piles = [[100],[100],[100],[100],[100],[100],[1,1,1,1,1,1,700]], k = 7
输出:706
解释:
如果我们所有硬币都从最后一个栈中取,可以得到最大面值和。

说明:

  • n == piles.length
  • 1 <= n <= 1000
  • 1 <= piles[i][j] <= 10^5
  • 1 <= k <= sum(piles[i].length) <= 2000

思路

有 n 个栈,栈中有 piles[i].length 个硬币,每次操作可以从任意栈顶取一个硬币,求 k 次操作取得的硬币和的最大值。

定义 dp[i][j] 表示 从前 i + 1 个栈中最多取 j 个 所能得到的最大值,与 0 - 1 背包不同的是我们需要枚举从当前栈取 1 至 x 枚硬币的最大值,状态转移方程为 dp[i][j] = Math.max(dp[i][j], Math.max(dp[i - 1][j], dp[i - 1][j - x] + prefix[i][x])),外层 max 求的是当前栈每种取法的最大值,第二个参数的 max 求的是当前栈 不取 或者 取 x 枚硬币对应的最大值。由于是从栈顶取,我们使用前缀和 prefix 记录每个栈从栈顶到底的硬币和。

代码


/**
 * @date 2025-01-21 13:46
 */
public class MaxValueOfCoins2218 {

    public int maxValueOfCoins(List<List<Integer>> piles, int k) {
        int n = piles.size();
        int[][] prefix = new int[n][];
        // 计算前缀和
        for (int i = 0; i < n; i++) {
            List<Integer> pile = piles.get(i);
            prefix[i] = new int[pile.size() + 1];
            for (int j = 1; j <= pile.size(); j++) {
                prefix[i][j] = prefix[i][j - 1] + pile.get(j - 1);
            }
        }
        // 定义dp[i][j] 表示 从前 i + 1 个栈中最多取 j 个 所能得到的最大值
        int[][] dp = new int[n][k + 1];
        // 初始化,从第一个栈最多取 k 个
        for (int j = 1; j <= k; j++) {
            int length = piles.get(0).size();
            if (j <= length) {
                dp[0][j] = prefix[0][j];
            } else {
                dp[0][j] = dp[0][length];
            }
        }
        for (int i = 1; i < n; i++) {
            // 枚举右边界
            int length = piles.get(i).size();
            for (int j = 1; j <= k; j++) {
                // j 表示从前 i + 1 个栈中总共取 j 个
                for (int x = 1; x <= length && j >= x; x++) {
                    // 表示从当前栈中取 x 个
                    dp[i][j] = Math.max(dp[i][j], Math.max(dp[i - 1][j], dp[i - 1][j - x] + prefix[i][x]));
                }
            }
        }
        return dp[n - 1][k];
    }

}

性能

2270.分割数组的方案数

目标

给你一个下标从 0 开始长度为 n 的整数数组 nums 。

如果以下描述为真,那么 nums 在下标 i 处有一个 合法的分割 :

  • 前 i + 1 个元素的和 大于等于 剩下的 n - i - 1 个元素的和。
  • 下标 i 的右边 至少有一个 元素,也就是说下标 i 满足 0 <= i < n - 1 。

请你返回 nums 中的 合法分割 方案数。

示例 1:

输入:nums = [10,4,-8,7]
输出:2
解释:
总共有 3 种不同的方案可以将 nums 分割成两个非空的部分:
- 在下标 0 处分割 nums 。那么第一部分为 [10] ,和为 10 。第二部分为 [4,-8,7] ,和为 3 。因为 10 >= 3 ,所以 i = 0 是一个合法的分割。
- 在下标 1 处分割 nums 。那么第一部分为 [10,4] ,和为 14 。第二部分为 [-8,7] ,和为 -1 。因为 14 >= -1 ,所以 i = 1 是一个合法的分割。
- 在下标 2 处分割 nums 。那么第一部分为 [10,4,-8] ,和为 6 。第二部分为 [7] ,和为 7 。因为 6 < 7 ,所以 i = 2 不是一个合法的分割。
所以 nums 中总共合法分割方案受为 2 。

示例 2:

输入:nums = [2,3,1,0]
输出:2
解释:
总共有 2 种 nums 的合法分割:
- 在下标 1 处分割 nums 。那么第一部分为 [2,3] ,和为 5 。第二部分为 [1,0] ,和为 1 。因为 5 >= 1 ,所以 i = 1 是一个合法的分割。
- 在下标 2 处分割 nums 。那么第一部分为 [2,3,1] ,和为 6 。第二部分为 [0] ,和为 0 。因为 6 >= 0 ,所以 i = 2 是一个合法的分割。

说明:

  • 2 <= nums.length <= 10^5
  • -10^5 <= nums[i] <= 10^5

思路

求数组的合法分割点个数,下标 i 是合法分割点的条件是 前 i + 1 个元素和大于剩余元素和,且至少要有一个元素。

直接的想法是计算前缀和,然后按照题意计算。

实际实现时发现只用到了 所有元素的和 sum 以及区间 [0, i] 的和 tmp,无需存储前缀,直接在遍历的时候计算就可以。

代码


/**
 * @date 2025-01-13 8:41
 */
public class WaysToSplitArray2270 {

    public int waysToSplitArray(int[] nums) {
        long sum = 0;
        for (int num : nums) {
            sum += num;
        }
        int res = 0;
        long tmp = 0;
        for (int i = 0; i < nums.length - 1; i++) {
            tmp += nums[i];
            if (2 * tmp >= sum) {
                res++;
            }
        }
        return res;
    }

}

性能

3251.单调数组对的数目II

目标

给你一个长度为 n 的 正 整数数组 nums 。

如果两个 非负 整数数组 (arr1, arr2) 满足以下条件,我们称它们是 单调 数组对:

  • 两个数组的长度都是 n 。
  • arr1 是单调 非递减 的,换句话说 arr1[0] <= arr1[1] <= ... <= arr1[n - 1] 。
  • arr2 是单调 非递增 的,换句话说 arr2[0] >= arr2[1] >= ... >= arr2[n - 1] 。
  • 对于所有的 0 <= i <= n - 1 都有 arr1[i] + arr2[i] == nums[i] 。

请你返回所有 单调 数组对的数目。

由于答案可能很大,请你将它对 109 + 7 取余 后返回。

示例 1:

输入:nums = [2,3,2]
输出:4
解释:
单调数组对包括:
([0, 1, 1], [2, 2, 1])
([0, 1, 2], [2, 2, 0])
([0, 2, 2], [2, 1, 0])
([1, 2, 2], [1, 1, 0])

示例 2:

输入:nums = [5,5,5,5]
输出:126

说明:

  • 1 <= n == nums.length <= 2000
  • 1 <= nums[i] <= 1000

思路

有一个长度为 n 正整数数组 nums,可以将其拆成两个数组 arr1 arr2,使之满足 arr1[i] + arr2[i] == nums[i]。问 有多少种拆分方法使得 arr1 非递减 且 arr2 非递增。

与昨天的 3250.单调数组对的数目I 相比,nums[i] 的最大值从 50 变成了 1000。时间复杂度大概为 O(n*m^2),mnums[i] 的最大值,如果还沿用昨天的解法就会超时。

先将昨天的题目改写为动态规划,定义 dp[i][j] 表示最后一个元素为 j,长度为 i + 1 的满足条件的 arr1 个数。由于 arr1 是非递减的,如果最后一个元素为 arr1[i] = j 那么倒数第二个元素arr1[i - 1] <= j。同时我们还要考虑到 arr2 非递增,即 arr2[i - 1] >= arr2[i]nums[i - 1] - arr1[i - 1] >= nums[i] - arr1[i]arr1[i - 1] <= nums[i - 1] - nums[i] + arr1[i]。综上,arr1[i - 1] <= Math.min(j, nums[i - 1] - nums[i] + j)

经过上面的分析,dp[i][j] = Σdp[i - 1][k],其中 k ∈ [0, Math.min(j, nums[i - 1] - nums[i] + j)]。这样写会超时,针对每个 j,我们会进行多次重复的计算。

d = nums[i - 1] - nums[i],当 d >= 0 时,上界为 j,否则上界为 j + d

考虑 nums[i - 1] < nums[i],即 d < 0

  • arr1[i - 1] = j 时,令arr2[i - 1] = nums[i - 1] - j = a
  • arr1[i] = j 时,arr2[i] = nums[i] - arr1[i] = nums[i - 1] - d - j = a - dd < 0

也就是说,当 arr1[i] 的取值与上一层一样时,arr2[i] 比上一层的值大了 |d|。为了使第 iarr2 非递增,那么 arr1 的取值只能从 |d| 开始。

它们之间的约束关系是这样的,当 nums[i] 变大,arr1i 层取 j 时,arr2 的第 i 层比上一层增大了 |d|,这时我们必须舍弃 [0, |d|) 的取值,因为它必定大于上一层 arr2 的最大值。然后考虑第 i 层的 arr1[|d|, nums[i]] 的情况,由于第 i 层的 arr2 相比第 i - 1 层增大了 |d|,因此需要减小第 i - 1 层的 arr1,使第 i - 1 层的 arr2 增大。所以第 i 层的 j 对应第 i - 1 层的 j - |d|

dp[i][j] 的取值类似前缀和,只不过有约束条件,并不是所有值都合法。考虑简单的情况 nums[0] == nums[1] && i == 1,

  • 当 j == 0 时,dp[1][0] = dp[0][0] = 1
  • 当 j == 1 时,上一层(i == 0) arr1 可以取 0、1,dp[1][1] = dp[1][0] + dp[0][1] = 2
  • 当 j == 2 时,上一层(i == 0) arr1 可以取 0、1、2,dp[1][2] = dp[1][1] + dp[0][2] = 3

因此我们有 dp[i][j] = (dp[i][j - 1] + dp[i - 1][j - d])

代码


/**
 * @date 2024-11-29 9:39
 */
public class CountOfPairs3251 {

    public static int MOD = 1000000007;

    public int countOfPairs(int[] nums) {
        int res = 0;
        int n = nums.length;
        int[][] dp = new int[n][1001];
        for (int i = 0; i <= nums[0]; i++) {
            dp[0][i] = 1;
        }
        for (int i = 1; i < n; i++) {
            int d = Math.max(nums[i] - nums[i - 1], 0);
            for (int j = d; j <= nums[i]; j++) {
                if (j == 0) {
                    dp[i][j] = dp[i - 1][0] % MOD;
                } else {
                    dp[i][j] = (dp[i][j - 1] + dp[i - 1][j - d]) % MOD;
                }
            }
        }
        for (int i : dp[n - 1]) {
            res = (res + i) % MOD;
        }
        return res;
    }

}

性能

3233.统计不是特殊数字的数字数量

目标

给你两个 正整数 l 和 r。对于任何数字 x,x 的所有正因数(除了 x 本身)被称为 x 的 真因数。

如果一个数字恰好仅有两个 真因数,则称该数字为 特殊数字。例如:

  • 数字 4 是 特殊数字,因为它的真因数为 1 和 2。
  • 数字 6 不是 特殊数字,因为它的真因数为 1、2 和 3。

返回区间 [l, r] 内 不是 特殊数字 的数字数量。

示例 1:

输入: l = 5, r = 7
输出: 3
解释:
区间 [5, 7] 内不存在特殊数字。

示例 2:

输入: l = 4, r = 16
输出: 11
解释:
区间 [4, 16] 内的特殊数字为 4 和 9。

说明:

  • 1 <= l <= r <= 10^9

提示:

  • A special number must be a square of a prime number.
  • We need to find all primes in the range [sqrt(l), sqrt(r)].
  • Use sieve to find primes till sqrt(10^9).

思路

返回给定区间 [l, r] 内不是特殊数字的数字个数,所谓特殊数字指除了它本身恰好有两个正因数的数字。

一看到数据范围是 10^9 就不可能使用使用暴力解法去判断每一个数字是不是 lr 的因数,要么使用二分要么预处理。特殊数字是确定的,它除了自身以外只有两个因数,1 一定是一个,即除了 1 和它本身只有一个因数。看了提示说特殊数字是质数的平方,我们需要找到所有在 [√l, √r] 范围内的质数,可以预处理 √10^9 内的质数。二分查找 [l, r] 范围内质数的个数,然后减掉即可。

埃氏筛的基本思想是创建一个 boolean[] 标记查询范围内的数是否为质数,初始时均标记为 true。从 2 开始遍历(01 后面直接过滤掉了),直到 i < √endi * i < end。在循环内部,如果当前值是质数,则将 i * i,i * (i + 1),i * (i + 2),…… 标记为非质数。比如在 2 的循环内,所有大于 2 的偶数都被标为非质数,以此类推,像筛子一样将质数筛选出来。

// todo 最后也可以使用前缀和计算个数

代码


/**
 * @date 2024-11-22 9:07
 */
public class NonSpecialCount3233 {

    public static int[] primes;

    static {
        List<Integer> primeList = new ArrayList<>();
        int end = (int) Math.ceil(Math.sqrt(1000000001));
        boolean[] isPrime = new boolean[end + 1];
        Arrays.fill(isPrime, true);
        for (int i = 2; i * i <= end; i++) {
            if (isPrime[i]) {
                for (int j = i * i; j <= end; j += i) {
                    isPrime[j] = false;
                }
            }
        }
        // 前面没有将 isPrime[0] isPrime[1] 置为false,这里从2开始
        for (int i = 2; i <= end; i++) {
            if (isPrime[i]) {
                primeList.add(i);
            }
        }
        int cnt = primeList.size();
        primes = new int[cnt];
        for (int i = 0; i < cnt; i++) {
            primes[i] = primeList.get(i);
        }
    }

    public int nonSpecialCount_v1(int l, int r) {
        int ceilLeft = (int) Math.ceil(Math.sqrt(l));
        int right = (int) Math.sqrt(r);
        if (ceilLeft > right) {
            return r - l + 1;
        }
        int a = Arrays.binarySearch(primes, ceilLeft);
        if (a < 0) {
            // 获取插入点,说明原来该位置的值大于ceilLeft
            a = -a - 1;
        }
        int b = Arrays.binarySearch(primes, right);
        if (b < 0) {
            // 这里多减了1,因为插入点是第一个大于right的位置,减1则小于 right
            b = -b - 2;
        }
//        return r - l + 1 - (b - a + 1);
        return r - l - b + a;
    }

}

性能

3261.统计满足K约束的子字符串数量II

目标

给你一个 二进制 字符串 s 和一个整数 k。

另给你一个二维整数数组 queries ,其中 queries[i] = [li, ri] 。

如果一个 二进制字符串 满足以下任一条件,则认为该字符串满足 k 约束:

  • 字符串中 0 的数量最多为 k。
  • 字符串中 1 的数量最多为 k。

返回一个整数数组 answer ,其中 answer[i] 表示 s[li..ri] 中满足 k 约束 的 子字符串 的数量。

示例 1:

输入:s = "0001111", k = 2, queries = [[0,6]]
输出:[26]
解释:
对于查询 [0, 6], s[0..6] = "0001111" 的所有子字符串中,除 s[0..5] = "000111" 和 s[0..6] = "0001111" 外,其余子字符串都满足 k 约束。

示例 2:

输入:s = "010101", k = 1, queries = [[0,5],[1,4],[2,3]]
输出:[15,9,3]
解释:
s 的所有子字符串中,长度大于 3 的子字符串都不满足 k 约束。

说明:

  • 1 <= s.length <= 10^5
  • s[i] 是 '0' 或 '1'
  • 1 <= k <= s.length
  • 1 <= queries.length <= 10^5
  • queries[i] == [li, ri]
  • 0 <= li <= ri < s.length
  • 所有查询互不相同

思路

定义二进制字符串满足 k 约束的条件是字符串中 0 的个数 或者 1 的个数都不超过 k。求给定字符串满足 k 约束的子字符串数量。子字符串 是字符串中 连续非空 字符序列。

这道题与昨天的 3258.统计满足K约束的子字符串数量I 相比多了一个查询数组,并且字符串的长度也来到了 10^5,返回结果是 long[],暴力枚举会超时。

滑动窗口的时间复杂度为 O(n),不可能对每一次查询都重新滑动一遍。显然需要在滑动的过程中记录下查询的结果。每次滑动我们可以得到一个区间 [l, r],这个区间的所有子数组是合法的。

使用一维数组记录这个区间,使用下标与值的映射,我们有两种选择:

  • 记录的左端点的最大右端点,即 left[l] = r
  • 记录的右端点的最小左端点,即 right[r] = l

对于查询区间 [ql, qr],它与我们已知的合法区间存在两种关系,被包含或者部分相交。

  • 如果是被包含的关系,那么查询区间的所有子数组均合法,子数组个数为 (qr - ql + 1) * (qr - ql + 2) / 2
  • 如果是相交的关系,说明 ql < r[ql, r] 的所有子数组是合法的,剩下的 [r + 1, qr] 的合法子数组如何求?可以在滑动过程中记录以每个元素为终点的合法子数组个数,以前缀和的形式保存。

这里区间的划分与结果集的构成是非常有讲究的。前缀和记录的是以元素为 终点 的合法子数组,如果我们在滑动的过程中根据查询区间终点匹配当前元素,即 qr == r,那么可能的情况为 ql >= l 查询区间被完全包含。如果 ql < l 则查询区间与合法区间相交。如果这时使用前缀和计算 [ql, l],使用公式计算 [l, r] 就错了。因为后面区间使用公式计算的子数组并不包括以前面区间内的元素为起点的子数组,并且前缀和记录的子数组的起点可能在查询的左边界之外。而反过来前面使用公式计算,后面使用前缀和计算,被减去的那部分子数组个数中就包含了以 前面区间所有元素 为终点的子数组,也就是前面使用公式计算的子数组个数。我们不用担心后面通过前缀和计算的子数组的起点超出左边界,如果超出的话,一定是被包含的关系。

核心点在于想清楚这两部分集合的关系, [i, j] 的所有子数组包括了以 b ∈ [i, j] 为终点,a ∈ [i, b] 为起点的子数组。而使用前缀和相减计算的是所有以 c ∈ [m, n] 为终点的合法子数组,起点可以不在该区间,但是不会超出 ql

代码


/**
 * @date 2024-11-13 0:25
 */
public class CountKConstraintSubstrings3261 {

    public long[] countKConstraintSubstrings_v1(String s, int k, int[][] queries) {
        int n = s.length();
        char[] chars = s.toCharArray();
        int[] cnt = new int[2];
        long[] res = new long[queries.length];
        long[] prefix = new long[n + 1];
        int[] right = new int[n];
        Arrays.fill(right, n);
        int l = 0;
        for (int i = 0; i < n; i++) {
            cnt[chars[i] - '0']++;
            while (l <= i && cnt[0] > k && cnt[1] > k) {
                right[l] = i;
                cnt[chars[l++] - '0']--;
            }
            prefix[i + 1] += prefix[i] + i - l + 1;
        }
        for (int i = 0; i < queries.length; i++) {
            int ql = queries[i][0];
            int qr = queries[i][1];
            int r = Math.min(right[ql], qr + 1);
            res[i] = (r - ql + 1L) * (r - ql) / 2 + prefix[qr + 1] - prefix[r];

        }
        return res;
    }

}

性能

2860.让所有学生保持开心的分组方法数

目标

给你一个下标从 0 开始、长度为 n 的整数数组 nums ,其中 n 是班级中学生的总数。班主任希望能够在让所有学生保持开心的情况下选出一组学生:

如果能够满足下述两个条件之一,则认为第 i 位学生将会保持开心:

  • 这位学生被选中,并且被选中的学生人数 严格大于 nums[i] 。
  • 这位学生没有被选中,并且被选中的学生人数 严格小于 nums[i] 。

返回能够满足让所有学生保持开心的分组方法的数目。

示例 1:

输入:nums = [1,1]
输出:2
解释:
有两种可行的方法:
班主任没有选中学生。
班主任选中所有学生形成一组。 
如果班主任仅选中一个学生来完成分组,那么两个学生都无法保持开心。因此,仅存在两种可行的方法。

示例 2:

输入:nums = [6,0,3,3,6,7,2,7]
输出:3
解释:
存在三种可行的方法:
班主任选中下标为 1 的学生形成一组。
班主任选中下标为 1、2、3、6 的学生形成一组。
班主任选中所有学生形成一组。

说明:

  • 1 <= nums.length <= 10^5
  • 0 <= nums[i] < nums.length

思路

从数组中选择一组元素,使之满足该组元素个数严格大于组中所有元素值并且严格小于未被选择的元素值,求满足条件的所有选择数。

注意到元素值不超过数组长度,可以先统计各元素值的出现次数 cnt[i],然后遍历 cnt

这道题的关键是要意识到:值相同的元素要么同时被选,要么同时不被选。因为选择的元素个数 sum 应该大于所有选择的元素值,小于所有未选择的元素值,一个元素不可能既小于 sum 又大于 sum题目的本质是将学生成两组(被选择的和未被选择的),选择的标准是根据所选人数动态变化的,水平相同的学生要么都选,要么都不选,要一碗水端平,这就是题目名想要表达的处世哲学吧。

  • cnt[0] == 0 时,说明所有元素都大于0,因此可以一个都不选。
  • cnt[i] != 0 时,如果我们想要选择这些元素值为i的学生,需要满足选择后的学生总数 sum 大于 i,并且 cnt[i+1] ~ cnt[sum] 应该都为0,否则就存在小于 sum 但是未被选的学生了。

官网题解使用的是排序,满足 sorted[i - 1] < i && i < sorted[i] 时累加计数。不过排序的复杂度是O(nlogn),其实我们的计数数组也是有序的,算是计数排序吧,时间复杂度为O(n)。

代码


/**
 * @date 2024-09-04 10:29
 */
public class CountWays2860 {
    public int countWays(List<Integer> nums) {
        int n = nums.size();
        int[] cnt = new int[n];
        for (Integer num : nums) {
            cnt[num]++;
        }
        int[] prefix = new int[n];
        for (int i = 1; i < n; i++) {
            prefix[i] = prefix[i - 1] + cnt[i];
        }
        int res = cnt[0] > 0 ? 0 : 1;
        int sum = 0;
        for (int i = 0; i < n; i++) {
            sum += cnt[i];
            if (sum > i && cnt[i] != 0 && prefix[Math.min(n - 1, sum)] == prefix[i]) {
                res++;
            }
        }
        return res;
    }
}

性能

2024.考试的最大困扰度

目标

一位老师正在出一场由 n 道判断题构成的考试,每道题的答案为 true (用 'T' 表示)或者 false (用 'F' 表示)。老师想增加学生对自己做出答案的不确定性,方法是 最大化 有 连续相同 结果的题数。(也就是连续出现 true 或者连续出现 false)。

给你一个字符串 answerKey ,其中 answerKey[i] 是第 i 个问题的正确结果。除此以外,还给你一个整数 k ,表示你能进行以下操作的最多次数:

  • 每次操作中,将问题的正确答案改为 'T' 或者 'F' (也就是将 answerKey[i] 改为 'T' 或者 'F' )。

请你返回在不超过 k 次操作的情况下,最大 连续 'T' 或者 'F' 的数目。

示例 1:

输入:answerKey = "TTFF", k = 2
输出:4
解释:我们可以将两个 'F' 都变为 'T' ,得到 answerKey = "TTTT" 。
总共有四个连续的 'T' 。

示例 2:

输入:answerKey = "TFFT", k = 1
输出:3
解释:我们可以将最前面的 'T' 换成 'F' ,得到 answerKey = "FFFT" 。
或者,我们可以将第二个 'T' 换成 'F' ,得到 answerKey = "TFFF" 。
两种情况下,都有三个连续的 'F' 。

示例 3:

输入:answerKey = "TTFTTFTT", k = 1
输出:5
解释:我们可以将第一个 'F' 换成 'T' ,得到 answerKey = "TTTTTFTT" 。
或者我们可以将第二个 'F' 换成 'T' ,得到 answerKey = "TTFTTTTT" 。
两种情况下,都有五个连续的 'T' 。

说明:

  • n == answerKey.length
  • 1 <= n <= 5 * 10^4
  • answerKey[i] 要么是 'T' ,要么是 'F'
  • 1 <= k <= n

思路

有一个字符串 answerKeyTF 组成, 允许我们执行 k 次操作,每次操作可以将字符串中的 T 改为 F 或者 F 改为 T。问 TF 可能的最大连续个数。

这道题没有做出来,想着使用动态规划去做,但是没有找到合适的状态定义。比如 dp[i][j][k] 表示 [0,i]TF 结尾剩余操作次数 k 时的最大连续个数。2 x 10^8 的存储空间肯定不行。

题解说是滑动窗口、或者 前缀和 + 二分查找,也有使用动态规划的。

这道题的难点在于想明白这 k 次操作必须统一,要么全部从 T 改为 F,要么全部从 F 改为 T,才能使连续个数最大。因为如果 T 的连续个数最多,并且存在将 T 改为 F 的操作,那么我们总可以撤回该操作,并将一个 F 改为 T(如果存在的话,如果不存在说明全是T,撤销操作也会加一) 使得连续个数至少加一。

网友题解中的动态规划是这样定义的 dp[i] 表示 [0,i] 中以 answerKey[i] 结尾的连续后缀个数。这里的前提就是遇到不连续的统一从 T 改为 F 或者 从 F 改为 T 使之连续,如果超过了可操作的次数,需要撤回最早的操作,使得当前后缀连续。后缀连续个数可以用当前下标减去最早进行操作的下标计算得到(使用链表保存操作的下标,获取链表head记录的下标后将其删,再将当前下标加入链表末尾)。在计算dp过程中记录其最大值即为最大连续个数。如果对这个动态规划进行存储优化,那就是滑动窗口。

寻找一个窗口,使得窗口内的 T 或者 F 个数小于等于 k,并且使 F 或者 T 的个数最大。滑动窗口的套路一般是枚举右边界,如果条件不满足,更新左边界直到条件满足。

二分的做法本质也是滑动窗口,枚举左边界,二分查找能够到达的最远右边界。

代码


/**
 * @date 2024-09-02 16:55
 */
public class MaxConsecutiveAnswers2024 {

    /**
     * 前缀和 + 二分查找
     * 其实本质也是滑动窗口,枚举左边界,二分查找最远的右边界
     * O(nlogn) 62ms
     */
    public int maxConsecutiveAnswers_v1(String answerKey, int k) {
        int n = answerKey.length();
        int[] prefix = new int[n + 1];
        for (int i = 1; i <= n; i++) {
            if (answerKey.charAt(i - 1) == 'T') {
                prefix[i] = prefix[i - 1] + 1;
            } else {
                prefix[i] = prefix[i - 1];
            }
        }
        int res = 0;
        for (int i = 1; i <= n; i++) {
            int l = i, r = n;
            int mid = l + (r - l) / 2;
            while (l <= r) {
                int num = mid - i + 1;
                int cnt = prefix[mid] - prefix[i - 1];
                if (num - cnt <= k || cnt <= k) {
                    l = mid + 1;
                } else {
                    r = mid - 1;
                }
                mid = l + (r - l) / 2;
            }
            res = Math.max(res, r - i + 1);
        }
        return res;
    }

    /**
     * 滑动窗口 O(n)
     * 21ms
     */
    public int maxConsecutiveAnswers(String answerKey, int k) {
        return Math.max(process(answerKey, k, 'T'), process(answerKey, k, 'F'));
    }

    public int process(String answerKey, int k, char turnTo) {
        int n = answerKey.length();
        int res = 0;
        List<Integer> optsIndex = new LinkedList<>();
        int cnt = 0;
        for (int i = 0; i < n; i++) {
            if (answerKey.charAt(i) != turnTo) {
                if (k > 0) {
                    k--;
                    optsIndex.add(i);
                    cnt++;
                } else {
                    cnt = i - optsIndex.remove(0);
                    optsIndex.add(i);
                }
            } else {
                cnt++;
            }
            res = Math.max(res, cnt);
        }
        return res;
    }

}

性能