C语言在线编程环境:https://sky315.com:8080/c/。
很多同学学到字符串这块就开始头疼:为什么strlen和sizeof算出来的数不一样?为什么scanf读字符串读到空格就停了?为什么用==比较两个字符串永远不相等?
说白了,字符串就是一串字符排好队,最后面跟了个看不见的\0当结束标志。你理解了这句话,后面所有的字符串操作都围绕这个\0在转。今天咱们通过几个经典例题,把字符串的常用操作全部过一遍,每个都从最基础的写法开始,不依赖库函数,让你看清楚字符串在内存里到底是怎么动的。
例1:自己实现strlen——算字符串长度
strlen是字符串里最基础的函数,没有之一。它的作用就是数一数字符串里\0之前有多少个字符。
你可能会说:这不是很简单吗?从头开始数,数到\0就停。确实简单,但这个"从头数到\0"的思路,是所有字符串操作的基础。
#include <stdio.h> int my_strlen(char s[]) { int len = 0; while (s[len] != '\0') { len++; } return len; } int main() { char str[] = "Hello"; printf("长度:%d\n", my_strlen(str)); // 输出5 return 0; }
拆开看:
-
char s[]作为参数,实际上传进来的是一个地址(指针),指向字符数组的首地址。你在函数里写s[len],就是在从首地址开始往后找。 -
while (s[len] != '\0'):只要当前字符不是\0,计数器就加1,然后继续往后走。 -
当遇到
\0的时候,循环退出,返回计数器的值。
指针写法:如果你对指针更熟悉,可以写成这样,更简洁:
int my_strlen(char *s) { int len = 0; while (*s != '\0') { len++; s++; } return len; }
甚至还能写成一行,装个X:
int my_strlen(char *s) { char *p = s; while (*p) p++; return p - s; }
这里while (*p)利用了\0的ASCII码是0,0就是假这个特性。p - s是两个地址相减,得到中间隔了多少个元素。
这个题的本质就是:遍历字符数组,直到撞见\0为止。 你把这个逻辑刻在脑子里,后面所有的字符串操作都是这个逻辑的变种。
例2:自己实现strcpy——字符串拷贝
拷贝就是把一个字符串的内容原封不动地复制到另一个数组里,包括最后的\0。
标准库的strcpy不检查目标数组够不够大,你自己实现的时候也一样,得保证目标数组能装下源字符串的全部内容,包括\0。
#include <stdio.h> void my_strcpy(char dest[], char src[]) { int i = 0; while (src[i] != '\0') { dest[i] = src[i]; i++; } dest[i] = '\0'; // 别忘了把\0也拷过去 } int main() { char src[] = "Hello World"; char dest[20]; my_strcpy(dest, src); printf("dest = %s\n", dest); // 输出 Hello World return 0; }
拆开看:
-
while (src[i] != '\0'):只要源字符串还没到末尾,就把当前字符复制到目标数组对应位置。 -
i++:两个数组的下标同步前进。 -
循环结束的时候,
i指向了源字符串\0的位置。把\0赋给dest[i],目标数组也成了一个合法的字符串。这一步不能省,否则dest里的内容没有结束标志,用%s打印出来就是乱码。
指针写法更简练:
void my_strcpy(char *dest, char *src) { while (*src != '\0') { *dest = *src; dest++; src++; } *dest = '\0'; }
终极简练版(这行代码你最好能看懂,但平时写代码不推荐这么写,可读性太差):
void my_strcpy(char *dest, char *src) { while ((*dest++ = *src++) != '\0'); }
这行代码的玄机是:先把*src赋给*dest,然后两个指针都自增,然后判断赋完的值是不是\0。如果是\0就退出循环。把赋值、移动、判断写在一行里了。你看得懂就行,别炫技。
例3:自己实现strcat——字符串拼接
拼接就是把一个字符串接在另一个字符串的末尾。
思路分两步:先找到目标字符串的末尾(也就是它的\0位置),然后从那个位置开始,把源字符串的内容挨个拷过去,最后补上\0。
#include <stdio.h> void my_strcat(char dest[], char src[]) { int i = 0, j = 0; // 第一步:找到dest的末尾 while (dest[i] != '\0') { i++; } // 第二步:从dest末尾开始拷贝src while (src[j] != '\0') { dest[i] = src[j]; i++; j++; } dest[i] = '\0'; } int main() { char str1[20] = "Hello "; char str2[] = "World"; my_strcat(str1, str2); printf("%s\n", str1); // 输出 Hello World return 0; }
拆开看执行过程:
初始:str1 = "Hello ",内容实际上是H e l l o (空格) \0,i从0开始。
第一个while:dest[0]是H,不是\0,i变1;dest[1]是e,i变2;……一直到dest[5]是空格,i变6;dest[6]是\0,循环退出。此时i=6,指向str1的末尾。
第二个while:j从0开始,src[0]是W,赋给dest[6];i变7,j变1;src[1]是o,赋给dest[7];……直到src[4]是d,赋给dest[10];j变5,src[5]是\0,循环退出。
最后dest[11] = '\0',拼接完成。
注意:目标数组的大小要能容纳拼接后的完整字符串。上面例子里str1长度是20,拼接后"Hello World"只有11个字符(含\0),够用。但如果str2很长,就可能溢出。
例4:自己实现strcmp——字符串比较
strcmp比较两个字符串的字典序。它逐个字符比较,根据ASCII码的大小决定结果:
-
如果两个字符串完全一样,返回0
-
如果第一个不相同的字符中,s1的比s2的小,返回负数
-
如果第一个不相同的字符中,s1的比s2的大,返回正数
#include <stdio.h> int my_strcmp(char s1[], char s2[]) { int i = 0; while (s1[i] != '\0' && s2[i] != '\0') { if (s1[i] != s2[i]) { return s1[i] - s2[i]; // 返回ASCII码差值 } i++; } // 其中一个字符串走到末尾了 return s1[i] - s2[i]; } int main() { char a[] = "Hello"; char b[] = "Hello"; char c[] = "World"; printf("%d\n", my_strcmp(a, b)); // 0 printf("%d\n", my_strcmp(a, c)); // 负数(H比W小) printf("%d\n", my_strcmp(c, a)); // 正数(W比H大) return 0; }
拆开看:
-
while (s1[i] != '\0' && s2[i] != '\0'):两个字符串都没走到头的时候,继续比较。 -
每次比较当前位置的字符,如果不相等,直接返回它们的ASCII码差值。返回正数表示s1大,负数表示s1小。
-
如果循环正常结束了(其中一个字符串走到了\0),说明前面所有字符都相等。这时候返回
s1[i] - s2[i]:如果两个字符串同时结束,返回0;如果s1先结束(s1[i]是\0,s2[i]还有字符),返回负值,说明s1更短、更小;反之返回正值。
注意:不能用==直接比较字符串数组名,因为数组名是地址,if (s1 == s2)比较的是两个地址是否相同,不是内容是否相同。
例5:字符串反转——双指针从两头往中间走
把一个字符串倒过来,比如"Hello"变成"olleH"。思路跟数组逆序完全一样:两个指针(或者下标),一个从头往后走,一个从尾往前走,交换它们指向的字符,直到相遇。
关键是:尾指针要从strlen(s) - 1开始,而不是strlen(s)。因为strlen(s)指向的是\0,你不能把\0换到前面去。
#include <stdio.h> #include <string.h> void reverse(char s[]) { int left = 0; int right = strlen(s) - 1; // 指向最后一个有效字符 char temp; while (left < right) { temp = s[left]; s[left] = s[right]; s[right] = temp; left++; right--; } } int main() { char str[] = "Hello"; reverse(str); printf("%s\n", str); // 输出 olleH return 0; }
追踪执行过程:
初始:"Hello",left=0(H),right=4(o)
第一轮:交换s[0]和s[4],变成"oellH",left=1,right=3
第二轮:交换s[1]和s[3],变成"ol leH"?慢着,s[1]是e,s[3]是l,交换后变成"ol l eH",整理一下是"ol leH"?不对,我写清楚点:
初始:H e l l o
交换0和4:o e l l H
交换1和3:o l l e H
left=2,right=2,循环结束。
结果是"olleH",对了。
这个反转思路是字符串操作的经典套路,你背下来都值得。很多字符串题目的变种都基于这个双指针交换的模式。
例6:判断回文串——正着读反着读都一样
回文串就是"abccba"、"level"这种,正着读反着读都一样。
思路跟反转很像,但不用交换,只需要比较两端字符是否相等。一旦发现不相等,直接返回"不是回文"。
#include <stdio.h> #include <string.h> int is_palindrome(char s[]) { int left = 0; int right = strlen(s) - 1; while (left < right) { if (s[left] != s[right]) { return 0; // 不是回文 } left++; right--; } return 1; // 是回文 } int main() { char s1[] = "abccba"; char s2[] = "Hello"; printf("%d\n", is_palindrome(s1)); // 1(是) printf("%d\n", is_palindrome(s2)); // 0(不是) return 0; }
这题跟反转的区别仅仅是"交换"变成了"比较"。如果你能熟练写出反转,这题就是改三行代码的事。
例7:统计单词个数——状态转换的思维
输入一个英文句子,统计有多少个单词。单词之间用空格分隔,可能有多个连续空格。
思路:遍历字符串,用一个标志记录"当前是否在单词里"。初始是"不在单词里"。每遇到一个非空格字符且当前不在单词里,就说明一个新单词开始了,计数器加1,然后把标志改成"在单词里"。遇到空格就把标志改成"不在单词里"。
这个"状态标志"的思路在处理文本的时候特别常用。
#include <stdio.h> int count_words(char s[]) { int count = 0; int in_word = 0; // 0表示不在单词里,1表示在单词里 int i = 0; while (s[i] != '\0') { if (s[i] != ' ' && in_word == 0) { // 当前不是空格,并且之前不在单词里 => 新单词开始 count++; in_word = 1; } else if (s[i] == ' ') { // 遇到空格,说明单词结束了 in_word = 0; } i++; } return count; } int main() { char str[] = " Hello world C language "; printf("单词数:%d\n", count_words(str)); // 输出4 return 0; }
拆开看:
-
in_word这个标志变量是核心。它记录了你当前是否正处在一个单词的内部。 -
遇到非空格字符的时候,如果
in_word == 0,说明之前不在单词里,这是一个新单词的开始,count加1,然后把in_word设为1。 -
遇到空格的时候,把in_word设为0,表示单词结束了。
-
如果遇到多个连续空格,in_word已经是0了,不会重复计数。
换一种更常见的写法:直接判断"当前字符不是空格且前一个字符是空格(或者这是第一个字符)":
int count_words(char s[]) { int count = 0; int i = 0; while (s[i] != '\0') { if (s[i] != ' ' && (i == 0 || s[i - 1] == ' ')) { count++; } i++; } return count; }
这种写法更短,不用标志变量。但那个(i == 0 || s[i - 1] == ' ')条件你得琢磨一下——它处理了字符串开头第一个字符就是单词的情况。两种写法你选自己顺眼的,我推荐标志变量的写法,逻辑更清晰。
例8:删除字符串中指定的字符
比如把"hello world"里的所有'l'删掉,变成"heo word"。
思路:两个下标,一个读一个写。读指针从头到尾扫一遍,遇到要删除的字符就跳过不写,遇到不删的字符就写到写指针的位置,写指针后移一位。最后在写指针位置补上\0。
#include <stdio.h> void delete_char(char s[], char target) { int read = 0; // 读指针 int write = 0; // 写指针 while (s[read] != '\0') { if (s[read] != target) { s[write] = s[read]; write++; } read++; } s[write] = '\0'; } int main() { char str[] = "hello world"; delete_char(str, 'l'); printf("%s\n", str); // 输出 "heo word" return 0; }
追踪执行过程,看"读写双指针"是怎么工作的:
初始:"hello world",read=0指向'h',write=0
read=0:'h'不等于'l',写入s[0]='h',write变1
read=1:'e'不等于'l',写入s[1]='e',write变2
read=2:'l'等于'l',不写,read变3
read=3:'l'等于'l',不写,read变4
read=4:'o'不等于'l',写入s[2]='o',write变3
read=5:空格不等于'l',写入s[3]=' ',write变4
...
最后s[write]='\0',新的字符串是"heo word"。
这个"读写双指针"的模式,在字符串压缩、过滤、去重等操作里反复出现。 一个指针负责读,一个指针负责写,读指针走得快,写指针走得慢(因为有被跳过的字符),两个指针之间的差距就是"被删掉的那段"。
例9:字符串查找子串——手写一个strstr的简化版
查找一个字符串里是否包含另一个子串,返回第一次出现的位置。如果找不到就返回-1。
思路:从主串的每一个位置开始,尝试匹配子串。
#include <stdio.h> int find_substr(char haystack[], char needle[]) { int i, j; for (i = 0; haystack[i] != '\0'; i++) { // 从位置i开始尝试匹配needle j = 0; while (haystack[i + j] != '\0' && needle[j] != '\0' && haystack[i + j] == needle[j]) { j++; } // 如果needle全部匹配完了,说明找到了 if (needle[j] == '\0') { return i; } } return -1; // 没找到 } int main() { char text[] = "hello world"; char pattern[] = "world"; char pattern2[] = "abc"; printf("%d\n", find_substr(text, pattern)); // 6 printf("%d\n", find_substr(text, pattern2)); // -1 return 0; }
这个算法的核心是"从每个位置试一遍"。比如主串"hello world",先在位置0试"hello"能不能匹配"world"?不能。位置1试"ello..."?不能。一直试到位置6开始"world",匹配成功,返回6。
当然这个算法效率不高(O(n*m)),但作为学习理解字符串匹配的原理,它足够了。你要知道C标准库里有一个strstr函数干的就是这个事,而且优化得更好。
几个必踩的坑:
坑1:数组长度不够放\0
char str[5] = "Hello"; 这种写法虽然编译能过,但数组里放了6个字符(算上\0),最后一个\0挤出去了。str不是一个合法的C字符串,strlen和printf都会出问题。数组长度至少要比字符串长度多1。
坑2:scanf读字符串遇空格就停
scanf("%s", str); 输入"Hello World",只能读到"Hello"。想读一整行用fgets。
fgets(str, sizeof(str), stdin); 会读入换行符,记得去掉:
fgets(str, sizeof(str), stdin); int len = strlen(str); if (len > 0 && str[len - 1] == '\n') { str[len - 1] = '\0'; }
坑3:strlen和sizeof分不清
char s[20] = "Hello";
-
strlen(s)= 5(有效字符个数,不含\0) -
sizeof(s)= 20(整个数组占的字节数)
坑4:用==比较字符串
if (s1 == s2) // 错的!比的是地址 if (strcmp(s1, s2) == 0) // 对的!比内容
坑5:自己实现的字符串函数忘了补\0
my_strcpy、my_strcat这些函数,最后一定要在目标数组末尾加\0。漏掉的话,结果字符串没有结束标志,后续操作全乱套。
最后说两句:
字符串处理的题,看起来花样多,但核心就一个:遍历字符数组,以\0为边界,该读就读,该写就写,该比就比。 你把这个逻辑弄通了,不管题目是统计、反转、查找、删除还是拼接,本质上都是在围着\0转。
练到什么程度算过关? 拿到一个字符串处理的需求,你能自然地想到用循环遍历,知道终止条件是\0,知道什么时候需要手动补\0。能随手写出strlen、strcpy、strcat、strcmp这几个常用函数的山寨版。能用双指针法做反转和回文判断。能用读写双指针做删除和过滤。
还是那句话,打开编译器,每段代码自己敲一遍。字符串的错误最直观,打印出来一看就知道哪儿不对。调试的时候多打印中间结果,看看每一步字符串变成了什么样子。亲手出几次错,比看十遍书都管用。