目录

题目描述

1768. 交替合并字符串

题意分析

题目给两个只含小写字母的字符串 word1word2,要求把它们交替合成一个新串:先取 word1 的首字符,再取 word2 的首字符,然后取两串的第二个字符,如此往复。这里「word1 开始」是硬性的顺序要求,不是可以自由选择的细节——两个字符的先后一旦写反,整个答案会全错,而不是只错在某个边界上。样例 word1 = "abc"word2 = "pqr" 的答案是 "apbqcr",而不是 "paqbrc",就是在钉死这个顺序。

约束里最关键的一条是 1 <= word1.length, word2.length <= 100。它透露了两个信号:其一,两串都非空,所以不必为空串单独开分支;其二,也是真正决定解法形状的一条——两串长度可以不等,且题目完全没有暗示谁更长。这就意味着不能假设「一人一个字符,正好配完」。

顺着「长度可能不等」往下推,答案必然呈现两段式结构。记 m = word1.length()n = word2.length():在两串共有的前 min(m, n) 个位置上,是严格的交替排列,这一段长度为 2 * min(m, n);越过 min(m, n) 之后,只剩下较长的那个串还有字符可取,此时已经没有「交替」可言了,题目要求把这个后缀整段原样追加到末尾。样例二 word1 = "ab"word2 = "pqrs" 的答案 "apbqrs" 里,"apbq" 是交替段,"rs" 是原样追加的剩余段;样例三 word1 = "abcd"word2 = "pq" 的答案 "apbqcd" 则是剩余段来自 word1 的镜像情形。

于是边界只有三类:两串等长(剩余段为空,答案长度为 2m)、word1 更长(剩余段来自 word1)、word2 更长(剩余段来自 word2)。再加上最小规模的 word1 = "a"word2 = "b",四个用例就能覆盖全部分支。还有一条几乎免费的自检式:答案长度恒等于 m + n,一个字符都不能丢、不能重、不能换位——写完先用它对一遍,能挡掉绝大多数低级错误。

解法:双指针交替追加

核心思路

既然答案天然分成「交替段」和「剩余段」两截,代码就照着这个形状写:用一个下标 i 同步走两个串,只在两串都还有字符时成对追加;i 停下来之后,把还有剩余的那个串的后缀整段接上。之所以能共用一个下标而不需要两个独立指针,是因为在交替段里两串永远同步前进,word1 取到第 i 位时 word2 也恰好取到第 i 位——这正是「交替」的定义。

另一种常见写法是只开一个循环,把循环上界放宽到 max(m, n),然后在循环体内写两个 if 判断当前串是否还有字符。这写法是对的,但它把「越界保护」和「业务逻辑」搅在了一起:循环里的每个 if 都要在脑子里同时回答「这是交替还是收尾」和「这次要不要跳过」两个问题,而且这两个 if 每轮都要重新求值,共 max(m, n) 次。拆成两段之后,主循环体里没有任何条件分支,纯粹是「取两个、追加两个」;收尾的两个 if 在循环外只执行一次,语义也变得单一——「谁还有剩余,就把谁的后缀接上」。在面试白板上,把条件判断从循环内挪到循环外,是让别人一眼看懂的最直接手段。

正确性可以用一条不变量说清。在主循环每一轮开始时,缓冲区里已写入的内容恰好是 word1 的前 i 个字符与 word2 的前 i 个字符的交替排列,长度为 2i 循环开始前 i = 0,缓冲为空,不变量成立;每一轮先追加 word1[i] 再追加 word2[i],然后 i 自增一,写入内容与下标同步推进到 i + 1,不变量得以保持。循环在 i == min(m, n) 时退出,此时不变量保证交替段已完整且正确。退出后 i < mi < n 至多一个成立——因为 i 已经等于两者的最小值,不可能同时小于两者——所以两个 if 中最多进一个,剩余段恰好被追加一次,既不重复也不遗漏。

