☰
Go实现出现频率最低数字:边界处理与易错点全解析
2026/10/9 3:19:54 网站建设 项目流程

刷题群里看到“出现频率最低的数字”这道题时,我第一反应是:这不就是计数题换个问法吗?但真正用 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 = 122333:1出现 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,统计数字 3;n = 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 := 1<<60 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十进制表示各数字出现次数期望返回值说明
0"0"0:10特殊值,只出现数字 0
1"1"1:11只有一位
-5"-5"5:15负数忽略负号
10"10"1:1, 0:10并列频率,数值最小的是 0
1122"1122"1:2, 2:21并列取最小数字
111"111"1:31只出现一个数字
1234567890"1234567890"全部数字各1次0十个数字都出现,取最小 0
999888777"999888777"7: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] = 0,0 < minFreq(初始1),于是ans = 1。最终结果变成 1,而不是 0。是不是很讽刺?一个 0 次出现的数字反而被当成“频率最低的答案”,完全违背常识。所以说,跳过未出现数字不是可选项,而是必须项。

4. 常见错误、性能对比与扩展思考

4.1 五个容易踩的坑

这道题写起来简单,但错误率并不低。我把常见问题整理成一个速查表,你们写之前扫一眼。

  1. 负数取模时忘记去掉负号。如果直接用n % 10,负数的结果是负数或零,导致cnt数组下标越界。字符串法不会踩这个坑,所以新手我更推荐直接Itoa。
  2. 没有跳过cnt[d] == 0的数字。正如前面说的,这会让所有没出现过的数字参与比较,导致答案永远倾向于返回某个“没出现的最小数字”,完全偏离题意。
  3. minFreq初始化错误。有同学会把minFreq初始化为 0,那么任何正数频率都大于 0,永远无法更新答案,最后返回一个错误的初始值。正确做法是初始化为一个足够大的数,比如1<<60或int(^uint(0)>>1)。
  4. 并列时用了<=。这会让答案变成并列数字中最大的那一个,而不是最小的。虽然只差一个符号,却会全盘皆输。
  5. 用 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);如果期望答案是某个未出现的数字,那么他的规则完全不同。我在实际面试中会主动向面试官确认这个语义,因为这不仅是做题,更是沟通能力的体现。当你把题读透、把边界测透、把选择讲透,这道题才真正属于你了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询