大家好,我是一个在代码世界里摸爬滚打了N年的老兵。今天想和大家聊聊最近在项目中遇到的一个棘手问题,以及我是如何用一个看似“学院派”的算法——广度优先搜索(BFS)——漂亮地解决它的。这趟旅程有“踩坑”的窘迫,也有“恍然大悟”的喜悦,希望能给同在路上的你带来一些启发。
我所在的团队正在开发一款面向设计师的创意软件。为了提升用户体验,我们想加一个“智能宏推荐”功能。啥意思呢?就是软件在后台默默记录用户的操作流,比如一长串这样的记录:
s = "复制图层-粘贴图层-调整透明度-选择画笔-复制图层-粘贴图层-调整透明度-删除图层"
如果用户反复进行了某套固定的操作(比如 复制图层-粘贴图层-调整透明度
出现了两次),我们就弹出一个提示:“嗨,我们发现您经常进行这套操作,要不要为您创建一个一键宏?”
听起来很酷,对吧?但魔鬼藏在细节里。需求很快就明确了:
k
):一个操作序列至少要重复 k
次(比如 k=2
),我们才认为它是个“习惯”,值得推荐。"复制-粘贴"
重复了3次,而 "复制-粘贴-调整"
重复了2次,我们显然应该推荐后者,因为它更具体,更有价值。所以,我们要找最长的那个操作序列。"选择A-应用B"
和 "选择C-应用D"
,长度一样,都重复了2次。我们总得有个标准来决定推荐哪个吧?产品经理一拍脑袋:“按字母表顺序,推荐那个看起来‘更大气’的!” —— 好了,这就是程序员黑话里的“字典序最大”。这不就是 LeetCode 上的那道题吗!
2014. 重复 K 次的最长子序列
给你一个字符串s
和一个整数k
。返回s
中最长重复k
次的子序列。如果存在多个答案,则返回字典序最大的那一个。如果不存在这样的子序列,则返回一个空字符串。
我的任务,就是写出这个推荐引擎的核心 findBestMacro(s, k)
函数。
一开始,我的思路非常直接:贪心!
“要想字典序最大,我每一步都选当前能选的、字典序最大的字符,不就行了?”
这个想法太诱人了,就像沙漠里看到了绿洲。我的贪心策略是这样的:
result
开始。'z'
到 'a'
添加字符到 result
后面。c
,就生成一个新的候选 newResult = result + c
。newResult
是否仍然满足“重复 k
次是 s
的子序列”。result
更新为 newResult
,然后马上开始下一轮的添加。我兴冲冲地写了代码,拿 s = "letsleetcode", k = 2
测试。
't'
(因为在 e,l,t
中 t
最大)。"t"
重复2次是 "tt"
,是 s
的子序列。成功!当前结果 result = "t"
。"t"
后面加字符。尝试加 'e'
,得到 "te"
。"te"
重复2次是 "tete"
,也是 s
的子序列。成功!当前结果 result = "te"
。"te"
后面加字符。尝试遍了,"tet"
, "tel"
, "tee"
都不行了。最终输出: "te"
。
我把结果给测试一看,他甩给我一个预期结果:"let"
。
"let"
重复两次是 "letlet"
,确实是 s
(letsletcode
) 的子序列,而且长度为3,比我的 "te"
(长度2) 要长!
我当场石化…
恍然大悟的瞬间
贪心算法在这里彻底失败了。因为它只顾眼前利益(每一步都选字典序最大的),导致它早早地走上了一条 “看起来很美” 的死胡同(
't'
开头的路)。它为了抓住't'
这个局部最优解,放弃了'l'
开头的、更有潜力的路径,最终错过了全局最优解"let"
。结论:此路不通! 我们需要一个能系统性地探索所有可能性,而不会被局部最优解“带偏”的办法。
这时候,我的脑海里冒出了一个词:广度优先搜索(BFS)。
BFS 就像一个极其严谨的寻宝队。它不会一头扎进一个山洞就走到黑,而是先把离入口距离为1的所有地方都探一遍,再把距离为2的所有地方探一遍… 这样下去,它找到宝藏时,一定能保证这条路径是最短的。
在这里,我们稍微变通一下:
BFS 策略如下:
""
放进去,作为我们搜索的起点。curr
。curr
后面追加每一个可能的字符 c
,形成新的序列 next = curr + c
。next
,我们用一个“验证器”函数 isKRepeatedSubsequence
检查它是否合法(即重复k次后仍是 s
的子序列)。next
加入队列,供下一轮探索,并更新我们的最终答案。'z'
到 'a'
倒序追加。这样,同一长度的合法序列中,字典序大的会先被我们发现并记录下来。由于 BFS 的特性,最后一个被记录下来的答案,一定是最长的序列中,字典序最大的那一个!这套组合拳,完美解决了“最长”和“字典序最大”两个核心矛盾。
下面就是我们 BFS 寻宝之旅的最终代码,我加了详细的注释,就像在和你结对编程一样。
import java.util.*;
class Solution {
public String longestSubsequenceRepeatedK(String s, int k) {
// 最终答案,初始化为空字符串
String ans = "";
// BFS 核心:队列,存储待检查的候选序列
Queue<String> queue = new LinkedList<>();
// 我们的寻宝之旅从一个空的地方(空字符串)开始
queue.offer("");
// 1. 预处理:找出“热门”字符
// 只有在 s 中出现次数 >= k 的字符,才有可能构成答案的一部分
int[] freq = new int[26];
for (char c : s.toCharArray()) {
freq[c - 'a']++;
}
StringBuilder possibleChars = new StringBuilder();
for (int i = 0; i < 26; i++) {
if (freq[i] >= k) {
possibleChars.append((char) ('a' + i));
}
}
String candidates = possibleChars.toString();
// 2. BFS 主循环
while (!queue.isEmpty()) {
// 取出当前层的候选序列
String current = queue.poll();
// 3. 尝试扩展:从 'z' 到 'a' 倒序尝试
// 为了满足“字典序最大”的要求,我们优先尝试更大的字符
for (int i = candidates.length() - 1; i >= 0; i--) {
char c = candidates.charAt(i);
String next = current + c;
// 4. 验证新序列的合法性
if (isKRepeatedSubsequence(next, s, k)) {
// 如果合法,它就是一个更好的答案(因为更长,或者一样长但字典序更大)
ans = next;
// 把它加入队列,作为下一轮探索的基础
queue.offer(next);
}
}
}
return ans;
}
/**
* 验证器:检查 seq 重复 k 次后,是否是 s 的子序列
* 这是我们整个算法的基石,也是性能关键点
* @param seq 候选序列 (e.g., "let")
* @param s 原始字符串 (e.g., "letsleetcode")
* @param k 重复次数 (e.g., 2)
* @return true 如果 "letlet" 是 "letsleetcode" 的子序列
*/
private boolean isKRepeatedSubsequence(String seq, String s, int k) {
// 边界情况:空序列永远是合法的
if (seq.isEmpty()) {
return true;
}
// 剪枝优化:如果目标长度超过原串,直接返回 false
if (seq.length() * k > s.length()) {
return false;
}
// 构造目标串,比如 seq="let", k=2 -> target="letlet"
StringBuilder targetBuilder = new StringBuilder();
for (int i = 0; i < k; i++) {
targetBuilder.append(seq);
}
String target = targetBuilder.toString();
// 双指针法判断子序列
int i = 0; // 指向 target
int j = 0; // 指向 s
while (i < target.length() && j < s.length()) {
// 在 s 中找到了 target 当前需要的字符
if (target.charAt(i) == s.charAt(j)) {
i++; // 匹配下一个 target 字符
}
j++; // 无论如何,s 的指针都要前进
}
// 如果 i 走完了整个 target,说明匹配成功
return i == target.length();
}
}
isKRepeatedSubsequence
函数详解:
这个函数是我们的“真伪鉴定师”。它使用经典的双指针技巧。想象一下,你拿着目标序列 target
的清单,在原始字符串 s
上从头走到尾。
i
指针是你清单上需要打勾的下一个物品。j
指针是你正在 s
这条商业街上检查的店铺。s[j]
就是你想要的 target[i]
,太好了!i
指针前进,准备找下一个物品。j
指针永远在前进。i
走到了尽头),那么你就成功了!n == s.length
2 <= k <= 2000
2 <= n < k * 8
s 由小写英文字母组成
其中 n < k * 8
是至关重要的突破口。我们来分析一下: 假设我们找到了答案 seq
,其长度为 L
。那么 seq*k
的长度就是 L * k
。 因为 seq*k
是 s
的子序列,所以 seq*k
的长度必然小于等于 s
的长度 n
。 即 L * k <= n
。 由这个不等式,我们可以推导出 L <= n / k
。 再结合题目给的提示 n < k * 8
,我们可以得到 n / k < 8
。 所以,L < 8
。 这意味着我们要找的 最长子序列 seq
的长度最多是 7!
恍然大悟 :这个看似困难的问题,其解的空间被这个提示极大地压缩了。我们不需要在庞大的 s
上做什么复杂的动态规划,而是应该去 搜索 那个长度不超过7的答案 seq
本身。
我们的搜索策略应该遵循题目的双重标准:
综合起来,我们的目标是构建一个最长、字典序最大的 seq
。
掌握了这种“BFS+状态生成”的思维模型后,你会发现它能解决很多问题:
从一个实际的产品需求,到一个错误的贪心尝试,再到最终通过 BFS 找到优雅的解决方案,这个过程不仅仅是写出几行代码那么简单。它体现了算法思维如何帮助我们剖析问题、识别陷阱,并选择最合适的工具来构建健壮、高效的系统。
希望我的这次“寻宝”经历能对你有所帮助。记住,当遇到需要找到“最优”路径或组合的问题时,别忘了你的工具箱里还有 BFS 这个强大而可靠的朋友!
祝大家编码愉快,少踩坑,多“Aha!”!