目录

题目描述

984. 不含 AAA 或 BBB 的字符串

题意分析

要构造一个字符串,其中恰好含有 a 个字符 a 和 b 个字符 b,且不出现连续三个相同字符(既不能有 aaa 也不能有 bbb)。答案不唯一,返回任意一个合法解即可。

「恰好」这两个字很关键:不是「至多」,两种字符都必须用完,长度固定为 a + b。

约束是 $0 \le a, b \le 100$,并且题目直接保证答案存在。这一句免掉了无解判断,但它同时也是一个隐含的数量关系——如果某一侧比另一侧多太多(大致超过两倍加二),就一定会被迫出现三连,题目已经把这类输入排除了。

规模极小,长度最多 200,所以时间不是考点,考点在于构造策略是否正确、能否说清楚为什么它不会走进死胡同。

边界要覆盖:某一侧为 0(比如 a = 0、b = 2 时答案只能是 "bb",b = 3 就不可能存在,题目已排除)、两侧相等(可以交替铺开)、以及某一侧恰好是另一侧两倍多一点(每两个多的配一个少的)。

解法:贪心构造

核心思路

最笨的想法是搜索:逐位枚举放 a 还是 b,非法就回溯。长度 200 时状态空间大得离谱,虽然剪枝后能过,但完全没抓住这道题要考的东西。

瓶颈在于「逐位试错」。换个角度看,每一位其实只有两个候选,真正需要判断的只是「这一位该选谁」;如果能找到一个每步都正确、且永远不会让后续陷入死局的选法,就一趟扫描搞定。

观察从最危险的情形入手:什么时候会写出三连?只有当结尾已经是两个相同字符,而下一位又放了同一个字符。所以「结尾两个相同」是一条硬约束——此时下一位没有选择余地,必须放另一种字符。

剩下的情形(结尾两个不同,或者长度不足 2)是自由的,这时该放哪个?直觉是先放剩余数量更多的那一种。理由是:数量多的一方是潜在的麻烦制造者,它越晚消耗、堆在后面的压力越大,最终一定会撞上三连;而优先消耗它,可以让两边的剩余量持续向平衡靠拢,平衡的局面永远是安全的。

把这两条合起来就是整个策略,并且可以总结成一个不变量:每一步做完选择后,已生成的前缀始终合法(无三连),且剩余的 a、b 仍然满足「能构造出合法后缀」的条件。硬约束触发时另一种字符必定还有剩余——因为若它已耗尽,说明之前连续放了两个多数派字符而少数派为 0,这种局面只可能来自题目已排除的无解输入。

实现上不需要真的记录「上一个放了什么、连放了几个」,直接读结果串最后两个字符即可,状态就藏在已生成的答案里。

解题步骤

  • 准备一个可追加的结果容器,容量预留 a + b,避免中途扩容。循环条件是 a > 0 || b > 0,两种字符都用完才停,对应题目的「恰好」。
  • 每轮先给出默认选择:chooseA = (a >= b),即优先消耗剩余更多的一方。相等时倒向 a 只是为了写法确定,倒向 b 同样正确。
  • 接着检查硬约束:若结果长度已达 2 且最后两个字符相同,就必须改选另一种。代码里写成 chooseA = (最后一个字符 == 'b')——最后两个都是 b 时必须放 a,最后两个都是 a 时必须放 b,一句话覆盖两种情况。
  • 硬约束的判断放在默认选择之后,是因为它是覆盖性的:无论默认选了谁,只要结尾出现两个相同字符,结论都被强制改写。顺序反过来写,默认选择就会把强制结果盖掉。
  • chooseA 追加对应字符,并把相应计数减一。计数和追加必须同步,否则剩余量与已写入内容会脱节。
  • 循环结束后把容器转成字符串返回。不需要在最后做任何合法性校验,因为每一步都已保证前缀合法。

a = 4b = 1 走一遍:初始结果为空。第一轮长度为 0,硬约束不触发,4 >= 1a,结果 "a",a 变 3。第二轮长度为 1,仍不足 2,3 >= 1a,结果 "aa",a 变 2。第三轮长度为 2 且最后两个都是 a,硬约束触发,强制选 b,结果 "aab",b 变 0。第四轮最后两个是 ab 不相同,默认 2 >= 0a,结果 "aaba",a 变 1。第五轮最后两个是 ba 不相同,默认 1 >= 0a,结果 "aabaa",a 变 0。此时 a 和 b 都为 0,循环结束,返回 "aabaa"。检查一下:四个 a、一个 b 数量正确,最长连续同字符是 "aa",没有三连,合法。

