☰
Go语言刷题:统计数字出现次数,避开隐藏条件与边界坑
2026/10/9 3:19:54 网站建设 项目流程

今天刷到一道 2026-01-27 的题目,题目本身很简短:给定一个整数n,统计它十进制表示里每个数字出现的次数,找出出现次数最少的那个数字,如果多个数字并列最少,就取数值最小的那一位,最后把这个数字作为整数返回。看题的时候我下意识觉得这题 5 分钟就能写完,结果真正动手才发现,越是这种“一句话题”,越容易在边角地方翻车。尤其是“出现过的数字”这个隐含条件,很多人第一次都会踩进去。这篇文章我会用 Go 语言从题意拆解开始,把两种主流的实现方案、完整代码、测试用例以及我实际踩过的坑都写清楚,适合刚入门算法题、想用 Go 刷题的朋友参考,也适合想复习一下数字处理细节的老手快速过一遍。

1. 先把题意吃透:这题到底在考什么

1.1 一个看似简单但有隐藏条件的统计问题

题目说的是“统计其十进制表示中每个数字出现的次数”,这里有一个非常关键的隐含条件:只统计十进制表示里实际出现过的数字。举个最简单的例子,如果n = 12345,那么数字0、6、7、8、9都没有出现过。如果你把没出现过的数字也算作“出现 0 次”,那最小频率一定是 0,并且在所有没出现过的数字里,最小的那个数字是0,所以任何不包含0的数,答案都直接变成0。这显然不是出题人的本意。

所以这道题真正的意思是:先把n的十进制表示拆成一个个数字字符,统计这些字符里0到9各自出现的次数,然后只在“出现过”的数字里找频次最低的那个。如果1出现 1 次、2出现 1 次、3出现 2 次,那么1和2并列最少,取数值更小的1。

这个隐藏条件不搞清楚,代码写得再漂亮也是错的。我一开始也犯了想当然的毛病,直接初始化一个长度为 10 的数组,然后从0到9找最小 count,结果跑测试用例n = 123时返回了0,当时还觉得没问题,后来仔细一想,0在123里压根没出现过,怎么能把它当成答案呢?所以第一步永远是:先把“只统计出现过的数字”这个前提钉死在大脑里。

1.2 为什么数组比 map 更合适

看到“统计每个数字出现的次数”,很多人第一反应就是用map[rune]int或者map[byte]int,这倒也没错,但在这道题里,用一个长度固定为 10 的数组是更优解。

原因很简单:十进制数字一共就只有0到9这 10 种可能,而且数字本身就可以直接当作数组下标来用。比如字符'7',把它减去'0'得到整型7,然后cnt[7]++就行。这样既不需要维护 map 的哈希逻辑,也不需要处理 key 不存在的情况,空间复杂度还是 O(1)。

如果用 map,代码通常长这样:

m := make(map[rune]int) for _, ch := range s { m[ch]++ }

然后找最小值的时候还得判断 key 是否存在,或者用value, ok := m[key]去读。对于一个固定只有 10 个可能键的场景,map 的灵活优势完全发挥不出来,反而增加了代码量和出错概率。我自己的习惯是:当候选集合小且固定时,数组永远是第一选择。这也是为什么这道题虽然简单,却很适合拿来练习“用数据结构的物理特性简化问题”的思维方式。

2. 两个主流实现方案:字符串流 vs 数学流

2.1 方案一:转成字符串,逐字符统计

第一个方案非常直白:用strconv.Itoa(n)把整数转成字符串,然后遍历字符串里的每个字符,判断它是不是数字字符,如果是就对应到数组下标累加。

这里有一个必须处理的细节:负数。strconv.Itoa(-122)会得到"-122",字符串里包含一个负号'-'。负号不是数字,所以遍历的时候一定要跳过它。判断条件可以写if ch >= '0' && ch <= '9',这样负号自然就被过滤掉了。

那为什么不直接判断ch >= '0'就累加呢?因为万一字符串里有其他字符,虽然Itoa正常情况下不会产生,但写个严谨的判断总归没坏处。而且 Go 的字符串遍历有两种方式:for i := 0; i < len(s); i++得到的是byte,for _, ch := range s得到的是rune。对于纯 ASCII 的数字和负号,这两种方式都能正确处理,因为每个字符恰好占一个字节。我习惯用range,因为可读性更好,后面即使字符串里混入了中文或其他 Unicode 字符,rune也能安全处理。

这个方案的时间复杂度是 O(len(s)),也就是 O(log n)。因为一个整数n的十进制位数大约是log10(n) + 1,在算法竞赛里这个复杂度已经非常优秀。空间复杂度是 O(1),因为不管字符串多长,我们只用一个长度为 10 的数组。

2.2 方案二:除十取余,纯数学处理

