字符串整理:最长公共前缀、异位词与计数说
摘要
本篇覆盖三道字符串经典题目:LeetCode 14(Longest Common Prefix)、LeetCode 49(Group Anagrams)和 LeetCode 38(Count and Say)。它们考察的方向各不相同:前缀对比、字符频率哈希、递推序列生成。难度相对较低,但各自有值得深挖的工程细节和优化技巧——最长公共前缀有纵向、横向、二分三种解法;异位词分组有排序键和质数哈希两种映射方式;Count and Say 是序列递推的最简单形式,也是理解”描述序列”这一类题目的基础。
第 1 章 LeetCode 14:最长公共前缀
1.1 题目
LeetCode 14 - Longest Common Prefix(简单)
编写一个函数来查找字符串数组中的最长公共前缀。如果不存在公共前缀,返回空字符串 ""。
输入: strs = ["flower","flow","flight"] → 输出: "fl"
输入: strs = ["dog","racecar","car"] → 输出: ""(无公共前缀)
输入: strs = ["a"] → 输出: "a"
输入: strs = [] → 输出: ""
1.2 解法一:横向扫描
将数组中的字符串两两取公共前缀,逐步缩短结果:
["flower","flow","flight"]
第一步: lcp("flower","flow") = "flow"
第二步: lcp("flow","flight") = "fl"
结果: "fl"
public String longestCommonPrefix(String[] strs) {
if (strs == null || strs.length == 0) return "";
String prefix = strs[0];
for (int i = 1; i < strs.length; i++) {
// 找 prefix 和 strs[i] 的公共前缀
int j = 0;
while (j < prefix.length() && j < strs[i].length()
&& prefix.charAt(j) == strs[i].charAt(j)) {
j++;
}
prefix = prefix.substring(0, j);
if (prefix.isEmpty()) return ""; // 提前终止
}
return prefix;
}复杂度:时间 ( 为所有字符串字符总数),空间 (不计输出)。
1.3 解法二:纵向扫描(推荐)
固定列索引 j,逐列检查所有字符串的第 j 个字符是否相同。第一个”不一致”列即为公共前缀长度:
public String longestCommonPrefix(String[] strs) {
if (strs == null || strs.length == 0) return "";
for (int j = 0; j < strs[0].length(); j++) {
char c = strs[0].charAt(j);
for (int i = 1; i < strs.length; i++) {
// 越界或字符不等,前 j 个字符即为公共前缀
if (j >= strs[i].length() || strs[i].charAt(j) != c) {
return strs[0].substring(0, j);
}
}
}
return strs[0]; // strs[0] 本身就是公共前缀
}直观理解:想象一个二维矩阵,行是字符串,列是字符位置,纵向扫描就是逐列检查。
复杂度:同样 ,但在公共前缀较短时能更快终止(不需要先构造 lcp 子串再比较)。
1.4 解法三:二分查找
公共前缀的长度在 0 到 minLen(最短字符串长度)之间,可以二分:
public String longestCommonPrefix(String[] strs) {
if (strs == null || strs.length == 0) return "";
int minLen = Integer.MAX_VALUE;
for (String s : strs) minLen = Math.min(minLen, s.length());
int lo = 0, hi = minLen;
while (lo < hi) {
int mid = (lo + hi + 1) / 2; // 上取整,防止死循环
if (isCommonPrefix(strs, mid)) {
lo = mid;
} else {
hi = mid - 1;
}
}
return strs[0].substring(0, lo);
}
private boolean isCommonPrefix(String[] strs, int len) {
String prefix = strs[0].substring(0, len);
for (int i = 1; i < strs.length; i++) {
if (!strs[i].startsWith(prefix)) return false;
}
return true;
}复杂度:时间 ( 为最短字符串长度),通常比纵向扫描慢,但二分本身是一种好的思维训练。
1.5 边界案例
- 空数组:直接返回
"" - 数组中有空字符串:公共前缀长度为 0,返回
""(纵向扫描的j >= strs[i].length()条件会处理这一情况) - 只有一个字符串:直接返回该字符串(纵向扫描 for 循环不执行,返回
strs[0])
纵向扫描为何推荐
纵向扫描相对横向扫描的优势在于不需要分配中间字符串(
strs[0].substring(0, j)只在最终结果时才分配一次),而横向扫描每次都可能调用substring创建新对象。在高频调用的场景下差异显著。
第 2 章 LeetCode 49:字母异位词分组
2.1 题目
LeetCode 49 - Group Anagrams(中等)
给你一个字符串数组,请你将字母异位词组合在一起。字母异位词是由重新排列源单词的所有字母得到的一个新单词,所有输入均为小写字母。
输入: strs = ["eat","tea","tan","ate","nat","bat"]
输出: [["bat"],["nat","tan"],["ate","eat","tea"]]
2.2 核心思路:找”异位词标识符”
两个字符串互为异位词,当且仅当它们包含的字符集合(含频率)完全相同。因此,关键是找到一个函数 ,使得互为异位词的字符串映射到同一个 key,不是异位词的映射到不同 key。
2.3 解法一:排序键(最常用)
将每个字符串的字符排序后,作为 HashMap 的 key:
"eat" → 排序 → "aet"
"tea" → 排序 → "aet" (同一组)
"tan" → 排序 → "ant"
"nat" → 排序 → "ant" (同一组)
"bat" → 排序 → "abt"
public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> map = new HashMap<>();
for (String s : strs) {
char[] chars = s.toCharArray();
Arrays.sort(chars);
String key = new String(chars);
map.computeIfAbsent(key, k -> new ArrayList<>()).add(s);
}
return new ArrayList<>(map.values());
}复杂度:时间 ( 个字符串,每个长度 ),空间 。
computeIfAbsent 是 Java 8 的 Map 方法,等价于:
if (!map.containsKey(key)) map.put(key, new ArrayList<>());
map.get(key).add(s);2.4 解法二:字符频率键
不排序,而是将 26 个字母的出现频率编码成字符串 key:
"eat" → [1,0,0,0,1,0,...,1,...] → "1#0#0#0#1#0#...#1#..."
public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> map = new HashMap<>();
for (String s : strs) {
int[] count = new int[26];
for (char c : s.toCharArray()) count[c - 'a']++;
// 将频率数组编码为字符串 key(用 # 分隔避免歧义)
StringBuilder sb = new StringBuilder();
for (int freq : count) {
sb.append(freq).append('#');
}
String key = sb.toString();
map.computeIfAbsent(key, k -> new ArrayList<>()).add(s);
}
return new ArrayList<>(map.values());
}复杂度:时间 ,空间 。相比排序键,时间从 降到 。
频率编码时为何用
#分隔?不加分隔符,
[1,12]和[11,2]都会编码成"112",导致不同异位词组被合并。用#分隔:[1,12]→"1#12#",[11,2]→"11#2#",两者不同。
2.5 解法三:质数哈希(趣味解法)
将 26 个字母映射到 26 个质数(a=2, b=3, c=5, ...),用所有字符对应质数的乘积作为 key。因为质数分解唯一,互为异位词的字符串乘积相同:
"eat": 2*5*29 = 290(a=2, e=5... 具体映射自定义)
"tea": 29*5*2 = 290 (乘法交换律,相同)
"tan": 29*2*13 = 754
private static final long[] PRIMES = {
2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47,
53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101
};
public List<List<String>> groupAnagrams(String[] strs) {
Map<Long, List<String>> map = new HashMap<>();
for (String s : strs) {
long key = 1L;
for (char c : s.toCharArray()) key *= PRIMES[c - 'a'];
map.computeIfAbsent(key, k -> new ArrayList<>()).add(s);
}
return new ArrayList<>(map.values());
}质数哈希的局限性
当字符串很长或字符频率很高时,乘积会溢出
long,导致哈希冲突。生产环境不建议用,但面试中作为一个”额外思路”是加分项。
第 3 章 LeetCode 38:Count and Say
3.1 题目
LeetCode 38 - Count and Say(中等)
「外观数列」是一个整数序列,从数字 1 开始,序列中的每一项都是对前一项的描述:
countAndSay(1) = "1"
countAndSay(2) = "11" (对 "1" 的描述:1个1,即"one 1"→"11")
countAndSay(3) = "21" (对 "11" 的描述:2个1→"21")
countAndSay(4) = "1211" (对 "21" 的描述:1个2,1个1→"1211")
countAndSay(5) = "111221" (对 "1211" 的描述:1个1,1个2,2个1→"111221")
规则:对当前字符串,从左到右统计连续相同字符的个数,输出”个数+字符”,拼接得到下一个字符串。
3.2 实现
public String countAndSay(int n) {
String result = "1";
for (int i = 1; i < n; i++) {
result = next(result);
}
return result;
}
private String next(String s) {
StringBuilder sb = new StringBuilder();
int len = s.length();
int idx = 0;
while (idx < len) {
char c = s.charAt(idx);
int count = 0;
// 统计连续相同字符数
while (idx < len && s.charAt(idx) == c) {
idx++;
count++;
}
sb.append(count).append(c);
}
return sb.toString();
}手动验证(从 “1211” 生成 “111221”):
idx=0: c='1', count=1 → "11"... 等等:
s = "1211"
idx=0: c='1', s[0]='1'==c, count=1, idx=1
s[1]='2'!='1', 退出
→ append "11" ... 不对,应该 append count=1, c='1' → "11"?
重读题目:count=1, c='1' → append "1" + "1" → "11"
idx=1: c='2', s[1]='2'==c, count=1, idx=2
s[2]='1'!='2', 退出
→ append "1" + "2" → "12"
idx=2: c='1', s[2]='1'==c, count=1, idx=3
s[3]='1'==c, count=2, idx=4
越界,退出
→ append "2" + "1" → "21"
结果: "11" + "12" + "21" = "111221" ✓
复杂度:每次迭代字符串长度最多增加两倍(不会无限增长,实际上接近 上限),第 次的字符串长度为 ,但对于题目给定的 ,完全可以接受。
3.3 用正则实现(简洁但非最优)
Java 正则可以用 (.)\1* 匹配连续相同字符:
public String countAndSay(int n) {
String result = "1";
for (int i = 1; i < n; i++) {
Matcher m = Pattern.compile("(.)\\1*").matcher(result);
StringBuilder sb = new StringBuilder();
while (m.find()) {
sb.append(m.group().length()).append(m.group().charAt(0));
}
result = sb.toString();
}
return result;
}(.)\\1*:(.) 捕获一个字符,\\1* 匹配与第一个捕获组相同的字符 0 次或多次。m.group() 返回整个匹配串,长度即为 count。
这是字符串模拟的典型题
Count and Say 本身难度不高,但它是”游程编码”(Run-Length Encoding,RLE)的极简版本。RLE 是 BMP、TIFF 等图像格式的基础压缩算法,也在 DNA 序列分析中有广泛应用。
第 4 章 三道题的总结与对比
| 题目 | 核心技术 | 时间复杂度 | 关键点 |
|---|---|---|---|
| LeetCode 14 | 纵向扫描 / 横向扫描 | 尽早终止,纵向扫描更优 | |
| LeetCode 49 | 哈希表 + 排序键/频率键 | / | 频率编码的分隔符不能省略 |
| LeetCode 38 | 字符串模拟(双指针) | 双指针统计连续相同字符 |
这三道题有一个共同点:都是字符串预处理的典型题。实际工程中,字符串预处理(归一化、分组、编码)是数据管道的重要步骤,这些题目所用的技术与生产代码高度重合。
下一篇预告
07 字符串模拟:Valid Number、整数与罗马互转 将进入字符串解析的复杂场景。LeetCode 65(Valid Number)是公认的最难解析题,需要用有限状态机(FSM)处理整数、浮点数、科学计数法的所有合法格式;LeetCode 12/13(Integer to Roman / Roman to Integer)则用贪心和哈希表简洁处理罗马数字的编解码规则。