很多人刷 LeetCode 的时候,看到 1451 这道题——“重新排列句子中的单词”,第一反应是“这不就是个按长度排个序嘛,简单”。做完了才发现,这道题表面上是排序,实际上真正想考的是三件事:你会不会处理大小写、你会不会分词、你知不知道“稳定排序”和“不稳定排序”的区别。这三件事随便哪个没想明白,提交上去就是 WA(Wrong Answer),而且大概率你一时半会儿还看不出自己错在哪。
我最初做这道题的时候,用的就是 C++ 的 std::sort,结果第三个示例就直接挂了。看了半天测试用例,才意识到问题出在“长度相同单词要保持原来的顺序”这句话上。后来我把这道题翻来覆去做了几遍,也看了不少题解,发现很多文章只给了代码,没把背后的关键点讲透。所以这篇我打算把“为什么”讲清楚:为什么是稳定排序、为什么要先整体转小写、为什么有些解法用桶排序更优雅,以及测试用例该怎么构造。
1. 题面拆解:这个“简单题”其实藏了三个考点
1.1 输入输出约束:比你想的更严格
先看题目要求。给你一个句子 text,句子里的单词由单个空格分隔,句子开头没有空格、结尾也没有空格,而且首字母大写,其余字母都是小写。你要做的,是把这些单词按照“长度从短到长”重新排列,然后重新组成一个句子。关键点有两个:第一,如果两个单词长度一样,必须保持它们在原句中的顺序;第二,重排后的句子,仍然要求“第一个单词的首字母大写,其他所有字母小写”。
很多人在第一步就理解错了。题目说的是“句子”,不是“字符串”。你不能直接对整个字符串做字符级的排序,否则整个单词会被打散成一堆字母。我见过有人把 "Leetcode is cool" 排成了 "cdeeLoo..." 这种乱七八糟的内容,这就是没把“单词”作为基本单位来处理。
还有一个容易被忽略的约束是:输入里的单词只有首字母大写,其余字母小写。这其实是个很强的简化条件。正因为如此,你才能安全地先把整个句子统一转成小写,然后排序,最后再把第一个单词的首字母变成大写。如果输入里混着多个大写字母,这个做法就不成立了。
1.2 大小写恢复:首字母大写是最容易漏的一步
我见过不少解法,排序部分写得很漂亮,最后输出的时候忘了处理大小写,直接返回 "is cool leetcode",少了开头的 I 大写,整个判题结果就错了。
正确的处理顺序是:先把所有单词都转成小写,然后做排序,最后把排序结果里的第一个单词首字母大写。
为什么不是“排序前保留原大小写,排序后再恢复”?因为这个句子里的单词,原始状态除了首字母之外都是小写,所以整体转小写不会丢失任何信息。唯一“特殊”的首字母大写,在排序完成后重新把第一个单词的首字母大写就行了。这样做的另一个好处是,你不用去记录“原句里第一个单词是什么”“它排到哪去了”,逻辑上简单很多。
顺带说一句,Python 里 s[0].upper() + s[1:] 这个写法对空字符串会直接抛异常,所以最好加个防御性判断。C++ 里同理,words[0][0] 需要保证 words 非空。
1.3 同长度单词顺序:题目里的“隐藏条件”就藏在这一句
如果不是反复看了几遍题目,我可能到现在还不会把“长度相同保持原顺序”当成一个考点。尤其在刷题量上来之后,很多人看到“排序”两个字就直接上 sort,根本不会去想这个排序是不是稳定的。
这里要记住一个结论:C++ 的 std::sort 不保证稳定,Python 的 list.sort() 和 sorted() 是稳定的,std::stable_sort 是稳定的。具体到这道题,长度相同的单词必须按输入顺序输出。如果用不稳定的排序,即便排序结果经常碰巧正确,也有可能在某些输入下乱序,导致判题失败。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 稳定排序:为什么它决定了这道题的成败
2.1 稳定与不稳定的区别:一个简单的例子
稳定排序的意思是:如果两个元素关键字相同,排序之后它们的相对顺序不改变。
举个例子,有一个句子 "b a c",三个单词长度都是 1。按题目要求,它们应该保持原来的顺序,输出应该是 "B a c"。
用不稳定排序,你可能会得到 "A b c" 之类的任意顺序,这在题目里就是错误答案。虽然有些时候不稳定排序碰巧没改变顺序,但只要用例设计得稍微多一点,比如 "d a c b",那错误就会暴露。
我在实际提交中,就是用这个思路构造了一组“所有单词长度都一样”的测试数据,专门用来验证我的排序策略是不是真的稳定。这也是为什么后来我推荐大家:刷这道题的时候,别只跑官方示例,一定要自己造几组极端用例。
2.2 用索引兜底:不想用 stable_sort 的通用方案
如果你用的是算法语言里不提供稳定排序的库,或者你压根不想依赖 stable_sort,那么有一个非常通用的做法:给每个单词带上它在原句里的下标,排序键改成 (长度, 下标) 二元组。
这个方法值得说一下,因为它在很多实际问题里都通用。比如 C++ 里你用 std::sort,自定义比较函数的时候写成:
cpp复制sort(words.begin(), words.end(),
[](const pair<string, int>& a, const pair<string, int>& b) {
if (a.first.size() != b.first.size())
return a.first.size() < b.first.size();
return a.second < b.second;
});
这样即使 std::sort 本身不稳定,你的比较规则里也已经强制要求“同长度按下标升序”,最终结果一定等于稳定排序。
Python 里也可以这样:
python复制words = sorted(words, key=lambda x: (len(x[0]), x[1]))
通用思路其实就是一句话:把“稳定”这个要求,显式地编码到比较规则里去。这样一来,你就不需要依赖排序算法的性质,任何排序结果都能保证正确。
2.3 稳定排序在真实业务里的位置
这道题用稳定排序,很多人觉得“不就是题目的要求吗”。但如果把视角放到工程里,稳定排序的意义远不止于此。
举个最常见的例子:数据库里的 ORDER BY 多字段排序。如果你想先按“部门”排序,再按“入职时间”排序,如果你的排序算法不稳定,第二遍排序可能把第一遍排序的结果打乱。正确做法是写 ORDER BY department, hire_date,让数据库按联合排序键一次搞定;但如果你是在代码里手动做多轮排序,就必须保证后一轮排序是稳定的,或者用“多级比较键”的方式一次性完成。
再比如搜索结果排序:搜索引擎对一批文档先按“相关度分数”排序,然后希望分数相同的文档保持索引顺序,这时候稳定排序就是一种很自然的诉求。所以这道题虽然小,但它背后的“稳定性”意识,放到真实项目里非常有用。
3. 复杂度与方案选型:除了排序还有什么路
3.1 三种路线的复杂度推演
假设句子长度为 L,单词数量为 n,所有单词的平均长度大约是 L / n。
最暴力的做法是两层循环,两两比较长度,类似插入排序的思路。时间复杂度是 O(n²),每次比较长度是 O(1),所以总复杂度 O(n²)。这种做法在小数据量下没问题,但显然不是这道题该有的解法。
第二种是直接排序。比较两个单词的长度是 O(1) 的,排序 n 个元素是 O(n log n),所以总复杂度 O(n log n)。这里的 n 是单词数,不是字符数。需要注意的是,在比较过程中不需要逐个字符比内容,只用看长度,所以不会多出一个 O(L) 的因子。
第三种是桶排序思路。因为单词长度最大也就是整个句子的长度 L,所以我们可以开一个长度 L+1 的桶数组。遍历一遍单词,把每个单词放到对应长度的桶里;因为单词在桶里是按遍历顺序追加的,所以每个桶内部天然保持了原来的相对顺序。最后从长度 1 到 L 依次取出所有桶里的单词,就得到了答案。总复杂度是 O(L + n),接近线性,空间复杂度是 O(n)。
三种方案对比一下:
| 方案 | 时间复杂度 | 空间复杂度 | 是否需要稳定排序 |
|---|---|---|---|
| 双层暴力比较 | O(n²) | O(1) 或 O(n) | 看实现方式 |
| 排序(稳定) | O(n log n) | O(1) 到 O(n) | 需要 |
| 桶排序 | O(L + n) | O(n) | 不需要,天然稳定 |
3.2 桶排序:把“长度”这个键用得更彻底
桶排序在这个场景里有天然优势:我们排序的键是“长度”,而长度是一个范围明确的整数,不是任意可比的浮点数。这就意味着我们可以直接开一个长度数组,把单词按长度扔进去。
这么做的好处除了时间上是线性,更重要的是思路特别清晰:
python复制if not text:
return ""
words = text.split(" ")
max_len = max(len(w) for w in words)
buckets = [[] for _ in range(max_len + 1)]
for w in words:
buckets[len(w)].append(w)
res_words = []
for b in buckets:
res_words.extend(b)
res_words[0] = res_words[0][0].upper() + res_words[0][1:]
return " ".join(res_words)
桶内天然保持原顺序,没有任何稳定性问题。你甚至可以不用先统一转小写,因为拼接的时候可以统一处理,不过先转小写逻辑更清晰。
这种方式通常比排序更快,因为 L 虽然是句子总长度,但开桶的时候只需要开到最大单词长度,不会浪费太多空间。而且对“长度”这种整数键来说,桶排序本身就是一个很自然的选择。
3.3 空间开销与取舍
std::sort 的空间复杂度是 O(log n)(递归栈),stable_sort 则会额外申请约 O(n) 的临时空间,因为它底层使用的是归并排序。Python 的 list.sort() 底层是 Timsort,也是稳定的,最坏情况会使用 O(n) 的额外空间。
所以如果你的内存特别紧张,而你又不想用稳定排序的开销,那可以选带索引的 std::sort,或者直接用桶排序。桶排序的空间主要是 O(n) 存单词,加上 O(maxLen) 存桶的头部,整体可控。
就这道题的数据范围来说,其实随便哪种方案都能过。但把这些复杂度差异和实现代价想清楚,对你做面试里“follow up 优化”会有帮助。
4. 代码落地:Python 与 C++ 双版本实现
4.1 Python:split + sorted + join 的极简解法
Python 处理字符串相当方便,这道题最直接的写法是这样:
python复制def arrangeWords(text: str) -> str:
if not text:
return ""
words = text.split()
words = [w.lower() for w in words]
words.sort(key=lambda w: len(w))
words[0] = words[0][0].upper() + words[0][1:]
return " ".join(words)
注意几点:
第一,text.split() 和 text.split(" ") 有区别。split() 会把连续的多个空格当作一个分隔符,同时会过滤掉首尾空字符串;split(" ") 则会保留空字符串。题目说输入是“单词之间单个空格”,所以用哪个都能过,但用 split() 更安全,防御性更强。
第二,list.sort() 是稳定排序,这一点在 Python 3 里是语言规范保证的。所以你不需要额外加索引,直接按长度排序就满足题目要求。
第三,最后 words[0][0].upper() 取首字母改成大写,然后拼接剩余部分。这一步不要写成 words[0] = words[0].capitalize(),因为 capitalize() 会把整个单词变成首字母大写、其他字母小写,虽然这道题输入已经是小写,但语义上不够精确。
如果你的环境要求比较严谨,可以写成:
python复制def arrangeWords(text: str) -> str:
if not text:
return ""
words = text.split()
words = [w.lower() for w in words]
words.sort(key=len)
words[0] = words[0][:1].upper() + words[0][1:]
return " ".join(words)
4.2 C++:istringstream + stable_sort
C++ 的写法里,我用 istringstream 做分词,用 stable_sort 做稳定排序:
cpp复制class Solution {
public:
string arrangeWords(string text) {
if (text.empty()) return "";
vector<string> words;
istringstream ss(text);
string word;
while (ss >> word) {
for (char &c : word) c = tolower(c);
words.push_back(word);
}
stable_sort(words.begin(), words.end(),
[](const string& a, const string& b) {
return a.size() < b.size();
});
words[0][0] = toupper(words[0][0]);
string res;
for (const string& w : words) {
if (!res.empty()) res += " ";
res += w;
}
return res;
}
};
需要包含 <sstream>、<vector>、<algorithm> 等头文件,在 LeetCode 环境里这些通常默认可用,但你自己本地编译时要记得。
istringstream 的好处是自动按空白字符切词,不管是单个空格还是多个连续空格都不会出问题,而且它会跳过前导和尾随空白。
stable_sort 在这里是关键。如果你换成 std::sort,第三个示例就可能出错,因为 std::sort 不保证同长度单词的相对顺序。
4.3 防御性写法与细节处理
不管是哪种语言,我建议加上空字符串处理,避免在最简单的边界输入上翻车。
C++ 里 words[0][0] = toupper(words[0][0]); 这行,如果 words 为空就会越界,所以先 if (text.empty()) return ""; 是必要的。
还有一个小细节:tolower 和 toupper 接收和返回的是 int,不是 char,对 char 类型使用时,建议先转成 unsigned char,避免出现负数导致未定义行为。不过在 LeetCode 的字符集下,一般不会遇到问题,我平时为了简洁,还是会直接写 char &c : word。
5. 测试与踩坑:拿这些用例验证你的代码
5.1 官方示例与延伸测试用例
官方给了三个示例:
text复制输入:text = "Leetcode is cool"
输出:"Is cool leetcode"
text复制输入:text = "Keep calm and code on"
输出:"On and keep calm code"
text复制输入:text = "To be or not to be"
输出:"To be or to be not"
第三个示例尤其值得注意。原句子里长度是 2 的单词有 To, be, or, to, be,稳定排序后这些词保持相对顺序,然后才是长度 3 的 not。所以输出是 "To be or to be not",看起来有点别扭,但这是题目要求的正确结果。如果你把相同长度的词按字典序排,就会得到 "Be or to be to not" 之类的错误答案。
我还会额外构造这些用例:
text复制输入:"b a c"
输出:"B a c"
这个用例专门验证稳定性。所有单词长度都是 1,必须保持原顺序,输出仍然是 "B a c"。如果用的是不稳定的排序,这里就可能输出成 "A b c"。
text复制输入:"A"
输出:"A"
单单词的情况。
text复制输入:"I love you"
输出:"I you love"
长度分别是 1、4、3,重新排列后是 I you love。
text复制输入:""
输出:""
空字符串的防御性处理。
5.2 我在提交过程中踩过的坑
第一次用 std::sort 提交,第三个示例挂了。这个坑最典型,因为 std::sort 是不稳定排序,在只有一两个用例的时候不一定能暴露问题,但多跑几个长度相同的用例就会现出原形。
第二次踩坑是没有把整个句子统一转小写。我当时想着“首字母已经大写了,排序后把第一个单词首字母大写就好”,结果输出的第二个单词还是大写,比如 "Is Cool Leetcode"。虽然 Leetcode 那个词本来首字母是大写,但题目要求重排后的句子除首单词外全部小写,所以这里必须显式转小写。
第三次踩坑是直接用字符数组排序,把整个字符串拆成了字符来排。这种错误很基础,但确实会犯,尤其是在你脑子里想的是“排序”而不是“对单词排序”的时候。所以写代码之前,先在注释里把步骤拆清楚:分词、转小写、排序、恢复首字母大写、拼接。
5.3 边界条件清单
最后给自己列一个边界条件清单,刷题和面试时都挺实用的:
- 空字符串
"" - 只有一个单词
- 所有单词长度相同
- 单词长度从小到大已经排好
- 单词长度从大到小,需要完全反转
- 句子第一个单词不是最短的(这样重排后首单词会变化,大小写处理必须正确)
- 句子中有长度为 1 和长度特别长的单词,测试桶排序的最大桶号
把这一组用例跑完,代码基本就稳了。
6. 题目之外:稳定排序与句子处理的工程联想
6.1 分词场景里的排序需求
这道题本质上是一个“分词 + 排序 + 重组”的任务。在自然语言处理里,分词之后按某些属性(比如长度、词频、字面量)排序是很常见的需求。比如提取关键词时,往往想按词频降序、同时保持原始文档里的出现顺序;再比如做中文文本处理的“按词长过滤”时,你可能需要先按长度筛选,再按出现顺序输出。这些场景和 1451 的模型几乎一模一样。
我遇到过一个实际需求:从一段日志里提取所有 IP 和域名,要求按域名长度从短到长排序,长度相同则保持日志中的出现顺序。当时我第一时间想到的就是这道题。直接按长度排序,稳定排序保证同长度顺序,最终实现起来非常快。
6.2 多关键字排序的通用思维
1451 是“只按长度一个键排序”,但因为要求稳定,实际上相当于“长度 + 原顺序”两个键的排序。这其实就是多关键字排序的雏形。
多关键字排序的通用做法有两种。一种是直接用语言自带的稳定排序,按优先级从低到高依次排;另一种是把所有键组合成一个元组,一次排序。比如“先按长度升序,再按字典序降序”,就可以写成类似 sorted(words, key=lambda w: (len(w), [-ord(c) for c in w])) 的形式。
在实际工程里,组合成元组是最不容易出错的写法,因为它把比较规则集中在一处,可读性和可维护性都更好。而这道题给了你一个很好的机会,去理解“同长度保持原顺序”其实就是在排序键里藏着 index。
6.3 从“通过用例”到“靠谱代码”还差几步
1451 这道题,很多题解几分钟就能写完,但我觉得它最值得学习的地方不在于“如何通过”,而在于你写完通过之后,还能不能想到这些问题:你的排序稳不稳定?如果不稳定,换一组数据会不会挂?你的大小写处理依赖了题目的哪些前提?如果输入里出现连续两个空格怎么办?
这些追问,恰恰是区分“会刷题”和“能写生产代码”的地方。一个句子可能只是几 KB,但真实场景里可能是几十 GB 的日志,这时候你会不会想到用桶排序、外部排序、或者流式处理?同样的思维迁移能力,才是刷题最该练的东西。
我个人在实际做这道题时最大的体会是:永远不要小看“所有单词长度相同”这种极端用例。它既是验证稳定排序的试金石,也是很多排序类题目的隐藏杀手。你在本地用手写用例多跑一步,提交时就能少一次 WA。