第二个方案不依赖字符串转换,而是直接用数学方法:每次对n取n % 10得到最低位数字,然后n /= 10去掉最低位,循环直到n变成 0。

这里有个大坑:如果n是负数,n % 10在 Go 里会得到负数。比如-122 % 10的结果是-2,直接用-2当数组下标肯定不对。所以要么先取绝对值,要么在循环里把余数转成正数。

比较稳妥的做法是:

m := n if m < 0 { m = -m } for m > 0 { cnt[m%10]++ m /= 10 }

但是这样一来,又引入了一个新问题:如果n恰好是 0 呢?如果先取绝对值再循环,m = 0,for 循环一次都不会执行,cnt数组全为 0,最后找一个最小值就会出错。因此必须对n == 0单独处理,直接让cnt[0] = 1,因为0的十进制表示就是字符串"0",数字0正好出现一次。

这个边界处理是数学方案的“老大难”,很多人写着写着就忘了。相比之下,字符串方案天然规避了这个问题:strconv.Itoa(0)返回"0",遍历统计后cnt[0]自然是 1。所以从“不容易漏边界”的角度看,我更推荐新手先写字符串方案。

2.3 两套方案怎么选

我自己平时做题时,会同时考虑这两种写法,然后用一张表来对比它们的特点:

对比维度字符串方案数学方案
实现思路转换后遍历字符,直观易懂除十取余,贴近十进制本质
负数处理用ch >= '0' && ch <= '9'过滤负号需要先取绝对值,注意溢出
0 的特殊处理天然支持,无需单写必须单独cnt[0] = 1
代码行数较少,约 15 行核心略多,但无需导入 strconv
潜在风险几乎无取绝对值可能溢出(针对 MinInt64)
适用场景大多数竞赛和面试题更底层、不想引入字符串的场景

从工程实践来看,字符串方案更“安全”,因为 Go 语言对字符串的处理非常成熟,strconv.Itoa是标准库函数,性能也足够好。数学方案的优势在于完全不依赖字符串转换,对于超大整数或某些无法转成字符串的特殊场景(其实很少见)会更灵活,但边界情况更多。

我最终在正式代码里选了字符串方案,不是因为数学方案不好,而是因为这道题考察的核心是“频率统计”而不是“进制转换”,用字符串方案可以把注意力集中在统计逻辑上,也方便读者理解。如果你想要更极致的性能,或者想练习取余运算,数学方案也完全值得写一遍。

3. 完整代码与关键注解

3.1 字符串版的完整代码

下面是我最终提交的版本,我把每一步的意图都写在注释里了:

