24. 字符串的字典序:字符串排序,寻找字典序的下一个排列
字典序,说白了就是「按字母表顺序排」。你查英文字典的时候,apple 排在 banana 前面,这就是字典序。在 C 语言里处理字符串排序,本质上就是比较字符的 ASCII 码值。
我记得刚入行那会儿,有个需求是要对一批用户名做排序。我心想这还不简单?直接冒泡排序,strcmp 一比较就完事了。结果上线后,用户反馈说排序结果不对——大小写混在一起,'Z' 排在了 'a' 前面。嗯,这就是典型的字典序陷阱。
24.1 字典序的基本规则
两个字符串比较时,从左到右逐个字符比较。遇到第一个不同的字符,谁的 ASCII 码小,谁就排在前面。如果一个字符串是另一个的前缀,短的排前面。
核心规则:
- 字符按 ASCII 码值比较:'A'(65) < 'B'(66) < ... < 'Z'(90) < 'a'(97) < 'b'(98)
- 空字符 '\0' 的 ASCII 码是 0,比任何可见字符都小
- 数字字符 '0'(48) 排在字母前面
举个例子:
"abc" < "abd" // 第三个字符 'c'(99) < 'd'(100)
"ab" < "abc" // "ab" 是 "abc" 的前缀
"ABC" < "abc" // 'A'(65) < 'a'(97)
"123" < "abc" // '1'(49) < 'a'(97)
24.2 字符串排序:用 qsort 实现
我建议你直接用标准库的 qsort,别自己手写排序算法。为什么?因为 qsort 是经过高度优化的,而且不容易写错。我自己在项目中踩过坑——手写冒泡排序处理几千个字符串,性能惨不忍睹。
来看一个完整的排序示例:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
// 比较函数:按字典序升序
int compare_strings(const void *a, const void *b) {
// a 和 b 是 char** 类型,需要先解引用
const char **str1 = (const char **)a;
const char **str2 = (const char **)b;
return strcmp(*str1, *str2);
}
int main() {
// 字符串数组
const char *fruits[] = {
"banana", "apple", "Cherry", "date", "Apple"
};
int n = sizeof(fruits) / sizeof(fruits[0]);
printf("排序前:\n");
for (int i = 0; i < n; i++) {
printf(" %s\n", fruits[i]);
}
// 调用 qsort 排序
qsort(fruits, n, sizeof(char *), compare_strings);
printf("\n排序后(字典序):\n");
for (int i = 0; i < n; i++) {
printf(" %s\n", fruits[i]);
}
return 0;
}
输出结果:
排序前:
banana
apple
Cherry
date
Apple
排序后(字典序):
Apple
Cherry
apple
banana
date
注意:大写字母 'A'(65) 比小写 'a'(97) 小,所以 "Apple" 排在了 "apple" 前面。如果你想要忽略大小写的排序,可以用 strcasecmp()(POSIX 标准)或者自己写一个忽略大小写的比较函数。
24.3 忽略大小写的字典序排序
实际项目中,大部分场景需要忽略大小写。我曾经做过一个通讯录应用,用户输入 "张三" 和 "zhangsan" 应该排在一起,而不是按 ASCII 码分开。
自己实现一个忽略大小写的比较函数:
#include <ctype.h>
int compare_ignore_case(const void *a, const void *b) {
const char **str1 = (const char **)a;
const char **str2 = (const char **)b;
const char *s1 = *str1;
const char *s2 = *str2;
while (*s1 && *s2) {
char c1 = tolower((unsigned char)*s1);
char c2 = tolower((unsigned char)*s2);
if (c1 != c2) {
return c1 - c2;
}
s1++;
s2++;
}
// 处理前缀情况
if (*s1 == '\0' && *s2 == '\0') return 0;
if (*s1 == '\0') return -1;
return 1;
}
注意:tolower() 的参数要转为 unsigned char,否则在扩展 ASCII 字符上可能产生未定义行为。这是我在一次处理中文环境时踩过的坑。
24.4 寻找字典序的下一个排列
这个题目很有意思。给你一个字符串,比如 "abc",它的下一个字典序排列是 "acb"。再下一个是 "bac",然后是 "bca","cab",最后是 "cba"。
算法思路其实不复杂,我总结为三步:
- 从右向左找第一个降序对:找到第一个位置 i,使得 s[i] < s[i+1]
- 从右向左找第一个比 s[i] 大的字符:找到位置 j,使得 s[j] > s[i]
- 交换并反转:交换 s[i] 和 s[j],然后将 i+1 到末尾的部分反转
为什么会这样?你想想看,我们要找「下一个」更大的排列,就要尽量不动高位,只调整低位。找到降序对,说明这里可以变大;找到比 s[i] 大的最小字符,保证变大的幅度最小;反转后面的部分,是因为后面原本是降序,反转后变成升序,就是最小的排列。
来看代码实现:
#include <stdio.h>
#include <string.h>
#include <stdbool.h>
// 交换两个字符
void swap(char *a, char *b) {
char temp = *a;
*a = *b;
*b = temp;
}
// 反转字符串从 start 到 end 的部分
void reverse(char *s, int start, int end) {
while (start < end) {
swap(&s[start], &s[end]);
start++;
end--;
}
}
// 寻找下一个字典序排列
// 如果存在下一个排列,返回 true;否则返回 false
bool next_permutation(char *s) {
int len = strlen(s);
if (len <= 1) return false;
// 第一步:从右向左找第一个降序对
int i = len - 2;
while (i >= 0 && s[i] >= s[i + 1]) {
i--;
}
// 如果没找到,说明已经是最大排列
if (i < 0) {
return false;
}
// 第二步:从右向左找第一个比 s[i] 大的字符
int j = len - 1;
while (s[j] <= s[i]) {
j--;
}
// 第三步:交换并反转
swap(&s[i], &s[j]);
reverse(s, i + 1, len - 1);
return true;
}
int main() {
char str[] = "abc";
printf("字符串 \"%s\" 的所有字典序排列:\n", str);
// 先排序,确保从最小排列开始
// 这里假设输入已经是升序排列
do {
printf(" %s\n", str);
} while (next_permutation(str));
return 0;
}
输出结果:
字符串 "abc" 的所有字典序排列:
abc
acb
bac
bca
cab
cba
关键点:
- 输入字符串必须是升序排列的,才能得到所有排列
- 如果返回 false,说明已经是最大排列(完全降序)
- 时间复杂度 O(n),空间复杂度 O(1)
24.5 处理重复字符的情况
如果字符串里有重复字符,比如 "aab",上面的算法依然有效。它会自动跳过重复的排列。
输入 "aab" 的排列:
aab
aba
baa
为什么不会出现重复?因为算法在找「第一个比 s[i] 大的字符」时,用的是 s[j] <= s[i] 这个条件。遇到相等的字符会跳过,这样就保证了不会产生重复的排列。
小技巧:如果你需要生成所有排列,可以先对字符串排序(用 qsort),然后不断调用 next_permutation 直到返回 false。这样保证从最小排列开始,到最大排列结束。
24.6 实际项目中的应用
我在做电商后台的时候,遇到过这样一个需求:商品 SKU 有多种属性组合,需要按字典序列出所有可能的组合。比如颜色有红、蓝,尺寸有 S、M、L,需要生成 "红S"、"红M"、"红L"、"蓝S"、"蓝M"、"蓝L" 这样的顺序。
当时就是用 next_permutation 的思路,只不过把字符换成了属性值数组。嗯,这个算法在组合数学和密码学里也很常见。
24.7 本章小结
字典序排序和下一个排列,这两个知识点是字符串处理的基石。我建议你记住以下几点:
- 排序用 qsort + strcmp,别自己写排序
- 注意大小写问题,根据需求选择是否忽略大小写
- next_permutation 的三步法:找降序对、找交换位、反转
- 处理重复字符时,条件判断要用
<=而不是<
这些技巧我在多个项目里反复用过,每次都能派上用场。你可以在自己的代码库里封装一个通用的 next_permutation 函数,以后遇到类似需求直接拿来用。
公众号:蓝海资料掘金营,微信deep3321