当前位置: 首页 > news >正文

单调栈通关指南:从力扣 84 到力扣 42

文章目录

  • 问题描述:柱状图中最大的矩形(力扣 84)
  • 暴力解法
    • 思路分析
    • 代码实现
    • 暴力解法痛点分析
    • 关键观察:边界的单调性​
    • 单调栈的引入:用栈维护有效边界
  • 双遍遍历解法:单调栈的基础应用
  • 常数优化:一次遍历完成边界计算
    • 优化的关键依据:出栈元素与当前元素的关系
    • 右边界的默认值设定
    • 一次遍历的完整逻辑​
    • 代码实现
    • 优化后的复杂度分析​
  • 总结:从暴力到单调栈的核心转变
  • 扩展:接雨水问题中的单调栈应用
    • 力扣 42:接雨水
    • 接雨水问题中单调栈的使用思路​
    • 代码实现

柱状图问题是一道经典的算法题,要求找到能勾勒出的最大矩形面积。暴力解法需要 O(n²) 的时间复杂度,而单调栈技巧可以优化到 O(n)。本文将深入剖析两种单调栈解法,带你彻底掌握这一重要数据结构。

问题描述:柱状图中最大的矩形(力扣 84)

在这里插入图片描述

暴力解法

只看题目,不看数组大小范围的话,我们最容易想到的解法就是暴力解法了。

思路分析

最直观的解法就是对于每个柱子,枚举以它(高度)为基准的矩形。

  1. 对于每个柱子 i,以其高度 heights[i] 作为矩形高度
  2. 向左扩展,找到第一个高度小于 heights[i] 的柱子位置 left
  3. 向右扩展,找到第一个高度小于 heights[i] 的柱子位置 right
  4. 计算矩形宽度:width = right - left - 1
  5. 计算矩形面积:heights[i] * width
  6. 在所有面积中取最大值

代码实现

代码十分直观,应该都能看懂。

class Solution {
public:int largestRectangleArea(vector<int>& heights) {int n = heights.size();int ans = 0;// 遍历每个柱子,以当前柱子为高度计算最大矩形for (int i = 0; i < n; ++i) {int left = i;// 向左寻找第一个高度小于当前柱子的位置while (left > 0 && heights[left - 1] >= heights[i]) {left--;}int right = i;// 向右寻找第一个高度小于当前柱子的位置while (right < n - 1 && heights[right + 1] >= heights[i]) {right++;}// 计算面积并更新最大值ans = max(ans, (right - left + 1) * heights[i]);}return ans;}
};

但很明显,这是会超时的。heights的大小在 105,暴力解法的时间复杂度为 O(n^2^),而一般单题的限制在 1s 左右,我们得把基本操作的次数控制在 108~109 这个范围。

暴力解法痛点分析

暴力解法的核心操作是对每个柱子重复寻找左右边界。例如,当处理第 i 个柱子时,需要从 i 向左逐个比较找到 left[i],再从 i 向右逐个比较找到 right[i]。在这个过程中,存在大量重复比较:比如计算 i+1 的左边界时,可能会再次比较 ii-1 的高度,而这在计算 i 的左边界时已经做过。​
这种重复比较导致时间复杂度达到 O(n²),当 n 较大时效率极低。因此,优化的关键在于减少重复比较,将边界信息的获取从 “每次重新搜索” 改为 “一次遍历中动态维护”

关键观察:边界的单调性​

以左边界为例,观察 left[i](左侧第一个比 heights[i] 矮的柱子索引)的特性:​

  • heights[j] ≥ heights[i](j < i),则 j 不可能是任何 k > iheights[k] ≤ heights[i] 的左边界(因为 i 更靠右且高度更低)。​
  • 反之,若 heights[j] < heights[i],则 j 可能成为 i 的左边界,且对于比 i 更高的柱子,j 仍可能作为边界存在。​

这意味着左边界的候选集合具有单调性:随着 i 增大,有效候选边界的高度呈现递增趋势(因为高度较高的候选会被更低的新候选取代)。这种单调性为使用栈来维护边界提供了可能。

单调栈的引入:用栈维护有效边界

基于上述观察,我们可以用栈来动态维护左边界的候选集合,具体规则如下:​

  1. 栈中存储柱子的索引,且对应的高度严格递增(即 heights[stack [0]] < heights[stack [1]] < ... < heights[stack [-1]])。​
  2. 处理 i 时,弹出栈中所有高度 ≥ heights[i] 的元素(这些元素不可能成为 i 或后续柱子的左边界)。​
  3. 弹出后,栈顶元素即为 left[i](若栈为空,则 left[i] = -1)。​
  4. i 入栈,作为后续柱子的候选边界。​

补充:
当栈为空时,为什么 left[i] = -1

