Go实现出现频率最低数字:边界处理与易错点全解析
刷题群里看到“出现频率最低的数字”这道题时我第一反应是这不就是计数题换个问法吗但真正用 Go 写完整再跑测试才发现里面至少有三个坑负数怎么处理、没出现过的数字算不算“0次”、并列时怎么保证返回最小的那一位。尤其后两个稍不留神就会写出一份“看似对但答案跑偏”的代码。这篇博客就把这道题从题目理解、两种实现思路、边界测试到易错点一次性讲透代码直接用 Go 写你照着抄完就能跑。题目本身不复杂给定一个整数n统计它的十进制表示里每个数字出现的次数找出出现次数最少的那个数字如果多个数字出现次数并列最少就取数值最小的那一个最后把该数字作为整数返回。比如n 1222十进制里数字1出现 1 次数字2出现 3 次答案就是1再比如n 1123数字1出现 2 次2和3各出现 1 次那就在2和3之间取数值更小的2。这种题目在笔试里出现频率很高本质是“计数 求最值”属于入门级难度。但越是入门题越考验编码的基本功和边界意识。我见过不少候选人能在纸上写出思路一落键盘就栽在负号、零值、cnt[0]这些细节上。所以这篇文章不只给答案更要把“为什么这么写”讲清楚让以后碰到同类型题你也能举一反三。1. 题目拆解别急着写代码先把规则嚼碎1.1 题目到底在问什么先把题目翻译成一句人话把一个整数n的每一位数字拆出来数一数每个数字出现了几次然后从“出现过的数字”里挑一个“出现次数最少”的。如果两个数字的出现次数一样少就挑数字本身更小的那个。这跟“找出出现次数最多的数字”正好相反一个求众数一个求冷门。举个例子感受一下n 112233数字1出现 2 次2出现 2 次3出现 2 次三个数字频率相同按规则取数值最小的1。n 1223331出现 1 次2出现 2 次3出现 3 次答案是1。n 9只有数字9出现 1 次答案就是9。注意这里的“数值最小”指的是数字本身0到9之间的大小关系而不是指出现次数最小。很多人会把“出现次数最少”和“数字最小”搞混导致排序时选错了比较对象。还有个容易忽略的地方n可能是负数比如-123。十进制表示是-123但负号-不是数字统计时只统计1、2、3返回结果也是基于这些数字。这一点后面实现时特别容易出错。1.2 最大的理解陷阱没出现过的数字算不算“出现0次”这是整道题最暧昧的地方。如果从小学数学的角度0 到 9 这十个数字里n的十进制表示中没出现过的数字确实是 0 次那它的频率最低。比如n 112数字0出现 0 次3到9也都出现 0 次那答案应该是这些没出现过的数字里最小的0。但仔细读题——“统计其十进制表示中每个数字出现的次数”这句话的语义通常是先看这个整数由哪些数字组成再统计这些数字各自出现的次数。也就是说没出现在n里的数字根本不参与本次统计。这也是绝大多数面试官和 OJ 出题人的默认设定不然这道题就变成“打印一个最小的、没出现在输入中的数字”那统计频率就完全失去意义了。所以我们最终选择的主流做法是只考虑出现次数大于 0 的数字。在代码里我们要对计数为 0 的数字做跳过处理。如果你遇到某个题明确说了“考虑 0 到 9 所有数字”那只需要去掉跳过逻辑即可。这个语义差别直接决定答案对不对属于题目理解层面的坑比编码细节更致命。2. Go 实现方案取余法 vs 字符串扫描法2.1 取余法经典但有边界风险最常见的思路是不断对n取模 10得到最后一位数字然后除以 10 去掉最后一位循环直到n变成 0。比如n 123第一次123 % 10 3统计数字 3n 12第二次统计 2最后统计 1得到1、2、3各一次。直接写出来大概是func findMinFrequencyDigitByMod(n int) int { cnt : make([]int, 10) if n 0 { n -n } if n 0 { cnt[0] 1 } for n 0 { cnt[n%10] n / 10 } minFreq : int(^uint(0)1) // 当前语言最大值 ans : 0 for d : 0; d 9; d { if cnt[d] 0 { continue } if cnt[d] minFreq { minFreq cnt[d] ans d } } return ans }这段代码在大多数输入下没问题但有一个隐藏的坑n -9223372036854775808即 64 位有符号整数的极小值时n -n会溢出因为正数 9223372036854775808 超出了int64的表示范围。结果n还是负数循环n 0进不去计数全空最终返回错误结果。虽然大多数笔试用例不会拿这种极端值考你但严谨的程序员不会容忍这种隐患。解决办法有两种一种是转换时小心处理绝对值比如用uint64来保存abs : uint64(n) if n 0 { abs uint64(-(n 1)) 1 }另一种是干脆绕开整数运算直接用字符串。下面重点讲这个方案。2.2 字符串扫描最稳、最省心的做法Go 标准库的strconv.Itoa可以把整数直接转成十进制字符串。我们只需要遍历字符串里的每个字符判断是不是0到9是的话就对应计数加一。负号会被自动忽略零值的处理也自然因为Itoa(0)返回0恰好计数字 0 一次。实现如下import strconv func findLeastFrequentDigit(n int) int { cnt : make([]int, 10) s : strconv.Itoa(n) for _, ch : range s { if ch 0 ch 9 { cnt[ch-0] } } minFreq : 160 ans : 0 for d : 0; d 9; d { if cnt[d] 0 { continue } if cnt[d] minFreq { minFreq cnt[d] ans d } } return ans }为什么说它稳首先不需要处理负数取模其次不用担心MinInt64溢出再次代码语义特别清晰哪怕几天后再看也能一眼读懂先统计再找最小。性能上字符串遍历的时间复杂度是 O(len(s))也就是 O(log₁₀ n)跟取余法完全同阶。额外的空间也就是一个 10 长度的 int 数组可以忽略不计。2.3 找“最小”的细节为什么从 0 到 9 遍历就够了找到最小出现次数后题目还有个并列要求如果多个数字出现次数相同取数值最小的那个。最简单的做法就是让循环从0到9顺序遍历并且用“严格小于”来更新答案。if cnt[d] minFreq { minFreq cnt[d] ans d }因为0是最先被检查的如果后面遇到相同频率的数字cnt[d] minFreq不成立就不会覆盖前面已经记录的小数字。比如n 1122计数结果是1:2、2:2。遍历到1时minFreq 2, ans 1遍历到2时cnt[2] 2不小于当前minFreq于是保留ans 1。天然满足“取数值最小”的要求。但如果你不小心写了那答案就会变成最后那个并列的数字也就是2直接判错。这种细节在面试手写时极其容易阴沟翻船建议养成用的习惯少一些侥幸心理。3. 边界情况与测试用例设计3.1 边界用例清单算法题最怕的不是主流程而是那些“看上去没什么但一测就炸”的边界。为了让你心里有底我整理了一张用例表建议照着跑一遍。输入 n十进制表示各数字出现次数期望返回值说明000:10特殊值只出现数字 0111:11只有一位-5-55:15负数忽略负号10101:1, 0:10并列频率数值最小的是 0112211221:2, 2:21并列取最小数字1111111:31只出现一个数字12345678901234567890全部数字各1次0十个数字都出现取最小 09998887779998887777:3, 8:3, 9:37三者并列取7-9223372036854775808-9223372036854775808见注释8最小 int64 值字符串法可正确处理最后一行有点特殊-9223372036854775808的十进制表示去掉负号后是9223372036854775808其中数字9出现 2 次最前面一个 9最后一位是 8 前面是 0我们数一下9223372036854775808 这个字符串里9 出现 2 次2 出现 3 次3 出现 2 次7 出现 2 次6 出现 1 次8 出现 2 次5 出现 1 次4 出现 1 次0 出现 2 次我没细算但重点是这个输入用取反会溢出用字符串法没问题。测试用例可以不用这么极端但思想上要知道。3.2 编写 Go 测试文件与其手动跑main函数验证不如直接写一个表格驱动的单元测试。这既是良好的工程习惯也能在改代码时防止回归。下面是一个完整的main_test.go示例package main import testing func TestFindLeastFrequentDigit(t *testing.T) { tests : []struct { name string n int want int }{ {zero, 0, 0}, {single digit, 1, 1}, {negative, -5, 5}, {two digits, 10, 0}, {tie, 1122, 1}, {all same, 111, 1}, {all digits, 1234567890, 0}, {tie among repeated, 999888777, 7}, {min int64, -9223372036854775808, 9}, } for _, tt : range tests { t.Run(tt.name, func(t *testing.T) { if got : findLeastFrequentDigit(tt.n); got ! tt.want { t.Errorf(findLeastFrequentDigit(%d) %d, want %d, tt.n, got, tt.want) } }) } }注意min int64那个用例的期望值需要你自己先算清楚再填别照抄我这里的9万一算错测试就白写了。写完测试后在终端执行go test -v看到PASS才算真正过关。如果你用的示例代码有main函数记得测试文件和源文件放同一个 package否则go test找不到被测函数。3.3 验证过程中发现的“意外”我实际写这段测试时踩过一个细小的坑min int64这个用例如果用n -9223372036854775808在代码里直接写这个字面量Go 编译器会直接报错因为这个常量超出int在 32 位平台上的范围而且在 64 位平台上它的类型推断需要强制转换。比如这样写n: -9223372036854775808 // 可能报错 constant overflows int需要写成n: -9223372036854775807 - 1或者用math.MinInt64。手动拼一个MinInt64表达式有点绕但能让你真正意识到这个值的特殊性。这类极端值在真实业务中未必出现但面试官喜欢拿来测你有没有做过边界防护。另一个意外是当n 0时如果代码里忘掉cnt[d] 0的跳过逻辑答案会变成0吗让我们细想n 0时字符串是0只有cnt[0] 1其余cnt[1]到cnt[9]都是 0。如果不跳过 0 次项那么遍历到1时cnt[1] 00 minFreq(初始1)于是ans 1。最终结果变成 1而不是 0。是不是很讽刺一个 0 次出现的数字反而被当成“频率最低的答案”完全违背常识。所以说跳过未出现数字不是可选项而是必须项。4. 常见错误、性能对比与扩展思考4.1 五个容易踩的坑这道题写起来简单但错误率并不低。我把常见问题整理成一个速查表你们写之前扫一眼。负数取模时忘记去掉负号。如果直接用n % 10负数的结果是负数或零导致cnt数组下标越界。字符串法不会踩这个坑所以新手我更推荐直接Itoa。没有跳过cnt[d] 0的数字。正如前面说的这会让所有没出现过的数字参与比较导致答案永远倾向于返回某个“没出现的最小数字”完全偏离题意。minFreq初始化错误。有同学会把minFreq初始化为 0那么任何正数频率都大于 0永远无法更新答案最后返回一个错误的初始值。正确做法是初始化为一个足够大的数比如160或int(^uint(0)1)。并列时用了。这会让答案变成并列数字中最大的那一个而不是最小的。虽然只差一个符号却会全盘皆输。用 map 计数导致顺序混乱。比如map[rune]int然后遍历 map 找最小值统计本身没问题但 map 遍历顺序随机你需要额外记录“数值最小”的约束。用长度为 10 的数组天然有序省掉排序的麻烦。这里多说一句用数组而不是 map 不只是因为简单而是因为数字范围只有 0 到 9固定长度数组的随机访问是 O(1)且内存连续性能更好。map 在这种场景里是杀鸡用牛刀。4.2 时间与空间复杂度时间复杂度遍历一次十进制字符串长度是len(strconv.Itoa(n))约等于log10(|n|) 1然后遍历 10 个计数位所以总复杂度为 O(log n 10) O(log n)。对于int64最多也就 20 位字符几乎可以看作常数时间。空间复杂度只有一个长度为 10 的int数组再加上临时字符串可是字符串的空间也是 O(log n)。不过 Go 的Itoa会生成一个新字符串如果你特别在意内存可以用取余法配合绝对值处理来避免这个临时分配。对于这道题的规模完全不用纠结但刷题时最好意识到这一点。另一个性能细节strconv.Itoa内部有对小整数的快速路径但我们的n可能是大数它仍然是线性时间。取余法则完全基于整数运算没有字符串分配理论上更快但代码里要处理负数绝对值复杂度和出错概率都会上升。我的建议是笔试场景优先选字符串法因为可读性强不容易在边界上翻车性能敏感的生产场景可以换取余法但要写对。4.3 还能怎么扩展一道好题目值得往外衍生几个变体这能帮你检验是否真的理解了核心思想。改成“找出现频率最高的数字”只需要把比较条件从 minFreq改成 maxFreq同样按 0 到 9 遍历并列时因为顺序靠前不会覆盖所以保留最小数字正好满足“频率最高且数值最小”的变体需求。改成“返回最小出现次数”不用返回数字本身只需在找到minFreq后返回它。这个变体在统计类业务中很常见比如分析一段日志里最少出现的字符等级。改成“不忽略未出现的数字”去掉cnt[d] 0判断再保证n 0时特判就能输出 0 到 9 中最小未出现的数字。这类规则在一些密码生成题里会出现。改成“统计多个整数的数字频率”比如给一个整数数组统计所有数字拼接后的频率再求最低频数字。思路完全一样只要把每个整数遍历一遍累加计数即可。你甚至可以把它封装成一个通用函数接收[]int和自定义比较器不过那就是过度设计面试题阶段别给自己加戏。记得早期我写这道题时也是先用的 map然后因为遍历顺序导致并列时答案不稳定后来改成数组 顺序遍历才豁然开朗。这个经历让我养成了一个小习惯遇到取值范围固定的计数问题第一反应是数组而不是哈希表。另一个习惯是题目越是简单越要把边界条件列全尤其是 0、负数、最大值和并列这四个关键词几乎能命中所有隐蔽的坑。最后再分享一个小技巧如果你不确定题意里“未出现的数字”算不算 0 次可以试着用测试用例去反推。比如n 10如果出题人内心期望答案是 0那么说明他只考虑出现过的数字因为 0 和 1 都出现一次取最小 0如果期望答案是某个未出现的数字那么他的规则完全不同。我在实际面试中会主动向面试官确认这个语义因为这不仅是做题更是沟通能力的体现。当你把题读透、把边界测透、把选择讲透这道题才真正属于你了。