本文始發於個人公眾號:TechFlow,原創不易,求個關注
今天是LeetCode專題第52篇文章,我們一起來看LeetCode第84題,Largest Rectangle in Histogram(最大矩形面積)。
這道題的官方難度是Hard,點贊3581,反對只有80,通過率在34.7%左右。從通過率上來看,難度其實還可以,並沒有特別大,但是這道題的點贊比很高,說明題目的質量很好。實際上也的確如此,這題非常經典,我個人也非常推薦。建議大家有能力的都做一下本題,一定會很有收獲。
題意
假設我們有一系列寬度相同都為1的矩形豎直地擺放在一起,請問擺放而成的這個圖案所能圍成的最大矩形的面積是多少?

比如上圖當中,我們有6個矩形,它們的寬度都是1。我們能找到的最大矩形應該是中間5和6圍成的矩形:

題目給定一個含有若干個整數的數字,表示這些矩形的高度,要求返回能找到的面積最大的矩形的面積。
樣例
Input: [2,1,5,6,2,3]
Output: 10
區間求最值
拿到手應該能感受到這題的難度,我們一上來的確沒有什么太好的思路,題目也比較明確,沒有太多可以分析的入手點。所以我們可以先來思考一下最簡單的解法。
最簡單的解法就是找出能夠圍成的所有矩形,然后比較它們之間的面積,得出其中的最大面積。我們很容易可以想到可以遍歷矩形的起始位置,這樣就得到了矩形的寬。至於矩形的長也很簡單,就是選定的這個區間段里的最低高度。

我們可以做一個小小的思路轉換,假設這些矩形都是木條,我們是要選出木條來制作木桶。那么根據木桶效應,木桶圍成的水的高度取決於最短的那根木條,同樣圍成矩形的面積的高取決於這些矩形當中最矮的那個。也就是說,當我們確定了區間之后,我們只需要找到區間里最小的數就可以了。所以這題就轉化成了區間求最值的問題,比如上圖當中,如果我們選擇最后三個矩形,那么它的高度就是2。
我們假設一共有n個長條矩形可供選擇,那么我們可以選出的首尾組合就是,大概是n的平方量級個區間。對於每個區間,我們需要遍歷它們中的元素獲取最小值,這需要
的遍歷時間,所以整體的復雜度應該在
量級。顯然這是一個非常大的數量級,當n超過1000就很難計算出解了。
這個思路顯然不夠好,我們想要對它進行優化也不容易。比如說如果你學過線段樹這類的數據結構,可能還會想到使用線段樹,我們可以將每次求最小值的查詢優化到,但即便如此最終的復雜度也很高。這是因為我們遍歷區間首尾位置就耗費了
,而這是很難優化的。所以這個思路的極限已經確定了,我們無法做出大的優化。
從這點出發,如果存在更好的解法,那么一定不是通過這種方式進行的。
逆向思維
上面的一種思路雖然不太可行,但是它提供了一種正向思路。我們搜索所有的區間,然后通過區間里的木條確定區圍成矩形的高度,就得到了矩形的面積。
既然這條路走不通,我們能不能反向思考呢?我們假設我們找到了答案,它是區間[a, b]段的木條圍成的矩形,它的高度是h。那么根據木桶效應,a到b區間段的木條當中一定有一根的長度是h。比如下圖當中[5, 6, 2, 3]如果要圍成矩形,那么高度只能是2。

既然如此,我們可以尋找以某根木條為短板所能構成的最大矩形。比如上圖當中,如果我們以第一根木條去尋找,就只能找到它本身,所以這個矩形的面積就是1 x 2 = 2。如果以第二根木條為短板去尋找,可以找到整個區間,它對應的面積就是1 x 6 = 6。
因為我們只有n個木條,以每個木條為短板尋找最大矩形,那么我們一定可以找出最多n個矩形。最終的答案一定在這n個矩形當中,在正向思維當中我們尋找木條區間需要的復雜度,然而我們尋找短板,只需要
,也就是說這種思路的搜索空間更小,只要我們保證搜索的效率,就可以更快地找到答案。
為了找到每個木條對應的最大矩形,我們需要找到每個短板向左以及向右能夠延伸到的最遠位置。比如上圖例子當中,根據每個木條向右延伸的最遠位置,我們可以得到[0, 5, 3, 3, 5, 5],同樣,我們可以得到每根木條向左延伸的數組:[0, 0, 2, 3, 2, 5]。有了這兩個數組之后,我們就可以計算出以每一根木條為短板的最大矩形的面積,在這其中面積最大的那個就是答案。
這個位置我們可以使用單調棧來求,我們用一個有序的棧來維護延伸的位置。舉個例子,我們用從棧底往棧頂遞增的單調棧來維護每根木條向右延伸的位置。當我們遇到一根新的木條時,會彈出棧中所有比它長的值。對於這些值來說,這根新的木條就是它的右邊界。比如[5, 6, 2],一開始讀到5,入棧。接着讀到6,由於6大於棧頂的5,所以6入棧。最后讀到2,由於2比6小,所以6出棧,對於6來說,2的位置就是它的右側邊界。正是由於2比它小,所以它才需要出棧,也說明了2的左側的元素都比6來的大,否則6在之前就應該出棧了。同理,2也是5的右側邊界。
如果你不了解單調棧,可以參考一下之前的文章:
LeetCode42題,單調棧、構造法、two pointers,這道Hard題的解法這么多?
我們把以上的邏輯翻轉,就得到了左側邊界求解的邏輯。左右邊界有了之后,我們只需要乘上它們之間的區間長度就得到了矩形的面積。
接着,我們來寫出代碼:
class Solution:
def largestRectangleArea(self, heights: List[int]) -> int: n = len(heights) # 左側邊界初始化為0 left_side = [0 for i in range(n)] # 右側邊界初始化為n-1 right_side = [n-1 for _ in range(n)] stack_left = [] stack_right = [] for i in range(n): h = heights[i] # 彈出棧中所有比當前元素小的值 # 注意,棧內存儲的是下標 while len(stack_right) > 0 and h < heights[stack_right[-1]]: tail = stack_right[-1] stack_right.pop() right_side[tail] = i - 1 # 當前元素入棧 stack_right.append(i) # 把坐標翻轉,等價於逆向遍歷 i_ = n - 1 - i h = heights[i_] # 維護單調棧的邏輯同上 while len(stack_left) > 0 and h < heights[stack_left[-1]]: tail = stack_left[-1] stack_left.pop() left_side[tail] = i_ + 1 # 當前元素入棧 stack_left.append(i_) ret = 0 for i in range(n): # 矩形面積等於右側邊界-左側邊界+1 x 高度 cur = (right_side[i] - left_side[i] + 1) * heights[i] ret = max(ret, cur) return ret
總結
想要把這道題做出來,單單理清楚題意和單單會單調棧都是沒有用的。既需要理清楚題意,從最簡單的解法出發推導出優化的方法,也需要深刻理解單調棧這個數據結構,才可以靈活應用。
另外,在代碼當中需要特別注意邊界的情況。比如初始化時左右邊界的設定,以及可能會出現連續相等元素的情況,這些都需要納入考慮。代碼雖然看起來簡單,但是隱藏了很多細節,所以只看代碼是沒用的,最好還是能親自實現一下。
今天的文章到這里就結束了,如果喜歡本文的話,請來一波素質三連,給我一點支持吧(關注、轉發、點贊)。
本文使用 mdnice 排版