zl程序教程

您现在的位置是:首页 >  其他

当前栏目

leetcode 1208. 尽可能使字符串相等-----滑动窗口篇五,前缀和篇一,二分篇一

2023-03-14 22:53:59 时间

尽可能使字符串相等题解集合


暴力法

枚举 s 的所有子串,判断当前和 t 中的子串的「汉明距离」总和是否不大于 maxCost ,更新最大长度即可。

注意:下面代码枚举终点的条件判断,它停留在无法再次修改或字符串末尾。也就是 j 在合法区间右侧 +1 位置

代码:

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 时最长的子数组的长度。

对题目抽象之后,抽象之后,我们知道这是一个区间题,求子区间经常使用的方法就是滑动窗口

《挑战程序设计竞赛》这本书中把滑动窗口叫做「虫取法」,我觉得非常生动形象。因为滑动窗口的两个指针移动的过程和虫子爬动的过程非常像:前脚不动,把后脚移动过来;后脚不动,把前脚向前移动。

我分享一个滑动窗口的模板,能解决大多数的滑动窗口问题:

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 循环中每次可能移动多步。右指针是主动前移,探索未知的新区域;左指针是被迫移动,负责寻找满足题意的区间。

模板的整体思想是:

  1. 定义两个指针 left 和 right 分别指向区间的开头和结尾,注意是闭区间;定义 sums 用来统计该区间内的各个字符出现次数;
  2. 第一重 while 循环是为了判断 right 指针的位置是否超出了数组边界;当 right 每次到了新位置,需要增加 right 指针的求和/计数;
  3. 第二重 while 循环是让 left 指针向右移动到 [left, right] 区间符合题意的位置;当 left每次移动到了新位置,需要减少 left 指针的求和/计数;
  4. 在第二重 while 循环之后,成功找到了一个符合题意的 [left, right] 区间,题目要求最大的区间长度,因此更新 res 为max(res, 当前区间的长度) 。
  5. right 指针每次向右移动一步,开始探索新的区间。

模板中的 sums 需要根据题目意思具体去修改,本题是求和题目因此把sums 定义成整数用于求和;如果是计数题目,就需要改成字典用于计数。当左右指针发生变化的时候,都需要更新 sums 。

另外一个需要根据题目去修改的是内层 while 循环的判断条件,即: 区间[left, right]不符合题意 。对于本题而言,就是该区内的和 sums 超过了 maxCost 。

代码:

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 ,计算机单秒肯定算不完,会超时 ~

所以我们直接放弃通过枚举的朴素做法。

那么如何优化呢?其实有了对于朴素解法的分析之后,无非就是两个方向:

  1. 优化第一个 O(n):减少需要枚举的滑动窗口长度
  2. 优化第二个 O(n):实现不完全滑动前缀和数组,也能确定滑动窗口长度是否合法

事实上第 2 点是无法实现的,我们只能「减少需要枚举的滑动窗口长度」。

一个 O(n) 的操作往下优化,通常就是优化成 O(logn),O(logn) 基本上我们可以先猜一个「二分」查找。

然后我们再来分析是否可以二分:假设我们有满足要求的长度 ans,那么在以 ans 为分割点的数轴上(数轴的范围是滑动窗口长度的范围:[1, n]):

  1. 所有满足 <= ans 的点的修改成本必然满足 <= maxCost
  2. 所有满足 > ans 的点的修改成本必然满足 > maxCost (否则 ans 就不会是答案)

注意:这里的ans就是当前滑动窗口的长度,我们利用二分法去找在满足条件的情况下,最大的滑动窗口长度

因此 ans 在数轴 [1, n] 上具有二段性,我们可以使用「二分」找 ans。得证「二分」的合理性。

二分的本质是二段性,而非单调性

编码细节:

  • 为了方便的预处理前缀和和减少边界处理,我会往字符串头部添加一个空格,使之后的数组下标从 1 开始
  • 二分出来滑动窗口长度,需要在返回时再次检查,因为可能没有符合条件的有效滑动窗口长度

代码:

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)