目录

题目描述

面试题 01.05. 一次编辑

题意分析

输入是两个字符串,要判断能否在「最多动一次」的预算内让它们相等。这里的「动一次」有三种形态:往其中一串里插入一个字符、从其中一串里删掉一个字符、把其中一个字符换成别的字符。

注意题面允许的是「一次或零次」,所以两串本来就完全相同也算合法答案,这是最容易被读漏的一句。

从三种操作反推长度关系,是本题最有价值的约束信号:替换不改变长度,插入和删除各让长度变化 1。既然预算只有一次,两串长度差就只能是 01,任何差值 2 以上的输入都可以立刻否定。

边界上要考虑的输入有:两串完全相同、其中一串为空、两串都为空、长度差恰好为 1、长度差大于 1、以及差异出现在末尾(例如 "abc""ab")。

解法:双指针比较

核心思路

最直白的暴力做法是把三种编辑全部枚举一遍:对较长串枚举删掉哪一位,对等长的两串枚举改哪一位,每次改完再整体比较一次。枚举位置要 $O(n)$,每次比较又要 $O(n)$,总共 $O(n^2)$,而且三类操作各写一段,代码又长又容易漏分支。

瓶颈在于「改完再整体比较」这件事重复做了太多次。观察被枚举的这些候选:它们的公共前缀其实是同一段,真正的分歧点只有一个。既然预算只有一次编辑,两串从左往右扫描时,第一次失配之前的部分必然逐位相等,第一次失配的位置就是唯一要花掉预算的地方。花完之后,剩下的部分必须逐位严格相等,不允许再有第二次失配。

于是只需要一趟扫描。先统一把较短的串放在 first,较长的放在 second,这样插入和删除就归并成了同一件事:短串少了一个字符,等价于在长串上跳过一个字符。

显式的不变量是:扫描到任意时刻,first[0..i) 都可以通过已经花掉的 edits 次编辑变成 second[0..j),并且 edits <= 1。字符相同时两个指针同步前进,不变量自然保持;字符不同时花掉一次预算,此时 edits 会变成 1,指针的走法由长度关系决定——等长就是替换,两个指针一起走;不等长就是长串多了一个字符,只让 j 前进跳过它。一旦 edits 要变成 2,不变量被破坏,直接返回 false

解题步骤

  • 先算两串长度差,大于 1 就返回 false。这一步不是优化而是正确性前提:后面的循环会在短串走到头时自然退出,如果长度差是 3,循环会在 edits 还是 0 的时候结束并错误地返回 true
  • firstsecond 长,交换参数再递归调用一次。这样做是为了让后续代码只需要处理「短串在前」这一种形态,把插入和删除合并成同一条分支。
  • i 指向 firstj 指向 secondedits 记录已花掉的编辑次数,循环条件是两个指针都没越界。
  • first[i] == second[j] 时两个指针同时后移。这一步不消耗预算,因为这一位不需要任何编辑。
  • 当两个字符不同时 edits 加一,若已经超过 1 立刻返回 false。之所以能立刻返回,是因为不变量要求全程 edits <= 1
  • 失配后若两串等长则 i 也后移,否则只移动 j。等长意味着这一位只能用替换解决,两边都要跳过;不等长意味着 second 多出一个字符,跳过它就让剩余部分重新对齐。
  • 循环退出后直接返回 true。此时要么短串已经走完,要么中途已经因为 edits 超标返回了 false,剩余的至多一个字符正好被那次尚未使用或已经使用的预算覆盖。

first = "pale"second = "ple" 走一遍:长度差是 $4 - 3 = 1$,不大于 1,通过预判。因为 first 更长,交换后变成 first = "ple"second = "pale",此时 i = 0j = 0edits = 0。第一轮 'p''p' 相等,i = 1j = 1。第二轮 first[1] = 'l'second[1] = 'a' 不等,edits 变成 1,两串长度 34 不等,所以只移动 j,得到 i = 1j = 2。第三轮 first[1] = 'l'second[2] = 'l' 相等,i = 2j = 3。第四轮 first[2] = 'e'second[3] = 'e' 相等,i = 3j = 4。此时 i 等于 first 长度 3,循环退出,返回 true,对应的编辑正是从 "pale" 中删掉 'a'

再看反例 first = "abcd"second = "ab":长度差是 2,第一步就返回 false,循环根本不会执行。

代码实现