  1. 因为我们维护的是左边界即左侧第一个小于heights[i]的柱子的索引。
  2. 如果栈为空,说明在柱子i的左侧没有比它更矮的柱子(因为栈中存储的是递增的柱子索引,栈空意味着当前柱子是遍历到目前最小的,或者左侧没有柱子了)。
  3. 因此,当栈为空时,-1 指的其实是索引 0 的左侧,即索引 -1(虚拟位置)
  4. 同理,当我们维护右边界时,如果栈为空,我们将 right[i] 设置为 n(heights数组的长度)

通过这种方式,每个元素最多入栈和出栈各一次,总操作次数为 O(n),避免了重复比较。右边界的计算同理,只需反向遍历并维护递减栈即可。

双遍遍历解法:单调栈的基础应用

基于上述推导,我们可以用栈去通过两次遍历,用于维护左右边界数组,再通过计算得出答案:

class Solution {
public:int largestRectangleArea(vector<int>& heights) {int n = heights.size();vector<int> left(n), right(n); // 存储每个柱子的左右边界索引stack<int> mono_stack;// 计算左边界:找到左侧第一个高度小于当前柱子的位置for (int i = 0; i < n; ++i) {while (!mono_stack.empty() && heights[mono_stack.top()] >= heights[i]) {// 栈顶元素高度 >= 当前高度,说明不是左边界,弹出mono_stack.pop();}// 左边界为栈顶或-1(无更小值)left[i] = (mono_stack.empty() ? -1 : mono_stack.top());// 当前柱子入栈,为后续柱子提供边界mono_stack.push(i);}// 同理计算右边界:找到右侧第一个高度小于当前柱子的位置mono_stack = stack<int>();for (int i = n - 1; i >= 0; --i) {while (!mono_stack.empty() && heights[mono_stack.top()] >= heights[i]) {mono_stack.pop();}right[i] = (mono_stack.empty() ? n : mono_stack.top());mono_stack.push(i);}int ans = 0;// 根据左右边界数组计算答案for (int i = 0; i < n; ++i) {ans = max(ans, (right[i] - left[i] - 1) * heights[i]);}return ans;}
};

常数优化:一次遍历完成边界计算

虽然双遍遍历效率已经很高了,但我们还可以进一步优化,只需一次遍历就能同时计算左右边界。这种优化的核心是利用元素出栈的时机,同步确定其右边界。

优化的关键依据:出栈元素与当前元素的关系

在计算左边界的遍历过程中,当我们弹出栈顶元素 top 时(因为 heights[top] >= heights[i]),可以观察到一个重要关系:i 是 top 的右边界

为什么当前元素 i 能够决定出栈元素 top 的右边界呢?

  • heights[i]首个top 右侧且高度小于 heights[top] 的元素(否则 top 不会被弹出)。​
  • 结合右边界的定义(右侧第一个高度小于当前元素的索引),i 恰好满足 right [top] = i

这意味着,在弹出栈顶元素时,我们可以直接记录其右边界,无需等到反向遍历。

右边界的默认值设定

对于未被弹出的元素(即始终留在栈中的元素),它们的右边界应该是数组末尾 n(因为右侧没有比它们更矮的元素)。因此,我们可以将 right 数组初始化为 n,省去后续处理:

vector<int> right(n, n); // 初始默认右边界为数组末尾

一次遍历的完整逻辑​

整合上述发现,一次遍历即可同时计算左右边界:​

  1. 遍历每个元素 i,维护单调递增栈。​
  2. 弹出栈顶元素 top 时,同步记录 right[top] = i。​
  3. 确定 i 的左边界 left[i](栈顶元素或 -1)。​
  4. i 入栈。​

整个过程中,每个元素的左边界在入栈前确定,右边界在出栈时确定(未出栈元素使用默认值 n),实现了一次遍历完成左右边界的计算。

代码实现

class Solution {
public:int largestRectangleArea(vector<int>& heights) {int n = heights.size();// 常数优化的关键点就在于给 right 数组全部初始化为 n,即默认每个柱子的右边界为数组末尾vector<int> left(n), right(n, n); // 存储每个柱子的左右边界索引stack<int> mono_stack;// 计算左边界:找到左侧第一个高度小于当前柱子的位置for (int i = 0; i < n; ++i) {// 当栈不为空且栈顶元素高度>=当前高度时,弹出栈顶元素// 说明当前i是栈顶元素的右边界(右侧第一个更小的高度)while (!mono_stack.empty() && heights[mono_stack.top()] >= heights[i]) {right[mono_stack.top()] = i;// 栈顶元素高度 >= 当前高度,说明不是左边界,弹出mono_stack.pop();}// 左边界为栈顶或-1(无更小值)left[i] = (mono_stack.empty() ? -1 : mono_stack.top());// 当前柱子入栈,为后续柱子提供边界mono_stack.push(i);}int ans = 0;// 根据左右边界数组计算答案for (int i = 0; i < n; ++i) {ans = max(ans, (right[i] - left[i] - 1) * heights[i]);}return ans;}
};

优化后的复杂度分析​

