目录

题目描述

1208. 尽可能使字符串相等

题意分析

给两个等长字符串 st,把 s[i] 改成 t[i] 的开销是两个字符 ASCII 码差的绝对值。在总开销不超过 maxCost 的前提下,求能让 s 的某个子串与 t 的对应子串完全相同的最长子串长度

第一步要看穿的是:每个下标的开销是互相独立、事先就确定的常量,与选了哪个区间无关。所以整道题可以立刻剥掉字符串的外壳,等价成——给定一个数组 cost[i] = |s[i] - t[i]|,求和不超过 maxCost 的最长连续子数组的长度。题面里的「字符串」「ASCII」只是包装。

转化之后,约束里最要紧的一条浮出水面:cost[i] 恒为非负数(绝对值必然 ≥ 0)。这条性质意味着区间和关于区间的包含关系是单调的——区间往右扩一格,和只增不减;往左缩一格,和只减不增。「非负 + 求满足和的上界约束的最长连续段」这个组合,是双指针能成立的全部理由。

另一个信号是「连续子串」而非「子序列」,答案只由左右两个端点决定,因此可以按右端点分类枚举,对每个右端点只保留最优的左端点,这是把 $O(n^2)$ 压成 $O(n)$ 的着力点。

边界要想清楚三处:maxCost 可能为 0,此时只有那些本来就相等的位置能入选;答案可能为 0(每个位置的开销都超预算);maxCost 最大 $10^4$,而单个位置开销最大 25,累加不会溢出,不必上 64 位。

解法:滑动窗口维护预算

核心思路

暴力做法是枚举左右端点:外层定左端点,内层往右扩并累加开销,一旦超预算就停,记录长度。这是 $O(n^2)$,n 达到 $10^5$ 时是 $10^{10}$ 次操作,必然超时。

瓶颈在于大量重复的累加:以 left = 0 试到 right = k 停下后,left = 1 又从头把 [1, k] 重新加了一遍。同一段和被反复计算了 $O(n)$ 次。

关键观察来自开销的非负性。设 R(l) 为「以 l 为左端点、总开销不超预算所能到达的最远右端点」。因为把左端点右移只会去掉一个非负数、让窗口和变小或不变,所以窗口只会更宽松,于是 R(l) 关于 l 单调不减。既然右端点不会倒退,就没必要为每个左端点重新扫一遍——让左右两个指针都只往右走,各自最多走 n 步,总代价降到 $O(n)$。

反过来按右端点组织更好写:对每个右端点 right,我们要的是最小的合法左端点 L(right),这样窗口最长。同样由非负性,L(right) 也随 right 单调不减,所以 left 指针一路向右推进即可,永不回退。

循环不变量:在每轮的答案更新时刻,窗口 [left, right] 满足两条——(1) 窗口内开销之和 cost $\le$ maxCost;(2) left 是使条件 (1) 成立的最小左端点,也就是说 [left - 1, right] 一定超预算(left = 0 时该条件空真)。有了这两条,right - left + 1 就是以 right 结尾的最长合法子串长度,对所有 right 取最大值就是全局答案。

维护不变量只需两个动作:right 右移时把 cost[right] 加进 cost(可能破坏条件 1);然后 while (cost > maxCost) 不断从左侧吐出元素(恢复条件 1,且因为是「刚好停下」所以同时保证了条件 2 的最小性)。

解题步骤

  • 初始化 left = 0cost = 0ans = 0ans 初值必须是 0 而不是 1:可能一个字符都改不起(maxCost = 0 且处处不等),此时答案就是 0。
  • 外层枚举右端点 right,先把 |s[right] - t[right]| 累加进 cost。为什么是「先加后收缩」:我们要维护的是「以当前 right 结尾」的窗口,右端点必须先真正进入窗口,才谈得上判定它是否超预算。
  • while 而不是 if 收缩左端。为什么必须是 while:新加入的这一个字符开销最大可达 25,而左侧可能有一串开销为 0 的位置,一次收缩不足以把 cost 压回预算内,必须循环到合法为止。写成 if 只在「每个元素都是 0/1」的题里侥幸正确,本题会直接错。
  • 收缩条件写 cost > maxCost 而非 >=。为什么:题目允许开销恰好等于 maxCost,等号情况下的窗口是合法的,用 >= 会把最优解主动踢掉。
  • 在收缩之后再更新答案 ans = max(ans, right - left + 1)。为什么顺序不能反:收缩前的窗口可能是超预算的非法状态,用它更新会得到虚高的长度。
  • 返回 ans。循环走完时每个右端点都被作为结尾考察过一次,由不变量可知每次考察拿到的都是该结尾下的最优长度,所以最大值就是全局最优。

