阅读视图

发现新文章,点击刷新页面。

每日一题-重复 K 次的最长子序列🔴

给你一个长度为 n 的字符串 s ,和一个整数 k 。请你找出字符串 s重复 k 次的 最长子序列

子序列 是由其他字符串删除某些(或不删除)字符派生而来的一个字符串。

如果 seq * ks 的一个子序列,其中 seq * k 表示一个由 seq 串联 k 次构造的字符串,那么就称 seq 是字符串 s 中一个 重复 k 的子序列。

  • 举个例子,"bba" 是字符串 "bababcba" 中的一个重复 2 次的子序列,因为字符串 "bbabba" 是由 "bba" 串联 2 次构造的,而 "bbabba" 是字符串 "bababcba" 的一个子序列。

返回字符串 s重复 k 次的最长子序列  。如果存在多个满足的子序列,则返回 字典序最大 的那个。如果不存在这样的子序列,返回一个 字符串。

 

示例 1:

example 1

输入:s = "letsleetcode", k = 2
输出:"let"
解释:存在两个最长子序列重复 2 次:let" 和 "ete" 。
"let" 是其中字典序最大的一个。

示例 2:

输入:s = "bb", k = 2
输出:"b"
解释:重复 2 次的最长子序列是 "b" 。

示例 3:

输入:s = "ab", k = 2
输出:""
解释:不存在重复 2 次的最长子序列。返回空字符串。

 

提示:

  • n == s.length
  • 2 <= k <= 2000
  • 2 <= n < k * 8
  • s 由小写英文字母组成

最简洁易懂的方法:利用字母频率

如果一个字母频率为freq,那么其可能参与组成的子串最多为freq//k个,因此我们只需要统计s中各个字母出现的频率,进行倒序排列便于后续能够直接筛选出首字母最大的子串,然后频率满足要求的字母组合起来成为新的串hot

接着我们求出hot全部子串的全排列,然后依次判断是否属于s,第一个满足要求的即为所求

