ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C语言字符串实战:逆序、分割、转换、比较与排序的避坑指南

C语言字符串实战:逆序、分割、转换、比较与排序的避坑指南 讲字符串基础的时候很多朋友觉得不就是字符数组嘛结果一上手写业务代码各种问题就冒出来了——逆序写错了边界、分割用strtok把自己坑了、atoi碰上非法输入直接摆烂。这篇继续聊字符串实战从最常用的几个操作场景入手逆序、分割、转换、比较、排序每个都给出能直接用的实现顺带把背后的原理和容易踩的坑一起说清楚。无论是刚学C语言的学生还是工作中被字符串处理折磨的开发者这篇的内容都适用。建议先通读一遍理解思路再照着代码敲一遍碰到报错再回来对照排查部分。1. 字符串逆序最简单的题最多坑的地方字符串逆序几乎每个初学者的练习题里都有但真到了项目里这玩意儿往往不是写出来就行而是写对才行。先看最直接的双指针原地逆序实现。#include stdio.h #include string.h void reverse_str(char *s) { if (NULL s) { return; } char *left s; char *right s strlen(s) - 1; while (left right) { char tmp *left; *left *right; *right tmp; left; right--; } }这个实现的核心逻辑是左指针从头往尾走右指针从尾往头走两边同时交换字符直到相遇。注意right s strlen(s) - 1减1是为了跳过结尾的\0这是新手最容易漏的地方。如果忘了减1会把字符串结尾的终止符换到开头整串直接坏掉。1.1 逆序题的两种变形场景实际开发中逆序很少单独出现更多是作为某个大逻辑的一个环节。比较典型的两个场景第一个是回文判断。判断一个字符串是不是回文不需要完整逆序再比较直接双指针从两端往中间扫逐个对比字符是否相等就行。但如果你已经写好了逆序函数也可以先逆序再strcmp只是这样多了一次拷贝和一次遍历不划算。我通常的做法是直接对原串做首尾对比时间复杂度 O(n)空间复杂度 O(1)。第二个场景是整串逆序后大小写状态保持。比如需要把一句话里的单词顺序反转但每个单词内部顺序不变像 hello world 变成 world hello。这个需求光靠整体逆序是做不出来的需要先整体逆序再对每个单词做一次逆序恢复。做法是第一次逆序后得到 dlrow olleh然后遍历遇到空格就把一个单词区间再逆序一次连续两次逆序等于没逆序单词内部的字符顺序就恢复原样了。1.2 逆序实现中的边界检查写逆序函数边界条件比实现本身更考验人。我建议至少检查三件事传入的指针是否为 NULL字符串是否为空串strlen 返回 0 时right s - 1直接出错字符串是否只有一个字符left right不需要交换。第一个和第三个在双指针循环里天然能避坑但空串的情况必须先拦截否则s strlen(s) - 1会让指针跑到字符串之前属于未定义行为。在实际工程里这种条件判断看起来啰嗦但能避免大量线上崩溃。2. 字符串分割C语言没有 split自己动手很多从 Python、Java 转过来的朋友到了 C 语言最不习惯的就是没有现成的split()。C 标准库只提供了strtok那玩意儿有状态保存多线程环境下用起来心惊胆战而且会修改原字符串。真的要批量分割字符串我建议自己写一个简单可靠的分割函数。先看一个不需要修改原字符串、可重复调用的实现#include stdio.h #include stdlib.h #include string.h char **split_str(const char *src, char delim, int *out_count) { if (NULL src || NULL out_count) { return NULL; } const char *p src; int count 1; while (*p) { if (*p delim) { count; } p; } char **result (char **)calloc(count, sizeof(char *)); if (NULL result) { return NULL; } const char *start src; int index 0; p src; while (1) { if (*p delim || *p \0) { int len (int)(p - start); char *item (char *)calloc(len 1, sizeof(char)); if (NULL item) { // 内存不足时要做清理这里省略了完整回溯 return NULL; } memcpy(item, start, len); item[len] \0; result[index] item; if (*p \0) { break; } start p 1; } p; } *out_count index; return result; } void free_split_result(char **arr, int count) { if (NULL arr) { return; } for (int i 0; i count; i) { free(arr[i]); } free(arr); }这个实现有两个关键点。第一统计分割后的段数用的是分隔符数量加 1的思路比如a,b,c有两个逗号分割结果是 3 段这个逻辑简单但容易错尤其字符串末尾带分隔符时比如a,b,按上面的代码会得到 3 段最后一段是空串。要不要保留空串完全看业务需求我建议用参数控制生产环境里绝大多数情况需要保留空串否则数据对不上位。第二每段都是单独calloc出来的新空间不修改原字符串这在多线程场景下很安全。代价就是调用方用完后必须逐段free所以我配套写了一个free_split_result函数防止忘记释放。曾经有个项目就因为忘释放分割结果跑了两天后内存暴涨排查了半天才发现是这里泄漏。2.1 strtok 的坑和正确打开方式如果你只是临时用一下、数据量小、确定是单线程strtok也不是完全不能用。但必须知道它的两个致命问题会修改原字符串把分隔符替换成\0内部用静态变量保存位置第二次调用会接着第一次的位置继续线程不安全。C11 标准提供了strtok_sWindows 上叫strtok_s其他平台有的叫strtok_r多线程环境下用这个是安全的选择。但无论哪个版本修改原字符串这点都没变。所以我的建议是能不碰 strtok 就不碰手写一个分割函数也就几十行还能顺便处理空串、连续分隔符这些边界情况一劳永逸。2.2 分割字符串的性能优化思路上面那个实现每个子串独立分配内存灵活但开销不小。如果分割频率极高可以考虑一次性分配一整块内存把所有子串连续存放再用指针数组索引。缺点是实现复杂度上去了而且子串长度不确定需要先扫描一遍记录每个子串的偏移。我的经验是业务代码里独立分配的方式足够用了不要过早优化但如果是嵌入式环境或者做网络协议解析这类高频场景一次性分配的方式更靠谱。3. 字符串与数字互转atoi 只是看起来好用字符串转数字初学者最常用atoi但这个函数有个大问题它不报告错误。你给它abc它返回 0给它123abc它返回 123给它 NULL行为未定义。很多时候程序不会立刻挂但数据错得莫名其妙。生产环境我更推荐用strtol系列它带错误检查能告诉你转换是否成功、从哪里开始停止转换。#include stdio.h #include stdlib.h #include errno.h int safe_str_to_int(const char *s, int *out) { if (NULL s || NULL out) { return -1; } errno 0; char *end NULL; long val strtol(s, end, 10); // 完全没有数字字符 if (end s) { return -1; } // 溢出检查 if (errno ERANGE || val -2147483648L || val 2147483647L) { return -1; } // 如果后面还有非空字符按需决定是否报错 while (*end ! \0) { if (*end ! *end ! \t *end ! \n) { return -1; } end; } *out (int)val; return 0; }这个函数检查了三种典型错误完全没数字、数值溢出、数字后面跟了非法字符。注意我在溢出检查里用了 L 后缀的常量因为strtol返回的是 long不同平台上 long 可能是 4 字节也可能是 8 字节直接和 int 的边界比较可以避免类型混淆带来的隐患。3.1 过滤不可转数字的字符串从数据库到业务校验热搜词里频繁出现过滤不可转为数字的字符串这其实是数据清洗里很常见的需求。比如从 Oracle 数据库取出的字段是 VARCHAR2但业务上期望它是数字又比如前端传来的订单号、手机号用 JSON 序列化后变成字符串后端必须判断能否转成数字。做法跟上面的safe_str_to_int一个思路只是不需要真正转换只做格式验证int is_pure_number(const char *s) { if (NULL s || *s \0) { return 0; } int i 0; if (s[0] - || s[0] ) { i 1; if (s[1] \0) { return 0; } } for (; s[i] ! \0; i) { if (s[i] 0 || s[i] 9) { return 0; } } return 1; }这个函数能处理正负号但不处理小数点。如果业务上要的是整数这样判断就够了如果要处理小数就得额外把小数点考虑进去并且只允许出现一次。我写这个函数时特意把空串和单个正负号的情况都拦住了很多脏数据就藏在这些不起眼的地方。3.2 数字转字符串的缓冲区陷阱反过来数字转字符串最常见的坑是缓冲区不够。用sprintf转 int理论上最多 11 个字符包括负号和终止符但很多新手开 10 个字节的缓冲区一旦是负数就溢出。更稳的方式是用snprintf显式传入缓冲区大小char buf[16]; int num -12345; int len snprintf(buf, sizeof(buf), %d, num); if (len 0 || len (int)sizeof(buf)) { // 处理截断或错误 }snprintf返回的是如果缓冲区足够长本该写入的字符数所以返回值大于等于缓冲区大小时就说明截断了必须处理。这个返回值特性很多人不知道只判断len 0实际上截断时返回值一样是正数。记住判断截断要看 len 是否 缓冲区大小。3.3 通用场景延伸字符串转日期比如 MySQL 的STR_TO_DATE、字符串拼接 JSON、模板字符串渲染本质上都是不同类型数据和字符串之间的桥接。理解了数字互转的原理再去看数据库的转换函数、脚本语言的类型转换理解速度会快很多。4. 字符串比较与包含判断不是只有 strcmp字符串比较大家都知道strcmp但什么时候该用strncmp什么时候不该用很多人分不清楚。strcmp比较的是整个字符串遇到\0停止strncmp只比较前 n 个字符n 是上限。#include stdio.h #include string.h // 比较完整字符串 if (strcmp(str1, str2) 0) { // 完全相等 } // 比较字符串中是否包含某个子串 const char *pos strstr(str, hello); if (pos ! NULL) { // 包含hello 从 pos 位置开始 } // 判断前缀 if (strncmp(str, http://, 7) 0) { // 以 http:// 开头 }这里最值得说的是strstr的性能问题。在大文本里反复查找子串时C 标准库的strstr通常是简单算法最坏复杂度 O(n*m)。如果查找频繁且文本很大建议换用 KMP 算法或者直接引入现成的字符串搜索库。我做过一次日志分析用strstr遍历几十万行日志找关键字耗时好几秒换成一次性建索引之后耗时降到了毫秒级。关键不是strstr不好而是要知道它的适用边界。4.1 大小写不敏感比较的实现很多时候比较字符串需要忽略大小写比如配置文件中布尔值的判断true、TRUE、True或者文件名后缀比对.JPG 和 .jpg。C 标准库没有直接提供不区分大小写的strcasecmp这是 POSIX 扩展Windows 上叫_stricmp跨平台代码最好自己写一个int str_equals_ignore_case(const char *a, const char *b) { if (NULL a || NULL b) { return 0; } while (*a ! \0 *b ! \0) { char ca (*a A *a Z) ? (*a 32) : *a; char cb (*b A *b Z) ? (*b 32) : *b; if (ca ! cb) { return 0; } a; b; } return (*a \0 *b \0); }注意这个写法只处理 ASCII 字母的大小写转换不涉及中文等多字节字符。如果要处理 UTF-8 编码的中文大小写变换的逻辑完全不同必须引入专门的字符库要不就上 ICU。在这个函数里我判断大小写用的是 ASCII 码区间 32 的方式相比tolower()少一次函数调用性能略优但可读性稍差。如果追求可读性和跨平台性直接用tolower()也完全可以。4.2 字符串长度的隐藏问题热搜词里字符串长度几乎是所有字符串操作的基础但长度也有两个隐藏问题第一strlen返回的是size_t无符号。如果你写strlen(s) - 3这样的表达式当字符串长度小于 3 时结果不是负数而是一个巨大的无符号数后面用到这个差值做循环条件会直接死循环。正确写法是先把长度存到 int 变量里再用 int 做减法。第二字符串长度和显示宽度的区别。每分10个字符汉字算一个字符英文字母和数字两个算一个字符这其实说的是控制台显示宽度——一个汉字占两个英文字符的宽度。这在做终端表格对齐时非常关键strlen根本不管这个它只按字节数算。一个汉字在 UTF-8 编码下是 3 个字节在 GBK 编码下是 2 个字节而显示宽度却都是 2 个英文字符的宽度。处理这类需求要么自己对 UTF-8 编码做解码要么引入wcwidth相关的库。5. 字符串排序从函数指针到指针数组字符串排序看起来简单实际上涉及一个很核心的 C 语言知识点指针数组和函数指针。排序的对象往往不是单个字符串变量而是字符串指针数组。每个元素是一个char *指向一块字符串内存。用 C 标准库的qsort排序字符串数组关键点在于比较函数要正确解引用两层指针#include stdio.h #include stdlib.h #include string.h int cmp_str(const void *a, const void *b) { // a 和 b 是数组元素的指针 // 数组元素是 char *所以 a 是 char ** const char *sa *(const char * const *)a; const char *sb *(const char * const *)b; return strcmp(sa, sb); } void sort_strings(char **arr, int n) { qsort(arr, n, sizeof(char *), cmp_str); }这段代码里最容易写错的地方是*(const char * const *)a。a的类型是const void *指向的是数组中的元素而元素本身是char *所以第一步要把a转成char * const *指向字符指针的指针第二步再解引用拿到真正的char *。很多新手写成*(char **)a还能编译通过但少了 const 限定在要求严格的代码规范里会被打回。5.1 按字符串长度排序的场景除了字典序排序按长度排序也很常见。比如统计日志里最长的关键字或者做瀑布流展示时按文本长度排序。实现上和字典序排序就差一个比较函数int cmp_str_by_len(const void *a, const void *b) { const char *sa *(const char * const *)a; const char *sb *(const char * const *)b; size_t la strlen(sa); size_t lb strlen(sb); if (la ! lb) { return la lb ? 1 : -1; } return strcmp(sa, sb); }这里要注意长度相等时我额外用strcmp做了一次稳定排序。因为qsort不是稳定排序如果只按长度比较长度相等的两个元素顺序可能乱掉。业务上可能不需要这个保证但加上这层比较后输出结果更可预期调试时也少一些怎么顺序每次不一样的困惑。5.2 字符串数组初始化的两种方式热搜词里有C字符串数组初始化这个在 C 语言里同样常遇到。两种初始化方式的区别至关重要// 方式一字符串字面量数组可以修改内容 char arr1[][16] {hello, world, test}; // 方式二字符指针数组指向字符串字面量内容不可修改 const char *arr2[] {hello, world, test};第一种是二维字符数组每个字符串存在固定大小的行里可以修改单个字符但 16 字节的上限决定了字符串长度被限制在 15 个字符以内。第二种是字符指针数组灵活得多但指向的是只读字符串字面量任何试图通过指针修改内容的操作都会崩溃。如果要用qsort排序的数组做原地交换两种方式都适合但如果你要修改字符串本身的内容只能用第一种。这个区别是很多隐蔽 bug 的来源声明时多花点时间想清楚要的是哪种。5.3 排序中的内存布局说到指针数组存放字符串不得不提内存布局的问题。字符串字面量存放在只读数据段malloc来的字符串存放在堆上栈上的字符数组存放在栈里。qsort排序时只交换数组中的指针值不搬运字符串数据本身这一点非常关键。正因如此字符串排序的代价很小只需要交换指针的大小8 字节64 位平台不需要移动实际字符串内容。如果改成对二维字符数组排序每次交换都要通过memcpy搬运整个字符串几十万条数据排序时性能差距会非常明显。所以面对大量字符串排序用一个独立的指针数组指向原始字符串是正确且高效的做法。6. 常见问题排查实录字符串相关的坑我替你踩过了字符串操作的报错往往不像数组越界那么直接程序可能继续运行很久才崩排查成本很高。这里把我实际踩过、也帮别人排查过的几类高频问题做个整理方便对照查。6.1 缓冲区溢出与内存泄漏热词里提到MFC字符串内存泄漏这个背景我不展开只讲一个通用原则字符串相关的内存问题十有八九是谁分配谁释放的原则没守住。比如前面实现的分割函数每个子串都是独立的malloc出来的调用方用完必须逐个free。如果只在不需要时free了返回的数组指针数组里每个子串的内存就会全部泄漏。C 语言没有垃圾回收字符串内存只能靠约定来管。我的习惯是任何返回指针数组的函数要么配套提供一个释放函数要么在注释里写清楚释放规则并在命名上给出明显提示。像free_split_result这种配套写法就是强制把分配方和释放方绑定在一起。排查内存泄漏的手段也不复杂Linux 上用 valgrindWindows 上用 CRT 调试堆都能直接列出泄漏点和调用栈。不要凭感觉猜工具给出的定位永远比人肉翻代码快。6.2 宽字符与中文字符串的问题很多做 Windows 开发的朋友遇到过codeblock 字符串 宽字符 L 表示 出错本质上是对宽字符串字面量的使用方式不对。C 里Lhello表示宽字符字符串wchar_t 数组而不是普通 char 数组如果混用在char *参数里编译器会直接报错。中文编程场景下还有一个显示宽度的问题回到了第 4.2 节的内容。处理中文文本时按字节截取字符串很容易截出半个汉字UTF-8 下可能把一个汉字的 3 个字节截断成 2 个这就是乱码的根源。稳健的做法是明确编码UTF-8、GBK、Unicode截取时先解码字符边界再按字符数截取而不是按字节数需要等宽对齐时用显示宽度而不是字节数。6.3 字符串常用操作速查这里整理一个小表把前面讲的核心函数按使用场景列出来方便日常翻阅。需求场景推荐方案注意事项字符串长度strlen注意 size_t 与 int 混用的风险逆序字符串双指针原地交换先判空、处理空串按字符分割手写分割器定好空串策略释放每个子串内存字符串转整数strtol 封装检查 end、errno、溢出整数转字符串snprintf检查返回值是否截断完整比较strcmp确认两个串都不是 NULL前缀/前 n 字符比较strncmp明确 n 的含义子串查找strstr大数据量时考虑 KMP忽略大小写比较自写 ASCII 转换不处理多字节字符排序字符串数组qsort 自定义比较注意两层解引用这张表说白了就是先确定场景再选函数。很多人出问题不是因为函数不会写而是场景判断错了——用strcmp去比较可能为 NULL 的字符串用strtok去多线程里分割用atoi去解析用户输入。函数本身没错用错了地方才是问题。6.4 一个综合案例按空格分割并逆序输出最后给一个练手用的综合案例把分割、逆序、排序三个主题串起来。需求是输入一个英文句子按空格切成单词每个单词各自逆序然后按字母序输出。#include stdio.h #include string.h #include stdlib.h void reverse_str(char *s) { int len (int)strlen(s); for (int i 0; i len / 2; i) { char tmp s[i]; s[i] s[len - 1 - i]; s[len - 1 - i] tmp; } } int cmp_str(const void *a, const void *b) { const char *sa *(const char * const *)a; const char *sb *(const char * const *)b; return strcmp(sa, sb); } int main(void) { char line[] the quick brown fox jumps over the lazy dog; char *tokens[64]; int count 0; char sep[] ; // 用 strtok 快速切分仅演示实际谨慎使用 char *p strtok(line, sep); while (p ! NULL) { tokens[count] p; p strtok(NULL, sep); count; } // 每个单词逆序 for (int i 0; i count; i) { reverse_str(tokens[i]); } // 排序并输出 qsort(tokens, count, sizeof(char *), cmp_str); for (int i 0; i count; i) { printf(%s\n, tokens[i]); } return 0; }这个例子故意用了strtok就是想帮你确认这里明确知道是单线程、一次性处理源字符串也可以被改写所以strtok能用。但如果你要把它改写成通用函数记得用第 2 节那个手写分割器替换否则隐藏隐患。输出的结果是每个单词逆序后的新字符串排序这个结果可能跟直觉不太一样但逻辑链路是完整的——分割、逆序、排序三个操作通过指针数组串联起来每一步都清晰可查。写字符串处理的代码我最大的体会是不要相信输入。无论是数据库来的、用户输入的还是配置文件的都假设它可能是脏的然后在代码入口做校验。字符串是 C 语言里和内存打交道最密切的领域之一也是培养严谨编码习惯最好的练习场。上面这些函数不管做项目还是刷题总有一个用得上建议都亲手敲一遍并跑出结果比光看不练扎实得多。最后再提醒一次涉及指针数组和内存分配的务必想清楚谁分配、谁释放这是所有字符串问题的总钥匙。
返回列表