4.23
1044. 最长重复子串 - 力扣(LeetCode)

Rabin-Karp算法介绍
Rabin-Karp算法是由Richard M. Karp和Michael O. Rabin在1987年提出的字符串匹配算法。该算法在计算机科学领域得到了广泛应用,主要用于在文本中搜索字符串出现的位置。
Rabin-Karp算法的基本思想是将字符串和模式都视为数字(比如,将它们看作字符编码的值),然后使用哈希函数(hash function)来计算它们的哈希值(hash value)。如果字符串的哈希值与模式的哈希值相等,则说明它们相等。这个过程的关键是如何计算哈希值,并且如何在不计算完整字符串哈希值的情况下快速地更新哈希值。
Rabin-Karp算法的时间复杂度为O(n+m),其中n是文本长度,m是模式长度。这个算法的时间复杂度比传统的字符串匹配算法(如Brute Force算法)要快得多。
Rabin-Karp算法是一种基于哈希的高效字符串匹配算法,通过滚动哈希快速比较子串。核心思想是将字符串视为数字,利用滑动窗口计算哈希值,从而在平均情况下实现线性时间复杂度。
Rabin-Karp算法的优化策略
为了进一步提高Rabin-Karp算法的运行效率,可以采用以下优化策略:
- 多重哈希(Multiple Hashing):在哈希函数中使用多个质数进行计算,以减少哈希冲突的发生概率和提高哈希表的容量。
- 指纹压缩(Fingerprint Compression):在哈希表中使用除余算法或位运算将哈希值压缩成更小的整数,以节省内存空间和加速比较操作。
- 字符串预处理(String Preprocessing):在匹配过程中,通过预处理模式串和主串的前缀和后缀信息,避免无效的比较操作,提高匹配效率。
算法步骤
- 预处理哈希:计算字符串的前缀哈希及基数幂次,以便快速计算任意子串的哈希值。
- 滚动哈希:通过滑动窗口更新哈希值,避免重复计算。
- 哈希冲突处理:当哈希值相同时,需逐字符比较确保子串实际匹配。
示例解析
以
s = "banana"为例:
- 预处理哈希:计算所有前缀的哈希值和幂次。
- 二分查找:初始范围为
[1, 5],中间值L=3。- 检查重复子串:
- 子串
s[1:4] = "ana"和s[3:6] = "ana"哈希值相同,实际比较后确认重复。- 更新结果:记录最长子串
"ana",最终返回。复杂度分析
- 时间复杂度:
O(N log N),二分查找O(log N),每次检查O(N)。- 空间复杂度:
O(N),存储前缀哈希及幂次数组。
借群友一句话:
温馨提示, 今天这个打卡题出自136场周赛第四题, 当时国服只有6个人做出来
所以不会做很正常哦,直接来看题解吧~解题思路
个人觉得 Rabin-Karp 是O(N)时间复杂度的字符串匹配算法中最好理解的一个。
它采用了滑窗的思想,天然的可以帮助我们在O(N)的时间复杂度内,找出固定长度的子串在原串中是否出现过。
其思想非常简单:假设我们需要找的子串记作sub;原串记作str。 子串长度为m,原串长度为n。
我们子串进行一次hash,然后对原串从位置1开始的每个位置i对应的长度为m的子串 str.substr(i, m) 也进行hash;比较这两个hash值是否相同,相同的话,则说明sub在str中出现过。
那如何进行hash呢? 我们可以用一个质数 p ,比如 31 当作底数; 将字符串转化为 $sub[0]∗p{m−1}+sub[1]∗p...+sub[m−1] $。 这其实基本上就是 JDK 中对 string 哈希的默认做法。
而这个哈希计算在滑动过程中,我们也不需要每次都重新计算一遍,可以用上一位置的状态转移过来,如果将hash值看成31进制数的话就是所有位数都左移一位,再去头加尾即可,相信很好理解:$hash = hash ∗ prime − prime^m∗(s[i−len]−charCode.at(a))+(s[i]−charCode.at(a)); $
这就是滑动窗口的威力。正常来说这个值会很大可能会导致溢出,所以RK算法应该还要对这个数取模,这样会导致hash冲突;不过用 unsigned long long 存储相当于自动取模了。
这样我们得到了对字符串是否存在某个长度的子串出现了不止一次的能力校验函数,下面我们不断的猜测可能的最大长度,验证即可。
二分搜索就可以满足我们的需要;当然,在猜测过程中,我们还需要把最大长度和出现位置记录下来。
代码
class Solution {
public:
int n;
unsigned long long prime = 31;//31无法过最后一个样例,改29能过全部样例
string longestDupSubstring(string s) {
n = s.size();
int l = 1;
int r = n - 1;
int pos = -1;
int len = 0;
auto find = [&](int len){
unsigned long long hash = 0;
unsigned long long power = 1;
for (int i = 0; i < len; i++) {
hash = hash * prime + (s[i] - 'a');
power *= prime;
}
unordered_set<unsigned long long> exist{hash};
for(int i = len; i < n; i++) {
hash = hash * prime - power * (s[i-len] - 'a') + (s[i] - 'a');
if (exist.count(hash)) return (i - len + 1);
exist.insert(hash);
}
return -1;
};
while(l <= r) {
int mid = (l + r) / 2;
int start = find(mid);
if (start != -1) {
len = mid;
pos = start;
l = mid + 1;
} else {
r = mid - 1;
}
}
if (pos == -1) return "";
else return s.substr(pos, len);
}
};
DP法:参考718. 最长重复子数组 - 力扣(LeetCode)
状态定义。仿照 1143,定义 f[i+1][j+1] 表示以 nums1[i] 结尾的子数组和以 nums2[j] 结尾的子数组的最长公共子数组的长度。这里 +1 是为了兼容空子数组的情况,也就是把 f[0][j] 和 f[i][0] 定义为空。如果不写 +1,就需要特判 i=0 或者 j=0 的情况了。
分类讨论:
- 如果 nums1[i]\=nums2[j],那么 f[i+1][j+1]=0。
- 如果 nums1[i]=nums2[j],那么问题变成以 nums1[i−1] 结尾的子数组和以 nums2[j−1] 结尾的子数组的最长公共子数组的长度,即 f[i+1][j+1]=f[i][j]+1。相当于在以 nums1[i−1] 结尾的子数组后面添加 nums1[i],在以 nums2[j−1] 结尾的子数组后面添加 nums2[j]。
初始值:f[0][j]=f[i][0]=0,空子数组没有公共部分。
答案:所有 f[i][j] 的最大值。
class Solution { public: int findLength(vector<int>& nums1, vector<int>& nums2) { int n = nums1.size(), m = nums2.size(), ans = 0; vector f(n + 1, vector<int>(m + 1)); for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { if (nums1[i] == nums2[j]) { f[i + 1][j + 1] = f[i][j] + 1; // 递推关系 ans = max(ans, f[i + 1][j + 1]); } } } return ans; } };滚动数组优化:
为何内层循环需要倒序遍历,避免覆盖?
举个例子,假设原来的二维数组中有两行,当前处理的是i=2。在二维的情况下,对于每个j来说,
dp[i][j]依赖于dp[i-1][j-1]。当用一维数组的时候,如果我们倒序处理j,那么在计算较大的j时,比如j=5,此时要取的j-1=4的位置的值还没有被当前i=2的处理所覆盖,所以此时dp[j-1]对应的还是i=1时的值。这样就能正确地计算出dp[j]的值。而正序的话,比如先处理j=1,再处理j=2,那么在处理j=2的时候,j-1=1已经被处理过了,此时dp[1]已经被更新为i=2的情况,导致错误。
dp[j] = dp[j - 1] + 1;这里的dp[j-1]指的是dp[i-1][j-1],所以不能先更新当前i的dp[j-1]class Solution { public: int findLength(vector<int>& nums1, vector<int>& nums2) { vector<int> dp(nums2.size() + 1 , 0); int res = 0; for (int i = 1; i <= nums1.size(); i++) { for (int j = nums2.size(); j > 0 ; j --) { if(nums1[i - 1] == nums2[j - 1]) dp[j] = dp[j - 1] + 1; else dp[j] = 0; res = max(res , dp[j]); } } return res; } };代码解析
这段代码采用 Rabin-Karp算法 + 二分查找 解决最长重复子串问题,核心思路如下:
- 二分查找框架
- 搜索范围
[l, r]表示可能的子串长度,初始为[1, n-1]。- 通过二分逐步逼近最长重复子串长度,每次检查是否存在长度为
mid的重复子串。- Rabin-Karp滚动哈希
- 哈希计算:将字符串视为一个多进制数(
prime=31),通过滑动窗口计算子串哈希值。- 滚动优化:利用前一个窗口的哈希值快速计算新窗口哈希,公式为:
new_hash = old_hash * prime - left_char * power + new_char
其中power = prime^len用于消除左端字符的影响。- 哈希冲突处理
- 仅使用单哈希(自然溢出),未采用双哈希或模大素数,可能因哈希冲突导致误判。
示例验证
以输入
s = "banana"为例,分析代码执行过程:
- 二分查找过程
l=1, r=5,初始mid=3,检查是否存在长度为3的重复子串。- 调用
find(3),发现子串"ana"(起始位置3)重复,更新len=3。- 继续搜索更长的子串,最终确定最长长度为3。
- 哈希计算步骤
- 初始窗口 (0-2):
"ban"的哈希值为1*31² + 0*31 + 13 = 974。- 滑动到窗口 (1-3):
计算974*31 - 1*31³ + 0 = 403,对应"ana"。- 滑动到窗口 (3-5):
计算403*31 - 0*31³ + 0 = 403,发现重复哈希,返回位置3。最终输出为
"ana",符合预期。代码问题与改进
哈希冲突风险
- 自然溢出可能导致不同子串哈希值相同,可通过 双哈希 或 大素数取模 缓解。
优化建议
// 修改哈希计算为双哈希 struct DoubleHash { ull h1, h2; }; vector<DoubleHash> hash_pows; // 在滚动哈希时同时计算两个哈希值总结
- 代码逻辑正确性:算法框架和哈希滚动实现正确,能处理一般测试用例。
- 潜在缺陷:单哈希可能引发冲突,需根据场景选择优化策略。
回到本题:
解题方法
动态规划的方法,首先,定义一个二维数组
dp[n][n],其中n是字符串的长度。然后,初始化所有元素为0。接着,遍历i从0到n-1,遍历j从i+1到n-1。当s[i] == s[j]时,如果i>0且j>0,则dp[i][j] = dp[i-1][j-1] +1,否则为1。然后,记录最大的dp[i][j]值,并记录对应的i的位置。最后,根据最大的长度和i的位置,得到对应的子串。例如,假设max_len是最大的
dp[i][j],则对应的子串起始位置是i - max_len +1,结束位置是i+1。所以,子串是s.substr(start, max_len)。但是,这样的二维数组的空间复杂度对于大n来说是无法承受的。例如,当n=1e4时,二维数组需要1e8的存储空间,这在C++中无法实现。所以,必须进行空间优化。
思路参考于leetcode718题:最长重复子数组。这一题我们的写法是将一个串当作两个一样的字符串来对比,但是不要比较下标相同的位置,计算出可能重复的最长长度之后还需要保存起始位置的下标。
复杂度
时间复杂度:O(n^2),循环遍历两次
空间复杂度:O(n),需要创建二维数组,但是可以采用滚动数组的方法压缩到O(n)
Code
二维:
class Solution { public: string longestDupSubstring(string s) { int n=s.size(); vector<vector<int>>dp(n+1,vector<int>(n+1,0)); int len=0;//len为能匹配上的最高长度 int loc=-1;//loc为需要计算的起始位置,如果是-1那就是没有。 for(int i=n-1;i>=0;i--) { for(int j=n-1;j>=0;j--) { if(i==j) continue;//i==j时位置就重复了,需要跳过 if(s[i]==s[j]) dp[i][j]=dp[i+1][j+1]+1;//如果i和j匹配上了,那么这里最长匹配距离就可以+1 if(dp[i][j]>len) { len=dp[i][j]; loc=i;//同时更新当前长度和下标 } } } if(loc==-1||len==0)//两个条件都可以来判断是否空串,任选一个都行 return ""; return s.substr(loc,len); } };压缩到一维:
- 状态定义:
dp[j]表示以字符s[i]和s[j]结尾的最长公共子串长度(其中i < j)。- 状态转移: 若
s[i] == s[j],则dp[j] = dp[j-1] + 1;否则dp[j] = 0。- 空间优化: 使用一维数组逆序更新,避免覆盖之前的状态。
- 复杂度: 时间复杂度 O(n²),空间复杂度 O(n),适用于较小规模的输入。
class Solution { public: string longestDupSubstring(string s) { int n = s.size(); vector<int> dp(n, 0); int max_len = 0; int end_pos = 0; for (int i = 0; i < n; ++i) { for (int j = n - 1; j > i; --j) { if (s[i] == s[j]) { dp[j] = (i > 0 && j > 0) ? dp[j-1] + 1 : 1; } else { dp[j] = 0; } if (dp[j] > max_len) { max_len = dp[j]; end_pos = i; } } } return max_len ? s.substr(end_pos - max_len + 1, max_len) : ""; } };
DP法本题是TLE的:

679. 24 点游戏 - 力扣(LeetCode)
C/C++中double类型除以0会得到inf, 不会报错,不用考虑 a/b中 b == 0 的情况。
class Solution {
public:
bool judgePoint24(vector<int>& cards) {
vector<double> nums(cards.begin() , cards.end());//转化为double,为了便于除法运算(会产生小数)
auto dfs = [&](this auto&& dfs , vector<double>& nums)->bool{
if(nums.size() == 1) return abs(nums[0] - 24) <= 1e-6;//如果只有一个数,判断跟24的差值
for (int i = 0; i < nums.size(); i++) {
for (int j = 0; j < nums.size(); j++) {
if(i == j) continue;//不能重复取数
double a = nums[i];//取两个数
double b = nums[j];
vector<double> shengyu;
for(int k = 0 ; k < nums.size() ; k ++){
if(k == i || k == j) continue;
shengyu.push_back(nums[k]);//第二步,存储下剩余的元素,等待第一步取出的两个数在四则运算之后的结果存入当前容器,再次进行dfs运算
}
double yunsuan[4] = {a + b , a - b , a * b , a / b};
for(int w = 0 ; w < 4 ; w ++){
shengyu.push_back(yunsuan[w]);
if(dfs(shengyu)) return true;
shengyu.pop_back();//回溯
}
}
}
return false;
};
return dfs(nums);
}
};


浙公网安备 33010602011771号