数据结构与算法笔记:求直方图最大面积算法分析

直方图最大面积

问题描述

  • 有n列的直方图,第j列高度为 h j h_j hj, 求一个面积最大的子矩阵

数据结构与算法笔记:求直方图最大面积算法分析_第1张图片

格式要求

  • 输入第一行正整数n,第二行n个空格隔开的非负整数
  • 数据范围: 所有直方图的列不会超过30000

数据范围

  • 对于 30% 的测试点,保证 n<=4
  • 对于 70% 的测试点,保证 n<=1000
  • 对于所有测试点,保证 n<=50000
  • 保证所有 h[i] 不超过 32767

关键算法实现

1 ) 算法1:时间复杂度为: O ( n 3 ) O(n^3) O(n3)的实现

分析

  • 矩形面积为底和高的乘积
  • 底部从a到b,那么底边长为: b-a+1
  • 高为: m i n { h i ∣ a ≤ i ≤ b } min \{h_i | a \leq i \leq b \} min{hiaib}
  • 面积:(b-a+1) * m i n { h i ∣ a ≤ i ≤ b } min \{h_i | a \leq i \leq b \} min{hiaib}
  • 蛮力算法:枚举每一条可能的底边, 计算出高, 求出面积再取一个最大的
  • 这里效率较低,可能通不过所有数据点,因为普通计算机的瓶颈是10的8次方
  • 当n为1000的时候,复杂度是 O ( n 3 ) = ( 1 0 3 ) 3 = 1 0 9 O(n^3) = (10^3)^3 = 10^9 O(n3)=(103)3=109 所以无法在1s内通过所有的OJ数据测试点
/*
* n:表示n列直方图
* h:表示高度的数组,h[i]表示第i列的高度(下标从1开始)
*/
int getResult(int n, int *h) {
    // 最后答案
    int res = 0;
    // 第一层循环枚举直方图左边界位置
    for (int a = 1; a <= n; ++a) {
        // 第二层循环枚举直方图右边界位置
        for (int b = a; b <= n; ++b) {
            // 要求,保证所有 h[i] 不超过 32767,记录最小高度, 这里只做数据的初始化,取一个比最大值大的值即可
            int minH = 50000;
            // 第三层循环从a到b更新最小高度,这里的高要找最矮的,木桶效应
            for (int c = a; c <= b; ++c) {
                minH = min(minH, h[c]); // 更新最小高度
            }
            res = max(res, (b - a + 1) * minH); // 更新最大面积
        }
    }
    return res;
}

总结:太慢了,属于比较原始的蛮力算法,没有任何优化的算法, 这是能够直接想到的

2 ) 算法2:减少循环, 时间复杂度为: O ( n 2 ) O(n^2) O(n2)的实现优化版本

分析

  • 考虑无法修改从a到b的底边位置的双重循环枚举,但是可以优化对最小高度的求解
  • 从a到b最小高度 H 1 H_1 H1, 和 从a到b+1的最小高度 H 2 H_2 H2之间的关系: H 2 = m i n { H 1 , h b + 1 } H_2 = min\{ H_1, h_{b+1} \} H2=min{H1,hb+1}
int getResult(int n, int *h) {
    // 最后答案
    int res = 0;
    // 第一层循环枚举直方图左边界位置
    for (int a = 1; a <= n; ++a) {
        // 要求,保证所有 h[i] 不超过 32767,记录最小高度, 这里只做数据的初始化,取一个比最大值大的值即可
        int minH = 50000;
        // 第二层循环枚举直方图右边界位置
        // 思路:a到b的最小高度H1 和 a到b+1的最小高度H2的关系:H2=min(H1, h_{b+1})
        // 由此减少了一次循环
        for (int b = a; b <= n; ++b) {
            minH = min(minH, h[b]); // 更新最小高度
            res = max(res, (b - a + 1) * minH); // 更新最大面积
        }
    }
    return res;
}

总结:优化思路是在a和b的底之间找到最小高度

3 ) 算法3:通过卡位来计算,时间复杂度为: O ( n 2 ) O(n^2) O(n2)的实现优化版本

  • 对于每一列,找到左右两端的卡位点,左卡位点lo, 右卡位点hi, 形成矩形的面积: S = ( h i − l o − 1 ) ∗ h i S = (hi - lo - 1) * h_i S=(hilo1)hi
  • 枚举所有列需要O(n), 每列找到卡位点也需要O(n),合起来是 O ( n 2 ) O(n^2) O(n2)
// 卡位计算, 蛮力算法
int getResult(int n, int *h) {
    // 最后答案
    int res = 0;
    // 第一层循环枚举直方图所有的列, 从1~n, 表示第1列到第n列
    for (int a = 1; a <= n; ++a) {
        // 取出当前列的高度
        int curH = h[a];
        // 左卡位点
        int lo = 0;
        // 右卡位点
        int hi = 0;
        // 第二层找到当前列的左右卡位点,并计算最大矩形面积
        // 1.找到左卡位点
        for (lo = a - 1; lo >= 1; lo --){
            if(h[lo] < curH) {
                break;
            }
        }
        // 2.找到右卡位点
        for (hi = a + 1; lo <= n; hi++){
            if(h[hi] < curH) {
                break;
            }
        }
        // 3.计算当前最大面积
        res = max(res, (hi - lo - 1) * curH);
    }
    return res;
}

