【C语言-字符数组】啃透C语言字符数组,字符串那些事儿一次说清楚

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

C语言在线编程环境:https://sky315.com:8080/c/。
前面咱们把一维数组、二维数组都聊透了。今天说个特殊的——字符数组。说它特殊,是因为它跟字符串紧密绑在一起,而字符串在C语言里又是个“没有专用类型”的奇葩存在。

很多同学学到字符数组就开始迷糊了:字符数组和字符串到底啥关系?char str[20]和char *p = "hello"有啥区别?为什么用scanf读字符串老翻车?strlen和sizeof算出来的数为啥不一样?

这些问题看着碎,但捋顺了就那么回事。字符数组就是用来存放字符的数组,而以'\0'结尾的字符数组,就成了C语言里的“字符串”。 就这一个区别,搞明白了后面一通百通。

一、字符数组长啥样?就是装字符的格子

字符数组的定义跟其他数组完全一样:

char str[10];      // 能放10个字符
char str2[20] = {'H', 'e', 'l', 'l', 'o'};  // 初始化,后面没填的自动补0
char str3[20] = "Hello";   // 用字符串字面量初始化,方便多了
char str4[] = "Hello";     // 不写大小,编译器自动算,长度是6(包含\0)

注意了,"Hello"实际上占了6个字节:H-e-l-l-o加上一个看不见的'\0'(空字符)。这个\0就是字符串的结束标志,C语言里所有字符串函数都靠它来判断字符串到哪儿结束。

如果你用字符数组存了一个不以\0结尾的字符序列,那它就是个普通字符数组,不是字符串。printf("%s")碰到没有\0的数组会一直往下读,直到内存里碰巧遇到一个\0为止,打印出来的东西乱七八糟。以\0结尾,这是字符数组和字符串的分水岭。

两种初始化方式的区别你得清楚:

char s1[] = {'H', 'e', 'l', 'l', 'o'};     // 长度5,没有\0,不是字符串
char s2[] = "Hello";                        // 长度6,自带\0,是字符串

s1如果用%s打印,会一直读到碰到\0为止,内存里紧挨着s1的后面如果是\0就运气好,如果不是就乱码。所以强烈建议用双引号的方式初始化字符串,省心又安全。

二、字符串的输入输出,各有各的脾气

输出字符串:

printf("%s", str); 是最常用的,从str的起始地址开始,逐个输出字符直到遇到\0。

puts(str); 更简单,输出字符串之后自动换行。但它不检查\0,遇到就停,没遇到就崩。

输入字符串:

scanf("%s", str); 这个要注意,它遇到空格、Tab、回车就会停。所以输入"Hello World",它只能读到"Hello","World"还留在缓冲区里等着被下一个输入读走。它还会自动在末尾补\0,所以你不用操心。

gets(str); 能读一整行包括空格,但这个函数极度不安全,因为它不检查数组长度,你输入100个字符它敢往10长度的数组里塞,缓冲区溢出就是它干的。C11标准已经把它废了,别用。

fgets(str, sizeof(str), stdin); 是安全版本,它会读入换行符,并且保证不会超过数组长度。比如你输入"Hello"然后回车,它读到的是"Hello\n",末尾还有\0。如果你不想要那个换行符,得自己手动去掉。

fgets(str, sizeof(str), stdin);
// 去掉末尾的换行符
int len = strlen(str);
if (str[len - 1] == '\n') {
    str[len - 1] = '\0';
}

三、字符串处理函数,打天下的基本功

C语言的string.h头文件里提供了一堆字符串操作函数。不要自己去写字符串拷贝、拼接的逻辑,直接用库函数,又安全又省事。

strlen——计算字符串长度

int len = strlen(str); 返回的是字符串中\0之前的字符个数,不包括\0。

char s[] = "Hello";
strlen(s);   // 5
sizeof(s);   // 6

strlen和sizeof经常被搞混。记住:strlen算的是字符串有效字符个数,不包含\0;sizeof算的是整个数组占用的字节数,包含\0。

strcpy——字符串拷贝

strcpy(dest, src); 把src拷贝到dest,包括结尾的\0。

char s1[20];
char s2[] = "Hello";
strcpy(s1, s2);   // 现在s1也是"Hello"

坑:dest的空间必须足够大,能放下src的全部内容加\0。不够的话会缓冲区溢出,编译不报错,运行结果不可预料。

strncpy——更安全的拷贝

