尽可能使字符串相等题解集合
- 暴力法
- 滑动窗口
- 暴力法与滑动窗口的区别
- 前缀和 二分 滑动窗口
暴力法
枚举 s 的所有子串,判断当前和 t 中的子串的「汉明距离」总和是否不大于 maxCost ,更新最大长度即可。
注意:下面代码枚举终点的条件判断,它停留在无法再次修改或字符串末尾。也就是 j 在合法区间右侧 1 位置
代码:
代码语言:javascript复制class Solution {
public:
int equalSubstring(string s, string t, int maxCost)
{
int maxLen = 0;
for (int i = 0, j, cost; i<s.size(); i )
{
for (j = i,cost=0; j<s.size(); j )
{
cost = abs(s[j] - t[j]);
if (cost > maxCost) break;
}
//将当前局部最优解与之前求出的最大值进行比较操作
maxLen = max(maxLen, j - i );
}
return maxLen;
}
};
滑动窗口
思路:
两个长度相等字符串的 s 和 t ,把 i 位置的 s[i] 转成 t[i] 的开销是两者 ASCII 码之差的绝对值。题目给出了允许的最大预算 maxCost ,求不超过预算的情况下能够转换的最长子串。
比如,对于 s = “abcd”, t = “bcdf”, cost = 3 而言,我们使用 costs[i] 表示从 s[i] 转成 t[i] 的开销,那么 costs = [1, 1, 1, 2] 。由于 maxCost = 3, 所以最多允许其前面三个字符进行转换。
于是题目变成了:已知一个数组 costs ,求:和不超过 maxCost 时最长的子数组的长度。
对题目抽象之后,抽象之后,我们知道这是一个区间题,求子区间经常使用的方法就是滑动窗口
《挑战程序设计竞赛》这本书中把滑动窗口叫做「虫取法」,我觉得非常生动形象。因为滑动窗口的两个指针移动的过程和虫子爬动的过程非常像:前脚不动,把后脚移动过来;后脚不动,把前脚向前移动。
我分享一个滑动窗口的模板,能解决大多数的滑动窗口问题:
代码语言:javascript复制def findSubArray(nums):
N = len(nums) # 数组/字符串长度
left, right = 0, 0 # 双指针,表示当前遍历的区间[left, right],闭区间
sums = 0 # 用于统计 子数组/子区间 是否有效,根据题目可能会改成求和/计数
res = 0 # 保存最大的满足题目要求的 子数组/子串 长度
while right < N: # 当右边的指针没有搜索到 数组/字符串 的结尾
sums = nums[right] # 增加当前右边指针的数字/字符的求和/计数
while 区间[left, right]不符合题意:# 此时需要一直移动左指针,直至找到一个符合题意的区间
sums -= nums[left] # 移动左指针前需要从counter中减少left位置字符的求和/计数
left = 1 # 真正的移动左指针,注意不能跟上面一行代码写反
# 到 while 结束时,我们找到了一个符合题意要求的 子数组/子串
res = max(res, right - left 1) # 需要更新结果
right = 1 # 移动右指针,去探索新的区间
return res
滑动窗口中用到了左右两个指针,它们移动的思路是:以右指针作为驱动,拖着左指针向前走。右指针每次只移动一步,而左指针在内部 while 循环中每次可能移动多步。右指针是主动前移,探索未知的新区域;左指针是被迫移动,负责寻找满足题意的区间。
模板的整体思想是:
- 定义两个指针 left 和 right 分别指向区间的开头和结尾,注意是闭区间;定义 sums 用来统计该区间内的各个字符出现次数;
- 第一重 while 循环是为了判断 right 指针的位置是否超出了数组边界;当 right 每次到了新位置,需要增加 right 指针的求和/计数;
- 第二重 while 循环是让 left 指针向右移动到 [left, right] 区间符合题意的位置;当 left每次移动到了新位置,需要减少 left 指针的求和/计数;
- 在第二重 while 循环之后,成功找到了一个符合题意的 [left, right] 区间,题目要求最大的区间长度,因此更新 res 为max(res, 当前区间的长度) 。
- right 指针每次向右移动一步,开始探索新的区间。
模板中的 sums 需要根据题目意思具体去修改,本题是求和题目因此把sums 定义成整数用于求和;如果是计数题目,就需要改成字典用于计数。当左右指针发生变化的时候,都需要更新 sums 。
另外一个需要根据题目去修改的是内层 while 循环的判断条件,即: 区间[left, right]不符合题意 。对于本题而言,就是该区内的和 sums 超过了 maxCost 。
代码:
代码语言:javascript复制class Solution {
public:
int equalSubstring(string s, string t, int maxCost)
{
//这里先对问题进行转换::已知一个数组 costs ,求和不超过 maxCost 时最长的子数组的长度。
//因此我们需要先求出costs数组
vector<int> costs(s.size(), 0);
for (int i = 0; i < s.size(); i )
costs[i] = abs(s[i] - t[i]);
//下面套模板
int right = 0, left = 0, sum = 0, res = 0;
while (right < s.size())
{
sum = costs[right];
while (sum > maxCost)
{
sum -= costs[left];
left ;
}
res = max(res, right - left 1);
right ;
}
return res;
}
};
- 时间复杂度: O(N) ,因为两个指针分别都只把每个元素遍历了一次。
- 空间复杂度: O(N) ,因为使用了 costs 数组用于保存每个字符转换的开销。
暴力法与滑动窗口的区别
上面暴力算法慢在对一个重叠区间多次进行统计,例如区间 [x, y] 中在第 x≤k≤y 位置上有两字符相差最大 26 > maxCost,此时枚举起点 i∈[x,k] 都在重复计算不可能最大的区间。
本题具有「决策单调性」,因为对于区间 [i, j] ,若当前区间恰好不满足其内 ASCII 码差值和小于等于 cost ,则窗口继续向右拓展 j ,一样不会是合法区间。因此只能通过缩减左端点 i ,将区间元素删除,这样才能保证该区间 ASCII 码差值和小于等于 cost 成立。
前缀和 二分 滑动窗口
给定了长度相同的 s 和 t,那么对于每一位而言,修改的成本都是相互独立而确定的。
我们可以先预处理出修改成本的前缀和数组 sum。
当有了前缀和数组之后,对于任意区间 [i,j] 的修改成本,便可以通过 sum[j] - sum[i - 1] 得出。
前缀和数组:
那么接下来我们只需要找出成本不超过 maxCost 的最大长度区间,这个长度区间其实就是滑动窗口长度,滑动窗口长度的范围为 [1, n] (n 为字符串的长度)。
通过枚举来找答案可以吗?
我们可以通过数据范围大概分析一下哈,共有 n 个滑动窗口长度要枚举,复杂度为 O(n),对于每个滑动窗口长度,需要对整个前缀和数组进行滑动检查,复杂度为 O(n)。也就是整体复杂度是 O(n^2)的。
数据范围是 10^5 ,那么单个样本的计算量是 10^10 ,计算机单秒肯定算不完,会超时 ~
所以我们直接放弃通过枚举的朴素做法。
那么如何优化呢?其实有了对于朴素解法的分析之后,无非就是两个方向:
- 优化第一个 O(n):减少需要枚举的滑动窗口长度
- 优化第二个 O(n):实现不完全滑动前缀和数组,也能确定滑动窗口长度是否合法
事实上第 2 点是无法实现的,我们只能「减少需要枚举的滑动窗口长度」。
一个 O(n) 的操作往下优化,通常就是优化成 O(logn),O(logn) 基本上我们可以先猜一个「二分」查找。
然后我们再来分析是否可以二分:假设我们有满足要求的长度 ans,那么在以 ans 为分割点的数轴上(数轴的范围是滑动窗口长度的范围:[1, n]):
- 所有满足 <= ans 的点的修改成本必然满足 <= maxCost
- 所有满足 > ans 的点的修改成本必然满足 > maxCost (否则 ans 就不会是答案)
注意:这里的ans就是当前滑动窗口的长度,我们利用二分法去找在满足条件的情况下,最大的滑动窗口长度
因此 ans 在数轴 [1, n] 上具有二段性,我们可以使用「二分」找 ans。得证「二分」的合理性。
二分的本质是二段性,而非单调性
编码细节:
- 为了方便的预处理前缀和和减少边界处理,我会往字符串头部添加一个空格,使之后的数组下标从 1 开始
- 二分出来滑动窗口长度,需要在返回时再次检查,因为可能没有符合条件的有效滑动窗口长度
代码:
代码语言:javascript复制class Solution {
public:
int equalSubstring(string s, string t, int maxCost)
{
vector<int> sum(s.size() 1, 0);
for (int i = 1; i <= s.size(); i ) sum[i] = sum[i - 1] abs(s[i-1] - t[i-1]);
int l = 1, r =s.size();
while (l < r)
{
int mid = l r 1 >> 1;//当前滑动窗口的长度为mid
//check函数:查看滑动窗口长度为mid时,是否存在满足条件的解
if (check(sum,mid,maxCost))//说明当前滑动窗口长度为mid时满足条件,那么我们尝试去扩大滑动窗口的长度
l = mid;//注意:这里不能是l=mid 1
else//说明当前滑动窗口长度为mid时,找不到满足条件的解,我们需要去缩小滑动窗口的长度
r = mid - 1;
}
//按理来说最后退出while循环得到l=r=mid,应该直接返回r或者l,这里mid是局部变量
//但是这样是错误的,因为如果没有任何长度的滑动区间能够满足条件,那么这里返回的会是1,因为最后l=r=1
//但是我们期望返回的是0,因此我们还需要在最后返回的时候,判断一下当前滑动窗口的长度是否可行,如果不可行就返回0
return check(sum,r,maxCost)?r:0 ;
}
bool check(vector<int>& sum, int mid, int maxCost)
{
//从起点开始,挨个测试是否存在长度为mid并且满足当前滑动区间内和小于maxCost的解,如果找到一个就直接返回true
for (int i = mid; i <sum.size(); i )
{
//获取当前长度为mid的区间的和
//解释sum[i]-sum[i-mid]:
//当有了前缀和数组之后,对于任意区间 [i,j] 的修改成本,便可以通过 sum[j] - sum[i - 1] 得出。
//这里sum下标从0开始,没有-1了
int tot = sum[i] - sum[i - mid];
if (tot <=maxCost)
return true;
}
return false;
}
};
- 时间复杂度:预处理出前缀和的复杂度为O(n);二分出「滑动窗口长度」的复杂度为O(logn),对于每个窗口长度,需要扫描一遍数组进行检查,复杂度为O(n),因此二分的复杂度为O(nlogn)。整体复杂度为 O(nlogn)
- 空间复杂度:使用了前缀和数组。复杂度为 O(n)