class Solution:
    def longestSubsequenceRepeatedK(self, s: str, k: int) -> str:
        num = Counter(s)
        hot = ''.join(ele * (num[ele] // k) for ele in sorted(num, reverse=True))
        for i in range(len(hot), 0, -1):
            for item in permutations(hot, i):
                word = ''.join(item)
                ss = iter(s)
                if all(c in ss for c in word * k):
                    return word
        return ''

注意在判断是否属于s时,利用iter()函数生成迭代器是个非常巧妙的选择,比直接for循环判断要更加简洁高效

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

分析

如果 $\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):
                    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站@灵茶山艾府

一个猎奇一点的随机算法

令参数 $\ell=\frac{n}{k}$,注意到题中的 $\ell$ 非常小,可以视为常数。不过根据个人习惯,下文分析复杂度的时候还是会将 $\ell$ 作为参数代入进行计算。
$O(\ell!\cdot n)$ 的搜索我猜肯定有人会写题解,用到的性质是最多只有 $\ell$ 个出现频率超过 $\frac{1}{\ell}$ 的候选字符,这里就不赘述了。下面介绍一个猎奇一点的随机算法:
考虑最优解中每个重复的子序列 $seq$ 的出现区间,平均来说长度不超过 $\ell$。根据 Markov's inequality,至少有 $\frac{k}{\ell}$ 个区间的长度不超过 $\ell+1$。那么如果我们从原数组中 $n-\ell$ 个可能的长度为 $\ell+1$ 的子区间中随机挑选一个,它完整包含子序列 $seq$ 的概率至少为 $\dfrac{k/\ell}{n-\ell}\geq \dfrac{1}{\ell^2}$。如果我们猜对了区间,就只需枚举该区间中所有 $2^{\ell+1}$ 个子序列进行贪心验证即可,每次验证需要 $O(n)$ 的时间。重复随机 $O(\ell^2\log \frac{1}{\epsilon})$ 次即可使错误率足够小。
这个算法的复杂度是 $O(2^\ell\cdot \mathrm{poly}(\ell)\cdot n\cdot \log \frac{1}{\epsilon})$,在 $\ell$ 较大时是比搜索的复杂度渐近更优的。(注意到根据斯特林公式有 $l!=O^*\left((\dfrac{\ell}{e})^\ell\right)$.)


以下代码没怎么进行剪枝优化,所以比较慢。比赛的时候随便狂调了几次重复次数就过了。仔细优化一下的话应该是稳过的。
无标题.png

const int L=8;
int n; char *a;
class Solution {
public:
bool test(string t,int k){
int m=t.size(),cnt=0,j=0;
char *b=&t[0];
for (int i=0;i<n;++i)
if (a[i]==b[j]){
if (j==m-1)j=0,++cnt;
else ++j;
}
return cnt>=k;
}
string longestSubsequenceRepeatedK(string s, int k) {
a=&s[0]; string ans;
n=s.size(); int l=min(n,L);
for (int T=1;T<=20;++T){
int p=rand()%(n-l+1);
for (int i=1;i<(1<<l);++i)
if (__builtin_popcount(i)>=ans.size()){
string b;
for (int j=0;j<l;++j)
if (i&(1<<j))b+=a[p+j];
if ((b.size()>ans.size()||b>ans)&&test(b,k))ans=b;
}
}
return ans;
}
};

用这个性质进行 dfs 剪枝,再调一下参,可以刷到第一。
2014.png{:width=400}

const int N=2005;
int c[255],_next[N][26],(*nxt)[26]=&_next[2],n,m,k;
char b[N];
string a,ans;
inline bool check(const string &t){
int m=t.size(),cur;
if (!m)return 1;
if (k>40){
bool flag=0;
for (int i=0;i<10;++i){
int p=rand()%n; cur=p-1;
for (auto &ch:t)cur=nxt[cur][ch-'a'];
if (cur>=0&&cur-p<=7){flag=1; break;}
}
if (!flag)return 0;
}
cur=-1;
for (int i=0;i<k;++i){
for (auto &ch:t)cur=nxt[cur][ch-'a'];
if (cur<0)return 0;
}
return 1;
}
void dfs(string &t){
if (!check(t))return;
if (t.size()>ans.size()||t.size()==ans.size()&&t>ans)ans=t;
for (int i=0;i<m;++i)if (!i||b[i]!=b[i-1]){
char ch=b[i]; --m;
for (int j=i;j<m;++j)b[j]=b[j+1];
t.push_back(ch);
dfs(t);
t.pop_back();
for (int j=m;j>i;--j)b[j]=b[j-1];
++m; b[i]=ch;
}
}
class Solution {
public:
string longestSubsequenceRepeatedK(string s, int k) {
m=0; ::k=k; a=ans="";
for (int i='a';i<='z';++i)c[i]=0;
for (auto &ch:s)++c[ch];
for (auto &ch:s)if (c[ch]>=k)a.push_back(ch);
n=a.size();
for (int i=0;i<26;++i)nxt[-2][i]=nxt[n-1][i]=-2;
for (int i=n-2;i>=-1;--i){
memcpy(nxt[i],nxt[i+1],sizeof(int)*26);
nxt[i][a[i+1]-'a']=i+1;
}
for (int i='a';i<='z';++i)
for (int j=0;j<c[i]/k;++j)b[m++]=i;
string _s; dfs(_s);
return ans;
}
};

每日一题-小于等于 K 的最长二进制子序列🟡

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

请你返回 s 的 最长 子序列的长度,且该子序列对应的 二进制 数字小于等于 k 。

注意:

  • 子序列可以有 前导 0 。
  • 空字符串视为 0 。
  • 子序列 是指从一个字符串中删除零个或者多个字符后,不改变顺序得到的剩余字符序列。

 

示例 1:

输入:s = "1001010", k = 5
输出:5
解释:s 中小于等于 5 的最长子序列是 "00010" ,对应的十进制数字是 2 。
注意 "00100" 和 "00101" 也是可行的最长子序列,十进制分别对应 4 和 5 。
最长子序列的长度为 5 ,所以返回 5 。

示例 2:

输入:s = "00101001", k = 1
输出:6
解释:"000001" 是 s 中小于等于 1 的最长子序列,对应的十进制数字是 1 。
最长子序列的长度为 6 ,所以返回 6 。

 

提示:

  • 1 <= s.length <= 1000
  • s[i] 要么是 '0' ,要么是 '1'
  • 1 <= k <= 109

贪心思想解决加强版

本题比较有用的是字符串长度扩展到 $10^6$ 时的做法。您不妨先思考一下。


好,下面是提示。

如果您会了或者想直接看答案,可以跳过。保证不看提示也能看懂正解。


提示一

$0$ 和 $1$ 中,谁才对数的大小造成贡献?

提示二

$0$ 比起 $1$ 来,有什么优越性?


提示三

假如没有 $1$,您会做吗?

提示四

假如只有一个 $1$ 呢?

假如只有两个 $1$ 呢?

假如……!


我们一一解答以上问题。

解释一

$1$。

解释二

$0$ 可以以前导 $0$ 的形式出现在数的前面,增加数的长度,同时保证数的大小不变。

解释三

全是前导〇嘛,全选上不就得了!

解释四

一个 $1$,那就讨论它选不选。

两个 $1$,讨论这两个 $1$ 分别选不选。

更多 $1$,$2^{length}$ 讨论选不选……?

显然我们需要更快的讨论方式。


正解

这里直接抛出结论:先选择所有的 $0$,再从低位到高位地贪心选择所有的 $1$,直到所构成的数超出 $k$。此时字符串长度即为答案。

例如,字符串 $s$ 为 $11001010$,$k$ 为 $16$。

  1. 先选择所有的 $0$,得 $(0000)_2=0\le k$。
  2. 选择最低位的未选择 $1$,得 $(00010)_2=2\le k$。
  3. 继续选择最低位的未选择 $1$,得 $(001010)_2=10\le k$。
  4. 继续选择最低位的未选择 $1$,得 $(1001010)_2=74> k$,结束并退出。

下面证明这样选择为什么是对的。考虑扰动法。

情况一:用一个未选择的 $1$ 替换选择的 $1$。

因为我们是从低到高地选择 $1$,所以未选择的一定是高位。低位 $1$ 一定比高位 $1$ 更优(因为对串长贡献相同,但是却会使得数更小),故不可能。

情况二:用一个未选择的 $1$ 替换选择的 $0$。

同上且更不可能。

综上是正确的贪心策略。代码就很简单了。

时间复杂度 $O(n)$。

点个赞吧。

UPD:一个理解上的问题。
有一种直观感受是,如果我删去一个低位的 $0$,相当于所有高位整体向右移动一位,对 k 的贡献是不确定的?
其实不然。别忘了我们是在使得字符串长度最长。考虑字符串长度相同的情况,就会发现删去最后一个必然导致的是前面补上一个 $0$ 或 $1$。整体右移一位其实是补上了一个前导 $0$,但是我们已经选择了所有的 $0$,故这个情况是不存在的。那么删去一个 $0$ 必然会导致补上一个 $1$,这个显然不优。

C++ Sol:

###cpp

class Solution {
public:
int longestSubsequence(string s, int k) {
int res=0;
for(char y:s) if(y=='0') ++res;
long long w=1,ans=0;
for(int i=s.length()-1; ~i; i--,w<<=1) {
if(s[i]=='1') ans+=w;
if(ans>k) return res;
if(s[i]=='1') ++res;
if(w>2e9) return res;
}
return res;
}
};

Java Sol:见评论区,感谢 @零点再睡觉。

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

设 $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站@灵茶山艾府

『 贪心 』从低位到高位遍历s,判断1能否被保留

贪心:

  1. $s$ 中的 0 均可保留,仅需判断 $s$ 中的 1 能否被保留。

    说明:
    在任何情况下,与其删除某一位的 0,不如删除最高位的 1。也就是说在必要的删除操作下,删除某一位 0 所带来的“收益”不会高于删除最高位的 1
    例如,对于 $s$ = "1001010",要使得其子序列对应的值小于等于 $k=10$,那么删除任一位的 0,如 "1001010",都不如删除最高位的 1,即 "1001010"。

  2. 要使得 $s$ 的子序列最长,且该子序列对应的 二进制 数字小于等于 $k$,那么应优先保留较低位的 1


解题思路

从低位到高位遍历 $s$ (反向遍历 $s$),记需要移除的 1 的个数为 $removed$:

  1. 若当前位为 0,可保留;
  2. 若当前位为 1,分类判断:
    • 计入当前位 1,数字总和依然 $\le k$,可保留;
    • 计入当前位 1,数字总和 $> k$,不可保留,$removed + 1$。

最终返回 $s$ 的总长度减去需要删除的 1 的个数,即 $len(s) - removed$。


代码

###Python

class Solution:
    def longestSubsequence(self, s: str, k: int) -> int:
        
        n = len(s)
        summ = 0                            # 当前子序列的值
        removed = 0                         # 记录要移除的1的个数
        for i, ch in enumerate(s[::-1]):    # 贪心:从低位到高位遍历s
            if ch == '1':                   # 判断当前位的1能否被保留
                if summ >= k:               # 当前位的1无法保留
                    removed += 1
                else:
                    summ += (1<<i)
                    if summ > k:            # 当前位的1无法保留
                        removed += 1

        return n - removed

###Python

class Solution:
    def longestSubsequence(self, s: str, k: int) -> int:
        
        n = len(s)
        summ = 0                            # 当前子序列的值
        removed = 0                         # 记录要移除的1的个数
        for i, ch in enumerate(s[::-1]):    # 贪心:从低位到高位遍历s
            if ch == '1':                   # 判断当前位的1能否被保留
                if summ + (1<<i) > k:       # 当前位的1无法保留
                    removed += 1
                else:                       # 当前位的1可以保留
                    summ += (1<<i)
        
        return n - removed

改进

还可充分利用 $1 \le k \le 10^9$ 这一条件:
从低位到高位遍历 $s$ 时,当遍历到的 1 的位数 $\ge 30$ 时可直接删除该位的 1,因为 $2^{30}=1073741824>10^9 > 2^{29}$。
这对于 C++ 等 $\text{INT_MAX}\le 2^{31}-1$ 的语言来说也能避免整数溢出的情况。

###Python

class Solution:
    def longestSubsequence(self, s: str, k: int) -> int:
        
        n = len(s)
        summ = 0                            # 当前子序列的值
        removed = 0                         # 记录要移除的1的个数
        for i, ch in enumerate(s[::-1]):    # 贪心:从低位到高位遍历s
            if ch == '1':                   # 判断当前位的1能否被保留
                if i >= 30 or summ + (1<<i) > k:    # 当前位的1无法保留
                    removed += 1
                else:                       # 当前位的1可以保留
                    summ += (1<<i)

        return n - removed

###CPP

class Solution {
public:
    int longestSubsequence(string s, int k) {
        int n = s.size();
        int summ = 0;                       // 当前子序列的值
        int removed = 0;
        for (int i = n - 1; i >= 0; i--) {  // 反向遍历s
            if (s[i] == '1') {
                int offset = n-1-i;         // offset:从低位到高位的位数
                if (offset >= 30 || summ + (1 << offset) > k)  removed += 1;
                else  summ += 1 << (offset);
            }
        }
        return n - removed;
    }
};

**复杂度分析**
  • 时间复杂度:$O(n)$,其中 $n=len(s)$。
  • 空间复杂度:$O(1)$(不计反转 $s$)。

每日一题-两个有序数组的第 K 小乘积🔴

给你两个 从小到大排好序 且下标从 0 开始的整数数组 nums1 和 nums2 以及一个整数 k ,请你返回第 k (从 1 开始编号)小的 nums1[i] * nums2[j] 的乘积,其中 0 <= i < nums1.length 0 <= j < nums2.length 。

 

示例 1:

输入:nums1 = [2,5], nums2 = [3,4], k = 2
输出:8
解释:第 2 小的乘积计算如下:
- nums1[0] * nums2[0] = 2 * 3 = 6
- nums1[0] * nums2[1] = 2 * 4 = 8
第 2 小的乘积为 8 。

示例 2:

输入:nums1 = [-4,-2,0,3], nums2 = [2,4], k = 6
输出:0
解释:第 6 小的乘积计算如下:
- nums1[0] * nums2[1] = (-4) * 4 = -16
- nums1[0] * nums2[0] = (-4) * 2 = -8
- nums1[1] * nums2[1] = (-2) * 4 = -8
- nums1[1] * nums2[0] = (-2) * 2 = -4
- nums1[2] * nums2[0] = 0 * 2 = 0
- nums1[2] * nums2[1] = 0 * 4 = 0
第 6 小的乘积为 0 。

示例 3:

输入:nums1 = [-2,-1,0,1,2], nums2 = [-3,-1,2,4,5], k = 3
输出:-6
解释:第 3 小的乘积计算如下:
- nums1[0] * nums2[4] = (-2) * 5 = -10
- nums1[0] * nums2[3] = (-2) * 4 = -8
- nums1[4] * nums2[0] = 2 * (-3) = -6
第 3 小的乘积为 -6 。

 

提示:

  • 1 <= nums1.length, nums2.length <= 5 * 104
  • -105 <= nums1[i], nums2[j] <= 105
  • 1 <= k <= nums1.length * nums2.length
  • nums1 和 nums2 都是从小到大排好序的。

一题三解(双指针、解不等式、前缀和)

解法框架:二分查找

做过类似题目(668. 乘法表中第k小的数 或者 719. 找出第 k 小的距离对)的不难发现这题的解法是二分查找。首先令 $f(x) =$ 满足 $nums_1[i] * nums_2[j] \le x$ 的数对个数,显然 $f(x)$ 是关于 $x$ 递增的,因此可以进行二分查找,找到第一个满足 $f(x) \ge k$ 的 $x$ 即可。

下面的给出三种求 $f(x)$ 的解法。

解法一:双指针 — 分类讨论

在类似题目 668. 乘法表中第k小的数719. 找出第 k 小的距离对 中,经典的解法是双指针,将单次 check 的时间复杂度降低到了 $O(n)$。那么这个题目呢?

首先,我们把 $nums_1$ 分成 $neg_1$ 和 $pos_1$,分别表示 $nums_1$ 的 负数部分非负数部分
把 $nums_2$ 分成 $neg_2$ 和 $pos_2$,分别表示 $nums_2$ 的 负数部分非负数部分

一图胜千言,下面用一幅图来解释双指针遍历的各种边界条件。

  • 情形一:$nums_1[i] \ge 0$, $nums_2[j] \ge 0$,分别对应 $pos_1$ 和 $pos_2$;

  • 情形二:$nums_1[i] < 0$, $nums_2[j] \ge 0$,分别对应 $neg_1$ 和 $pos_2$;

  • 情形三:$nums_1[i] \ge 0$, $nums_2[j] < 0$,分别对应 $pos_1$ 和 $neg_2$;

  • 情形四:$nums_1[i] < 0$, $nums_2[j] < 0$,分别对应 $neg_1$ 和 $neg_2$。

image.png

代码

###cpp

class Solution {
public:
    long long kthSmallestProduct(vector<int>& nums1, vector<int>& nums2, long long k) {
        vector<int> neg1, pos1, neg2, pos2;
        for(int v : nums1) (v < 0)? neg1.push_back(v) : pos1.push_back(v);
        for(int v : nums2) (v < 0)? neg2.push_back(v) : pos2.push_back(v);

        long long l = -1e10, r = 1e10;
        while(l < r) {
            long long m = (l + r) >> 1;
            long long cur = 0;
            for(int i = 0, j = (int)pos2.size() - 1; i < pos1.size(); ++i) {
                while(j >= 0 && 1ll * pos1[i] * pos2[j] > m) --j;
                cur += j + 1;
            }
            for(int i = 0, j = 0; i < neg1.size(); ++i) {
                while(j < pos2.size() && 1ll * neg1[i] * pos2[j] > m) ++j;
                cur += (int)pos2.size() - j;
            }
            for(int i = 0, j = 0; i < pos1.size(); ++i) {
                while(j < neg2.size() && 1ll * pos1[i] * neg2[j] <= m) ++j;
                cur += j;
            }
            for(int i = 0, j = (int)neg2.size() - 1; i < neg1.size(); ++i) {
                while(j >= 0 && 1ll * neg1[i] * neg2[j] <= m) --j;
                cur += (int)neg2.size() - 1 - j;
            }
            if(cur < k) l = m + 1;
            else r = m;
        }

        return l;
    }
};

如果不想思考那么多复杂的边界条件,那么下面这种双指针方法的思考量较小,可以一试。

解法一点五:双指针 — 等价转换

上面的分类讨论之所以麻烦,关键在于数字有正有负,需要分 4 种情况讨论。如果我们可以将 4 种情况都转化为一种情况呢?
答案是可以。

  • 如果 $nums_1[i] < 0, nums_2[j] \ge 0$,则 $nums_1[i] \times nums_2[j] \le x$
    等价于 $(-nums_1[i]) \times nums_2[j] \ge x$
  • 如果 $nums_1[i] \ge 0, nums_2[j] < 0$,则 $nums_1[i] \times nums_2[j] \le x$
    等价于 $nums_1[i] \times (-nums_2[j]) \ge x$
  • 如果 $nums_1[i] \ge 0, nums_2[j] < 0$,则 $nums_1[i] \times nums_2[j] \le x$
    等价于 $(-nums_1[i]) \times (-nums_2[j]) \le x$

这样我们就将有负数的情形二、三、四转化为全是非负整数的情形一了。注意,由于负数取反后,大小关系发生了逆转,所以需要将数组反转,以保持递增的顺序。

代码

###c++

class Solution {
public:
    long long calc(vector<int>& a, vector<int>& b, long long x, bool greater) {
        long long res = 0;
        if(!a.size() || !b.size()) return 0;
        for(int i = 0, j = (int)b.size() - 1, k = (int)b.size() - 1; i < a.size(); ++i) {
            while(j >= 0 && 1ll * a[i] * b[j] > x) --j;
            while(k >= 0 && 1ll * a[i] * b[k] >= x) --k;
            if(greater) res += (int)b.size() - 1 - k;
            else res += j + 1;
        }
        return res;
    }
    long long kthSmallestProduct(vector<int>& nums1, vector<int>& nums2, long long k) {
        vector<int> neg1, pos1, neg2, pos2;
        for(int v : nums1) (v < 0)? neg1.push_back(-v) : pos1.push_back(v);
        for(int v : nums2) (v < 0)? neg2.push_back(-v) : pos2.push_back(v);
        reverse(neg1.begin(), neg1.end());
        reverse(neg2.begin(), neg2.end());

        long long l = -1e10, r = 1e10;
        while(l < r) {
            long long m = (l + r) >> 1, cur = 0;
            cur += calc(pos1, pos2, m, false);
            cur += calc(neg1, pos2, -m, true);
            cur += calc(pos1, neg2, -m, true);
            cur += calc(neg1, neg2, m, false);
            if(cur < k) l = m + 1;
            else r = m;
        }

        return l;
    }
};

解法二:解不等式 + 嵌套二分查找

我们可以首先在 $nums_1$ 枚举数字 $a$,然后找出 $nums_2$ 中满足 $ab \le x$ 的数字的 $b$ 的个数即可。

如果 $a > 0$,则不等式 $ab \le x$ 成立的条件是 $\displaystyle{b \le \left\lfloor \frac{x}{a} \right\rfloor}$(向下取整);

如果 $a < 0$,则不等式 $ab \le x$ 成立的条件是 $\displaystyle{b \ge \left\lceil \frac{x}{a} \right\rceil}$(向上取整);

$a = 0$ 的情况比较特殊,此时若 $x \ge 0$,则 $ab \le x$ 恒成立;否则 $ab \le x$ 恒不成立。

由于 $nums_2$ 已经排好序,故我们对于 $nums_1$ 中的每个 $a$,只需要在 $nums_2$ 中二分查找小于等于(或大于等于)$x$ 的数量即可。

细节 + 小知识

如何实现向下取整呢?直接用整数除法 $\texttt{a/b}$ 不行吗?不幸的是,实际上 C/C++ 的除法实现是 向 0 取整 的。例如 $\texttt{-1 / 2}$ 的值是 $0$,而不是 $-1$。因此需要稍微改变一下思路。
思路一:采用浮点数 + floor 库函数实现向下取整;顺带用 ceil 库函数实现向上取整。

###c++

long long floorDiv(long long x, long long y) {
    return floor(x / (double)y + 1e-7);
}
long long ceilDiv(long long x, long long y) {
    return ceil(x / (double)y - 1e-7);
}

思路二:避免浮点数运算的实现:

###c++

long long floorDiv(long long x, long long y) {
    if(y < 0) x = -x, y = -y;
    if(x < 0) return (x - (y - 1)) / y;
    return x / y;
}
long long ceilDiv(long long x, long long y) {
    if(y < 0) x = -x, y = -y;
    if(x < 0) return x / y;
    return (x + (y - 1)) / y;
}

最终代码

class Solution {
public:
    long long floorDiv(long long x, long long y) {
        if(y < 0) x = -x, y = -y;
        if(x < 0) return (x - (y - 1)) / y;
        return x / y;
    }
    long long ceilDiv(long long x, long long y) {
        if(y < 0) x = -x, y = -y;
        if(x < 0) return x / y;
        return (x + (y - 1)) / y;
    }
    long long kthSmallestProduct(vector<int>& nums1, vector<int>& nums2, long long k) {
        long long l = -1e10, r = 1e10;
        while(l < r) {
            long long m = (l + r) >> 1;
            long long cur = 0;
            for(int v : nums1) {
                if(v < 0) {
                    cur += nums2.end() - lower_bound(nums2.begin(), nums2.end(), ceilDiv(m, v));
                } else if(v == 0) {
                    cur += (m >= 0)? nums2.size() : 0;
                } else {
                    cur += upper_bound(nums2.begin(), nums2.end(), floorDiv(m, v)) - nums2.begin();
                }
            }
            if(cur < k) l = m + 1;
            else r = m;
        }
        return l;
    }
};

解法三:前缀和

解法二中,我们也可以不用嵌套二分查找。注意到 $-10^5 \le nums_1[i], nums_2[i] \le 10^5$,我们完全可以开辟足够大的空间来统计 $nums_2$ 中各个数字的数量,然后采用前缀和的方式来统计 $nums_2$ 中 $\le x$ 的数字数目,这样可以免去二分查找。

代码

###c++

class Solution {
public:
    long long sums[200005] = {0};
    long long floorDiv(long long x, long long y) {
        if(y < 0) x = -x, y = -y;
        if(x < 0) return (x - (y - 1)) / y;
        return x / y;
    }
    long long ceilDiv(long long x, long long y) {
        if(y < 0) x = -x, y = -y;
        if(x < 0) return x / y;
        return (x + (y - 1)) / y;
    }
    long long kthSmallestProduct(vector<int>& nums1, vector<int>& nums2, long long k) {
        for(int v : nums2) sums[v + 100000]++;
        for(int i = 1; i <= 200000; ++i) sums[i] += sums[i-1];

        auto sum = [&](long long x) -> long long {
            if(x < -100000) return 0;
            if(x > 100000) return sums[200000];
            return sums[x + 100000];
        };

        long long l = -1e10, r = 1e10;
        while(l < r) {
            long long m = (l + r) >> 1;
            
            long long cnt = 0;
            for(int v : nums1) {
                if(v < 0) cnt += sum(100000) - sum(ceilDiv(m, v) - 1);
                if(v == 0 && m >= 0) cnt += nums2.size();
                if(v > 0) cnt += sum(floorDiv(m, v));
            }

            if(cnt < k) l = m + 1;
            else r = m;
        }

        return l;
    }
};

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

第 $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站@灵茶山艾府

二分套二分

题解

通过二分查找第 $k$ 小的乘积 $p$,每次判定时枚举 nums1 中的数 $a$,通过二分再次判断 nums2 中有几个数 $b$ 满足 $ab \le p$。注意需要对 $a > 0$,$a < 0$ 和 $a = 0$ 三种情况分别讨论。复杂度 $\mathcal{O}(n\log n\log A)$。

代码

###c++

class Solution {
    vector<int> A, B;
    
    long long gao(long long lim) {
        long long ret = 0;
        for (long long x : A) {
            if (x > 0) {
                if (x * B[0] > lim) continue;
                int head = 0, tail = B.size() - 1;
                while (head < tail) {
                    int mid = (head + tail + 1) >> 1;
                    if (x * B[mid] <= lim) head = mid;
                    else tail = mid - 1;
                }
                ret += head + 1;
            } else if (x < 0) {
                if (x * B[B.size() - 1] > lim) continue;
                int head = 0, tail = B.size() - 1;
                while (head < tail) {
                    int mid = (head + tail) >> 1;
                    if (x * B[mid] <= lim) tail = mid;
                    else head = mid + 1;
                }
                ret += B.size() - head;
            } else if (lim >= 0) ret += B.size();
        }
        return ret;
    }
    
public:
    long long kthSmallestProduct(vector<int>& nums1, vector<int>& nums2, long long k) {
        A = nums1; B = nums2;
        long long head = -1e11, tail = 1e11;
        while (head < tail) {
            long long mid = (head + tail) >> 1;
            long long x = gao(mid);
            if (x >= k) tail = mid;
            else head = mid + 1;
        }
        return head;
    }
};

找出数组中的所有 K 近邻下标

方法一:枚举

思路与算法

我们可以枚举所有的下标对 $(i, j)$,并判断是否满足 $\textit{nums}[j] = \textit{key}$ 且 $|i - j| \le k$。与此同时,我们用数组 $\textit{res}$ 维护所有 $K$ 近邻下标。如果上述两个条件均满足,则我们将 $i$ 添加进数组 $\textit{res}$ 中。

为了使得 $\textit{res}$ 中不含有重复下标,且按照递增顺序,我们可以先按递增顺序枚举 $i$,再枚举 $j$,并且每当 $i$ 被添加进 $\textit{res}$ 后,我们就终止内层循环,开始遍历下一个 $i$。最终,数组 $\textit{res}$ 即为符合要求的所有 $K$ 近邻下标,我们返回作为答案即可。

代码

###C++

class Solution {
public:
    vector<int> findKDistantIndices(vector<int>& nums, int key, int k) {
        vector<int> res;
        int n = nums.size();
        // 遍历数对
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < n; ++j) {
                if (nums[j] == key && abs(i - j) <= k) {
                    res.push_back(i);
                    break;   // 提前结束以防止重复添加
                }
            }
        }
        return res;
    }
};

