以一道题来总结单调栈

题目链接1:316. 去除重复字母 - 力扣(LeetCode)

解析:

  1. 字典序最小:要让结果字符串的字典序尽可能小,需要让较小的字符尽量靠前。
  2. 去重且保持顺序:每个字符只能出现一次,且相对顺序不能打乱。
  3. 关键策略:遍历字符串时,如果当前字符比栈顶字符小,并且栈顶字符在后面还会出现,那么就可以弹出栈顶字符(后面再放入),这样能使较小的字符提前。

具体步骤

  1. 统计字符出现次数:记录每个字符在字符串中剩余的未处理次数。
  2. 维护一个单调递增栈:栈中存放最终结果的字符,保持从栈底到栈顶的字符递增(字典序)。
  3. 遍历字符串:
    • 每遇到一个字符,将其剩余次数减1。
    • 如果该字符已经在栈中,跳过(避免重复)。
    • 如果不在栈中,则循环判断:
      • 当栈非空,且栈顶字符大于当前字符,且栈顶字符在后面还会出现(剩余次数 > 0)时,弹出栈顶字符,并标记为不在栈中。
    • 将当前字符入栈,并标记为已在栈中。
  4. 栈中字符即为结果:将栈内字符拼接成字符串返回。
class Solution {
public:


    string removeDuplicateLetters(string s) {
        int n = s.size();
        int num[26];
        bool vis[26];
        string ret = "";
        memset(num, 0, sizeof(num));
        memset(vis, 0, sizeof(vis));
        for (int i = 0; i < n; i++) {
            num[s[i] - 'a']++;
        }
        for (int i = 0; i < n; i++) {
            num[s[i] - 'a']--;
            if(vis[s[i] - 'a']) continue;
            while(!ret.empty() && ret.back() >= s[i] && num[ret.back() - 'a'] > 0) {
                vis[ret.back() - 'a'] = false;
                ret.pop_back();
            }
            ret.push_back(s[i]);
            vis[s[i] - 'a'] = true;
            
        }
        return ret;
        




        return ret;

    }
};

总结一

一、为什么这道题是经典的单调栈题目?

1. 题目核心要求

  • 字典序最小:意味着我们需要让较小的字母尽可能靠前。
  • 去重:每个字母只出现一次。
  • 保持相对顺序:不能打乱原有字符的顺序(但可以删除字符)。
这三点结合,就变成了:
我们需要在原字符串的顺序约束下,选择哪些字符保留、哪些字符跳过,并且让保留的字符按字典序尽可能小。

2. 如何联想到单调栈?

试想一下,如果我们从左到右遍历字符串,并逐个决定是否将当前字符加入结果序列,会遇到这样的冲突:
  • 如果当前字符比前面已经加入的某个字符小,那么把它放到前面可能得到更小的字典序。
  • 但是,如果前面那个字符在后面不会再出现,就不能把它移走(否则会缺失字符)。
  • 如果前面那个字符在后面还会出现,就可以把它移走(因为后面可以再补上),让更小的字符放在前面。
这个过程就像维护一个序列,当遇到一个新字符时,检查它是否可以替换掉前面已经加入的某些字符,以得到更优的结果。
这种“检查前面并可能移除”的操作,正是单调栈的典型行为。

3. 与单调栈的对应关系

  • 栈:用来存储当前已经选入结果序列的字符。
  • 单调性:我们希望结果字符串的字典序最小,就相当于让栈内的字符保持递增(栈底最小,栈顶最大)。
  • 弹出条件:
    当遇到一个新字符 ch时:
    1. 如果 ch已经在栈中,跳过(去重)。
    2. 否则,如果 ch比栈顶字符小,并且栈顶字符在后面还会出现,那么弹出栈顶(因为后面可以重新加进来),直到栈顶字符比 ch小或者栈顶字符后面不再出现。
    3. 然后将 ch入栈。
这样,栈内始终保持递增,并且每个字符都会在合适的位置出现一次。

二、单调栈题目的判断依据(特点总结)

如果你遇到一个问题,可以看看它是否具备以下特征:

1. 问题涉及顺序处理数据

  • 数据是线性结构(数组、字符串),需要按顺序扫描。
  • 例如:遍历数组、字符串的每个元素。

2. 需要维护一个“最优序列”

  • 最终结果需要满足某种有序性(单调递增/递减)或者最优性(如字典序最小/最大)。
  • 在构建序列时,当前元素可能会影响前面已经选择的元素,可能需要移除或调整前面的元素。

3. 决策依赖后续信息

  • 在决定是否保留某个元素时,需要知道它之后是否还会出现、或者之后有没有更优的元素。
  • 这种“向后看”的信息通常可以通过预处理(如统计剩余次数)获得。

