1. 先搞懂“解密犯罪时间”在问什么第一次见到“解密犯罪时间”这个题目名我心里先是一紧以为要做推理、查日志、还原作案过程。结果把题面看完就笑了这不就是“用原时间里的数字重新拼出一个距离当前时间最近的下一个合法时间”嘛。Java、JS、Python 三种语言都能写而且只要思路对拿满 100 分一点都不夸张。这道题在算法能力测试里算是典型的中等偏下难度考的是三件事字符串处理、枚举思路、边界情况。很适合用来检验基础编程能力也是我面试候选人时常挑出来让对面现场写的题。因为它能用最简单的方式暴露一个人会不会处理“下一步时间”这类循环问题。再说得直白一点输入是一个合法时间比如19:35里面出现的数字是 1、9、3、5。允许你重复使用这四个数字去组成一个新的时间比如19:53、15:15、11:11都可以。但是有个硬性要求新时间必须合法也就是小时在 00 到 23 之间分钟在 00 到 59 之间并且它必须是“从当前时刻往后看最近的一个合法时间”。如果今天剩下的时间里找不到那就看第二天的凌晨开始往后找。输出还是HH:mm这种格式。这题的难点从来不是“能不能写出来”而是“能不能一次写对”。很多人上来就想用贪心从低位往高位找更大数字结果在进位、补零、跨天的地方反复翻车。我见过不少工作了三五年的人面对这个看似简单的题目依然会在边界上栽跟头。所以我这篇就专门分享一个稳到不行的解法顺便把三种语言的实现差异和容易踩的坑都列清楚。1.1 输入输出与核心约束先明确一下输入输出长什么样输入一个字符串格式固定是HH:mm保证是合法时间。例如12:34。数字池去掉冒号后时间字符串中出现的数字字符集合。注意是“去重后的数字集合”不是“数字的排列组合集合”。比如12:34的数字池是 {1, 2, 3, 4}。允许重复拼新时间时可以重复使用数字池里的数字。也就是说1 出现了几次不重要只要这个数字在数字池里新时间的任何一位都可以用它。输出也是HH:mm格式。例如输入19:34数字池 {1,9,3,4}从 19:35 往后找19:44 合法不一定因为 4 和 4 都在数字池里19:44 确实合法但 19:44 中间没有比 19:34 大且更近的吗实际上 19:39 也可以因为 3 和 9 都在池里。所以答案是19:39。这里有个容易被忽略的点新时间必须严格大于当前时间不能等于当前时间。比如输入22:22数字池是 {2}你当然可以用 2 组成22:22但这不是“之后”的时刻所以不能选它作为答案。如果当天后面没有合法时间比如输入23:59数字池 {2,3,5,9}那从 00:00 到 23:59 之间找第一个合法时间答案是22:22因为 2 和 2 都在池里00:00 也合法0 不在池里不行22:22 是最早的合法时间。这个跨天的处理等会儿会细说。1.2 为什么第一反应要避开贪心可能你以前刷过“下一个更大排列”或者“下一个回文串”第一反应就是贪心从右往左找第一个能变大的位置然后让更低位尽量小。听上去很顺但套到时间上会非常麻烦。举个例子输入23:59数字池 {2,3,5,9}。从右往左看分钟个位是 9找不到比 9 更大的池内数字所以必须向分钟十位进位。分钟十位是 5找比 5 大的池内数字有 9于是把 5 改成 9同时分钟个位取池内最小数字 2。得到23:92显然非法因为分钟最大是 59。于是还要继续往小时位进位小时个位 3 可以变成 9但小时十位 2 变成什么直接贪心很容易得到29:22非法因为小时最大是 23。你看一个简单的进位问题能把“分钟溢出”和“小时溢出”搅在一起。就算你用数学方法做也要单独判断小时后两位是否小于24、分钟后两位是否小于60分支非常多。相比之下暴力枚举整点分钟数最多 1440 种情况不管怎么想都不会错。在面试里能写出“容易验证正确”的解法比写出“看起来高级但容易错”的解法更值钱。2. 最稳做法按分钟暴力推进靠“数字集合校验”既然题目只要求“最近的未来时间”那最朴素也最不容易出错的办法就是从当前时间往后一分钟一分钟地看每一分钟都检查一下这个时间的四个字符是不是都在原数字池里。如果是就直接返回。你可能会担心如果从23:59开始找要找到第二天的某个时间最多找多久其实最多找 1440 次因为一天只有 1440 分钟。哪怕最坏情况找到 23 小时 59 分钟后的“前一天同一时间”不对我们不允许等于当前时间所以最坏是要找满 1439 次然后如果还没找到就说明只有当前时间这一个合法时间此时回退返回当前时间作为兜底。1440 次循环每次检查 4 个字符就算用最慢的 Python 写也是毫秒级完成。完全满足算法题的时间要求。2.1 用分钟数表示时间简化跨天判断跨天是最容易出 bug 的地方。如果直接用字符串23:59加一分钟你要先转成分钟 59加一变成 60再处理进位变成 00:00。手动做字符串进位很容易出错。我建议先把时间转成“当天第几分钟”。规则很简单小时部分乘以 60加上分钟部分得到totalMinutes。然后从totalMinutes 1开始一直到totalMinutes 1439逐个取模1440得到“新的总分钟数”。再把“新的总分钟数”还原成小时和分钟小时是newTotal / 60分钟是newTotal % 60。为什么从1开始因为要找严格大于当前时间的下一个时间不能把当前时间算进去。那为什么最多到1439因为1440又回到了当前时间的下一秒的同一个时间也就是 24 小时后这是一天后的同一时刻严格来说已经不是“下一个”了。所以我们只遍历 1 到 1439。如果循环结束后都没有找到就说明当前时间是唯一合法时间直接返回当前时间作为兜底。我在实战里遇到过这种极端输入后面会讲。用分钟数表示时间还有个好处还原格式时统一用%02d补零天然规避了“9:05”和“09:05”这类格式不一致的问题。2.2 核心校验函数判断一个候选时间是否由原数字组成这一步是整个算法的灵魂。你不需要真的去排列组合只需要做一个很简单的判断候选时间的四个字符是否全部出现在“原数字集合”里。假设原时间是19:35数字集合是{1,9,3,5}。候选时间19:39的四个字符是1、9、3、9其中 9 在集合里所以合法。候选时间19:45中出现了 4但 4 不在集合里所以不合法。这里有一个实现上的选择用SetCharacter还是boolean[]。两种都行。Set写起来直观但每次contains有装箱开销boolean[10]更快但需要把字符转成数字下标。考虑到最多循环 1440 次用Set完全没有性能问题。不过在 Java 里如果追求干净用boolean[]其实也不复杂。我们后面写代码时会展示两种风格。还有一个容易忽略的点判断一个时间合法性时不要光看字符是否在集合里还要保证小时小于 24、分钟小于 60。但因为我们是用“分钟数还原”的方式生成候选时间的小时和分钟天然就在合法范围内。比如还原出来的小时是 24实际上不会出现因为newTotal % 1440的范围是 0 到 1439除以 60 后小时最大是 23。所以这一步天然安全。2.3 边界情况和返回值设计写代码前把边界情况在脑子里过一遍能少踩很多坑。输入00:00数字池是 {0}。从 00:01 开始找任何时间都必须由 0 组成但除了 00:00还有 00:00严格大于的话没有。循环 1439 次找不到合法时间按兜底逻辑返回00:00。这个行为是否合理取决于题目要求。如果题目保证一定存在更晚的合法时间那这个用例不会出现如果不保证兜底返回当前时间至少能让程序不崩溃。输入13:13数字池 {1,3}。从 13:14 开始找13:31? 1、3、3、1 合法且是最近。输出13:31。这里要注意分钟从 14 到 31 中间有 15、16、17、18 等很多数字但 1 和 3 不在池子里所以直接跳过。输入23:59数字池 {2,3,5,9}。从 00:00 开始找00:00 有 0不合法一直找到 22:22 才合法输出22:22。这个用例最考验跨天处理。输入22:22数字池 {2}。从 22:23 开始找因为 3 不在池子里之后所有分钟都有非 2 数字直到 23:59 也找不到跨天后的 00:00 有 0 不合法然后 22:22 又出现了但时间和当前相等因为严格大于那也找不到。所以也走兜底返回22:22。这些边界看起来极端实际测试里很容易出现。尤其是...:13这种数字重复的情况最容易让贪心解法算错。而暴力枚举加兜底逻辑上几乎无懈可击。3. 三种语言的实现对比说实话这类题不存在“某一种语言有绝对优势”的情况。Java、JS、Python 都能很轻松地实现。但每种语言都有一些小细节需要注意尤其是字符串格式化、取余、字符和数字的转换。我把三种语言的完整实现都写出来并标注容易踩的坑。3.1 Java用 Set 或布尔数组都行注意格式化先看 Java 版本。我倾向于把整个逻辑拆成两个方法一个负责找答案一个负责校验候选时间。这样结构清晰也方便测试。public class CrimeTime { public String nextTime(String time) { String[] parts time.split(:); int hour Integer.parseInt(parts[0]); int minute Integer.parseInt(parts[1]); int current hour * 60 minute; // 收集原时间的数字集合 boolean[] digits new boolean[10]; for (char c : time.toCharArray()) { if (c ! :) { digits[c - 0] true; } } // 从下一分钟开始查找最近合法时间 for (int offset 1; offset 1440; offset) { int next (current offset) % 1440; String candidate String.format(%02d:%02d, next / 60, next % 60); if (isValid(candidate, digits)) { return candidate; } } // 兜底只有当前时间本身合法时返回它 return time; } private boolean isValid(String candidate, boolean[] digits) { for (char c : candidate.toCharArray()) { if (c :) continue; if (!digits[c - 0]) return false; } return true; } }这里有几个用 Java 时要注意的点第一String.format在循环里调用 1440 次确实比手工拼接慢但完全在可接受范围内。如果你对性能有执念可以这样构造String candidate String.format(%02d:%02d, next / 60, next % 60)的替代方案是((next/60)10?0:) (next/60) : ...但可读性差。我推荐先用String.format等真正需要优化时再改。在算法题场景下代码可读性比微秒级性能重要得多。第二boolean[]的下标是c - 0。如果c是冒号要提前跳过。你也可以在收集数字时只遍历time.substring(0,2)和time.substring(3)但直接遍历整个字符串并跳过冒号更简洁。第三兜底返回time的逻辑。因为题目如果保证有解且解不可能是当前时间那这个分支永远走不到但万一测试数据里出现00:00或22:22这种极端情况程序不会报错或死循环而是稳定输出一个合法时间。我在现场编码时一定会写这个兜底。3.2 JavaScript字符串补零和取余的细节JavaScript 版本在思路上和 Java 完全一致但有几个语言层面的坑初学者很容易踩。function nextTime(time) { const [h, m] time.split(:).map(Number); const current h * 60 m; const digitSet new Set(time.replace(:, ).split()); for (let offset 1; offset 1440; offset) { const next (current offset) % 1440; const hour Math.floor(next / 60); const minute next % 60; const candidate ${String(hour).padStart(2, 0)}:${String(minute).padStart(2, 0)}; if ([...candidate].every((ch) ch : || digitSet.has(ch))) { return candidate; } } return time; }这里的padStart(2, 0)是 ES2017 的方法很好用但要注意String(hour)必须先把数字转成字符串否则padStart会报错。以前我在面试里见过有人写hour.padStart(...)结果发现hour是数字类型当场愣住。另一个容易错的是取整。JavaScript 没有整数除法next / 60得到的是浮点数所以必须先Math.floor再取小时。如果不取整候选字符串就会变成19.5:30这种离谱格式。这个错误我很常见。另外new Set(time.replace(:,).split())得到的是一个由字符0到9组成的集合。后面判断时candidate里的字符也是字符串类型和集合元素类型一致所以判断没问题。如果你习惯把字符转成数字也可以但要保持两边一致否则digitSet.has(9)和digitSet.has(9)是两回事。3.3 Python代码最短但别滥用生成器Python 写这种题非常舒服代码量甚至可以压到二十几行。但我建议不要为了短而短尤其是any/all里套表达式时可读性会迅速下降。def next_time(time: str) - str: hour, minute map(int, time.split(:)) current hour * 60 minute digits set(time.replace(:, )) for offset in range(1, 1440): nxt (current offset) % 1440 candidate f{nxt // 60:02d}:{nxt % 60:02d} if all(ch : or ch in digits for ch in candidate): return candidate return timePython 里最容易出错的是all生成器里忘记跳过冒号。虽然冒号本身不在数字集合里但 candidate 字符串里必然有冒号不跳过的话所有候选时间都会被判为非法最终掉进兜底分支。我第一次写的时候就在这卡了十分钟后来加了个ch : or才通过。另外f{nxt // 60:02d}的格式化虽然简洁但nxt // 60必须确保是整数除法。在 Python 3 里/是浮点除法//才是整除千万别写错。如果你用 Python 2 写/还是整除那又是另一套规则但现在主流都用 Python 3这里就不展开。三种语言对比下来你会发现核心逻辑完全一样。这也验证了这类题考的是算法思维而不是特定语言的 API 熟练度。只要把“分钟数推进 数字集合校验”这个框架记住换成什么语言都只是语法差异。4. 踩过的坑和排查清单这部分是我真正想分享的。下面这些坑不是从题解里看来的而是我在不同场合写这道题时亲测出来的有的还和解法无关纯粹是语言细节。4.1 首位 0 被吞掉如果输入是09:30直接转成分钟数后是9*60 30 570。在处理候选时间时如果小时是 9还原成字符串时要补成09而不是9。很多人在 Java 里用String.valueOf(hour)就会得到9:30然后拿去和原时间比或者作为返回值格式不符合要求。用String.format或padStart都能避免这个问题。另外如果使用SetCharacter判断09:39时字符是0、9、3、9没有问题。但如果你把时间字符串转成整数再比较比如int parseIntCandidate Integer.parseInt(candidate.replace(:,))那么09:39会变成939这个数字反而可能被误认为合法。所以建议始终用字符串级别判断而不是整数级别。4.2 等值时间和跨天问题我在第一次做这道题时用的是“从当前时间开始找包含当前时间”结果输入22:22时输出22:22和预期答案不一样。这就是没搞清“严格大于”的含义。其实题目里通常会说“下一个时间”言下之意就是不能等于当前时间。所以循环起点应该是current 1而不是current。跨天的处理如果不用取模很容易写成while (true)一直加直到某个时间合法。但这样有死循环风险而且无法判断“是不是绕了一圈回到原点”。用固定偏移量offset从 1 到 1439天然限定了搜索范围是最稳的做法。如果你猛地一看觉得for offset in range(1, 1440)很笨请记住在算法竞赛里1440 这个常数小到可以忽略而正确性是第一位的。4.3 重复数字与组合枚举的陷阱如果你不想用“按分钟推进”而是想用“生成所有四位组合”的思路那要小心去重。例如输入13:11数字集合 {1,3}允许重复那么组合数量是 2^4 16但其中很多重复比如1111和1111其实完全一样。如果不用Set去重排序后可能会把同一个时间列两次然后在选下一个时出错。我见过有人用itertools.product(digits, repeat4)生成所有组合然后把元组转成字符串再用set去重最后排序。这个思路本身没问题但要注意排序时不能直接用字符串排序因为112这种没有冒号的字符串和11:12的字典序不完全等价。正确做法是先把组合转成合法时间格式再用时间比较或者直接存成分钟数。相比之下按分钟推进的方法根本不需要处理排列去重的烦恼。4.4 复杂度要不要纠结这道题的输入规模很小数字池最多 4 个不同数字一天最多 1440 分钟。所以无论你是 O(1440) 的推进法还是 O(K^4) 的组合法都没问题。真正的复杂度瓶颈其实在字符串格式化上但就算用String.format循环 1440 次也远小于 1 毫秒。所以这道题不需要优化复杂度反而要小心“过度优化”引入 bug。如果面试官非要问“你能不能做到 O(1)”你可以说有贪心算法但那不是现场快速写对的场景。更好的回答是这题的数据范围决定了 O(1) 和 O(1440) 在实际运行时间上没有差别而 O(1440) 的代码更容易验证正确性。大多数面试官会认可这个回答。5. 进一步扩展如果题目变种怎么办这道题看起来简单但它代表了一类“在受限字符集下找字典序/时间序下一个合法值”的问题。理解了它很多变种都能轻松应对。5.1 从“时间”变成“任意长度密码”假设题目改成给定一个长度为 N 的字符串里面只有 0-9 的数字你可以用这些数字组成任意 N 位密码要求密码大于原密码且最短类似思路。那时候我们不能用分钟数了而是要按位推进或者用组合枚举。如果 N 很大枚举就不行了得用更高级的贪心或二分。但核心思想仍然是把“下一个”的问题转化成“在有限候选集里找刚好大于当前值的那一个”。5.2 从“最近”变成“第K近”如果让你返回“从当前时间往后数第 K 个合法时间”暴力法依然有效从current 1开始找到一个合法时间就计数加一直到计数等于 K。最多循环 K*1440 次如果 K 很小完全没有压力。另一种做法是把所有合法时间预先算出来存成数组然后二分查找当前时间的位置再往后数 K 个。这个思路同样可以应用到这道题上只是没必要。5.3 从“存在解”变成“构造最小解”如果题目不是找“下一个”而是直接让“用数字池构造一个最小的合法时间且时间要大于当前时间”那情况又不同。这时候其实等价于找所有合法时间中大于当前时间且最小值可以先把数字池排序然后从小到大枚举组合找到第一个满足大于当前时间的组合即可。本质上就是“字典序下一个”问题。如果你掌握了按位贪心这个变种也能做但依然要处理小时和分钟的溢出复杂度不会比暴力法低太多。我个人在实际编码里还是倾向于先写一个“慢但正确”的版本再根据需求做优化。因为面试或考试里最致命的是交上去一个“看似快但边界错了”的答案。尤其是这类带时间跨度的题目暴力解法的正确性一目了然反而最容易拿分。如果你现在准备去参加算法测试我建议你把这三种语言的版本都亲手敲一遍然后把00:00、23:59、13:13、22:22这四个用例跑一遍。跑通了你就不会再在这类题上丢分了。等以后遇到类似的“用给定字符集生成下一个合法序列”的题你也能更快地想到暴力枚举和集合校验这个组合拳。