1. 题目解析与核心思路
最长公共前缀(Longest Common Prefix)是LeetCode上的一道经典字符串处理题目,编号14。这道题看似简单,但能很好地考察编程基本功和算法优化能力。我们先从题目本身入手,理解它的核心要求。
1.1 题目要求详解
题目要求我们编写一个函数,找出字符串数组中的最长公共前缀。如果没有公共前缀,则返回空字符串""。这里有几个关键点需要注意:
- 公共前缀:指的是所有字符串从第一个字符开始相同的部分
- 最长:意味着我们要找到尽可能长的公共前缀
- 边界情况:包括空数组、单元素数组、空字符串等情况
1.2 示例分析
让我们通过几个示例来加深理解:
-
输入:["flower","flow","flight"]
输出:"fl"
解释:三个字符串都从"fl"开始 -
输入:["dog","racecar","car"]
输出:""
解释:三个字符串没有共同的前缀 -
输入:["a"]
输出:"a"
解释:单元素数组直接返回自身
1.3 核心考察点
这道题主要考察以下几个方面的能力:
- 字符串操作:如何高效地遍历和比较字符串
- 边界处理:对各种特殊情况的处理能力
- 算法优化:如何减少不必要的比较,提高效率
2. 解法一:逐字符遍历法
2.1 基本思路
逐字符遍历法是最直观的解法,其核心思想是:
- 以第一个字符串为基准
- 逐个字符检查其他字符串对应位置是否相同
- 一旦发现不匹配或超出长度,立即停止并返回当前结果
2.2 详细实现步骤
让我们用TypeScript来实现这个算法:
typescript复制function longestCommonPrefix(strs: string[]): string {
// 处理边界情况
if (!strs || strs.length === 0) return "";
if (strs.length === 1) return strs[0];
let result = "";
// 以第一个字符串为基准
for (let j = 0; j < strs[0].length; j++) {
const currentChar = strs[0][j];
let allMatch = true;
// 检查其他字符串
for (let i = 1; i < strs.length; i++) {
// 检查是否超出当前字符串长度
if (j >= strs[i].length) {
allMatch = false;
break;
}
// 检查字符是否匹配
if (strs[i][j] !== currentChar) {
allMatch = false;
break;
}
}
if (allMatch) {
result += currentChar;
} else {
break;
}
}
return result;
}
2.3 复杂度分析
- 时间复杂度:O(mn),其中m是第一个字符串的长度,n是字符串数组的长度
- 空间复杂度:O(1),只使用了常数级别的额外空间
2.4 注意事项与优化
- 边界处理:必须处理空数组和单元素数组的情况
- 越界检查:比较时要确保不超出其他字符串的长度
- 提前终止:一旦发现不匹配,立即终止循环,避免不必要的比较
提示:在实际面试中,即使想到更优的解法,也应该先实现这个基础版本,确保正确性后再考虑优化。
3. 解法二:二分查找法
3.1 基本思路
二分查找法利用了"最长公共前缀的长度一定在0到最短字符串长度之间"这一特性。通过二分查找来缩小可能的长度范围,可以显著减少比较次数。
3.2 详细实现步骤
typescript复制function longestCommonPrefix(strs: string[]): string {
if (!strs || strs.length === 0) return "";
if (strs.length === 1) return strs[0];
// 找到最短字符串长度
let minLength = Infinity;
for (const str of strs) {
minLength = Math.min(minLength, str.length);
}
let low = 0;
let high = minLength;
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
if (isCommonPrefix(strs, mid)) {
low = mid;
} else {
high = mid - 1;
}
}
return strs[0].substring(0, low);
}
// 辅助函数:检查指定长度是否是公共前缀
function isCommonPrefix(strs: string[], length: number): boolean {
const prefix = strs[0].substring(0, length);
for (let i = 1; i < strs.length; i++) {
if (!strs[i].startsWith(prefix)) {
return false;
}
}
return true;
}
3.3 复杂度分析
- 时间复杂度:O(mn log m),其中m是最短字符串长度
- 空间复杂度:O(1)
3.4 关键优化点
- 二分查找范围:将查找范围限制在0到最短字符串长度之间
- 向上取整:避免在low和high相差1时陷入死循环
- 辅助函数:单独封装前缀检查逻辑,提高代码可读性
4. 解法比较与选择建议
4.1 性能对比
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 逐字符遍历法 | O(mn) | O(1) | 字符串较短或差异在前 |
| 二分查找法 | O(mn log m) | O(1) | 字符串较长且差异在后 |
4.2 选择建议
- 字符串较短时:选择逐字符遍历法,实现简单且实际效率可能更高
- 字符串较长时:选择二分查找法,能显著减少比较次数
- 面试场景:建议先实现逐字符遍历法,再讨论优化思路
5. 其他优化思路
5.1 横向扫描法
横向扫描法的思路是:
- 将第一个字符串作为初始前缀
- 依次与后续字符串比较,不断缩短前缀
- 如果前缀变为空,立即返回
typescript复制function longestCommonPrefix(strs: string[]): string {
if (!strs || strs.length === 0) return "";
let prefix = strs[0];
for (let i = 1; i < strs.length; i++) {
while (strs[i].indexOf(prefix) !== 0) {
prefix = prefix.substring(0, prefix.length - 1);
if (prefix === "") return "";
}
}
return prefix;
}
5.2 排序法
排序法的思路是:
- 先对字符串数组进行排序
- 比较第一个和最后一个字符串的公共前缀
typescript复制function longestCommonPrefix(strs: string[]): string {
if (!strs || strs.length === 0) return "";
strs.sort();
const first = strs[0];
const last = strs[strs.length - 1];
let i = 0;
while (i < first.length && i < last.length && first[i] === last[i]) {
i++;
}
return first.substring(0, i);
}
6. 常见问题与调试技巧
6.1 常见错误
- 越界访问:忘记检查字符串长度导致数组越界
- 边界条件:没有处理空数组或单元素数组的情况
- 效率问题:没有及时终止不必要的比较
6.2 调试建议
- 打印中间结果:在关键步骤打印变量值,帮助理解程序执行流程
- 测试用例:准备各种边界情况的测试用例,确保代码健壮性
- 性能分析:对于大规模数据,可以使用性能分析工具评估算法效率
7. 实际应用与扩展
最长公共前缀问题在实际开发中有多种应用场景:
- 文件路径匹配:查找多个文件路径的公共前缀
- 自动补全:实现输入提示功能
- 数据压缩:利用公共前缀减少存储空间
对于想要进一步挑战的开发者,可以尝试以下扩展:
- 处理超大字符串:当字符串非常大时,如何优化内存使用
- 并行计算:如何利用多线程加速比较过程
- 模糊匹配:允许少量不匹配情况下的最长公共前缀查找
8. 个人实践心得
在实际解决这个问题时,我发现以下几点特别重要:
- 先处理边界情况:这能避免很多潜在的错误
- 画图辅助理解:对于二分查找法,画图能帮助理解查找过程
- 逐步优化:不要一开始就追求最优解,先确保正确性再考虑优化
一个特别容易忽视的细节是字符串越界检查。我曾经因为使用>而不是>=来比较长度,导致程序在某些情况下崩溃。这个教训让我更加重视边界条件的测试。
对于性能优化,我发现当字符串数组很大但字符串本身很短时,排序法往往是最快的。而在处理超长字符串时,二分查找法的优势就体现出来了。因此,在实际应用中,根据数据特点选择合适的算法非常重要。