最后是缓冲区的选择。这题的本质是「往末尾追加 m + n 次」,如果用不可变字符串做 ans += c,每一次 += 都会新建一个字符串并把已有内容整体拷贝一遍,累计拷贝量是 $1 + 2 + \dots + (m + n)$,总体退化成 $O((m + n)^2)$。改用可变缓冲(Java 的 StringBuilder、Go 的 strings.Builder)后,追加是均摊 $O(1)$ 的写入。更进一步:答案长度已知恒为 m + n,所以可以在创建缓冲时就把容量设满,让底层数组一次分配到位,连扩容时的搬迁拷贝也一并省掉。这一步不改变复杂度量级,但它是面试里的加分项——它说明你知道缓冲区会扩容,也知道这里的最终长度是可以提前算出来的。

解题步骤

  • 取出两串长度 mn为什么:后面的循环上界与剩余段的切分点都由这两个值决定,先取出来能避免在循环里反复调用长度方法,也让「两段式」的边界 min(m, n) 一眼可见。
  • 创建可变缓冲,容量直接设为 m + n为什么:追加次数是 m + n,用可变缓冲把每次追加降到均摊 $O(1)$;容量一次给满则连中途扩容的数组拷贝都省掉,因为答案长度是提前可知的。
  • i = 0,进入主循环,条件为 i < m && i < n为什么:用 && 而不是 ||,把「两串都还有字符」这个前提提到循环条件里,循环体内就不必再做任何越界检查。
  • 循环体内先追加 word1[i]、再追加 word2[i],然后 i++为什么:顺序必须是「先 word1word2」,这是题目钉死的要求;成对追加保证每一轮结束时缓冲长度都是偶数 2i,与不变量吻合。
  • 循环结束后,若 i < m,把 word1 从下标 i 到末尾的后缀整段追加。为什么:循环退出说明 word2 已取完,word1 剩下的部分不再参与交替,题目要求原样附加;整段追加而不是逐字符循环,代码更短且少一层下标出错的机会。
  • i < n,把 word2 从下标 i 到末尾的后缀整段追加。为什么:这是镜像情形。这两个 if 至多命中一个(i 已等于 min(m, n)),所以写成两个独立 if 与写成 if / else if 等价,无需担心重复追加。
  • 把缓冲转成字符串返回。为什么:返回类型是 String,缓冲本身不是答案;顺手用「长度是否等于 m + n」自检一遍。

word1 = "ab"word2 = "pqrs" 走一遍:此时 m = 2n = 4,缓冲容量设为 6i = 0

第一轮,i = 0,检查 0 < 2 && 0 < 4 成立,进入循环:追加 word1[0] = 'a',缓冲变为 "a";追加 word2[0] = 'p',缓冲变为 "ap"i 变成 1。此时不变量成立——两串各前 1 个字符交替排列,长度 2

第二轮,i = 1,检查 1 < 2 && 1 < 4 成立:追加 word1[1] = 'b',缓冲变为 "apb";追加 word2[1] = 'q',缓冲变为 "apbq"i 变成 2

第三次检查,i = 22 < 2 不成立,主循环退出,交替段定格为 "apbq"。进入收尾:i < m2 < 2 为假,跳过;i < n2 < 4 为真,把 word2 下标 2 到末尾的后缀 "rs" 整段追加,缓冲变为 "apbqrs"。返回 "apbqrs",长度 6 等于 m + n,与官方样例一致。

再以 word1 更长的 word1 = "abcd"word2 = "pq" 走一遍m = 4n = 2,容量 6i = 0。第一轮追加 'a''p',缓冲 "ap"i = 1;第二轮追加 'b''q',缓冲 "apbq"i = 2;第三次检查时 2 < 4 成立但 2 < 2 不成立,&& 短路,主循环退出。收尾时 i < m2 < 4 为真,把 word1 下标 2 到末尾的后缀 "cd" 整段追加,缓冲变为 "apbqcd"i < n2 < 2 为假,跳过。返回 "apbqcd",长度 6,与官方样例一致。可以看到两个用例走的是同一份代码,唯一的差别只是收尾时命中了哪个 if

代码实现

