基礎知識
首先大家要知道 棧和佇列是STL(C++標準庫)裡面的兩個資料結構。
C++標準庫是有多個版本的,要知道我們使用的STL是哪個版本,才能知道對應的棧和佇列的實現原理。
那麼來介紹一下,三個最為普遍的STL版本:
- HP STL 其他版本的C++ STL,一般是以HP STL為藍本實現出來的,HP STL是C++ STL的第一個實現版本,而且開放原始碼。
- P.J.Plauger STL 由P.J.Plauger參照HP STL實現出來的,被Visual C++編譯器所採用,不是開源的。
- SGI STL 由Silicon Graphics Computer Systems公司參照HP STL實現,被Linux的C++編譯器GCC所採用,SGI STL是開源軟體,原始碼可讀性甚高。
接下來介紹的棧和佇列也是SGI STL裡面的資料結構, 知道了使用版本,才知道對應的底層實現。
來說一說棧,棧先進後出,如圖所示:
棧提供
push
和pop
等等介面,所有元素必須符合先進後出規則,所以棧不提供走訪功能,也不提供迭代器(iterator)。 不像是set
或者map
提供迭代器iterator
來遍歷所有元素。棧是以底層容器完成其所有的工作,對外提供統一的介面,底層容器是可插拔的(也就是說我們可以控制使用哪種容器來實現棧的功能)。
所以
STL
中棧往往不被歸類為容器,而被歸類為container adapter
(容器介面卡)。那麼問題來了,
STL
中棧是用什麼容器實現的?從下圖中可以看出,棧的內部結構,棧的底層實現可以是
vector
,deque
,list
都是可以的, 主要就是陣列和連結串列的底層實現。我們常用的
SGI STL
,如果沒有指定底層實現的話,預設是以deque
為預設情況下棧的底層結構。
deque
是一個雙向佇列,只要封住一段,只開通另一端就可以實現棧的邏輯了。
SGI STL
中 佇列底層實現預設情況下一樣使用deque
實現的。我們也可以指定
vector
為棧的底層實現,初始化語句如下:std::stack<int, std::vector<int> > third; // 使用vector為底層容器的棧
剛剛講過棧的特性,對應的佇列的情況是一樣的。
佇列中先進先出的資料結構,同樣不允許有遍歷行為,不提供迭代器,
SGI STL
中佇列一樣是以deque
為預設情況下的底部結構。也可以指定
list
為起底層實現,初始化queue
的語句如下:std::queue<int, std::list<int>> third; // 定義以list為底層容器的佇列
所以
STL
佇列也不被歸類為容器,而被歸類為container adapter
( 容器介面卡)。我這裡講的都是C++ 語言中的情況, 使用其他語言的同學也要思考棧與佇列的底層實現問題, 不要對資料結構的使用淺嘗輒止,而要深挖其內部原理,才能夯實基礎。
實戰部分
232.用棧實現佇列
題意描述:
請你僅使用兩個棧實現先入先出佇列。佇列應當支援一般佇列支援的所有操作(
push
、pop
、peek
、empty
):實現
MyQueue
類:
void push(int x)
將元素 x 推到佇列的末尾int pop()
從佇列的開頭移除並返回元素int peek()
返回佇列開頭的元素boolean empty()
如果佇列為空,返回true
;否則,返回false
說明:
- 你 只能 使用標準的棧操作 —— 也就是隻有
push to top
,peek/pop from top
,size
, 和is empty
操作是合法的。- 你所使用的語言也許不支援棧。你可以使用 list 或者 deque(雙端佇列)來模擬一個棧,只要是標準的棧操作即可。
示例 1:
輸入: ["MyQueue", "push", "push", "peek", "pop", "empty"] [[], [1], [2], [], [], []] 輸出: [null, null, null, 1, 1, false] 解釋: MyQueue myQueue = new MyQueue(); myQueue.push(1); // queue is: [1] myQueue.push(2); // queue is: [1, 2] (leftmost is front of the queue) myQueue.peek(); // return 1 myQueue.pop(); // return 1, queue is [2] myQueue.empty(); // return false
提示:
1 <= x <= 9
- 最多呼叫
100
次push
、pop
、peek
和empty
- 假設所有操作都是有效的 (例如,一個空的佇列不會呼叫
pop
或者peek
操作)進階:
- 你能否實現每個操作均攤時間複雜度為
O(1)
的佇列?換句話說,執行n
個操作的總時間複雜度為O(n)
,即使其中一個操作可能花費較長時間。
思路:
這是一道模擬題,不涉及到具體演算法,考察的就是對棧和佇列的掌握程度。
使用棧來模式佇列的行為,如果僅僅用一個棧,是一定不行的,所以需要兩個棧一個輸入棧,一個輸出棧,這裡要注意輸入棧和輸出棧的關係。
下面動畫模擬以下佇列的執行過程:
執行語句:
queue.push(1); queue.push(2); queue.pop(); **注意此時的輸出棧的操作** queue.push(3); queue.push(4); queue.pop(); queue.pop();**注意此時的輸出棧的操作** queue.pop(); queue.empty();
在
push
資料的時候,只要資料放進輸入棧就好,但在pop
的時候,操作就複雜一些,輸出棧如果為空,就把進棧資料全部匯入進來(注意是全部匯入),再從出棧彈出資料,如果輸出棧不為空,則直接從出棧彈出資料就可以了。最後如何判斷佇列為空呢?如果進棧和出棧都為空的話,說明模擬的佇列為空了。
在程式碼實現的時候,會發現pop() 和 peek()兩個函式功能類似,程式碼實現上也是類似的,可以思考一下如何把程式碼抽象一下。
AC程式碼:
class MyQueue {
public:
stack<int> stIn;
stack<int> stOut;
/** Initialize your data structure here. */
MyQueue() {
}
/** Push element x to the back of queue. */
void push(int x) {
stIn.push(x);
}
/** Removes the element from in front of queue and returns that element. */
int pop() {
// 只有當stOut為空的時候,再從stIn裡匯入資料(匯入stIn全部資料)
if (stOut.empty()) {
// 從stIn匯入資料直到stIn為空
while(!stIn.empty()) {
stOut.push(stIn.top());
stIn.pop();
}
}
int result = stOut.top();
stOut.pop();
return result;
}
/** Get the front element. */
int peek() {
int res = this->pop(); // 直接使用已有的pop函式
stOut.push(res); // 因為pop函式彈出了元素res,所以再新增回去
return res;
}
/** Returns whether the queue is empty. */
bool empty() {
return stIn.empty() && stOut.empty();
}
};
- 時間複雜度: push和empty為O(1), pop和peek為O(n)
- 空間複雜度: O(n)
擴充:
可以看出
peek(
)的實現,直接複用了pop()
, 要不然,對stOut
判空的邏輯又要重寫一遍。再多說一些程式碼開發上的習慣問題,在工業級別程式碼開發中,最忌諱的就是 實現一個類似的函式,直接把程式碼粘過來改一改就完事了。
這樣的專案程式碼會越來越亂,一定要懂得複用,功能相近的函式要抽象出來,不要大量的複製貼上,很容易出問題!(踩過坑的人自然懂)。
工作中如果發現某一個功能自己要經常用,同事們可能也會用到,自己就花點時間把這個功能抽象成一個好用的函式或者工具類,不僅自己方便,也方便了同事們。
同事們就會逐漸認可你的工作態度和工作能力,自己的口碑都是這麼一點一點積累起來的!在同事圈裡口碑起來了之後,你就發現自己走上了一個正迴圈,以後的升職加薪才少不了你!
225. 用佇列實現棧
題意描述:
請你僅使用兩個佇列實現一個後入先出(LIFO)的棧,並支援普通棧的全部四種操作(
push
、top
、pop
和empty
)。實現
MyStack
類:
void push(int x)
將元素 x 壓入棧頂。int pop()
移除並返回棧頂元素。int top()
返回棧頂元素。boolean empty()
如果棧是空的,返回true
;否則,返回false
。注意:
- 你只能使用佇列的標準操作 —— 也就是
push to back
、peek/pop from front
、size
和is empty
這些操作。- 你所使用的語言也許不支援佇列。 你可以使用 list (列表)或者 deque(雙端佇列)來模擬一個佇列 , 只要是標準的佇列操作即可。
示例:
輸入: ["MyStack", "push", "push", "top", "pop", "empty"] [[], [1], [2], [], [], []] 輸出: [null, null, null, 2, 2, false] 解釋: MyStack myStack = new MyStack(); myStack.push(1); myStack.push(2); myStack.top(); // 返回 2 myStack.pop(); // 返回 2 myStack.empty(); // 返回 False
提示:
1 <= x <= 9
- 最多呼叫
100
次push
、pop
、top
和empty
- 每次呼叫
pop
和top
都保證棧不為空進階:你能否僅用一個佇列來實現棧。
思路:
(這裡要強調是單向佇列)
有的同學可能疑惑這種題目有什麼實際工程意義,其實很多演算法題目主要是對知識點的考察和教學意義遠大於其工程實踐的意義,所以面試題也是這樣!
剛剛做過
[棧與佇列]
的同學可能依然想著用一個輸入佇列,一個輸出佇列,就可以模擬棧的功能,仔細想一下還真不行!佇列模擬棧,其實一個佇列就夠了,那麼我們先說一說兩個佇列來實現棧的思路。
佇列是先進先出的規則,把一個佇列中的資料匯入另一個佇列中,資料的順序並沒有變,並沒有變成先進後出的順序。
所以用棧實現佇列, 和用佇列實現棧的思路還是不一樣的,這取決於這兩個資料結構的性質。(負負得正,正正依然是正)
但是依然還是要用兩個佇列來模擬棧,只不過沒有輸入和輸出的關係,而是另一個佇列完全用來備份的!
如下面動畫所示,用兩個佇列
que1
和que2
實現佇列的功能,que2
其實完全就是一個備份的作用,把que1
最後面的元素以外的元素都備份到que2
,然後彈出最後面的元素,再把其他元素從que2
導回que1
。模擬的佇列執行語句如下:
queue.push(1); queue.push(2); queue.pop(); // 注意彈出的操作 queue.push(3); queue.push(4); queue.pop(); // 注意彈出的操作 queue.pop(); queue.pop(); queue.empty();
AC程式碼:
class MyStack {
public:
queue<int> que1;
queue<int> que2; // 輔助佇列,用來備份
/** Initialize your data structure here. */
MyStack() {
}
/** Push element x onto stack. */
void push(int x) {
que1.push(x);
}
/** Removes the element on top of the stack and returns that element. */
int pop() {
int size = que1.size();
size--;
while (size--) { // 將que1 匯入que2,但要留下最後一個元素
que2.push(que1.front());
que1.pop();
}
int result = que1.front(); // 留下的最後一個元素就是要返回的值
que1.pop();
que1 = que2; // 再將que2賦值給que1
while (!que2.empty()) { // que2非空時清空que2
que2.pop();
}
return result;
}
/** Get the top element. */
//棧頂元素是最後進佇列的元素,在back隊尾
int top() {
return que1.back();
}
/** Returns whether the stack is empty. */
bool empty() {
return que1.empty();
}
};
- 時間複雜度: pop為O(n),其他為O(1)
- 空間複雜度: O(n)
最佳化
其實這道題目就是用一個佇列就夠了。
一個佇列在模擬棧彈出元素的時候只要將佇列頭部的元素(除了最後一個元素外) 重新新增到佇列尾部,此時再去彈出元素就是棧的順序了。
最佳化程式碼:
class MyStack {
public:
queue<int> que;
/** Initialize your data structure here. */
MyStack() {
}
/** Push element x onto stack. */
void push(int x) {
que.push(x);
}
/** Removes the element on top of the stack and returns that element. */
int pop() {
int size = que.size();
size--;
while (size--) { // 將佇列頭部的元素(除了最後一個元素外) 重新新增到佇列尾部
que.push(que.front());
que.pop();
}
int result = que.front(); // 此時彈出的元素順序就是棧的順序了
que.pop();
return result;
}
/** Get the top element. */
int top() {
return que.back();
}
/** Returns whether the stack is empty. */
bool empty() {
return que.empty();
}
};
- 時間複雜度: pop為O(n),其他為O(1)
- 空間複雜度: O(n)
20. 有效的括號
題意描述:
給定一個只包括
'('
,')'
,'{'
,'}'
,'['
,']'
的字串s
,判斷字串是否有效。有效字串需滿足:
- 左括號必須用相同型別的右括號閉合。
- 左括號必須以正確的順序閉合。
- 每個右括號都有一個對應的相同型別的左括號。
示例 1:
輸入:s = "()" 輸出:true
示例 2:
輸入:s = "()[]{}" 輸出:true
示例 3:
輸入:s = "(]" 輸出:false
提示:
1 <= s.length <= 104
s
僅由括號'()[]{}'
組成
思路:
括號匹配是使用棧解決的經典問題。
題意其實就像我們在寫程式碼的過程中,要求括號的順序是一樣的,有左括號,相應的位置必須要有右括號。
如果還記得編譯原理的話,編譯器在詞法分析的過程中處理括號、花括號等這個符號的邏輯,也是使用了棧這種資料結構。
再舉個例子,
linux
系統中,cd這個進入目錄的命令我們應該再熟悉不過了。cd a/b/c/../../
這個命令最後進入a目錄,系統是如何知道進入了a目錄呢 ,這就是棧的應用(其實可以出一道相應的面試題了)
由於棧結構的特殊性,非常適合做對稱匹配類的題目。
首先要弄清楚,字串裡的括號不匹配有幾種情況。
一些同學,在面試中看到這種題目上來就開始寫程式碼,然後就越寫越亂。
建議在寫程式碼之前要分析好有哪幾種不匹配的情況,如果不在動手之前分析好,寫出的程式碼也會有很多問題。
先來分析一下 這裡有三種不匹配的情況,
- 第一種情況,字串裡左方向的括號多餘了 ,所以不匹配。
- 第二種情況,括號沒有多餘,但是 括號的型別沒有匹配上。
- 第三種情況,字串裡右方向的括號多餘了,所以不匹配。
我們的程式碼只要覆蓋了這三種不匹配的情況,就不會出問題,可以看出 動手之前分析好題目的重要性。
動畫如下:
第一種情況:已經遍歷完了字串,但是棧不為空,說明有相應的左括號沒有右括號來匹配,所以return false
第二種情況:遍歷字串匹配的過程中,發現棧裡沒有要匹配的字元。所以return false
第三種情況:遍歷字串匹配的過程中,棧已經為空了,沒有匹配的字元了,說明右括號沒有找到對應的左括號return false
那麼什麼時候說明左括號和右括號全都匹配了呢,就是字串遍歷完之後,棧是空的,就說明全都匹配了。
分析完之後,程式碼其實就比較好寫了,
但還有一些技巧,在匹配左括號的時候,右括號先入棧,就只需要比較當前元素和棧頂相不相等就可以了,比左括號先入棧程式碼實現要簡單的多了!
AC程式碼:
class Solution {
public:
bool isValid(string s) {
if (s.size() % 2 != 0) return false; // 如果s的長度為奇數,一定不符合要求
stack<char> st;
for (int i = 0; i < s.size(); i++) {
if (s[i] == '(') st.push(')');
else if (s[i] == '{') st.push('}');
else if (s[i] == '[') st.push(']');
// 第三種情況:遍歷字串匹配的過程中,棧已經為空了,沒有匹配的字元了,說明右括號沒有找到對應的左括號 return false
// 第二種情況:遍歷字串匹配的過程中,發現棧裡沒有我們要匹配的字元。所以return false
else if (st.empty() || st.top() != s[i]) return false;
else st.pop(); // st.top() 與 s[i]相等,棧彈出元素
}
// 第一種情況:此時我們已經遍歷完了字串,但是棧不為空,說明有相應的左括號沒有右括號來匹配,所以return false,否則就return true
return st.empty();
}
};
- 時間複雜度: O(n)
- 空間複雜度: O(n)
1047. 刪除字串中的所有相鄰重複項
題意描述:
給出由小寫字母組成的字串
S
,重複項刪除操作會選擇兩個相鄰且相同的字母,並刪除它們。在 S 上反覆執行重複項刪除操作,直到無法繼續刪除。
在完成所有重複項刪除操作後返回最終的字串。答案保證唯一。
示例:
輸入:"abbaca" 輸出:"ca" 解釋: 例如,在 "abbaca" 中,我們可以刪除 "bb" 由於兩字母相鄰且相同,這是此時唯一可以執行刪除操作的重複項。之後我們得到字串 "aaca",其中又只有 "aa" 可以執行重複項刪除操作,所以最後的字串為 "ca"。
提示:
1 <= S.length <= 20000
S
僅由小寫英文字母組成。
思路:
本題要刪除相鄰相同元素,相對於
[20. 有效的括號 (opens new window)]
來說其實也是匹配問題有效的括號
是匹配左右括號,本題是匹配相鄰元素,最後都是做消除的操作。本題也是用棧來解決的經典題目。
那麼棧裡應該放的是什麼元素呢?
我們在刪除相鄰重複項的時候,其實就是要知道當前遍歷的這個元素,我們在前一位是不是遍歷過一樣數值的元素,那麼如何記錄前面遍歷過的元素呢?
所以就是用棧來存放,那麼棧的目的,就是存放遍歷過的元素,當遍歷當前的這個元素的時候,去棧裡看一下我們是不是遍歷過相同數值的相鄰元素。
然後再去做對應的消除操作。 如動畫所示:
從棧中彈出剩餘元素,此時是字串ac,因為從棧裡彈出的元素是倒序的,所以再對字串進行反轉一下,就得到了最終的結果。
AC程式碼:
class Solution {
public:
string removeDuplicates(string S) {
stack<char> st;
for (char s : S) {
if (st.empty() || s != st.top()) {
st.push(s);
} else {
st.pop(); // s 與 st.top()相等的情況
}
}
string result = "";
while (!st.empty()) { // 將棧中元素放到result字串彙總
result += st.top();
st.pop();
}
reverse (result.begin(), result.end()); // 此時字串需要反轉一下
return result;
}
};
- 時間複雜度: O(n)
- 空間複雜度: O(n)
當然可以拿字串直接作為棧,這樣省去了棧還要轉為字串的操作。
程式碼如下:
class Solution {
public:
string removeDuplicates(string S) {
string result;
for(char s : S) {
if(result.empty() || result.back() != s) {
result.push_back(s);
}
else {
result.pop_back();
}
}
return result;
}
};
- 時間複雜度: O(n)
- 空間複雜度: O(1),返回值不計空間複雜度
M:150. 逆波蘭表示式求值
題意描述:
給你一個字串陣列
tokens
,表示一個根據 逆波蘭表示法 表示的算術表示式。注:逆波蘭式(Reverse Polish Notation,RPN,或逆波蘭記法),也叫字尾表示式(將運算子寫在運算元之後)。
請你計算該表示式。返回一個表示表示式值的整數。
注意:
- 有效的算符為
'+'
、'-'
、'*'
和'/'
。- 每個運算元(運算物件)都可以是一個整數或者另一個表示式。
- 兩個整數之間的除法總是 向零截斷 。
- 表示式中不含除零運算。
- 輸入是一個根據逆波蘭表示法表示的算術表示式。
- 答案及所有中間計算結果可以用 32 位 整數表示。
示例 1:
輸入:tokens = ["2","1","+","3","*"] 輸出:9 解釋:該算式轉化為常見的中綴算術表示式為:((2 + 1) * 3) = 9
示例 2:
輸入:tokens = ["4","13","5","/","+"] 輸出:6 解釋:該算式轉化為常見的中綴算術表示式為:(4 + (13 / 5)) = 6
示例 3:
輸入:tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"] 輸出:22 解釋:該算式轉化為常見的中綴算術表示式為: ((10 * (6 / ((9 + 3) * -11))) + 17) + 5 = ((10 * (6 / (12 * -11))) + 17) + 5 = ((10 * (6 / -132)) + 17) + 5 = ((10 * 0) + 17) + 5 = (0 + 17) + 5 = 17 + 5 = 22
提示:
1 <= tokens.length <= 104
tokens[i]
是一個算符("+"
、"-"
、"*"
或"/"
),或是在範圍[-200, 200]
內的一個整數逆波蘭表示式:
逆波蘭表示式是一種字尾表示式,所謂字尾就是指算符寫在後面。
- 平常使用的算式則是一種中綴表示式,如
( 1 + 2 ) * ( 3 + 4 )
。- 該算式的逆波蘭表示式寫法為
( ( 1 2 + ) ( 3 4 + ) * )
。逆波蘭表示式主要有以下兩個優點:
- 去掉括號後表示式無歧義,上式即便寫成
1 2 + 3 4 + *
也可以依據次序計算出正確結果。- 適合用棧操作運算:遇到數字則入棧;遇到算符則取出棧頂兩個數字進行計算,並將結果壓入棧中
思路:
遞迴就是用棧來實現的。所以棧與遞迴之間在某種程度上是可以轉換的! 這一點我們在後續講解二叉樹的時候,會更詳細的講解到。
那麼來看一下本題,其實逆波蘭表示式相當於是二叉樹中的後序遍歷。 大家可以把運算子作為中間節點,按照後序遍歷的規則畫出一個二叉樹。
但我們沒有必要從二叉樹的角度去解決這個問題,只要知道逆波蘭表示式是用後序遍歷的方式把二叉樹序列化了,就可以了。
在進一步看,本題中每一個子表示式要得出一個結果,然後拿這個結果再進行運算,那麼這豈不就是一個相鄰字串消除的過程,和
[1047.刪除字串中的所有相鄰重複項]
中的對對碰遊戲是不是就非常像了。如動畫所示:
只不過本題不要相鄰元素做消除了,而是做運算。
除法與減法需要尤其注意順序!!
AC程式碼:
class Solution {
public:
int evalRPN(vector<string>& tokens) {
// 力扣修改了後臺測試資料,需要用longlong
stack<long long> st;
for (int i = 0; i < tokens.size(); i++) {
if (tokens[i] == "+" || tokens[i] == "-" || tokens[i] == "*" || tokens[i] == "/") {
long long num1 = st.top();
st.pop();
long long num2 = st.top();
st.pop();
if (tokens[i] == "+") st.push(num2 + num1);
//這裡除法是num2先輸入在棧下面,因此是num2 - num1
if (tokens[i] == "-") st.push(num2 - num1);
if (tokens[i] == "*") st.push(num2 * num1);
//這裡除法是num2先輸入在棧下面,因此是num2 / num1
if (tokens[i] == "/") st.push(num2 / num1);
} else {
st.push(stoll(tokens[i]));
}
}
int result = st.top();
st.pop(); // 把棧裡最後一個元素彈出(其實不彈出也沒事)
return result;
}
};
- 時間複雜度: O(n)
- 空間複雜度: O(n)
注:stoll函式
1、
stol()
此函式將在函式呼叫中作為引數提供的字串轉換為long int
。它解析str並將其內容解釋為指定基數的整數,並將其作為long int
型別的值返回。句法:
long int stol(const string&str,size_t * idx = 0,int base = 10)
引數: 該函式接受三個引數,如下所述:
str:它指定一個字串物件,並以整數表示。
idx:它指定一個指向size_t型別的物件的指標,該指標的值由函式設定為數值之後str中下一個字元的位置。該引數也可以是空指標,在這種情況下不使用它。
base:指定數字基數,以確定用於解釋字元的數字系統。如果基數為0,則要使用的基數由序列中的格式確定。預設值為10。
返回值:該函式將轉換後的整數返回為long int型別的值。
2、
stoll()
此函式將在函式呼叫中作為引數提供的字串轉換為long long int
。它解析str並將其內容解釋為指定基數的整數,並將其作為long long int
型別的值返回。句法:
long long int stoll(const string&str,size_t * idx = 0,int base = 10)
引數:該函式接受三個引數,如下所述:
str:此引數指定帶有整數的String物件。
idx:此引數指定指向size_t型別的物件的指標,該物件的值由功能設定為數值後str中下一個字元的位置。此引數也可以是空指標,在這種情況下,將不使用該引數。
base:此引數指定數字基數,以確定用於解釋字元的數字系統。如果基數為0,則它使用的基數由序列中的格式確定。預設基數為10。
返回值:該函式將轉換後的整數作為long long int型別的值返回。
題外話
我們習慣看到的表示式都是中綴表示式,因為符合我們的習慣,但是中綴表示式對於計算機來說就不是很友好了。
例如:4 + 13 / 5,這就是中綴表示式,計算機從左到右去掃描的話,掃到13,還要判斷13後面是什麼運算子,還要比較一下優先順序,然後13還和後面的5做運算,做完運算之後,還要向前回退到 4 的位置,繼續做加法,你說麻不麻煩!
那麼將中綴表示式,轉化為字尾表示式之後:["4", "13", "5", "/", "+"] ,就不一樣了,計算機可以利用棧來順序處理,不需要考慮優先順序了。也不用回退了, 所以字尾表示式對計算機來說是非常友好的。
可以說本題不僅僅是一道好題,也展現出計算機的思考方式。
在1970年代和1980年代,惠普在其所有臺式和手持式計算器中都使用了RPN(字尾表示式),直到2020年代仍在某些模型中使用了RPN。
參考維基百科如下:
During the 1970s and 1980s, Hewlett-Packard used RPN in all of their desktop and hand-held calculators, and continued to use it in some models into the 2020s.
H:239. 滑動視窗最大值
題意描述:
給你一個整數陣列
nums
,有一個大小為k
的滑動視窗從陣列的最左側移動到陣列的最右側。你只可以看到在滑動視窗內的k
個數字。滑動視窗每次只向右移動一位。返回 滑動視窗中的最大值 。
示例 1:
輸入:nums = [1,3,-1,-3,5,3,6,7], k = 3 輸出:[3,3,5,5,6,7] 解釋: 滑動視窗的位置 最大值 --------------- ----- [1 3 -1] -3 5 3 6 7 3 1 [3 -1 -3] 5 3 6 7 3 1 3 [-1 -3 5] 3 6 7 5 1 3 -1 [-3 5 3] 6 7 5 1 3 -1 -3 [5 3 6] 7 6 1 3 -1 -3 5 [3 6 7] 7
示例 2:
輸入:nums = [1], k = 1 輸出:[1]
提示:
1 <= nums.length <= 105
-104 <= nums[i] <= 104
1 <= k <= nums.length
你能線上性時間複雜度內解決此題嗎?
思路:
這是使用單調佇列的經典題目。
難點是如何求一個區間裡的最大值呢? (這好像是廢話),暴力一下不就得了。
暴力方法,遍歷一遍的過程中每次從視窗中再找到最大的數值,這樣很明顯是O(n × k)的演算法。
有的同學可能會想用一個大頂堆(優先順序佇列)來存放這個視窗裡的k個數字,這樣就可以知道最大的最大值是多少了, 但是問題是這個視窗是移動的,而大頂堆每次只能彈出最大值,我們無法移除其他數值,這樣就造成大頂堆維護的不是滑動視窗裡面的數值了。所以不能用大頂堆。
此時我們需要一個佇列,這個佇列呢,放進去視窗裡的元素,然後隨著視窗的移動,佇列也一進一出,每次移動之後,佇列告訴我們裡面的最大值是什麼。
這個佇列應該長這個樣子:
class MyQueue { public: void pop(int value) { } void push(int value) { } int front() { return que.front(); } };
每次視窗移動的時候,呼叫
que.pop
(滑動視窗中移除元素的數值),que.push
(滑動視窗新增元素的數值),然後que.front()
就返回我們要的最大值。這麼個佇列香不香,要是有現成的這種資料結構是不是更香了!
其實在C++中,可以使用
multiset
來模擬這個過程,文末提供這個解法僅針對C++,以下講解我們還是靠自己來實現這個單調佇列。然後再分析一下,佇列裡的元素一定是要排序的,而且要最大值放在出隊口,要不然怎麼知道最大值呢。
但如果把視窗裡的元素都放進佇列裡,視窗移動的時候,佇列需要彈出元素。
那麼問題來了,已經排序之後的佇列 怎麼能把視窗要移除的元素(這個元素可不一定是最大值)彈出呢。
大家此時應該陷入深思.....
其實佇列沒有必要維護視窗裡的所有元素,只需要維護有可能成為視窗裡最大值的元素就可以了,同時保證佇列裡的元素數值是由大到小的。
那麼這個維護元素單調遞減的佇列就叫做單調佇列,即單調遞減或單調遞增的佇列。C++中沒有直接支援單調佇列,需要我們自己來實現一個單調佇列
不要以為實現的單調佇列就是 對視窗裡面的數進行排序,如果排序的話,那和優先順序佇列又有什麼區別了呢。
來看一下單調佇列如何維護佇列裡的元素。
動畫如下:
對於視窗裡的元素{2, 3, 5, 1 ,4},單調佇列裡只維護{5, 4} 就夠了,保持單調佇列裡單調遞減,此時佇列出口元素就是視窗裡最大元素。
此時大家應該懷疑單調佇列裡維護著{5, 4} 怎麼配合視窗進行滑動呢?
設計單調佇列的時候,pop,和push操作要保持如下規則:
- pop(value):如果視窗移除的元素
value
等於單調佇列的出口元素,那麼佇列彈出元素,否則不用任何操作- push(value):如果push的元素
value
大於入口元素的數值,那麼就將佇列入口的元素彈出,直到push元素的數值小於等於佇列入口元素的數值為止保持如上規則,每次視窗移動的時候,只要問
que.front()
就可以返回當前視窗的最大值。為了更直觀的感受到單調佇列的工作過程,以題目示例為例,輸入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3,動畫如下:
那麼我們用什麼資料結構來實現這個單調佇列呢?
使用
deque
最為合適,在文章棧與佇列:來看看棧和佇列不為人知的一面 (opens new window)中,我們就提到了常用的queue
在沒有指定容器的情況下,deque
就是預設底層容器。
AC程式碼:
class MyQueue { //單調佇列(從大到小)
public:
deque<int> que; // 使用deque來實現單調佇列
// 每次彈出的時候,比較當前要彈出的數值是否等於佇列出口元素的數值,如果相等則彈出。
// 同時pop之前判斷佇列當前是否為空。
void pop(int value) {
if (!que.empty() && value == que.front()) {
que.pop_front();
}
}
// 如果push的數值大於入口元素的數值,那麼就將佇列後端的數值彈出,直到push的數值小於等於佇列入口元素的數值為止。
// 這樣就保持了佇列裡的數值是單調從大到小的了。
void push(int value) {
while (!que.empty() && value > que.back()) {
que.pop_back();
}
que.push_back(value);
}
// 查詢當前佇列裡的最大值 直接返回佇列前端也就是front就可以了。
int front() {
return que.front();
}
};
這樣我們就用deque
實現了一個單調佇列,接下來解決滑動視窗最大值的問題就很簡單了,直接看程式碼吧。
C++程式碼如下:
class Solution {
private:
class MyQueue { //單調佇列(從大到小)
public:
deque<int> que; // 使用deque來實現單調佇列
// 每次彈出的時候,比較當前要彈出的數值是否等於佇列出口元素的數值,如果相等則彈出。
// 同時pop之前判斷佇列當前是否為空。
void pop(int value) {
if (!que.empty() && value == que.front()) {
que.pop_front();
}
}
// 如果push的數值大於入口元素的數值,那麼就將佇列後端的數值彈出,直到push的數值小於等於佇列入口元素的數值為止。
// 這樣就保持了佇列裡的數值是單調從大到小的了。
void push(int value) {
while (!que.empty() && value > que.back()) {
que.pop_back();
}
que.push_back(value);
}
// 查詢當前佇列裡的最大值 直接返回佇列前端也就是front就可以了。
int front() {
return que.front();
}
};
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
MyQueue que;
vector<int> result;
for (int i = 0; i < k; i++) { // 先將前k的元素放進佇列
que.push(nums[i]);
}
result.push_back(que.front()); // result 記錄前k的元素的最大值
for (int i = k; i < nums.size(); i++) {
que.pop(nums[i - k]); // 滑動視窗移除最前面元素
que.push(nums[i]); // 滑動視窗前加入最後面的元素
result.push_back(que.front()); // 記錄對應的最大值
}
return result;
}
};
- 時間複雜度: O(n)
- 空間複雜度: O(k)
再來看一下時間複雜度,使用單調佇列的時間複雜度是 O(n)。
有的同學可能想了,在佇列中 push元素的過程中,還有pop操作呢,感覺不是純粹的O(n)。
其實,大家可以自己觀察一下單調佇列的實現,nums 中的每個元素最多也就被 push_back 和 pop_back 各一次,沒有任何多餘操作,所以整體的複雜度還是 O(n)。
空間複雜度因為我們定義一個輔助佇列,所以是O(k)。
M:347. 前 K 個高頻元素
題意描述:
給你一個整數陣列
nums
和一個整數k
,請你返回其中出現頻率前k
高的元素。你可以按 任意順序 返回答案。示例 1:
輸入: nums = [1,1,1,2,2,3], k = 2 輸出: [1,2]
示例 2:
輸入: nums = [1], k = 1 輸出: [1]
提示:
1 <= nums.length <= 105
k
的取值範圍是[1, 陣列中不相同的元素的個數]
- 題目資料保證答案唯一,換句話說,陣列中前
k
個高頻元素的集合是唯一的進階:你所設計演算法的時間複雜度 必須 優於
O(n log n)
,其中n
是陣列大小。
思路:
這道題目主要涉及到如下三塊內容:
- 要統計元素出現頻率
- 對頻率排序
- 找出前K個高頻元素
首先統計元素出現的頻率,這一類的問題可以使用
map
來進行統計。然後是對頻率進行排序,這裡我們可以使用一種 容器介面卡就是優先順序佇列。
- 什麼是優先順序佇列呢?
其實就是一個披著佇列外衣的堆,因為優先順序佇列對外介面只是從隊頭取元素,從隊尾新增元素,再無其他取元素的方式,看起來就是一個佇列。
而且優先順序佇列內部元素是自動依照元素的權值排列。那麼它是如何有序排列的呢?
預設情況下
priority_queue
利用max-heap
(大頂堆)完成對元素的排序,這個大頂堆是以vector為表現形式的complete binary tree(
完全二叉樹)。
- 什麼是堆呢?
堆是一棵完全二叉樹,樹中每個結點的值都不小於(或不大於)其左右孩子的值。 如果父親結點是大於等於左右孩子就是大頂堆,小於等於左右孩子就是小頂堆。
所以大家經常說的大頂堆(堆頭是最大元素),小頂堆(堆頭是最小元素),如果懶得自己實現的話,就直接用
priority_queue
(優先順序佇列)就可以了,底層實現都是一樣的,從小到大排就是小頂堆,從大到小排就是大頂堆。本題我們就要使用
優先順序佇列
來對部分頻率進行排序。為什麼不用快排呢, 使用快排要將
map
轉換為vector
的結構,然後對整個陣列進行排序, 而這種場景下,我們其實只需要維護k個有序的序列就可以了,所以使用優先順序佇列是最優的。此時要思考一下,是使用小頂堆呢,還是大頂堆?
有的同學一想,題目要求前 K 個高頻元素,那麼果斷用大頂堆啊。
那麼問題來了,定義一個大小為k的大頂堆,在每次移動更新大頂堆的時候,每次彈出都把最大的元素彈出去了,那麼怎麼保留下來前K個高頻元素呢。
而且使用大頂堆就要把所有元素都進行排序,那能不能只排序k個元素呢?
所以我們要用小頂堆,因為要統計最大前k個元素,只有小頂堆每次將最小的元素彈出,最後小頂堆裡積累的才是前k個最大元素。
尋找前k個最大元素流程如圖所示:(圖中的頻率只有三個,所以正好構成一個大小為3的小頂堆,如果頻率更多一些,則用這個小頂堆進行掃描)
AC程式碼:
class Solution {
public:
// 小頂堆
class mycomparison {
public:
bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) {
return lhs.second > rhs.second;
}
};
vector<int> topKFrequent(vector<int>& nums, int k) {
// 要統計元素出現頻率
unordered_map<int, int> map; // map<nums[i],對應出現的次數>
for (int i = 0; i < nums.size(); i++) {
map[nums[i]]++;
}
// 對頻率排序
// 定義一個小頂堆,大小為k
priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pri_que;
// 用固定大小為k的小頂堆,掃面所有頻率的數值
for (unordered_map<int, int>::iterator it = map.begin(); it != map.end(); it++) {
pri_que.push(*it);
if (pri_que.size() > k) { // 如果堆的大小大於了K,則佇列彈出,保證堆的大小一直為k
pri_que.pop();
}
}
// 找出前K個高頻元素,因為小頂堆先彈出的是最小的,所以倒序來輸出到陣列
vector<int> result(k);
for (int i = k - 1; i >= 0; i--) {
result[i] = pri_que.top().first;
pri_que.pop();
}
return result;
}
};
- 時間複雜度: O(nlogk)
- 空間複雜度: O(n)
擴充
大家對這個比較運算在建堆時是如何應用的,為什麼左大於右就會建立小頂堆,反而建立大頂堆比較困惑。
確實 例如我們在寫快排的cmp函式的時候,
return left>right
就是從大到小,return left<right
就是從小到大。優先順序佇列的定義正好反過來了,可能和優先順序佇列的原始碼實現有關(我沒有仔細研究),我估計是底層實現上優先佇列隊首指向後面,隊尾指向最前面的緣故!
總結:
棧與佇列是我們熟悉的不能再熟悉的資料結構,但它們的底層實現,很多同學都比較模糊,這其實就是基礎所在。
可以出一道面試題:
棧裡面的元素在記憶體中是連續分佈的麼?
這個問題有兩個陷阱:
- 陷阱1:棧是容器介面卡,
底層容器使用不同的容器
,導致棧內資料在記憶體中不一定
是連續分佈的。- 陷阱2:預設情況下,預設底層容器是
deque
,那麼deque
在記憶體中的資料分佈是什麼樣的呢? 答案是:不連續的
,下文也會提到deque。所以這就是考察候選者基礎知識扎不紮實的好問題。
大家還是要多多重視起來!
瞭解了棧與佇列基礎之後,那麼可以用棧與佇列:棧實現佇列 (opens new window)和 棧與佇列:佇列實現棧 (opens new window)來練習一下棧與佇列的基本操作。
值得一提的是,用棧與佇列:用佇列實現棧還有點彆扭 (opens new window)中,其實只用一個佇列就夠了。
一個佇列在模擬棧彈出元素的時候只要將佇列頭部的元素(除了最後一個元素外) 重新新增到佇列尾部,此時在去彈出元素就是棧的順序了。
棧在系統中的應用
- 如果還記得編譯原理的話,編譯器在詞法分析的過程中處理括號、花括號等這個符號的邏輯,就是使用了棧這種資料結構。
- 再舉個例子,linux系統中,cd這個進入目錄的命令我們應該再熟悉不過了。
cd a/b/c/../../
這個命令最後進入a目錄,系統是如何知道進入了a目錄呢 ,這就是棧的應用。這在leetcode上也是一道題目,編號:
71. 簡化路徑
- 遞迴的實現是棧:每一次遞迴呼叫都會把函式的區域性變數、引數值和返回地址等壓入呼叫棧中,然後遞迴返回的時候,從棧頂彈出上一次遞迴的各項引數,所以這就是
遞迴為什麼可以返回上一層位置
的原因。括號匹配問題
棧與佇列:系統中處處都是棧的應用 (opens new window)
括號匹配是使用棧解決的經典問題。
建議要寫程式碼之前要分析好有哪幾種不匹配的情況,如果不動手之前分析好,寫出的程式碼也會有很多問題。
先來分析一下 這裡有三種不匹配的情況,
- 第一種情況,字串裡
左方向的括號多餘了
,所以不匹配。- 第二種情況,括號沒有多餘,但是括號的
型別沒有匹配上
。- 第三種情況,字串裡
右方向的括號多餘了
,所以不匹配。這裡還有一些技巧,在匹配左括號的時候,將一個右括號先入棧,就只需要比較當前元素和棧頂相不相等就可以了,比左括號先入棧程式碼實現要簡單的多了!
字串去重問題
棧與佇列:匹配問題都是棧的強項 (opens new window)中講解了字串去重問題。
思路就是可以把字串順序放到一個棧中,然後如果相同的話棧就彈出,這樣最後棧裡剩下的元素都是相鄰不相同的元素了。
逆波蘭表示式問題
棧與佇列:有沒有想過計算機是如何處理表示式的? (opens new window)中講解了求逆波蘭表示式。
本題中每一個子表示式要得出一個結果,然後拿這個結果再進行運算,那麼這豈不就是一個相鄰字串消除的過程,和棧與佇列:匹配問題都是棧的強項 (opens new window)中的對對碰遊戲非常像。
佇列的經典題目
滑動視窗最大值問題
在棧與佇列:滑動視窗裡求最大值引出一個重要資料結構 (opens new window)中講解了一種資料結構:
單調佇列
。這道題目還是比較繞的,如果第一次遇到這種題目,需要反覆琢磨琢磨
主要思想是佇列沒有必要維護視窗裡的所有元素,只需要維護有可能成為視窗裡最大值的元素就可以了,同時保證佇列裡的元素數值是由大到小的。
那麼這個維護元素單調遞減的佇列就叫做單調佇列,即單調遞減或單調遞增的佇列。C++中沒有直接支援單調佇列,需要我們自己來一個單調佇列
而且不要以為實現的單調佇列就是 對視窗裡面的數進行排序,如果排序的話,那和優先順序佇列又有什麼區別了呢。
設計單調佇列的時候,pop,和push操作要保持如下規則:
- pop(value):如果視窗移除的元素
value
等於單調佇列的出口元素,那麼佇列彈出元素,否則不用任何操作- push(value):如果push的元素
value
大於入口元素的數值,那麼就將佇列出口的元素彈出,直到push元素的數值小於等於佇列入口元素的數值為止保持如上規則,每次視窗移動的時候,只要問
que.front()
就可以返回當前視窗的最大值。一些同學還會對單調佇列都有一些困惑,首先要明確的是,題解中單調佇列裡的pop和push介面,僅適用於本題。
單調佇列不是一成不變的,而是不同場景不同寫法,總之要保證佇列裡單調遞減或遞增的原則,所以叫做單調佇列。
不要以為本題中的單調佇列實現就是固定的寫法。
我們用
deque
作為單調佇列的底層資料結構,C++中deque
是stack
和queue
預設的底層實現容器(這個我們之前已經講過),deque
是可以兩邊擴充套件的,而且deque
裡元素並不是嚴格的連續分佈的。
求前 K 個高頻元素
棧與佇列:求前 K 個高頻元素和佇列有啥關係? (opens new window)中講解了求前 K 個高頻元素。
透過求前 K 個高頻元素,引出另一種佇列就是優先順序佇列。
什麼是優先順序佇列呢?
其實就是一個披著佇列外衣的堆,因為優先順序佇列對外介面只是從隊頭取元素,從隊尾新增元素,再無其他取元素的方式,看起來就是一個佇列。
而且優先順序佇列內部元素是自動依照元素的權值排列。那麼它是如何有序排列的呢?
預設情況下
priority_queue
利用max-heap
(大頂堆)完成對元素的排序,這個大頂堆是以vector
為表現形式的complete binary tree
(完全二叉樹)。什麼是堆呢?
堆是一棵完全二叉樹,樹中每個結點的值都不小於(或不大於)其左右孩子的值。 如果父親結點是大於等於左右孩子就是大頂堆,小於等於左右孩子就是小頂堆。
所以大家經常說的大頂堆(堆頭是最大元素),小頂堆(堆頭是最小元素),如果懶得自己實現的話,就直接用
priority_queue
(優先順序佇列)就可以了,底層實現都是一樣的,從小到大排就是小頂堆,從大到小排就是大頂堆。本題就要使用優先順序佇列來對部分頻率進行排序。 注意這裡是對部分資料進行排序而不需要對所有資料排序!
所以排序的過程的時間複雜度是 \(O(\log k)\) ,整個演算法的時間複雜度是 \(O(n\log k)\) 。