以一道题来总结单调栈
题目链接1:316. 去除重复字母 - 力扣(LeetCode)
解析:
-
字典序最小:要让结果字符串的字典序尽可能小,需要让较小的字符尽量靠前。
-
去重且保持顺序:每个字符只能出现一次,且相对顺序不能打乱。
-
关键策略:遍历字符串时,如果当前字符比栈顶字符小,并且栈顶字符在后面还会出现,那么就可以弹出栈顶字符(后面再放入),这样能使较小的字符提前。
具体步骤
-
统计字符出现次数:记录每个字符在字符串中剩余的未处理次数。
-
维护一个单调递增栈:栈中存放最终结果的字符,保持从栈底到栈顶的字符递增(字典序)。
-
遍历字符串:
-
每遇到一个字符,将其剩余次数减1。
-
如果该字符已经在栈中,跳过(避免重复)。
-
如果不在栈中,则循环判断:
-
当栈非空,且栈顶字符大于当前字符,且栈顶字符在后面还会出现(剩余次数 > 0)时,弹出栈顶字符,并标记为不在栈中。
-
-
将当前字符入栈,并标记为已在栈中。
-
-
栈中字符即为结果:将栈内字符拼接成字符串返回。
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时:-
如果
ch已经在栈中,跳过(去重)。 -
否则,如果
ch比栈顶字符小,并且栈顶字符在后面还会出现,那么弹出栈顶(因为后面可以重新加进来),直到栈顶字符比ch小或者栈顶字符后面不再出现。 -
然后将
ch入栈。
-
这样,栈内始终保持递增,并且每个字符都会在合适的位置出现一次。
二、单调栈题目的判断依据(特点总结)
如果你遇到一个问题,可以看看它是否具备以下特征:
1. 问题涉及顺序处理数据
-
数据是线性结构(数组、字符串),需要按顺序扫描。
-
例如:遍历数组、字符串的每个元素。
2. 需要维护一个“最优序列”
-
最终结果需要满足某种有序性(单调递增/递减)或者最优性(如字典序最小/最大)。
-
在构建序列时,当前元素可能会影响前面已经选择的元素,可能需要移除或调整前面的元素。
3. 决策依赖后续信息
-
在决定是否保留某个元素时,需要知道它之后是否还会出现、或者之后有没有更优的元素。
-
这种“向后看”的信息通常可以通过预处理(如统计剩余次数)获得。
4. 经典题型
-
下一个更大/更小元素:直接使用单调栈模板。
-
移除数字使数字最大/最小:给定数字字符串,移除 k 位使剩下的数字最大/最小。
-
去除重复字母并保持最小字典序:就是本题。
-
直方图最大矩形面积:维护一个递增栈,计算以每个柱子为高的最大矩形。
-
接雨水:维护递减栈,计算每个低洼处能接多少水。
三、本题的单调栈解法步骤回顾
-
预处理:统计每个字符在字符串中出现的总次数。
-
遍历字符串,对每个字符:
-
将其剩余次数减 1。
-
如果已经在栈中,跳过。
-
如果不在栈中,则:
-
当栈非空、栈顶字符大于当前字符、且栈顶字符后面还会出现(剩余次数 > 0)时,弹出栈顶,并标记为不在栈中。
-
将当前字符入栈,标记为已在栈中。
-
-
-
栈中字符即为结果。
这个过程保证了:
-
每个字符只出现一次(通过
in_stack标记)。 -
字典序最小(通过单调递增栈和弹出条件)。
-
相对顺序不变(因为遍历原字符串,并且只在后面还有相同字符时才弹出前面字符)。
四、总结
判断一道题是否可以用单调栈的关键:
观察问题是否需要在顺序扫描的过程中,维护一个序列,并且根据当前元素和后续信息调整前面已经选择的元素,使得最终序列满足某种单调性或最优性。
题目链接2:321. 拼接最大数 - 力扣(LeetCode)
解析:
我们需要从
nums1中取 i个数字,从 nums2中取 k-i个数字,然后将这两个子序列合并成一个长度为 k的序列,且合并后的序列字典序最大。其中 i的取值范围需满足:0 ≤ i ≤ len(nums1),0 ≤ k-i ≤ len(nums2)。因此,问题可以分解为三个关键步骤:
-
枚举所有可能的
i:即nums1中选取的数字个数。 -
对每个
i,从nums1中提取长度为i的最大子序列,从nums2中提取长度为k-i的最大子序列。 -
将两个子序列合并成一个字典序最大的序列,并更新全局最大值。
下面详细解释每个步骤。
步骤一:从一个数组中提取指定长度的最大子序列
给定一个数组
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:-
从
nums1中提取长度为i的最大子序列sub1。 -
从
nums2中提取长度为k-i的最大子序列sub2。 -
合并
sub1和sub2得到当前候选序列cur。 -
如果
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);
}
};
总结二
一、题型特点
-
多序列选择:从两个或多个数组中按原顺序选取数字,组合成一个新序列。
-
保持相对顺序:每个数组内数字的先后顺序不能改变。
-
字典序最大:目标是使最终序列的字典序最大(即数值最大)。
-
长度固定:新序列长度固定为
k,且k ≤ m + n。 - 常见变体:有时要求最小序列,或单个数组的类似问题(如移掉K位数字)。
二、如何快速判断并解题?
识别题型特征
-
看到 “从多个序列中按顺序取数组成最大/最小序列”,且 长度固定,立即想到:
-
如果是单个序列,直接用单调栈取最大/最小子序列。
-
如果是两个序列,先枚举分配,再分别取最大子序列,最后合并。
-
-
合并时若要求字典序最大,必须用 后缀比较 的贪心合并。
解题步骤
-
判断是否可分治:问题能否分解为独立子问题(如分别从两个数组取子序列)?能则枚举分配。
-
子问题是否经典:单个数组取指定长度最大子序列 → 单调栈。
-
合并是否有特殊规则:字典序合并 → 比较后缀。
-
整合求解:枚举所有分配,分别求解子问题,合并后比较更新答案。
自己选择的路,跪着也要走完。朋友们,虽然这个世界日益浮躁起来,只要能够为了当时纯粹的梦想和感动坚持努力下去,不管其它人怎么样,我们也能够保持自己的本色走下去。

浙公网安备 33010602011771号