☰
C语言字符串处理:删除子串与截取子串的边界与指针操作
2026/10/5 6:16:15 网站建设 项目流程

网上很多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)是5,sizeof(s)是100。一个是“有效字符个数”,一个是“数组总大小”。写删除和截取的时候,脑子里的长度一定要分清楚,不然很容易越界访问。

理解了这些,下面两个操作就都可以推导出来:你没有“删除”一个字符的能力,只能把后面的数据往前搬,覆盖掉不要的部分;你也没有“截取”的魔法,只能从源数组里按偏移量拷出来一份。

2. 删除子串:核心不是“删”,而是把后面的字符搬上来

2.1 先理解覆盖法

现在要做的操作,例如:

母串 s:"I love C language"
子串 sub:"love "

期望结果:"I C language"

你仔细想一下,这个“删”是怎么发生的?找到"love "在母串中的位置之后,把它后面的内容"C language\0"整体往前移动,让'C'出现在原来'l'的位置上。剩下的"love "就被盖住了。这就是数组删除的标准思路:元素前移覆盖。

所以整个流程就三步:

  1. 用strstr找到子串在母串中第一次出现的位置p。
  2. 算出子串长度sub_len。
  3. 把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 language

2.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';

结果是什么?字符串的实际内容是对的,但最后一个有效字符后面没有\0,printf会继续往后打印,直到碰到某个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)会报错,需要在编译时加-std=c99,或者把循环变量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"替换成"world",memmove同样会把尾巴往后移。但要注意,这个函数假设s的缓冲区足够大,能够容纳替换后更长的新字符串。工程上写这种函数,通常还会加一个capacity参数,用来限制写入范围,避免越界。

5.2 解析简单的键值对文本

比如处理一段"name=zhangsan&age=20&city=beijing"这样的数据,不用上什么高大上的库,用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?越界了怎么收场?这些问题想明白了,代码的骨架自然就出来了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询