strncpy(dest, src, n); 最多拷贝n个字符,如果src长度小于n,剩余位置补\0。

但strncpy有个毛病:如果src长度大于等于n,它不会在dest末尾自动加\0。你得自己手动加:

strncpy(s1, s2, sizeof(s1) - 1);
s1[sizeof(s1) - 1] = '\0';

strcat——字符串拼接

strcat(dest, src); 把src拼接到dest末尾,覆盖dest原来的\0,然后在新的末尾加\0。

char s1[20] = "Hello ";
char s2[] = "World";
strcat(s1, s2);   // s1变成"Hello World"

dest的空间必须够大,能容纳拼接后的完整字符串。strcat不会检查空间够不够,全靠你自己保证。

strncat——更安全的拼接

strncat(dest, src, n); 最多取src的前n个字符拼接到dest末尾,然后自动加\0。比strcat安全一些。

strcmp——字符串比较

int result = strcmp(s1, s2); 比较两个字符串的字典序。

返回值:

  • 小于0:s1 < s2

  • 等于0:s1 == s2

  • 大于0:s1 > s2

注意:不能用==比较两个字符串,那比的是地址,不是内容。if (s1 == s2)永远是假,除非它们指向同一个地址。用strcmp才是正道。

strchr——查找字符

char *p = strchr(str, 'c'); 在str中查找字符'c'第一次出现的位置,返回指向它的指针。找不到返回NULL。

strstr——查找子串

char *p = strstr(str, "Hello"); 在str中查找子串"Hello"第一次出现的位置,返回指向它的指针。找不到返回NULL。

四、典型例题挨个讲,字符串操作全练到

下面这几个题是字符串处理的经典,你挨个写一遍,基本所有常用操作就过了一遍。

例1:字符串长度计算——自己实现strlen

不用库函数,手动算长度。

int my_strlen(char s[]) {
    int len = 0;
    while (s[len] != '\0') {
        len++;
    }
    return len;
}

也可以写成指针版:

int my_strlen(char *s) {
    int len = 0;
    while (*s != '\0') {
        len++;
        s++;
    }
    return len;
}

这个循环的精髓就是一直往后走,直到撞见\0为止。'\0'的ASCII码是0,所以也可以写成while (*s),因为0就是假。

例2:字符串复制——自己实现strcpy

void my_strcpy(char *dest, char *src) {
    while (*src != '\0') {
        *dest = *src;
        dest++;
        src++;
    }
    *dest = '\0';   // 别忘了末尾补\0
}

更简练的写法:

void my_strcpy(char *dest, char *src) {
    while ((*dest++ = *src++) != '\0');
}

先赋值,再判断赋值后的值是不是\0。把src的内容挨个搬到dest,直到搬完\0为止。这行代码是C语言里经典的“写一行装个X”写法,看不懂没关系,能写出上面那个带花括号的版本就够用了。

例3:字符串反转

把"Hello"变成"olleH"。思路跟数组逆序一样,双指针从两头往中间走,交换字符。

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--;
    }
}

注意right初始值是strlen(s) - 1,不是strlen(s)。因为最后一位是\0,不能把它也换到前面去。

例4:统计字符串中某个字符出现的次数

int count_char(char s[], char target) {
    int count = 0;
    for (int i = 0; s[i] != '\0'; i++) {
        if (s[i] == target) {
            count++;
        }
    }
    return count;
}

这题就是个遍历数组的基础操作,判断条件从s[i]改成s[i] != '\0'而已。

例5:删除字符串中所有的某个字符

比如把"hello world"里的所有'l'删掉,变成"heo word"。

思路:用两个下标,一个读(i),一个写(j),遇到要删的字符就不写,其他字符往前挪。

void delete_char(char s[], char target) {
    int i, j = 0;
    for (i = 0; s[i] != '\0'; i++) {
        if (s[i] != target) {
            s[j] = s[i];
            j++;
        }
    }
    s[j] = '\0';   // 新的结尾
}

这个“读写双指针”的技巧在处理字符串删除、压缩、过滤的时候特别好使。一个指针负责读数据,一个指针负责写有效数据,避免了频繁挪动整个字符串。

例6:判断是不是回文串

回文串就是正着读反着读都一样,比如"abcba"、"level"。

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;   // 是回文
}

这个跟反转的思路有点像,但不用交换,只需要比较两端字符是否相等。一旦发现不相等就立刻返回0,全部比完都相等就返回1。

