1. 字符串在C语言里不是“类型”,而是一段带结束约定的内存
网上讨论牛客101的字符串题,很多帖子上来就给你贴代码,告诉你“照着写就完事”。但我建议先把视角拉高一点:C语言里压根没有字符串类型。你看遍标准库头文件也找不到一个叫string的结构体。所谓字符串,本质上就是一个以\0(ASCII码为0的字符)结尾的char数组。这道“结束约定”才是C语言字符串所有坑的源头。
很多人从Python、Java转过来学C,第一反应是“字符串操作不是有现成函数吗,strlen、strcpy、strcmp随便调”。这没错,但那些函数全部建立在一个前提上:内存里有一串连续的char,并且最后一个有效字符后面紧跟着一个'\0'。一旦这个约定被破坏,所有库函数都会行为异常,而且没有报错提示,只有玄学般的乱码、段错误。
牛客101的字符串部分,题目本身不难,但想要一次通过并不容易。我见过太多人卡在“本地运行好好的,一交上去就答案错误”,或者“用scanf读入后,后面逻辑怎么都不对”。这些问题90%都能追溯到对字符串内存模型的理解不到位。
1.1 字符数组与字符指针:两种写法背后的内存差异
先看两行最常见的代码:
char str1[] = "hello"; // 写法一:字符数组 char *str2 = "hello"; // 写法二:字符指针表面上看都是“存了一个hello字符串”,但底层的区别非常大。
写法一会在栈上分配6个字节,分别是'h' 'e' 'l' 'l' 'o' '\0'。这块内存是可读可写的,你可以修改str1[0] = 'H',完全合法。
写法二干的事情完全不同:"hello"这个字符串字面量被编译器放到了只读区(在Linux下通常是.rodata段),而str2只是个指向这段只读内存的指针。如果你执行str2[0] = 'H',程序大概率直接崩溃,这就是段错误的经典来源之一。
在牛客101的题目里,凡是需要对字符串内容做修改(逆序、排序、大小写转换),都必须用字符数组接收输入。如果题目只是让你遍历统计、判断比较,用指针也能过。我建议刷题阶段统一用字符数组,不要贪图指针写法的那点灵活性——在你还没把内存布局吃透之前,字符数组的容错率更高。
还有一个必须掌握的细节:数组名在表达式里会“退化”为指针。printf("%s", str1)能通过编译,就是因为str1在这里被隐式转换成了指向首元素的指针。这个退化规则还牵涉到sizeof的问题,下面单开一节讲。
1.2 '\0'结束符:最容易忽略的第四个字节
初学者最容易忽略的一件事:字符串"abc"不是一个3字节的数组,而是4字节——'a' 'b' 'c' '\0'。这个多余的字节就是结束符,它决定了字符串在哪里结束。
举一个我在实际阅卷时经常见到的错误:有人想构造一个长度为n的字符串,写了这样的代码:
char str[100]; for (int i = 0; i < n; i++) { str[i] = 'a' + i; }然后直接printf("%s", str),输出结果是“abc…一堆不明字符”。为什么?因为str数组里第n个位置没有被赋值,而局部数组没有默认初始化的保证,那块内存里存的是上一次调用留下的残留数据。printf在找\0时发现了残留数据里的某个字节不是\0,就把后面那些垃圾全部打印出来了。
正确做法有两个:要么在循环结束后手动补上str[n] = '\0',要么在声明时就清零:
char str[100] = {0};两者本质都是保证\0存在于正确位置。
还有一个反直觉的点:'0'和'\0'是两个完全不同的字符。'0'的ASCII码是48,是数字字符零;'\0'的ASCII码是0,是结束符。写代码时千万不能混。字符串"123"内部存的字节是49 50 51 0,最后那个0才是结束符。如果你错把'0'当成结束符放进字符串,strlen会把它当普通字符统计进去,后续所有长度相关的逻辑全部错乱。
1.3 strlen、sizeof、指针退化:三个高频混淆点
这三个东西在字符串题里出现频率极高,也是最容易让新手翻车的知识组合。
strlen是函数,作用是计算字符串长度,从首地址开始逐字节找\0,找到就返回中间隔了多少个字符。时间复杂度是O(n)。
sizeof是编译期运算符,求的是变量或类型占用的字节总数。对同一个数组,sizeof和strlen的结果可能完全不一样:
char str[100] = "hello"; sizeof(str); // 100,整个数组的字节数 strlen(str); // 5,第一个\0前面的字符个数刷题时常见的错误是:申请了char str[100],读入内容后,用for (int i = 0; i < sizeof(str); i++)去遍历。这会把数组里100个字节全部过一遍,包括后面一堆没意义的空字节。虽然不一定会崩,但逻辑完全错了。
更隐蔽的是指针退化带来的sizeof陷阱。你把数组传给函数:
void print_len(char str[]) { printf("%zu\n", sizeof(str)); }你以为传进去的是数组,其实函数形参里的char str[]在编译时会被调整成char *str,sizeof(str)这个时候得到的是指针大小——64位系统上是8字节。这就是为什么很多人在主函数里计算长度没问题,写进函数就傻了。
我把这个知识点按住强调一次:在主函数里sizeof(数组名)能拿到正确值,因为主函数里的数组名还没有退化;一旦作为参数传进函数,数组名就变成了指针,sizeof再也不是数组大小。
牛客101这类在线评测系统,编译不开高警告级别的话,这些错误往往不会报错,只是结果不对。排查起来非常恼人。理解了这三点,字符串题的一半坑你已经避开了。
2. 牛客101字符串题型的解题套路与模板代码
把牛客101刷过一遍之后,你会发现字符串题虽然题目名字千变万化,但归类下来就四板斧:逆序、比较、排序分割、数字转换。每一种都有固定的解题套路,我按自己整理的习惯分成四组,给出模板和理由。
2.1 逆序输出与双指针:一道题延伸出三种考法
字符串逆序输出是牛客101里最基础的题,通常要求把输入字符串倒序打印。网上最常见的解法是开一个新数组,从后往前拷贝:
char str[105], rev[105]; scanf("%s", str); int len = strlen(str); for (int i = 0; i < len; i++) { rev[i] = str[len - 1 - i]; } rev[len] = '\0'; printf("%s\n", rev);这个写法能过,但我更推荐双指针原地逆序的模板:
void reverse(char *s) { int left = 0; int right = strlen(s) - 1; while (left < right) { char tmp = s[left]; s[left] = s[right]; s[right] = tmp; left++; right--; } }为什么推荐原地版本?因为它只遍历一次,时间O(n),额外空间O(1)。更重要的是,双指针这套思路可以平移到牛客101里的很多变形题:
- 判断回文:左右指针往中间走,逐个比较字符,不等就返回假。
- 删除字符串中的指定字符:快慢指针,快指针遍历,慢指针只记录要保留的字符。
- 反转单词顺序:先反转整个字符串,再按空格边界反转每个单词。
我实际刷题时发现,那些标着“加强版”“进阶”的字符串题,几乎都是双指针的变体。把双指针练熟了,遇到新题的第一反应快很多,直接往双指针上靠,往往能解。
2.2 字符串长度与相等比较:手写strlen和strcmp的正确姿势
牛客101里有一类题,题目要求你“不用库函数”实现字符串长度计算。这类题的原意就是考察指针运算。手写strlen的标准答案:
int my_strlen(const char *s) { const char *p = s; while (*p) { p++; } return p - s; }这里最关键的是最后那个p - s。两个指针指向同一个数组时做减法,结果是它们之间差了几个元素。因为p最终停在\0的位置,而\0的下标就是字符串长度,所以这个差值恰好等于长度。
字符串相等的判断,绝大多数人知道要用strcmp,但用法经常出错。strcmp的返回值规则是:相等返回0;s1小于s2返回负数;s1大于s2返回正数。
一个特别常见的错误写法:
if (strcmp(a, b) == 1) { // 想表达a>b }这是不对的。strcmp的返回值在C标准里只规定了正负,没有规定具体数值。有的编译器返回1,有的返回两个字符的ASCII码差值,比如'b' - 'a'可能得到1,但'c' - 'a'可能返回2。你写== 1,在某个编译器上碰巧能过,换个环境就翻车。
正确姿势永远是与0比较:
if (strcmp(a, b) == 0) // 相等 if (strcmp(a, b) > 0) // a大于b if (strcmp(a, b) < 0) // a小于b排序题里经常用最后两种判断来决定交换顺序,这也是为什么我每次写排序都要在比较那行停下来想三秒的原因。
2.3 字符串排序:二维数组交换时的strcpy陷阱
字符串排序的典型场景是:输入n个字符串,按字典序输出。我的建议是用二维字符数组存储,然后配合strcmp做比较。
存储形式:
char arr[100][105]; // 最多100个字符串,每个长度不超过104读入的时候用scanf("%s", arr[i])。注意这里不需要写&,因为arr[i]已经退化为指向该行首元素的指针了。这个细节在群里见过好几个人问,写&arr[i]反而类型不对。
排序用冒泡或选择都行:
for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (strcmp(arr[j], arr[j + 1]) > 0) { char tmp[105]; strcpy(tmp, arr[j]); strcpy(arr[j], arr[j + 1]); strcpy(arr[j + 1], tmp); } } }交换部分必须用strcpy逐字节拷贝,这是新手最常踩的坑。因为二维数组的每一行是数组类型,不是指针变量,你不可能写char *tmp = arr[j]然后arr[j] = arr[j+1]——编译器会直接报错“不能给数组赋值”。数组名一旦创建,它所代表的内存起始地址就固定了,不能像指针一样重新指向别处。
如果数据量大(比如n=10000),冒泡排序会超时,这种情况建议用qsort。给二维字符数组排序时,比较函数要这么写:
int cmp(const void *a, const void *b) { return strcmp((const char *)a, (const char *)b); }qsort对待排序数组的每个元素调用比较函数时,传入的是元素的首地址。二维数组每行是一个char[105]类型,所以这个地址其实是char (*)[105]类型。强转成const char *后传给strcmp,恰好符合strcmp的参数要求。我用这个方法做牛客上的字符串排序题,时间能压到非常低。
2.4 分割与数字转换:从strtok到手写my_atoi
字符串分割在C语言里没有现成的split函数,最接近的是strtok。它的用法很简单:
char str[] = "apple,banana,cherry"; char *token = strtok(str, ","); while (token != NULL) { printf("%s\n", token); token = strtok(NULL, ","); }但strtok有两个明显缺陷:第一,它会修改原字符串,把分隔符替换成\0;第二,它内部用静态变量保存上一次的位置,不是线程安全的,重复调用时结果可能互相干扰。
牛客网大部分题目不涉及多线程,用strtok通常能过。但如果你在准备面试或者想体现功底,我建议手写一个简单的分割函数,按分隔符把字符串切到二维数组里:
int split(char *str, char delim, char out[][100]) { int cnt = 0; int col = 0; for (int i = 0; str[i] != '\0'; i++) { if (str[i] == delim) { out[cnt][col] = '\0'; cnt++; col = 0; } else { out[cnt][col++] = str[i]; } } out[cnt][col] = '\0'; return cnt + 1; }这个版本不依赖静态状态,逻辑完全可控,面试时讲起来也清晰。
字符串转数字类题目,核心是手写atoi。它的套路很固定:跳过空格、处理正负号、逐位累加:
int my_atoi(const char *str) { int i = 0; int sign = 1; int result = 0; while (str[i] == ' ') i++; if (str[i] == '-') { sign = -1; i++; } else if (str[i] == '+') { i++; } while (str[i] >= '0' && str[i] <= '9') { result = result * 10 + (str[i] - '0'); i++; } return sign * result; }这里真正有技术含量的是str[i] - '0'这个表达式。字符'0'到'9'在ASCII表里是连续排列的,str[i] - '0'恰好等于该字符对应的数值。这个套路会反复出现在各种字符串转数字的题目里,值得刻在脑子里。
3. 刷题现场还原:五个经典报错与排查链路
这一节不直接给标准答案,我把真实刷题时遇到的报错场景和你一起走一遍排查流程。排查问题的思路比记住正确答案更值钱。
3.1 缓冲区遗留:当你的“读入整数”吃掉了一行字符串
典型的题目场景:第一行输入一个整数n,后面跟着n行字符串。代码长这样:
int n; scanf("%d", &n); char buf[100]; for (int i = 0; i < n; i++) { gets(buf); // 对buf做处理 }运行后你会发现:明明输入了n行字符串,但程序只处理了n-1次,而且第一行缓冲区内容还是空的。
问题出在哪?scanf("%d", &n)只读取了数字字符,输入里的换行符\n残留在缓冲区中。紧接着的gets(buf)读到了一行“空字符串”(实际上是一个没有字符、只有\0的串),于是第一行内容被错过了。
排查这类问题时,我的思路很固定:
- 在
gets之前加一行getchar(),吃掉那个残留的换行符。 - 把
gets换成fgets(buf, sizeof(buf), stdin),然后手动处理末尾的\n。 - 如果仍然不对,就在循环结束处打印缓冲区的十六进制值,确认换行符是否被消费。
顺带提醒一点:gets在最新的C标准里已经被标记为废弃,因为无法限制读取长度,缓冲区溢出风险极高。牛客网部分编译器仍允许使用,但你在牛客101碰到带“读入带空格一行”的题时,我更推荐fgets,它在限定长度的前提下不会越界。
fgets会把换行符也读进字符串。所以读入后要顺手处理:
fgets(buf, sizeof(buf), stdin); int len = strlen(buf); if (buf[len - 1] == '\n') { buf[len - 1] = '\0'; }去掉这个换行符是高频操作,我习惯把它封装成一个小函数,每次读完直接调用。
3.2 越界访问与未初始化数组:乱码和段错误的第一嫌疑
本地运行结果不对,大多是“输出乱码”。乱码的直接原因通常是字符串中间或结尾的\0位置不对。
还原一个具体场景:题目要求将字符串中的小写字母改成大写。有人这么写:
char str[100]; scanf("%s", str); for (int i = 0; i <= strlen(str); i++) { if (str[i] >= 'a' && str[i] <= 'z') { str[i] -= 32; } }注意循环条件是i <= strlen(str),多访问了一次str[strlen(str)],也就是\0本身。虽然\0不会被str[i] >= 'a'命中,但这个访问在法律上依然是合法的。真正的隐患在于:如果恰好输入的字符串长度是99,str[strlen(str)]访问的就是str[99],恰好越界了。越界访问在牛客评测机上不一定稳定崩溃,但属于未定义行为,绝对要避免。
标准写法是i < strlen(str),但更聪明的做法是循环前先算好长度存进变量,避免每次迭代都调用strlen重新从头数一遍:
int len = strlen(str); for (int i = 0; i < len; i++) { if (str[i] >= 'a' && str[i] <= 'z') { str[i] -= 32; } }这个优化在字符数量少的时候看不出来,但在字符串很长或者循环嵌套很多时,能把O(n^2)变成O(n)。
另一个未初始化数组的经典问题:声明char str[100]后直接用strcat拼接内容,结果前面多了很多乱码。strcat的执行逻辑是从目标字符串的\0位置开始追加,但未初始化的局部数组里\0在哪儿是未知的。它可能停在某个残留字节处,也可能越过数组边界长期搜索,导致拼接位置完全不对。
解决方式就一句话:使用之前先清零。
3.3 strcmp返回值和直接比较:字符串相等判断的两种错误
字符串相等判断是牛客101里出场率极高的知识点。我在实际批改时见过两种典型错误。
第一种是拿数组名做==比较。C语言的数组名是首元素地址,两个不同的数组即使内容完全相同,首地址也不可能相等。用if (str1 == str2)判断字符串是否相等,结果永远是假。
但在牛客101的判题环境里,这种错误往往不报编译错,只是答案错误。因为编译器把str1 == str2合法地翻译为地址比较,程序能跑,逻辑全错。排查时如果发现“比较结果永远不成立”,第一反应就应该是这里。
第二种错误是strcmp返回值用法不当。前面讲过,strcmp返回正数/负数/零,而不是1。写成if (strcmp(a, b) == 1)是刻舟求剑,不同编译器的实现细节不一样。
还有一种少见的错误:混淆了strncmp与strcmp的用途。strncmp(a, b, n)比较前n个字符,只在需要比较部分内容时使用。把strncmp当strcmp用来判断完整相等,很容易漏掉字符串末尾的差异。
排查字符串比较问题,我的调试手段一般是打印每个字符的ASCII码,手工走一遍strcmp的逻辑。比如"abc"和"abd",先比较'a'和'a'相等,再比'b'和'b'相等,最后'c'和'd'不等,'c'的ASCII是99,'d'是100,所以返回-1。这个过程走一遍,代码里的问题基本就找到了。
3.4 字符串读入方式:scanf与fgets的边界差异
字符串读入是牛客101所有字符串题目的入口,这一步错了,后面全是白搭。最常见的两种方式各有利弊,但很多人只知其面,不知道边界在哪。
scanf("%s", str)的规则:跳过前导空白字符,读入连续的非空白字符,遇到空白停止,自动在末尾补\0。比如输入是"hello world\n",scanf("%s", str)只读入"hello","world"还在缓冲区。这带来的问题是:如果题目要求读入“带空格的整行”,scanf无法胜任。
fgets(str, size, stdin)的规则:从当前缓冲区位置开始读,直到读够size-1个字符、遇到换行或遇到文件结束符为止。它会保留换行符,然后把\0接到最后。这是它能读整行但需要手动去掉\n的原因。
我在牛客101里遇到的题目有两种典型场景:
- 场景A:“输入是一个单词”,用
scanf("%s", str)最省事。 - 场景B:“输入是一行带空格的英文句子”,必须用
fgets。
有一个细节容易被忽略:如果先用scanf("%d", &n)再用fgets(buf, sizeof(buf), stdin),fgets会先读到scanf留下的那个换行符,导致读到的字符串为空。这个问题的处理方式与3.1相同:在scanf之后加一个getchar()把换行符消费掉,或者用scanf(" %[^\n]", buf)这类格式串直接跳过前导空白并读到换行。
如果你不确定当前缓冲区里到底有什么,最直接的办法是写一行调试代码把缓冲区内容打印出来。很多人在这一步浪费时间猜来猜去,打印一下就清楚了。
4. 从牛客101走向实战:内存可视化与工具函数改造
牛客101刷完一遍,很多人会有一个感觉:题都会做了,但离开在线评测系统,自己写一个字符串处理的小工具还是不知道从何下手。这很正常,因为在线评测只校验输出结果,不校验你的代码在真实环境里是否健壮。这一节聊怎么把刷题时练到的东西,改造成实战能用的工具函数。
4.1 用GDB把字符串的内存摊开看
如果你在本地(Linux环境,比如Ubuntu虚拟机)配置好了C语言环境,强烈建议学会用GDB观察字符串内存。很多“玄学”问题,在GDB面前一目了然。
编译时加-g参数:
gcc -g -o test test.c gdb ./test在GDB里可以对printf之前打断点,然后用print命令看字符串内容:
(gdb) break main (gdb) run (gdb) print str如果只显示"hel",说明字符串在某个位置被提前截断了。再用x/20bx str把内存的二十个字节以十六进制形式打印出来,你就能直接看到\0到底在哪里,前面有没有残留的垃圾字节。
举个实际例子:有个字符串拼接的题,我在牛客上一直答案错误,本地GDB一查,发现目标数组在第8个字节处已经出现\0,但字符串实际长度是13。这就是源数组末尾的\0没有被正确处理,导致拼接从错误位置开始。看到十六进制字节的那一刻,错误原因就完全清楚了。
GDB对新手来说有点上手门槛,但哪怕只会break、run、print、x这四个命令,排查字符串问题的效率都要比盲改代码高一大截。
4.2 字符、ASCII码、数字之间的互相转换
牛客101的字符串题里还有一种变体:让你输出字符串中每个字符的ASCII码,或者反过来根据ASCII码还原字符。这类题本质是利用C语言里“字符就是整数”这一特性。
记忆三组关键数字就够了:
'0'的ASCII码是48,'9'是57。'A'的ASCII码是65,'Z'是90。'a'的ASCII码是97,'z'是122。
所以大写转小写就是ch = ch + 32,小写转大写就是ch = ch - 32。
判断一个字符是不是数字可以用ch >= '0' && ch <= '9',判断是不是字母可以用ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z'。牛客101里有一类“分离字母和数字”“判定字符类型”的题,全部建在这组ASCII关系上。
实战中更推荐直接用ctype.h头文件里的isalpha、isdigit,它们处理了字符集差异,代码可读性也更好。但刷题时有些编译器环境对ctype.h的支持不是很好,或者题目明确要求手写判断,那就老老实实用ASCII范围判断,性能没差别。
数字转字符串,实战里最顺手的是sprintf:
char buf[32]; int num = 12345; sprintf(buf, "%d", num);sprintf类似printf,只是把结果写入字符串而不是输出到屏幕。注意缓冲区大小要够用,%d的最大位数加上结束符,32字节非常宽裕。如果做嵌入式开发担心缓冲区溢出,可以换成snprintf(buf, sizeof(buf), "%d", num),它会自动限制写入长度。
4.3 综合示例:一个可复用的分词统计工具函数
最后放一个综合示例,把这一篇的知识点串起来。假设你现在要处理一个问题:输入一行英文句子,统计其中有多少个单词,以及最长单词的长度。
这个任务在牛客101里也有类似的题,很适合作为字符串综合练习。用fgets读入整行,去掉末尾换行,然后用状态机方式遍历统计:
#include <stdio.h> #include <string.h> #include <ctype.h> #define MAX_LINE 256 void analyze_words(const char *str) { int word_count = 0; int max_len = 0; int cur_len = 0; int in_word = 0; for (int i = 0; str[i] != '\0'; i++) { if (isalpha(str[i])) { if (!in_word) { in_word = 1; word_count++; cur_len = 0; } cur_len++; if (cur_len > max_len) { max_len = cur_len; } } else { in_word = 0; } } printf("单词数: %d, 最长单词长度: %d\n", word_count, max_len); } int main() { char line[MAX_LINE]; fgets(line, sizeof(line), stdin); int len = strlen(line); if (len > 0 && line[len - 1] == '\n') { line[len - 1] = '\0'; } analyze_words(line); return 0; }这个工具函数的亮点在于用状态变量in_word记录“当前是否处于一个单词中”。它在遇到字母时置1、遇到非字母时置0,只有从非字母状态切换到字母状态时才增加单词数,避免了重复计数。
刷完牛客101后,我建议你挑两三道题,改成这类面向真实输入的工具函数:处理任意长度的输入、处理边缘情况(空行、连续空格、行首行尾空格)、加注释。这个过程才是把刷题能力转化成工程能力的最后一步。字符串的知识光看不练没有意义,至少要亲手把逆序、排序、分割、转数字这些模板各写一遍,写到自己闭上眼睛都能默出来的程度,才算真正会了。
我在实际刷题过程中最深的一点体会是:牛客101这套题真正的价值不在于那101这个数字,而在于它逼着你把字符串的每个细节都过了一遍。等你把这些细节内化成下意识反应,后面再遇到更复杂的数据结构题目,就不会被字符串的边角问题绊住手脚了。