C语言刷题和用脚本语言刷题完全是两种体验。拿这道“最后一个单词的长度”来说,在Python里一行s.strip().split()[-1]就收工了,但在C语言里没有现成的split工具,你得自己面对字符串、指针和边界条件。这也是很多初学者在LeetCode 58这道Easy题上翻车的原因——不是不会写逻辑,而是不知道该怎么处理“特殊情况”。
题目本身很简单:给一个由英文字母和空格组成的字符串,返回最后一个单词的长度。单词就是连续的非空格字符。比如"Hello World"返回5," fly me to the moon "返回4,"luffy is still joyboy"返回6。这篇文章我会从思路推导、代码实现、边界测试到调试踩坑完整过一遍,适合刚开始刷题、或者想巩固C字符串基本功的人参考。
1. 一道Easy题的隐藏难度在哪里
先说个反直觉的事实:这道题通过率并不低,但每年论坛上都有人发帖问“为什么我的代码提交不过”。问题几乎都出在同一个地方——大家习惯性地把字符串当成一个可以用“分割”处理的序列,却没意识到C语言里没有split,你只能一个字符一个字符地判断。
1.1 送分题背后的语言差异
LeetCode 58的官方难度是Easy,题目约束也不复杂:字符串长度最大10^4,只包含大小写英文字母和空格字符,而且保证至少有一个单词。如果你用高级语言,这题的解法就是调库。但C语言里没有这种“一句话”的便利,你必须自己设计遍历逻辑。这种差异正是C语言题目容易被低估的原因。
我第一次做这道题时,第一反应是:正着遍历,用一个count记录当前单词长度,遇到空格就清零,最后输出count不就行了吗?这个思路听上去完全合理,但写到一半你会发现一个尴尬的问题——如果字符串末尾有空格,最后一次遇到空格已经把计数器清零了,你记录的就变成了“最后一个单词之后的所有空格”的长度,也就是0。必须额外用一个变量保存“上一次非空格段的长度”,这逻辑一下子就拐弯了。
1.2 为什么要选择“倒着找”的视角
正序遍历确实能解,但代码绕。换个视角会清爽得多:既然要找的是“最后一个”单词,为什么不直接从字符串末尾往前找?从末尾往前遇到的第一段连续非空格字符,就是我们要的答案。这样既不需要保存历史状态,也不需要额外的数组或变量,一次扫描就能解决。
这种“倒着处理”的思路在真实开发中也很常见。比如解析日志文件时,你往往只关心行尾的最后一个字段;处理用户输入时,你只想拿到末尾那段有效数据。从末尾定位目标,往往比从开头一路找过去省事得多。
2. 反向双指针:从右往左数非空格字符
把思路转换成可执行的算法,其实就是两个循环:第一个循环跳过末尾所有的空格,第二个循环数非空格字符的数量。这两个循环配合,就能精确锁定最后一个单词的长度。
2.1 跳过末尾空格:第一轮扫描的意义
假设字符串是"Hello World ",末尾有三个空格。我们要找的最后一个单词是World,长度为5。第一步就是把World后面的三个空格全部跳过去,让指针停到d的位置。这一步很关键,它把“末尾可能有空格”的干扰彻底清除了,后续的数数操作才会准确。
有不少人会问:如果字符串末尾没有空格怎么办?没有空格的话,第一轮扫描直接不会执行,指针停在字符串末尾的\0前一位,正好是最后一个单词的最后一个字符。所以“先跳空格”这个操作对“无空格”的情况天然免疫,不需要特判。
2.2 数非空格字符:第二轮扫描的终止条件
指针停到最后一个单词的末尾后,开始往前逐个字符检查,只要不是空格,计数器就加1,继续往前;一旦遇到空格,说明这个单词已经完整数完,立刻退出循环。这里有两个终止条件需要你非常警惕:
- 遇到空格字符,说明单词左边是边界;
- 指针已经越过字符串开头(索引变成
-1),说明这个单词就是字符串的第一个单词,前面没有空格了。
第二个条件是新手最容易漏掉的。举个例子,字符串是"Hello",没有空格,第二轮扫描会一直往前数,数到H之后指针继续减到-1,这时如果循环条件里没有“索引大于等于0”的判断,代码就会访问s[-1],属于非法内存访问。虽然偶尔“碰巧”能跑出结果,但这是典型的未定义行为,在线判题系统里可能直接崩溃。
2.3 用生活化类比理解两次扫描
想象你面前有一摞文件,最上面几张是空白的,底下压着写有关键信息的纸。你要找最后一张写了字的纸。正常人不会从最底下开始一张一张翻开检查,而是直接从上往下把空白纸抽掉,然后拿起第一张有字的纸,数它的字数就行。这个“先抽空白纸,再数关键信息”的流程,就是上面的两次扫描。
3. C语言实现与代码逐行拆解
下面给出一个干净、无额外依赖的C语言实现。这个版本只用到标准库里的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 每个关键步骤在干什么
第一步,strlen(s)求出字符串长度,并让索引i指向字符串最后一个字符的位置。这里有个细节:C字符串以\0结尾,s[len - 1]才是最后一个有效字符,s[len]是字符串结束符,不需要参与遍历。
第二步,第一个while循环里,i >= 0和s[i] == ' '是两个必须同时满足的条件。有人会问,为什么先判i >= 0再取s[i]?因为&&运算符有短路特性,如果i已经变成-1,后面的s[i]根本不会执行,避免了非法访问。如果把顺序反过来,遇到全空格字符串时,代码会在i = -1时去读s[-1],轻则结果随机,重则段错误。
第三步,第二个while循环开始数数。每遇到一个非空格字符,count加1。循环退出时,要么遇到了空格,说明单词左边有边界;要么i变成了-1,说明单词已经延伸到了字符串开头。两种情况都表示我们已经数完了整个单词。
3.2 三种常见写法对比
除了上面这种“索引法”,还有两种写法在C语言里也很常见。
第一种是纯指针版本,不显式使用下标:
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; }这个版本的核心逻辑和索引法完全一致,只是把“索引和数组名”换成了“指针和指针比较”。指针版本更贴近C语言的底层习惯,但要注意p >= s这种比较在指针越界时同样是未定义行为。标准库的写法里,更稳妥的是先判断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; }这种写法在一些禁止调库的刷题环境里更稳妥,因为strlen本身也是一次完整遍历,提前算出来并不会影响整体复杂度。三者的时间代价都是扫描常数量级,区别只在于代码风格和可读性。我个人推荐索引版本入门,逻辑直接,调试时不容易在指针比较上栽跟头。
3.3 关于返回值和题目约束的说明
题目的约束是字符串长度至少为1,且至少有一个单词,所以len - 1不会变成负数。但在实际工程代码里,调用者可能传空字符串、全空格字符串甚至空指针进来。你会看到很多题解不考虑这些情况,因为在线判题系统的测试用例严格符合题目约束。不过把“空指针判断”写进去是一种职业习惯,尤其是你准备把这段逻辑复用到生产环境时。加上防御性判断本身不会影响这道题的答案,而且能让你的代码更稳健。
int lengthOfLastWord(char* s) { if (s == NULL) return 0; // 其余逻辑不变 }4. 边界情况才是本题的主战场:6类测试用例
一个算法题真正考你的,往往不是主流程,而是边界。这一道题我整理了6类必须手动验证的用例,每一类都对应一种容易出错的输入形态。
| 用例 | 输入 | 预期输出 | 考察点 |
|---|---|---|---|
| 基础案例 | "Hello World" | 5 | 正常双单词场景 |
| 末尾多空格 | "Hello World " | 5 | 第一轮跳过空格是否生效 |
| 开头多空格 | " Hello World" | 5 | 开头的空格是否被正确忽略 |
| 全空格 | " " | 0 | 会不会发生越界 |
| 单个单词 | "Hello" | 5 | 第二轮扫描到开头能否退出 |
| 单个字母 | "H" | 1 | 最小规模输入 |
4.1 全空格用例为什么会卡住很多人
按照题目约束,输入不会全空格。但很多用户在用本地编辑器测试时会顺手敲几个空格进去,然后发现程序输出一堆奇怪的结果,甚至崩溃。原因前面已经说过:第二个循环里如果i降到-1后仍然去判断s[i],就会越界。如果你写了while (s[i] == ' ')而忘了i >= 0,这个用例基本上必挂。
我建议所有人在本地测试时至少把全空格用例跑一遍。这不仅仅是刷题的问题,而是“万一调用方给了脏数据,我的程序会怎样”的意识。真实项目中,用户输入末尾多几个空格、整个字段全是空格的情况太常见了,这类防御性编写习惯会让你少挨很多骂。
4.2 只有一个单词时,第二轮循环如何正确退出
当输入是"Hello",字符串末尾没有空格,第一个循环直接退出,i停在o的位置。第二个循环会依次检查o、l、l、e、H,每检查一个count加1。检查完H后,i变成-1,此时i >= 0不再成立,循环终止,返回5。整个过程没有越界。
很多人在这一步会写成while (s[i] != ' '),完全忘记检查索引下界。在"Hello"这种用例上,s[-1]读取的是数组前一个地址的内容,可能是垃圾数据,也可能恰好不是空格,导致继续循环,结果彻底乱套。这个问题在本地测试中不一定稳定复现,但在在线判题环境里很可能暴露出来。
4.3 混合极端情况的组合测试
除了上面这种单一边界,还要习惯做“组合测试”,比如" a "(开头三个空格、一个字母、结尾三个空格)。这种用例能同时考验第一轮跳过空格、第二轮正确识别单词、以及索引不越界三个能力。组合测试是刷题里最容易漏掉的,因为很多人测试时习惯只改一个变量,而真实系统的输入往往同时存在多种边界问题。
我一般会用这样一组用例做快速回归:全空格、单字母、单字母加前后空格、长串单词加末尾空格、超长字符串加混杂大小写。只要这几个都过了,主流程基本稳了。
5. 调试实录:我在提交过程中翻过的车
前面讲了很多理论,这里分享一下我自己实际调试这道题时踩过的几个坑。每一个都让我多花了时间,如果你正在做这题,看到这些能少走点弯路。
5.1 第一个坑:把'\0'和' '搞混
我第一次拿这道题练手时,下意识在循环里写了s[i] != '\0'作为判定条件。这个错误非常隐蔽,因为字符串末尾确实是'\0',而且程序在某些用例下能跑出正确答案。比如"Hello World"从末尾往前找,遇到d之前没有任何空格,while 循环在第一个空格就退出,'\0'根本不会被遇到,所以字符串里有没有'\0'参与判断根本不会影响该用例的结果。但放到"Hello World "这个用例上,第一轮跳过空格时会一直往前跳,直到遇到W才停,而'\0'在d后面的位置,整个过程中'\0'同样没有被访问到。真正能暴露问题的用例是"a"这种最短单词:从a往左一步就是越界位置,这时候访问内存可能读到任意值。所以一定要记住,'\0'是字符串终止符,' '是空格字符,二者不一样,循环里判断结束要用索引边界,而不是靠'\0'当兜底。
5.2 第二个坑:strlen 的返回值类型阴了一下
strlen的返回类型是size_t,本质上是无符号整数。如果直接把strlen(s) - 1赋给一个int变量,在大多数平台上没问题,因为在int能表示的范围内,无符号转到有符号没有语义变化。可一旦字符串长度为0,strlen(s) - 1会变成size_t类型的最大值,赋给int后变成一个负数。虽然题目约束保证了长度至少为1,但在本地测试时输入空串,我的第一版代码就出现了索引负数的情况,走了完全错误的流程。用int len = strlen(s);显式转换一下,可读性和安全性都更好。
5.3 第三个坑:用本地编译器过了,提交后却超时
严格来说这道题不会超时,但我曾经把正序遍历写法里的变量count用成了static局部变量。结果多次调用之间状态没有清零,第二次调用拿到的是上一次遗留的计数,整个程序行为错乱。类似这种“状态残留”问题在刷题时不多见,但在写真实服务时很常见——一个全局状态被多个逻辑共用,又忘了复位。
提示:刷题时优先使用局部变量,不要用
static或全局变量保存中间状态。C语言里static变量的初始化时机和生命周期与普通局部变量完全不同,很多诡异问题都是从这里冒出来的。
6. 把这道题的思路移植到真实开发场景
LeetCode 58看起来像是纯粹的面试题,但它背后的“从末尾截取一段有效数据”思想,在真实开发里应用非常广泛。至少有三类场景能直接套用。
6.1 日志文件行尾字段提取
处理日志时,行尾往往是时间戳或状态码,而行的中间可能包含用户输入内容,本身就有空格。比如:
[INFO] 2025-01-15 10:23:45 user login success如果我想提取最后的success,直接正着切需要根据空格位置计算多个字段的起始和结束,非常繁琐。用同样的倒查思路,先跳过末尾的空格,再数到下一个空格或行首,就能拿到最后一个单词。我写过的一个小工具就是靠这个逻辑在几万行日志里提取状态码,速度比逐行split快得多。
6.2 处理用户输入中的多余空格
用户输入带前后空格是常态。比如一个表单里的搜索关键词,用户在末尾敲了一个空格,直接拿去查询就会出问题。很多C语言项目里没有现成的trim函数,你完全可以仿照这道题的逻辑,从末尾把空格跳过,再配合开头跳过空格的逻辑,就得到了一个轻量级的“去首尾空格”工具。这种自己动手写的小函数比引库更可控。
6.3 协议解析中的“最后一个字段”场景
一些简单的自定义网络协议,字段用空格分隔,但最后一节不允许有空格。解析时用同样的思路:从报文末尾倒着找最后一个非空字段,就能快速提取结束标志或校验码。倒查的好处是天然忽略前面字段长度变化带来的影响——不管前面有多少个字段、字段内容是什么,我关心的只是尾部那一段。
我个人的体会是,刷题的最大收获往往不是记住某道题的答案,而是把题目里的思路拆出来,变成自己写代码时的一种条件反射。LeetCode 58的代码量不大,却把“两次扫描”“索引边界”“防御性判断”这几个C语言核心能力完整过了一遍。如果你刚开始刷题,不妨把这几个点当成比正确答案更重要的收获。