【C语言-算法-文本处理】4. 字符与字符串的"文本处理"

开发技术 作者: 精灵星空 SKY315.COM 更新于: 浏览: 304
码字发文不易,转载请注明出处【精灵星空 SKY315.COM】

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这几个常用函数的山寨版。能用双指针法做反转和回文判断。能用读写双指针做删除和过滤。

还是那句话,打开编译器,每段代码自己敲一遍。字符串的错误最直观,打印出来一看就知道哪儿不对。调试的时候多打印中间结果,看看每一步字符串变成了什么样子。亲手出几次错,比看十遍书都管用。