###Python

class Solution:
    def findKDistantIndices(self, nums: List[int], key: int, k: int) -> List[int]:
        res = []
        n = len(nums)
        # 遍历数对
        for i in range(n):
            for j in range(n):
                if nums[j] == key and abs(i - j) <= k:
                    res.append(i)
                    break   # 提前结束以防止重复添加
        return res

###Java

class Solution {
    public List<Integer> findKDistantIndices(int[] nums, int key, int k) {
        List<Integer> res = new ArrayList<>();
        int n = nums.length;
        // 遍历数对
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < n; ++j) {
                if (nums[j] == key && Math.abs(i - j) <= k) {
                    res.add(i);
                    break; // 提前结束以防止重复添加
                }
            }
        }
        return res;
    }
}

###C#

public class Solution {
    public IList<int> FindKDistantIndices(int[] nums, int key, int k) {
        List<int> res = new List<int>();
        int n = nums.Length;
        // 遍历数对
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < n; ++j) {
                if (nums[j] == key && Math.Abs(i - j) <= k) {
                    res.Add(i);
                    break; // 提前结束以防止重复添加
                }
            }
        }
        return res;
    }
}

###Go

func findKDistantIndices(nums []int, key int, k int) []int {
    var res []int
n := len(nums)
// 遍历数对
for i := 0; i < n; i++ {
for j := 0; j < n; j++ {
if nums[j] == key && abs(i - j) <= k {
res = append(res, i)
break // 提前结束以防止重复添加
}
}
}
return res
}

