LeetCode 第 390 场周赛
- 1. [每个字符最多出现两次的最长子字符串](https://leetcode.cn/problems/maximum-length-substring-with-two-occurrences/)
- 2. [执行操作使数据元素之和大于等于 K](https://leetcode.cn/problems/apply-operations-to-make-sum-of-array-greater-than-or-equal-to-k/)
- 3. [最高频率的 ID](https://leetcode.cn/problems/most-frequent-ids/)
- 4. [最长公共后缀查询](https://leetcode.cn/problems/longest-common-suffix-queries/)
第 390 场周赛 - 力扣(LeetCode)
1. 每个字符最多出现两次的最长子字符串
给你一个字符串 s
,请找出满足每个字符最多出现两次的最长子字符串,并返回该
子字符串的 最大 长度。
示例 1:
输入: s = “bcbbbcba”
输出: 4
解释:
以下子字符串长度为 4,并且每个字符最多出现两次:"bcbb(bcba)"
。
示例 2:
输入: s = “aaaa”
输出: 2
解释:
以下子字符串长度为 2,并且每个字符最多出现两次:"(aa)aa"
。
提示:
2 <= s.length <= 100
s
仅由小写英文字母组成。
周赛第一第二题一般都是打卡题,本题也不例外,双指针+滑动窗口即可解决。只不过应该熟悉滑动窗口指向边界的两个指针实际的读入和弹出时机,有些用户喜欢半开半闭区间,有些用户喜欢全闭区间。两种方法各有优劣,使用半开半闭区间可以使得窗口内不包含任何元素,使用全闭区间窗口内至少有一个元素。这题我使用的是全闭区间 p , q p,\ q p, q 指向同一个元素的时候,窗口的范围是 [ p , q ] [p,\ q] [p, q] ,也就是说两个指针指向的同一个元素是唯一有效的元素,可以这么理解, p p p 指向窗口内最旧的有效元素, q q q 指向窗口内最新的有效元素,窗口内至少有一个字符,因为本题的有效答案中不会包括空字符串,如果空字符串也是本题的一个有效答案那么就不能用这种全闭区间的窗口方式。其中 p p p 先对指向字符在统计哈希表中的计数减一,然后前移一个位置, q q q 则是先前移一个位置,然后对指向位置字符的统计量加一。在每次大循环中,总是 q q q 先前进,直到发现一个字符的计数量大于 2 了,移动 p p p 删减字符计数,直到 q q q 所指向的字符统计量合法(小于等于 2),这时候窗口范围 [ p , q ] [p,\ q] [p, q] 的长度就是一个合法子串的长度,和维护的最长子串长度进行比较并取最大值。
- 我们每次扩大窗口范围的时候,都要和已经维护的最长子串长度进行比较维护
- 子串不合法的时候,再移动 p p p 使得子串合法,然后继续步骤 1
class Solution {
public:
int maximumLengthSubstring(string s) {
int p = 0;
int q = 0;
int len = s.length();
int ans = 0;
map<char, int> charCnt;
while (q < len) {
charCnt[s[q]]++;
while (p < q && charCnt[s[q]] > 2) {
charCnt[s[p]]--;
p++;
}
ans = max(ans, q-p+1);
q++;
}
return ans;
}
};
2. 执行操作使数据元素之和大于等于 K
给你一个正整数 k
。最初,你有一个数组 nums = [1]
。
你可以对数组执行以下 任意 操作 任意 次数(可能为零):
- 选择数组中的任何一个元素,然后将它的值 增加
1
。 - 复制数组中的任何一个元素,然后将它附加到数组的末尾。
返回使得最终数组元素之 和 大于或等于 k
所需的 最少 操作次数。
示例 1:
输入: k = 11
输出: 5
解释:
可以对数组 nums = [1]
执行以下操作:
- 将元素的值增加
1
三次。结果数组为nums = [4]
。 - 复制元素两次。结果数组为
nums = [4,4,4]
。
最终数组的和为 4 + 4 + 4 = 12
,大于等于 k = 11
。
执行的总操作次数为 3 + 2 = 5
。
示例 2:
输入: k = 1
输出: 0
解释:
原始数组的和已经大于等于 1
,因此不需要执行操作。
提示:
1 <= k <= 10^5
一看就是数学题啊,而且很容易去贪心。不过对数学题不太敏感的同学也可能想到去 DFS ,也不是不行,就是每次使用 DFS 之前都要比较小心 DFS 的空间复杂度,因为比较难计算,DFS 在图论中虽然时间复杂度是 O ( n ) O(n) O(n) 的(每个节点只访问一次),但是空间复杂度不一定,因为是以栈递归的方式消耗空间的。
本题的贪心思路也是比较容易逆向想到的(毕竟是第二题嘛)。首先通过操作一自增到合适的基数,然后再使用操作二使得基数复制为多个,其实就相当于倍数吧。所以我们现在的问题就是如何找到合适的基数,我们需要找到一个平衡点:构造基数的花费和基数翻倍的花费。我们可以从目标 k k k 开始,遍历所有可能的基数,比如 k 1 , k 2 , k 3 , … \frac{k}{1},\ \frac{k}{2},\ \frac{k}{3},\ \dots 1k, 2k, 3k, … 注意,由于整数除法的问题,我们必须使用向上整除而不是语言默认的向下整除,因为题目的要求是 “使得最终数组元素之和 大于等于 k k k ”,因此我们的基数翻倍之后可以超过或者等于 k k k ,但是绝对不能低于 k k k 。比如 6 ÷ 4 = 1.5 → 2 6\div4=1.5 \rightarrow 2 6÷4=1.5→2 才行。向下整除是语言自带的特性,向上整除的公式则需要平常积累记忆: ( a − 1 ) / b + 1 (a-1)/b+1 (a−1)/b+1 。因此我们可以得到每个基数的花费:
- 构造基数的花费 x − 1 x-1 x−1
- 基数翻倍的花费
k
x
−
1
\frac{k}{x} - 1
xk−1
这里的 − 1 -1 −1 是因为基数的构造是从 1 开始的,然后翻倍的时候数组本身已经有一个基数了。
class Solution {
public:
int minOperations(int k) {
// ceil: (a-1)/b+1
// building + copying
int ans = INT_MAX;
for (int i = 1; i <= k; ++i) {
ans = min(ans, (k-1)/i+1-1 + i-1);
}
return ans;
}
};
3. 最高频率的 ID
你需要在一个集合里动态记录 ID 的出现频率。给你两个长度都为 n
的整数数组 nums
和 freq
,nums
中每一个元素表示一个 ID ,对应的 freq
中的元素表示这个 ID 在集合中此次操作后需要增加或者减少的数目。
- 增加 ID 的数目: 如果
freq[i]
是正数,那么freq[i]
个 ID 为nums[i]
的元素在第i
步操作后会添加到集合中。 - 减少 ID 的数目: 如果
freq[i]
是负数,那么-freq[i]
个 ID 为nums[i]
的元素在第i
步操作后会从集合中删除。
请你返回一个长度为 n
的数组 ans
,其中 ans[i]
表示第 i
步操作后出现频率最高的 ID 数目 ,如果在某次操作后集合为空,那么 ans[i]
为 0 。
示例 1:
输入: nums = [2,3,2,1]
, freq = [3,2,-3,1]
输出: [3,3,2,2]
解释:
第 0 步操作后,有 3 个 ID 为 2 的元素,所以 ans[0] = 3
。
第 1 步操作后,有 3 个 ID 为 2 的元素和 2 个 ID 为 3 的元素,所以 ans[1] = 3
。
第 2 步操作后,有 2 个 ID 为 3 的元素,所以 ans[2] = 2
。
第 3 步操作后,有 2 个 ID 为 3 的元素和 1 个 ID 为 1 的元素,所以 ans[3] = 2
。
示例 2:
输入: nums = [5,5,3]
, freq = [2,-2,1]
输出: [2,0,1]
解释:
第 0 步操作后,有 2 个 ID 为 5 的元素,所以 ans[0] = 2
。
第 1 步操作后,集合中没有任何元素,所以 ans[1] = 0
。
第 2 步操作后,有 1 个 ID 为 3 的元素,所以 ans[2] = 1
。
提示:
1 <= nums.length == freq.length <= 10^5
1 <= nums[i] <= 10^5
-105 <= freq[i] <= 10^5
freq[i] != 0
- 输入保证任何操作后,集合中的元素出现次数不会为负数。
简单记录一下遇到这题的时候闪过的一些想法:
- LFU 变例?好像也不是,没有时间维度上的要求并且每次频次的变更不是 1 ,不过硬要用 LFU 来做也不是不行,就是感觉有点杀鸡用牛刀了,LFU 对于这类修改元素频次的题都是通用的。
- 优先队列?每次修改元素的频次的时候,先删除优先队列里该元素,然后再添加该元素的新频次为键的二元组到优先队列里面自动排序?好像也不行,因为题目要求得到最高频次下元素的数量,可是优先队列没有“桶”的概念呀,堆顶只有最大频次的那个元素,虽然可以通过逐个弹出元素来统计有多少个元素和堆顶的频次一样,但是不是还要重新插回去?
- 看来只能使用类似 LFU 的结构了。LFU 相比于 LRU 复杂的地方在于双哈希表,一个哈希表维护键到频次的关系,一个哈希表维护频次到桶的关系,桶里面存放了具有相同频次的元素。根据题目的要求的不同,“桶”的实际实现也会不同,比如要求具有时间维度的 LFU ,则桶里面的元素仍然需要保持添加和弹出的顺序,如果还要求是 O ( 1 ) O(1) O(1) 的头尾添加和删除,那么桶的结构只能是双向链表了。
本题应该使用类似 LFU 双哈希表维护键到频次和频次到桶的关系。然后主要就是一个模拟题,由于每次修改元素的频次时,增加或减少的频次非零,因为元素是按照频次聚集在一个桶中的,所以只要元素的频次变更了,那么元素一定会转移到其他的桶中,因此我们先判断该元素是否存在于我们的缓存中,如果存在则说明一定在某个桶中,然后从该桶中删除该元素,并将该元素添加到对应的频次的桶。注意从一个桶中删除了元素之后,这个桶可能就是空的了,要把这个桶删除掉,否则比如当这个桶刚好是具有最大频次的桶,但是桶里面元素是空的,我们每次完成元素频次变更之后,都要查询具有最大频次的非空桶,那么这个空桶会污染我们的查询,查询到一个频次虽然最大但是桶内没有任何元素的空桶。
再考虑需要使用到的数据结构,由于键到频次的映射没有排序的需求,因此直接使用哈希表就好了,但是桶每次需要求最大的频次桶,有排序需求,则桶的键就是桶的频次,并且排序就直接使用平衡二叉查找树(AVL或者红黑树)。
class Solution {
typedef long long ll;
private:
unordered_map<int, ll> ktof; // key to freq
map<ll, unordered_set<int>> ftob; // freq to bucket
void insert(int key, ll times) {
if (ktof.count(key)) {
ll freq = ktof[key];
// 由于变更频率不为零,所以一定会从原来的桶中删除
// 加入到新的频次桶,如果原来的桶空了记得删除
ktof.erase(key);
ftob[freq].erase(key);
if (ftob[freq].empty()) {
ftob.erase(freq);
}
ll new_freq = freq + times;
if (new_freq <= 0) return;
ktof[key] = new_freq;
ftob[new_freq].insert(key);
} else {
ktof[key] = times;
ftob[times].insert(key);
}
}
public:
vector<long long> mostFrequentIDs(vector<int>& nums, vector<int>& freq) {
// 无法使用优先队列解法,因为要求频次桶中元素的个数
// 优先队列只能求出频率最高的元素,也就是说只有一个
int len = nums.size();
vector<ll> ans(len);
for (int i = 0; i < len; ++i) {
insert(nums[i], freq[i]);
if (!ktof.empty()) {
ans[i] = ftob.rbegin()->first;
}
}
return ans;
}
};
4. 最长公共后缀查询
给你两个字符串数组 wordsContainer
和 wordsQuery
。
对于每个 wordsQuery[i]
,你需要从 wordsContainer
中找到一个与 wordsQuery[i]
有 最长公共后缀 的字符串。如果 wordsContainer
中有两个或者更多字符串有最长公共后缀,那么答案为长度 最短 的。如果有超过两个字符串有 相同 最短长度,那么答案为它们在 wordsContainer
中出现 更早 的一个。
请你返回一个整数数组 ans
,其中 ans[i]
是 wordsContainer
中与 wordsQuery[i]
有 最长公共后缀 字符串的下标。
示例 1:
**输入:**wordsContainer = ["abcd","bcd","xbcd"]
, wordsQuery = ["cd","bcd","xyz"]
输出:[1,1,1]
解释:
我们分别来看每一个 wordsQuery[i]
:
- 对于
wordsQuery[0] = "cd"
,wordsContainer
中有最长公共后缀"cd"
的字符串下标分别为 0 ,1 和 2 。这些字符串中,答案是下标为 1 的字符串,因为它的长度为 3 ,是最短的字符串。 - 对于
wordsQuery[1] = "bcd"
,wordsContainer
中有最长公共后缀"bcd"
的字符串下标分别为 0 ,1 和 2 。这些字符串中,答案是下标为 1 的字符串,因为它的长度为 3 ,是最短的字符串。 - 对于
wordsQuery[2] = "xyz"
,wordsContainer
中没有字符串跟它有公共后缀,所以最长公共后缀为""
,下标为 0 ,1 和 2 的字符串都得到这一公共后缀。这些字符串中, 答案是下标为 1 的字符串,因为它的长度为 3 ,是最短的字符串。
示例 2:
输入: wordsContainer = ["abcdefgh","poiuygh","ghghgh"]
, wordsQuery = ["gh","acbfgh","acbfegh"]
输出: [2,0,2]
解释:
我们分别来看每一个 wordsQuery[i]
:
- 对于
wordsQuery[0] = "gh"
,wordsContainer
中有最长公共后缀"gh"
的字符串下标分别为 0 ,1 和 2 。这些字符串中,答案是下标为 2 的字符串,因为它的长度为 6 ,是最短的字符串。 - 对于
wordsQuery[1] = "acbfgh"
,只有下标为 0 的字符串有最长公共后缀"fgh"
。所以尽管下标为 2 的字符串是最短的字符串,但答案是 0 。 - 对于
wordsQuery[2] = "acbfegh"
,wordsContainer
中有最长公共后缀"gh"
的字符串下标分别为 0 ,1 和 2 。这些字符串中,答案是下标为 2 的字符串,因为它的长度为 6 ,是最短的字符串。
提示:
1 <= wordsContainer.length, wordsQuery.length <= 10^4
1 <= wordsContainer[i].length <= 5 * 10^3
1 <= wordsQuery[i].length <= 5 * 10^3
wordsContainer[i]
只包含小写英文字母。wordsQuery[i]
只包含小写英文字母。wordsContainer[i].length
的和至多为5 * 10^5
。wordsQuery[i].length
的和至多为5 * 10^5
。
我一看答案的取值条件这么复杂,内心觉得可能是一道模拟题吧,这样子在技术深度上不会太深,考察用户的边界处理和细节处理能力?于是我先尝试了一把暴力,直接模拟每个字符串的最长后缀匹配,以及每个查询的答案维护。
class Solution {
private:
int longestSuffix(const string& searchd, const string& pattern) {
int lenSearchd = searchd.length();
int lenPattern = pattern.length();
int len = min(lenSearchd, lenPattern);
int p = lenSearchd - 1;
int q = lenPattern - 1;
int c = 0;
while (c < len && searchd[p] == pattern[q]) {
p--;
q--;
c++;
}
return c;
}
public:
vector<int> stringIndices(vector<string>& wordsContainer, vector<string>& wordsQuery) {
vector<int> ans(wordsQuery.size());
int lenContainer = wordsContainer.size();
int lenQuery = wordsQuery.size();
for (int i = 0; i < lenQuery; ++i) {
// 对于每一次查询需要新建的变量
int maxSuffixLen = 0;
int tmpSuffixLen = 0;
for (int j = 0; j < lenContainer; ++j) {
// 对于每一个查询字符串
tmpSuffixLen = longestSuffix(wordsContainer[j], wordsQuery[i]);
if (tmpSuffixLen > maxSuffixLen) {
maxSuffixLen = tmpSuffixLen;
ans[i] = j;
}
if (tmpSuffixLen == maxSuffixLen) {
if (wordsContainer[j].length() < wordsContainer[ans[i]].length()) {
ans[i] = j;
}
}
}
}
return ans;
}
};
这样子做确实能过 99% 的用例(TLE ,Time Limit Exceeded),在笔试中也许能取得一个不错的分数,但是这题的模拟复杂程度在笔试中最多作为第二题的难度,笔试的难度一般都是竞赛或者 CodeForces 。那么这题的真正解法是什么?构造逆向索引?记录每个后缀存在于哪些查询值中,记录查询值的下标到一个数组里面?然后对每个查询尝试不同长度的后缀(当然是从最长的后缀开始),如果发现了该后缀存在任何下标的话,使用该下标?
其实逆向索引的思维和一个数据结构的核心思路是一样的,只不过它不使用哈希表而已 —— Trie(字典树)。只要稍微了解过字典树的同学看到字典树的提示,估计五分钟之内就写完这题了……
使用字典树,首先把查询词逆向用于构建后缀字典树,并且在每个字符节点的值中维护该后缀的最佳查询词下标(根据题目的要求,具有相同最长公共后缀长度时,使用查询词长度最短的查询词,多个最短查询词长度,则使用在查询词数组中首先出现的那个查询词)。然后使用模式串在后缀字典树中进行搜索,返回最长的后缀(能到达的最深节点)的节点保存的最佳搜索词下标。
struct Node
{
int idx;
int len;
vector<Node*> child;
Node() {
child.resize(26);
idx = 0;
len = INT_MAX;
}
};
class Solution {
private:
Node* root;
void insert(const string& word, const int& wordIdx) {
Node* curr = root;
int wordLen = word.length();
if (wordLen < root->len) {
root->idx = wordIdx;
root->len = wordLen;
}
for (int i = wordLen - 1; i >= 0; --i) {
char c = word[i];
if (curr->child[c-'a'] == nullptr) {
curr->child[c-'a'] = new Node();
}
curr = curr->child[c-'a'];
if (wordLen < curr->len) {
curr->idx = wordIdx;
curr->len = wordLen;
}
}
}
int search(const string& word) {
Node* curr = root;
int wordLen = word.length();
for (int i = wordLen - 1; i >= 0; --i) {
char c = word[i];
if (curr->child[c-'a'] != nullptr) {
curr = curr->child[c-'a'];
} else {
break;
}
}
return curr->idx;
}
public:
vector<int> stringIndices(vector<string>& wordsContainer, vector<string>& wordsQuery) {
root = new Node();
for (int i = 0; i < wordsContainer.size(); ++i) {
insert(wordsContainer[i], i);
}
vector<int> ans;
for (const string& query : wordsQuery)
ans.emplace_back(search(query));
return ans;
}
};
这个版本使用数组作为字典树节点的孩子索引,但是目前这题的数据量来看,有些极端测试样例数据会导致这种开数组的行为浪费大量的无用空间,有些字母根本就从来不出现(是的,上面的版本会 MLE,Memory Limit Exceeded)。所以还是老实的换成哈希表吧……
struct Node
{
int idx;
int len;
unordered_map<char, Node*> child;
Node() {
idx = 0;
len = INT_MAX;
}
};