通過棧和隊列的學習,我們似乎會感覺到其實數據結構還是非常簡單的嘛。當然,這只是一個開始,我們從順序表、鏈表開始,到現在的棧和隊列,其實都是爲了將來在鋪路。在樹和圖的遍歷算法中,都可以見到棧和隊列的身影。在這裏,我們先簡單的看看棧和隊列的一些實際應用。
迴文題
假設有一段文字,我們要判斷它是不是“迴文”(不是回族兄弟的文字)。就可以應用棧來解決這個問題。
迴文指的就是將這段文字一分爲二之後,前面一段內容和後面一段內容是完全相同的,但是順序是相反的。比如非常出名的:上海自來水來自海上。上海自來,來自海上,這樣的兩段結構在一句話裏就構成了一段迴文。又比如雙數長度的一段字符:abcddcba,這也是一段迴文。
類似的這種題目其實很容易出現在一些簡單的算法面試題中,相信也有不少小夥伴已經看出端倪了,我們可以將前半段入棧,然後再一個一個的出棧與後半段進行比對就可以判斷當前的字符串是否是迴文了。別光說不練,我們就上代碼來實現。
$string1 = 'abcdedcba';
$string2 = 'abcdeedcba';
$string3 = 'abcdefcba';
function getIsPlalindrome($string)
{
if (gettype($string) != 'string') {
return false;
}
$strlen = strlen($string);
$mid = floor($strlen / 2);
$arr = [];
if ($strlen < 2) {
return false;
}
// 入棧
for ($i = 0; $i < $mid; $i++) {
array_push($arr, $string[$i]);
}
$j = $mid;
$i = $strlen % 2 == 0 ? $mid : $mid + 1; // $i 從中位數開始
for (; $i < $strlen; $i++) {
$v = $arr[$j - 1]; // 獲得棧頂元素
if ($v != $string[$i]) {
return false;
}
array_pop($arr); // 彈出棧頂元素
$j--;
}
if ($arr) {
return false;
}
return true;
}
var_dump(getIsPlalindrome($string1)); // bool(true)
var_dump(getIsPlalindrome($string2)); // bool(true)
var_dump(getIsPlalindrome($string3)); // bool(false)
很簡單吧,就是使用 array_push() 和 array_pop() 來進行的順序棧的操作而已。唯一需要注意的就是對於字符長度奇偶數的不同,我們要取的中位數也相應的要發生改變。
迴文算法還是比較簡單的,另外還經常會出現的像是簡單的括號匹配、算式運算、中綴轉後綴表達式這類的題目都是棧的典型算法面試題。大家可以自行查找相關的內容來嘗試嘗試。
遞歸
在講遞歸前,我們要弄清楚一件事情,那就是:編程語言中的函數調用本質上就是棧的調用。
怎麼理解這句話呢?當我們執行代碼時,如果遇到一個函數,總是會先進入到這個函數中,運行完這個函數中的代碼之後纔會再回到原來的代碼執行線中繼續執行調用當前這個函數的代碼。比如下面這段代碼。
function testA()
{
echo 'A start.', PHP_EOL;
testB();
echo 'A end.', PHP_EOL;
}
function testB()
{
echo 'B start.', PHP_EOL;
echo 'B end.', PHP_EOL;
}
echo 'P start.', PHP_EOL;
testA();
echo 'P end.', PHP_EOL;
// P start.
// A start.
// B start.
// B end.
// A end.
// P end.
當前頁面 P ,在運行到 testA() 函數時,就進入了 testA() 函數內部執行其內部的代碼,也就是 P -> A 。然後 testA() 函數又調用了 testB() 函數,那麼現在就進入了 testB() 中並執行該函數體內的代碼,也就是 P -> A -> B 。當 testB() 的代碼運行完成後,返回到 testA() 繼續執行 testA() 函數體裏面的內容,最後回到頁面 P 繼續向下執行,也就是 B -> A -> P 。
上面這段描述如果一次沒看明白的話,請再多看幾次,細細品。這不就是一個棧的調用過程嘛!!
這麼一看,在編程語言中,棧還真是深入骨髓般的存在。因爲你只要是在開發代碼,那麼你一定就是在運用棧這個東西了。而“遞歸”,則是棧的更典型的實現。
function recursion($n)
{
if ($n == 0 || $n == 1) {
return 1;
}
$result = recursion($n - 1) * $n;
return $result;
}
echo recursion(10), PHP_EOL;
這是一段簡單的階乘算法的遞歸實現,由於遞歸會建立一個棧,所以我們這段代碼最先計算出來的是的棧底的 n 是 1,出棧返回 1 之後,再出棧時就是用 1 乘以 2 ,再繼續出棧就是 2 乘以 3 ,依次類推,直到計算出從 1 到 10 的階乘結果。
遞歸相關的面試題也是我們在面試中非常常見的內容,所以我們一定要把握好遞歸其實就是棧的一種表現形式,然後運用棧的思想來解構整個遞歸的調用過程。
隊列應用
最後,我們再講講隊列的一些實際應用。隊列在代碼層面其實並沒有太多很好的示例,比較常見的可能有兩個隊列合併出隊(舞伴問題)或者兩組隊列一起出隊,一邊出兩個另一個才能出一個之類的這種問題。大家可以自行查找一下相關的題目。相對來說,隊列的算法題在面試題中還是比較少的,包括在考研的時候也多是以選擇判斷之類的題目出現的。不過,在實際應用中,隊列現在卻是解決高併發問題的超級法寶,也是面試官判斷你經驗的一個重要內容。
在實際的項目開發中,隊列最典型的一個功能就是秒殺問題。就像搶火車票或者搶小米手機一樣,在整點的時候,大量的請求湧入,如果僅僅依靠服務器來處理,超高的併發量不僅會帶給服務器巨大壓力,而且還有可能出現各種高併發場景下才會出現的問題,比如超賣、事務異常等。(多個線程同時更新數據)
而隊列,正是解決這個問題的一把好手。通常我們會使用的隊列系統(redis、rabbitmq)都是以內存爲主的隊列系統,它們的特點就是存儲非常快。由前端(生產者)生成的大量請求都存入隊列中(入隊),然後在後臺腳本(消費者)中進行處理(出隊)。前端只需要返回一個正在處理中,或者正在排隊的提示即可,然後後臺處理完成後,通知前臺顯示結果。這樣,在一個秒殺場景中基本上就算是解決了高併發的問題了。當然,現實環境可能還需要考慮更多因素,但核心都是以隊列的方式來解決這種瞬間高併發的業務功能。
另外,隊列還有一個重要的業務場景,那就是通知、消息、郵件、短信之類的這種信息發送。因爲隊列的所能解決的一些問題的最大特點就是需要生產者/消費者的業務解耦。通常我們在羣發消息時都會批量進行大規模的發送,這時就只需要準備好消息內容和對應的手機號、設備id,就可以讓系統在後臺隊列中慢慢進行消息發送了,而不需要我們的前端一直等待消息全部發送完成。
這時,不少小夥伴又看出了一點點門道了。隊列這貨在實際應用中,就是多線程的感覺呀,JS 中的事件回調,CPU 的碎片時間輪詢可不就是一種隊列的真實應用嘛。還有設計模式中的“觀察者模式”,本身就是事件回調這種機制的一種編程範式,所以,用它來實現的很多功能中,我們都能看到隊列的影子。
總結
看看,一個棧,一個隊列,竟然都是我們在開發過程中天天要接觸的東西。是不是感覺自己的腦容量不夠用了?仔細再想想,還有哪些東西和我們的棧、隊列有關呢?其實只要把握住它們的兩個本質就可以了:棧是後進先出(LIFO)或者說是先進後出(FILO),而隊列是先進先出(FIFO)。
測試代碼:
參考資料:
《數據結構》第二版,嚴蔚敏
《數據結構》第二版,陳越
《數據結構高分筆記》2020版,天勤考研