
今天刷到一道 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得到的是bytefor _, 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 0for 循环一次都不会执行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 次并列取最小 198765432100所有数字都出现 1 次最小是 01000210 出现 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到9answer 会变成“频次最低的那些数字里最大的一个”完全违背了“取数值最小”的要求。正确做法是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而是重新理解了“数组下标即键值”这个最朴素却也最实用的设计思路。下次再遇到类似的统计题我大概率会直接套这个模式省时又省心。