:反向遍歷解決“最后一個單詞的長度”)
C語言刷題和用腳本語言刷題完全是兩種體驗。拿這道“最后一個單詞的長度”來說在Python里一行s.strip().split()[-1]就收工了但在C語言里沒有現(xiàn)成的split工具你得自己面對字符串、指針和邊界條件。這也是很多初學(xué)者在LeetCode 58這道Easy題上翻車的原因——不是不會寫邏輯而是不知道該怎么處理“特殊情況”。題目本身很簡單給一個由英文字母和空格組成的字符串返回最后一個單詞的長度。單詞就是連續(xù)的非空格字符。比如Hello World返回5 fly me to the moon 返回4luffy is still joyboy返回6。這篇文章我會從思路推導(dǎo)、代碼實現(xiàn)、邊界測試到調(diào)試踩坑完整過一遍適合剛開始刷題、或者想鞏固C字符串基本功的人參考。1. 一道Easy題的隱藏難度在哪里先說個反直覺的事實這道題通過率并不低但每年論壇上都有人發(fā)帖問“為什么我的代碼提交不過”。問題幾乎都出在同一個地方——大家習(xí)慣性地把字符串當(dāng)成一個可以用“分割”處理的序列卻沒意識到C語言里沒有split你只能一個字符一個字符地判斷。1.1 送分題背后的語言差異LeetCode 58的官方難度是Easy題目約束也不復(fù)雜字符串長度最大10^4只包含大小寫英文字母和空格字符而且保證至少有一個單詞。如果你用高級語言這題的解法就是調(diào)庫。但C語言里沒有這種“一句話”的便利你必須自己設(shè)計遍歷邏輯。這種差異正是C語言題目容易被低估的原因。我第一次做這道題時第一反應(yīng)是正著遍歷用一個count記錄當(dāng)前單詞長度遇到空格就清零最后輸出count不就行了嗎這個思路聽上去完全合理但寫到一半你會發(fā)現(xiàn)一個尷尬的問題——如果字符串末尾有空格最后一次遇到空格已經(jīng)把計數(shù)器清零了你記錄的就變成了“最后一個單詞之后的所有空格”的長度也就是0。必須額外用一個變量保存“上一次非空格段的長度”這邏輯一下子就拐彎了。1.2 為什么要選擇“倒著找”的視角正序遍歷確實能解但代碼繞。換個視角會清爽得多既然要找的是“最后一個”單詞為什么不直接從字符串末尾往前找從末尾往前遇到的第一段連續(xù)非空格字符就是我們要的答案。這樣既不需要保存歷史狀態(tài)也不需要額外的數(shù)組或變量一次掃描就能解決。這種“倒著處理”的思路在真實開發(fā)中也很常見。比如解析日志文件時你往往只關(guān)心行尾的最后一個字段處理用戶輸入時你只想拿到末尾那段有效數(shù)據(jù)。從末尾定位目標(biāo)往往比從開頭一路找過去省事得多。2. 反向雙指針從右往左數(shù)非空格字符把思路轉(zhuǎn)換成可執(zhí)行的算法其實就是兩個循環(huán)第一個循環(huán)跳過末尾所有的空格第二個循環(huán)數(shù)非空格字符的數(shù)量。這兩個循環(huán)配合就能精確鎖定最后一個單詞的長度。2.1 跳過末尾空格第一輪掃描的意義假設(shè)字符串是Hello World 末尾有三個空格。我們要找的最后一個單詞是World長度為5。第一步就是把World后面的三個空格全部跳過去讓指針停到d的位置。這一步很關(guān)鍵它把“末尾可能有空格”的干擾徹底清除了后續(xù)的數(shù)數(shù)操作才會準(zhǔn)確。有不少人會問如果字符串末尾沒有空格怎么辦沒有空格的話第一輪掃描直接不會執(zhí)行指針停在字符串末尾的\0前一位正好是最后一個單詞的最后一個字符。所以“先跳空格”這個操作對“無空格”的情況天然免疫不需要特判。2.2 數(shù)非空格字符第二輪掃描的終止條件指針停到最后一個單詞的末尾后開始往前逐個字符檢查只要不是空格計數(shù)器就加1繼續(xù)往前一旦遇到空格說明這個單詞已經(jīng)完整數(shù)完立刻退出循環(huán)。這里有兩個終止條件需要你非常警惕遇到空格字符說明單詞左邊是邊界指針已經(jīng)越過字符串開頭索引變成-1說明這個單詞就是字符串的第一個單詞前面沒有空格了。第二個條件是新手最容易漏掉的。舉個例子字符串是Hello沒有空格第二輪掃描會一直往前數(shù)數(shù)到H之后指針繼續(xù)減到-1這時如果循環(huán)條件里沒有“索引大于等于0”的判斷代碼就會訪問s[-1]屬于非法內(nèi)存訪問。雖然偶爾“碰巧”能跑出結(jié)果但這是典型的未定義行為在線判題系統(tǒng)里可能直接崩潰。2.3 用生活化類比理解兩次掃描想象你面前有一摞文件最上面幾張是空白的底下壓著寫有關(guān)鍵信息的紙。你要找最后一張寫了字的紙。正常人不會從最底下開始一張一張翻開檢查而是直接從上往下把空白紙抽掉然后拿起第一張有字的紙數(shù)它的字?jǐn)?shù)就行。這個“先抽空白紙再數(shù)關(guān)鍵信息”的流程就是上面的兩次掃描。3. C語言實現(xiàn)與代碼逐行拆解下面給出一個干凈、無額外依賴的C語言實現(xiàn)。這個版本只用到標(biāo)準(zhǔn)庫里的strlen核心邏輯完全是自己寫的。#include stdio.h #include string.h int lengthOfLastWord(char* s) { int len strlen(s); int i len - 1; int count 0; while (i 0 s[i] ) { i--; } while (i 0 s[i] ! ) { count; i--; } return count; }3.1 每個關(guān)鍵步驟在干什么第一步strlen(s)求出字符串長度并讓索引i指向字符串最后一個字符的位置。這里有個細(xì)節(jié)C字符串以\0結(jié)尾s[len - 1]才是最后一個有效字符s[len]是字符串結(jié)束符不需要參與遍歷。第二步第一個while循環(huán)里i 0和s[i] 是兩個必須同時滿足的條件。有人會問為什么先判i 0再取s[i]因為運算符有短路特性如果i已經(jīng)變成-1后面的s[i]根本不會執(zhí)行避免了非法訪問。如果把順序反過來遇到全空格字符串時代碼會在i -1時去讀s[-1]輕則結(jié)果隨機重則段錯誤。第三步第二個while循環(huán)開始數(shù)數(shù)。每遇到一個非空格字符count加1。循環(huán)退出時要么遇到了空格說明單詞左邊有邊界要么i變成了-1說明單詞已經(jīng)延伸到了字符串開頭。兩種情況都表示我們已經(jīng)數(shù)完了整個單詞。3.2 三種常見寫法對比除了上面這種“索引法”還有兩種寫法在C語言里也很常見。第一種是純指針版本不顯式使用下標(biāo)int lengthOfLastWord(char* s) { char* p s strlen(s) - 1; while (p s *p ) p--; int count 0; while (p s *p ! ) { count; p--; } return count; }這個版本的核心邏輯和索引法完全一致只是把“索引和數(shù)組名”換成了“指針和指針比較”。指針版本更貼近C語言的底層習(xí)慣但要注意p s這種比較在指針越界時同樣是未定義行為。標(biāo)準(zhǔn)庫的寫法里更穩(wěn)妥的是先判斷p s再解引用和索引法的短路判斷是一個道理。第二種是不依賴strlen的“完全手寫”版本int lengthOfLastWord(char* s) { int len 0; while (s[len] ! \0) len; int i len - 1; while (i 0 s[i] ) i--; int count 0; while (i 0 s[i] ! ) { count; i--; } return count; }這種寫法在一些禁止調(diào)庫的刷題環(huán)境里更穩(wěn)妥因為strlen本身也是一次完整遍歷提前算出來并不會影響整體復(fù)雜度。三者的時間代價都是掃描常數(shù)量級區(qū)別只在于代碼風(fēng)格和可讀性。我個人推薦索引版本入門邏輯直接調(diào)試時不容易在指針比較上栽跟頭。3.3 關(guān)于返回值和題目約束的說明題目的約束是字符串長度至少為1且至少有一個單詞所以len - 1不會變成負(fù)數(shù)。但在實際工程代碼里調(diào)用者可能傳空字符串、全空格字符串甚至空指針進(jìn)來。你會看到很多題解不考慮這些情況因為在線判題系統(tǒng)的測試用例嚴(yán)格符合題目約束。不過把“空指針判斷”寫進(jìn)去是一種職業(yè)習(xí)慣尤其是你準(zhǔn)備把這段邏輯復(fù)用到生產(chǎn)環(huán)境時。加上防御性判斷本身不會影響這道題的答案而且能讓你的代碼更穩(wěn)健。int lengthOfLastWord(char* s) { if (s NULL) return 0; // 其余邏輯不變 }4. 邊界情況才是本題的主戰(zhàn)場6類測試用例一個算法題真正考你的往往不是主流程而是邊界。這一道題我整理了6類必須手動驗證的用例每一類都對應(yīng)一種容易出錯的輸入形態(tài)。用例輸入預(yù)期輸出考察點基礎(chǔ)案例Hello World5正常雙單詞場景末尾多空格Hello World 5第一輪跳過空格是否生效開頭多空格 Hello World5開頭的空格是否被正確忽略全空格 0會不會發(fā)生越界單個單詞Hello5第二輪掃描到開頭能否退出單個字母H1最小規(guī)模輸入4.1 全空格用例為什么會卡住很多人按照題目約束輸入不會全空格。但很多用戶在用本地編輯器測試時會順手敲幾個空格進(jìn)去然后發(fā)現(xiàn)程序輸出一堆奇怪的結(jié)果甚至崩潰。原因前面已經(jīng)說過第二個循環(huán)里如果i降到-1后仍然去判斷s[i]就會越界。如果你寫了while (s[i] )而忘了i 0這個用例基本上必掛。我建議所有人在本地測試時至少把全空格用例跑一遍。這不僅僅是刷題的問題而是“萬一調(diào)用方給了臟數(shù)據(jù)我的程序會怎樣”的意識。真實項目中用戶輸入末尾多幾個空格、整個字段全是空格的情況太常見了這類防御性編寫習(xí)慣會讓你少挨很多罵。4.2 只有一個單詞時第二輪循環(huán)如何正確退出當(dāng)輸入是Hello字符串末尾沒有空格第一個循環(huán)直接退出i停在o的位置。第二個循環(huán)會依次檢查o、l、l、e、H每檢查一個count加1。檢查完H后i變成-1此時i 0不再成立循環(huán)終止返回5。整個過程沒有越界。很多人在這一步會寫成while (s[i] ! )完全忘記檢查索引下界。在Hello這種用例上s[-1]讀取的是數(shù)組前一個地址的內(nèi)容可能是垃圾數(shù)據(jù)也可能恰好不是空格導(dǎo)致繼續(xù)循環(huán)結(jié)果徹底亂套。這個問題在本地測試中不一定穩(wěn)定復(fù)現(xiàn)但在在線判題環(huán)境里很可能暴露出來。4.3 混合極端情況的組合測試除了上面這種單一邊界還要習(xí)慣做“組合測試”比如 a 開頭三個空格、一個字母、結(jié)尾三個空格。這種用例能同時考驗第一輪跳過空格、第二輪正確識別單詞、以及索引不越界三個能力。組合測試是刷題里最容易漏掉的因為很多人測試時習(xí)慣只改一個變量而真實系統(tǒng)的輸入往往同時存在多種邊界問題。我一般會用這樣一組用例做快速回歸全空格、單字母、單字母加前后空格、長串單詞加末尾空格、超長字符串加混雜大小寫。只要這幾個都過了主流程基本穩(wěn)了。5. 調(diào)試實錄我在提交過程中翻過的車前面講了很多理論這里分享一下我自己實際調(diào)試這道題時踩過的幾個坑。每一個都讓我多花了時間如果你正在做這題看到這些能少走點彎路。5.1 第一個坑把\0和 搞混我第一次拿這道題練手時下意識在循環(huán)里寫了s[i] ! \0作為判定條件。這個錯誤非常隱蔽因為字符串末尾確實是\0而且程序在某些用例下能跑出正確答案。比如Hello World從末尾往前找遇到d之前沒有任何空格while 循環(huán)在第一個空格就退出\0根本不會被遇到所以字符串里有沒有\(zhòng)0參與判斷根本不會影響該用例的結(jié)果。但放到Hello World 這個用例上第一輪跳過空格時會一直往前跳直到遇到W才停而\0在d后面的位置整個過程中\(zhòng)0同樣沒有被訪問到。真正能暴露問題的用例是a這種最短單詞從a往左一步就是越界位置這時候訪問內(nèi)存可能讀到任意值。所以一定要記住\0是字符串終止符 是空格字符二者不一樣循環(huán)里判斷結(jié)束要用索引邊界而不是靠\0當(dāng)兜底。5.2 第二個坑strlen 的返回值類型陰了一下strlen的返回類型是size_t本質(zhì)上是無符號整數(shù)。如果直接把strlen(s) - 1賦給一個int變量在大多數(shù)平臺上沒問題因為在int能表示的范圍內(nèi)無符號轉(zhuǎn)到有符號沒有語義變化??梢坏┳址L度為0strlen(s) - 1會變成size_t類型的最大值賦給int后變成一個負(fù)數(shù)。雖然題目約束保證了長度至少為1但在本地測試時輸入空串我的第一版代碼就出現(xiàn)了索引負(fù)數(shù)的情況走了完全錯誤的流程。用int len strlen(s);顯式轉(zhuǎn)換一下可讀性和安全性都更好。5.3 第三個坑用本地編譯器過了提交后卻超時嚴(yán)格來說這道題不會超時但我曾經(jīng)把正序遍歷寫法里的變量count用成了static局部變量。結(jié)果多次調(diào)用之間狀態(tài)沒有清零第二次調(diào)用拿到的是上一次遺留的計數(shù)整個程序行為錯亂。類似這種“狀態(tài)殘留”問題在刷題時不多見但在寫真實服務(wù)時很常見——一個全局狀態(tài)被多個邏輯共用又忘了復(fù)位。提示刷題時優(yōu)先使用局部變量不要用static或全局變量保存中間狀態(tài)。C語言里static變量的初始化時機和生命周期與普通局部變量完全不同很多詭異問題都是從這里冒出來的。6. 把這道題的思路移植到真實開發(fā)場景LeetCode 58看起來像是純粹的面試題但它背后的“從末尾截取一段有效數(shù)據(jù)”思想在真實開發(fā)里應(yīng)用非常廣泛。至少有三類場景能直接套用。6.1 日志文件行尾字段提取處理日志時行尾往往是時間戳或狀態(tài)碼而行的中間可能包含用戶輸入內(nèi)容本身就有空格。比如[INFO] 2025-01-15 10:23:45 user login success如果我想提取最后的success直接正著切需要根據(jù)空格位置計算多個字段的起始和結(jié)束非常繁瑣。用同樣的倒查思路先跳過末尾的空格再數(shù)到下一個空格或行首就能拿到最后一個單詞。我寫過的一個小工具就是靠這個邏輯在幾萬行日志里提取狀態(tài)碼速度比逐行split快得多。6.2 處理用戶輸入中的多余空格用戶輸入帶前后空格是常態(tài)。比如一個表單里的搜索關(guān)鍵詞用戶在末尾敲了一個空格直接拿去查詢就會出問題。很多C語言項目里沒有現(xiàn)成的trim函數(shù)你完全可以仿照這道題的邏輯從末尾把空格跳過再配合開頭跳過空格的邏輯就得到了一個輕量級的“去首尾空格”工具。這種自己動手寫的小函數(shù)比引庫更可控。6.3 協(xié)議解析中的“最后一個字段”場景一些簡單的自定義網(wǎng)絡(luò)協(xié)議字段用空格分隔但最后一節(jié)不允許有空格。解析時用同樣的思路從報文末尾倒著找最后一個非空字段就能快速提取結(jié)束標(biāo)志或校驗碼。倒查的好處是天然忽略前面字段長度變化帶來的影響——不管前面有多少個字段、字段內(nèi)容是什么我關(guān)心的只是尾部那一段。我個人的體會是刷題的最大收獲往往不是記住某道題的答案而是把題目里的思路拆出來變成自己寫代碼時的一種條件反射。LeetCode 58的代碼量不大卻把“兩次掃描”“索引邊界”“防御性判斷”這幾個C語言核心能力完整過了一遍。如果你剛開始刷題不妨把這幾個點當(dāng)成比正確答案更重要的收獲。