ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

解密犯罪时间算法题:暴力枚举+数字集合校验,三种语言实现

解密犯罪时间算法题:暴力枚举+数字集合校验,三种语言实现

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 不在集合里,所以不合法。

这里有一个实现上的选择:用Set<Character>还是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 time

Python 里最容易出错的是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都能避免这个问题。

另外,如果使用Set<Character>,判断"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, repeat=4)生成所有组合,然后把元组转成字符串,再用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"这四个用例跑一遍。跑通了,你就不会再在这类题上丢分了。等以后遇到类似的“用给定字符集生成下一个合法序列”的题,你也能更快地想到暴力枚举和集合校验这个组合拳。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进