class Solution {
    public String mergeAlternately(String word1, String word2) {
        int m = word1.length(), n = word2.length();
        // 答案长度恒为 m + n,预先把容量设满,底层数组一次分配到位,省掉扩容拷贝
        StringBuilder sb = new StringBuilder(m + n);
        int i = 0;
        // 交替段:两串都还有字符时,按「先 word1 后 word2」成对追加,循环体内无需越界判断
        while (i < m && i < n) {
            sb.append(word1.charAt(i));
            sb.append(word2.charAt(i));
            i++;
        }
        // 剩余段:此时 i == min(m, n),两个条件至多命中一个,整段追加后缀
        if (i < m) {
            // 注意 append(CharSequence, start, end) 的第三个参数是结束下标,不是长度
            sb.append(word1, i, m);
        }
        if (i < n) {
            sb.append(word2, i, n);
        }
        return sb.toString();
    }
}
func mergeAlternately(word1 string, word2 string) string {
    m, n := len(word1), len(word2)
    // 答案长度恒为 m + n,先 Grow 到位,避免 Builder 内部反复扩容拷贝
    var sb strings.Builder
    sb.Grow(m + n)
    i := 0
    // 交替段:两串都还有字符时,按「先 word1 后 word2」成对追加,循环体内无需越界判断
    for i < m && i < n {
        // 题目保证只含小写字母,均为单字节,按 byte 取用安全
        sb.WriteByte(word1[i])
        sb.WriteByte(word2[i])
        i++
    }
    // 剩余段:此时 i == min(m, n),两个条件至多命中一个,整段追加后缀
    if i < m {
        sb.WriteString(word1[i:])
    }
    if i < n {
        sb.WriteString(word2[i:])
    }
    return sb.String()
}

复杂度分析

  • 时间复杂度:$O(m + n)$,其中 mn 分别为两串长度。主循环执行 $\min(m, n)$ 轮、每轮做两次常数时间的追加,收尾时整段追加剩余的 $ m - n $ 个字符,每个输入字符恰好被读取并写入一次,合计 $m + n$ 次均摊 $O(1)$ 的操作;由于容量已按 m + n 预设,底层数组不会触发扩容,也就不存在扩容时把已有内容整体搬迁的重复拷贝,均摊边界因此是真实成立的常数而非被扩容摊薄的结果。
  • 空间复杂度:$O(m + n)$,即输出缓冲本身占用的空间,这是返回一个长度为 m + n 的新字符串所无法避免的下界;除此之外只用了 mni 三个整型变量,额外空间是 $O(1)$。

关键点总结

  • 长度不等的双序列合并,统一套路是「公共段循环 + 剩余段整段追加」:先在两者都有元素的区间里按规则配对,再把长出来的尾巴一次性接上。这条在合并两个有序数组、合并两个有序链表里同样成立——那两题的收尾也都是「一边走完了,把另一边剩下的直接挂过去」,只不过配对规则从「交替」换成了「比大小」。
  • 把越界保护写进循环条件,而不是写进循环体:用 while (i < m && i < n) 代替 while (i < max(m, n)) 加两个内部 if,循环体就退化成无分支的直线代码,正确性一眼可验,也不会漏掉某个分支。
  • 构造型答案要先问「最终长度是否可知」:本题答案长度恒为 m + n,既能用来预设缓冲容量,也是最省力的自检式。凡是长度可以提前算出的构造题,都值得把这一步固化成习惯。
  • 写循环前先把不变量说出来:「每轮开始时缓冲内容 = 两串各前 i 个字符的交替排列」这一句同时确定了循环体该做什么、i 该在哪里自增、以及退出后剩余段该从哪个下标开始,比在纸上试下标高效得多。
  • 累积字符必须用可变缓冲:不可变字符串的 += 每次都要整体拷贝,$n$ 次追加累积成 $O(n^2)$。这在小数据上看不出来,但它是写法层面的错误,与数据规模无关。
  • 面试视角:这题作为 easy 题,面试官真正在看三件事——你是否用了 StringBuilder(而不是 += 拼接)、你是否主动处理了两串长度不等(而不是默认等长、等测试用例打脸才补)、以及你是否想到预设容量。三点都做到,题目难度虽低但代码是「工程质量合格」的;只做到前两点是及格,一点没做到则会被追问到底。主动说出「答案长度是 m + n,所以我先把容量给满」这一句,性价比极高。

