LeetCode 1768. 交替合并字符串
题目描述
题意分析
题目给两个只含小写字母的字符串
word1与word2,要求把它们交替合成一个新串:先取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 < m与i < 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,所以可以在创建缓冲时就把容量设满,让底层数组一次分配到位,连扩容时的搬迁拷贝也一并省掉。这一步不改变复杂度量级,但它是面试里的加分项——它说明你知道缓冲区会扩容,也知道这里的最终长度是可以提前算出来的。
解题步骤
- 取出两串长度
m与n。为什么:后面的循环上界与剩余段的切分点都由这两个值决定,先取出来能避免在循环里反复调用长度方法,也让「两段式」的边界min(m, n)一眼可见。- 创建可变缓冲,容量直接设为
m + n。为什么:追加次数是m + n,用可变缓冲把每次追加降到均摊 $O(1)$;容量一次给满则连中途扩容的数组拷贝都省掉,因为答案长度是提前可知的。- 令
i = 0,进入主循环,条件为i < m && i < n。为什么:用&&而不是||,把「两串都还有字符」这个前提提到循环条件里,循环体内就不必再做任何越界检查。- 循环体内先追加
word1[i]、再追加word2[i],然后i++。为什么:顺序必须是「先word1后word2」,这是题目钉死的要求;成对追加保证每一轮结束时缓冲长度都是偶数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 = 2、n = 4,缓冲容量设为6,i = 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 = 2,2 < 2不成立,主循环退出,交替段定格为"apbq"。进入收尾:i < m即2 < 2为假,跳过;i < n即2 < 4为真,把word2下标2到末尾的后缀"rs"整段追加,缓冲变为"apbqrs"。返回"apbqrs",长度6等于m + n,与官方样例一致。再以
word1更长的word1 = "abcd"、word2 = "pq"走一遍:m = 4、n = 2,容量6,i = 0。第一轮追加'a'、'p',缓冲"ap",i = 1;第二轮追加'b'、'q',缓冲"apbq",i = 2;第三次检查时2 < 4成立但2 < 2不成立,&&短路,主循环退出。收尾时i < m即2 < 4为真,把word1下标2到末尾的后缀"cd"整段追加,缓冲变为"apbqcd";i < n即2 < 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)$,其中 m、n分别为两串长度。主循环执行 $\min(m, n)$ 轮、每轮做两次常数时间的追加,收尾时整段追加剩余的 $m - n $ 个字符,每个输入字符恰好被读取并写入一次,合计 $m + n$ 次均摊 $O(1)$ 的操作;由于容量已按 m + n预设,底层数组不会触发扩容,也就不存在扩容时把已有内容整体搬迁的重复拷贝,均摊边界因此是真实成立的常数而非被扩容摊薄的结果。- 空间复杂度:$O(m + n)$,即输出缓冲本身占用的空间,这是返回一个长度为
m + n的新字符串所无法避免的下界;除此之外只用了m、n、i三个整型变量,额外空间是 $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 = 2、m - 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 value。strings.Builder内部持有指向自身的指针来检测拷贝,非零值一旦按值复制就不能再写,要传只能传*strings.Builder——这也是本题不必拆函数、把两段都写在主函数里更稳的原因之一。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 21. 合并两个有序链表 | 简单 | 配对规则从「固定交替」变成「比较值大小」,两个指针不再同步推进;剩余段靠一次指针挂接完成,无需逐个拷贝 |
| 88. 合并两个有序数组 | 简单 | 要求原地写回 nums1 而非新建缓冲,因此改成从后往前倒序填充以避免覆盖未读数据;剩余段只需补 nums2 一侧 |
| 415. 字符串相加 | 简单 | 双指针从末位向前对齐而非从首位向后,且要额外维护进位这个跨位状态;缓冲里得到的是逆序结果,返回前需反转 |