class Solution {
    public boolean oneEditAway(String first, String second) {
        if (Math.abs(first.length() - second.length()) > 1) {
            return false;
        }
        if (first.length() > second.length()) {
            return oneEditAway(second, first);
        }

        int i = 0;
        int j = 0;
        int edits = 0;
        while (i < first.length() && j < second.length()) {
            if (first.charAt(i) == second.charAt(j)) {
                i++;
                j++;
            } else {
                edits++;
                if (edits > 1) {
                    return false;
                }
                if (first.length() == second.length()) {
                    i++;
                }
                j++;
            }
        }
        return true;
    }
}
func oneEditAway(first string, second string) bool {
    if absInt(len(first)-len(second)) > 1 {
        return false
    }
    if len(first) > len(second) {
        return oneEditAway(second, first)
    }

    i, j := 0, 0
    edits := 0
    for i < len(first) && j < len(second) {
        if first[i] == second[j] {
            i++
            j++
        } else {
            edits++
            if edits > 1 {
                return false
            }
            if len(first) == len(second) {
                i++
            }
            j++
        }
    }
    return true
}

func absInt(x int) int {
    if x < 0 {
        return -x
    }
    return x
}

复杂度分析

  • 时间复杂度:$O(n)$,$n$ 是较短串长度。每轮循环至少让 j 前进一格,j 最多走 $n + 1$ 步就会让循环因为 ij 越界而退出,交换参数的递归只会发生一次。
  • 空间复杂度:$O(1)$,只用了两个下标和一个计数器,没有开辟与输入规模相关的额外结构;那次交换参数的递归深度也是常数。

关键点总结

  • 先从操作的「代数性质」反推约束,再动手写循环。替换保长度、插删改长度 1,这条推理直接给出了长度差不超过 1 的硬性剪枝,比在循环里补丁式地打各种判断可靠得多。
  • 遇到「A 和 B 对称」的两种操作,优先用规范化把它们合并。这里把短串强制放到 first,插入和删除立刻塌缩成同一条分支,代码量和出错面同时减半。
  • 双指针的核心是想清楚「失配之后谁动」。等长动两个、不等长只动长串,这个决策规则本身就是本题的全部难点,写代码前应该先用一句话把它说出来。
  • 把不变量写成「前缀已经用 edits 次编辑对齐且 edits <= 1」,循环退出时的正确性就不需要额外讨论,这是所有一趟扫描类题目通用的论证套路。
  • 面试视角:这题是编辑距离的 $k = 1$ 特例,面试官几乎一定会追问「改成最多 k 次编辑呢」。正确回答是当 k 变大后一趟扫描的贪心不再成立,要退回 $O(mn)$ 的编辑距离动态规划,或者用带状 DP 只算主对角线附近 $2k + 1$ 条带做到 $O(kn)$。能讲清「为什么 $k = 1$ 才能线性」比写对代码更加分。
  • 面试视角:白板上先说剪枝与不变量再落笔,中途主动补一句「零次编辑也算合法」,能直接展示对题面边界的敏感度,这是这道简单题唯一的观察点。

易错点总结

  • 错误写法:省略长度差预判,循环跑完就 return true。用例 first = "ab"second = "abcde" → 两串前缀完全相同,循环因为 i 走到 first 末尾而退出,edits 全程是 0,返回 true,但实际需要三次插入,答案应为 false
  • 错误写法:不做「短串换到前面」的归一化就直接进循环。用例 first = "pale"second = "ple" → 第二轮 'a''l' 失配,长度不等的分支只推进 j,而此时 j 指向的是短串,指针越走越错位,第三轮再次失配使 edits 变成 2,返回 false,正确答案是 true
  • 错误写法:失配后无论长度关系一律同时移动两个指针。用例 first = "ple"second = "pale" → 第二轮 'l''a' 失配后 i 也跟着前进,后续变成 'e''l' 再次失配,edits 达到 2 返回 false,正确答案是 true
  • 错误写法:失配后只移动短串指针 i。用例 first = "ple"second = "pale"j 一直停在 'a' 上,第三轮 'e''a' 又一次失配,返回 false,正确答案是 true
  • 错误写法:把「恰好一次编辑」当成合法条件,要求 edits == 1 才返回 true。用例 first = "abc"second = "abc" → 全程没有失配,edits0,被判为 false,但题目明确允许零次编辑。
  • 错误写法:长度差的判断写成 abs(len1 - len2) != 1 才继续。用例 first = "ab"second = "ab" → 长度差为 0 直接被拒,所有等长的替换场景和相同串场景全部误判为 false
  • 错误写法:交换分支里递归调用时参数没有真正换位,写成 oneEditAway(first, second)。用例 first = "pale"second = "ple" → 每次进入都满足 first 更长的条件,无限递归直到栈溢出。
  • 错误写法:把边界写成 abs(len1 - len2) >= 1 就返回 false。用例 first = "pale"second = "ple" → 长度差恰为 1 的插入删除场景被整类排除,只剩等长替换能通过,答案错成 false

相似题目

题目 难度 考察点
72. 编辑距离 中等 编辑次数不设上限,需要二维 dp 求最少操作数
583. 两个字符串的删除操作 中等 只保留删除操作,等价于求最长公共子序列后取补
392. 判断子序列 简单 允许任意多次跳过,双指针不再受编辑预算约束
680. 验证回文串 II 简单 同样是一次删除预算,但指针从两端相向而不是同向