例7:字符串连接——自己实现strcat

void my_strcat(char *dest, char *src) {
    // 先找到dest的末尾
    while (*dest != '\0') {
        dest++;
    }
    // 从末尾开始拷贝src
    while ((*dest++ = *src++) != '\0');
}

先走到dest的\0位置,然后从那里开始把src的内容挨个复制过去。最后自动补上\0。

例8:统计单词个数

输入一个英文句子,单词之间用空格分隔,统计有多少个单词。比如"Hello world C language"有4个单词。

思路:遍历字符串,遇到非空格且前一个字符是空格(或者这是第一个字符)的时候,单词数加1。

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[0]没有前一个字符可以判断。

例9:字符串转数字(atoi的简化版)

把"123"转成整数123。思路:从左到右遍历,每读到一个数字,当前结果乘以10加上这个数字。

int my_atoi(char s[]) {
    int num = 0;
    for (int i = 0; s[i] != '\0'; i++) {
        if (s[i] >= '0' && s[i] <= '9') {
            num = num * 10 + (s[i] - '0');
        } else {
            break;   // 遇到非数字字符就停
        }
    }
    return num;
}

num * 10 + (s[i] - '0')是数字转换的核心公式。'0'到'9'的ASCII码是连续的,用字符减去'0'就得到了它对应的数字值。

加上负数支持和空格跳过,就是一个完整的atoi了。你可以自己扩展试试。

例10:字符串排序(多个字符串按字典序排列)

这个题把二维字符数组和字符串比较结合起来了。假如有5个名字,排序后按字母顺序输出。

char names[5][20] = {"Bob", "Alice", "Charlie", "David", "Eve"};
int n = 5;

// 冒泡排序
for (int i = 0; i < n - 1; i++) {
    for (int j = 0; j < n - 1 - i; j++) {
        if (strcmp(names[j], names[j + 1]) > 0) {
            char temp[20];
            strcpy(temp, names[j]);
            strcpy(names[j], names[j + 1]);
            strcpy(names[j + 1], temp);
        }
    }
}

for (int i = 0; i < n; i++) {
    printf("%s\n", names[i]);
}

排序逻辑跟整数数组一模一样,区别是比较用strcmp,交换用strcpy。二维字符数组的每一行就是一个字符串,交换的时候整行整行地拷。

五、这些坑我不说你肯定要踩

坑1:数组长度不够

定义字符数组的时候,长度要比你打算放的字符串至少多1,用来存\0。你想放"Hello"(5个字符),数组长度至少是6。经常有人写成char str[5] = "Hello";,编译能过,但数组里塞了6个字符(算上\0),末尾的\0挤出去了,str就不是一个合法的字符串了。

坑2:scanf读字符串遇上空格

scanf("%s")读到空格就停了。你想读一整行带空格的,用fgets。

坑3:strlen和sizeof傻傻分不清

再强调一遍:strlen算的是有效字符个数(不含\0),sizeof算的是整个数组占的字节数。

char s[20] = "Hello";
strlen(s);   // 5
sizeof(s);   // 20

坑4:strcpy、strcat不检查长度

这两个函数不会判断目标数组够不够大,你往小数组里拷大字符串,缓冲区溢出。用的时候自己保证目标空间足够,或者改用strncpy和strncat。

坑5:用==比较字符串

if (s1 == s2)比的是地址,不是内容。用strcmp。

坑6:fgets会把换行符读进来

这个前面说过,用fgets读字符串,回车会被存进数组里。如果不处理,你拿它跟别的字符串比较会莫名其妙地失败。记得去掉换行符。

六、最后说两句

字符数组和字符串处理,说白了就是对一块连续内存进行字符级别的操作。你跟一维数组对比一下——整数数组存的是整数,字符数组存的是字符,但操作逻辑几乎一样,区别就在于字符串有一个特殊的结束标志\0,所有的操作都围绕着识别和正确处理这个\0展开。

练到什么程度算过关? 拿到一个字符串处理的需求,你能手到擒来地写出遍历、拷贝、拼接、比较、查找、替换等操作,脑子里清楚每个函数在内存里动了哪些字节,字符串的结尾\0在哪里、什么时候需要手动补——这就算彻底掌握了。

光看没用,打开编译器,每个例子自己敲一遍。字符串的调试最好的方法就是打印中间结果,看看每一步字符串变成了什么。动手吧,把这些练熟了,你就不会再怵字符串了。