1. 题目到底在考什么:题干拆解与核心考点
1.1 题干里最容易忽略的三个点
LeetCode 1451 这道题,我刷的时候第一感觉是“这不就是一个排序题吗”,但真正提交才发现里面埋了稳定排序、大小写处理、末尾句点这三个坑。题目本身不难,但把这三个点全部踩一遍再回来的体验,比直接看题解深刻得多。
先复述一遍题干。输入一个字符串 text,代表一个英文句子,单词之间用单个空格分隔,句子以句点结尾。要求按单词长度升序重新排列句子中的单词,如果两个单词长度相同,则保留它们在原句子中的相对顺序。返回的新句子除了第一个单词的首字母大写外,其余字母全部小写,并且句子仍然以句点结尾。
第一个坑是末尾句点不属于最后一个单词。如果直接拿 text.split(" ") 去切分,最后一个单词会变成类似 "cool." 的形态,长度多算一位,排序结果直接错。正确做法是先把 text[:-1] 拿到手,或者 split 之后再单独处理最后一项。这个错法非常隐蔽,因为大部分测试用例的最后一个单词通常恰好比较长,你可能一眼看不出问题,等到提交时某个用例才暴雷。
第二个坑是大小写处理规则。规则不是“只把首字母大写,其他字母原样保留”,而是“全部转小写,再把第一个单词首字母大写”。也就是说,原句里的 "Leetcode" 经过处理后应该是 "leetcode",而不是保持原样。如果你忘了把后面的单词转小写,输出的句子会出现 "Is cool Leetcode." 这种不符合规则的答案。
第三个坑是稳定排序。题干明确要求长度相同保持原顺序,这在很多语言里默认排序并不稳定。比如 C++ 的 std::sort 不保证稳定性,Java 对基本类型数组的排序也不是稳定的。如果不注意这一点,用错了排序函数,用例可能大部分能过,但某个隐藏用例就会 WA。
1.2 从示例看排序规则的真正含义
示例二是一个很经典的测试用例:"Keep calm and code on."。单词长度分别是 keep 4、calm 4、and 3、code 4、on 2。按长度升序后,on 排第一,and 排第二,剩下三个长度为 4 的单词 keep、calm、code 必须保持它们在原句中的相对顺序,也就是 keep 在 calm 前,calm 在 code 前。最后得到 "On and keep calm code."。
这个示例能看出一个细节:排序只是把短单词提到前面,同长度的单词互相之间没有发生位置交换。这就是“稳定排序”的含义。如果你在实现里用了不稳定的排序,或者用了类似 (单词, 长度) 的排序但比较器里没有加入原始下标,三个等长单词的顺序就可能变成 "calm code keep" 之类的错误结果。
这个示例还提醒我们,处理顺序应该是:先拆词、再全部转小写、排序、再拼装、再处理首字母大写。如果你先排序再转小写,逻辑上也通,但需要额外记录每个单词原本的大小写状态,完全没必要。先归一化再排序,代码会简单很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 思路设计:为什么“排序”没有想象中简单
2.1 稳定排序 vs 不稳定排序:一个隐藏的分水岭
稳定排序的意思是:如果两个元素排序键相等,它们在排序后的序列中保持原先的相对顺序。举个例子,原序列 [("a", 2), ("b", 2)],按第一个字符排序,稳定排序结果是 [("a",2), ("b",2)],不稳定排序可能变成 [("b",2), ("a",2)]。
为什么这题跟稳定排序强相关?因为题目要求“长度相同保留原顺序”,这正好是稳定排序的定义。用 Python 的时候不太容易踩这个坑,因为 Python 的 sorted 和 list.sort 都是稳定的,官方文档明确写了。但如果你切换到 C++,std::sort 在大多数标准库实现里是内省排序,不保证稳定性,需要专门用 std::stable_sort。
Java 这边要特别留意:Arrays.sort 对 Object[] 使用的是稳定排序(TimSort 或归并排序),对 int[] 等基本类型数组使用的是双轴快速排序,不稳定。本题中我们排序的是 String[],属于对象数组,所以用 Arrays.sort 是稳定的;但如果你把单词拆成 char[] 或者用其他基本类型数组存储长度,稳定性的保证就没了。用 ArrayList
一句话总结:看到“保持原顺序”就条件反射地检查排序稳定性,这是这道题最大的考点。面试时候能主动说出这一点,比默默写对代码更能体现你对排序本质的理解。
2.2 先转小写还是先排序?处理顺序决定代码优雅度
两种顺序都能做对,但代码简洁度差别很大。
顺序一:先全部转小写,再排序,最后首字母大写。这种最直白。你只需要一个循环把每个单词 lower(),然后排序,最后把第一个单词的第一个字符 upper()。这个方案符合“归一化 → 排序 → 再恢复局部特征”的思路,也是我推荐的做法。
顺序二:先排序,再转小写,最后首字母大写。这个顺序在逻辑上也能跑通,但有个隐患:如果你没有在一开始把单词转小写,排序时 "Leetcode" 和 "leetcode" 会被视为不同字符串。虽然这题只看长度不看字典序,不影响排序结果,但代码里处处都要小心大小写带来的不一致。更重要的是,“先排序再转小写”需要额外记录每个单词在原始句子中的位置,否则长度相同情况下,两个原本不同大小写的相同单词交换位置后,你还得保证最终输出时大小写正确,复杂度反而上去了。
我的建议是:无条件先 lower(),然后排序,最后统一处理首字母大写。这样代码路径最短,出错概率最低。你可以把这一步理解成数据处理里的“清洗”环节——先把数据归一到最简单形态,再执行核心逻辑,最后恢复展示层需要的格式。
2.3 索引绑定法:一种“手写稳定性”的兜底方案
如果你用的语言或者库函数不提供稳定排序,或者你想在不同语言间统一写法,可以给每个单词绑定一个原始下标,然后按 (长度, 下标) 作为排序键。这样就算排序本身是不稳定的,只要排序键是完整的,结果依然是稳定的。
这个模式在很多题解里都能看到:
python复制words = text[:-1].split(' ')
pairs = [(w.lower(), i) for i, w in enumerate(words)]
pairs.sort(key=lambda x: (len(x[0]), x[1]))
排序键里显式带上原始下标,等于把“稳定性”从排序算法层面搬到了数据层面。这种做法的好处是:无论你用 std::sort 还是 Arrays.sort,结果都是确定的。坏处是多了一点代码量,而且需要注意索引不会因为前面的交换而改变,但这里下标是只读的,不用担心。
实际我在刷题时更倾向于直接用稳定排序,因为代码更短。但在面试中,如果面试官追问“如果排序不稳定,你怎么保证正确性”,能说出索引绑定法会是很加分的回答。这也是一个通用的工程技巧——需求方要求稳定结果,但底层组件不保证稳定,你就自己把稳定性编码进数据里。
3. 代码落地:Python / C++ / Java 三语言实现与逐行解析
3.1 Python 实现:用 sort(key=len) 拿下
Python 里这题最短的解法大概只有六行:
python复制def arrangeWords(text: str) -> str:
words = text[:-1].split(' ')
words = [w.lower() for w in words]
words.sort(key=len) # Python 排序是稳定的
words[0] = words[0][0].upper() + words[0][1:]
return ' '.join(words) + '.'
逐行解释:
- text[:-1]:去掉末尾句点,注意这里用切片而不是 replace,因为句点只出现一次且必然在末尾。如果你用 replace(".", ""),万一句子中间还有别的句点会误删,不过本题输入保证只在末尾有句点,两种都可以。
- split(' '):按单个空格切分。这里我用 split(' ') 而不是 split(),是因为题干保证单词之间只有一个空格。用 split() 也能行,它会把连续空白符都当作分隔符并自动过滤空字符串,逻辑上更安全。两种都行,看个人风格。
- [w.lower() for w in words]:把每个单词整体转小写,这样后面的首字母大写处理只需要面对一个小写字符串。
- words.sort(key=len):按长度升序排序,key 是内置的 len 函数,不用写 lambda。Python 的 list.sort 是稳定排序,所以长度相同的单词自动保持原来的相对顺序。
- words[0] = words[0][0].upper() + words[0][1:]:把第一个单词的首字母大写。words[0][1:] 是剩余部分,因为已经 lower,所以剩余部分都是小写。
- ' '.join(words) + '.':用空格拼接并补回句点。
这里有一个很多人不知道的小技巧:如果你想把整个句子恢复成“首字母大写,其余小写”的形态,可以用 Python 的 capitalize() 方法,它会把整个字符串的第一个字符转大写,其余字符全部转小写,正好符合本题要求。于是代码可以进一步压缩成:
python复制def arrangeWords(text: str) -> str:
words = text[:-1].split()
words.sort(key=str.lower) # 不对,这里需要按长度,不是按字典序
这样会写错,正确的是:
python复制def arrangeWords(text: str) -> str:
words = [w.lower() for w in text[:-1].split()]
words.sort(key=len)
return ' '.join(words).capitalize() + '.'
capitalize() 的缺点是隐式处理了所有字符的大小写,初学者可能看不明白,而且如果单词里本应保留某些特殊字符的大小写(本题没有),它也会强行转小写。我的建议是:比赛抢时间时可以用 capitalize(),面试或写工程代码时写显式版本,可读性更重要。
3.2 C++ 实现:stable_sort 与 stringstream 配合
C++ 版本最需要区分两件事:一是别用 sort,要用 stable_sort;二是字符串分割频繁用到 stringstream,注意头文件。
cpp复制class Solution {
public:
string arrangeWords(string text) {
text.pop_back();
vector<pair<string, int>> words;
stringstream ss(text);
string word;
int idx = 0;
while (ss >> word) {
for (char &c : word) {
c = tolower(c);
}
words.push_back({word, idx++});
}
sort(words.begin(), words.end(), [](const auto &a, const auto &b) {
if (a.first.size() != b.first.size()) {
return a.first.size() < b.first.size();
}
return a.second < b.second;
});
string ans;
for (int i = 0; i < words.size(); i++) {
if (i > 0) ans += ' ';
if (i == 0) {
words[i].first[0] = toupper(words[i].first[0]);
}
ans += words[i].first;
}
ans += '.';
return ans;
}
};
注意这里我用的是 sort 而不是 stable_sort,因为比较器里显式带了索引,所以 sort 也能得到稳定结果。如果你想更贴近题意,可以改用 stable_sort 并去掉索引比较:
cpp复制stable_sort(words.begin(), words.end(), [](const auto &a, const auto &b) {
return a.first.size() < b.first.size();
});
两者都正确。不过我习惯在写题解时展示带索引的版本,因为它对“稳定性”的意图更明确,读代码的人一眼就能看出你是故意保持原序的。如果你用稳定排序,比较器可以只比较长度,代码更短一点,但需要你明确知道 stable_sort 的语义。
C++ 的几个细节:
- text.pop_back() 假设 text 非空且末尾是句点,这是题目保证的。
- tolower 和 toupper 来自
,注意参数要转成 unsigned char 才是完全合法的,因为标准库对负数参数(非 ASCII 字符)行为未定义。LeetCode 的用例默认 ASCII,直接传 char 也问题不大,但严谨一点可以写 c = tolower(static_cast<unsigned char>(c));。 - stringstream 默认以空格切分,连续空格会当作多个分隔符,因为题目保证单个空格,所以没问题。如果要应对任意空白符,用 stringstream 本身就是最佳选择,不需要额外处理。
3.3 Java 实现:注意 split 与 Lambda
Java 版本里最容易踩的坑是 split 和排序稳定性。
java复制class Solution {
public String arrangeWords(String text) {
String[] words = text.substring(0, text.length() - 1).split(" ");
List<String> list = new ArrayList<>();
for (String w : words) {
list.add(w.toLowerCase());
}
list.sort(Comparator.comparingInt(String::length));
list.set(0, Character.toUpperCase(list.get(0).charAt(0)) + list.get(0).substring(1));
return String.join(" ", list) + ".";
}
}
解释一下关键点:
- substring(0, text.length() - 1) 和 Python 的 text[:-1] 对应,去掉末尾句点。
- String.split(" ") 会按单个空格分割,返回 String[]。要注意 split 如果分隔符出现在字符串开头或结尾,会产生空字符串,但题目句子开头是字母,不会出现这种情况。
- 因为后面要排序,我先用 ArrayList 包装一下,方便后面 set(0, ...) 直接替换第一个元素。其实也可以不包装,直接对数组排序,但数组长度固定,修改第一个元素需要手动写 words[0] = ...,也很简单。
- list.sort(...) 是 List 接口的默认方法,底层是 TimSort,稳定。Comparator.comparingInt(String::length) 表示只按长度升序比较,长度相同不比较,所以稳定排序保留了原始顺序。
- 首字母大写:Character.toUpperCase(list.get(0).charAt(0)) + 剩余部分。
Java 方案还有一个隐藏细节:如果你用 Arrays.sort(words, Comparator.comparingInt(String::length)),因为 words 是 String[],属于对象数组,Arrays.sort 也是稳定的。所以其实可以直接在数组上排,不需要 ArrayList。完整点就是先把 toLowerCase 放进数组,排序,再把 words[0] 大写:
java复制String[] words = text.substring(0, text.length() - 1).split(" ");
for (int i = 0; i < words.length; i++) {
words[i] = words[i].toLowerCase();
}
Arrays.sort(words, Comparator.comparingInt(String::length));
words[0] = Character.toUpperCase(words[0].charAt(0)) + words[0].substring(1);
return String.join(" ", words) + ".";
这段代码更短,而且因为 String[] 是对象数组,Arrays.sort 是稳定的,完全符合题意。但如果你把单词转成 char[] 再排序,那就变成基本类型数组,稳定性就没了。这是 Java 里很隐蔽的坑,面试时值得单独提一句。
3.4 复杂度分析与边界情况
时间复杂度:拆词 O(L),转小写 O(L),排序 O(k log k),k 是单词个数,拼接 O(L)。总体 O(L + k log k),其中 L 是句子总长度。因为比较器只取长度,每次比较是 O(1),所以这个复杂度在 LeetCode 上属于很常规的级别,通常执行时间在 10ms 以内。
空间复杂度:主要开销是存储单词列表,O(L) 级别。如果使用原地 sort,额外空间主要是排序栈,O(log k)。整体可以接受。
边界情况清单:
- 只有一个单词:"Hello." → "Hello."。lower 之后变成 "hello",首字母大写变回 "Hello",没毛病。
- 首字母原本就大写的短词不是第一个,比如 "Are we ready." → "We are ready."。这里 we 长度 2,are 长度 3,排序后 we 在第一个,首字母大写就变成了 "We"。
- 全部单词长度相同,比如 "b a c." → "B a c.",稳定排序后顺序不变。
- 句子结尾句点前有空格?题目保证不会,但如果你用 text[:-1] 去除句点后多一个空格,split 会生成空字符串。防御性写法可以用 split("\s+") 再过滤空串,但 LeetCode 不需要。
4. 这些坑我在提交时都踩过:常见错误与调试技巧
4.1 句点处理错位导致最后一个单词出错
这个错误非常典型,我第一次写 C++ 版本时直接把整个 text 扔进 stringstream,导致最后一个单词变成 "cool.",长度变成 5,排序就错了。一开始我还没发现,因为这句示例里 cool 本来就有 4 个字母,多一个句点变成 5 之后,它依然比 Leetcode 的 8 个字母短,排序结果碰巧是对的。直到我换了一个最后单词特别短的用例才发现问题。
排查方法就是在排序前把拆分后的每个单词打印出来,看看最后一个单词是否带着句点。处理方式有两种:第一种是在拆分前就把句点去掉,推荐,干净利落;第二种是拆分后单独对最后一个单词做 pop_back,但这样要保证句子至少两个词,逻辑更绕。我在实际项目中更倾向第一种,因为“预处理数据到统一形态”的思路在任何语言里都适用。
4.2 大小写处理顺序引起的连环 bug
如果你在排序前不转小写,排序后 "LEETCODE" 和 "leetcode" 可能被当成不同的字符串。虽然这题只比较长度,不比较字典序,所以大小写不影响排序结果,但在最后拼接时,如果你忘记把后面的单词转成小写,输出的句子就会出现 "Is COOL Leetcode." 这种不符合规则的结果。
我见过一种错误写法是先保留原始字符串做排序,然后拼接时统一转小写,最后把第一个字符大写。这种做法也能过,但代码里会多处出现 toLowerCase() 和 Character.toUpperCase(),容易漏。我后来总结了一个规律:所有字符串处理题,先做“归一化”再做“核心逻辑”,最后做“格式恢复”,这个顺序能大幅减少 bug。这里的归一化就是所有单词转小写,核心逻辑就是按长度排序,格式恢复就是首字母大写和补句点。
4.3 稳定性丢失导致 WA:一个假通过的真实案例
我朋友用 C++ 写这题,他一开始用了 std::sort,并且只按长度做比较器,本地测试和大部分用例都过了,直到遇到 "To be or not to be." 这种用例才发现顺序不对。原因很简单:std::sort 不保证稳定,be(2)、to(2)、to(2) 这些等长单词的先后顺序可能被打乱,而题目要求保持原序。
怎么快速定位稳定性问题?找一个所有单词长度都相同但顺序有意义的测例,比如 "b a c."。正确输出还是 "B a c.",如果输出变成 "B c a." 之类的,那一定是稳定性出问题了。这类测试用例很短,很适合用来验证排序稳定性。你可以把这段测试用例直接贴到你的代码里跑一遍,如果没过,基本可以断定是排序稳定性问题。
4.4 快速自测用例清单
推荐一组我自己常用的用例,覆盖了大部分坑点:
| 输入 | 输出 | 说明 |
|---|---|---|
| "Leetcode is cool." | "Is cool leetcode." | 标准示例 |
| "Keep calm and code on." | "On and keep calm code." | 等长词保持原序 |
| "To be or not to be." | "To be or to be not." | 出现多个等长重复词 |
| "Hello." | "Hello." | 单单词边界 |
| "Are we ready." | "We are ready." | 首字母原大写的短词不是第一个 |
| "B a c." | "B a c." | 等长顺序验证 |
| "I am ok." | "I am ok." | 稳定且有序的常规情况 |
把这组用例跑一遍,基本能覆盖所有坑点。注意最后一行 "I am ok.",长度分别是 1、2、2,稳定排序后 am 在 ok 前,输出 "I am ok."。如果你不小心把 am 和 ok 顺序弄反,就会输出 "I ok am.",一眼就能看出来。
5. 从这题延伸出去:一类稳定排序问题的通用解法
5.1 稳定排序可以解决哪些“按条件保持原序”的题目
LeetCode 里还有不少题本质上是在考稳定排序。比如按出现频率排序并保持原序的变体题,还有多关键字排序题,先把主关键字定下来,次关键字用原索引。面试里也经常考“按某个字段排序,但不要打乱相同字段的内部顺序”,这种需求在业务系统中很常见,比如按部门分组但不改变员工入职顺序。
如果你掌握了稳定排序的思路,遇到这类需求时会下意识地判断:能不能用语言自带的稳定排序?不能的话,自己绑定索引。我后来发现,这个“绑定索引”的技巧可以泛化到很多排序场景——只要排序结果要求保持原始顺序,加一列自增序号作为次关键字就永远不会错。写出这种代码的人,通常也被认为懂得底层数据的不可变性。
5.2 字符串处理题中 capitalize / toLowerCase 的边界
本题最后我提到可以用 Python 的 capitalize() 一行搞定,但需要理解它的边界。capitalize() 会把整个字符串的第一个字符转大写,其余全转小写,所以刚好满足“第一个单词首字母大写,其余全部小写”的规则。但 capitalize() 也有一个容易被忽略的行为:如果字符串的第一个字符不是字母,比如数字或符号,它不会做任何大写转换,只把后面的字母全部转小写。这在本题里不会出现,但如果换到其他字符串题,要格外小心。
Java 里对应的做法没有 Python 那么一行到位,通常用 Character.toUpperCase(str.charAt(0)) + str.substring(1)。注意 substring 的参数是从哪里开始,从 1 开始就能保留剩余部分。C++ 则直接操作字符数组,words[i].first[0] 前先判断非空,否则越界。
5.3 刷题复盘:这道题适合放在什么阶段练习
我的判断是,这道题非常适合在刷题早期做。它不涉及复杂算法,但把字符串处理、排序稳定性、大小写转换、边界条件几个基本功都串起来了。如果你刚学完数组和排序,拿它来练手比直接上困难题效果好得多。
等到面试前,这类题也可以作为“热身题”快速过一遍,五分钟内写出 bug-free 的代码,能给后面的算法题打个稳定基础。我之前面试前热身就喜欢把 1451 这种题写一遍,属于性价比很高的复习方式。LeetCode 上还有一些类似的“简单字符串+排序”题,比如按长度排序、按字符频率排序,刷完可以横向对比,总结出一套模板,以后遇到同类题直接套。
这道题我刷过至少三遍,每一遍都有不同体会。第一次是在刚接触稳定排序概念时,被 std::sort 坑了一次;第二次是用 Python 写,发现只要 sort(key=len) 一行就能搞定;第三次是在准备面试时,把它当作热身题,发现很多基础细节其实都能在十几行代码里展露无遗。所以如果你觉得自己基础还行,可以试试限定五分钟内写完这道题,写完再和我上面给出的三种实现对照一下,看看有没有遗漏的边界情况。能把这些小细节全部处理干净,比多刷十道重复题有意义得多。