易错点总结

  • 用不可变字符串 += 累积字符:把 sb.append(...) 换成 ans += word1.charAt(i),逻辑完全正确,但把两串各放大到 100000 个字符后实测耗时 1095 ms,而 StringBuilder 版本同一输入只用 8 ms,相差约 137 倍——每次 += 都新建字符串并整体拷贝,总拷贝量退化到 $O((m + n)^2)$。本题约束只有 100,测试用例不会卡掉它,但面试里这是会被当场指出的写法问题。
  • 主循环上界取较长串的长度,循环体内无条件索引:写成 for (int i = 0; i < Math.max(m, n); i++) { sb.append(word1.charAt(i)); sb.append(word2.charAt(i)); },用 word1 = "abcd"word2 = "pq" 触发,Java 直接抛 java.lang.StringIndexOutOfBoundsException: String index out of range: 2;Go 的对应写法则 panic: runtime error: index out of range [2] with length 2。放宽上界的写法要么配上两个内部 if,要么就别放宽。
  • 只写了交替段,忘记追加剩余段:删掉收尾的两个 if,用 word1 = "ab"word2 = "pqrs" 触发,输出 "apbq" 而正确答案是 "apbqrs""rs" 整段丢失;镜像用例 word1 = "abcd"word2 = "pq" 同样输出 "apbq",丢掉 "cd"。这类错误用「答案长度必须等于 m + n」一验就现形——4 != 6
  • 交替顺序写反,先取 word2 再取 word1:循环体改成先 append(word2.charAt(i))append(word1.charAt(i)),用 word1 = "abc"word2 = "pqr" 触发,输出 "paqbrc" 而正确答案是 "apbqcr"。长度校验完全通过,字符集也一致,只有顺序错了,所以自检式挡不住它——必须回头核对题目的「从 word1 开始」。
  • StringBuilder.append(CharSequence, start, end) 的第三个参数误传成长度:把 sb.append(word1, i, m) 写成 sb.append(word1, i, m - i),用 word1 = "abcd"word2 = "pq" 触发,此时 i = 2m - i = 2,于是 start == end,一个字符都没追加,输出 "apbq";镜像用例 word1 = "ab"word2 = "pqrs" 同样输出 "apbq"。这个签名收的是结束下标,不是长度,而且它退化时不报错、只静静少写字符,比抛异常更难查。
  • new StringBuilder(word1) 把内容当成了容量:本意是「预留 word1 那么大的空间」,但这个构造器接收 CharSequence 时是用它作为初始内容。用 word1 = "abc"word2 = "pqr" 触发,输出 "abcapbqcr",比正确答案 "apbqcr" 多出前缀 "abc",长度从 6 变成 9。预设容量必须传 int,即 new StringBuilder(m + n)
  • Go 里剩余段的切片方向写反:把 sb.WriteString(word1[i:]) 写成 sb.WriteString(word1[:i]),用 word1 = "abcd"word2 = "pq" 触发,输出 "apbqab"——追加的是已经用过的前缀而不是未用的后缀;镜像用例 word1 = "ab"word2 = "pqrs" 输出 "apbqpq"。长度恰好还是 6,长度自检也拦不下来,只能靠「剩余段必须从 i 开始」这句话本身守住。
  • Go 里把 strings.Builder 按值传进辅助函数后继续写入:例如抽出 func appendRest(sb strings.Builder, s string) string { sb.WriteString(s); return sb.String() } 来处理剩余段,用 word1 = "abcd"word2 = "pq" 触发,运行时 panic: strings: illegal use of non-zero Builder copied by valuestrings.Builder 内部持有指向自身的指针来检测拷贝,非零值一旦按值复制就不能再写,要传只能传 *strings.Builder——这也是本题不必拆函数、把两段都写在主函数里更稳的原因之一。

相似题目

题目 难度 考察点
21. 合并两个有序链表 简单 配对规则从「固定交替」变成「比较值大小」,两个指针不再同步推进;剩余段靠一次指针挂接完成,无需逐个拷贝
88. 合并两个有序数组 简单 要求原地写回 nums1 而非新建缓冲,因此改成从后往前倒序填充以避免覆盖未读数据;剩余段只需补 nums2 一侧
415. 字符串相加 简单 双指针从末位向前对齐而非从首位向后,且要额外维护进位这个跨位状态;缓冲里得到的是逆序结果,返回前需反转