據(jù)結構實戰(zhàn):用隊列模擬買房排隊全流程)
拿到“w11必做題1買房必做2隊列”這個題目描述時我的第一反應是這標題寫得也太隨手了活像備忘錄里的一條待辦。但等你真正坐下來讀題會發(fā)現(xiàn)這恰好是數(shù)據(jù)結構課程里最值得認真做的一類練習——它用“買房排隊”這個生活場景把隊列Queue這個基礎數(shù)據(jù)結構考得明明白白。這篇文章我打算直接按“業(yè)務拆解 → 數(shù)據(jù)結構選型 → 完整實現(xiàn) → 踩坑復盤 → 工程擴展”的順序來講。不管你是正在趕作業(yè)的學生還是想復習隊列應用細節(jié)的開發(fā)者都可以照著這份思路把代碼寫出來并且能解釋清楚每個設計決策的理由。尤其是那些OJ上不報錯、但代碼評審一眼就能看出來的問題我會單獨拉一章來講。1. “買房”到底在考什么先讀懂業(yè)務再談代碼1.1 一個售樓處選房的完整業(yè)務過程題目叫“買房”但具體到代碼層面它到底想模擬什么我給你還原一個最常見的出題版本。假設售樓處有若干套房子分為幾種戶型。比如A戶型還剩2套B戶型還剩1套C戶型已經(jīng)售罄。然后有若干個客戶按照到達順序一個接一個地進入選房區(qū)。每位客戶身上帶著三個關鍵屬性姓名、心理預算、偏好的戶型。流程是客戶走到前臺說出自己想要的戶型如果這個戶型還有余量并且客戶預算足夠那么成交該戶型余量減一如果目標戶型已經(jīng)售罄或者預算不夠客戶可以選擇放棄也可以考慮備選戶型具體看題目規(guī)則不管成交還是放棄這個客戶處理完之后下一位客戶才能上來。最后程序要輸出什么一般是兩類結果一類是每套房子的成交情況另一類是每個客戶最終買到了什么、等待了多長時間。有些版本還要求統(tǒng)計成交率、平均等待時長。注意這里最核心的約束是“按照到達順序一個接一個”。這就是一個標準的先進先出過程。你不需要把客戶按預算排序也不需要按姓氏排序甚至不能因為某個客戶出價高就讓他插隊。先來后到就是唯一的規(guī)則。1.2 為什么這題的核心不是“排序”而是“排隊”很多同學看到“買房”兩個字第一反應是這不就是個排序題嗎把客戶按預算從高到低排把房子從貴到便宜排然后逐個匹配。這一步其實已經(jīng)掉進陷阱了。如果題目沒有明確說“出價高者優(yōu)先”那買房就是先到先得。兩個客戶同時看中同一套房先到的客戶預算不足后到的客戶預算反而夠了你能讓后到的客戶直接買走嗎真實業(yè)務里不可以因為客戶是按順序接待的。也就是說順序是絕對剛性的預算只能決定這個客戶買不買得起不能決定他排在哪。那用數(shù)組模擬行不行當然行但不優(yōu)雅。如果你用一個數(shù)組存放所有客戶然后每次從頭處理處理完一個就把后面的元素整體前移每次刪除的時間復雜度是 O(n)處理 n 個客戶就是 O(n2)。數(shù)據(jù)量小還能忍數(shù)據(jù)量一大就明顯超時。隊列的好處是入隊和出隊都是 O(1)整個流程跑下來是 O(n)這才符合“模擬業(yè)務過程”的本意。我習慣用一個簡單的對比來看待這類問題數(shù)組適合“隨機訪問”棧適合“后到先處理”隊列適合“先到先處理”。買房選房這個場景天然就和隊列綁定。所以這道題真正考的是你能不能把一個業(yè)務過程翻譯成數(shù)據(jù)結構操作。2. 隊列實現(xiàn)選型鏈式隊列和循環(huán)數(shù)組隊列怎么選2.1 兩種存儲結構的核心差異確定了要用隊列緊接著就面臨選型問題。數(shù)據(jù)結構課本里教過兩種隊列實現(xiàn)方式鏈式隊列和循環(huán)數(shù)組隊列。課堂上兩種都要求會寫但實際做題時選哪個取決于輸入規(guī)模。鏈式隊列的優(yōu)點是容量可以動態(tài)增長不需要事先知道客戶總數(shù)。缺點也很明顯每個節(jié)點需要額外存一個指針字段內(nèi)存開銷略大操作時還要小心指針指錯。循環(huán)數(shù)組隊列的優(yōu)點是內(nèi)存緊湊、操作簡單性能通常會比鏈式略好一點。但它要求你提前知道最大容量。如果題目給了明確的人數(shù)上限比如“客戶總數(shù)不超過10000”那用循環(huán)數(shù)組非常省心。我通常這樣判斷如果輸入第一行給了總人數(shù) n或者題目說明了規(guī)模上限我優(yōu)先用循環(huán)數(shù)組隊列如果題目是一個流式的輸入不知道什么時候結束我就用鏈式隊列。下面是一個簡單的對比表方便你快速決策維度鏈式隊列循環(huán)數(shù)組隊列容量動態(tài)增長需要預知上限內(nèi)存開銷每個節(jié)點多一個指針固定數(shù)組略省內(nèi)存入隊/出隊復雜度O(1)O(1)實現(xiàn)難度中等需處理指針較低但要注意滿/空判斷適用場景未知輸入量、頻繁增刪已知規(guī)模、OJ時間要求嚴格2.2 一個最小可用的隊列接口設計無論你選哪種實現(xiàn)方式我都強烈建議按照“接口思維”來寫隊列不要讓業(yè)務代碼直接操作隊列的內(nèi)部節(jié)點。這也是這道題隱藏的加分點代碼結構清晰。以鏈式隊列為例我會把業(yè)務字段和隊列節(jié)點分開設計。一個客戶節(jié)點可以這樣定義typedef struct Customer { char name[32]; int budget; // 心理預算 int prefer; // 偏好戶型編號 int arrive_time; // 到達時間用于統(tǒng)計等待時長 } Customer; typedef struct QNode { Customer data; struct QNode *next; } QNode; typedef struct Queue { QNode *front; QNode *rear; int size; } Queue;這里把業(yè)務數(shù)據(jù)和隊列指針分離開好處是日后如果要改隊列的數(shù)據(jù)類型不用動主邏輯。然后實現(xiàn)四個最基本的方法初始化、判空、入隊、出隊、取隊頭。注意一個細節(jié)取隊頭是讀但不刪除出隊是刪除但不返回數(shù)據(jù)。這樣拆分是為了避免你在出隊時還要考慮要不要釋放內(nèi)存的問題。void initQueue(Queue *q) { q-front q-rear NULL; q-size 0; } int isEmpty(Queue *q) { return q-size 0; } void enQueue(Queue *q, Customer c) { QNode *node (QNode *)malloc(sizeof(QNode)); node-data c; node-next NULL; if (q-rear NULL) { q-front q-rear node; } else { q-rear-next node; q-rear node; } q-size; } Customer getFront(Queue *q) { return q-front-data; } Customer deQueue(Queue *q) { Customer c q-front-data; QNode *tmp q-front; q-front q-front-next; if (q-front NULL) { q-rear NULL; } free(tmp); q-size--; return c; }這段代碼里的關鍵點有兩個。第一出隊的時候必須特判“出隊后隊列變空”的情況把 rear 也置為 NULL否則后面再入隊時 rear 還指向一個已經(jīng)釋放的節(jié)點就會出現(xiàn)野指針。第二如果你實際上不需要出隊后返回的數(shù)據(jù)可以直接調(diào)用 getFront 拿到隊頭再調(diào)用 deQueue 刪除業(yè)務邏輯會更清晰。3. 買房主流程實現(xiàn)入隊、出隊、成交判斷的分工與合作3.1 輸入階段與主循環(huán)的整體結構隊列寫完之后接下來就是業(yè)務主流程。這個主流程本身并不復雜核心就是一句話把所有客戶按順序入隊然后循環(huán)處理隊頭客戶直到隊列為空。我先把整體骨架羅列出來int main() { Queue q; initQueue(q); int n; scanf(%d, n); for (int i 0; i n; i) { Customer c; scanf(%s %d %d %d, c.name, c.budget, c.prefer, c.arrive_time); enQueue(q, c); } while (!isEmpty(q)) { Customer c getFront(q); // 1. 判斷客戶是否買得起、目標戶型是否有余量 // 2. 成交或放棄 // 3. 記錄結果 deQueue(q); } // 輸出結果 }為什么不寫成一邊出隊一邊判斷也就是直接調(diào)用 deQueue 拿到客戶數(shù)據(jù)再判斷因為可能出隊之后你才發(fā)現(xiàn)這個客戶還需要和其他狀態(tài)項比如備選方案交互如果一開始就把數(shù)據(jù)取走隊列的完整性就沒有意義了。保持“先查看隊頭再決定是否出隊”的順序可以讓邏輯更接近真實業(yè)務。3.2 分支條件的判斷次序很重要我給一個最簡單的版本每個客戶只有一個目標戶型如果目標戶型售罄或預算不足客戶直接離開。while (!isEmpty(q)) { Customer c getFront(q); if (house_count[c.prefer] 0 c.budget house_price[c.prefer]) { house_count[c.prefer]--; printf(%s bought house %d\n, c.name, c.prefer); } else { printf(%s left\n, c.name); } deQueue(q); }這段代碼看著沒有問題但如果你把兩個判斷條件反過來寫就會出現(xiàn)邏輯錯誤。比如你先判斷預算夠不夠再判斷房源余量那么“預算夠但沒有房”和“預算不夠但有房”會被混為一類輸出信息不夠準確。更隱蔽的問題是如果題目要求“目標戶型售罄時可以買備選戶型”判斷次序就更加關鍵必須先用目標戶型嘗試失敗后再用備選戶型而不是一上來就檢查備選。我給出一個帶備選戶型版本的判斷順序先看目標戶型是否有余量且預算足夠滿足則成交否則看備選戶型是否有余量且預算足夠滿足則買備選兩個都不滿足客戶離開。這個順序是嚴格按照現(xiàn)實業(yè)務邏輯來的客戶優(yōu)先選擇自己最喜歡的戶型買不到才會退而求其次。3.3 統(tǒng)計指標等待時間與成交數(shù)據(jù)的處理不少題目要求輸出平均等待時間或者判斷“誰的等待時間最長”。這時候你就要在客戶節(jié)點里記錄到達時間然后在輪到他處理時計算等待時間。int wait_time current_time - c.arrive_time;這里有幾個細節(jié)很容易出錯。第一current_time 是什么如果題目描述的是“每個客戶處理需要固定時間”那 current_time 就是從頭開始累計的處理時間如果題目描述的是“當前時刻”那 current_time 可能已經(jīng)給定。一定要仔細讀題。第二如果用 int 統(tǒng)計總等待時間客戶數(shù)量超過一定規(guī)模時會溢出。雖然業(yè)務上單個人等待時間不會很長但總和可能很大。習慣上用 long 來存累計等待時間更穩(wěn)妥。第三如果你需要知道“最后買走某套房子的客戶是誰”建議在房源結構里維護一個買家字段而不是等到最后再回頭查。4. 實測踩坑復盤這題最容易扣分的四個細節(jié)4.1 坑一把“先來后到”實現(xiàn)成“排序后處理”這個坑我見過太多次。題目剛發(fā)下來有同學一看“買房”立刻聯(lián)想到貪心算法把客戶按預算從高到低排序優(yōu)先處理出價高的人。這種方案在某些“拍賣型”題目里是對的但在這道“排隊型”題目里直接掛掉。原因很簡單業(yè)務規(guī)則決定順序算法不能擅自改變順序。如果題目明確說“客戶按到達順序依次選房”那到達順序就是一條不可違反的鐵律。你在設計算法時第一步應該是找“順序約束”而不是找“最優(yōu)策略”。怎么檢查你有沒有踩這個坑把輸入數(shù)據(jù)打亂順序再跑一遍如果結果變了說明你的算法依賴輸入順序之外的某種排序規(guī)則那就要警惕了。4.2 坑二鏈式隊列出隊后沒有釋放舊節(jié)點這屬于典型的內(nèi)存管理問題。OJ上跑的話通常不會爆出內(nèi)存泄漏的報錯最多就是內(nèi)存占用偏高。但如果是人工代碼評審這一步基本要被扣分。錯誤寫法通常是這樣的Customer deQueue(Queue *q) { QNode *tmp q-front; q-front q-front-next; return tmp-data; // 忘了 free(tmp) }每次出隊少 free 一次跑一萬個客戶就泄漏一萬個節(jié)點。更麻煩的是如果出隊的是最后一個節(jié)點你還要把 rear 置為 NULL否則后續(xù)入隊會訪問到原本已經(jīng)釋放的內(nèi)存。這兩個問題經(jīng)常同時出現(xiàn)。我建議你寫完隊列之后專門寫一個“只有三個客戶”的小測試逐一入隊再逐一出隊配合內(nèi)存檢測工具檢查是否有泄漏。這一步花費的時間很少但能避免很多隱性扣分。4.3 坑三循環(huán)數(shù)組隊列的“滿”和“空”判斷搞反如果你選用循環(huán)數(shù)組隊列最常見的問題就是“隊滿”和“隊空”的判斷條件互相混淆。經(jīng)典的實現(xiàn)方式是讓數(shù)組保留一個空位用 front 和 rear 的相對位置來判斷隊列空front rear隊列滿(rear 1) % capacity front很多同學會把隊滿條件寫成 front rear導致一個滿隊列被誤判為空然后死循環(huán)。還有同學在入隊時沒有正確處理取模操作比如q-rear q-rear 1; // 錯誤沒有取模 q-data[q-rear] c;這條代碼在連續(xù)入隊幾次之后rear 會直接越界訪問數(shù)組尾部。正確寫法是q-rear (q-rear 1) % capacity; q-data[q-rear] c;取模這件事特別容易在“看起來沒報錯”的狀態(tài)下出錯因為數(shù)組越界訪問不一定立刻崩潰可能只是偷偷覆蓋了相鄰內(nèi)存等到輸出統(tǒng)計結果時才變得莫名其妙。4.4 坑四判斷次序?qū)懛磳е乱咽垠缿粜捅辉俅钨u出這個坑往往出現(xiàn)在稍微復雜的題目版本里。比如客戶可以先判斷“預算夠不夠”再判斷“有沒有房”。如果預算不夠程序可能直接輸出離開但忽略了房源已經(jīng)被前面的人全部買完此時應該輸出“售罄”而不是“預算不足”。反過來如果先判斷“有沒有房”再判斷“預算夠不夠”那么在房源還有、但預算不夠的情況下應該繼續(xù)嘗試備選戶型而不是直接放棄。換句話說判斷分支的先后順序直接決定了業(yè)務邏輯是否自洽。我的建議是把所有可能的判斷條件列出來模擬一個客戶走完全部流程再決定代碼順序。你可以在代碼里加一條注釋把判斷順序?qū)懗鰜?/ 1. 目標戶型是否售罄 // 2. 預算是否足夠 // 3. 備選戶型是否售罄 // 4. 備選戶型是否買得起這看起來是小事但在寫復雜版本時這一步能幫你減少無數(shù)調(diào)試時間。4.5 如何用邊界測試驗證正確性作業(yè)提交之前我強烈建議你構造幾組邊界測試用例不要只拿題目給的示例跑一遍就完事。下面是我常用的幾組用例類型輸入特征期望結果空輸入客戶總量 n 0不報錯輸出為空單個客戶只有一位客戶房源充足該客戶正常成交房源歸零所有戶型數(shù)量都是 0所有客戶離開預算不足客戶預算低于任何戶型價格客戶全部離開順序競爭兩客戶搶同一戶型先到的預算不足先到的放棄后到的成交不能交換順序備選邏輯首選戶型售罄備選有余量客戶買備選戶型這些測試用例構造起來很快但價值很高。很多隱藏邏輯錯誤比如“判斷次序?qū)懛础薄绊樞虮慌判蚱茐摹痹诔R?guī)測試里看不出來邊界測試一跑就現(xiàn)出原形。5. 從課堂作業(yè)到工程實踐隊列的三種進階變形5.1 優(yōu)先隊列當VIP客戶可以插隊時“買房排隊”這個場景在真實世界中往往不是絕對公平的。有的題目會加一條規(guī)則VIP客戶可以優(yōu)先進場。這時候你再用普通隊列就不合適了。優(yōu)先隊列和非優(yōu)先隊列的區(qū)別在于出隊的不一定是入隊最早的而是優(yōu)先級最高的。在C語言里你可以用堆Heap來實現(xiàn)在C里可以直接用 priority_queue。這個數(shù)據(jù)結構在算法題里非常常見核心思路是在入隊時就把順序優(yōu)先級排好出隊時直接取堆頂。如果你做完這道“買房隊列”的必做題我建議你順手把同樣場景改成“VIP優(yōu)先”再寫一遍。你會發(fā)現(xiàn)業(yè)務規(guī)則一變數(shù)據(jù)結構選擇也跟著變這才是真正的能力考察點。5.2 雙端隊列當客戶可能反悔時有些模擬場景中客戶可能會“撤銷剛才的選房”或者新來的客戶需要排到最前面。這時雙端隊列 deque 就派上用場了它允許你在隊頭和隊尾兩端都能入隊、出隊。在“買房”這個業(yè)務里怎么用比如客戶選完房后突然決定反悔退房出來后重新排隊到末尾這個過程需要從隊尾入隊而如果題目有“老客戶優(yōu)先”的規(guī)則那就可能需要從隊頭插入。雙端隊列能同時覆蓋這兩種需求抽象能力比普通隊列強很多。5.3 隊列在真實系統(tǒng)中的同構應用學完這道作業(yè)題你會發(fā)現(xiàn)隊列的應用范圍遠比“模擬排隊”廣泛。舉幾個例子消息隊列生產(chǎn)者把消息入隊消費者按順序出隊保證消息不丟失、不重復任務調(diào)度操作系統(tǒng)中的就緒隊列CPU按時間片輪轉處理任務網(wǎng)絡請求服務器接收大量并發(fā)請求往往先放入請求隊列再逐個處理打印任務多個文檔發(fā)送到打印機打印機按提交順序逐個打印。這些場景的共同點都是“先來先服務”和買房排隊本質(zhì)上是同構的。你在未來工作中很可能遇到比這道題大得多的數(shù)據(jù)量但抽象思路完全一樣。最后再分享一個實際經(jīng)驗。在寫這類“場景模擬”題目時我建議你養(yǎng)成一個習慣在每一步關鍵操作前后打印日志比如“XX入隊當前隊頭是XX”然后拿著日志模擬一遍整個買房流程。這個方法看起來原始但排查邏輯錯誤極其高效。很多時候你以為自己的隊列出了問題其實只是判斷分支寫錯了順序。這道作業(yè)題的價值并不在于讓你背下隊列的八個方法而是在于訓練一種能力——把現(xiàn)實世界里復雜、動態(tài)、多約束的業(yè)務過程翻譯成一個只有入隊、出隊、取隊頭三種操作的簡潔模型。能順利完成這種翻譯數(shù)據(jù)結構這門課的核心目標也就達到了。