LeetCode156双周赛详解

LeetCode156双周赛

到处都是知识点,学吧...


【Q1】3541. 找到频率最高的元音和辅音

给你一个由小写英文字母('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;
    }
};

【Q2】3542. 将所有元素变为 0 的最少操作次数

给你一个大小为 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();
    }
};

【Q3】3543. K 条边路径的最大边权和

给你一个整数 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,则有:

\[f|=g=\begin{cases} g(i+block) = f[i] << offset,当前block f[i]增加offset,所以是左移\\ g(i+block+1) |= f[i] >> (64-offset ),下一个block 要使用f[i]超出的部分,也就是高offset位的数据\\ \end{cases} \]

另外举个例子是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;
    }
};
posted @ 2025-05-29 18:49  云上寒烟  阅读(39)  评论(0)    收藏  举报