func abs(x int) int {
    if x < 0 {
        return -x
    }
    return x
}

###C

int* findKDistantIndices(int* nums, int numsSize, int key, int k, int* returnSize) {
    int* res = (int*)malloc(numsSize * sizeof(int));
    int count = 0;
    // 遍历数对
    for (int i = 0; i < numsSize; ++i) {
        for (int j = 0; j < numsSize; ++j) {
            if (nums[j] == key && abs(i - j) <= k) {
                res[count++] = i;
                break; // 提前结束以防止重复添加
            }
        }
    }
    *returnSize = count;
    return res;
}

###JavaScript

var findKDistantIndices = function(nums, key, k) {
    const res = [];
    const n = nums.length;
    // 遍历数对
    for (let i = 0; i < n; ++i) {
        for (let j = 0; j < n; ++j) {
            if (nums[j] === key && Math.abs(i - j) <= k) {
                res.push(i);
                break; // 提前结束以防止重复添加
            }
        }
    }
    return res;
};

###TypeScript

function findKDistantIndices(nums: number[], key: number, k: number): number[] {
    const res: number[] = [];
    const n = nums.length;
    // 遍历数对
    for (let i = 0; i < n; ++i) {
        for (let j = 0; j < n; ++j) {
            if (nums[j] === key && Math.abs(i - j) <= k) {
                res.push(i);
                break; // 提前结束以防止重复添加
            }
        }
    }
    return res;
}