package main import ( "fmt" "strconv" ) func leastFrequentDigit(n int) int { // 1. 将整数转为字符串,负号也会包含在内 s := strconv.Itoa(n) // 2. 长度为 10 的数组,下标 0~9 对应数字 0~9 var cnt [10]int // 3. 遍历字符串中的每个字符 for _, ch := range s { // 只统计数字字符,跳过负号等其他符号 if ch >= '0' && ch <= '9' { cnt[ch-'0']++ } } // 4. 找出现次数最少的最小数字 minCount := int(^uint(0) >> 1) // 初始化成 int 类型的最大值 answer := 0 for d := 0; d <= 9; d++ { if cnt[d] == 0 { // 关键:跳过没出现过的数字 continue } if cnt[d] < minCount { minCount = cnt[d] answer = d } } return answer } func main() { tests := []int{0, 1, 11, 122, 112233, -122, 12345, 9876543210} for _, n := range tests { fmt.Printf("n=%d -> %d\n", n, leastFrequentDigit(n)) } }

这里最需要注意的是第 4 步里的continue。我见过很多人在这个位置不写continue,结果把从未出现的数字当成候选者,最终答案永远是 0。如果你把continue去掉,把条件改成if cnt[d] <= minCount,那cnt[d] == 0的情况下d会一直被更新,最后返回最小的从未出现的数字。这就是我在 1.1 里强调的隐藏条件,代码里必须由这个continue来体现。

minCount初始化为int最大值,这个写法int(^uint(0) >> 1)在不同位数的 Go 环境下都能正确表示 int 的最大值,比写死math.MaxInt32更稳妥。当然,如果你确定n的取值范围不会超过 32 位,直接用math.MaxInt32也行,但既然 Go 的int在 64 位机器上是 64 位的,用这种通用初始化方式更有“平台无关”的味道。

3.2 数学版的完整代码

如果你想练习纯数学写法,可以参考下面这个版本:

package main import "fmt" func leastFrequentDigitMath(n int) int { var cnt [10]int // 对 0 特殊处理 if n == 0 { cnt[0] = 1 } else { m := n if m < 0 { m = -m } for m > 0 { cnt[m%10]++ m /= 10 } } minCount := int(^uint(0) >> 1) answer := 0 for d := 0; d <= 9; d++ { if cnt[d] == 0 { continue } if cnt[d] < minCount { minCount = cnt[d] answer = d } } return answer } func main() { tests := []int{0, 1, 11, 122, 112233, -122, 12345, 9876543210} for _, n := range tests { fmt.Printf("n=%d -> %d\n", n, leastFrequentDigitMath(n)) } }

数学版的核心逻辑都一样,唯一多出来的就是if n == 0这个分支。这里我建议用n == 0而不是m == 0来判断,因为n是原始输入,一旦把m取绝对值后再判断,就丢失了“原始值为 0”这个信息,虽然实际上m == 0也能判断,但可读性不如直接判断n。

这个版本不导入strconv,从依赖上来看更“轻”,但逻辑上多了两个 if,反而更容易出错。我实际测试过n = -122,正确输出是 1,因为负号被去掉了,数字 1 出现一次,数字 2 出现两次。如果你忘了处理负数,m%10会得到负余数,数组下标直接变成负数,轻则统计错误,重则 panic,这种情况在刷题平台上可不会给你好脸色看。

3.3 关于 int64 与溢出的一个细节

用数学方案处理负数时,m = -m这行代码有一个非常经典的溢出隐患:如果m是math.MinInt64,也就是-9223372036854775808,那么取反后应该是9223372036854775808,但这个数已经超出了 int64 的最大值9223372036854775807,在 Go 里会直接溢出,结果是很多编译器依赖的具体行为,不保证正确。

对于这道题的常见输入范围,可能不会真的遇到MinInt64,但作为严谨的工程实践,我建议:如果题目没有明确n的范围,最好直接用字符串方案,因为字符串方案天然不涉及取绝对值,也就没有溢出风险。如果非要用数学方案,可以把m的类型改成uint64,先做一次类型转换,但那样代码会变得更复杂,没必要为了省一个strconv的导入给自己埋雷。

另外,Go 的int类型在 32 位和 64 位平台上长度不同,如果你提交到需要跨平台编译的代码库,字符串方案的可移植性也更好。所以我的结论很明确:这道题里,字符串方案是“收益/风险比”最高的选择。

4. 测试用例设计与踩坑实录

4.1 手写一组覆盖边界情况的用例

写代码的人不能只靠题目自带的测试用例,我习惯自己列一张表,把能想到的边界情况都过一遍。下面是我就这道题整理的一组用例:

输入 n期望输出说明
00只有数字 0,出现 1 次
11只有数字 1,出现 1 次
111数字 1 出现 2 次,唯一出现过
12211 出现 1 次,2 出现 2 次
11223311、2、3 都出现 2 次,并列取最小 1
-1221负号不计,1 出现 1 次,2 出现 2 次
123451每个数字都出现 1 次,并列取最小 1
98765432100所有数字都出现 1 次,最小是 0
1000210 出现 3 次,1 出现 1 次
99988877777、8、9 各出现 3 次,并列取最小 7

这些用例里,112233和999888777特别能考察“并列取最小”的逻辑。112233中 1、2、3 各出现两次,如果不做“取最小数字”的处理,可能会返回 1,但如果你用<=而不是<去更新 answer,遍历到 2 的时候由于cnt[2] == minCount就会把 answer 覆盖成 2,最后返回 2,这就错了。所以更新最小值时,一定要用严格小于<,这样只有当某个数字的频次严格低于当前最小值时才更新 answer,并列时保持前面更小的数字不变。

4.2 三个最常见的翻车点

我在实际写这道题的过程中,以及帮别人 review 代码时,发现大家翻车的点几乎集中在下面三个地方:

第一个是把没出现过的数字当作候选者。具体表现就是遍历cnt数组找最小值时,没有continue掉cnt[d] == 0的项。这种错法非常隐蔽,因为如果输入的数字比较“全”,比如n = 1234567890,所有数字都出现过,那确实不会出错;可一旦输入n = 123,结果立刻变成 0,测试用例直接失败。

第二个是忘记处理负号。用strconv.Itoa时,字符串里可能带'-',用数学法时可能存在负余数。最常见的错误代码是直接for _, ch := range s { cnt[ch-'0']++ },没加if ch >= '0' && ch <= '9'。这样'-'会被转换成'-' - '0',也就是-3,数组下标直接就 panic 了。这种错误在本地可能偶发,但一旦输入负数就必现,非常恶心。

第三个是并列时用错了比较条件。我见过有人写if cnt[d] <= minCount,这样每遇到一个频次相同的数字,answer 就被更新成更大的数字,等遍历完0到9,answer 会变成“频次最低的那些数字里最大的一个”,完全违背了“取数值最小”的要求。正确做法是if cnt[d] < minCount,只有严格更小才更新。

这三个坑,每一个我都踩过,或者说看过别人踩过。尤其是第一个坑,特别能迷惑人,因为很多测试用例会故意把全数字都出场的场景当成 happy path,让你觉得代码没问题,等你提交到线上平台,隐藏用例n = 100一跑,就原形毕露了。

4.3 如果题目改成“频率最高”怎么办

顺手说个变体,如果题目改成“找出出现次数最多的数字,并列时取数值最大”,你会怎么改?其实核心逻辑不变,只需要把minCount初始化为-1,然后判断条件改成if cnt[d] > maxCount,同时遍历顺序改成从9到0,或者用>=更新取最大,都能解决问题。

这种“改一个条件就变成新题”的题目,特别适合拿来训练自己对条件的敏感度。我自己的方法是:永远先想清楚“候选集合是什么”“比较规则是什么”“并列规则是什么”,然后再动手写代码。一旦这三个要素想清楚了,代码结构基本不会大改,变的只是初始化值、比较符号和遍历方向。

5. 延伸:这道题的工程味道

5.1 频率统计在代码里无处不在

别看这道题简单,频率统计在实际工程项目里出现频率极高。比如日志系统里要统计 ERROR、WARN、INFO 各有多少条;电商系统要统计某个 SKU 一周内被点击了多少次;用户画像系统要统计某个行为特征在用户群体里的分布情况。这些本质上都是“统计离散值的出现次数”,和这道题的数位统计完全同构。

如果你在做这些系统,思路也是一样的:先确定候选值集合(日志级别、SKU 列表、行为类型),然后用数组或 map 记录频次,最后按业务规则找最大值、最小值、TopN。这中间的关键点和刷题完全一致:要不要把没出现的候选值算进去。比如统计日志级别时,如果某天没有 ERROR 日志,那 ERROR 的频次是 0,但你通常不会把 ERROR 当成“最少出现”的级别去告警,因为“没出现”在很多业务场景里不代表“正常”,它可能意味着数据缺失。这就是刷题带给工程思维的启发:算法题的边界条件,往往对应真实系统的业务规则。

5.2 Go 语言里处理数字的常用套路

借着这道题,我也把 Go 里处理数字和字符串的几个常用套路总结一下:

  • strconv.Itoa(n):int 转字符串,最常用。
  • strconv.Atoi(s):字符串转 int,注意返回两个值,必须先处理 error。
  • fmt.Sprintf("%d", n):也能转字符串,但性能略低于Itoa,而且可读性差不多的场景下我优先用Itoa。
  • for _, ch := range s:遍历字符串字符,得到rune,如果需要 ASCII 数字,直接减'0'。
  • var cnt [10]int:固定长度数组,零值初始化,非常适合频次统计。
  • int(^uint(0) >> 1):获取 int 类型的最大值,平台无关。

这套组合拳我在写各种字符统计工具时反复用,几乎成了肌肉记忆。尤其是var cnt [10]int这种写法,在 Go 里比make([]int, 10)更简洁,而且数组是值类型,在函数间传递时不会因为 slice 的引用共享而搞出副作用。当然,缺点是不能动态扩容,但对于固定 10 个数字的场景,这反而是优点。

5.3 从这道题能学到的通用思路

最后我想说说这道题给我留下的三个通用方法论。

第一,固定候选集合时优先用数组,不要滥用 map。数组下标天然就是候选值,查找是 O(1),没有哈希开销,代码也更贴近业务语义。只有当候选值不是连续整数,或者范围很大时,才值得用 map。

第二,边界条件要单独列清单。每次刷题,我都建议先花 30 秒把“空值”“负数”“0”“最大值”“并列情况”这五个关键词写下来,然后逐个想清楚代码会怎么走。这道题里的 0 和负数,就是最容易炸的两个点。

第三,比较逻辑的符号选择必须和题意严格对齐。<和<=差一个字符,结果可能天差地别。遇到“取最小”就用严格小于,遇到“取最大”就用严格大于,不要因为懒而混用,否则并列规则一旦变化,代码就要跟着改,很容易漏。

我在实际调试这段代码时,还意外发现 Go 的strconv.Itoa对负数转字符串时,负号和数字之间没有空格,所以len(s)会比纯数字位数多 1。如果你用n的位数来推算字符串长度,记得把负号算进去,否则在其它需要精确长度的场景会出怪问题。这也算是一个小小的经验吧。

这道题的整体难度不高,很适合作为热身题,但它的价值在于把“统计”“边界”“并列”这三个算法题常见考点揉在了一起。如果你能一次性把所有边界都处理好,说明你写代码的细心程度已经超过很多人了。我自己写完后再回头看,最大的收获不是学会了某个 API,而是重新理解了“数组下标即键值”这个最朴素却也最实用的设计思路。下次再遇到类似的统计题,我大概率会直接套这个模式,省时又省心。

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

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

立即咨询