s = "abcd"t = "bcdf"maxCost = 3 走一遍。先算开销数组:|a-b| = 1|b-c| = 1|c-d| = 1|d-f| = 2,即 cost = [1, 1, 1, 2]

  • right = 0cost 累加为 1,不超 3,窗口 [0, 0],长度 1,ans = 1
  • right = 1cost = 2,不超预算,窗口 [0, 1],长度 2,ans = 2
  • right = 2cost = 3,恰好等于预算——这里正是 >>= 的分水岭,用 > 判定则不收缩,窗口 [0, 2],长度 3,ans = 3
  • right = 3cost = 3 + 2 = 5 > 3,进入收缩:吐出 left = 0 的开销 1,cost = 4,仍超预算;再吐出 left = 1 的开销 1,cost = 3,合法,此时 left = 2。窗口 [2, 3],长度 2,ans 保持 3。注意这一轮 while 循环了两次,若写成 if 就会停在 cost = 4 的非法窗口上,算出长度 3 并让后续状态全错。
  • 循环结束,返回 3,对应把 "abc" 改成 "bcd",总开销恰好 3。

再看一个退化用例 s = "a"t = "z"maxCost = 0cost = 25 > 0,收缩把 left 推到 1,窗口长度 0 - 1 + 1 = 0ans 保持 0。可见 left 越过 right 时长度自动算成 0,不需要任何特判。

代码实现

class Solution {
    public int equalSubstring(String s, String t, int maxCost) {
        int left = 0;
        int cost = 0;
        int ans = 0;

        for (int right = 0; right < s.length(); right++) {
            cost += Math.abs(s.charAt(right) - t.charAt(right));

            // 窗口始终维护为总成本不超过预算的最长后缀。
            while (cost > maxCost) {
                cost -= Math.abs(s.charAt(left) - t.charAt(left));
                left++;
            }

            ans = Math.max(ans, right - left + 1);
        }

        return ans;
    }
}
func equalSubstring(s string, t string, maxCost int) int {
    left := 0
    cost := 0
    ans := 0

    for right := 0; right < len(s); right++ {
        cost += absByteDiff(s[right], t[right])

        // 窗口始终维护为总成本不超过预算的最长后缀。
        for cost > maxCost {
            cost -= absByteDiff(s[left], t[left])
            left++
        }

        if right-left+1 > ans {
            ans = right - left + 1
        }
    }

    return ans
}

func absByteDiff(a byte, b byte) int {
    if a >= b {
        return int(a - b)
    }
    return int(b - a)
}

复杂度分析

  • 时间复杂度:$O(n)$,n 为字符串长度。凭什么:right 由外层循环单调走 n 步;left 只在内层 while 里递增且从不回退,全程最多也走 n 步。虽然 while 嵌在 for 里,但内层总执行次数由 left 的总位移量决定,是摊还的 $O(n)$ 而非 $O(n^2)$。
  • 空间复杂度:$O(1)$。凭什么:只用了 leftcostans 三个标量;开销数组是「按需现算」而非预先物化的,省掉了一个 $O(n)$ 的数组。

关键点总结

  • 先剥包装再选算法:本题的字符串外壳可以一眼换成「非负数组求和受限的最长连续段」,转化完成后解法自明。面试时先说出这个等价转化,比直接写代码更能体现建模能力。
  • 双指针不回退的合法性来自单调性,而单调性来自元素非负。面试官追问「为什么 left 可以不回头」时,标准答案是「因为右移 left 只会减小窗口和,所以每个右端点的最小合法左端点单调不减」——把这句话说出来,比说「滑窗模板」有分量得多。
  • 收缩用 while 而非 if 是这类题的通用铁律:新元素的量级可能远大于单个旧元素,一次收缩未必够。只有在「每步增量至多 1」的特殊题里 if 才恰好等价。
  • 「不超过预算」类判定要盯死等号方向:>>= 一字之差,直接决定最优解是否被误杀。写之前先默念一遍题面里是「不超过」还是「小于」。
  • 答案更新必须发生在窗口恢复合法之后——先修复不变量,再读取不变量,这是所有滑窗代码的固定顺序。
  • 本题也可以用「前缀和 + 二分」在 $O(n \log n)$ 内做(前缀和非降,故可二分找最小左端点),但既然双指针能到 $O(n)$,面试首选双指针,把二分作为「还有别的思路吗」的备选答案。