4 ) 算法4:通过单调栈来计算,时间复杂度为: O ( n ) O(n) O(n)的实现优化版本

  • 从左至右枚举直方图的每一列,然后维护一个从栈底到栈顶是高度单调上升的栈
    • 比如,当前在k列,经过一系列扫描, 扫描k之前的元素,栈顶元素的高度是当前栈中最大的高度
    • 这个栈存放的是直方图所有列的索引(编号)
  • 根据下一个需要插入的列与栈顶的关系,进行弹出栈顶并更新答案或将该列压入栈中
    • 如果当前k的元素高度比栈顶元素的高度要大,那么当前元素k进栈,循环继续往前走
    • 如果要小,则当前的k会作为矩形的右卡位点,栈顶元素作为矩形的左卡位点,栈顶元素的高度作为矩形的高度,算出当时的面积,栈顶元素出栈(已计算完成,出栈)
    • 进而累计得出当前最大面积
    • 循环继续往前走,每次进行判断,进栈和出栈,计算得到当前最大矩形面积
    • 循环结束后即可得到整体最大矩形面积
  • 技巧在于左右两边加上哨兵
    • 如果不在左右加上,需要判断各种边界条件
    • 这是一个小trick
  • h是高度数组,h[i]表示第i列的高度(下标从1开始),数组大小为n+2
  • 其中前后两端都加一个哨兵,分别是:h[0], h[n+1], 高度均为0
// 单调栈方式实现
int getResult(int n, int *h) {
    // 最后答案
    int res = 0;
    // 单调栈,记录的是在h数组中的位置,栈顶所对应的高度是最高的,也就是单调递增
    stack<int> myStack;
    // 初始化单调栈,提前压入一个哨兵,总体有前后两个哨兵,分别是h[0]和h[n+1],高度都是0
    myStack.push(0);

    // 进行栈过滤, 从1到n+1, 最后一个也是一个哨兵0, 可以循环n+1次
    for (int i = 1; i <= n + 1; ++i) {
        // 如果栈顶元素高度大于当前元素的高度时,则弹出栈顶
        while(h[myStack.top()] > h[i]) {
            // 记录栈顶元素高度
            int nowHeight = h[myStack.top()];
            // 弹出栈顶
            myStack.pop();
            // 比较最大面积
            res = max(res, (i - myStack.top() - 1) * nowHeight);
        }
        // 将当前下标插入栈中
        myStack.push(i);
    }
    return res;
}

如果不考虑while, 程序变形:

// 单调栈方式实现 形式转换
int getResult(int n, int *h) {
    // 最后答案
    int res = 0;
    // 单调栈,记录的是在h数组中的位置,栈顶所对应的高度是最高的,也就是单调递增
    stack<int> myStack;

    // 初始化单调栈,提前压入一个哨兵,总体有前后两个哨兵,分别是h[0]和h[n+1],高度都是0
    myStack.push(0);

    // 进行栈过滤, 从1到n+1, 最后一个也是一个哨兵0, 可以循环n+1次
    for (int i = 0; i <= n+1;) {
        if(myStack.empty() || h[myStack.top()] <= h[i]) {
            // 进栈
            myStack.push(i);
            i++;
        } else {
            // 记录当前栈顶元素高度
            int nowHeight = h[myStack.top()];
            // 弹出栈顶
            myStack.pop();
            // 比较最大面积
            res = max(res, (i - myStack.top() - 1) * nowHeight);
        }
    }
    return res;
}

总结:对于每一列, 找到左右两端的卡位hi和lo, 形成的矩形面积为:底×高, 即:(hi - lo - 1) * h, 可见这是一个线性的算法O(n)

算法5:通过分治策略来处理

分析

  • 同样是基于卡位点来实现的
  • 找到某一点作为pivot来分开左右两边(lo,h) k = findPivot(h, lo, hi),
  • 分完之后, 还要考虑merge的方式,即左右和左右贯穿
    • (1) 计算 maxRect(h,lo,k) 左矩形面积
    • (2) 计算 maxRect(h,k+1,hi) 右矩形面积
    • (3) 计算 h[k] * (hi - lo - 1) 左右贯穿矩形面积
    • 找到上面三个的最大值作为局部最大值
  • 此算法框架的复杂度取决于所使用的技术(可以考虑使用线段树的相关知识减少复杂度,此处不做过多说明)

通用的输入程序

int main() {
    int n;
    cin >> n;
    int *height = new int[n + 2]();
    // 在这里控制输入中的height[0]和height[n+1]均为0
    for (int i = 1; i <= n; ++i) {
        cin >> height[i];
    }
    cout << getResult(n, height) << endl;
    delete[] height;
    return 0;
}

测试输入数据

  • 输入:
    • 5
    • 2 3 3 3 2
  • 输出:10

相关说明

  • 如果没有什么高效的好的算法,先从蛮力算法开始解决问题,在这个过程中可能启发我们想到更高效的做法
  • 最后可以用蛮力算法来验证新算法的正确性
  • 一般而言,算法优化顺序是:蛮力 => 贪心 => 减治和分治

扩展

  • 还有一种结构叫做 LookUpTable, 需要考虑 构造、存储和查询的复杂度,尽管查询很快,只需要O(1), 但是构造和查询很复杂 O ( n 2 ) O(n^2) O(n2), 效率令人堪忧
  • 另一种是线段树 Segment Tree, 构,存,查的复杂度分别为:nlogn、n、logn

你可能感兴趣的:(Data,Structure,and,Algorithms,C,C++,数据结构与算法,dsa,直方图最大面积,算法分析)