作为一名开发者,我们经常处理数字的运算,比如计算两个数字的最大公约数(GCD)。但你是否想过,字符串也可以有「公约数」?
在这篇文章中,我们将深入探讨一个非常有意思的算法问题:如何找到一个字符串数组中的「最大公约数」字符串。我们将从基本概念出发,一步步构建解决方案,并使用多种编程语言实现代码。如果你对算法优化、字符串处理技巧感兴趣,或者正在准备技术面试,那么这篇文章非常适合你。
什么叫做「字符串的最大公约数」?
在数字的世界里,我们称 $X$ 是 $A$ 和 $B$ 的最大公约数,如果 $A$ 和 $B$ 都能被 $X$ 整除。这个概念其实可以完美迁移到字符串领域。
首先,我们需要定义字符串的「整除」:
> 定义:对于两个字符串 $A$ 和 $B$,我们说 $B$ 能「整除」$A$(记为 $B \mid A$),当且仅当字符串 $A$ 是由字符串 $B$ 重复连接若干次(至少一次)构成的。
举个例子:
- INLINECODE7fde674b 可以被 INLINECODEcbe31b49 整除,因为 INLINECODEb29f11b9 重复连接 2 次就是 INLINECODE2b6ab83f。
- INLINECODE1a29355e 可以被 INLINECODE91a51f2c 整除。
- 但是 INLINECODE0186404c 不能被 INLINECODE70ebe742 整除,因为尾部多了一个
"C"。
现在,我们的任务很清楚了:给定一个字符串数组 arr[],我们要找到最长的那个字符串 $G$,使得数组中的每一个字符串都能被 $G$ 整除。
举个栗子
为了让你更直观地理解,让我们看两个具体的例子。
示例 1:
输入:arr[] = { "ABCABC", "ABC", "AB" }
让我们分析一下:
- INLINECODEdc959fae 能被 INLINECODEd7a5a296 整除吗?不能。
- INLINECODE51c0dd10 能被 INLINECODE870011e9 整除吗?不能。
- INLINECODEd2222dfb 能被 INLINECODEf5652aef 整除吗?能(
"ABC"重复 2 次)。 - INLINECODEec3892c5 能被 INLINECODE371f46d4 整除吗?能。
但是,对于整个数组来说,没有一个比 INLINECODE7cf47d86 更大的字符串能同时整除所有元素(因为 INLINECODEfedda656 无法整除 "ABC")。等等,这不对,让我们换一个符合题目要求的例子:
输入:arr[] = { "GFGGFG", "GFGGFG", "GFGGFGGFGGFG" }
- 第一个元素:
"GFGGFG"(重复 1 次) - 第二个元素:
"GFGGFG"(重复 1 次) - 第三个元素:INLINECODEb262b945 + INLINECODEef4d1b17 +
"GFGGFG"(重复 3 次)
显然,"GFGGFG" 是能整除数组中所有元素的最大字符串。
示例 2:
输入:arr = { "LeetCode", "Leet" }
输出:"" (空字符串)
解释:INLINECODE22fe0618 不是 INLINECODE396368ba 的整数倍重复,所以没有公共的整除串。
解决思路:利用数学思维简化问题
面对 N 个字符串的问题,我们通常的直觉是「遍历所有可能性」,但这太低效了。我们可以借鉴数学中计算多个数字 GCD 的方法:欧几里得算法。
在数学中,gcd(a, b, c) = gcd(gcd(a, b), c)。这个性质同样适用于字符串!
所以,我们可以把复杂的问题拆解为:编写一个计算「两个字符串」最大公约数的函数,然后遍历数组,依次计算出累积的结果。
#### 核心算法:两个字符串的 GCD
假设我们要计算 gcd(str1, str2)。我们可以使用类似辗转相除法的思路,不过这次是用字符串的操作来实现。
递归逻辑如下:
- 基准情况 1(长度交换):如果 INLINECODE4078d983 的长度小于 INLINECODE1bb6cc0f 的长度,为了方便计算,我们交换它们的位置,递归调用
gcd(str2, str1)。这样我们总是保证处理「长字符串」和「短字符串」的关系。
- 基准情况 2(无法整除):检查 INLINECODE86ca40ed 是否以 INLINECODEa10fefdc 开头。注意,仅仅检查前缀是不够的,我们需要确保 INLINECODE03dce36d 能够通过重复构成 INLINECODE18266185。但在递归逻辑中,我们先检查前缀。如果 INLINECODE8d967a04 甚至不是以 INLINECODEfd843b96 开头的(例如 INLINECODEd63c7989, INLINECODE3a0e5281),那么 INLINECODE8d42dcf0 肯定不可能是它们的公约数。直接返回空字符串 INLINECODE3863b147。
注:仅仅检查 INLINECODE8bb1913a 还不够严谨(因为可能会漏掉长度倍数的检查),但在递归切割的过程中,如果 INLINECODE95a4f8bb 完全由 INLINECODE2b224b0e 重复组成,最终剩下的部分一定是空字符串或者 INLINECODE21499f94。下面的步骤会处理这个问题。*
* 更严谨的判断:其实更准确的判断是:INLINECODEe8e55c3e 是否能被 INLINECODE84c8b42d 完全表示。我们可以先判断 INLINECODEfe864a0a 的长度是否能被 INLINECODE0d1dd82c 整除。如果不能整除,直接返回 INLINECODEf07acea1。如果长度整除,再检查 INLINECODE717a3508 是否由 str2 重复而成。
* 优化后的逻辑(针对代码实现):让我们采用文章代码片段中的逻辑,它稍微有些不同但同样有效:它专注于前缀匹配和递归切割。
- 递归步骤:如果 INLINECODE7a90b41f 以 INLINECODE51be8688 开头(说明可能整除),我们将 INLINECODE47cd1279 的前缀(即 INLINECODE03c7a055 的长度部分)切除,得到剩下的部分 INLINECODE8255c340。然后,我们递归计算 INLINECODE76e0f735 和这个「剩下的部分」的 GCD。
举个例子: gcd("ABCABC", "ABC")
- INLINECODEe7536604 长度 6,INLINECODE968801bf 长度 3。无需交换。
- INLINECODEd9d55154 是否以 INLINECODE9cbe4e27 开头?是的 (
"ABC"...)。 - 切除 INLINECODEe4e17feb 的前 3 个字符,剩下 INLINECODE1561d37e。
- 递归调用
gcd("ABC", "ABC")。
* 现在两个长度相等,且开头匹配。切除后剩下空字符串 ""。
* 递归调用 gcd("ABC", "")。
* 基准情况:如果一个字符串为空,GCD 就是另一个非空字符串。返回 "ABC"。
这种方法非常巧妙地避免了复杂的循环判断,利用了语言的递归特性。
编写代码实现
接下来,让我们看看如何用代码实现这个逻辑。我们将提供 C++、Java 和 Python3 的实现。
#### 1. C++ 实现
C++ 提供了强大的 STL,我们可以直接利用 INLINECODE52f3f403 和 INLINECODE7ac809b1 来简化操作。
#include
using namespace std;
// 辅助函数:找到两个字符串的 GCD
string gcd(string str1, string str2) {
// 1. 确保 str1 是较长的那个字符串
// 如果 str1 比 str2 短,交换它们,递归调用
if (str1.length() < str2.length()) {
return gcd(str2, str1);
}
// 2. 检查 str1 是否以 str2 开头
// 如果 str1.find(str2) != 0,说明 str2 不是 str1 的前缀,无法整除
else if (str1.find(str2) != 0) {
return "";
}
// 3. 递归终止条件
// 如果 str2 为空,说明之前的 str1 能够整除之前的串
else if (str2 == "") {
return str1;
}
// 4. 递归步骤
// 既然 str1 以 str2 开头,我们把 str1 的前缀切掉(切掉 str2 的长度)
// 剩下的部分继续与 str2 比较
else {
return gcd(str1.substr(str2.length()), str2);
}
}
// 主函数:找到字符串数组的 GCD
string findGCD(string arr[], int n) {
// 初始化结果为第一个元素
string result = arr[0];
// 遍历数组,依次计算当前结果与下一个元素的 GCD
// gcd(a, b, c) = gcd(gcd(a, b), c)
for (int i = 1; i < n; i++) {
result = gcd(result, arr[i]);
// 剪枝优化:如果在计算过程中结果已经变成空字符串,
// 说明不可能再有公共除数了,可以直接提前返回空字符串
if (result == "") {
return "";
}
}
return result;
}
int main() {
// 示例输入
string arr[] = { "GFGGFG", "GFGGFG", "GFGGFGGFGGFG" };
int n = sizeof(arr) / sizeof(arr[0]);
// 函数调用
cout << "GCD of array is: " << findGCD(arr, n) << endl;
return 0;
}
代码解析:
注意我们在 INLINECODE52a5631c 中加入了一个微小的优化:如果 INLINECODE498fdaa8 在任何时刻变成了空字符串,我们立即停止循环。因为空字符串是 GCD 计算中的「零元素」,一旦出现,最终结果必然为空。
#### 2. Java 实现
Java 的字符串处理非常直观。我们需要注意 INLINECODE949341eb 和 INLINECODE650174b0 的用法。
public class StringGCD {
// 找到两个字符串的 GCD
static String gcd(String str1, String str2) {
// 如果 str1 长度小于 str2,交换并递归
if (str1.length() < str2.length()) {
return gcd(str2, str1);
}
// 检查 str1 是否以 str2 开头,如果不是,返回空
else if (!str1.startsWith(str2)) {
return "";
}
// 递归终止条件:str2 为空时,str1 就是 GCD
else if (str2.isEmpty()) {
return str1;
}
// 切割前缀,递归处理
else {
return gcd(str1.substring(str2.length()), str2);
}
}
// 找到数组的 GCD
static String findGCD(String arr[], int n) {
String result = arr[0];
for (int i = 1; i < n; i++) {
result = gcd(result, arr[i]);
// 同样的,提前终止检查
if (result.isEmpty()) {
return "";
}
}
return result;
}
public static void main(String[] args) {
String arr[] = { "ABCABC", "ABC", "ABCABCABC" };
int n = arr.length;
System.out.println("GCD of array is: " + findGCD(arr, n));
// 测试一个无解的例子
String arr2[] = { "ABC", "DEF" };
System.out.println("GCD of arr2 is: " + findGCD(arr2, arr2.length));
}
}
#### 3. Python3 实现
Python 的切片功能非常强大,可以让代码变得极其简洁。
def gcd(str1, str2):
# 1. 确保 str1 是较长的字符串
if len(str1) < len(str2):
return gcd(str2, str1)
# 2. 检查 str1 是否以 str2 开头
elif not str1.startswith(str2):
return ""
# 3. 递归终止条件
elif len(str2) == 0:
return str1
# 4. 递归切割
else:
# 切掉前缀部分,递归
return gcd(str1[len(str2):], str2)
def findGCD(arr, n):
result = arr[0]
for i in range(1, n):
result = gcd(result, arr[i])
# 提前终止优化
if result == "":
return ""
return result
# Driver Code
if __name__ == "__main__":
arr = ["GFGGFG", "GFGGFG", "GFGGFGGFGGFG"]
n = len(arr)
print(f"GCD of array is: {findGCD(arr, n)}")
常见错误与调试技巧
在处理这类问题时,作为开发者,你可能会遇到一些坑。这里有几个实用建议:
- 空字符串处理:务必注意当其中一个字符串为空时的逻辑。根据整除的定义,任何字符串 $S$ 都可以认为是空字符串(重复 0 次)吗?在这个 GCD 问题的上下文中,通常我们如果遇到空字符串参与 GCD 计算,结果取决于具体定义,但在我们的代码逻辑里,INLINECODEd55c3e56 应该返回 INLINECODE34210877(如果
str也能整除其他数的话)。
- 内存限制(递归深度):上面的代码使用了递归。在 Python 或 Java 中,如果字符串非常长(比如包含数万个字符),递归可能会导致栈溢出。
* 解决方案:将递归改为循环。对于上面的算法,循环版本并不难写,只要用 while 循环模拟切割过程即可。
- 时间复杂度:朴素的做法可能涉及到 INLINECODE1e0fa816,其中 $M$ 是字符串长度,$k$ 是重复次数。但实际上,我们每次都切掉一个 INLINECODE5a2a64c7 的长度,这类似于欧几里得算法的复杂度,通常是 INLINECODE3f099908 级别。对于包含 $N$ 个字符串的数组,总的时间复杂度大约是 INLINECODEf3889952,其中 $L$ 是最短字符串的长度相关的度量。
总结
通过这篇文章,我们不仅解决了「N 个字符串的最大公约数」这个问题,更重要的是,我们复习了如何将数学概念迁移到编程逻辑中。我们从 GCD 的定义出发,利用递归思想,优雅地实现了代码。
你可以试着扩展这个思路:如果我们不是找 GCD(最大),而是找 LCM(最小公倍数)字符串呢?这在加密和编码领域是一个很有趣的话题。希望今天的分享对你有所帮助,快去你的代码编辑器里试试吧!