  • 时间复杂度:仍为 O(n),但减少了一次遍历,常数项降低。​
  • 空间复杂度O(n),与双遍遍历相同。​

总结:从暴力到单调栈的核心转变

阶段核心思路时间复杂度关键优化
暴力解法对每个元素重新搜索左右边界O(n²)
单调栈(双遍)用栈动态维护边界,减少重复比较O(n)利用单调性,将边界搜索改为栈内维护
单调栈(单遍)一次遍历中同时计算左右边界O(n)弹出栈顶时同步更新其右边界,复用遍历过程

从暴力到单调栈的推导,本质是对问题中 “边界单调性” 的发现和利用:通过栈这种数据结构,将原本分散的边界信息集中管理,使每个元素的边界信息能在一次遍历中动态确定,从而将时间复杂度从 O(n²) 降至 O(n)。这种思想同样适用于接雨水等类似问题,是算法优化中 “减少重复计算” 的典型范例。

扩展:接雨水问题中的单调栈应用

力扣 42:接雨水

接雨水问题同样可以用单调栈高效解决。接雨水问题描述为:给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

接雨水问题中单调栈的使用思路​

在接雨水问题中,单调栈存储的是柱子的索引,栈内元素对应的高度是单调递减的。当遇到比栈顶元素更高的柱子时,就意味着栈顶元素处可以形成积水。​

具体步骤如下:​

  1. 遍历数组,对于每个元素,当栈不为空且当前柱子高度大于栈顶柱子高度时,弹出栈顶元素。​
  2. 弹出栈顶元素后,若栈为空,则说明没有左边界,无法形成积水,退出循环;否则,计算积水的高度和宽度。积水高度为当前柱子高度与新栈顶柱子高度中的较小值减去弹出元素的高度,积水宽度为当前索引与新栈顶索引的差值减 1。​
  3. 将当前索引入栈。

代码实现

这就不再过多讲解了,直接看代码吧。

class Solution {
public:int trap(vector<int>& height) {int n = height.size();stack<int> st;  // 单调递减栈,存储柱子下标int ans = 0;    // 存储总水量for (int i = 0; i < n; ++i) {// 当栈不为空且当前柱子高度大于栈顶柱子高度时while (!st.empty() && height[i] > height[st.top()]) {int top = st.top();  // 取出栈顶元素(当前最低洼处)st.pop();            // 弹出栈顶if (st.empty()) break;  // 栈空说明没有左边界了,退出循环int left = st.top();  // 新的栈顶是左边界int width = i - left - 1;  // 计算宽度:当前位置与左边界的距离int h = min(height[i], height[left]) - height[top];  // 计算有效高度ans += width * h;  // 累加当前凹槽的水量}st.push(i);  // 当前柱子下标入栈}return ans;}
};
http://www.lryc.cn/news/582976.html

相关文章:

  • eslint扁平化配置
  • IoTDB:专为物联网场景设计的高性能时序数据库
  • 深圳凭物联网软件开发构建智慧‘城市大脑‘
  • c语言学习_函数递归
  • 「Java案例」求n1-n2内的素数
  • 使用Node.js搭建Web应用有哪些注意事项?
  • 在 Vue2 与 Vue3 中,面对 **大数据量交互体验优化** 和 **ECharts 大数据渲染性能优化**
  • 萌新赛第(一)场
  • EfficientVMamba: Atrous Selective Scan for Light Weight Visual Mamba论文精读(逐段解析)
  • 华为泰山服务器重启后出现 XFS 文件系统磁盘“不识别”(无法挂载或访问),但挂载点目录仍在且无数据
  • Nginx完全指南 - 从入门到精通(加强版)
  • 【深度学习入门 鱼书学习笔记(1)感知机】
  • Java常用加密算法详解与实战代码 - 附可直接运行的测试示例
  • Spring Boot 多数据源切换:AbstractRoutingDataSource
  • 语言模型 RLHF 实践指南(一):策略网络、价值网络与 PPO 损失函数
  • MySQL索引面试问题梳理
  • 【Android】组件及布局介绍
  • Flutter基础(前端教程②-卡片列表)
  • 【牛客刷题】小红的v三元组
  • 从单体到微服务:Spring Cloud 开篇与微服务设计
  • 音频主动降噪技术
  • 暑假算法日记第四天
  • Spring AI:检索增强生成(RAG)
  • 工作中的思考
  • Java教程:【程序调试技巧】入门
  • 项目Win系统下可正常获取Header字段,但是到了linux、docker部署后无法获取
  • 数据湖技术之Iceberg-03 Iceberg整合Flink 实时写入与增量读取
  • 【HarmonyOS】鸿蒙端云一体化开发入门详解 (一)
  • 深度剖析 Linux ip neigh:邻居表项的查看与添加实践
  • RabbitMQ第二章(RocketMQ的五大工作模式)