LeetCode 791. 自定义字符串排序
题目描述
题意分析
给两个只含小写字母的字符串
order和s,其中order内部字符互不重复。要求重排s,使得凡是同时出现在order里的两个字符x、y,若x在order中排在y前面,则重排后s中所有的x都必须排在所有的y前面。不在order中的字符可以放在任意位置。答案不唯一,返回任何一个合法结果即可。约束里有两条关键信号。第一,字符集固定为 26 个小写字母,这意味着任何「按字符归类」的操作都可以用一个定长数组完成,不需要哈希表也不需要动态扩容。第二,题目只约束相对顺序,完全不关心某个字符原来在
s的第几位——同一个字母的多个副本彼此不可区分,交换它们不影响结果。这就等于宣布:s的位置信息是冗余的,只有「每个字母出现了几次」这 26 个数字是有用的。一旦承认位置信息可丢,输出就不是「排序」而是「按某个顺序把桶里的字母倒出来」。这是本题真正的解法入口。
边界有三处:
s里可能含有order中不存在的字符,它们必须一个不漏地出现在答案里,只是位置自由;order里可能含有s中不存在的字符,这些字符不该凭空出现在答案里;两个串长度都可能为 1。此外答案长度必须严格等于s的长度,多一个或少一个都是错的。
解法:计数重排
核心思路
先看最直接的写法:把
s拆成字符数组,用一个比较器排序,比较键取该字符在order中的下标,不在order中的统一取一个比所有下标都大的值(比如 26)。这个做法是对的,但要付出 $O(n \log n)$ 的比较代价,而且每次比较都要在order里查一次下标。瓶颈在于:排序算法假设元素两两不同、需要逐对比较才能定序。但这里的比较键只有 26 种取值,而取值相同的元素本身也完全相同——键为 3 的字符必然都是同一个字母。既然如此,排序做的所有比较工作都是在重复确认一件早就知道的事。
由此得到观察:只要知道每个字母出现了多少次,答案就可以直接「按顺序印出来」,一次比较都不用做。
order给出了前一部分的印刷顺序,剩下的字母随便什么顺序印都合法。于是维护一个长度 26 的计数数组
cnt,全程的不变量是:cnt[c]恒等于字符c中「还没有被写进结果」的个数。初始化时cnt装的是s的全部字符,此时结果为空;每往结果里追加一个c就把cnt[c]减一。因此任意时刻都有已写入长度 + cnt 各项之和 = s.length,当所有cnt归零时结果长度自然等于s.length,不多也不少。输出分两趟:第一趟按
order的顺序把命中的字母全部倒空,第二趟扫 26 个桶把剩下的倒空。第一趟保证了order约束成立——先倒的字母整体排在后倒的字母之前;第二趟处理的字符不受任何约束,放在末尾必然合法。这里也解释了为什么order中s没有的字符不会带来麻烦:它的计数本来就是 0,while循环一次都不进。
解题步骤
- 建立计数:遍历
s,cnt[ch - 'a']++。用ch - 'a'把字符映射成 0..25 的下标,这是小写字母题的标准手法,比哈希表省掉装箱和哈希计算。- 准备可变的结果容器:Java 用
StringBuilder,Go 用预分配len(s)容量的[]byte。不能用字符串拼接,那样每次追加都会复制整个前缀,总代价退化成 $O(n^2)$。- 第一趟按
order输出:对order的每个字符ch,只要cnt[ch - 'a'] > 0就追加一个ch并把计数减一。用while而不是if,因为同一个字母在s中可能出现多次,必须一次性全部倒空——否则它的副本会漏到第二趟去,破坏相对顺序。- 计数清零即是标记:减到 0 这个动作同时完成了「已输出」的标记,第二趟看到 0 自然跳过。不需要额外的
visited数组,这是让计数数组一身二职的小技巧。- 第二趟输出剩余:从 0 到 25 扫一遍,把还有余量的字母全部追加。这些字符不在
order里(在order里的已经被清零了),顺序任意,这里恰好是字典序。- 返回结果:把容器转成字符串。
以
order = "cba"、s = "abcd"走一遍。建表后:
cnt['a'] = 1、cnt['b'] = 1、cnt['c'] = 1、cnt['d'] = 1,其余全 0,结果串为空。
第一趟第 1 轮:ch = 'c',cnt['c'] = 1 > 0,追加得"c",cnt['c']降为 0;再判一次已是 0,退出内层循环。
第一趟第 2 轮:ch = 'b',追加得"cb",cnt['b']降为 0。
第一趟第 3 轮:ch = 'a',追加得"cba",cnt['a']降为 0。
此时cnt中只剩cnt['d'] = 1。
第二趟:下标 0..2 对应的a、b、c计数都是 0,跳过;下标 3 对应d,计数为 1,追加得"cbad",减为 0;其余下标全 0。
返回"cbad",长度 4 与s相同,c在b前、b在a前,符合order;d不受约束。再看一个含重复字符的例子体会
while的必要性:order = "cba"、s = "aabbcc"时,第一趟每个字母都要连倒两次,得到"ccbbaa"。如果内层写成if,只会各倒一个得到"cba",剩下的a、b、c被第二趟以字典序追加成"cbaabc"——里面a跑到了b、c前面,直接违反order。
代码实现
class Solution {
// 按 order 的顺序输出对应字符,次数用尽后输出剩余字符。
public String customSortString(String order, String s) {
int[] cnt = new int[26];
for (char ch : s.toCharArray()) {
cnt[ch - 'a']++;
}
StringBuilder sb = new StringBuilder();
for (char ch : order.toCharArray()) {
while (cnt[ch - 'a'] > 0) {
sb.append(ch);
cnt[ch - 'a']--;
}
}
for (int i = 0; i < 26; i++) {
while (cnt[i] > 0) {
sb.append((char) ('a' + i));
cnt[i]--;
}
}
return sb.toString();
}
}
func customSortString(order string, s string) string {
// 按 order 的顺序输出对应字符,次数用尽后输出剩余字符。
cnt := make([]int, 26)
for i := 0; i < len(s); i++ {
cnt[s[i]-'a']++
}
res := make([]byte, 0, len(s))
for i := 0; i < len(order); i++ {
ch := order[i]
for cnt[ch-'a'] > 0 {
res = append(res, ch)
cnt[ch-'a']--
}
}
for i := 0; i < 26; i++ {
for cnt[i] > 0 {
res = append(res, byte('a'+i))
cnt[i]--
}
}
return string(res)
}
复杂度分析
- 时间复杂度:$O(n + m)$,其中 $n$ 为
s长度、$m$ 为order长度。建表扫一遍s;两趟输出的内层while总执行次数等于s的长度(每次执行必定写出一个字符且计数减一,总减少量恰好是 $n$),外层是 $m + 26$ 次判断。全程没有任何比较排序,凭的就是「比较键只有 26 种且键相同即元素相同」这一点。- 空间复杂度:$O(1)$ 额外空间,计数数组固定 26 个整数,与输入规模无关;返回的结果串占 $O(n)$,但那是必需的输出而非辅助结构。
关键点总结
- 当排序键的值域是小常数、且键相同的元素彼此不可区分时,比较排序可以整体降级为计数排序,$O(n \log n)$ 直接降到 $O(n)$。这是本题最值得迁移的一条。
- 题目只约束相对顺序而不约束绝对位置,是「可以丢掉原始下标」的许可证。看到这句话就该想到计数。
- 让计数数组一身二职:数值既是剩余个数,归零又充当「已处理」标记,省掉一个
visited数组。- 分两趟输出的正确性证明很短——第一趟内部满足
order,第二趟的字符不受任何约束,因此整体合法。面试时把这两句说出来,比默写代码有说服力得多。- 面试视角:面试官通常会先接受 $O(n \log m)$ 的自定义比较器解法,然后追问「能不能做到线性」。如果你能主动指出比较键只有 26 种取值、进而给出计数版本,并顺带说明
StringBuilder与字符串拼接的复杂度差别,这道题就答满了。- 追问的常见延伸是「如果字符集是全 Unicode 呢」:此时计数数组换成哈希表,第二趟改为遍历哈希表的键,复杂度变成 $O(n + m)$ 期望,结论不变。
易错点总结
- 内层用
if而非while:order = "cba"、s = "aabbcc"时第一趟只输出"cba",剩余的a被第二趟按字典序放到最前,得到"cbaabc",a出现在c之前,判定失败。- 输出后忘记把计数清零:
order = "cba"、s = "abc"时c、b、a在第二趟被再次输出,返回"cbaabc",长度 6 而s长度只有 3。- 只输出
order中的字符,漏掉第二趟:order = "cba"、s = "abcd"返回"cba",字符d凭空消失。- 第二趟遍历
s而不是 26 个桶:s = "aabbcc"时同一个字母会被它的每个副本各触发一次判断,若同时忘了清零就会重复输出;即便写对了也白白多出 $O(n)$ 次无效判断,并且逻辑上更容易和第一趟的清零耦合出错。- 用字符串拼接代替
StringBuilder:s长度为 200 时看不出问题,但写法本身是 $O(n^2)$,面试官几乎必问,属于送分变送命。- 假设
order一定包含s的所有字符:order = "kqep"、s = "pekeq"尚可,但order = "kqep"、s = "pekexlz"中的x、l、z会被吞掉。题目从未保证order是全集。- 假设
s一定包含order的所有字符:若第一趟不判断cnt > 0就无脑追加一次,order = "abc"、s = "b"会返回"abc",凭空造出两个不存在的字符,并把计数减成负数。- 用
ch - 'A'或直接拿ch当下标:前者对小写字母算出负下标直接数组越界,后者需要长度 128 的数组,用 26 会ArrayIndexOutOfBoundsException。- Go 里对字符串用
range取字符:for i, ch := range s拿到的ch是rune且i是字节偏移,虽然本题全是 ASCII 不会出错,但与res = append(res, ch)的byte类型不匹配,编译期就会报错。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 1122. 数组的相对排序 | 简单 | 同款「按给定序列重排」,但值域是 0..1000 的整数,且未命中的部分要求升序而非任意序 |
| 451. 根据字符出现频率排序 | 中等 | 排序键改成出现次数而非外部给定顺序,需要对计数结果再排序或用桶排序 |
| 75. 颜色分类 | 中等 | 同样是常数值域,但要求原地且一趟完成,只能用三指针而不能用计数两趟 |
| 49. 字母异位词分组 | 中等 | 计数结果不用来输出,而是当作分组的哈希键 |
| 242. 有效的字母异位词 | 简单 | 只需比较两张计数表是否相等,不涉及任何输出顺序 |
| 387. 字符串中的第一个唯一字符 | 简单 | 计数后必须回到原串按下标扫描,位置信息在这里恰恰不能丢 |
| 347. 前 K 个高频元素 | 中等 | 值域无界,计数之后靠堆或桶排序取前 K,而非全量重排 |