代码实现

class Solution {
    // 若结果末尾两个字符已经相同,下一位必须放另一种字符,这是保证合法性的硬约束。
    public String strWithout3a3b(int a, int b) {
        StringBuilder result = new StringBuilder(a + b);
        while (a > 0 || b > 0) {
            int length = result.length();
            boolean chooseA = a >= b;
            if (length >= 2 && result.charAt(length - 1) == result.charAt(length - 2)) {
                chooseA = result.charAt(length - 1) == 'b';
            }

            if (chooseA) {
                result.append('a');
                a--;
            } else {
                result.append('b');
                b--;
            }
        }

        return result.toString();
    }
}
func strWithout3a3b(a int, b int) string {
    // 若结果末尾两个字符已经相同,下一位必须放另一种字符,这是保证合法性的硬约束。
    result := make([]byte, 0, a+b)
    for a > 0 || b > 0 {
        length := len(result)
        chooseA := a >= b
        if length >= 2 && result[length-1] == result[length-2] {
            chooseA = result[length-1] == 'b'
        }

        if chooseA {
            result = append(result, 'a')
            a--
        } else {
            result = append(result, 'b')
            b--
        }
    }

    return string(result)
}

复杂度分析

  • 时间复杂度:$O(a + b)$,每轮循环恰好写出一个字符,循环次数等于结果长度,每轮只做常数次比较。
  • 空间复杂度:$O(1)$,不计返回的结果串时只用到两个计数器和两次字符读取;结果串本身是必须的输出,长度为 a + b。

关键点总结

  • 构造类题目先找「硬约束」再找「软偏好」:硬约束是不满足就直接非法的规则,必须最后判定且具有覆盖性;软偏好只在自由时生效。这个分层能把大量分支压成两行。
  • 「优先消耗剩余更多的一方」是资源分配型贪心的通用直觉,其正确性来自「让剩余量趋于平衡,而平衡局面永远安全」,答题时要把这句理由讲出来,而不是只说「这样比较好」。
  • 状态可以藏在已生成的答案里:直接读结果串末尾两个字符,比另外维护「上一个字符是什么、连续了几次」更少出错,也少两个需要同步的变量。
  • 题目写「保证答案存在」时,要能主动说出这个保证等价于什么数量关系(多的一方不超过少的一方的两倍加二),否则面试官会怀疑你只是在碰运气。
  • 面试视角:这题几乎一定会被追问「为什么贪心是对的」和「不加保证时如何判无解」,能答上这两问比写出代码更重要;再追问就是 1405. 最长快乐字符串,那题扩到三种字符且要求最长,用堆维护剩余最多者即可。

易错点总结

  • 错误写法:把硬约束判断写在默认选择之前 → a = 4、b = 1 时第三轮本该被强制放 b,却又被后面的 a >= b 改回 a,写出 "aaa",直接违规。
  • 错误写法:默认选择写成「优先放剩余更少的一方」 → a = 4、b = 1 时会先放 b 用光少数派,之后只剩四个 a 且无处可插,必然出现 "aaa"。
  • 错误写法:只判断最后一个字符与待放字符是否相同就切换 → 退化成严格交替,a = 4、b = 1 时最多写出 "abab" 就无字符可交替,剩下的 a 放不下,得不到合法解。
  • 错误写法:循环条件写成 a > 0 && b > 0 → 少数派先用完后循环立刻结束,a = 4、b = 1 时只返回 "aab",长度不足,没满足「恰好用完」。
  • 错误写法:判断末尾时不检查长度是否已达 2,直接访问 result[length - 2] → 结果串还只有 0 或 1 个字符时下标为负,越界。
  • 错误写法:追加字符后忘记减对应计数,或者减错了那一个 → 循环永远不结束,或者两种字符的数量与题目要求对不上。
  • 错误写法:用 String 做拼接而不是 StringBuilder/字节切片 → 每次拼接都复制整串,长度 200 虽然不至于超时,但读末尾字符和追加都变成 $O(n)$,写法上直接失分。
  • 错误写法:写完后再补一段「如果发现三连就回头修补」的逻辑 → 修补会破坏前面已经满足的数量约束,且很难证明修补总能成功;正确的做法是让每一步都不产生三连。

相似题目

题目 难度 考察点
767. 重构字符串 中等 任意多种字符且要求相邻不同,需先判无解
1405. 最长快乐字符串 中等 三种字符且求最长,用堆挑剩余最多者
621. 任务调度器 中等 只求总长度,可用最高频任务直接推公式