
网上很多C语言字符串的练习题绕来绕去其实都在考同一个东西你有没有真的理解“字符串是一块连续内存”这件事。就拿“在母串里删掉一个子串”和“按输入位置截取子串”这两道题来说表面上一个是删除、一个是截取好像没什么关联但你写几个版本就会发现难点全落在同一处——边界在哪、结束符怎么处理、指针怎么偏移。这一篇我把这两道题的完整思路和代码整理出来。适合刚学完指针和数组、想在字符串处理上再往前迈一步的同学也适合备考计算机二级或复习C语言基础的人。看完你不仅能直接抄代码还能搞明白为什么网上各种写法有的对、有的跑起来就崩。1. 字符串就是一块带结束符的内存先统一认知C语言里没有真正的字符串类型所谓的字符串本质上就是一个以\0结尾的字符数组。比如hello在内存里的真实样子是h e l l o \0一共6个字节最后的\0不是打印出来给你看的字符而是所有字符串函数的“工作底线”。strlen数到\0就停strstr匹配到\0就停printf(%s)打印到\0就停。如果哪次操作把\0弄丢了你看到的输出就会一直往后蔓延直到内存里碰巧遇到一个0字节输出才停下来。很多新手调试字符串程序莫名其妙输出一堆乱码八成就是\0被覆盖掉了。这里还有个特别容易踩的坑字符串通常有两种写法看起来差别不大实际差别巨大。char s1[] hello; // 合法内容可修改 char *s2 hello; // 指向字符串字面量一般位于只读区域char s1[]是在栈上开辟一个数组里面存着h e l l o \0可以随便改。而char *s2只是存了一个地址指向编译器放在只读数据段的字符串字面量。问题就来了下面这两道题都需要修改字符串如果用s2这种写法运行时会直接报段错误。很多在线OJ或者本地实验里报“Segmentation fault”的同学一半以上都是栽在这。另外还有一对概念也容易混strlen(s)和sizeof(s)。char s[100] hello;时strlen(s)是5sizeof(s)是100。一个是“有效字符个数”一个是“数组总大小”。写删除和截取的时候脑子里的长度一定要分清楚不然很容易越界访问。理解了这些下面两个操作就都可以推导出来你没有“删除”一个字符的能力只能把后面的数据往前搬覆盖掉不要的部分你也没有“截取”的魔法只能从源数组里按偏移量拷出来一份。2. 删除子串核心不是“删”而是把后面的字符搬上来2.1 先理解覆盖法现在要做的操作例如母串 sI love C language子串 sublove 期望结果I C language你仔细想一下这个“删”是怎么发生的找到love 在母串中的位置之后把它后面的内容C language\0整体往前移动让C出现在原来l的位置上。剩下的love 就被盖住了。这就是数组删除的标准思路元素前移覆盖。所以整个流程就三步用strstr找到子串在母串中第一次出现的位置p。算出子串长度sub_len。把p sub_len开始的所有字符连同最后的\0搬移到p开始的位置。这个逻辑无论用循环还是用内存拷贝函数都能实现。2.2 删除第一次出现的子串最简单的版本#include stdio.h #include string.h void delete_substr_once(char *s, const char *sub) { char *p strstr(s, sub); if (p NULL) { return; } size_t sub_len strlen(sub); memmove(p, p sub_len, strlen(p sub_len) 1); }解释一下为什么用memmove而不是memcpy这两个函数功能相似但memmove允许源和目的内存区域重叠memcpy不保证重叠时的行为。在这里目标地址是p源地址是p sub_len两个区域挨在一起复制过程中目标区域会被覆盖源区域也会受影响本质就是重叠区域的移动。所以必须用memmove。上面的strlen(p sub_len) 1是计算源区域的有效长度。p sub_len是子串后面的第一个字符strlen数到\0之前再加1把\0也搬过去。这样搬完字符串就正确收尾了。测试一下char s[] I love C language; delete_substr_once(s, love ); printf(%s\n, s); // I C language2.3 删除所有出现的子串有的练习要求把母串中所有子串都删掉这时候直接把上面的操作放进循环里void delete_substr_all(char *s, const char *sub) { size_t sub_len strlen(sub); if (sub_len 0) { return; // 子串是空串时strstr 会直接返回 s不处理会死循环 } char *p s; while ((p strstr(p, sub)) ! NULL) { memmove(p, p sub_len, strlen(p sub_len) 1); } }这里有一个非常隐蔽的问题为什么删完一次之后下次查找还用p而不是p sub_len因为删除操作会让后面的字符往前移动如果移动过来的字符恰好又能和后面的字符拼成一个新的子串而查找起点跳过了当前位置就会漏删。举个例子char s[] aaaa; delete_substr_all(s, aa);第一次找到aa在位置0删除后字符串变成aa内存里是a a \0。如果下一次从p sub_len开始找位置是2那aa就在位置0直接漏掉。而从p开始找就能看见它并继续删除最后得到空串。所以这个p不往后跳恰恰是正确处理连续重叠子串的做法。再看一个例子char s[] abababa; delete_substr_all(s, aba);第一次找到位置0的aba删除后变baba继续从位置0找strstr(baba, aba)匹配到位置1删除后变成bba再找就没有了。最终结果是bba。这个过程说明删除操作会不断改变字符串结构你必须以“当前字符串”为准不能按最初的位置表去打。3. 按位置截取子串先定好参数再写边界检查3.1 接口设计约定比实现更重要这类题目的描述通常是这样输入一个字符串再输入一个起始位置和长度输出从该位置开始的指定长度的子串。真正写代码之前一定要先明确几个问题问题我的约定起始位置从0开始还是从1开始按C语言习惯从0开始后面给出从1开始的适配方法位置超出字符串长度怎么办返回空串长度超过剩余字符数怎么办截到末尾为止目标缓冲区够不够大调用者要保证dest至少能容纳len 1个字节这些约定就是函数的“用户手册”。很多题解代码逻辑本身没问题但位置约定和题目要求不一致最后输出就是错的。函数原型/** * 从 src 中截取从 pos 开始的 len 个字符存入 dest * pos 从 0 开始计数 */ void subString(const char *src, char *dest, int pos, int len);3.2 完整实现void subString(const char *src, char *dest, int pos, int len) { int total (int)strlen(src); if (pos 0 || pos total || len 0) { dest[0] \0; return; } if (pos len total) { len total - pos; } for (int i 0; i len; i) { dest[i] src[pos i]; } dest[len] \0; }逐段解释一下这些检查为什么必不可少。total是源字符串长度。pos 0和pos total都是非法位置直接返回空串避免从越界地址读数据。len 0也是非法情况没有意义。pos len total意味着要截取的长度超出了源字符串剩余部分这时候只能“有多少取多少”把len收敛为total - pos。最后手动写循环复制而不是用strncpy是因为strncpy在字符串场景下的行为很坑如果源字符串长度小于n它会用\0拼命填充如果源长度大于等于n它不会自动补\0。也就是说strncpy复制完之后dest不一定是一个合法字符串。与其去记这些规则不如自己循环复制一遍然后手动补结束符。这也是我推荐大家手写的原因清晰安全。测试一下char s[] hello world; char out[64]; subString(s, out, 6, 5); printf(%s\n, out); // world subString(s, out, 0, 5); printf(%s\n, out); // hello subString(s, out, 8, 100); printf(%s\n, out); // rld长度超出剩余部分时截到末尾 subString(s, out, -1, 3); printf(%d\n, out[0] \0); // 1非法位置返回空串3.3 如果题目要求位置从1开始怎么适配很多中文教材或者老师出题时习惯说“第3个字符”要求输入的位置从1开始。这时候有两种处理方式。第一种调用方做转换传入pos - 1。但这样容易出错因为函数的内部实现还是按0基准每次调用都得惦记着减一。第二种在函数内部统一转成0基准。比如接口仍然叫subString但约定pos从1开始void subStringFromOne(const char *src, char *dest, int pos, int len) { int start pos - 1; int total (int)strlen(src); if (start 0 || start total || len 0) { dest[0] \0; return; } if (start len total) { len total - start; } for (int i 0; i len; i) { dest[i] src[start i]; } dest[len] \0; }我个人建议不管题目怎么说内部一律用0基准处理只在入口处把“从1开始的位置”转换成“从0开始的偏移量”。这样最容易检查也最不容易写乱。4. 实战中一定会遇到的几个隐蔽坑4.1 对只读字符串动手段错误警告这个问题在开头提过这里再强调一次。很多人这么写char *s hello world; delete_substr_once(s, world); // 运行时崩溃s指向的是字符串字面量把它当数组去改行为未定义常见表现是段错误。正确写法是用数组char s[] hello world; delete_substr_once(s, world);如果数据来自动态分配的内存需要先确保缓冲区足够大再操作。判断字符串能不能改就一句话这块内存是你自己申请的可写内存吗不是的话别动。4.2 搬移之后忘了补结束符乱码就来了假设你自己手写了循环删除比如char *p strstr(s, sub); size_t sub_len strlen(sub); char *q p sub_len; while (*q) { *p *q; p; q; } // 忘了写 *p \0;结果是什么字符串的实际内容是对的但最后一个有效字符后面没有\0printf会继续往后打印直到碰到某个0字节才停。这就是乱码和“输出多了东西”的根源。我调试这类问题时最常用的手段是直接打印每个字节的值for (int i 0; i 20; i) { if (s[i] 32 s[i] 126) { printf(%c, s[i]); } else { printf(?); } } printf(\n);这样能直观看到哪些位置是\0哪些位置还残留旧数据。肉眼一看就知道结束符写没写对。4.3 删除连续子串时的死循环前面那段delete_substr_all里我特意加了if (sub_len 0) return;。如果去掉这一句传入空子串会怎样strstr(s, )永远返回s然后memmove(p, p, 某长度)原地没变化循环条件永远成立程序就死循环了。别看这个情况好像很极端实际写代码的时候真的会遇到。尤其当你的子串来自用户输入谁也不能保证用户不会输入一个空串。健壮的程序应该在入口就把这种参数拦截掉。4.4 位置参数差一结果永远对不上截取子串最常见的错误就是“差一”。比如题目说“第3个字符开始”你直接用pos 3结果截出来是从第4个字符开始的。我的测试习惯是固定用一个短字符串验证char s[] abcdefghij; char out[16]; subString(s, out, 2, 3);如果我的起始位置是0基准那么结果应该是cde。看到cde就说明约定对上了。如果出来的是def那就是把1基准误当成0基准用了。这种固定小样例验证比盯着逻辑看半天高效得多。另外一个容易忽略的点代码里如果用for (int i 0; ...)这是C99标准才支持的写法。有些老编译器比如学校机房老旧的 VC6会报错需要在编译时加-stdc99或者把循环变量i声明到函数开头。5. 组合起来用字符串替换与文本解析思路这两个操作单独都是小工具组合起来能解决不少实际问题。5.1 用“查找 删除 插入”实现字符串替换字符串替换可以看成“查找子串 移动后面字符 拷入新串”的组合。思路是把旧子串后面的部分移动到新串长度对应的位置再把新串拷贝进去。void replace_first(char *s, const char *old, const char *new_word) { char *p strstr(s, old); if (p NULL) { return; } size_t old_len strlen(old); size_t new_len strlen(new_word); size_t tail_len strlen(p old_len) 1; // 旧子串后面的尾巴 \0 // 先把尾巴整体移动到 p new_len 处 memmove(p new_len, p old_len, tail_len); // 再把新串拷进来 memcpy(p, new_word, new_len); }比如s hello world把world替换成C结果是hello C。memmove把\0从原来的位置搬到新位置printf遇到结束符自然停止所以即使原地址上还残留了orld的字符也不会被打印出来。如果替换后字符串变长比如C替换成worldmemmove同样会把尾巴往后移。但要注意这个函数假设s的缓冲区足够大能够容纳替换后更长的新字符串。工程上写这种函数通常还会加一个capacity参数用来限制写入范围避免越界。5.2 解析简单的键值对文本比如处理一段namezhangsanage20citybeijing这样的数据不用上什么高大上的库用strstr、strchr加上前面的subString就能拆。思路是先用strchr找到的位置确定键和值再用strstr找到的位置确定一段记录的结束边界最后用subString把键和值分别拷贝出来。这种“手工解析三板斧”在嵌入式开发、命令行参数解析里非常常用也是很多同学从“会写练习题”过渡到“能处理真实数据”的必经一步。完整的可运行代码我放在这里可以直接编译测试#include stdio.h #include string.h void delete_substr_once(char *s, const char *sub) { char *p strstr(s, sub); if (p NULL) { return; } size_t sub_len strlen(sub); memmove(p, p sub_len, strlen(p sub_len) 1); } void delete_substr_all(char *s, const char *sub) { size_t sub_len strlen(sub); if (sub_len 0) { return; } char *p s; while ((p strstr(p, sub)) ! NULL) { memmove(p, p sub_len, strlen(p sub_len) 1); } } void subString(const char *src, char *dest, int pos, int len) { int total (int)strlen(src); if (pos 0 || pos total || len 0) { dest[0] \0; return; } if (pos len total) { len total - pos; } for (int i 0; i len; i) { dest[i] src[pos i]; } dest[len] \0; } int main(void) { char s1[] I love C language; delete_substr_once(s1, love ); printf(%s\n, s1); // I C language char s2[] aaaa; delete_substr_all(s2, aa); printf(\%s\\n, s2); // char out[64]; const char *s3 hello world; subString(s3, out, 6, 5); printf(%s\n, out); // world subString(s3, out, 8, 100); printf(%s\n, out); // rld return 0; }写字符串处理题我个人的体会是不要背代码先把“字符串以\0结尾”和“修改的本质是搬移数据”这两句话刻在脑子里。删除子串时问自己结束符有没有搬对连续匹配的情况会不会漏截取子串时问自己位置约定是0还是1越界了怎么收场这些问题想明白了代码的骨架自然就出来了。