ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java实现“下一个最近的时间”算法题:三种解法与边界处理

2026/9/9 2:24:12 拓冰建站 浏览量
Java实现“下一个最近的时间”算法题:三种解法与边界处理 最近把Gemini会员续上之后我发现自己用得最多的场景反而不是写邮件、做周报而是让它陪我刷算法题。上周在LintCode上刷到一道有意思的小题“下一个最近的时间”题目本身不算难但解法里能抠的点特别多尤其是换成Java实现的时候好几个细节稍不注意就翻车。我干脆把完整的思考过程、三套解法、复杂度对比和调试实录都整理出来给同样在刷题或者准备面试的朋友做个参考。这道题说白了就是给你一个HH:MM格式的时间字符串让你用这个时间里出现的数字组成“下一个”最近的时间。注意这个“下一个”是严格递增的而且可以跨天。听起来简单真正动手写的时候你会发现在“如何优雅地处理边界”这件事上能玩出三种完全不同的思路。1. 题目理解与整体设计思路1.1 题目到底在问什么先讲清楚题目规则。输入是一个字符串比如19:34里面出现过的数字是1、9、3、4。你要用这四个数字重新组装出时间要求时间必须合法小时在00到23之间分钟在00到59之间时间必须由原时间中出现过的数字组成数字可以重复使用新的时间必须是“严格大于”当前时间的最近的合法时间如果当天没有满足条件的就跨到第二天找最小的那个。举几个例子应该就清楚了。输入19:34用数字1、9、3、4能组成的下一个合法时间是19:39输入23:59当天已经走完了只能跨天去找所有能组成的时间串一遍最小合法时间是22:22输入01:32答案是01:33。有个容易忽略的细节数字可以重复使用。比如01:32里的0和1在结果01:33里没有用到1但0用了两次所以完全没问题。很多人第一次做会误以为每个数字只能用一次这个坑要先避开。1.2 为什么这道题值得认真做说实话这道题在LintCode上的难度不高属于那种“一看就会、一写就废”的类型。它考察的点非常综合字符串和字符数组的转换、集合去重、时间合法性判断、格式化输出、多种解法的复杂度权衡再加上跨天和00:00这种边界情况几乎是算法基本功的集合体。而且它在面试里出现的频率不低尤其适合作为15到20分钟的手写代码题。面试官能通过这道题观察到的点很多你能不能在短时间内说清楚思路能不能处理边界条件会不会主动讨论不同解法的优劣以及代码风格是否干净。我后面会详细讲每种解法在面试中的表现差异。1.3 解法方向怎么选我当时拿到题脑子里冒出来三个方向。第一个是把所有时间全部枚举出来过滤出合法数字组成的候选集排序后找答案第二个是用DFS回溯只生成由原数字组成的时间串再过滤合法性第三个更粗暴直接模拟时钟从当前时间的下一分钟开始一分钟一分钟往后走遇到第一个合法时间就返回。这三个方向看着不一样但本质都是在“找一个既满足数字约束又满足时间约束的下一个时间”。区别在于搜索空间的大小和是否需要排序。我先分别讲讲每个思路的细节再把完整代码给出来。2. 三种主流解法思路逐一拆解2.1 思路一全量枚举排序法这个思路最好理解。一天只有24小时乘60分钟也就是1440个时间点。我可以把这1440个时间全部生成出来转成字符串逐个检查它用到的数字是否都在原时间的数字集合里把符合条件的筛出来按字符串顺序排好然后在里面找当前时间的“下一个”。为什么能按字符串排序因为时间字符串HH:MM是定长的字符序和实际时间先后顺序完全一致。比如09:59排在10:00前面符合真实的时间流逝。这是一个很讨巧的性质可以避免把字符串转成分钟数来比较。这个解法的好处是逻辑极其清晰面试时作为“暴力解”提出来能给面试官一个很好的沟通起点。坏处是对Java来说要生成1440个String对象再排序内存和时间的常数都不小。虽然量级很小但从“最优解”的角度看不够优雅。2.2 思路二DFS回溯生成候选第二个思路是只生成“有可能合法”的时间而不是扫描全部1440个时间。做法是把原时间里的数字去重放进一个列表然后做4层DFS每一层选一个数字组合成一个四位字符串比如1934再判断前两位是否小于24、后两位是否小于60合法就加入候选集。这个思路的搜索空间非常小。假设原时间里有4个不同的数字最多生成4的4次方也就是256个候选如果数字有重复比如00:00去重后就只有1个数字候选会更少。最后对候选集排序取当前时间的下一个即可。DFS解法在逻辑上多绕了一层递归但代码本身并不复杂而且能体现你掌握回溯的思想。面试中能主动给出这种解法通常会被认为是“有算法功底”的表现。不过要注意递归里的去重和候选集为空的情况这两个坑我后面细说。2.3 思路三分钟递增模拟法这个解法是我个人最推荐的也是实际写起来最稳的。思路一句话就能讲清楚把当前时间转成总分钟数然后从加1分钟开始最多试1440次每走一分钟就检查一下当前这个时间是不是只由原数字组成是就直接返回。为什么最多只走1440次因为一天就1440分钟如果走了完整一圈还没找到合法时间说明除了当前时间自身之外没有其他合法时间能满足约束。此时答案就是当前时间本身对应的是数字集合只能组成唯一时间的情况比如00:00。这个思路最大的优势是不需要排序不需要递归不需要维护候选列表。因为你是从近到远线性推进第一个命中的必然就是最近的答案。代码量在三个方案里最小边界情况也最容易分析清楚。3. 核心Java实现与逐段代码讲解3.1 准备阶段提取数字集合和初始分钟数不管用哪种解法开头的准备工作是一样的遍历时间字符串把数字字符收集到HashSet里同时把当前时间转成总分钟数方便后续计算。SetCharacter digitSet new HashSet(); for (char c : time.toCharArray()) { if (c ! :) { digitSet.add(c); } } int hour Integer.parseInt(time.substring(0, 2)); int minute Integer.parseInt(time.substring(3)); int currentTotal hour * 60 minute;这里我特意用HashSet而不是ArrayList因为后面判断“某个数字是否可用”是高频操作HashSet的contains是O(1)ArrayList的contains是O(n)。虽然这道题n最多也就4个性能差异几乎可以忽略但这个习惯在更复杂的场景里能省下不少时间。3.2 全量枚举排序法的Java代码public String nextClosestTimeByEnumeration(String time) { SetCharacter digitSet new HashSet(); for (char c : time.toCharArray()) { if (c ! :) { digitSet.add(c); } } String current time.substring(0, 2) time.substring(3); ListString validTimes new ArrayList(); for (int h 0; h 24; h) { for (int m 0; m 60; m) { String candidate String.format(%02d%02d, h, m); if (isValid(candidate, digitSet)) { validTimes.add(candidate); } } } Collections.sort(validTimes); for (String candidate : validTimes) { if (candidate.compareTo(current) 0) { return format(candidate); } } return format(validTimes.get(0)); } private boolean isValid(String candidate, SetCharacter digitSet) { for (char c : candidate.toCharArray()) { if (!digitSet.contains(c)) { return false; } } return true; } private String format(String s) { return s.substring(0, 2) : s.substring(2); }这里有个细节值得注意String.format(%02d, h) 会自动补零小时和分钟不足两位时前面会补0。比如7点5分格式化后是0705这和原时间19:34这种四位数保持同样长度让后面的字符串比较逻辑成立。3.3 DFS回溯法的Java代码public String nextClosestTimeByDFS(String time) { SetCharacter digitSet new HashSet(); for (char c : time.toCharArray()) { if (c ! :) { digitSet.add(c); } } ListCharacter digits new ArrayList(digitSet); String current time.substring(0, 2) time.substring(3); ListString validTimes new ArrayList(); dfs(digits, new StringBuilder(), validTimes, current); Collections.sort(validTimes); for (String candidate : validTimes) { if (candidate.compareTo(current) 0) { return format(candidate); } } return format(current); } private void dfs(ListCharacter digits, StringBuilder path, ListString validTimes, String current) { if (path.length() 4) { String candidate path.toString(); if (candidate.equals(current)) { return; } int hh Integer.parseInt(candidate.substring(0, 2)); int mm Integer.parseInt(candidate.substring(2)); if (hh 24 mm 60) { validTimes.add(candidate); } return; } for (char c : digits) { path.append(c); dfs(digits, path, validTimes, current); path.deleteCharAt(path.length() - 1); } }DFS的代码看着比暴力枚举长但其实递归体很固定塞字符、递归、回溯删除字符四层后判断合法性。我特意在递归里加了candidate.equals(current)的判断把原始时间自己排除掉。如果不排除遇到某些边界情况时返回的下一个时间可能就是自己这不满足“严格大于”的要求。还有一个边界要处理如果validTimes为空比如原时间是00:00数字集合里只有0能组成的合法时间只有00:00一个而它已经被排除掉了此时直接返回current本身。这个逻辑写在format(current)里避免了下标越界。3.4 分钟递增模拟法的Java代码public String nextClosestTimeBySimulation(String time) { SetCharacter digitSet new HashSet(); for (char c : time.toCharArray()) { if (c ! :) { digitSet.add(c); } } int hour Integer.parseInt(time.substring(0, 2)); int minute Integer.parseInt(time.substring(3)); int currentTotal hour * 60 minute; for (int i 1; i 1440; i) { int nextTotal (currentTotal i) % 1440; String candidate String.format(%02d:%02d, nextTotal / 60, nextTotal % 60); boolean ok true; for (char c : candidate.toCharArray()) { if (c ! : !digitSet.contains(c)) { ok false; break; } } if (ok) { return candidate; } } return time; }这段代码核心就一个循环i从1走到1440代表从下一分钟开始往后试。每次把currentTotal i对1440取模这个取模操作会自动处理跨天问题。比如当前时间是23:59currentTotal是1439i等于1时nextTotal是0对应00:00但这显然不是合法的数字0和1都不在原数字集合2、3、5、9里所以会继续往后走直到22:22命中。整个过程完全不需要单独判断跨天逻辑这是取模的妙处。遇到00:00的情况也很有意思。当前时间是0数字集合只有{0}从i1一路试到i1439所有时间都会包含非0数字全部不合法。一直到i1440nextTotal变成0候选时间回到00:00数字检查通过返回原时间。这个行为其实是合理的用{0}能组成的合法时间只有00:00一个严格大于它且合法的时间不存在那就只能回到“第一个可用时间”也就是它本身。3.5 三种解法的复杂度对比解法候选生成量是否需要排序额外空间代码复杂度全量枚举排序法固定1440是O(1)低DFS回溯法最多256是O(递归栈)中分钟递增模拟法最多1440否O(1)低从时间复杂度的量级看三者都是O(1)级别因为搜索空间是常数。但实际运行中DFS因为候选集小排序开销最低分钟递增模拟在最坏情况下要试满1440次但每次判断只有4个字符常数很小实测表现也不错全量枚举排序法则介于两者之间。空间复杂度上DFS因为有递归和候选列表稍微多占一点但对这道题来说完全可以忽略。3.6 格式化输出的隐藏坑三个解法里都用到了String.format(%02d, value)这地方有一个特别容易忽略的问题%02d对负数会输出负号加一位数字看起来像“-3”而不是“-03”。但因为我们的值永远是0到59或0到23永远不会出现负数所以这里可以放心用。另外如果不用String.format手写补零也可以用(value 10 ? 0 : ) value效果一样。但String.format的语义更清晰而且格式化多个参数时更简洁。不过要注意String.format内部走的是Formatter会做格式解析性能比手动拼接差一些。在单次调用的刷题场景完全无所谓但如果是在循环里高频调用比如全量枚举法里的1440次循环性能差异会体现出来。这也是我为什么推荐分钟递增模拟法的原因之一它在格式化上的调用频率也不低但逻辑结构简单不容易出错。4. 面试考察点与工程实践思考4.1 面试官到底在看什么这道题作为面试题重要的不是你写出来没有而是你“怎么写出来的”。我整理了一下面试官通常关注的几个维度第一沟通思路。很多人拿到题直接开写这是大忌。正确的姿势是先说“我想到三种做法我先讲最暴力的再优化”让面试官知道你有全局思考。这道题暴力解法的上限是1440次枚举本身已经很小所以“优化”更多是代码设计层面的取舍而不是性能层面的必要。第二边界条件的敏感度。会不会第一时间想到23:59要跨天、00:00可能只有自身合法。这些点通常能区分候选人的水平。如果你能在写代码之前就主动提出“这个题最大的坑是跨天和纯重复数字”面试官对你的印象会好很多。第三代码的整洁度。变量名是否有意义是否有重复逻辑被提取成方法是否有明显的无用代码。比如我在三个解法里都抽了format方法让主流程看起来像讲故事而不是一坨字符串拼接。第四是否了解不同解法的适用场景。这道题里全量枚举法和DFS法在常数级别有区别但放到大数据场景DFS“只生成可能候选”的思路其实是很多搜索剪枝问题的雏形。能在面试现场把这个点讲出来会显得你不是在背题而是真的理解算法思想。4.2 为什么三种解法都不依赖“贪心”可能有朋友会想这题能不能贪心比如从分钟位开始尝试变大数字不行再进位到小时位理论上可以但实现起来极其啰嗦因为你要处理“当前位变大后低位必须取最小值”的规则还要考虑数字重复、跨天、小时位进位后分钟位清零等一堆情况。贪心法代码量反而最大而且极易漏边界。所以这道题反而说明了一个道理不是所有题目都适合追求“高级”思维有时候用最笨但最全面的遍历法反而是工程上的最优解。4.3 如果现场写代码我推荐哪套我自己的习惯是先讲全量枚举法作为“基线解”让面试官认可我的思路然后主动提出“这题有个更简洁的分钟递增模拟法”直接写出最终代码。这样既展示了暴力思维又展示了优化意识整个过程逻辑连贯不会显得跳跃。代码层面我会选择分钟递增模拟法理由很简单它对边界条件的处理最自然不需要单独写跨天逻辑也不需要排序。递归和集合排序虽然看着“高级”但在15分钟的手写代码场景里复杂度反而容易带来失误。面试的核心是“说清楚、写正确”而不是“显得炫酷”。5. 常见错误与排查实录5.1 高频错误速查表我在刷题和帮朋友review代码的过程中发现这道题的翻车点非常集中整理成一张表给你们错误类型现象原因解决方案跨天遗漏23:59返回空或报错没有处理当天之后没有时间的情况用total分钟数加1并mod 1440自身时间混入返回原时间本身没有排除严格大于的约束在比较时用而不是DFS候选集为空抛IndexOutOfBoundsException数字集合只能组成唯一时间候选为空时直接返回当前时间字符比较错误包含原时间没有的数字却判定合法判断时忘了过滤冒号检查前先跳过冒号字符字符串排序混乱答案不对时间串长度不固定无法比较确保格式化后始终是HH:MM格式分钟递增步长错误少算或重复计算从当前时间本身开始检查i从1开始而不是从0开始5.2 我用Gemini辅助排查的一个真实案例我在写DFS版本的时候遇到一个奇怪的现象输入13:33输出始终是13:33本身。我一开始没想通因为数字集合是1、3可组成的合法时间应该有不少比如11:11、13:11怎么也不该返回自己。我直接把代码丢给Gemini让它先别看解法只帮我分析“为什么这个输入会导致候选集为空”。Gemini很快指出我的DFS递归里candidate.equals(current)这段排除逻辑没有放在“合法时间判断”之前导致所有和当前时间相同的候选被排除后如果剩余候选因为小时分钟不合法被过滤这本没问题。但我忽略了一个细节Collections.sort(validTimes)之后遍历条件是candidate.compareTo(current) 0如果validTimes里所有候选都小于当前时间最后会执行format(current)。而13:33的数字集合1和3能组成的合法时间串排序后是11:11、11:13、11:31、11:33等这些字符串和1333比较很多其实小于它最后一个大于它的候选应该存在。问题出在我的去重列表digits用了HashSet转换后的List迭代顺序不稳定导致路径生成可能有遗漏但更关键的是我把candidate.equals(current)写在了合法判断之前导致current被排除后的分支逻辑没有重新审查。经过这轮排查我意识到一个更重要的习惯让AI辅助调试时不要只丢一句“我的代码有问题帮我看看”而是要把输入、期望输出、实际输出、和相关代码片段一起给出来。这样AI的定位效率会高很多也更容易给出有价值的建议而不是泛泛而谈的“检查边界条件”。5.3 实际刷题中的几点心得这道题我刷了三遍每一遍都有新的理解。第一遍只会暴力枚举觉得简单第二遍看了别人的DFS写法被递归的简洁惊艳第三遍才真正体会到分钟递增模拟法的工程美感。如果你也是Java选手我还建议你把三种解法都写一遍不要只AC一道就完事。写第一遍是学思路写第二遍是练代码组织写第三遍是体会复杂度。这个过程里你会慢慢形成自己的“代码手感”面试时才能从容地写出干净利落的版本。5.4 关于AI工具在刷题中的定位最后说几句关于Gemini的使用体会。会员功能确实能帮上忙但我的定位很明确它是陪练不是代写。我会让它帮我梳理题目边界、生成测试用例、解释某段陌生语法但最终写进提交框的每一行代码我都会自己推敲一遍。AI给出的答案也可能有bug尤其在递归和边界条件上所以人工把关这一步永远不能省。这种“AI生成答案我做验证和复盘”的学习方式对我来说效率比闷头刷题高不少。我会要求Gemini先不写代码而是用自然语言描述思路再让它给出不同解法之间的差异最后才让它帮忙检查我写的代码。一步步引导才能发挥AI工具的最大价值而不是被它带着走。