构造限制重复的字符串 图解题解
这道题到底在问什么
- 输入
- s="cczazcc", repeatLimit=3
- 输出
- "zzcccac"
- 输入
- s="aababab", repeatLimit=2
- 输出
- "bbabaa"
先想最直接的笨办法
先数一遍 s 里每个字母出现几次:z 两个、c 四个、a 一个。右边这张小表就是词频桶,从大到小排好 z、c、a,记着各自还剩几个。左边七个方格是答案的位置,现在全空,我们要从最大的字母开始,一个一个把它们填满。(动画第 4 步)
最优解:一步一步想明白
- 3记住这两句:先摆最大、一段至多 repeatLimit 个;放不下还剩就垫一个更小的字母隔开再续。下面一步步套用它。
- 4先数一遍 s 里每个字母出现几次:z 两个、c 四个、a 一个。右边这张小表就是词频桶,从大到小排好 z、c、a,记着各自还剩几个。左边七个方格是答案的位置,现在全空,我们要从最大的字母开始,一个一个把它们填满。
- 5为什么从最大字母下手?比较字典序是从左往右逐位看,谁在越靠前的位上放了更大的字母,谁就更大。所以贪心策略很直接:每一步都在还没放满的最靠前位置,填进当前还有剩的最大字母。此刻最大的是 z,就从 z 开始。
- 6当前最大字母是 z。一段最多连放 repeatLimit 也就是 3 个,而 z 手头只有 2 个,取两者里小的,这一段就放 2 个 z。因为 2 没到 3,放完 z 正好用光,不需要隔板。开始往前两个空位填 z。
- 7第一个 z 落到位置 0,这是整串的最高位,放上了最大的字母,开局就把字典序顶到最高。右边桶里 z 的剩余从 2 减到 1。这一段目前连续 1 个 z,绿色标出来的就是当前的连续段。
- 8第二个 z 落到位置 1,z 的剩余减到 0,用光了。现在开头是连续两个 z,长度 2,没有超过限制 3,合法。绿色这段清楚地显示了这个连续的 z 段。z 既然摆完,下一步该找比它小的、还有剩的最大字母。
- 9回头核对一下这段 z:连续 2 个,规则允许至多 3 个,稳稳合法。答案前两位已经锁定成 z z,这是任何合法串在这两位上能给到的最大字母了。z 用完,接下来看谁是还有剩的最大字母。
- 10词频桶里 z 已经是 0,跳过它。往下看,c 还剩 4 个,是现在最大的可用字母。于是把当前处理对象切到 c。注意 c 有 4 个、限制才 3,这里就要遇到本题真正的看点:一段接不完,得想办法续。
- 11轮到 c。一段最多连放 3 个,而 c 有 4 个,取小的,这一段先放 3 个 c。放的时候盯着:一旦连续凑满 3 个,就不能再直接接第 4 个 c 了。先把这 3 个 c 填进接下来的三个空位。
- 12第一个 c 落到位置 2,c 的剩余从 4 减到 3。当前这段连续 c 的长度是 1。这一位为什么不接着放 z?因为 z 已经没有了,c 是现在能拿到的最大字母,放 c 就是这一位的最优选择。
- 13第二个 c 落到位置 3,c 剩余减到 2。绿色的连续 c 段长度变成 2,还没到上限 3,可以再接一个。继续放第三个 c。
- 14第三个 c 落到位置 4,连续 c 的长度到了 3,正好顶到上限。可 c 还剩 1 个没放。麻烦来了:下一位要是再放 c,连续就变成 4 个,超过限制 3,整串作废。所以这一位绝对不能再接 c。
- 15设想一下硬接第四个 c:当前这段 c 会连成连续 4 个 c,直接违反至多 3 个的规则,这个串就不合法了。正确做法是先在中间垫一个比 c 小的字母当隔板,把连续的 c 断成两段,之后才能再接 c。下面去找这个隔板。
- 16隔板不能随便选,为了尽量不拉低字典序,要挑比 c 小的字母里、当前还有剩的、最大的那一个。比 c 小的依次是 b、a。b 一个都没有,跳过;a 还剩 1 个,就用 a 当隔板。挑到了。
- 17词频桶里 b 是 0,直接跳过;a 是 1,选中它。这里只放 1 个 a 就够了,隔板的作用是断开连续,用一个就能把前面的 c 段和后面要接的 c 段隔开,多放 a 反而挤占了后面本可以放 c 的位置,不划算。
- 18把 a 放到位置 5,a 的剩余减到 0,用光了。现在答案末尾是 a,已经不是 c 了,连续的 c 被成功断开。绿色的当前段只剩这一个 a。隔板铺好,可以回头继续处理还剩 1 个的 c 了。
- 19关键就在这一下:末尾变成了 a,再往后放 c,这个 c 前面紧挨的是 a 而不是 c,连续计数重新从 1 开始,不会触犯规则。隔板的价值就是这个断点。回到 c,看它还剩多少、这一段能放几个。
- 20继续处理 c。它现在只剩 1 个,一段最多能放 3 个,取小的,这一段就放 1 个 c。放完 c 也就用光了。填到最后一个空位。
- 21最后一个 c 落到位置 6,c 的剩余减到 0,也用光了。这一位的 c 紧挨着前面的 a,是新一段连续,长度只有 1,合法。七个位置全部填满,答案已经成形。
- 22再看词频桶,z、c、a 的剩余全是 0,没有字母可放了,构造到此结束。七个位置从左到右读出来就是 zzcccac。因为每一步都在最靠前的位置放了当前能放的最大字母,整串的字典序就是最大的。
- 23回放一遍最终串 zzcccac。逐段数连续长度:开头 z z 是 2,接着 c c c 是 3,然后 a 是 1,最后 c 是 1,每一段都不超过限制 3,完全合法。而且高位始终压着最大字母,所以它就是字典序最大的那个合法串,正是答案。
⚠️ 容易写错的地方
✗ 错:一段放满就直接换下一个更小的字母
✓ 对:放满后若还有剩,垫一个隔板再回来接着放同一个字母
大字母没用完就换走会让它剩在手里、拉低字典序,正确是垫隔板续放,把大字母尽量用足
✗ 错:隔板一次垫好几个小字母
✓ 对:只垫 1 个就够断开连续
隔板多放会挤占后面本可以放大字母的位置,反而变小,断开连续用一个字母足矣
✗ 错:隔板随便挑一个更小的字母
✓ 对:挑比它小的里当前最大且还有剩的
隔板本身也占一位,选尽量大的小字母才少拉低字典序,还要保证它确实有剩
✗ 错:更小的字母用光了还硬凑
✓ 对:找不到隔板就舍弃当前字母的剩余
题目允许不用完所有字符,没有隔板时再接同一个字母必然超限,只能放弃剩下的
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
from string import *
from operator import *
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def repeatLimitedString(self, s: str, repeatLimit: int) -> str:
cnt = [0] * 26
for c in s:
cnt[ord(c) - ord("a")] += 1
ans = []
j = 24
for i in range(25, -1, -1):
j = min(i - 1, j)
while 1:
x = min(repeatLimit, cnt[i])
cnt[i] -= x
ans.append(ascii_lowercase[i] * x)
if cnt[i] == 0:
break
while j >= 0 and cnt[j] == 0:
j -= 1
if j < 0:
break
cnt[j] -= 1
ans.append(ascii_lowercase[j])
return "".join(ans)C++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <optional>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode() : val(0), left(nullptr), right(nullptr) {}
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};
struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
class Solution {
public:
string repeatLimitedString(string s, int repeatLimit) {
int cnt[26]{};
for (char& c : s) {
++cnt[c - 'a'];
}
string ans;
for (int i = 25, j = 24; ~i; --i) {
j = min(j, i - 1);
while (1) {
for (int k = min(cnt[i], repeatLimit); k; --k) {
ans += 'a' + i;
--cnt[i];
}
if (cnt[i] == 0) {
break;
}
while (j >= 0 && cnt[j] == 0) {
--j;
}
if (j < 0) {
break;
}
ans += 'a' + j;
--cnt[j];
}
}
return ans;
}
};Java
import java.util.*;
class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(){} TreeNode(int val){this.val=val;} TreeNode(int val, TreeNode left, TreeNode right){this.val=val;this.left=left;this.right=right;} }
class ListNode { int val; ListNode next; ListNode(){} ListNode(int val){this.val=val;} ListNode(int val, ListNode next){this.val=val;this.next=next;} }
class Solution {
public String repeatLimitedString(String s, int repeatLimit) {
int[] cnt = new int[26];
for (int i = 0; i < s.length(); ++i) {
++cnt[s.charAt(i) - 'a'];
}
StringBuilder ans = new StringBuilder();
for (int i = 25, j = 24; i >= 0; --i) {
j = Math.min(j, i - 1);
while (true) {
for (int k = Math.min(cnt[i], repeatLimit); k > 0; --k) {
ans.append((char) ('a' + i));
--cnt[i];
}
if (cnt[i] == 0) {
break;
}
while (j >= 0 && cnt[j] == 0) {
--j;
}
if (j < 0) {
break;
}
ans.append((char) ('a' + j));
--cnt[j];
}
}
return ans.toString();
}
}复杂度
时间
O(n + 26)
n 是 s 的长度。统计词频扫一遍 s 是 O(n);主体里下标 i 从 25 到 0 单调递减、隔板指针 j 也只会单调下降,两个指针各自最多走 26 步,放字符的总次数不超过 n。合起来是线性的 O(n),字母集大小 26 是常数
空间
O(26)
按峰值算。只用了一个长度 26 的计数数组和两个指针,与 n 无关,是常数级。答案串不计入额外空间
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 构造限制重复的字符串 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
这题的贪心策略一句话怎么说?+
从最大的字母往小处理,每段最多连放 repeatLimit 个;一段放满还有剩就插一个比它小的、当前最大且还有剩的字母当隔板,断开连续后回来续放;更小的字母都用光就舍弃剩余。这样每一位都放了当前能放的最大字母,得到字典序最大的合法串。
为什么这样贪心是对的,不会错过更大的解?+
字典序比较是从左往右逐位定胜负,只要能在越靠前的位放越大的字母,整体一定更大。每一步我们都在最靠前的空位放当前可放的最大字母,隔板也只在被迫时才用、且选尽量大的,所以没有哪一位可以换成更大的字母,局部最优拼出的就是全局最优。
找隔板那个指针为什么可以只往一个方向走?+
隔板指针 j 指向比当前字母小的候选,随着外层 i 从大往小走,可用的更小字母范围只会收窄不会变宽,而且一个小字母被垫掉后不会再变多,所以 j 全程单调下降、不回头。正因如此,统计之外整个过程是线性的。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄声线把 构造限制重复的字符串 一步步讲透(全站 0 节语音精讲,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。