最近在刷 LintCode 的时候,遇到一道很有意思的题:下一个最近的时间。题名看着像送分题——不就是把时间加一分钟嘛?真正动手用 Java 实现的时候才发现,里面至少藏了两个容易踩的细节。我把题目先丢给 Qwen3-Max 做了个预分析,它给出的整体方向确实没错,不外乎暴力枚举和贪心替换两条路,但等到我自己写完代码、跑完边界用例,才意识到这类题的最大难点从来不在算法本身,而在对“下一个”和“最近”这两个词的理解是否严谨。这篇文章打算把题目拆透,给出两种可复现的 Java 解法,并把我在调试中踩过的坑一并交代清楚。不管你是刚开始刷题的初学者,还是准备面试想快速找到最优解的老手,都可以对照着抄作业。
1. 题目拆解:别急着写代码,先把规则嚼碎
1.1 题目到底在要求什么
题目给一个形如HH:MM的时间字符串,比如"19:34"。要求你从这 4 个数字字符里提取出可用的数字集合:{1, 9, 3, 4},然后用这些数字重新组成下一个有效时间。
这里的“有效时间”是 24 小时制:小时部分在00到23之间,分钟部分在00到59之间。返回的不是随便一个有效时间,而是“下一个最近的时间”。
我一开始以为答案就是把分钟加 1,然后把非数字集合里的字符替换掉。这个直觉在简单例子里是对的,比如"19:34"的下一个确实是"19:39",因为 4 后面能用的更大数字只有 9。但一旦遇到"23:59",问题就变得有意思了:分钟个位的 9 后面没有更大的数字,分钟十位的 5 后面也没有,小时个位的 3 后面倒是有 5 和 9,但25:59、29:59都不合法。这时候答案会跳到第二天的"22:22"。
所以这道题考的不只是“会不会用集合”,更考的是一种时间循环思维:一天只有 1440 分钟,时间走到 23:59 之后自动回到 00:00。你要做的,是在这条循环的时间轴上,找当前时刻之后的下一个合法时间。
1.2 两个容易被忽略的隐含规则
第一个规则是数字可以重复使用。比如"19:34"里只有{1, 9, 3, 4},但你可以组成"11:11"、"33:33"这样的时间,只要每个字符都属于原集合就行。很多人第一次做的时候会误以为每个数字只能用一次,这个偏差会直接让答案错误。
第二个规则是“下一个”必须是严格大于当前时间的。也就是说,你不能把当前时间本身当作答案。这个规则在某些特殊用例里会产生一个非常反直觉的结果:如果输入是"22:22",而可用数字只有{2},那么除了22:22本身,一天之内根本不存在其他由2组成的合法时间。此时题目要求返回“最早的有效时间”,而这个最早有效时间恰好就是"22:22"本身。
1.3 把题目翻译成可计算的模型
刷算法题最忌讳的就是在脑子里把事情想得模模糊糊就上手写代码。我习惯先把自然语言翻译成明确的计算模型。
这里我用的模型是:把时间HH:MM转成 0 到 1439 的整数分钟。设当前时间为T,那么我们要找的,就是在序列:
T + 1, T + 2, T + 3, ... , T + 1439(都模 1440)中,第一个满足“格式化后是合法时间,且四个字符都属于原数字集合”的分钟值。
这个模型的优势是天然处理了跨天问题。因为取模运算会把“第二天凌晨”自动映射回当天的00:00到某个时刻,你不需要额外写“如果超过 23:59 就回绕”的判断。我第一次实现时没有用这个模型,而是直接在字符串上操作,结果在跨天判断上反复出错。
2. 解法一:暴力枚举分钟——最稳的兜底方案
2.1 思路本质:搜索空间只有 1440
暴力解法之所以在这道题里完全可行,是因为搜索空间极小。一天只有 1440 分钟,你只需要从当前时间的下一分钟开始,一个一个往后检查,最多检查 1439 个候选时间。每次检查一个时间,只需要看它的 4 个字符是否都在原数字集合里,复杂度是 O(5)。整体算下来,最坏情况也就是 1440 乘以 5 次字符比较,约等于 7200 次运算,对现代 CPU 来说连零头都算不上。
枚举顺序本身就保证了“最近”这个性质:你从T+1开始往后找,看到的第一个合法时间,必然是离T最近的下一个合法时间。不需要排序,不需要贪心推理,代码的正确性一眼就能看得出来。
这个解法特别适合作为写不出来最优解时的兜底方案。很多面试场景下,先给一个正确但朴素的做法,再逐步优化,比一上来就憋大招要稳妥得多。
2.2 完整 Java 实现
直接看代码:
import java.util.HashSet; import java.util.Set; public class NextClosestTime { public String nextClosestTime(String time) { // 收集原时间中的所有数字字符 Set<Character> allowed = new HashSet<>(); for (char c : time.toCharArray()) { if (c != ':') { allowed.add(c); } } int start = toMinutes(time); // 从当前时间的下一秒开始,往后找下一个合法时间 for (int offset = 1; offset < 1440; offset++) { int candidate = (start + offset) % 1440; String candidateTime = toTimeString(candidate); if (canBuild(candidateTime, allowed)) { return candidateTime; } } // 如果一天之内都没有其他合法时间,返回由这些数字组成的最小合法时间 for (int minutes = 0; minutes < 1440; minutes++) { String candidateTime = toTimeString(minutes); if (canBuild(candidateTime, allowed)) { return candidateTime; } } return time; } private int toMinutes(String time) { int hour = (time.charAt(0) - '0') * 10 + (time.charAt(1) - '0'); int minute = (time.charAt(3) - '0') * 10 + (time.charAt(4) - '0'); return hour * 60 + minute; } private String toTimeString(int totalMinutes) { int hour = totalMinutes / 60; int minute = totalMinutes % 60; return String.format("%02d:%02d", hour, minute); } private boolean canBuild(String time, Set<Character> allowed) { for (char c : time.toCharArray()) { if (c != ':' && !allowed.contains(c)) { return false; } } return true; } }几个实现细节说一下:
toMinutes里直接用字符相减得到数字,是因为 Java 的字符串解析方法Integer.parseInt在这里反而显得笨重。字符相减简单直接,也更容易看出小时和分钟分别是从哪几个位置取出来的。toTimeString用String.format("%02d:%02d", ...)保证补零。这是最容易忽略的地方,没有%02d的话,凌晨时间会输出成0:00而不是00:00,直接导致答案格式错误。canBuild里跳过冒号不检查,因为数字集合里不可能有冒号。
2.3 循环边界:offset 到底该从 1 到多少
这是我踩过最大的坑,必须单独拿出来说。
我第一版代码写的是for (int offset = 1; offset <= 1440; offset++),理由是从当前时刻往后数 1440 分钟正好回到当前时刻,这样就能包含“唯一合法时间就是原时间”的情况,比如"22:22"。
表面上看没毛病,但遇到"23:59"就出事了。当offset = 1440的时候,(start + 1440) % 1440又回到了start,也就是"23:59"本身,而"23:59"的四个字符全部在数字集合{2, 3, 5, 9}里,于是程序直接错误地返回了"23:59"。
正确答案应该是"22:22",因为当天内没有比"23:59"更晚的合法时间了,只能回到第二天的"22:22"。
所以循环边界应该是offset < 1440,把“回到原时间”这个情况排除掉。如果 1 到 1439 都没找到合法时间,再进入第二个循环,从 0 找最小的合法时间作为兜底。第二个循环一定会找到答案,因为原始时间本身是合法时间,它的所有字符都在数字集合里,所以整个时间空间里至少有一个合法候选。
2.4 这个解法真正适合什么场景
暴力枚举的优势是正确性几乎不可能被推翻,你把整个状态空间搜了一遍,不可能漏掉答案。劣势是它没有利用问题的结构,所以在面试官眼里显得“不够聪明”。
但它非常适合作为笔试、机试或者快速验证思路的手段。我自己的习惯是:先写一版暴力解,确保对题目规则的理解没问题,然后再尝试写贪心解,最后把两个解法在本地跑同一组用例,互相印证。后面要讲的贪心解法,我就是在暴力解法的验证下才彻底放心的。
3. 解法二:从低位到高位的贪心替换——让变化量最小
3.1 贪心的直觉:尽量动低位的数字
为什么"19:34"的下一个答案是"19:39",而不是"23:19"或"33:11"?因为只要分钟个位能变大,我们就没必要去动分钟十位、小时个位这些更高位。改动的位置越靠右,对时间数值的影响就越小,得到的结果就越“近”。
这个直觉就是贪心解法的核心:从右往左扫描时间字符串的四个数字位置,找到第一个“可以替换成更大数字且替换后整体时间合法”的位置,然后把该位置替换成比原数字大的最小值,再把该位置右边的所有位置全部填成数字集合里的最小值。
举个例子。"19:34"的数字集合是{1, 9, 3, 4},排序后是[1, 3, 4, 9]。
从右往左看:
- 分钟个位是
4,比4大的最小数字是9,替换后得到"19:39",合法,直接返回。 - 整个过程甚至没有用到分钟十位和小时位,因为最低位已经能解决问题了。
再看"23:59":
- 数字集合是
{2, 3, 5, 9},排序后是[2, 3, 5, 9]。 - 分钟个位是
9,没有比它更大的数字,跳过。 - 分钟十位是
5,比它大的只有9,替换后得到"23:99",分钟达到 99,非法,尝试失败。 - 小时个位是
3,比它大的有5和9,先试5,得到"25:59",小时达到 25,非法;再试9,得到"29:59",同样非法。 - 小时十位是
2,比它大的有3、5、9,但"33:59"、"35:59"、"39:59"的小时都超过 23,全部非法。 - 所有位置都无法合法变大,说明当天没有更晚的合法时间,直接返回由最小数字组成的
"22:22"。
注意这里的“跳过”不是直接结束,而是继续往前找更高位。因为更低位的数字已经不能变大了,只能通过改变更高位来让时间变大,只是高位的变化会带来更大的时间跨度。
3.2 为什么替换后低位要全部填最小数字
这里有个新手容易想不明白的点:为什么在某个位置替换成功后,右边的所有位置直接填最小值,而不是逐个尝试?
回到"13:14"这个例子。数字集合是{1, 3, 4},排序后是[1, 3, 4]。
- 分钟个位是
4,没有比它更大的数字。 - 分钟十位是
1,比它大的最小数字是3,替换后得到"13:34",分钟是 34,合法。 - 此时分钟个位应该填什么?填最小数字
1,得到"13:31"。
为什么不填3或4?因为分钟十位已经确定从1变成了3,这一步让时间至少向前推进了 20 分钟。分钟个位越小,整体时间越接近原始时间。填1得到的"13:31",是 13:34 之后 17 分钟;填3得到"13:33",是之后 19 分钟;填4得到"13:34",因为严格大于的要求,这个候选实际上不合法,而且从数值上看等于原时间。所以在位置 p 替换成功后,p 右边所有位置取数字集合的最小值,就是这个 p 位置下的最优选择。
这个推理包含了严格的贪心选择性质:为了使时间变化最小,最高变化位应该尽量靠右;在最高变化位确定后,该位的值应该取比原值大的最小值;该位之后的所有位应该取全局最小值。这三条合在一起,就是贪心解法的完整逻辑。
3.3 完整 Java 实现
代码不长,但边界细节不少:
import java.util.Arrays; public class NextClosestTimeGreedy { public String nextClosestTime(String time) { char[] result = time.toCharArray(); // 收集四个数字字符并排序 char[] digits = new char[] { time.charAt(0), time.charAt(1), time.charAt(3), time.charAt(4) }; Arrays.sort(digits); // 从右往左扫描四个数字位置 for (int i = 4; i >= 0; i--) { if (i == 2) { continue; // 跳过冒号位置 } char original = result[i]; for (char digit : digits) { if (digit > original) { result[i] = digit; if (isValid(result)) { // 当前位置已经合法变大,右边全部填最小数字 for (int j = i + 1; j < 5; j++) { if (j == 2) { continue; } result[j] = digits[0]; } return new String(result); } // 替换后不合法,恢复原值 result[i] = original; break; } } } // 所有位置都无法合法变大:整体填充最小数字 StringBuilder sb = new StringBuilder(); sb.append(digits[0]).append(digits[0]) .append(':') .append(digits[0]).append(digits[0]); return sb.toString(); } private boolean isValid(char[] time) { int hour = (time[0] - '0') * 10 + (time[1] - '0'); int minute = (time[3] - '0') * 10 + (time[4] - '0'); return hour <= 23 && minute <= 59; } }几个关键点:
- 外层循环从
i = 4到i = 0,用i == 2跳过冒号,保证只处理四个数字位置。 - 内层循环在
digit > original时,先尝试替换,再用isValid判断整体时间是否合法。合法就直接填充右边并返回;不合法就恢复原值并break。 - 关于
break的优化:为什么第一个比原值大的数字不合法,就可以直接break,不再尝试更大的数字?因为其他位置固定时,当前位数字越大,小时或分钟的值只会越大。比如小时十位是 2,个位尝试 5 时已经 25 点非法了,再尝试 9 得到 29 点只会更非法;分钟十位是 5,尝试 9 时已经 59 分钟之后的 9x 非法了,更大的数字一样非法。所以一旦第一个候选都不合法,后面的候选必然不合法,直接跳出。
3.4 整体填充最小数字为什么一定合法
最后那个整体填充可能会让一些人担心:如果最小的数字是 9,那"99:99"不是非法吗?
实际上这种情况不可能出现。输入保证是一个合法时间,比如"09:59",数字集合是{0, 9, 5, 9},排序后最小数字是0,填充得到"00:00",合法。比如"11:11",最小数字是1,填充得到"11:11",合法。极端一点,输入"22:22",最小数字是2,填充得到"22:22",合法。因为原时间的小时十位一定落在 0 到 2 之间,而这个数字一定是数字集合的一员,所以全局最小数字不会超过 2。用四个不超过 2 的数字填出来的时间,小时最多22,分钟最多22,必然在合法范围内。
4. 边界用例与两种方案的取舍——刷过之后我才想明白的事
4.1 必须跑通的用例清单
我在本地调试时准备了一组测试用例,几乎覆盖了所有容易出错的场景。建议你也把这组用例存下来,每次写下一种解法都跑一遍。
| 输入 | 期望输出 | 场景说明 |
|---|---|---|
"19:34" | "19:39" | 最基础的正向替换,分钟个位变大 |
"23:59" | "22:22" | 当天无解,跨天到最小合法时间 |
"00:00" | "00:00" | 数字集合只有 0,唯一合法时间就是原时间 |
"11:11" | "11:11" | 数字集合只有 1,同理 |
"13:14" | "13:31" | 个位不能变大,需要动分钟十位并取最小个位 |
"05:55" | "00:00" | 当天后续无合法时间,回绕到第二天凌晨 |
"09:59" | "00:00" | 小时十位可变成 9 但非法,最终整体填充 |
"01:02" | "01:10" | 分钟十位用最小可用的 1,个位取最小 0 |
拿这组用例分别跑暴力解和贪心解,两边输出应该完全一致。如果某个用例两边结果不一样,一定是某一个解法的边界处理出了问题,这时候先用暴力解的输出当标准答案,回头排查贪心解。
4.2 时间复杂度和代码可读性的对比
两种解法在最坏情况下都是常数级复杂度,毕竟时间总量固定是 1440 分钟。但常数大小有明显差异:
| 指标 | 暴力枚举 | 贪心替换 |
|---|---|---|
| 核心循环次数 | 最多 1440 次候选时间检查 | 最多 4 个位置,每个位置最多 4 个候选 |
| 每次判断成本 | 检查 4 个字符是否在集合中 | 检查小时和分钟是否在合法范围 |
| 空间占用 | 一个最多 4 个字符的 HashSet | 一个长度为 4 的 char 数组 |
| 代码理解难度 | 极易理解,规则翻译成代码 | 需要理解贪心选择性质 |
| 面试加分程度 | 作为兜底方案稳妥 | 体现优化能力,明显加分 |
从工程可读性角度,我反而更倾向暴力枚举。它的逻辑直白到不需要注释都能看懂,后续维护也完全不会出错。但从面试表现看,能在五分钟内写出贪心解法,同时讲清楚“为什么从右往左替换是安全的”,会给人留下更好的印象。
4.3 这类题真正的工程价值
把这道题刷透之后我才意识到,它背后的模型在很多实际场景里都通用:给定一组有限字符,在一个固定周期的表盘上找下一个满足约束的时间点。
我在实际项目里做过一个会议室预约系统,用户选了会议室后要显示“下一个可用的开始时间”,约束条件包括会议时长、会议室每日可预约时段、间隔时间。当时的实现思路和这道题的贪心解法如出一辙:从当前时间开始,先尝试在分钟级别找最近可用的 slot,不行就推进到小时、日期,找到第一个满足所有约束的候选后直接返回。
还有一个排序系统里遇到过类似问题:给定一组可用端口数字,找下一个大于当前端口号且未被占用的合法端口。这不就是把“时间合法性”换成“端口占用合法性”的同一个问题模型吗?搜索空间大时用二分或线段树,搜索空间小时暴力枚举永远是最快写对的方案。
所以别小看这道看起来人畜无害的字符串处理题。规则简单,但它逼你想清楚了两个通用问题:如何严格定义“下一个”,以及如何在有限的循环空间里高效寻找满足约束的候选。
最后分享一点个人体会。我第一版实现就是栽在offset <= 1440这个边界上,当时对着"23:59"这个用例反复怀疑人生。后来把时间全部转成分钟,在本地写了一个简单的单测脚本,跑完所有边界用例才真正把题目想通。让 AI 预分析给出思路只是第一步,严格地把边界条件跑一遍,才是把题吃透的关键。以后遇到类似“找下一个满足约束的时间点”的题目,我都会先问自己一个问题:搜索空间有多大?空间小,暴力永远是最稳的兜底;空间大,再考虑像贪心、二分这样的优化。顺序别反了就行。