:從狀態(tài)壓縮DP到TopK堆的Python解法)
秋招那陣子我整理過不少電商公司的編程題Shopee 2019校招這批題目尤其有意思——題目本身不掛公司Logo但你一眼就能看出它是照著電商業(yè)務出的滿減券、熱銷榜、倉配路徑、商品ID校驗。很多平時在LeetCode上刷得飛起的同學一到這種業(yè)務場景題反而卡殼問題往往不是算法基本功不行而是沒有把業(yè)務語言翻譯成算法條件。這篇文章我想從幾道典型的業(yè)務場景編程題出發(fā)講講出題邏輯、Python實現(xiàn)細節(jié)以及筆試現(xiàn)場最容易翻車的隱藏扣分點。無論你是正在準備校招還是想轉(zhuǎn)做業(yè)務后端這組思路都可以直接拿來參考。1. 先搞明白Shopee這類電商公司的筆試題為什么長這樣1.1 從2019年校招特點聊起2019年前后正是東南亞電商高速擴張的階段Shopee這類平臺對后端研發(fā)、算法崗的需求量很大。和國內(nèi)大廠喜歡出純數(shù)據(jù)結(jié)構(gòu)題不同Shopee筆試題有一個明顯傾向把電商真實業(yè)務里每天都會遇到的問題抽象成一道可以在45分鐘內(nèi)寫完的編程題。它不是單純考你會不會背紅黑樹而是考你在業(yè)務約束下能不能快速建模、寫出可運行的代碼。我當時第一感覺是題目看起來都不難比如給你一個商品價格數(shù)組、一個滿減規(guī)則讓你算最優(yōu)支付金額再比如給你一批銷量數(shù)據(jù)讓你輸出熱銷TopK。難的是你能否一眼看穿它背后對應哪個經(jīng)典算法同時把業(yè)務里那些模糊條件轉(zhuǎn)化成明確的輸入輸出約束。筆試時間有限如果讀題階段就卡住了后面基本沒戲。1.2 業(yè)務場景直接映射到算法考點我整理過這批題目的考點分布發(fā)現(xiàn)規(guī)律比較清晰業(yè)務場景考點典型數(shù)據(jù)結(jié)構(gòu)滿減券計價動態(tài)規(guī)劃 / 子集枚舉數(shù)組、位運算熱銷商品TopK堆 / 快速選擇優(yōu)先隊列倉庫配送成本動態(tài)規(guī)劃 / 圖最短路徑二維數(shù)組、鄰接表商品ID校驗字符串處理 / 狀態(tài)機哈希集合、正則訂單庫存扣減貪心 / 二分答案數(shù)組、雙指針看懂這張表你會發(fā)現(xiàn)它跟LeetCode熱門題型的分布基本一致只不過套了一層業(yè)務外殼。所以準備這類筆試核心不是去背業(yè)務背景而是把常規(guī)算法練到閉眼就能寫的熟練度。下面我挑幾類高頻題型展開講每道題都給出可復現(xiàn)的Python解法。2. 第一類必考題折扣與結(jié)算順序考的不是貪心是建模2.1 還原一道滿減券分組結(jié)算題這道題的原型大概長這樣購物車里有 n 件商品價格存在數(shù)組prices里平臺有 m 張滿減券第 i 張券的規(guī)則是“訂單金額達到threshold_i時減免discount_i”。每張券只能用一次每筆訂單最多使用一張券。你可以把任意幾件商品合并成一筆訂單也可以把一件商品單獨下一單問最低總支付金額是多少。約束是 n 不超過 15價格和門檻都是正整數(shù)。很多同學拿到題的第一反應是“按折扣力度從大到小排序能湊單就湊單”這是一個典型的錯誤示范。滿減券不是無門檻紅包它要求你先湊到門檻才能減免所以最優(yōu)策略往往是先把小額商品組合起來“夠到門檻”而不是把優(yōu)惠券硬套在大額商品上。舉個例子A商品120元B商品80元券是滿100減20如果直接對A用券B單獨支付總價是10080180但把A和B合并成200元的訂單用券后總價是180一樣。換一組數(shù)據(jù)A商品150元B商品30元同樣滿100減20分開付款是13030160合單是180-20160還是一樣。別急著下結(jié)論真實場景里多張券疊加時問題會突然變復雜。我把數(shù)據(jù)加到一個臨界點商品價格 [70, 60, 50]三張券分別是滿100減20、滿80減10、滿50減5。如果直接按門檻從高到低湊單7050用滿100減20支付10060用滿80減10不滿足門檻只能用滿50減5支付55合計155。但實際上最優(yōu)分組是7060用滿100減20支付11050用滿50減5支付45合計155再換一種分組70單獨用滿50減5支付656050用滿100減20支付90合計155??梢钥闯龃鸢覆辉倌敲粗庇^必須枚舉所有可能的分組方式。2.2 Python解法與復雜度分析n 不超過 15這個約束是一個非常明確的信號可以用狀態(tài)壓縮枚舉子集。我們把每件商品看成一個二進制位用一個整數(shù) mask 表示一組商品的集合group_price[mask]表示這個集合的總價。再用best_discount[mask]表示這個集合作為一筆訂單時能享受到的最大減免金額。剩下的問題就是把所有商品劃分成若干組讓總支付最少。這是一個典型子集DP。狀態(tài)dp[mask]表示已經(jīng)處理完mask中這些商品時的最低總支付轉(zhuǎn)移時枚舉mask的子集作為“新開的一筆訂單”from typing import List def min_payment(prices: List[int], coupons: List[List[int]]) - int: n len(prices) m 1 n group_price [0] * m for mask in range(m): s 0 for i in range(n): if mask i 1: s prices[i] group_price[mask] s best_discount [0] * m for mask in range(m): for threshold, discount in coupons: if group_price[mask] threshold: best_discount[mask] max(best_discount[mask], discount) dp [float(inf)] * m dp[0] 0 for mask in range(1, m): sub mask while sub: prev mask ^ sub cost max(0, group_price[sub] - best_discount[sub]) dp[mask] min(dp[mask], dp[prev] cost) sub (sub - 1) mask return dp[m - 1]這里面有一個細節(jié)best_discount[sub]可能大于group_price[sub]比如一張滿50減100的異常券但實際支付金額不能是負數(shù)所以外面套了一層max(0, ...)。筆試里這種邊界條件如果沒加樣例過了換一組數(shù)據(jù)就會WA。while sub枚舉子集的寫法是sub (sub - 1) mask這個技巧必須熟練它的時間復雜度是 O(3^n)n15 時大約 1400 萬次枚舉Python 在筆試時間內(nèi)可以跑完但不能再大了。2.3 這道題真正想看的三個能力第一個是識別小數(shù)據(jù)范圍的能力??吹?n 15 就要立刻想到枚舉所有組合而不是糾結(jié)貪心是否正確。第二個是狀態(tài)壓縮DP的熟練度子集枚舉的代碼必須默寫。第三個是把業(yè)務規(guī)則翻譯成程序約束的能力“每張券只能用一次”“每筆訂單最多用一張券”這些條件如果漏掉任何一個答案就會偏。這個題還有一個變體商品必須按原始順序切分成連續(xù)區(qū)間。一旦加了順序約束問題就退化成區(qū)間DP狀態(tài)從dp[mask]變成dp[i]表示前 i 件商品的最優(yōu)解轉(zhuǎn)移時枚舉最后一段的起點。我建議你把這兩個版本都寫一遍對“約束如何影響算法選型”會有更直觀的感受。3. 第二類必考題海量數(shù)據(jù)下的TopK別一上來就排序3.1 熱銷商品TopK的經(jīng)典長相第二類高頻題是熱銷榜。題目描述通常是給定 N 條商品銷量記錄格式是商品ID, 銷量輸出銷量最大的 K 個商品ID按銷量從高到低排序。N 可能很大比如 10^7 級別但 K 比較小比如 20。大部分學校課程里教的是“全部排序后取前K個”這個方法在數(shù)據(jù)量小的時候沒問題但 10^7 條記錄全排一遍時間和內(nèi)存都不劃算。TopK 的正確思路是維護一個大小為 K 的最小堆遍歷數(shù)據(jù)時如果當前元素比堆頂大就把堆頂替換掉。這樣遍歷一遍就能拿到最大的 K 個時間復雜度 O(N log K)內(nèi)存占用 O(K)。3.2 最小堆的Python實現(xiàn)細節(jié)Python 里直接用heapq模塊但有幾個坑必須注意。堆元素是元組(cnt, item_id)排序優(yōu)先級是先比較 cnt再比較 item_id。如果你想要銷量大的排前面堆里存原始銷量即可讓最小的銷量在堆頂方便替換最后輸出時再反轉(zhuǎn)。import heapq def top_k_sales(records, k): heap [] for item_id, cnt in records: if len(heap) k: heapq.heappush(heap, (cnt, item_id)) elif cnt heap[0][0]: heapq.heapreplace(heap, (cnt, item_id)) res [] while heap: res.append(heapq.heappop(heap)[1]) return res[::-1]可能有同學會問為什么不用heappushpopheapreplace和heappushpop在堆大小為 k 時的效果幾乎一樣區(qū)別在于當新元素不小于堆頂時heappushpop會先 push 再 pop而heapreplace是先 pop 再 push。兩者在這道題里結(jié)果一致但heapreplace更高效。這個細節(jié)面試官追問起來能答出區(qū)別的人不多屬于加分項。3.3 快速選擇與堆排序的實戰(zhàn)取舍除了堆TopK 還有一個經(jīng)典方案是快速選擇Quickselect平均時間復雜度 O(N)最壞 O(N2)。筆試中我會優(yōu)先寫堆原因是快速選擇屬于“期望復雜度”優(yōu)秀但代碼里涉及隨機選 pivot、邊界遞歸出錯率比堆高不少。堆方案雖然多一個 log K 的因子但 K 通常很小實際耗時完全可以接受而且代碼穩(wěn)定。再補充一個真實業(yè)務中會遇到的問題如果商品 ID 不是簡單的整數(shù)而是長字符串比如SPU20250316ABC123那么排序時的比較操作會比整數(shù)慢。堆里存(cnt, item_id)時item_id只會在銷量相同的情況下參與比較正常業(yè)務里銷量相同的商品不會太多所以影響不大。這個觀察在系統(tǒng)設計面試里同樣適用排序字段的選擇要優(yōu)先避免長字符串的頻繁比較。4. 第三類必考題最短配送路徑把DFS優(yōu)化成DP的過程4.1 題目還原網(wǎng)格配送成本第三類題型是倉配路徑。題目原型是給定一個 m 行 n 列的矩陣每個格子表示一個倉庫或配送節(jié)點格子里的數(shù)字表示經(jīng)過這個節(jié)點需要的配送成本。機器人從左上角(0,0)出發(fā)只能向右或向下走最終要到達右下角(m-1,n-1)問最小總成本是多少。這是最經(jīng)典的網(wǎng)格DPLeetCode 64題的原型。但它在校招筆試里的出現(xiàn)率極高因為代碼短、考點清晰而且可以追問空間優(yōu)化和路徑還原。4.2 從暴力遞歸到狀態(tài)轉(zhuǎn)移我見過不少同學一上來就寫DFS遞歸提交后發(fā)現(xiàn)超時。如果題目沒有特別說明數(shù)據(jù)量小網(wǎng)格題多半要往DP想dp[i][j]表示從(0,0)走到(i,j)的最小成本。由于只能向右或向下走(i,j)的前一個位置只可能是(i-1,j)或(i,j-1)所以狀態(tài)轉(zhuǎn)移方程是dp[i][j] min(dp[i-1][j], dp[i][j-1]) grid[i][j]邊界位置單獨處理第一行只能從左往右累加第一列只能從上往下累加。下面是完整實現(xiàn)def min_delivery_cost(grid): m, n len(grid), len(grid[0]) dp [[0] * n for _ in range(m)] dp[0][0] grid[0][0] for i in range(1, m): dp[i][0] dp[i-1][0] grid[i][0] for j in range(1, n): dp[0][j] dp[0][j-1] grid[0][j] for i in range(1, m): for j in range(1, n): dp[i][j] min(dp[i-1][j], dp[i][j-1]) grid[i][j] return dp[m-1][n-1]如果面試需要還原具體路徑可以額外開一個pre[i][j]記錄每個格子是從上方還是左方來的最后從右下角反推。4.3 筆試里的DP代碼怎么寫不容易翻車DP 題翻車通常翻在初始化上。第一行和第一列的初始化必須在主循環(huán)之前完成否則dp[0][0]漏掉或者dp[i-1][j]越界。我的習慣是先把邊界情況全部顯式寫出來再去寫主循環(huán)哪怕是簡單的 dp[i][j] grid[0][0]。再就是空間優(yōu)化。dp[i][j]只依賴當前行的左邊和上一行的同一列所以可以把二維數(shù)組壓縮成一維def min_delivery_cost_1d(grid): m, n len(grid), len(grid[0]) dp [0] * n dp[0] grid[0][0] for j in range(1, n): dp[j] dp[j-1] grid[0][j] for i in range(1, m): dp[0] grid[i][0] for j in range(1, n): dp[j] min(dp[j], dp[j-1]) grid[i][j] return dp[-1]筆試時先用二維版本保證正確性最后有時間再優(yōu)化。我不會建議一上來就寫一維滾動數(shù)組因為空間優(yōu)化版的初始化邏輯更容易出錯一旦寫錯Debug 花費的時間遠大于省下的那點內(nèi)存。5. 字符串與輸入輸出筆試現(xiàn)場最容易拖垮你的隱藏扣分點5.1 商品ID校驗題的正確打開方式還有一類看著簡單、實際很陰的題是字符串處理常見場景是商品ID校驗。題目可能會要求你判斷一個ID是否合法規(guī)則有好幾條比如長度8到20位、必須同時包含字母和數(shù)字、不能出現(xiàn)AAA這種連續(xù)重復三次的字符。題目本身不涉及高深算法但考察的是細心程度和對字符串API的熟練度。我的實現(xiàn)思路是這樣先做長度判斷再維護三個布爾標記分別記錄是否出現(xiàn)數(shù)字、是否出現(xiàn)字母、以及是否出現(xiàn)連續(xù)重復字符。注意連續(xù)重復三次不能只看一個方向我習慣在遍歷時同時檢查s[i] s[i-1] and s[i] s[i-2]這樣只用一次循環(huán)就能完成。def valid_product_id(s: str) - bool: if not (8 len(s) 20): return False has_digit False has_alpha False for i, ch in enumerate(s): if ch.isdigit(): has_digit True if ch.isalpha(): has_alpha True if i 2 and s[i] s[i-1] s[i-2]: return False return has_digit and has_alpha這道題最常見的扣分點是漏掉“必須包含大寫字母”里的“大寫”或者把“不能連續(xù)重復三次”誤解成“不能出現(xiàn)任何重復字符”。讀題慢一點、逐條核對規(guī)則比寫代碼手速快更重要。5.2 本地跑通但OJ報錯的常見原因字符串題牽出的另一個大坑是輸入輸出。筆試題經(jīng)常給多行輸入如果你用for i in range(n):來讀但題目第一行給的是測試用例總數(shù)不是數(shù)據(jù)條數(shù)就會多讀或少讀一行。我建議在筆試前固定一套輸入輸出模板比如用sys.stdin.read().split()一次性把所有token讀進來再按順序取用。這樣最穩(wěn)不容易被空白行和換行符干擾。import sys def solve(): data sys.stdin.read().split() idx 0 n int(data[idx]); idx 1 # 后面繼續(xù)按順序取用 if __name__ __main__: solve()另一個高頻報錯是 Python 的round()行為。它采用的是銀行家舍入不是我們數(shù)學課學的四舍五入。比如round(2.5)結(jié)果是2round(3.5)結(jié)果是4。如果題目要求精確到小數(shù)點后兩位計算金額時建議用Decimal轉(zhuǎn)成整數(shù)分再運算別依賴浮點數(shù)。5.3 關于Python版本的幾個細節(jié)順便說一下Python環(huán)境。2025年再看這套題Python 3仍然是主流但不同OJ的Python小版本可能有差異。比如int.bit_count()在 Python 3.8 里沒有3.10 之后才有字典的dict保持插入順序從 3.7 開始是語言規(guī)范。這些細節(jié)平時寫業(yè)務代碼無感但在筆試環(huán)境里可能直接影響你能不能AC。我自己的建議是用你能接受的最保守的Python 3語法寫核心邏輯避免使用太新、太花哨的語法糖。校招筆試不是為了炫技而是為了在有限時間內(nèi)寫出穩(wěn)定的正確答案。6. 刷題路線的復盤建議6.1 按題型而不是按數(shù)量來刷從這套2019年Shopee編程題能看出來電商公司筆試的題型高度集中DP、堆、字符串、二分、基礎圖論。我在輔導學弟學妹時會建議他們先按題型做橫向刷題也就是把同一類型的題集中刷10到15道而不是今天做鏈表、明天做DP、后天又跳到回溯。舉一個具體的安排例子第一周專攻DP從斐波那契數(shù)列、爬樓梯、打家劫舍這類入門題開始逐步過渡到編輯距離、背包問題、區(qū)間DP第二周專攻TopK和堆把heapq的用法練熟第三周專攻字符串寫5到8道字符串模擬題。按題型走每次練完后對這類題的套路會有體感到了考場上遇到新題也能快速歸類。6.2 做一題要有一題的沉淀我當初刷題時有個習慣每做完一道題會在題目旁邊記下三個東西考點、時間復雜度、我犯過的錯。不要小看這個動作它是把“刷題量”轉(zhuǎn)化成“解題能力”的關鍵。比如做完那道滿減券分組結(jié)算題我會寫“子集DP 最低支付金額注意 max(0, cost)”做完TopK我會寫“堆替換使用 heapreplace輸出前反轉(zhuǎn)”。這些筆記在筆試前一周復習時幫助巨大。與其把LeetCode前300題全部重刷一遍不如只看筆記里的易錯點和考點效率高很多。而且很多題你第二次刷的時候大腦會產(chǎn)生“熟悉感”而不是“理解感”如果只看答案不記筆記很容易陷入假性掌握。6.3 我的一點個人體會說回這套2019年校招編程題。我后來跟幾個參加過筆試的同學聊發(fā)現(xiàn)大家最大的共識是這些題放在今天依然不過時不是因為題目本身有多難而是因為它考察的東西是業(yè)務后端每天都要面對的基礎能力——如何在約束下建模、如何選擇合適的數(shù)據(jù)結(jié)構(gòu)、如何寫出邊界正確的代碼。編程題之外技術面還會問項目、問系統(tǒng)設計但代碼能力始終是第一道門檻。如果你正在準備類似的校招筆試我的建議是別把希望寄托在“背題”上而是把一個題型的底層邏輯吃透。比如滿減券分組那道題你理解了“n小就枚舉子集”這個判斷以后遇到類似的組合優(yōu)化問題你就能舉一反三。再比如TopK那道題你理解了“數(shù)據(jù)量大、K小時用堆”這個原則以后遇到實時排行榜、熱點統(tǒng)計思路自然就有了。編程題其實是在訓練一種思維習慣這種習慣不只在筆試時有用在真實工程里同樣值錢。