4. 经典题型

  • 下一个更大/更小元素:直接使用单调栈模板。
  • 移除数字使数字最大/最小:给定数字字符串,移除 k 位使剩下的数字最大/最小。
  • 去除重复字母并保持最小字典序:就是本题。
  • 直方图最大矩形面积:维护一个递增栈,计算以每个柱子为高的最大矩形。
  • 接雨水:维护递减栈,计算每个低洼处能接多少水。

三、本题的单调栈解法步骤回顾

  1. 预处理:统计每个字符在字符串中出现的总次数。
  2. 遍历字符串,对每个字符:
    • 将其剩余次数减 1。
    • 如果已经在栈中,跳过。
    • 如果不在栈中,则:
      • 当栈非空、栈顶字符大于当前字符、且栈顶字符后面还会出现(剩余次数 > 0)时,弹出栈顶,并标记为不在栈中。
      • 将当前字符入栈,标记为已在栈中。
  3. 栈中字符即为结果。
这个过程保证了:
  • 每个字符只出现一次(通过 in_stack标记)。
  • 字典序最小(通过单调递增栈和弹出条件)。
  • 相对顺序不变(因为遍历原字符串,并且只在后面还有相同字符时才弹出前面字符)。

四、总结

判断一道题是否可以用单调栈的关键:
观察问题是否需要在顺序扫描的过程中,维护一个序列,并且根据当前元素和后续信息调整前面已经选择的元素,使得最终序列满足某种单调性或最优性。
 

题目链接2:321. 拼接最大数 - 力扣(LeetCode)

解析:
我们需要从 nums1中取 i个数字,从 nums2中取 k-i个数字,然后将这两个子序列合并成一个长度为 k的序列,且合并后的序列字典序最大。其中 i的取值范围需满足:0 ≤ i ≤ len(nums1),0 ≤ k-i ≤ len(nums2)。
因此,问题可以分解为三个关键步骤:
  1. 枚举所有可能的 i:即 nums1中选取的数字个数。
  2. 对每个 i,从 nums1中提取长度为 i的最大子序列,从 nums2中提取长度为 k-i的最大子序列。
  3. 将两个子序列合并成一个字典序最大的序列,并更新全局最大值。
下面详细解释每个步骤。

步骤一:从一个数组中提取指定长度的最大子序列

给定一个数组 nums和长度 x,需要保持数字的相对顺序,选出 x个数字使子序列的字典序最大。这可以通过 单调栈​ 实现,原则是尽可能让高位数字大。
算法:
  • 初始化一个空栈。
  • 遍历 nums中的每个数字 num:
    • 当栈不为空、且栈顶元素小于 num、且栈内元素个数 + 剩余未遍历元素个数 > x​ 时,弹出栈顶(因为弹出后仍有足够数字凑够 x个)。
    • 将 num入栈。
  • 遍历结束后,栈中可能有多于 x个元素(例如数组递增时),只需取前 x个作为结果。
注意:条件“栈内元素个数 + 剩余未遍历元素个数 > x”保证了弹出栈顶后仍能凑足 x个数字。

步骤二:合并两个子序列

给定两个子序列 sub1和 sub2,需要将它们合并成一个字典序最大的序列,且各自内部顺序不变。这类似于归并排序的合并过程,但不能简单比较当前元素大小,因为如果当前元素相等,需要看后续元素来决定先取哪个。
合并规则:
  • 使用两个指针 i和 j分别指向 sub1和 sub2的当前位置。
  • 每次选择时,比较 sub1[i:]和 sub2[j:]的字典序:
    • 如果 sub1[i:]> sub2[j:],则选取 sub1[i],i++;
    • 否则选取 sub2[j],j++。
  • 重复直到选满 k个数字。
注意:在 Python 中可以直接用列表切片比较字典序,但为了效率可以自定义比较函数,避免频繁切片。

步骤三:枚举所有分配方案

枚举 i从 max(0, k-len(nums2))到 min(k, len(nums1)),对每个 i:
  1. 从 nums1中提取长度为 i的最大子序列 sub1。
  2. 从 nums2中提取长度为 k-i的最大子序列 sub2。
  3. 合并 sub1和 sub2得到当前候选序列 cur。
  4. 如果 cur比全局最大序列 ans字典序更大,则更新 ans = cur。
最后返回 ans。

复杂度分析

  • 枚举 i:最多 O(k)种情况。
  • 提取最大子序列:每次 O(m+n)。
  • 合并两个子序列:最坏 O(k^2)(因为每次比较可能需要 O(k))。
    总时间复杂度约为 O(k * (m+n + k^2))。在 LeetCode 数据范围(m, n ≤ 500)下可以接受。