###Rust

impl Solution {
    pub fn find_k_distant_indices(nums: Vec<i32>, key: i32, k: i32) -> Vec<i32> {
        let mut res = Vec::new();
        let n = nums.len();
        // 遍历数对
        for i in 0..n {
            for j in 0..n {
                if nums[j] == key && (i as i32 - j as i32).abs() <= k {
                    res.push(i as i32);
                    break; // 提前结束以防止重复添加
                }
            }
        }
        res
    }
}

复杂度分析

  • 时间复杂度:$O(n^2)$,其中 $n$ 为数组 $\textit{nums}$ 的长度。即为遍历下标 $i, j$ 寻找目标下标的时间复杂度。

  • 空间复杂度:$O(1)$,输出数组不计入空间复杂度。

方法二:一遍遍历

思路与算法

我们不妨设数组 $\textit{nums}$ 的长度为 $n$。那么,对于任何一个满足 $\textit{nums}[j] = \textit{key}$ 的下标 $j$,闭区间 $[\max(0, j - k), \min(n - 1, j + k)]$ 区间内的所有下标均为 $K$ 近邻下标(此处取最大最小值是为了保证下标合法)。

那么,我们就可以通过一次遍历数组 $\textit{nums}$,找到所有满足 $\textit{nums}[j] = \textit{key}$ 的下标 $j$,并将对应区间内的整数添加进 $\textit{res}$ 即可。但这样仍然会导致可能有重复的下标被添加进答案数组。为此,我们可以用 $r$ 来表示当前未被判断过是否为 $K$ 近邻下标的最小下标。在遍历开始前,$r = 0$;每当遍历到符合条件的 $j$ 时,我们只需要将闭区间 $[\max(0, j - k), \min(n - 1, j + k)]$ 区间内的所有下标依次添加至 $\textit{res}$ 中即可,同时,我们将 $r$ 更新为 $\min(n - 1, j + k) + 1$。当遍历完成后,$\textit{res}$ 即为按递增顺序排序、且不重复的符合要求的所有 $K$ 近邻下标。

