C语言字符串操作实战:利用strstr与memmove高效删除子串

📅 2026/7/25 6:26:06 ✍️ 编辑团队 👁️ 阅读次数
C语言字符串操作实战:利用strstr与memmove高效删除子串
1. 项目概述一个看似简单却暗藏玄机的字符串操作今天想聊一个在C语言学习和面试中高频出现但又常常被轻视的经典问题如何实现删除字符串中的指定子串。乍一看这问题简单得有点“小儿科”——不就是找到子串然后把后面的字符往前挪吗很多新手朋友可能随手就能写个循环嵌套觉得大功告成。但如果你真这么想那可能就错过了理解C语言字符串操作精髓以及提升代码健壮性和效率的绝佳机会。这个项目标题“用C语言实现删除字符串中的子串C 库函数strstr()、memmove()、strcpy()、memset()、strlen()”已经为我们指明了核心它不仅仅是一个功能实现更是一次对C标准库中几个关键字符串/内存操作函数的深度理解和综合运用实战。strstr负责查找memmove负责安全地“搬运”内存strcpy和memset看似简单却各有适用场景strlen则是我们操作的基础尺子。把这些函数像乐高积木一样正确、高效地组合起来解决一个实际问题远比单独背诵它们的函数原型有意义得多。无论是正在啃《C语言程序设计》的学生准备技术面试的求职者还是希望夯实基础的开发者通过亲手实现这个功能并深入理解背后的“为什么”你都能获得远超代码本身的收获对指针、内存布局、函数边界条件的深刻认知以及写出既正确又优雅的C代码的能力。我们这就开始一步步拆解这个“麻雀虽小五脏俱全”的经典案例。2. 核心思路拆解与方案选型在动手写代码之前我们必须把问题想清楚。删除字符串中子串本质上是一个查找并覆盖的过程。假设我们有一个源字符串src和一个待删除的子串sub我们需要在src中找出所有sub出现的位置然后将该位置之后的所有字符不包括被删除的子串向前移动覆盖掉这个子串。2.1 为什么不能简单地用循环和指针“硬挪”很多初学者的第一反应是写两层循环外层遍历字符串内层比较子串。一旦匹配成功就用另一个循环把后面的字符逐个向前赋值。这个方法在逻辑上可行但存在几个明显问题效率低下每次删除哪怕只删除一个字符都可能引发大量字符的逐个移动时间复杂度在最坏情况下会接近 O(n*m)。代码冗长易错需要手动处理字符串结束符\0指针的加减运算稍有不慎就会导致越界或错误。未充分利用标准库C标准库提供了高度优化、久经考验的函数重复造轮子不仅增加工作量还容易引入bug。2.2 基于标准库的高效方案设计标题给出的函数列表就是我们的“工具箱”。一个高效且健壮的方案流程如下查找定位使用strstr()函数在源字符串中查找子串首次出现的位置。这是整个算法的“眼睛”。计算长度使用strlen()获取源字符串和子串的长度为后续内存移动提供精确计量。安全移除找到子串后需要将子串之后的部分整体前移。这里的关键是不能使用strcpy()因为源和目的内存区域可能存在重叠我们正是要把后面的内容拷贝到前面来。此时必须使用memmove()它是专门为处理重叠内存区域拷贝而设计的。循环与结束完成一次删除后在新的字符串中继续查找子串因为删除后可能产生了新的匹配直到strstr()返回NULL表示已无更多子串。结果输出最终源字符串缓冲区中的内容就是删除所有子串后的结果。这个方案将时间复杂度优化到了接近 O(n)因为strstr和memmove的实现通常都经过高度优化并且我们避免了嵌套循环内的逐个字符操作。注意这里我们明确排除了使用strcpy()来执行删除操作。strcpy(dest, src)在dest和src内存区域重叠时行为是未定义的极有可能导致错误结果或程序崩溃。这是一个非常重要的知识点。3. 核心函数深度解析与避坑指南在实现之前我们必须吃透这几个核心函数了解它们的脾气秉性否则极易踩坑。3.1 strstr() – 字符串查找器char *strstr(const char *haystack, const char *needle);这个函数在haystack干草堆中查找needle针第一次出现的位置返回指向该位置的指针如果没找到返回NULL。关键点与避坑参数检查虽然标准规定传入NULL指针行为未定义但健壮的代码应该自己检查。如果needle是空字符串根据C99标准应返回haystack。返回值的使用返回的指针指向的是haystack中匹配开始的位置。我们需要保存这个指针因为它决定了从哪里开始删除。查找的连续性在一次删除操作后下一次查找的起点应该是当前删除操作完成后的新字符串的当前位置而不是简单地从之前找到的位置后继续。因为字符串内容已经改变了。3.2 memmove() vs memcpy() strcpy() – 内存搬运大师void *memmove(void *dest, const void *src, size_t n);这是本项目的灵魂函数。它的作用是从src指向的内存地址拷贝n个字节到dest指向的内存地址。它与memcpy()的关键区别在于memmove()会先将源数据拷贝到一个临时缓冲区然后再拷贝到目标地址从而安全地处理源和目标内存区域重叠的情况。而memcpy()则假定它们不重叠重叠时行为未定义。为什么这里必须用memmove假设字符串是“hello world”要删除“lo”。找到“lo”在位置p。我们需要把p2即” world”的起始位置开始的内容移动到p的位置。源p2和目标p是重叠的memcpy(p, p2, len)会导致数据损坏。而memmove能正确处理这种情况。参数计算n应该拷贝多少字节应该是子串之后剩余字符串的长度 1包括结尾的\0。我们可以用strlen(found_position sub_len)来获取剩余字符串长度然后1来包含\0。3.3 strlen() – 长度测量员size_t strlen(const char *str);返回字符串长度不包括结尾的\0。这个函数看似简单但要注意它是通过遍历字符串直到遇到\0来计数的时间复杂度是 O(n)。在循环中频繁调用strlen可能成为性能瓶颈。在本项目中我们可以在循环开始前获取一次子串长度在循环内根据需要计算剩余部分长度。3.4 strcpy() 和 memset() – 本项目的“配角”在本项目的核心删除逻辑中strcpy()并不适用原因已述。memset()通常用于将一段内存设置为特定的值。在本项目中一个可能的用法是在删除子串后将字符串末尾空出来的部分如果有的话用\0填充以确保字符串正确终止并且避免残留的旧字符造成混淆。但这并非必须因为memmove已经将末尾的\0一并移动了。4. 分步实现与代码精讲理论清晰后我们开始动手实现。我将提供一个完整、健壮且带有详细注释的版本。4.1 函数接口设计首先明确我们的函数原型。由于C语言中字符串通常存储在字符数组中我们的操作会直接修改传入的缓冲区。一个经典的接口设计是void delete_substr(char *str, const char *sub);str指向待处理的字符串缓冲区的指针。函数将直接修改这块内存。sub指向待删除子串的指针。这个设计简单直接但调用者必须确保str指向的缓冲区足够大且可修改即不能是字符串常量。4.2 完整实现代码#include stdio.h #include string.h #include assert.h /** * brief 从字符串 str 中删除所有子串 sub 的出现。 * param str 待处理的字符串会被修改。 * param sub 待删除的子串。 * note 该函数直接修改传入的 str 缓冲区。 */ void delete_all_substr(char *str, const char *sub) { // 防御性编程检查输入指针是否有效 if (str NULL || sub NULL) { return; // 或进行错误处理如 fprintf(stderr, Invalid input\n); } size_t sub_len strlen(sub); // 如果子串为空则无需任何操作根据strstr语义空串被视为匹配开头 if (sub_len 0) { return; } // 主循环持续查找并删除直到字符串中不再包含子串 char *pos NULL; while ((pos strstr(str, sub)) ! NULL) { // 计算需要移动的尾部字符串的起始位置和长度 char *tail_start pos sub_len; // 子串之后的部分 size_t tail_len strlen(tail_start) 1; // 1 为了包含结尾的 \0 // 使用 memmove 安全地重叠内存拷贝 memmove(pos, tail_start, tail_len); // 注意删除后str 的内容已经改变循环条件中的 strstr 会基于新字符串继续查找 } } /** * brief 从字符串 str 中删除子串 sub 的第一次出现。 * param str 待处理的字符串会被修改。 * param sub 待删除的子串。 * return 指向删除操作后字符串的指针通常就是 str。如果未找到子串返回原 str。 */ char *delete_first_substr(char *str, const char *sub) { if (str NULL || sub NULL) { return str; } char *pos strstr(str, sub); if (pos ! NULL) { size_t sub_len strlen(sub); size_t tail_len strlen(pos sub_len) 1; memmove(pos, pos sub_len, tail_len); } return str; }4.3 代码逐行精讲与避坑点输入验证if (str NULL || sub NULL) return;这是健壮代码的第一步。直接操作空指针会导致程序崩溃。处理空子串if (sub_len 0) return;这是一个边界条件。strstr(str, )通常会返回str这会导致无限循环或错误操作。我们提前处理这种情况。核心循环while ((pos strstr(str, sub)) ! NULL)这是一个非常简洁高效的写法。每次循环都从当前字符串str的开头查找子串。pos记录了本次找到的位置。计算移动参数char *tail_start pos sub_len;这指向了被删除子串之后的第一个字符。size_t tail_len strlen(tail_start) 1;这里1是整个实现中最容易忽略的关键点。strlen(tail_start)只计算了\0之前的字符数。我们必须把\0也一起移动过来才能保证移动后的字符串正确终止。忘记1会导致新字符串没有结束符后续操作如printf、strlen会读取到后面的垃圾数据直至遇到一个偶然的\0引发不可预知的错误。执行移动memmove(pos, tail_start, tail_len);这就是执行“删除”的魔法语句。它将尾部字符串含\0安全地搬运到子串开始的位置实现了覆盖删除。循环继续移动完成后while循环的条件会再次执行strstr(str, sub)。由于str指向的缓冲区内容已经更新所以它会从新的字符串中继续查找是否还有匹配的子串。例如删除“aa”中的“a”第一次删除后字符串变为“a”第二次strstr(“a”, “a”)依然能找到直到字符串为空。关于delete_first_substr函数它只删除第一次出现的子串。有时我们只需要这个功能。它的实现是delete_all_substr的简化版只执行一次查找和移动。返回值的设计模仿了标准库风格方便链式调用。5. 测试用例与边界条件处理写好了函数必须进行全面的测试。以下是一些必须考虑的测试场景int main() { char str1[100] hello world, hello universe!; printf(原始: %s\n, str1); delete_all_substr(str1, hello ); printf(删除hello 后: %s\n, str1); // 预期: world, universe! // 测试子串在末尾 char str2[100] This is a test string test; delete_all_substr(str2, test); printf(删除test后: %s\n, str2); // 预期: This is a string // 测试子串重叠出现 char str3[100] aaaa; delete_all_substr(str3, aa); printf(删除aa后: %s\n, str3); // 这是一个有趣案例预期结果是什么 // 第一次删除前两个a字符串变为aa继续查找删除最终变为空串。 // 测试空子串 char str4[100] normal string; delete_all_substr(str4, ); printf(删除空串后: %s\n, str4); // 预期: normal string (不变) // 测试子串不存在 char str5[100] original; delete_all_substr(str5, xyz); printf(删除不存在的xyz后: %s\n, str5); // 预期: original (不变) // 测试字符串为空 char str6[100] ; delete_all_substr(str6, something); printf(空字符串删除后: %s\n, str6); // 预期: (不变) // 测试只删除第一次出现 char str7[100] repeat repeat repeat; delete_first_substr(str7, repeat ); printf(仅删除第一次repeat 后: %s\n, str7); // 预期: repeat repeat return 0; }运行这些测试观察输出是否符合预期是验证函数正确性的最好方法。特别是重叠删除“aaaa”中删除“aa”和空串处理是常见的思维盲区。6. 常见问题、性能分析与优化在实际使用和面试中围绕这个实现会衍生出许多深入的问题。6.1 为什么循环中不更新str指针细心的读者会发现在delete_all_substr的循环里我们始终用strstr(str, sub)这个str是最初传入的指针一直没变。而字符串内容在memmove后头部可能已经改变了如果从开头删除但str指向的地址没变这有问题吗没有问题。str是一个指向字符数组首地址的指针。memmove移动的是该指针所指向缓冲区里的内容。无论内容怎么变str这个“门牌号”始终指向这块内存的开始。strstr每次都是从这块内存的起始地址开始搜索新的内容逻辑是正确的。如果我们在循环里让str pos;反而会出错因为跳过了已经被检查和处理过的前半部分。6.2 内存重叠的陷阱再探讨我们强调必须用memmove。为了加深理解可以思考如果非要用strcpy来实现这个移动怎么写才是安全的 答案是无法安全实现。因为strcpy在拷贝时不知道长度它遇到\0才停止。在重叠的情况下源字符串的\0可能在拷贝过程中被覆盖导致strcpy停不下来直到发生缓冲区溢出或访问到非法内存。这是一个根本性的设计限制。6.3 性能分析与潜在优化我们的算法时间复杂度可以近似认为是 O(n*m)其中 n 是原串长度m 是子串长度这主要是strstr的最坏情况复杂度。memmove的复杂度是 O(k)k 是每次需要移动的尾部长度。优化点1避免重复计算子串长度。我们在循环外只计算一次sub_len这是好的。优化点2减少strlen调用。在循环内我们使用了strlen(tail_start)来计算尾部长度。如果待删除的子串很长或者删除操作很多这里可以优化。我们可以通过指针运算直接得到尾部长度size_t tail_len (str strlen(str)) - tail_start 1;。但前提是我们要在循环内维护当前字符串的长度这增加了状态管理的复杂度。对于大多数情况当前的写法清晰度优先性能开销可接受。优化点3使用更高效的查找算法。标准库的strstr实现通常已经足够高效可能使用了 KMP、Boyer-Moore 等算法。但在极端性能要求的场景下可以自己实现特定的查找逻辑。6.4 一个进阶挑战不分配额外空间原地删除所有指定字符这是一个相关的经典面试题。例如删除字符串中的所有空格。思路更简单使用两个指针或索引一个快指针p_fast用于遍历原字符串一个慢指针p_slow用于指向下一个有效字符的存放位置。当p_fast指向的字符不是要删除的字符时就将其拷贝到p_slow的位置然后两个指针都前进否则只有p_fast前进。最后在p_slow的位置写入\0。这个方法只需要一次遍历时间复杂度 O(n)且是严格的原地操作。void remove_char(char *str, char c) { if (str NULL) return; char *dst str; // 慢指针 char *src str; // 快指针 while (*src) { if (*src ! c) { *dst *src; } src; } *dst \0; }理解了这个“双指针”技巧再回头看我们的删除子串函数会发现其核心思想有相通之处只不过“判断是否要删除”的逻辑从“单个字符是否相等”变成了“是否匹配一个子串”。7. 项目总结与扩展思考通过这个“删除字符串中子串”的项目我们完成了一次对C语言字符串和内存操作的深度遍历。它远不止于几行代码而是串联起了指针、数组、内存管理、标准库函数应用和边界条件处理等多个核心知识点。我个人在实现和教学这个案例时最深的体会是C语言的简洁和强大背后是对程序员责任的严格要求。每一个函数的选择memmovevsmemcpy、每一个边界条件的判断空串、NULL指针、每一次长度的计算是否包含\0都直接关系到程序的正确性与安全性。忽略其中任何一点都可能埋下难以察觉的bug。这个项目可以很自然地扩展到其他相关功能替换子串找到子串后不是删除而是用另一个字符串替换它。这需要处理新子串长度不同带来的内存伸缩问题可能涉及realloc如果字符串在堆上或确保缓冲区足够大。分割字符串基于一个分隔符子串将字符串分割成多个部分。这通常需要动态数组或链表来保存结果。实现一个简单的字符串处理库将删除、替换、查找、分割等功能封装成一组函数并统一处理内存分配错误。最后建议你不仅仅停留在阅读和理解上。打开你的编辑器无论是VSCode、CLion还是简单的文本编辑器配合GCC把代码敲一遍运行不同的测试用例甚至故意写一些有bug的版本比如把memmove换成memcpy观察会发生什么。这种从“知道”到“做到”再到“理解为什么必须这样做”的过程才是提升C语言功力的不二法门。