普通视图

发现新文章,点击刷新页面。
今天 — 2025年7月1日首页

相邻相同字母的个数加一(Python/Java/C++/Go)

作者 endlesscheng
2024年10月27日 08:36

根据题意:

  • 如果所有相邻字母都互不相同,那么 Alice 不可能犯错,所以方案数只有 $1$ 种。
  • 如果有 $1$ 对相邻字母相同,那么 Alice 可以在这里犯错一次,例如 $\texttt{abb}$,一开始想要输入的可能是 $\texttt{abb}$,也可能是 $\texttt{ab}$,其中 $\texttt{b}$ 多打了一次,所以方案数有 $2$ 种。
  • 如果有 $2$ 对相邻字母相同,那么一开始想要输入的字符串会再多一种。例如 $\texttt{abbb}$,一开始想要输入的可能是 $\texttt{abbb}$,也可能是 $\texttt{abb}$,也可能是 $\texttt{ab}$,所以方案数有 $3$ 种。
  • 依此类推,方案数等于相邻相同字母的个数加一。

具体请看 视频讲解,欢迎点赞关注~

###py

class Solution:
    def possibleStringCount(self, word: str) -> int:
        ans = 1
        for x, y in pairwise(word):
            if x == y:
                ans += 1
        return ans

###py

class Solution:
    def possibleStringCount(self, word: str) -> int:
        return 1 + sum(x == y for x, y in pairwise(word))

###java