易错点总结

  • 收缩用 if 代替 whiles = "abcd"t = "bcdf"maxCost = 3 走到 right = 3cost = 5,一次收缩只降到 4,窗口仍非法,却按 [1, 3] 算出长度 3,若此时真实最优是 2 就会输出偏大的答案;开销跨度更大的数据上错得更明显。
  • 收缩条件写成 cost >= maxCost:上例 right = 2cost 恰好等于 3,本该保留窗口 [0, 2] 得到答案 3,却被强行收缩到 [1, 2],最终输出 2。
  • 在收缩之前更新答案right = 3 时窗口 [0, 3] 的开销是 5 已经超预算,却用它算出长度 4 并写进 ans,返回 4 而正确答案是 3。
  • ans 初值设成 1s = "a"t = "z"maxCost = 0 时一个字符都改不动,正确答案是 0,却返回 1。
  • 收缩时忘记从 cost 里减去左端开销、只做 left++cost 只增不减,第一次超预算后 while 会把 left 一路推到越界,Java 抛 StringIndexOutOfBoundsException,Go 直接 panic。
  • 减的是 right 位置的开销而不是 left 位置的cost = [1, 1, 1, 2]right = 3 时会反复减 2,cost 变成 3 后停下但 left 只前进一步,窗口 [1, 3] 的真实开销是 4,超预算却被当成合法,答案偏大。
  • 写成 Math.abs(s.charAt(right)) - Math.abs(t.charAt(right)):括号位置错了,绝对值套在单个字符上恒等于它自身,差值可能为负,cost 被越加越小甚至变负,while 永不触发,直接返回 n
  • 误以为可以对开销数组排序后贪心取小的cost = [5, 1, 1, 5]maxCost = 2 时排序贪心会选出两个 1 并报长度 2,但它们在原数组里就是相邻的下标 1、2,这次侥幸对;换成 cost = [1, 5, 1]maxCost = 2 就会报 2,而真实答案是 1——子串必须连续,排序破坏了下标关系。
  • 用前缀和 + 二分却把二分方向写反:前缀和数组非降,要找的是最小l 使 pre[right + 1] - pre[l] <= maxCost,等价于 pre[l] >= pre[right + 1] - maxCost,即「第一个不小于某值的位置」;写成「最后一个不大于」会得到最短窗口而不是最长窗口,maxCost 很大时答案退化成 1。
  • Go 里把字符串转成 []rune 再比较:题目保证只有小写英文字母,按字节访问即可;转 rune 白白多出 $O(n)$ 空间,且在 ASCII 场景下语义完全一致,属于无意义开销。

相似题目

题目 难度 考察点
209. 长度最小的子数组 中等 同为非负数组 + 和的约束,但求最短满足下界的窗口,收缩时机相反
1004. 最大连续1的个数 III 中等 预算是「翻转 0 的次数」,等价于本题中开销只取 0/1 的特例
424. 替换后的最长重复字符 中等 窗口代价依赖「窗口内最高频字符」,是动态量,需额外维护计数数组
1493. 删掉一个元素以后全为 1 的最长子数组 中等 预算固定为删一个 0,且最终长度要减 1,边界处理与本题不同
3. 无重复字符的最长子串 中等 约束是「窗口内元素互异」而非数值求和,靠哈希表判定而不是累加器
904. 水果成篮 中等 约束是「窗口内至多两种元素」,收缩依据是种类数,需要计数表配合
713. 乘积小于 K 的子数组 中等 元素为正保证乘积单调,但统计的是子数组个数,每轮累加窗口长度
1658. 将 x 减到 0 的最小操作数 中等 需先把「删两端最少个数」反向转成「求中间和为定值的最长窗口」才能滑窗