## 【算法题】最大矩形面积,单调栈解法 柱状图中最大的矩形 给定 $n$ 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 $1$ 。 求在该柱状图中,能够勾勒出来的矩形的最大面积。
题意很简单,翻译一下就是:求该图中最大矩形的面积。 那么,这道题的思路就是遍历。不过如何高效遍历是一个学问。 ### 一、暴力法 **先上暴力法,一切美好都从暴力开始** $Code$ ```cpp {.line-numbers} #include using namespace std; const int N = 10000010; int a[N]; int res; /* 测试样例I 6 3 2 1 4 5 2 输出: 8 测试样例II 5 2 4 3 4 5 输出: 12 */ int main() { int n; cin >> n; for (int i = 0; i < n; i++) cin >> a[i]; for (int i = 0; i < n; i++) { int width = 1; for (int j = i - 1; j >= 0; j--) { if (a[j] < a[i]) break; width++; } for (int j = i + 1; j < n; j++) { if (a[j] < a[i]) break; width++; } res = max(res, width * a[i]); } cout << res << endl; return 0; } ``` 很明显,暴力的算法在特定给定数据的情况下是效率是$O(N^2)$的,比如共$1e7$个数据,每个数据的高度都是$100$,那么,遍历到每个柱子时,都需要向左+向右共检查$1e7-1$个柱子,也就是一个完整的双重循环,需要计算$1e14$次,$C++$一秒的计算大约是$1e8$,肯定要超时。当然,一般的普通数据,用暴力来求解还是没有问题的,可以在考场中骗一半左右的分数。 ### 二、单调栈优化 #### 1、什么是单调栈 - 本质就是一个栈 - 单调上升或单调下降,比如$1 \ 2 \ 3 \ 4,$或者$4 \ 3 \ 2 \ 1$ #### 2、生成逻辑 以单调上升栈为例进行说明: - ① 单调栈内保存的是 **序号**,不是数值,不是数值,不是数值,是用序号去找的数值! - ② 遇到新元素,如果栈为空,则直接入栈 - ③ 遇到新元素,如果栈不空,并且新元素比栈顶元素大,则直接入栈 - ④ 遇到新元素,如果栈不空,并且新元素比栈顶元素小,则栈顶元素出栈,重复步骤④,直到遇到场景②或③,完成入栈 - ⑤ 最终形成一个单调上升的序列(单调上升指的是数值单调上升) #### 3、模拟求解过程
以该矩形为例 以$2$为高的最大矩形是 $2 * 4 = 4$; 以$3$为高的最大矩形是$3 * 3 = 9$; 以$4$为高的最大矩形是$4 * 2 = 8$; 以$5$为高的最大矩形是$5 * 1 = 1$; 那么有人要问了,有聪明的小脑袋们要问了,哎呀。 实际上某些矩形中间还有矩形,并不是真正的递序列,会不会产生影响捏?
如果原图为这样,那么出栈之后维护的递增图与上图对应。由于中间的$4$大于$3$也大于$2$,所以,中间的矩形应该是最大的,可以把$4$当成$3$即可。 **我们可以以出栈为契机**,计算矩形的面积 **语言描述**: 1、 栈中为空栈,将矩形$0$入栈。 此时栈中矩形为:$0$ 2、 矩形$1$的高为$4$,大于栈顶元素$2$,将矩形$1$入栈,此时栈中矩形为 $0 ,1$ 3、 矩形$2$的高为$3$,$3$小于栈顶元素的高$4$,所以将栈顶矩形$1$出栈,同时计算矩形$1$高的最大 矩形,为 $4 * 1 = 4$;同时将$3$入栈,此时栈中矩形为: $0, 2$ 4、 因为矩形$3$的高大于栈顶矩形$2$的高,所以将矩形$3$入栈,此时栈中矩形为: $0, 2,3$ 5、 因为矩形$4$的高大于栈顶矩形$3$的高,所以将矩形$4$入栈,此时栈中矩形为: $0, 2,3,4$ 6、 此时所有的元素已经入栈完毕,且栈中元素为地址矩形,**依次出栈计算所有值即可**。 #### 4、深入理解 **$Q$:为什么单调栈的求解过程是正确的呢?** 其实单调栈并不是什么天外飞仙,不是什么牛哄哄,我们可以通过先暴力后优化的思路来思考它是怎么工作的: **暴力算法**: 1、枚举每个柱子 2、针对每个柱子,向左找到第一个比自己小的,向右找到第一个比自己小的,两个位置之间的长度就是以自己为根据地最大矩形的宽度,再乘上自己的高度,就得到的以自己为根据地的最大矩形。 **单调栈优化** 1、该枚举还是得枚举,不枚举是不可能的,第一层循环不能省。 2、那就只能考虑省第二层循环了,需要把第二层循环干掉。 3、怎么干掉呢?思考一下它这是在干什么:向左找出第一个比自己小的柱子位置,向右找出第一个比自己小的柱子位置。 4、联想一下单调栈的处理逻辑,先思考一下向右的情况: - 当遇到的新元素比栈顶元素大时,就直接加入栈,此时表示栈内的旧元素还没有找到比自己小的柱子。 - 当遇到的新元素比栈顶元素小时,旧的元素要出栈了!为什么要出栈呢?是因为它已经找到了右边第一个比它小的位置,它的目标实现了,不出栈还等着上菜吗? 5、现在我们整明白了向右是怎么探测第一个比自己小的元素的,那么左边我也没看到它处理啊,为什么左边也能正确的找到位置呢? 6、我们要理解清楚,栈里放的是序号,是序号,序号,不是柱子高度!然后再来进行思考: 当某个柱子出栈后,那么现在栈顶的元素是哪个呢?当然是左侧第一个比自己小的啊!$OK$,完美啊!一个单调栈的数据结构,一次出栈就把向左找比自己小,向右找比自己小的全部搞定!而且是一路走来动态维护,方便又快捷! 7、最后一个问题:那如果最终形成了类似于$1,2,3,4$这样的单调上升的栈内情况,它们不是还得出栈吗?要不它们不是还没有找到自己负责的最大矩形吗? 是的,它们最终不在呆在栈内,也是要出栈的。但是问题是它们后面已经没有新元素加入了,不是死在里面了吗?嗯,此时 **哨兵** 的伟大思想放光芒!我们在最后虚拟的加入一个高度为$0$的哨兵,前面不管多高都会比它高,也就是它们都会遇到一个比自己小的数字! 加入后面的哨兵后,就把所有逻辑统一了,也就都会出栈了。 #### 5、$Code$ ```cpp {.line-numbers} #include using namespace std; const int N = 10000010; int a[N]; int res; /* 测试样例I 6 3 2 1 4 5 2 输出: 8 测试样例II 5 2 4 3 4 5 输出: 12 */ int main() { int n; cin >> n; for (int i = 0; i < n; i++) cin >> a[i]; stack stk; for (int i = 0; i <= n; i++) { // 注意:这里是0~n,其实数组中有效数据是0~n-1,之所以最后要多枚举一个n,是因为栈内可能存在一直找不到右手边比自己小的数据,最后补一个0,让它们都出栈 while (stk.size() && a[i] <= a[stk.top()]) { // 栈不为空,并且,当前柱子高度小于栈顶柱子高度,此时,栈顶柱子找到了右手边的最远距离 int height = a[stk.top()]; // 栈顶柱子高度 stk.pop(); // 栈顶柱子出栈 int width = (stk.size() == 0 ? i : i - 1 - stk.top()); res = max(res, height * width); } stk.push(i); } cout << res << endl; return 0; } ``` #### 6、总结 经典代码就是经典,这个单调栈的数据结构,如果你原来没有学过,我就不相信你能在考场上现场做出这道题,像学习外语一下学习算法这句话不是白说的,大家一定要注意 **厚积薄发**! 金鳞岂是池中物,一遇风云便化龙。 做好准备,等待蜕变吧。