代码

###C++

class Solution {
public:
    vector<int> findKDistantIndices(vector<int>& nums, int key, int k) {
        vector<int> res;
        int r = 0;   // 未被判断过的最小下标
        int n = nums.size();
        for (int j = 0; j < n; ++j) {
            if (nums[j] == key) {
                int l = max(r, j - k);
                r = min(n - 1, j + k) + 1;
                for (int i = l; i < r; ++i) {
                    res.push_back(i);
                }
            }
        }
        return res;
    }
};

###Python

class Solution:
    def findKDistantIndices(self, nums: List[int], key: int, k: int) -> List[int]:
        res = []
        r = 0   # 未被判断过的最小下标
        n = len(nums)
        for j in range(n):
            if nums[j] == key:
                l = max(r, j - k)
                r = min(n - 1, j + k) + 1
                for i in range(l, r):
                    res.append(i)
        return res

###Java

class Solution {
    public List<Integer> findKDistantIndices(int[] nums, int key, int k) {
        List<Integer> res = new ArrayList<>();
        int r = 0; // 未被判断过的最小下标
        int n = nums.length;
        for (int j = 0; j < n; ++j) {
            if (nums[j] == key) {
                int l = Math.max(r, j - k);
                r = Math.min(n - 1, j + k) + 1;
                for (int i = l; i < r; ++i) {
                    res.add(i);
                }
            }
        }
        return res;
    }
}

