
1. 螺旋矩陣這道題到底在考什么LeetCode 54這題我愿稱之為“模擬類題目的教科書”。題目描述很簡單給你一個m行n列的矩陣按照順時針螺旋順序返回矩陣中的所有元素。看起來就是個遍歷真正動手寫的時候才發現邊界處理能把人繞暈。這題能被收錄進 Hot 100不是因為它難而是因為它背后考察的東西非常基礎且重要對二維數組索引的敏感度、邊界條件的把控能力以及最基本的模擬思想——把“沿著螺旋路徑走”這件事用代碼準確地表達出來。我在刷題群里見過不少朋友一上來就試圖找數學規律、推導坐標公式結果越推越復雜。實際上這題的正解就是老老實實地“走迷宮”每一步都明確知道自己在哪、要去哪、什么時候該拐彎。用C寫這道題還能順帶鞏固vector的二維操作、方向數組的定義、循環不變量的維護這些基本功。這篇文章會從思路選型講起然后把兩種主流寫法的代碼逐行拆開最后聊聊我實際提交過程中踩過的坑和總結的調試技巧。不管你是剛開始刷Hot 100的新手還是準備面試想快速復習的老手這篇應該都能給你一點參考。2. 為什么“模擬法”是這道題的正解而不是數學公式2.1 螺旋遍歷的本質是一個“狀態機”先想一個問題如果讓你在紙上手動按螺旋順序圈出一個矩陣你的大腦執行的是什么指令其實就兩條沿著當前方向一直走走到頭了越界或者遇到已經走過的格子就順時針轉90度繼續走。直到所有格子都被訪問過為止。這就是一個典型的有限狀態機狀態是“當前位置當前方向”轉移條件是“下一步是否合法”。所謂模擬法就是把大腦里這套規則原封不動地翻譯成代碼。有人會想能不能用數學公式直接算出第k個位置的行列坐標對于某些特殊矩陣可以但通用性很差而且推導過程容易出錯。模擬法的優勢在于它的邏輯與人類直覺完全一致寫出來之后正確性一目了然調試也方便。在面試或筆試場景下“能快速寫出正確代碼”遠比“寫出炫技的數學解法”更實際。2.2 兩條技術路線的對比轉向法 vs 分層法模擬螺旋遍歷社區里最常見的寫法有兩種我分別稱為“轉向法”和“分層法”。轉向法維護一個方向數組dirs {{0,1},{1,0},{0,-1},{-1,0}}分別對應右、下、左、上四個方向。每次嘗試往前走一步如果下一步越界或者撞上已經訪問過的格子就切換方向。為了知道哪些格子訪問過需要一個同樣大小的visited二維數組。分層法則像是“剝洋蔥”。維護四個邊界變量top, bottom, left, right每一輪按“從左到右、從上到下、從右到左、從下到上”遍歷當前最外層的一條邊遍歷完一條邊就收縮對應的邊界。當top bottom或left right時結束。兩種方法的時間復雜度都是O(m*n)因為每個格子恰好訪問一次。空間復雜度上轉向法額外需要O(m*n)的visited數組分層法只需要幾個整型變量是O(1)額外空間。對比維度轉向法分層法核心思想狀態機方向切換邊界收縮按邊遍歷額外空間O(m*n) visited數組O(1) 四個邊界變量代碼量稍短但易錯點隱蔽稍長但結構清晰出錯概率方向判斷、visited條件容易寫混邊界更新時機容易寫錯推薦場景快速AC、追求簡潔面試講解、強調可讀性我個人在刷題時兩種都會寫但面試時更推薦分層法。原因很簡單它的每一步都在“做事”而不是在“判斷”代碼讀起來更像是在描述“怎么螺旋”而不是在描述“怎么防止出錯”。對于需要邊寫邊講思路的面試場景分層法更容易讓對方跟上你的節奏。2.3 為什么說這題是“模擬類”的敲門磚模擬題在算法競賽和面試題里的地位很特殊。它不考高深的算法設計考的是把現實規則精確翻譯成代碼的能力。螺旋矩陣就是這類題目的典型代表——規則極其簡單沒有任何公式需要背但寫起來卻能暴露很多基本功問題數組下標有沒有越界、循環不變式有沒有想清楚、邊界條件有沒有遺漏。把這道題吃透之后再去做像“旋轉圖像”“之字形打印矩陣”“島嶼數量”這類二維數組遍歷的題目會有一種打通任督二脈的感覺。因為它們底層共享同一套能力在二維坐標系里安全地移動、準確地判斷邊界。我見過有些同學把一道題刷完就扔感覺“會了”下次換個馬甲又不認識了。我的建議是刷完螺旋矩陣之后可以在草稿紙上把二維數組的四個角坐標寫一遍把“右移、下移、左移、上移”對應的行列變化規律自己推一遍。這個基本功打牢了后面遇到任何矩陣遍歷題都不慌。3. C實現細解從方向數組到邊界收縮3.1 轉向法用一個方向數組控制“走路”先給出轉向法的完整代碼我用的是vectorvectorint存儲矩陣這也是LeetCode C題目的標準輸入格式。class Solution { public: vectorint spiralOrder(vectorvectorint matrix) { if (matrix.empty() || matrix[0].empty()) return {}; int m matrix.size(); int n matrix[0].size(); vectorint res; res.reserve(m * n); vectorvectorbool visited(m, vectorbool(n, false)); // 方向數組右、下、左、上 int dirs[4][2] {{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; int dir 0; // 當前方向索引0右 1下 2左 3上 int row 0, col 0; for (int i 0; i m * n; i) { res.push_back(matrix[row][col]); visited[row][col] true; // 計算下一步的位置 int nextRow row dirs[dir][0]; int nextCol col dirs[dir][1]; // 如果下一步越界或者已經訪問過就需要轉向 if (nextRow 0 || nextRow m || nextCol 0 || nextCol n || visited[nextRow][nextCol]) { dir (dir 1) % 4; nextRow row dirs[dir][0]; nextCol col dirs[dir][1]; } row nextRow; col nextCol; } return res; } };這段代碼的核心邏輯就一句話每次先假設“繼續往前走”如果發現走不通就轉彎。dir (dir 1) % 4是方向切換的標準寫法% 4保證索引在0到3之間循環。這里有一個細節值得注意為什么不是先判斷再走而是先走一步再判斷其實兩種寫法等價但“先算下一步、再判斷、再更新”的方式更直觀也更不容易漏掉邊界情況。我在代碼里用nextRow和nextCol暫存下一步坐標就是為了避免在判斷和更新之間出現“用了舊坐標”的bug。visited數組是轉向法必須的。沒有它程序在走到第二圈時會把已經收錄過的格子當作“可走的空格”然后一頭撞進死胡同。有些朋友覺得visited數組浪費空間想用“走過的格子就標記為特殊值”的辦法但那樣會修改原始輸入面試時可能被追問不太推薦。3.2 分層法四個邊界變量“剝洋蔥”分層法的思路更像是在“切蛋糕”。每一輪操作都固定處理當前最外圈的四條邊處理完一圈就往里縮一層。class Solution { public: vectorint spiralOrder(vectorvectorint matrix) { if (matrix.empty() || matrix[0].empty()) return {}; int top 0; int bottom matrix.size() - 1; int left 0; int right matrix[0].size() - 1; vectorint res; res.reserve(matrix.size() * matrix[0].size()); while (top bottom left right) { // 1. 從左到右遍歷上邊界 for (int j left; j right; j) { res.push_back(matrix[top][j]); } top; // 2. 從上到下遍歷右邊界 for (int i top; i bottom; i) { res.push_back(matrix[i][right]); } right--; // 3. 從右到左遍歷下邊界注意要檢查是否還有有效行 if (top bottom) { for (int j right; j left; j--) { res.push_back(matrix[bottom][j]); } bottom--; } // 4. 從下到上遍歷左邊界同理檢查是否還有有效列 if (left right) { for (int i bottom; i top; i--) { res.push_back(matrix[i][left]); } left; } } return res; } };分層法的關鍵在最后兩條邊。為什么需要if (top bottom)和if (left right)這兩種檢查因為當矩陣只有一行時第一條邊“從左到右”就把這一行遍歷完了此時top變成了1大于bottom0第三、第四條邊就不應該執行。如果不加檢查matrix[bottom][j]訪問到的其實是已經被遍歷過的那一行元素會導致重復收錄。這個bug藏得挺深因為它在m n的方陣上完全不會觸發只有矩陣退化成一維的時候才暴露。我一開始刷的時候就是在測試[[1,2,3,4]]這個用例時發現結果里出現了重復元素。3.3 兩種方法我推薦你重點掌握哪一種如果你只能記住一種寫法我的建議是記住分層法。原因有三條它不需要額外的visited數組空間更省它不需要方向數組和取模運算代碼里的“魔法成分”更少它的每一步都對應一個明確的物理動作走完一條邊、收縮一個邊界講解起來非常自然。轉向法可以作為進階理解因為它更接近“狀態機”的思維方式在一些更復雜的模擬題比如“機器人模擬行走”那類里方向數組是標配。兩種寫法都值得動手敲一遍體會一下各自的思維模式差異。我在實際刷題時會刻意訓練自己拿到題目先想清楚“用什么數據結構維護狀態”再想“循環終止條件是什么”最后才動手寫。螺旋矩陣這道題轉向法的狀態是“坐標方向”終止條件是“走了m*n步”分層法的狀態是“四個邊界值”終止條件是“上下邊界或左右邊界交叉”。想清楚這兩件事寫代碼就只是翻譯問題了。4. 邊界條件、復雜度分析與測試用例4.1 容易被忽視的三個邊界場景螺旋矩陣的惡心之處不在于主流程而在于幾個邊界場景。第一個是空矩陣也就是matrix.empty()或者matrix[0].empty()的情況。不判空直接訪問matrix[0]會觸發未定義行為輕則運行時錯誤重則在面試官面前出洋相。標準寫法就是在函數開頭統一處理。第二個是單行或單列矩陣。比如[[1,2,3,4]]或[[1],[2],[3]]。分層法里需要靠那兩個if守衛來防止重復遍歷轉向法里由于每個格子只入隊一次天然不會重復但仍要關注方向切換是否會把索引帶出界。第三個是已經走過的格子“堵路”的情形。這在轉向法中通過visited數組解決在分層法中則通過收縮邊界解決。兩者哲學不同一個是“標記已訪問”一個是“讓已訪問區域從地圖上消失”。我整理了一張自測用例表每次寫完螺旋矩陣的代碼都會跑一遍測試用例預期輸出考察點[][]空矩陣判空[[]][]空行判斷[[1,2,3,4]][1,2,3,4]單行防止重復[[1],[2],[3]][1,2,3]單列防止越界[[1,2],[3,4]][1,2,4,3]最小方陣[[1,2,3],[4,5,6],[7,8,9]][1,2,3,6,9,8,7,4,5]標準3x3[[1,2,3,4],[5,6,7,8],[9,10,11,12]][1,2,3,4,8,12,11,10,9,5,6,7]3x4矩形4.2 復雜度分析為什么這是最優解時間復雜度和空間復雜度是面試必問題。時間復雜度很顯然是O(m*n)因為無論哪種寫法每個矩陣元素恰好被訪問一次。這里無法優化到更低因為輸出本身就有m*n個元素下界就是O(m*n)。空間復雜度要分情況說。輸出數組res本身不算在額外空間里的話轉向法需要O(m*n)的visited數組分層法只需要O(1)的邊界變量。如果面試官追問“能不能把空間壓到O(1)”分層法就是答案。這也是我推薦它的另一個理由。這里有個小優化很多人忽略res.reserve(m * n)。提前分配好容量可以避免vector在push_back過程中反復擴容搬運元素在矩陣很大的時候能省下不少時間。雖然LeetCode上跑測試用例不一定感覺得到但這是個好習慣。4.3 我調試時必用的一個技巧打印機模式寫這類矩陣遍歷題最怕的就是“腦子里的索引”和“代碼里的索引”對不上。我的習慣是先在本地加一行調試輸出把每一步訪問的坐標打出來// 調試代碼提交前記得刪除 cout ( row , col ) - matrix[row][col] endl;然后配合一個小矩陣比如3x3或3x4手動在草稿紙上模擬一遍把每一步應該訪問的坐標寫出來再和程序輸出對比。只要坐標序列能對得上結果就一定是對的。這個方法聽起來原始但真的能救急。有次我調一個旋轉矩陣的題就是靠打印坐標發現自己在“向下走”時多走了一格導致整個序列錯位。坐標一錯看起來就是“訪問了不該訪問的格子”很快就鎖定了問題。5. 從螺旋矩陣延伸出去相關題目與面試考點5.1 一道題帶出一類題常見的變種螺旋矩陣不是孤立的一道題它身上掛著一串兄弟姐妹。LeetCode 59題“螺旋矩陣II”正好是逆向操作給定正整數n生成一個包含1到n2的螺旋矩陣。輸入和輸出對調核心邏輯幾乎一樣區別只是把“遍歷讀取”換成“遍歷寫入”。刷完54題再去做59題體感會很輕松。劍指Offer 29題“順時針打印矩陣”和54題一模一樣唯一的區別是輸入格式可能是一個vectorvectorint也可能是一個C風格的二維數組指針。刷面試題時會頻繁遇到。還有一個有趣的變種是LeetCode 885題“螺旋矩陣III”它從矩陣外的某個點開始螺旋走需要在“越界時仍然繼續走只在回到矩陣內時記錄元素”。這個題的“邊界條件”更反直覺你不能因為當前坐標越界就停下反而要等它繞回來。解法依然可以套用方向數組模擬但終止條件變成了“已經收錄了r*c個元素”。LeetCode 2326題“螺旋矩陣IV”則把鏈表和螺旋矩陣結合需要先把鏈表節點逐個填入螺旋矩陣。這類復合題考察的是“把不同數據結構拼在一起”的能力從側面說明螺旋遍歷是個非常基礎的構造模塊。我刷題的一個心得是遇到一個核心題型就把它的變種集中吃掉。螺旋矩陣這個系列54、59、885、2326四道題一起刷比單獨刷十道不相關的題更能建立體系感。5.2 面試中關于這道題的高頻追問面試考螺旋矩陣通常不是只讓寫代碼后面往往跟一串追問考察你對代碼的理解深度。第一個追問大概率是“你的代碼空間復雜度是多少能不能優化”。這就是在給分層法遞話。如果你上來就用轉向法就得解釋visited數組的存在意義然后說“如果希望節省空間可以改成邊界收縮的寫法”。能主動給出優化方案在面試官眼里是加分項。第二個追問是“如果矩陣不是矩形而是鋸齒狀的每行長度不一樣你的代碼會怎么處理”。這個場景在C里其實就是vectorvectorint的每行長度可以不同。解法需要改成逐行確認右邊界不能直接取matrix[0].size()當作通用列數。這題考察的是“你的代碼是死板的還是健壯的”。第三個追問是“螺旋遍歷和深度優先搜索有什么關系”。其實轉向法的visited數組加方向數組本質就是一個DFS從左上角出發優先往右走走不通就順時針轉向。理解了這層關系遇到“迷宮尋路”類問題會容易上手很多因為它們共享同一套“坐標方向visited”的框架。5.3 這道題背后的“模擬法”能遷移到哪些場景模擬法作為一類算法思想應用范圍遠不止矩陣遍歷。操作系統里頁面置換算法的時鐘Clock算法就是用一個循環指針和一個標志位數組模擬“掃描一圈找到替換頁面”和螺旋矩陣的轉向法有異曲同工之妙。圖形學里的多邊形掃描填充、游戲里的貪吃蛇移動、機器人路徑規劃里的沿墻走Wall Following底層都涉及“方向狀態邊界判斷路徑記錄”這套邏輯。所以不要覺得“我刷了一道二維數組題而已”。你真正練的是把現實世界的規則抽象成循環和分支的能力這個能力在C開發中的價值遠高于記住某個具體的API。我自己在做圖像處理項目時就經常用到“按照一定順序遍歷像素鄰域”的代碼寫起來輕車熟路就是刷這類矩陣題打下的底子。6. 實戰心得我從這道題里提煉的刷題方法論6.1 畫圖是解矩陣題的第一步永遠不要省很多同學打開LeetCode看完題目就埋頭寫代碼寫一半發現索引搞錯了又回來讀題。這種“先寫后想”的方式對于螺旋矩陣這種規約復雜的題大概率會浪費時間。我的習慣是在草稿紙上畫一個3x3的方陣手動按螺旋順序標記1到9然后觀察行和列的變化規律。這個過程看似笨拙但能幫你提前發現“行號變化還是列號變化”“從第幾列開始到第幾列結束”之類的關鍵信息。用程序員的話說這叫“先建立心智模型再翻譯成代碼”。我曾經直接把一個4x4矩陣的螺旋路徑畫出來然后用箭頭標出每一步的坐標變化寫著寫著就發現規律了右走n步、下走m-1步、左走n-1步、上走m-2步……照這個規律也能寫出一種解法而且不容易出錯。這就是畫圖的威力。6.2 刷題不要只求AC要對拍和復盤這道題我第一次AC用的是轉向法提交通過后我很得意。后來我看到題解區有人提到分層法才意識到“我的解法雖然對但不是最優”。如果是在面試現場被追問“空間能否O(1)”我可能會卡殼。所以我現在刷題有個習慣拿到一道題至少看三種解法自己寫一遍題解區最高票解法再對比一下和自己的思路差在哪。對拍測試也很有用寫一個隨機矩陣生成器把兩種解法的輸出塞給同一個校驗函數對比能發現很多“恰好通過只是運氣好”的隱藏bug。螺旋矩陣這題轉向法和分層法我都寫過代碼風格完全不同。每次重寫都會發現細節在變好——比如reserve、比如邊界變量的初始化順序、比如空矩陣的判空位置。這些點滴的改進才是刷題真正的積累。6.3 用C寫這類題的一些風格建議C刷題和Python刷題的習慣很不一樣。Python寫起來行數少很多人喜歡把多個邏輯塞進一行C則更強調步驟清晰、變量命名表意。我建議變量名不要用i和j一把梭。top、bottom、left、right這種名字讀代碼的人一眼就能看到“邊界在哪”比a、b、c好理解十倍。在LeetCode評論區看別人代碼時我也更青睞那些“變量名即注釋”的寫法。另一個建議是用res.reserve(m * n)提前分配空間。LeetCode的判題環境里一個大型矩陣動輒幾十萬個元素vector反復擴容拷貝的耗時不是零。雖然跑測試用例可能看不出差別但在真實項目中處理大數據量時這行代碼能帶來肉眼可見的性能提升。最后C11及以后的版本里vectorvectorint的遍歷盡量用范圍for循環或者把matrix.size()存到局部變量里避免在循環條件里反復調用。這算是C性能優化里的入門習慣了。