class Solution {
    public int possibleStringCount(String word) {
        int ans = 1;
        for (int i = 1; i < word.length(); i++) {
            if (word.charAt(i - 1) == word.charAt(i)) {
                ans++;
            }
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    int possibleStringCount(string word) {
        int ans = 1;
        for (int i = 1; i < word.length(); i++) {
            if (word[i - 1] == word[i]) {
                ans++;
            }
        }
        return ans;
    }
};

###go

func possibleStringCount(word string) int {
ans := 1
for i := 1; i < len(word); i++ {
if word[i-1] == word[i] {
ans++
}
}
return ans
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{word}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/最短路/最小生成树/二分图/基环树/欧拉路径)
  7. 动态规划(入门/背包/状态机/划分/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与一般树(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

昨天以前首页

排序+相向双指针(Python/Java/C++/Go)

作者 endlesscheng
2025年6月16日 09:22

推荐先完成本题的简单版本2824. 统计和小于目标的下标对数目

虽然本题要求的是子序列,但由于我们只关心子序列的最小值和最大值,并不关心元素的位置,所以可以先把 $\textit{nums}$ 排序,从而方便计算。

从小到大排序后,对于任意子序列,第一个数一定是最小的,最后一个数一定是最大的。

注意:子序列的最小值和最大值可以是同一个数,此时子序列长度为 $1$。

分类讨论:

  • 如果 $\textit{nums}[0] + \textit{nums}[n-1] > \textit{target}$,这意味着 $\textit{nums}[n-1]$ 太大了,与最小的 $\textit{nums}[0]$ 相加不满足要求,与更大的 $\textit{nums}[1],\textit{nums}[2],\ldots, \textit{nums}[n-1]$ 相加,更不满足要求。所以 $\textit{nums}[n-1]$ 不可能作为子序列的最大值。去掉 $\textit{nums}[n-1]$,问题变成剩余 $n-1$ 个数中的满足要求的子序列的数目。
  • 如果 $\textit{nums}[0] + \textit{nums}[n-1] \le \textit{target}$,这意味着 $\textit{nums}[0]$ 可以作为子序列的最小值,不仅与 $\textit{nums}[n-1]$ 相加满足要求,与更小的 $\textit{nums}[n-2],\textit{nums}[n-3],\ldots, \textit{nums}[0]$ 相加,也满足要求。换句话说,如果选 $\textit{nums}[0]$ 作为最小值,那么其余 $n-1$ 个数,每个数可以选也可以不选,每个数有 $2$ 种方案,一共有 $2^{n-1}$ 种方案(乘法原理),加到答案中。接下来,去掉 $\textit{nums}[0]$,计算剩余 $n-1$ 个数中的满足要求的子序列的数目。

一般地:

  1. 初始化 $\textit{left}=0$,$\textit{right}=n-1$,分别表示剩余元素中的最小下标和最大下标。
  2. 如果 $\textit{nums}[\textit{left}] + \textit{nums}[\textit{right}] > \textit{target}$,这意味着 $\textit{nums}[\textit{right}]$ 太大了,不仅与剩余元素中最小的 $\textit{nums}[\textit{left}]$ 相加不满足要求,与更大的 $\textit{nums}[\textit{left}+1],\textit{nums}[\textit{left}+2],\ldots, \textit{nums}[\textit{right}]$ 相加,更不满足要求。所以 $\textit{nums}[\textit{right}]$ 不可能作为剩余元素中的子序列最大值。去掉 $\textit{nums}[\textit{right}]$(也就是把 $\textit{right}$ 减一),问题变成 $[\textit{left},\textit{right}-1]$ 中满足要求的子序列数目。
  3. 如果 $\textit{nums}[\textit{left}] + \textit{nums}[\textit{right}] \le \textit{target}$,这意味着 $\textit{nums}[\textit{left}]$ 可以作为子序列的最小值,不仅与 $\textit{nums}[\textit{right}]$ 相加满足要求,与更小的 $\textit{nums}[\textit{right}-1],\textit{nums}[\textit{right}-2],\ldots, \textit{nums}[\textit{left}]$ 相加,也满足要求。换句话说,如果选 $\textit{nums}[\textit{left}]$ 作为最小值,那么其余下标在 $[\textit{left}+1,\textit{right}]$ 中的这 $\textit{right}-\textit{left}$ 个数可选可不选,有 $2^{\textit{right}-\textit{left}}$ 种方案,加到答案中。接下来,去掉 $\textit{nums}[\textit{left}]$(也就是把 $\textit{left}$ 加一),问题变成 $[\textit{left}+1,\textit{right}]$ 中满足要求的子序列数目。
  4. 循环直到没有剩余元素,即 $\textit{left}>\textit{right}$。

代码实现时,可以预处理 $2$ 的幂。为什么可以在中途取模,原理见 模运算的世界:当加减乘除遇上取模

###py

MOD = 1_000_000_007
MX = 100_000

pow2 = [1] * MX  # pow2[i] = 2 ** i % MOD
for i in range(1, MX):
    pow2[i] = pow2[i - 1] * 2 % MOD

class Solution:
    def numSubseq(self, nums: List[int], target: int) -> int:
        nums.sort()
        ans = 0
        left, right = 0, len(nums) - 1
        while left <= right:  # 可以相等,此时子序列的最小最大是同一个数
            if nums[left] + nums[right] <= target:
                # nums[left] 可以作为子序列的最小值
                # 其余下标在 [left+1,right] 中的数选或不选都可以
                ans += pow2[right - left]
                left += 1
            else:
                # nums[right] 太大了,即使与剩余元素的最小值 nums[left] 相加也不满足要求
                right -= 1
        return ans % MOD

###java

class Solution {
    private static final int MOD = 1_000_000_007;
    private static final int[] pow2 = new int[100_000]; // 2^i
    private static boolean initialized = false;

    // 这样写比 static block 快
    private void init() {
        if (initialized) {
            return;
        }
        initialized = true;

        pow2[0] = 1;
        for (int i = 1; i < pow2.length; i++) {
            pow2[i] = pow2[i - 1] * 2 % MOD;
        }
    }

    public int numSubseq(int[] nums, int target) {
        init();
        Arrays.sort(nums);
        long ans = 0;
        int left = 0;
        int right = nums.length - 1;
        while (left <= right) { // 可以相等,此时子序列的最小最大是同一个数
            if (nums[left] + nums[right] <= target) {
                // nums[left] 可以作为子序列的最小值
                // 其余下标在 [left+1,right] 中的数选或不选都可以
                ans += pow2[right - left];
                left++;
            } else {
                // nums[right] 太大了,即使与剩余元素的最小值 nums[left] 相加也不满足要求
                right--;
            }
        }
        return (int) (ans % MOD);
    }
}

###cpp

const int MOD = 1'000'000'007;
const int MX = 100'000;

int pow2[MX]; // 2^i

auto init = [] {
    pow2[0] = 1;
    for (int i = 1; i < MX; i++) {
        pow2[i] = pow2[i - 1] * 2 % MOD;
    }
    return 0;
}();

class Solution {
public:
    int numSubseq(vector<int>& nums, int target) {
        ranges::sort(nums);
        long long ans = 0;
        int left = 0, right = nums.size() - 1;
        while (left <= right) { // 可以相等,此时子序列的最小最大是同一个数
            if (nums[left] + nums[right] <= target) {
                // nums[left] 可以作为子序列的最小值
                // 其余下标在 [left+1,right] 中的数选或不选都可以
                ans += pow2[right - left];
                left++;
            } else {
                // nums[right] 太大了,即使与剩余元素的最小值 nums[left] 相加也不满足要求
                right--;
            }
        }
        return ans % MOD;
    }
};

###go

const mod = 1_000_000_007

var pow2 = [100_000]int{1} // 2^i

func init() {
for i := 1; i < len(pow2); i++ {
pow2[i] = pow2[i-1] * 2 % mod
}
}

func numSubseq(nums []int, target int) (ans int) {
slices.Sort(nums)
left, right := 0, len(nums)-1
for left <= right {
if nums[left]+nums[right] <= target { // 可以相等,此时子序列的最小最大是同一个数
// nums[left] 可以作为子序列的最小值 
// 其余下标在 [left+1,right] 中的数选或不选都可以
ans += pow2[right-left]
left++
} else {
// nums[right] 太大了,即使与剩余元素的最小值 nums[left] 相加也不满足要求
right--
}
}
return ans % mod
}

复杂度分析

不计入预处理的时间和空间。

  • 时间复杂度:$\mathcal{O}(n\log n)$,其中 $n$ 是 $\textit{nums}$ 的长度。瓶颈在排序上。
  • 空间复杂度:$\mathcal{O}(1)$。忽略排序时的栈开销。

思考题

改成最大元素与最小元素之差 $\le \textit{target}$ 呢?

总结

如果是两数之和 $\le$(或者 $=$、$\ge$)问题,其中一个数变小,另一个数变大,通常用相向双指针解决。

如果是两数之差 $\le$(或者 $=$、$\ge$)问题,其中一个数变大,另一个数也变大,通常用同向双指针解决。

相似题目

见下面双指针题单的「§3.1 相向双指针」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

简单题,简单做(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2021年12月12日 00:12

问题相当于选 $\textit{nums}$ 中最大的 $k$ 个数,这样总和才能最大。

但子序列的顺序是不能变的,怎么办?

  1. 创建一个下标数组 $\textit{idx} = [0,1,2,\ldots,n-1]$,根据 $\textit{nums}[\textit{idx}[i]]$ 对 $\textit{idx}$ 排序,这样就知道最大的 $k$ 个数在哪里了。
  2. 排序前 $k$ 大元素的下标。
  3. 最后,获取这些下标对应的元素值,即为子序列。
class Solution:
    def maxSubsequence(self, nums: List[int], k: int) -> List[int]:
        idx = sorted(range(len(nums)), key=lambda i: nums[i])  # 创建下标数组,对下标数组排序
        idx = sorted(idx[-k:])  # 取前 k 大元素的下标,排序
        return [nums[i] for i in idx]  # 取出 nums 的子序列
class Solution {
    public int[] maxSubsequence(int[] nums, int k) {
        // 创建下标数组,对下标数组排序
        Integer[] idx = new Integer[nums.length];
        Arrays.setAll(idx, i -> i);
        Arrays.sort(idx, (i, j) -> nums[j] - nums[i]);

        // 对前 k 个下标排序
        // 注:排序 int[] 比排序 Integer[] 快 2ms
        int[] ans = new int[k];
        for (int i = 0; i < k; i++) {
            ans[i] = idx[i];
        }
        Arrays.sort(ans);

        // 取出 nums 的子序列
        for (int i = 0; i < k; i++) {
            ans[i] = nums[ans[i]];
        }
        return ans;
    }
}
class Solution {
public:
    vector<int> maxSubsequence(vector<int>& nums, int k) {
        // 创建下标数组,对下标数组排序
        vector<int> idx(nums.size());
        ranges::iota(idx, 0);
        ranges::sort(idx, {}, [&](int i) { return -nums[i]; });

        // 对前 k 个下标排序
        idx.resize(k);
        ranges::sort(idx);

        // 取出 nums 的子序列
        for (int& i : idx) {
            i = nums[i];
        }
        return idx;
    }
};
class Solution {
public:
    vector<int> maxSubsequence(vector<int>& nums, int k) {
        int n = nums.size();
        vector<int> idx(n);
        ranges::iota(idx, 0);
        ranges::nth_element(idx, idx.begin() + k, {}, [&](int i) { return -nums[i]; });

        vector<int8_t> in_seq(n);
        for (int i = 0; i < k; i++) {
            in_seq[idx[i]] = true; // 标记前 k 大元素的下标
        }

        idx.resize(k);
        int j = 0;
        for (int i = 0; i < n; i++) {
            if (in_seq[i]) {
                idx[j++] = nums[i];
            }
        }
        return idx;
    }
};
int* _nums;

int cmp_by_num(const void* a, const void* b) {
    return _nums[*(int*)b] - _nums[*(int*)a];
}

int cmp(const void* a, const void* b) {
    return *(int*)a - *(int*)b;
}

int* maxSubsequence(int* nums, int numsSize, int k, int* returnSize) {
    _nums = nums;

    // 创建下标数组,对下标数组排序
    int* idx = malloc(sizeof(int) * numsSize);
    for (int i = 0; i < numsSize; i++) {
        idx[i] = i;
    }
    qsort(idx, numsSize, sizeof(int), cmp_by_num);

    // 对前 k 个下标排序
    qsort(idx, k, sizeof(int), cmp);

    // 取出 nums 的子序列
    for (int i = 0; i < k; i++) {
        idx[i] = nums[idx[i]];
    }

    *returnSize = k;
    return idx;
}
func maxSubsequence(nums []int, k int) []int {
// 创建下标数组,对下标数组排序
idx := make([]int, len(nums))
for i := range idx {
idx[i] = i
}
slices.SortFunc(idx, func(i, j int) int { return nums[j] - nums[i] })

// 对前 k 个下标排序
idx = idx[:k]
slices.Sort(idx)

// 取出 nums 的子序列
for i, j := range idx {
idx[i] = nums[j]
}
return idx
}
var maxSubsequence = function(nums, k) {
    // 创建下标数组,对下标数组排序
    let idx = [...nums.keys()]
    idx.sort((i, j) => nums[j] - nums[i]);

    // 对前 k 个下标排序
    idx.length = k;
    idx.sort((a, b) => a - b);

    // 取出 nums 的子序列
    return idx.map(i => nums[i]);
};
impl Solution {
    pub fn max_subsequence(nums: Vec<i32>, k: i32) -> Vec<i32> {
        // 创建下标数组,对下标数组排序
        let mut idx = (0..nums.len()).collect::<Vec<_>>();
        idx.sort_unstable_by_key(|&i| -nums[i]);

        // 对前 k 个下标排序
        idx.truncate(k as usize);
        idx.sort_unstable();

        // 取出 nums 的子序列
        idx.into_iter().map(|i| nums[i]).collect()
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$ 或 $\mathcal{O}(n)$,其中 $n$ 是 $\textit{nums}$ 的长度。使用快速选择可以做到 $\mathcal{O}(n)$,见 C++ 代码。
  • 空间复杂度:$\mathcal{O}(n)$。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

枚举排列 + 判断子序列(Python/Java/C++/Go)

作者 endlesscheng
2021年9月19日 12:26

分析

如果 $\textit{seq} * k$ 是 $s$ 的子序列,必须满足:

  • $\textit{seq}$ 中的每个字母在 $s$ 中至少出现 $k$ 次。

示例 1 的 $s=\texttt{letsleetcode}$,$k=2$,其中 $\texttt{s},\texttt{c},\texttt{o},\texttt{d}$ 这些只出现一次的字母,一定不在 $\textit{seq}$ 中(因为 $\textit{seq}$ 要重复 $k=2$ 次,$1$ 个字母无法重复 $2$ 次)。只有 $\texttt{l},\texttt{e},\texttt{t}$ 这些至少出现 $k$ 次的字母,才可能在 $\textit{seq}$ 中。

分析到这里,题目的这个奇怪的数据范围 $n < 8k$ 就有用了。

设有 $x$ 个字母至少出现 $k$ 次,那么

$$
kx\le n
$$

$$
x \le \dfrac{n}{k} < 8
$$

所以至多有 $7$ 个字母至少出现 $k$ 次。

枚举排列

枚举从 $7$ 个字母中选出 $i=1,2,3,4,5,6,7$ 个字母的排列,枚举次数为

$$
A_7^1 + A_7^2 + A_7^3 + A_7^4 + A_7^5 + A_7^6 + A_7^7 = 13699
$$

这不多,在时间范围内可以接受。

在示例 1 中,至少出现 $k=2$ 次的字母有 $\texttt{l},\texttt{e},\texttt{t}$。其中字母 $\texttt{e}$ 出现了 $4$ 次,这意味着 $\textit{seq}$ 中可以有 $\left\lfloor\dfrac{4}{k}\right\rfloor=2$ 个 $\texttt{e}$。所以我们枚举的是 $a=[\texttt{l},\texttt{e},\texttt{e},\texttt{t}]$ 的 47. 全排列 II我的题解。注意排列中有重复元素。

判断子序列

设当前枚举的排列为 $\textit{seq}$,我们需要判断 $\textit{seq} * k$ 是否为 $s$ 的子序列。

做法见 392. 判断子序列我的题解

优化前

class Solution:
    # 392. 判断子序列
    # 返回 seq 是否为 s 的子序列
    def isSubsequence(self, seq: str, s: str) -> bool:
        it = iter(s)
        return all(c in it for c in seq)  # in 会消耗迭代器

    def longestSubsequenceRepeatedK(self, s: str, k: int) -> str:
        cnt = Counter(s)
        a = [ch for ch, freq in cnt.items() for _ in range(freq // k)]
        a.sort(reverse=True)  # 排序后,下面会按照字典序从大到小枚举排列

        for i in range(len(a), 0, -1):  # 长的优先
            for perm in permutations(a, i):  # 枚举 a 的长为 i 的排列
                seq = ''.join(perm)
                if self.isSubsequence(seq * k, s):  # seq*k 是 s 的子序列
                    return seq
        return ''
class Solution {
    private char[] ans;
    private int ansLen = 0;

    public String longestSubsequenceRepeatedK(String S, int k) {
        char[] s = S.toCharArray();

        int[] cnt = new int[26];
        for (char c : s) {
            cnt[c - 'a']++;
        }

        StringBuilder tmp = new StringBuilder();
        // 倒序,这样我们可以优先枚举字典序大的排列
        for (int i = 25; i >= 0; i--) {
            String c = String.valueOf((char) ('a' + i));
            tmp.append(c.repeat(cnt[i] / k));
        }
        char[] a = tmp.toString().toCharArray();

        ans = new char[a.length];
        permute(a, k, s);

        return new String(ans, 0, ansLen);
    }

    // 47. 全排列 II
    // 枚举从 nums 中选任意个数的所有排列,处理枚举的排列
    private void permute(char[] nums, int k, char[] s) {
        int n = nums.length;
        char[] path = new char[n];
        boolean[] onPath = new boolean[n]; // onPath[j] 表示 nums[j] 是否已经填入排列
        dfs(0, nums, path, onPath, k, s);
    }

    private void dfs(int i, char[] nums, char[] path, boolean[] onPath, int k, char[] s) {
        // 处理当前排列 path
        process(path, i, k, s);

        if (i == nums.length) {
            return;
        }

        // 枚举 nums[j] 填入 path[pathLen]
        for (int j = 0; j < nums.length; j++) {
            // 如果 nums[j] 已填入排列,continue
            // 如果 nums[j] 和前一个数 nums[j-1] 相等,且 nums[j-1] 没填入排列,continue
            if (onPath[j] || j > 0 && nums[j] == nums[j - 1] && !onPath[j - 1]) {
                continue;
            }
            path[i] = nums[j]; // 填入排列
            onPath[j] = true; // nums[j] 已填入排列(注意标记的是下标,不是值)
            dfs(i + 1, nums, path, onPath, k, s); // 填排列的下一个数
            onPath[j] = false; // 恢复现场
            // 注意 path 无需恢复现场,直接覆盖 path[i] 就行
        }
    }

    private void process(char[] seq, int seqLen, int k, char[] s) {
        // 先比大小(时间复杂度低),再判断是否为子序列(时间复杂度高)
        if (seqLen > ansLen || seqLen == ansLen && compare(seq, ans, ansLen) > 0) {
            if (isSubsequence(seq, seqLen, k, s)) {
                System.arraycopy(seq, 0, ans, 0, seqLen);
                ansLen = seqLen;
            }
        }
    }

    // 比较 a 和 b 的字典序大小
    private int compare(char[] a, char[] b, int n) {
        for (int i = 0; i < n; i++) {
            if (a[i] != b[i]) {
                return a[i] - b[i];
            }
        }
        return 0;
    }

    // 392. 判断子序列
    // 返回 seq*k 是否为 s 的子序列
    private boolean isSubsequence(char[] seq, int n, int k, char[] s) {
        int i = 0;
        for (char c : s) {
            if (seq[i % n] == c) {
                i++;
                if (i == n * k) { // seq*k 的所有字符匹配完毕
                    return true; // seq*k 是 s 的子序列
                }
            }
        }
        return false;
    }
}
class Solution {
    // 47. 全排列 II
    // 枚举从 nums 中选任意个数的所有排列,用 f 处理枚举的排列
    void permuteFunc(const string& nums, auto&& f) {
        int n = nums.size();
        string path;
        vector<int8_t> on_path(n); // on_path[j] 表示 nums[j] 是否已经填入排列
        auto dfs = [&](this auto&& dfs) -> void {
            f(path);
            if (path.size() == n) {
                return;
            }
            // 枚举 nums[j] 填入 path[i]
            for (int j = 0; j < n; j++) {
                // 如果 nums[j] 已填入排列,continue
                // 如果 nums[j] 和前一个数 nums[j-1] 相等,且 nums[j-1] 没填入排列,continue
                if (on_path[j] || j > 0 && nums[j] == nums[j - 1] && !on_path[j - 1]) {
                    continue;
                }
                path += nums[j]; // 填入排列
                on_path[j] = true; // nums[j] 已填入排列(注意标记的是下标,不是值)
                dfs(); // 填排列的下一个数
                on_path[j] = false; // 恢复现场
                path.pop_back(); // 恢复现场
            }
        };
        dfs();
    };

    // 392. 判断子序列
    // 返回 seq*k 是否为 s 的子序列
    bool isSubsequence(const string& seq, int k, const string& s) {
        int n = seq.size();
        int i = 0;
        for (char c : s) {
            if (seq[i % n] == c) {
                i++;
                if (i == n * k) { // seq*k 的所有字符匹配完毕
                    return true; // seq*k 是 s 的子序列
                }
            }
        }
        return false;
    }

public:
    string longestSubsequenceRepeatedK(string s, int k) {
        int cnt[26]{};
        for (char c : s) {
            cnt[c - 'a']++;
        }

        string a;
        for (int i = 25; i >= 0; i--) { // 倒序,这样我们可以优先枚举字典序大的排列
            a.insert(a.end(), cnt[i] / k, 'a' + i);
        }

        string ans;
        permuteFunc(a, [&](const string& seq) {
            // 先比大小(时间复杂度低),再判断是否为子序列(时间复杂度高)
            if (seq.size() > ans.size() || seq.size() == ans.size() && seq > ans) {
                if (isSubsequence(seq, k, s)) {
                    ans = seq;
                }
            }
        });
        return ans;
    }
};
// 47. 全排列 II
// 枚举从 nums 中选任意个数的所有排列,用 f 处理枚举的排列
func permuteFunc[T comparable](nums []T, f func([]T)) {
    n := len(nums)
    path := []T{}
    onPath := make([]bool, n) // onPath[j] 表示 nums[j] 是否已经填入排列
    var dfs func()
    dfs = func() {
        f(path)
        if len(path) == n {
            return
        }
        // 枚举 nums[j] 填入 path[i]
        for j, on := range onPath {
            // 如果 nums[j] 已填入排列,continue
            // 如果 nums[j] 和前一个数 nums[j-1] 相等,且 nums[j-1] 没填入排列,continue
            if on || j > 0 && nums[j] == nums[j-1] && !onPath[j-1] {
                continue
            }
            path = append(path, nums[j])
            onPath[j] = true // nums[j] 已填入排列(注意标记的是下标,不是值)
            dfs() // 填排列的下一个数
            onPath[j] = false // 恢复现场
            path = path[:len(path)-1] // 恢复现场
        }
    }
    dfs()
}

// 392. 判断子序列
// 返回 seq*k 是否为 s 的子序列
func isSubsequence(seq []byte, k int, s string) bool {
    n := len(seq)
    i := 0
    for _, c := range s {
        if seq[i%n] == byte(c) {
            i++
            if i == n*k { // seq*k 的所有字符匹配完毕
                return true // seq*k 是 s 的子序列
            }
        }
    }
    return false
}

func longestSubsequenceRepeatedK(s string, k int) string {
    cnt := [26]int{}
    for _, c := range s {
        cnt[c-'a']++
    }
    a := []byte{}
    for i := 25; i >= 0; i-- { // 倒序,这样我们可以优先枚举字典序大的排列
        bs := []byte{'a' + byte(i)}
        a = append(a, bytes.Repeat(bs, cnt[i]/k)...)
    }

    ans := []byte{}
    permuteFunc(a, func(seq []byte) {
        // 先比大小(时间复杂度低),再判断是否为子序列(时间复杂度高)
        if len(seq) > len(ans) || len(seq) == len(ans) && bytes.Compare(seq, ans) > 0 {
            if isSubsequence(seq, k, s) {
                ans = slices.Clone(seq)
            }
        }
    })
    return string(ans)
}

优化:子序列自动机

用 392 题的进阶做法(子序列自动机),原理见 我的题解

class Solution:
    def longestSubsequenceRepeatedK(self, s: str, k: int) -> str:
        s = [ord(c) - ord('a') for c in s]  # 转成 0 到 25,这样下面无需频繁调用 ord

        # 392. 判断子序列(进阶做法)
        n = len(s)
        nxt = [[n] * 26 for _ in range(n + 1)]
        for i in range(n - 1, -1, -1):
            nxt[i][:] = nxt[i + 1]
            nxt[i][s[i]] = i

        def isSubsequence(seq: Tuple[int, ...]) -> bool:
            i = -1
            for _ in range(k):
                for c in seq:
                    i = nxt[i + 1][c]
                    if i == n:  # c 不在 s 中,说明 seq*k 不是 s 的子序列
                        return False
            return True  # seq*k 是 s 的子序列

        cnt = Counter(s)
        a = [ch for ch, freq in cnt.items() for _ in range(freq // k)]
        a.sort(reverse=True)  # 排序后,下面会按照字典序从大到小枚举排列

        for i in range(len(a), 0, -1):  # 长的优先
            for seq in permutations(a, i):  # 枚举 a 的长为 i 的排列
                if isSubsequence(seq):  # seq*k 是 s 的子序列
                    return ''.join(ascii_lowercase[c] for c in seq)
        return ''
class Solution {
    private char[] ans;
    private int ansLen = 0;

    public String longestSubsequenceRepeatedK(String S, int k) {
        char[] s = S.toCharArray();

        // 392. 判断子序列(进阶做法)
        int n = s.length;
        int[] cnt = new int[26];
        int[][] nxt = new int[n + 1][];
        nxt[n] = new int[26];
        Arrays.fill(nxt[n], n);
        for (int i = n - 1; i >= 0; i--) {
            int c = s[i] - 'a';
            nxt[i] = nxt[i + 1].clone();
            nxt[i][c] = i;
            cnt[c]++;
        }

        StringBuilder tmp = new StringBuilder();
        // 倒序,这样我们可以优先枚举字典序大的排列
        for (int i = 25; i >= 0; i--) {
            String c = String.valueOf((char) ('a' + i));
            tmp.append(c.repeat(cnt[i] / k));
        }
        char[] a = tmp.toString().toCharArray();

        ans = new char[a.length];
        permute(a, k, nxt);

        return new String(ans, 0, ansLen);
    }

    // 47. 全排列 II
    // 枚举从 nums 中选任意个数的所有排列,处理枚举的排列
    private void permute(char[] nums, int k, int[][] nxt) {
        int n = nums.length;
        char[] path = new char[n];
        boolean[] onPath = new boolean[n]; // onPath[j] 表示 nums[j] 是否已经填入排列
        dfs(0, nums, path, onPath, k, nxt);
    }

    private void dfs(int i, char[] nums, char[] path, boolean[] onPath, int k, int[][] nxt) {
        // 处理当前排列 path
        process(path, i, k, nxt);

        if (i == nums.length) {
            return;
        }

        // 枚举 nums[j] 填入 path[pathLen]
        for (int j = 0; j < nums.length; j++) {
            // 如果 nums[j] 已填入排列,continue
            // 如果 nums[j] 和前一个数 nums[j-1] 相等,且 nums[j-1] 没填入排列,continue
            if (onPath[j] || j > 0 && nums[j] == nums[j - 1] && !onPath[j - 1]) {
                continue;
            }
            path[i] = nums[j]; // 填入排列
            onPath[j] = true; // nums[j] 已填入排列(注意标记的是下标,不是值)
            dfs(i + 1, nums, path, onPath, k, nxt); // 填排列的下一个数
            onPath[j] = false; // 恢复现场
            // 注意 path 无需恢复现场,直接覆盖 path[i] 就行
        }
    }

    private void process(char[] seq, int seqLen, int k, int[][] nxt) {
        // 先比大小(时间复杂度低),再判断是否为子序列(时间复杂度高)
        if (seqLen > ansLen || seqLen == ansLen && compare(seq, ans, ansLen) > 0) {
            if (isSubsequence(seq, seqLen, k, nxt)) {
                System.arraycopy(seq, 0, ans, 0, seqLen);
                ansLen = seqLen;
            }
        }
    }

    // 比较 a 和 b 的字典序大小
    private int compare(char[] a, char[] b, int n) {
        for (int i = 0; i < n; i++) {
            if (a[i] != b[i]) {
                return a[i] - b[i];
            }
        }
        return 0;
    }

    // 392. 判断子序列
    // 返回 seq*k 是否为 s 的子序列
    private boolean isSubsequence(char[] seq, int n, int k, int[][] nxt) {
        int i = -1;
        while (k-- > 0) {
            for (int j = 0; j < n; j++) {
                char c = seq[j];
                i = nxt[i + 1][c - 'a'];
                if (i + 1 == nxt.length) { // c 不在 s 中,说明 seq*k 不是 s 的子序列
                    return false;
                }
            }
        }
        return true;
    }
}
class Solution {
    // 47. 全排列 II
    // 枚举从 nums 中选任意个数的所有排列,用 f 处理枚举的排列
    void permuteFunc(const string& nums, auto&& f) {
        int n = nums.size();
        string path;
        vector<int8_t> on_path(n); // on_path[j] 表示 nums[j] 是否已经填入排列
        auto dfs = [&](this auto&& dfs) -> void {
            f(path);
            if (path.size() == n) {
                return;
            }
            // 枚举 nums[j] 填入 path[i]
            for (int j = 0; j < n; j++) {
                // 如果 nums[j] 已填入排列,continue
                // 如果 nums[j] 和前一个数 nums[j-1] 相等,且 nums[j-1] 没填入排列,continue
                if (on_path[j] || j > 0 && nums[j] == nums[j - 1] && !on_path[j - 1]) {
                    continue;
                }
                path += nums[j]; // 填入排列
                on_path[j] = true; // nums[j] 已填入排列(注意标记的是下标,不是值)
                dfs(); // 填排列的下一个数
                on_path[j] = false; // 恢复现场
                path.pop_back(); // 恢复现场
            }
        };
        dfs();
    };

public:
    string longestSubsequenceRepeatedK(string s, int k) {
        // 392. 判断子序列(进阶做法)
        int n = s.size();
        vector<array<int, 26>> nxt(n + 1);
        ranges::fill(nxt[n], n);
        for (int i = n - 1; i >= 0; i--) {
            nxt[i] = nxt[i + 1];
            nxt[i][s[i] - 'a'] = i;
        }

        auto isSubsequence = [&](const string& seq, int k) -> bool {
            int i = -1;
            while (k--) {
                for (char c : seq) {
                    i = nxt[i + 1][c - 'a'];
                    if (i == n) { // c 不在 s 中,说明 seq*k 不是 s 的子序列
                        return false;
                    }
                }
            }
            return true;
        };

        int cnt[26]{};
        for (char c : s) {
            cnt[c - 'a']++;
        }

        string a;
        for (int i = 25; i >= 0; i--) { // 倒序,这样我们可以优先枚举字典序大的排列
            a.insert(a.end(), cnt[i] / k, 'a' + i);
        }

        string ans;
        permuteFunc(a, [&](const string& seq) {
            // 先比大小(时间复杂度低),再判断是否为子序列(时间复杂度高)
            if (seq.size() > ans.size() || seq.size() == ans.size() && seq > ans) {
                if (isSubsequence(seq, k)) {
                    ans = seq;
                }
            }
        });
        return ans;
    }
};
// 47. 全排列 II
// 枚举从 nums 中选任意个数的所有排列,用 f 处理枚举的排列
func permuteFunc[T comparable](nums []T, f func([]T)) {
    n := len(nums)
    path := []T{}
    onPath := make([]bool, n) // onPath[j] 表示 nums[j] 是否已经填入排列
    var dfs func()
    dfs = func() {
        f(path)
        if len(path) == n {
            return
        }
        // 枚举 nums[j] 填入 path[i]
        for j, on := range onPath {
            // 如果 nums[j] 已填入排列,continue
            // 如果 nums[j] 和前一个数 nums[j-1] 相等,且 nums[j-1] 没填入排列,continue
            if on || j > 0 && nums[j] == nums[j-1] && !onPath[j-1] {
                continue
            }
            path = append(path, nums[j])
            onPath[j] = true  // nums[j] 已填入排列(注意标记的是下标,不是值)
            dfs()             // 填排列的下一个数
            onPath[j] = false // 恢复现场
            path = path[:len(path)-1]
        }
    }
    dfs()
}

func longestSubsequenceRepeatedK(s string, k int) string {
    // 392. 判断子序列(进阶做法)
    n := len(s)
    nxt := make([][26]int, n+1)
    for j := range nxt[n] {
        nxt[n][j] = n
    }
    for i := n - 1; i >= 0; i-- {
        nxt[i] = nxt[i+1]
        nxt[i][s[i]-'a'] = i
    }
    isSubsequence := func(seq []byte) bool {
        i := -1
        for range k {
            for _, c := range seq {
                i = nxt[i+1][c-'a']
                if i == n { // c 不在 s 中,说明 seq*k 不是 s 的子序列
                    return false
                }
            }
        }
        return true
    }

    cnt := [26]int{}
    for _, c := range s {
        cnt[c-'a']++
    }
    a := []byte{}
    for i := 25; i >= 0; i-- { // 倒序,这样我们可以优先枚举字典序大的排列
        bs := []byte{'a' + byte(i)}
        a = append(a, bytes.Repeat(bs, cnt[i]/k)...)
    }

    ans := []byte{}
    permuteFunc(a, func(seq []byte) {
        // 先比大小(时间复杂度低),再判断是否为子序列(时间复杂度高)
        if len(seq) > len(ans) || len(seq) == len(ans) && bytes.Compare(seq, ans) > 0 {
            if isSubsequence(seq) {
                ans = slices.Clone(seq)
            }
        }
    })
    return string(ans)
}

复杂度分析

  • 时间复杂度:$\mathcal{O}((n/k)!\cdot n)$,其中 $n$ 是 $s$ 的长度。注意 $\sum_{i=0}^m A_m^i$ 就是全排列搜索树的节点个数,我在 排列型回溯【基础算法精讲 16】中精确地算出了全排列搜索树的节点个数为 $\left\lfloor e\cdot m!\right\rfloor$,其中 $e=2.718\cdots$ 为自然常数。比如 $m=7$ 时,$\sum_{i=0}^7 A_7^i = 13700 = \left\lfloor e\cdot 7!\right\rfloor$。当 $m=n/k$ 时,遍历这棵搜索树需要 $\mathcal{O}((n/k)!)$ 的时间,每个节点判断子序列需要 $\mathcal{O}(n)$ 的时间。
  • 空间复杂度:$\mathcal{O}(n|\Sigma|)$。其中 $|\Sigma|=26$ 是字符集合的大小。

相似题目

  1. 回溯题单的「§4.5 排列型回溯」。
  2. 双指针题单的「§4.2 判断子序列」。
  3. 字符串题单的「九、子序列自动机」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

脑筋急转弯(Python/Java/C++/Go/JS/Rust)

作者 endlesscheng
2022年6月19日 12:08

设 $k$ 的二进制长度为 $m$。比如 $k=4=100_{(2)}$,二进制长度 $m=3$。

核心思路

  1. 任意长为 $m-1$ 的子序列,数值一定小于 $k$。比如 $00$,$01$,$10$,$11$ 都小于 $100$。
  2. 前导零不影响数值。子序列越靠右,我们能添加的前导零就越多,子序列就越长。贪心地,选 $s$ 的后缀作为子序列。(为什么?理由见下面的问答)
  3. 如果 $s$ 的长为 $m$ 的后缀 $\le k$,那么就选长为 $m$ 的后缀作为子序列;否则选长为 $m-1$ 的后缀(一定小于 $k$)作为子序列。在此基础上,加上长为 $n-m$ 的前缀中的 $0$ 的个数,即为答案。

在示例 1 中,$s=1001010$,$k=101_{(2)}$,$s$ 的长为 $m=3$ 的后缀 $010\le k$。如果在 $010$ 的前面添加 $1$,就超过 $k$ 了。此外,由于我们选的是 $s$ 的后缀,可以往 $010$ 前面添加尽量多的前导零,从而增加子序列的长度,但不会增大子序列的数值。最终得到 $00 + 010 = 00010$,长为 $5$。

又比如 $s=1001110$,$k=101_{(2)}$,长为 $m$ 的后缀 $110>k$,但长为 $m-1$ 的后缀 $10$ 一定小于 $k$。在这个后缀的前面添加两个前导零,最终得到 $00 + 10 = 0010$,长为 $4$。

:在上例中,虽然 $110>k$,但我可以选其他长为 $m$ 的子序列呀?比如 $100$,$101$,长为 $m$ 且 $\le k$。为什么不考虑这样的子序列呢?

:这是因为,要想得到比后缀 $110$ 更小的长为 $m$ 的子序列,比如 $100$,$101$,这样的子序列必然包含「本应添加到后缀 $10$ 前的前导零」,这些长为 $m$ 的子序列,相比长为 $m-1$ 的后缀,长度仅仅增加了 $1$,但我们只需要在长为 $m-1$ 的后缀的基础上,再添加一个前导零,也能得到长为 $m$ 的子序列 $010$,且这个子序列的第一个字符是「尽量靠右」的,可以添加更多的前导零。所以这些长为 $m$ 的子序列,并不会比「一个 $0$ + 长为 $m-1$ 的后缀」这种构造方式更优。

class Solution:
    def longestSubsequence(self, s: str, k: int) -> int:
        n, m = len(s), k.bit_length()
        if n < m:  # int(s, 2) < k
            return n  # 全选
        ans = m if int(s[-m:], 2) <= k else m - 1  # 后缀长度
        return ans + s[:-m].count('0')  # 添加前导零
class Solution {
    public int longestSubsequence(String s, int k) {
        int n = s.length();
        int m = 32 - Integer.numberOfLeadingZeros(k); // k 的二进制长度
        if (n < m) {
            return n; // 全选
        }

        int sufVal = Integer.parseInt(s.substring(n - m), 2);
        int ans = sufVal <= k ? m : m - 1; // 后缀长度

        for (int i = 0; i < n - m; i++) {
            ans += '1' - s.charAt(i); // 添加前导零
        }
        return ans;
    }
}
class Solution {
public:
    int longestSubsequence(string s, int k) {
        int n = s.length();
        int m = bit_width((uint32_t) k);
        if (n < m) {
            return n; // 全选
        }
        int suf_val = stoi(s.substr(n - m), nullptr, 2);
        int ans = suf_val <= k ? m : m - 1; // 后缀长度
        return ans + count(s.begin(), s.end() - m, '0'); // 添加前导零
    }
};
int longestSubsequence(char* s, int k) {
    int n = strlen(s);
    int m = 32 - __builtin_clz(k); // k 的二进制长度
    if (n < m) {
        return n; // 全选
    }

    int suf_val = strtol(s + n - m, NULL, 2);
    int ans = suf_val <= k ? m : m - 1; // 后缀长度

    for (int i = 0; i < n - m; i++) {
        ans += '1' - s[i]; // 添加前导零
    }
    return ans;
}
func longestSubsequence(s string, k int) int {
n, m := len(s), bits.Len(uint(k))
if n < m {
return n // 全选
}
ans := m // 后缀长度
sufVal, _ := strconv.ParseInt(s[n-m:], 2, 0)
if int(sufVal) > k {
ans--
}
return ans + strings.Count(s[:n-m], "0") // 添加前导零
}
var longestSubsequence = function(s, k) {
    const n = s.length;
    const m = 32 - Math.clz32(k); // k 的二进制长度
    if (n < m) {
        return n; // 全选
    }

    const sufVal = parseInt(s.slice(n - m), 2);
    let ans = sufVal <= k ? m : m - 1; // 后缀长度

    for (let i = 0; i < n - m; i++) {
        if (s[i] === '0') {
            ans++; // 添加前导零
        }
    }
    return ans;
};
impl Solution {
    pub fn longest_subsequence(s: String, k: i32) -> i32 {
        let n = s.len();
        let m = (32 - k.leading_zeros()) as usize; // k 的二进制长度
        if n < m {
            return n as _; // 全选
        }

        let suf_val = i32::from_str_radix(&s[n - m..], 2).unwrap();
        let ans = if suf_val <= k { m } else { m - 1 }; // 后缀长度

        (ans + s[..n - m].bytes().filter(|&c| c == b'0').count()) as _ // 添加前导零
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 为 $s$ 的长度。这里是严格意义上的一次遍历,$s$ 的每个字符都会被恰好遍历一次。(C 语言要计算字符串长度,遍历两次)
  • 空间复杂度:$\mathcal{O}(n)$ 或 $\mathcal{O}(m)$ 或 $\mathcal{O}(1)$,取决于实现。手动遍历计算可以做到 $\mathcal{O}(1)$ 空间。

思考题

子序列改成子串,要怎么做?你能做到 $\mathcal{O}(n)$ 时间复杂度吗?

欢迎在评论区发表你的思路/代码。

专题训练

见下面贪心与思维题单的「§5.2 脑筋急转弯」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

第 k 小/大问题的通用转换方法(Python/Java/C++/Go)

作者 endlesscheng
2021年10月17日 00:13

第 $k$ 小/大问题的通用转换方法

  • 第 $k$ 小等价于:求最小的 $x$,满足 $\le x$ 的数至少有 $k$ 个。(注意是至少不是恰好)
  • 第 $k$ 大等价于:求最大的 $x$,满足 $\ge x$ 的数至少有 $k$ 个。

$x$ 越大,越能找到 $k$ 个数;$x$ 越小,越不能找到 $k$ 个数。据此,可以二分猜答案。关于二分算法的原理,请看 二分查找 红蓝染色法【基础算法精讲 04】

现在本题转化成一个判定性问题:

  • 给定整数 $\textit{mx}$,统计 $\le \textit{mx}$ 的乘积个数 $\textit{cnt}$,判断是否满足 $\textit{cnt}\ge k$。

如何高效统计 $\textit{cnt}$ 呢?

为方便描述,下文把 $\textit{nums}_1$ 记作 $a$,把 $\textit{nums}_2$ 记作 $b$。

看示例 3,定义 $\textit{matrix}[i][j] = a[i]\cdot b[j]$,得到如下矩阵

$$
\begin{bmatrix}
6 & 2 & -4 & -8 & -10 \
3 & 1 & -2 & -4 & -5 \
0 & 0 & 0 & 0 & 0 \
-3 & -1 & 2 & 4 & 5 \
-6 & -2 & 4 & 8 & 10 \
\end{bmatrix}
$$

按照元素正负,把矩阵分成如下四个区域(人为规定 $0$ 分到下面两个区域)

$$
\begin{array}{cc|cc}
6 & 2 & -4 & -8 & -10 \
3 & 1 & -2 & -4 & -5 \ \hline
0 & 0 & 0 & 0 & 0 \
-3 & -1 & 2 & 4 & 5 \
-6 & -2 & 4 & 8 & 10 \
\end{array}
$$

其中右下区域

$$
\begin{array}{}
0 & 0 & 0 \
2 & 4 & 5 \
4 & 8 & 10 \
\end{array}
$$

每行每列都是有序的,和 378. 有序矩阵中第 K 小的元素 完全一样,都可以用双指针解决,见 图解

其余三个区域也都是有序的,只是递增的方向不同,都可以用双指针解决。

最后,确定二分的左右边界。我们需要知道矩阵的最小值和最大值,这一定来自矩阵的四个角,即 $a[0],a[n-1]$ 与 $b[0],b[m-1]$ 的两两乘积,一共 $4$ 种情况。

答疑

:为什么二分结束后,答案 $\textit{ans}$ 一定在矩阵中?

:反证法。假设 $\textit{ans}$ 不在矩阵中,这意味着矩阵中第 $k$ 小的数比 $\textit{ans}$ 小,或者说 $\le \textit{ans}-1$。换句话说,$\le \textit{ans}-1$ 的数有 $k$ 个,即 $\text{check}(\textit{ans}-1)=\texttt{true}$。但根据循环不变量,二分结束后 $\text{check}(\textit{ans}-1)=\texttt{false}$,矛盾。故原命题成立。

class Solution:
    def kthSmallestProduct(self, a: List[int], b: List[int], k: int) -> int:
        i0 = bisect_left(a, 0)  # 四个区域的水平分界线
        j0 = bisect_left(b, 0)  # 四个区域的垂直分界线

        def check(mx: int) -> bool:
            if mx < 0:
                cnt = 0

                # 右上区域
                i, j = 0, j0
                while i < i0 and j < m:  # 不判断 cnt < k 更快
                    if a[i] * b[j] > mx:
                        j += 1
                    else:
                        cnt += m - j
                        i += 1

                # 左下区域
                i, j = i0, 0
                while i < n and j < j0:
                    if a[i] * b[j] > mx:
                        i += 1
                    else:
                        cnt += n - i
                        j += 1
            else:
                # 右上区域和左下区域的所有数都 <= 0 <= mx
                cnt = i0 * (m - j0) + (n - i0) * j0

                # 左上区域
                i, j = 0, j0 - 1
                while i < i0 and j >= 0:
                    if a[i] * b[j] > mx:
                        i += 1
                    else:
                        cnt += i0 - i
                        j -= 1

                # 右下区域
                i, j = i0, m - 1
                while i < n and j >= j0:
                    if a[i] * b[j] > mx:
                        j -= 1
                    else:
                        cnt += j - j0 + 1
                        i += 1

            return cnt >= k

        n, m = len(a), len(b)
        corners = (a[0] * b[0], a[0] * b[-1], a[-1] * b[0], a[-1] * b[-1])
        left, right = min(corners), max(corners)
        return left + bisect_left(range(left, right), True, key=check)
class Solution {
    public long kthSmallestProduct(int[] a, int[] b, long k) {
        int i0 = lowerBound(a, 0); // 四个区域的水平分界线
        int j0 = lowerBound(b, 0); // 四个区域的垂直分界线

        int n = a.length;
        int m = b.length;
        List<Long> corners = Arrays.asList((long) a[0] * b[0], (long) a[0] * b[m - 1], (long) a[n - 1] * b[0], (long) a[n - 1] * b[m - 1]);
        long left = Collections.min(corners) - 1;
        long right = Collections.max(corners);

        while (left + 1 < right) {
            long mid = left + (right - left) / 2;
            if (check(a, b, i0, j0, k, mid)) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return right;
    }

    private boolean check(int[] a, int[] b, int i0, int j0, long k, long mx) {
        int n = a.length;
        int m = b.length;
        long cnt = 0;

        if (mx < 0) {
            // 右上区域
            int i = 0;
            int j = j0;
            while (i < i0 && j < m) { // 不判断 cnt < k 更快
                if ((long) a[i] * b[j] > mx) {
                    j++;
                } else {
                    cnt += m - j;
                    i++;
                }
            }

            // 左下区域
            i = i0;
            j = 0;
            while (i < n && j < j0) {
                if ((long) a[i] * b[j] > mx) {
                    i++;
                } else {
                    cnt += n - i;
                    j++;
                }
            }
        } else {
            // 右上区域和左下区域的所有数都 <= 0 <= mx
            cnt = (long) i0 * (m - j0) + (long) (n - i0) * j0;

            // 左上区域
            int i = 0;
            int j = j0 - 1;
            while (i < i0 && j >= 0) {
                if ((long) a[i] * b[j] > mx) {
                    i++;
                } else {
                    cnt += i0 - i;
                    j--;
                }
            }

            // 右下区域
            i = i0;
            j = m - 1;
            while (i < n && j >= j0) {
                if ((long) a[i] * b[j] > mx) {
                    j--;
                } else {
                    cnt += j - j0 + 1;
                    i++;
                }
            }
        }

        return cnt >= k;
    }

    // 见 https://www.bilibili.com/video/BV1AP41137w7/
    private int lowerBound(int[] nums, int target) {
        int left = -1;
        int right = nums.length;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] >= target) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return right;
    }
}
class Solution {
public:
    long long kthSmallestProduct(vector<int>& a, vector<int>& b, long long k) {
        int n = a.size(), m = b.size();
        int i0 = ranges::lower_bound(a, 0) - a.begin(); // 四个区域的水平分界线
        int j0 = ranges::lower_bound(b, 0) - b.begin(); // 四个区域的垂直分界线

        auto check = [&](long long mx) -> bool {
            long long cnt = 0;

            if (mx < 0) {
                // 右上区域
                int i = 0, j = j0;
                while (i < i0 && j < m) { // 注:可以加个 cnt < k 的判断,提前退出
                    if (1LL * a[i] * b[j] > mx) {
                        j++;
                    } else {
                        cnt += m - j;
                        i++;
                    }
                }

                // 左下区域
                i = i0;
                j = 0;
                while (i < n && j < j0) {
                    if (1LL * a[i] * b[j] > mx) {
                        i++;
                    } else {
                        cnt += n - i;
                        j++;
                    }
                }
            } else {
                // 右上区域和左下区域的所有数都 <= 0 <= mx
                cnt = 1LL * i0 * (m - j0) + 1LL * (n - i0) * j0;

                // 左上区域
                int i = 0, j = j0 - 1;
                while (i < i0 && j >= 0) {
                    if (1LL * a[i] * b[j] > mx) {
                        i++;
                    } else {
                        cnt += i0 - i;
                        j--;
                    }
                }

                // 右下区域
                i = i0;
                j = m - 1;
                while (i < n && j >= j0) {
                    if (1LL * a[i] * b[j] > mx) {
                        j--;
                    } else {
                        cnt += j - j0 + 1;
                        i++;
                    }
                }
            }

            return cnt >= k;
        };

        long long corners[4] = {1LL * a[0] * b[0], 1LL * a[0] * b[m - 1], 1LL * a[n - 1] * b[0], 1LL * a[n - 1] * b[m - 1]};
        auto [left, right] = ranges::minmax(corners);
        left--;
        while (left + 1 < right) {
            long long mid = left + (right - left) / 2;
            (check(mid) ? right : left) = mid;
        }
        return right;
    }
};
func kthSmallestProduct(a, b []int, K int64) int64 {
n, m, k := len(a), len(b), int(K)
i0 := sort.SearchInts(a, 0) // 四个区域的水平分界线
j0 := sort.SearchInts(b, 0) // 四个区域的垂直分界线

corners := []int{a[0] * b[0], a[0] * b[m-1], a[n-1] * b[0], a[n-1] * b[m-1]}
left := slices.Min(corners)
right := slices.Max(corners)
ans := left + sort.Search(right-left, func(mx int) bool {
mx += left
cnt := 0

if mx < 0 {
// 右上区域
i, j := 0, j0
for i < i0 && j < m { // 注:可以加个 cnt < k 的判断,提前退出
if a[i]*b[j] > mx {
j++
} else {
cnt += m - j
i++
}
}

// 左下区域
i, j = i0, 0
for i < n && j < j0 {
if a[i]*b[j] > mx {
i++
} else {
cnt += n - i
j++
}
}
} else {
// 右上区域和左下区域的所有数都 <= 0 <= mx
cnt = i0*(m-j0) + (n-i0)*j0

// 左上区域
i, j := 0, j0-1
for i < i0 && j >= 0 {
if a[i]*b[j] > mx {
i++
} else {
cnt += i0 - i
j--
}
}

// 右下区域
i, j = i0, m-1
for i < n && j >= j0 {
if a[i]*b[j] > mx {
j--
} else {
cnt += j - j0 + 1
i++
}
}
}

return cnt >= k
})
return int64(ans)
}

复杂度分析

  • 时间复杂度:$\mathcal{O}((n+m)\log U)$,其中 $n$ 是 $a$ 的长度,$m$ 是 $b$ 的长度,$U$ 为矩阵四个角的最大最小之差。二分 $\mathcal{O}(\log U)$ 次,每次双指针需要 $\mathcal{O}(n+m)$ 的时间。
  • 空间复杂度:$\mathcal{O}(1)$。

相似题目

更多相似题目,见下面二分题单的「§2.6 第 K 小/大」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

❌
❌