LeetCode156双周赛详解
到处都是知识点,学吧...
给你一个由小写英文字母('a' 到 'z')组成的字符串 s。你的任务是找出出现频率 最高 的元音('a'、'e'、'i'、'o'、'u' 中的一个)和出现频率最高的辅音(除元音以外的所有字母),并返回这两个频率之和。
暖场送分题,只贴代码
class Solution {
public:
int maxFreqSum(string s) {
vector<int> count(26, 0);
for(auto v : s) count[v - 'a']++; //统计每个字母的频率
string t = "aeiou";
int c0 = 0, c1 = 0;
for(int i = 0; i < 26; i++){
//如果是元音 则更新最大值
if(t.contains(i + 'a')) c0 = max(c0, count[i]);
//否则更新辅音最大值
else c1 = max(c1, count[i]);
}
return c0 + c1;
}
};
给你一个大小为 n 的 非负 整数数组 nums 。
在一次操作中,你可以选择一个子数组 [i, j],将该子数组中所有 最小的非负整数 的设为 0。
返回使整个数组变为 0 所需的最少操作次数。
输入: nums = [0,2]
输出: 1
输入: nums = [3,1,2,1]
输出: 3
输入: nums = [1,2,1,2,1,2]
输出: 4
提示:
1 <= n == nums.length <= 1e5
0 <= nums[i] <= 1e5
本题是个人最喜欢的题目类型,特点是
- 题面简单 不要有冗余的背景描述,也不要有过多的条件设定
- 思维困难 不要大模拟或者直来直去的套板子
- 多种解法 最好是有多种思路解法 更能融会贯通
感觉这种更适合提升个人水平
先挖掘一下题面,子数组所有最小非负整数设0
- 需要对这个子数组找出最小非负整数,然后把所有这些最小非负整数变为0。
最小整数可能有多个是这个问题的关键,比如[1,2,1,2,1,2]里起始最小的1有3个 - 每次操作后,由于最小值变为0,则下次操作必然无法再包含这个下标,因为0肯定是最小值,是无效操作。
也就是每操作一次,最小值的位置会出现截断,整个数组实际进行了分段。
如何选择子数组?由于是自由选择,首先应当考虑贪心。
一次操作中可能有多个最小值x,应贪心选择至少包含所有这些x的区间
假如没有完全包含,则将来还需要对剩余的x移除,会额外浪费操作。
可能的疑问:由于一次操作会产生分段,有没有可能不贪心选择所有x,后续处理更大的yz会更优呢?
比如[y,x,y]?
不可能,即使这个最小值当前轮不选择,后续x两侧的y依然无法在一次操作中归零。
每轮操作贪心是最优解。
- 解法1:分治
分治相当于递归模拟,相对来说容易理解,但是代码复杂。
具体的执行过程是:
1 对当前处理的区间[L,R],找出所有最小值,执行一次操作。
2 依据找出的最小值的位置,把当前区间分段,对于每一段,递归执行操作1。
初始处理区间为整个数组[0,n-1]。
容易走入的误区:每次操作时不只考虑最小值,试图遍历所有值。
注意所谓递归分治,就应当简化每次递归的处理,不然递归的意义在哪里。
然后我们考虑下复杂度,假如暴力处理:
每次从当前处理的[L,R]区间,遍历查找最小值的所有位置。
举个简单例子,[1,2,3,4,5,...,99999,100000],最终会从前往后处理,执行n轮,每轮会执行一次n的遍历
总体复杂度\(O(n^2)\) 这是无法接受的。
优化1:找区间最小值可以使用ST表或者线段树。这里不展开介绍这两个数据结构。
利用数据结构每次查找最小值的复杂度降为\(O(1)\)或\(O(logn)\)。
那么如何对最小值的所有位置截断呢?
暴力遍历复杂度依然是\(O(n)\)
优化2:需要先预处理所有值的下标列表,存到一个哈希套列表的结构里
比如对于[3,1,2,1],形成的结构是{1:[1,3], 2:[2], 3:[0]}
这个结构相当于把包含n个元素的列表拆成多组。
关于哈希套列表的关键理解:- 组的个数可能是\(O(n)\)级别
- 组内元素个数可能是\(O(n)\)级别
- 但是二重枚举组数和组内元素,却是\(O(n)\)复杂度。
即使到这一步,也容易走入一个误区:当找到最小值后,通过哈希查得下标列表,直接遍历这个下标列表。
这依然是有问题的。
反例:[1,2,1,2,...,1,2] \(n/2\)组[1,2]。
哈希套列表的结构为{1:[0,2,4,...k*2], 2:[1,3,5,...k*2-1]}
第一轮中最小值为1,递归到下一轮会出现\(n/2\)个分段,每个分段需要遍历\(n/2\)长度,复杂度飙升为\(O(n^2/4)\)
正确做法是二分当前要处理的列表,左侧不能低于区间左边界L,右侧不能高于区间右边界R 这也是哈希套列表的经典技巧
在这个有效区间中遍历,对当前区间[L,R]分治处理即可。
细节:递归下一层时新区间显然不应包含分界点,如果当前最小值已经是0了,不需要+1,否则一次递归+1。
递归出口即L > R
class Solution {
public:
int minOperations(vector<int>& nums) {
int n = nums.size();
unordered_map<int, vector<int>> all; //存储每个值对应的下标列表
for(int i = 0; i < n; i++) all[nums[i]].push_back(i);
int ans = 0;
SegmentTree tree(nums); //线段树模板略。只有一个功能 查询区间[l,r]的最小值
//分治
auto dfs = [&](auto&& self, int l, int r)->void {
if(l > r) return;
int mv = tree.Query(l, r); //当前区间最小值
auto& list = all[mv];
int pre = l;
//二分查找当前[l,r]区间的有效范围
int posL = lower_bound(list.begin(), list.end(), l) - list.begin();
int posR = upper_bound(list.begin(), list.end(), r) - list.begin();
for(int i = posL; i < posR; i++){
int v = list[i];
self(self, pre, v - 1); //从pre到当前位置分段
pre = v + 1;
}
self(self, pre, r); //最后的一个分段
if(mv > 0) ans++; //当前分段固定只增加一次
};
//从区间[0,n-1]开始处理
dfs(dfs, 0, n - 1);
return ans;
}
};
分治思路可能思维上难度小,但是代码较为复杂,能否直接从每个下标列表整体处理?
比如[2,1,2,2],2的下标列表是[0,2,3], 0 2中间有1显然不能合并,而2 3可以合并。
如果是[2,3,2],则2都可以合并,这中间区别是2中间夹的是更大数还是更小数。
我们需要获取这一关键信息。
考虑正难则反。
从大到小,由于更大的数不会影响更小的数,这是一个优势。
如何处理?
考虑[1,2,3,3,2,1],显然两个3是可以合并的,能合并的原因是什么?
是因为3的下标[1,2]是连续的
之后2也能合并,但是下标[0,3]是不连续的,如何转化?当3处理完成后,能简单把3变成2就好了。
如[1,2,2,2,2,1],显然2可以合并处理。1也同理。
处理方式有多种。
- 解法2:正难则反 + 线段树
具体处理如下:
1 每一轮操作中,枚举下标列表,首个下标操作+1
2 之后每个下标需要跟前面的下标进行连续性判定。
由于我们可以标记已处理过的大数
此时判断两个下标之间的标记数量是否等于下标距离即可。
3 标记当前轮次处理的所有下标。继续执行下一轮的流程1
标记过程对应线段树的单点修改(单点修改为1),查询区间下标数量对应线段树的区间求和。
class Solution {
public:
int minOperations(vector<int>& nums) {
int n = nums.size();
map<int, vector<int>> all;
for(int i = 0; i < n; i++) all[nums[i]].push_back(i);
int ans = 0;
SegmentTree tree(n); //线段树模板略 需要支持单点修改 区间求和
//倒序遍历
for(auto it = all.rbegin(); it != all.rend(); it++){
if(it->first == 0) break; //0不需要操作
auto& list = it->second;
for(int i = 0; i < list.size(); i++){
if(i == 0 || tree.Sum(list[i - 1] + 1, list[i] - 1) < list[i] - list[i - 1] - 1) ans++;
tree.Update(list[i], 1); //注意这里同步修改了list[i],对应下轮的list[i-1],所以上面区间计算要小心。清晰的写法不如分开再遍历一次。
}
}
return ans;
}
};
- 解法3:正难则反 + 并查集
思路同上,连续性区间问题常用并查集处理
具体的,维护每个下标与下一个相邻位置指向。最终所有元素均指向自己可合并的最右侧位置。
如[1,2,2,1],初始每个元素均指向自己,枚举2时,发现下一个位置是也是2,则指向最右侧的2。处理完2之后,最后指向到下一个不是2的位置,表示已消除。
当枚举1时,发现下一个下标指向到最后一个1,依然是可合并的。
class Solution {
public:
int minOperations(vector<int>& nums) {
int n = nums.size();
UnionFind uf(n);
map<int, vector<int>> all;
for(int i = 0; i < n; i++) all[nums[i]].push_back(i);
int ans = 0;
for(auto it = all.rbegin(); it != all.rend(); it++){
if(it->first == 0) break;
auto& list = it->second;
for(int i = 0; i < list.size(); i++){
if(list[i] == n - 1 || uf.Find(list[i]) == n) ans++;
else{
if(it->first == nums[uf.Find(list[i] + 1)]) uf.Connect(list[i], list[i] + 1);
else{
uf.Connect(list[i], uf.Find(list[i]) + 1);
ans++;
}
}
}
}
return ans;
}
};
注意本题作为第二题,以上解法在LeetCode周赛上是有点过于难了。
- 解法4:本题最优解单调栈
一道不明显的单调栈放T2也是略微离谱
为什么是单调栈?
通过上面对题意的挖掘,至少有一个这样的感性认识
每个小数必然分割更大的数
每个小数需要等待可能相同的小数合并
类似这种含义的题目往往可以使用单调栈。
具体的:
1 枚举每个下标,如果如果比栈内的值更大,入栈
2 如果比栈内值要小,意味着站内数据至多处理到当前位置,操作次数要结算,退栈直到栈内递增。
3 最后栈内剩余也要结算,实际数量就是操作数
再次说明为什么栈内是递增顺序?因为小数碰到更大数需要等待下一个可能出现的小数合并。而大数碰到小数显然是遇到边界出栈。
class Solution {
public:
int minOperations(vector<int>& nums) {
int ans = 0;
stack<int> st;
for(auto v : nums){
while(st.size() > 0 && st.top() > v){
st.pop();
ans++;
}
if(v == 0) continue; //0的特殊性
if(st.size() == 0 || st.top() < v) st.push(v); //相同数字不必入栈,否则出栈时还需要去重
}
return ans + st.size();
}
};
给你一个整数 n 和一个包含 n 个节点(编号从 0 到 n - 1)的 有向无环图(DAG)。该图由二维数组 edges 表示,其中 edges[i] = [ui, vi, wi] 表示一条从节点 ui 到 vi 的有向边,边的权值为 wi。
同时给你两个整数 k 和 t。
你的任务是确定在图中边权和 尽可能大的 路径,该路径需满足以下两个条件:
路径包含 恰好 k 条边;
路径上的边权值之和 严格小于 t。
返回满足条件的一个路径的 最大 边权和。如果不存在这样的路径,则返回 -1。
所以为什么说这场题目值得写呢,都是典题。
这题读完题看各维度数据范围,边个数300,路径上限300,权值和600,直接刷表计算,大致5e7的计算量理论可以直接过题。
思维难度不是特别大。
但是这里有点卡常,所以需要一点优化。
- 解法1: 拓扑排序
需求恰好k条边,符合条件的必然是图上的一条有向路径,这里以终点递推容易计算。
即考虑 f[i][j][k]表示以节点i结尾,长度为j,权值和为k是否存在
那么当枚举到u->v边时,有f[v][j+1][k+w] = f[u][j][k]。
注意,DAG中实际上一个节点v可能有多个父节点u。
所以必须要处理完v的所有父节点u,才能处理v的子节点,否则会遗漏。
这对应了拓扑排序,题目点出是DAG未尝不是一种暗示
具体的,只需要按照拓扑排序依次处理每个节点,
对于u的每个子节点,增量更新子节点的数据。
如果直接使用三维数组,会超时(TLE),有两种优化方式:
优化1 权值和这个维度要改为哈希表。
因为每个节点不可能包含所有[1,t]的权值和,所以第三个维度使用哈希表而不是数组可以减少大量冗余遍历。
优化2 使用bitset优化,这个放最后说。
class Solution {
public:
int maxWeight(int n, vector<vector<int>>& edges, int k, int t) {
vector<unordered_map<int, int>> g(n);
vector<int> deg(n);
for(auto& e : edges) {
g[e[0]][e[1]] = e[2];
deg[e[1]]++;
}
//f[u][k]表示以u为终点,长度为k 可能出现的权重和
vector<vector<unordered_set<int>>> f(n, vector<unordered_set<int>>(k + 1));
queue<int> que;
for(int i = 0; i < n; i++) if(!deg[i]) que.push(i);
int ans = -1;
while(!que.empty()){
int u = que.front(); que.pop();
f[u][0].insert(0);
for(auto [v, w] : g[u]){
if(w >= t) continue;
for(int i = 0; i < k; i++){
for(auto j : f[u][i]){
if(j + w < t) f[v][i + 1].insert(j + w);
}
}
deg[v]--;
if(!deg[v]) que.push(v);
}
}
for(int i = 0; i < n; i++) for(auto v : f[i][k]) ans = max(ans, v);
return ans;
}
};
- 解法2:一般图dp
思路有点像floyd求最短路。
按照链长从小到大枚举,然后再枚举所有的边
显然容易得到下一个链长的数据。
即考虑 f[i][j][k]表示长度为i,以节点j结尾,权值和为k是否存在
注意与解法1拓扑排序处理里,i和j顺序相反。
同样第三个维度用哈希表优化。
class Solution {
public:
int maxWeight(int n, vector<vector<int>>& edges, int k, int t) {
vector f(k + 1, vector<unordered_set<int>>(n));
for (int i = 0; i < n; i++) f[0][i].insert(0);
for (int i = 0; i < k; i++) {
for (auto& e : edges) {
for (int s : f[i][e[0]]) {
if (s + e[2] < t) {
f[i + 1][e[1]].insert(s + e[2]);
}
}
}
}
int ans = -1;
for(int i = 0; i < n; i++) for(auto v : f[k][i]) ans = max(ans, v);
return ans;
}
};
- 关于bitset优化
上面提到第三个维度需要使用哈希表优化常数,另一个更高级的做法是使用bitset优化。
在纯bool值的线性递推中,可以使用bitset把复杂度降低一个w常数,w一般是32或者64,即复杂度除w。
首先给一个介绍bitset的典题416. 分割等和子集
这题是01背包基本形,标准解如下(包含空间优化):
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for(auto v : nums) sum += v;
if(sum % 2 == 1) return false;
sum /= 2;
vector<int> f(sum + 1);
//核心处理
f[0] = 1;
for(auto x : nums) for(int v = sum; v >= x; v--) f[v] |= f[v - x];
return f[sum];
}
};
典型的bool形递推式。
所谓bitset,就是用每一位表示某个数字是否存在,不妨设w=32,则一个整数可以表示32个数字是否存在。
假设左边为低位
[1,0,0,1]表示数字[0,3]是有效值,数字[1,2]不存在。
如果此时加入数值2,则此次会加入[2,5],最终bitset内部的数据变为
[1,0,1,1,0,1]
一般语言都有对应的库实现。以下使用C++里的bitset的优化代码
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for(auto v : nums) sum += v;
if(sum % 2 == 1) return false;
sum /= 2;
//定义
bitset<20000> f;
f[0] = 1;
for(auto x : nums) f |= f << x; //关键
return f[sum];
}
};
再深入说明下bitset的原理,其实并不复杂。
用vector
此时如果增加x,也就是原值向高位移动x位,需要跨越的下标数 block = x / 64
目标块内偏移 offset = x % 64
另外注意,由于移动位数未必是64的整数倍,所以目标块的下一块也会有个额外处理。
综合起来,如果计算f |= f << x,不妨创建个临时的g,则有:
另外举个例子是3181. 执行操作可获得的最大总奖励 II
代码量并不大,只因为必须使用bitset优化,很多人不会,rating直接到了2688
回到本题,解法2 一般图dp的bitset优化代码
class Solution {
public:
int maxWeight(int n, vector<vector<int>>& edges, int k, int t) {
vector f(k + 1, vector<bitset<600>>(n));
for (int i = 0; i < n; i++) f[0][i] |= 1;
for (int i = 0; i < k; i++) {
for (auto& e : edges) {
f[i + 1][e[1]] |= f[i][e[0]] << e[2];
}
}
int ans = -1;
for(int i = 0; i < n; i++){
for(int j = t - 1; j >= 0; j--){
if(f[k][i][j]) {
ans = max(ans, j);
}
}
}
return ans;
}
};
【Q4】3544. 子树反转和
给你一棵以节点 0 为根节点包含 n 个节点的无向树,节点编号从 0 到 n - 1。该树由长度为 n - 1 的二维整数数组 edges 表示,其中 edges[i] = [ui, vi] 表示节点 ui 和 vi 之间有一条边。
同时给你一个整数 k 和长度为 n 的整数数组 nums,其中 nums[i] 表示节点 i 的值。
你可以对部分节点执行 反转操作 ,该操作需满足以下条件:
子树反转操作:
当你反转一个节点时,以该节点为根的子树中所有节点的值都乘以 -1。
反转之间的距离限制:
你只能在一个节点与其他已反转节点“足够远”的情况下反转它。
具体而言,如果你反转两个节点 a 和 b,并且其中一个是另一个的祖先(即 LCA(a, b) = a 或 LCA(a, b) = b),那么它们之间的距离(它们之间路径上的边数)必须至少为 k。
返回应用 反转操作 后树上节点值的 最大可能 总和 。
在一棵有根树中,某个节点 v 的子树是指所有路径到根节点包含 v 的节点集合。
对于这个题目,树形dp是显然的,重点是状态定义和状态转移的处理。
由于是反转操作,较大的正数可能变成较小的负数,这种类型一般需要同时记录最大值和最小值。
这个知识点可以参考这个152. 乘积最大子数组
由于有距离维度,所以状态定义上,每个节点需要记录不同距离,所以是一个数组形式。
每个节点需要记录,子节点中翻转点距离为[0,k]的所有最大/最小值
每个节点都需要记录,因此表示为二维数组fmax[n][k+1],fmin[n][k+1]
如fmax[u][s]表示距离节点u最近反转子节点距离为s的最大和
状态转移上,考虑当前节点u反转与不反转。
- 如果不反转,则当前节点的可能产生的最大值是子节点的最大值之和
注意,如果子节点距离其反转点距离是s, 则当前节点距离就是s+1。
此时本题最大的难点出现,以最大值处理为例:
按照上述定义,我们需要枚举所有距离[0,k]处理u
但是每个枚举又需要枚举[0,s]注意s<k的距离取最大值,这会导致复杂度额外乘k
对于这种类型,典型的处理方式是修改下fmax[u][s]的含义。
fmax[u][s]表示对于当前节点u,子节点最近反转距离【至多】是s的最大和
更术语化的,fmax[u][s]表示的是前缀/后缀最大值。
这个技巧在处理最大值转移时往往能够优化复杂度。
这样,处理u节点的每个距离s时,直接取当前子节点的对应s-1的值即可。
当前节点计算时则需要倒序累计计算
即fmax[u][s-1] = max(fmax[u][s-1], fmax[u][s])
这样单个节点复杂度保持为系数k - 如果翻转,只需要处理距离为0的情况
fmax[u][0] = max(fmax[u][0], -fmin[u][k]);
fmin[u][i] += min(fmin[v][k - 1], fmin[v][k]);
这个题目也是没有特别大的思维难度,对码力有一定要求。
using i64 = long long;
i64 MAX = LONG_MAX / 2;
i64 MIN = LONG_MIN / 2;
class Solution {
public:
long long subtreeInversionSum(vector<vector<int>>& edges, vector<int>& nums, int k) {
int n = edges.size() + 1;
//邻接表建图
vector<unordered_set<int>> g(n);
for(auto& e : edges){
g[e[0]].insert(e[1]);
g[e[1]].insert(e[0]);
}
//节点状态数组
//如fmax[u][i]表示 节点u的子节点中最近翻转距离【至多为i】时最大点权和
//另外注意f[u][k]表示的是距离>=k的所有情况
vector<vector<i64>> fmax(n, vector<i64>(k + 1, 0));
vector<vector<i64>> fmin(n, vector<i64>(k + 1, 0));
auto dfs = [&](auto&& self, int u, int p)->void{
//自身点权
for(int i = 0; i <= k; i++) fmax[u][i] = fmin[u][i] = nums[u];
for(auto v : g[u]){
if(v == p) continue;
self(self, v, u);
for(int i = k; i > 0; i--){
if(i == k){
fmax[u][i] += max(fmax[v][k - 1], fmax[v][k]);
fmin[u][i] += min(fmin[v][k - 1], fmin[v][k]);
}
else{
fmax[u][i] += fmax[v][i - 1];
fmin[u][i] += fmin[v][i - 1];
}
}
//累计更新
//到当前节点距离至多为i的点权和应当包含至多为i+1的数据
for(int i = k - 1; i >= 0; i--){
fmax[u][i] = max(fmax[u][i], fmax[u][i + 1]);
fmin[u][i] = min(fmin[u][i], fmin[u][i + 1]);
}
}
//反转当前节点的处理
fmax[u][0] = max(fmax[u][0], -fmin[u][k]);
fmin[u][0] = min(fmin[u][0], -fmax[u][k]);
};
dfs(dfs, 0, -1);
i64 ans = MIN;
for(int i = 0; i <= k; i++) ans = max(ans, fmax[0][i]);
return ans;
}
};
浙公网安备 33010602011771号