示例说明

以示例 1 为例:
  • nums1 = [3,4,6,5], nums2 = [9,1,2,5,8,3], k=5。
  • 尝试不同的 i:
    • i=2:从 nums1取 2 个最大子序列为 [6,5](注意实际最大子序列是 [4,6]或 [4,5]?需要正确计算)。从 nums2取 3 个最大子序列为 [9,8,3],合并后得到 [9,8,6,5,3](假设合并正确)。
    • 其他 i可能得到更小的序列。
  • 最终结果为 [9,8,6,5,3]。
class Solution {
public:
    vector<int> maxNumber(vector<int>& nums1, vector<int>& nums2, int k) {
        int m = nums1.size(), n = nums2.size();
        vector<int> ans(k, 0);
        
        // 枚举从 nums1 中选取的数字个数 i
        // i 的范围:max(0, k - n) 到 min(k, m)
        for (int i = max(0, k - n); i <= min(k, m); ++i) {
            int j = k - i;  // 从 nums2 中选取的数字个数
            // 获取两个数组的最大子序列
            vector<int> sub1 = maxSubsequence(nums1, i);
            vector<int> sub2 = maxSubsequence(nums2, j);
            // 合并两个子序列
            vector<int> cur = merge(sub1, sub2);
            // 更新答案
            if (greater(cur, 0, ans, 0)) {
                ans = cur;
            }
        }
        return ans;
    }

private:
    // 从数组 nums 中提取长度为 k 的最大子序列(保持相对顺序)
    vector<int> maxSubsequence(vector<int>& nums, int k) {
        if (k == 0) return {};
        vector<int> stack;
        int n = nums.size();
        int drop = n - k;  // 最多可以丢弃的数字个数
        for (int i = 0; i < n; ++i) {
            while (!stack.empty() && nums[i] > stack.back() && drop > 0) {
                stack.pop_back();
                drop--;
            }
            stack.push_back(nums[i]);
        }
        // 如果栈中元素多于 k 个,只取前 k 个
        stack.resize(k);
        return stack;
    }

    // 合并两个子序列,使其字典序最大
    vector<int> merge(vector<int>& nums1, vector<int>& nums2) {
        vector<int> res;
        int i = 0, j = 0;
        while (i < nums1.size() || j < nums2.size()) {
            if (i >= nums1.size()) {
                res.push_back(nums2[j++]);
            } else if (j >= nums2.size()) {
                res.push_back(nums1[i++]);
            } else {
                // 比较后缀,选择字典序更大的那个
                if (greater(nums1, i, nums2, j)) {
                    res.push_back(nums1[i++]);
                } else {
                    res.push_back(nums2[j++]);
                }
            }
        }
        return res;
    }

    // 比较两个数组从指定位置开始的字典序
    bool greater(vector<int>& nums1, int i, vector<int>& nums2, int j) {
        int n1 = nums1.size(), n2 = nums2.size();
        while (i < n1 && j < n2) {
            if (nums1[i] != nums2[j]) {
                return nums1[i] > nums2[j];
            }
            i++;
            j++;
        }
        // 如果其中一个数组已经比较完,则较长的数组更大
        return (n1 - i) > (n2 - j);
    }
};

总结二

一、题型特点

  1. 多序列选择:从两个或多个数组中按原顺序选取数字,组合成一个新序列。
  2. 保持相对顺序:每个数组内数字的先后顺序不能改变。
  3. 字典序最大:目标是使最终序列的字典序最大(即数值最大)。
  4. 长度固定:新序列长度固定为 k,且 k ≤ m + n。
  5. 常见变体:有时要求最小序列,或单个数组的类似问题(如移掉K位数字)。

二、如何快速判断并解题?

识别题型特征

  • 看到 “从多个序列中按顺序取数组成最大/最小序列”,且 长度固定,立即想到:
    1. 如果是单个序列,直接用单调栈取最大/最小子序列。
    2. 如果是两个序列,先枚举分配,再分别取最大子序列,最后合并。
  • 合并时若要求字典序最大,必须用 后缀比较​ 的贪心合并。

解题步骤

  1. 判断是否可分治:问题能否分解为独立子问题(如分别从两个数组取子序列)?能则枚举分配。
  2. 子问题是否经典:单个数组取指定长度最大子序列 → 单调栈。
  3. 合并是否有特殊规则:字典序合并 → 比较后缀。
  4. 整合求解:枚举所有分配,分别求解子问题,合并后比较更新答案。
posted @ 2026-01-21 23:12  WTSRUVF  阅读(28)  评论(0)    收藏  举报