###C#

public class Solution {
    public IList<int> FindKDistantIndices(int[] nums, int key, int k) {
        List<int> res = new List<int>();
        int r = 0; // 未被判断过的最小下标
        int n = nums.Length;
        for (int j = 0; j < n; ++j) {
            if (nums[j] == key) {
                int l = Math.Max(r, j - k);
                r = Math.Min(n - 1, j + k) + 1;
                for (int i = l; i < r; ++i) {
                    res.Add(i);
                }
            }
        }
        return res;
    }
}

###Go

func findKDistantIndices(nums []int, key int, k int) []int {
    var res []int
r := 0 // 未被判断过的最小下标
n := len(nums)
for j := 0; j < n; j++ {
if nums[j] == key {
l := max(r, j - k)
r = min(n - 1, j + k) + 1
for i := l; i < r; i++ {
res = append(res, i)
}
}
}
return res
}

###C

int* findKDistantIndices(int* nums, int numsSize, int key, int k, int* returnSize) {
    int* res = (int*)malloc(numsSize * sizeof(int));
    int count = 0;
    int r = 0; // 未被判断过的最小下标
    for (int j = 0; j < numsSize; ++j) {
        if (nums[j] == key) {
            int l = fmax(r, j - k);
            r = fmin(numsSize - 1, j + k) + 1;
            for (int i = l; i < r; ++i) {
                res[count++] = i;
            }
        }
    }
    *returnSize = count;
    return res;
}

###JavaScript

var findKDistantIndices = function(nums, key, k) {
    const res = [];
    let r = 0; // 未被判断过的最小下标
    const n = nums.length;
    for (let j = 0; j < n; ++j) {
        if (nums[j] === key) {
            const l = Math.max(r, j - k);
            r = Math.min(n - 1, j + k) + 1;
            for (let i = l; i < r; ++i) {
                res.push(i);
            }
        }
    }
    return res;
};

###TypeScript

function findKDistantIndices(nums: number[], key: number, k: number): number[] {
    const res: number[] = [];
    let r = 0; // 未被判断过的最小下标
    const n = nums.length;
    for (let j = 0; j < n; ++j) {
        if (nums[j] === key) {
            const l = Math.max(r, j - k);
            r = Math.min(n - 1, j + k) + 1;
            for (let i = l; i < r; ++i) {
                res.push(i);
            }
        }
    }
    return res;
};

###Rust

impl Solution {
    pub fn find_k_distant_indices(nums: Vec<i32>, key: i32, k: i32) -> Vec<i32> {
        let mut res = Vec::new();
        let mut r = 0; // 未被判断过的最小下标
        let n = nums.len();
        for j in 0..n {
            if nums[j] == key {
                let l = r.max(j as i32 - k);
                r = (n as i32 - 1).min(j as i32 + k) + 1;
                for i in l..r {
                    res.push(i);
                }
            }
        }
        res
    }
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 为数组 $\textit{nums}$ 的长度。即为遍历数组统计所有目标下标的时间复杂度。

  • 空间复杂度:$O(1)$,输出数组不计入空间复杂度。

❌