ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Go语言实现:统计出现次数可被k整除的元素值之和

Go语言实现:统计出现次数可被k整除的元素值之和 这道题我最早是在一次算法练习里遇到的当时第一反应是“这不就是个统计频次的题嘛”结果写完一提交才发现题目里“元素所贡献的总和”这几个字特别容易让人理解偏。有人把它理解成“把出现次数累加”有人把它理解成“把元素值累加”还有人把“整除”理解成“能被k整除的元素值加起来”。如果不把题意彻底嚼碎后面写得再快也是白写。这篇文章就围绕这个题目来拆用Go语言怎么实现怎么理解“出现次数能被 k 整除的元素所贡献的总和”以及我在实际编写、测试和提交过程中踩过的几个坑。无论你是刚接触Go的初学者还是准备刷题的求职者这篇内容都可以直接参考。1. 先拆题这道题要算的到底是什么1.1 题目描述精读题目给出一个整数数组nums和一个整数k要求计算数组中某些“元素”的总和。这些元素需要满足一个条件它们在数组中出现的次数能够被k整除。关键信息拆开来看有三层第一层需要统计每个不同元素在数组中出现的次数也就是频次。第二层判断每个频次是否能被k整除即count % k 0。第三层把满足条件的“元素值”累加起来而不是把“出现次数”累加起来。举个例子假设nums [1, 2, 1, 3, 2, 1]k 2。先统计频次元素出现次数132231再判断元素 1 出现 3 次3 % 2 1不满足不累加。元素 2 出现 2 次2 % 2 0满足累加 2。元素 3 出现 1 次1 % 2 1不满足不累加。最终结果是2。这里最容易出错的地方元素 2 出现了 2 次最后累加的是元素值2不是出现次数2。当元素值和出现次数恰好相等时很容易让人产生混淆但绝大多数情况下它们并不相等。1.2 输出到底是“元素值”还是“出现次数”我见过不少人在讨论区问这题到底加的是value还是count这里必须拎清楚。题目原文是“求出数组中那些出现次数能被 k 整除的元素所贡献的总和”主语是“元素”所以贡献的主体是元素的值。也就是说当一个元素符合条件时我们把它的值加入总和。至于出现次数它只是用来做整除判断的“门槛”不是求和对象。有一个变体题型也很常见改成“出现次数能被k整除的元素其出现次数之和”。那结果就完全是另一个数了。以刚才的例子来说如果变体问的是“次数之和”答案应该是2因为元素 2 的出现次数是 2但原题问的是“元素值之和”答案同样是2数据凑巧一样而已。换成nums [5, 5, 5, 5],k 2元素 5 出现 4 次4 % 2 0原题答案是5而变体答案是4。差一个数字整个逻辑就不同。所以读题时我会习惯性地把“主语”圈出来。题目问的是“元素贡献的总和”那就先找元素再看这个元素的值。1.3 边界条件梳理动手写代码前先把边界条件想清楚能省掉很多反复调试的时间。这个题目的边界条件主要有这几个nums为空数组没有任何元素总和为0。nums中所有元素都只出现一次频次都是1。只有当k 1时所有元素才满足条件总和就是数组所有元素之和当k 1时没有任何元素满足结果为0。k 1任何正整数都能被 1 整除所以所有元素的出现次数都满足条件直接返回整个数组所有元素的和。元素值可能为负数题目没有说元素值一定是非负的。如果出现负数元素且满足频次条件需要正常累加。k可能很大比如k大于数组中任何一个元素的出现次数那么只有当某个元素出现次数恰好等于k的倍数时才满足条件。由于频次不可能超过数组长度所以此时很可能结果为0。元素重复且分布不均像[1, 1, 2]k 2元素 1 出现 2 次满足累加 1元素 2 出现 1 次不满足。结果是1。把这些边界都想通之后写出来的代码才不会在某些特殊用例上翻车。2. 从暴力解法出发先跑通再谈优化2.1 暴力思路双重循环逐个统计我第一次遇到这类题时第一版代码并不复杂思路非常直观遍历数组中的每一个元素对每个元素再遍历一次整个数组统计它出现的次数然后判断次数是否能被k整除。如果可以就把这个元素值累加到结果中。这个思路的伪代码大概是初始化总和total 0。遍历数组的每一个位置i。对于每个nums[i]统计它在整个数组中出现的次数count。如果count % k 0就把nums[i]加到total。由于数组中重复元素会被多次处理需要想办法避免重复累加同一个元素。这里有个关键细节如果数组里有重复元素比如[1, 1, 2]你在遍历第一个1时会把1加进去遍历第二个1时又会加一次结果就错了。所以暴力解法还要引入一个“是否已经处理过”的标记比如用一个集合记录已经处理过的元素。2.2 暴力版本的Go实现用 Go 写暴力版代码并不长func sumOfElementsDivisibleByK(nums []int, k int) int { total : 0 processed : make(map[int]bool) for i : 0; i len(nums); i { val : nums[i] if processed[val] { continue } processed[val] true count : 0 for j : 0; j len(nums); j { if nums[j] val { count } } if count % k 0 { total val } } return total }这段代码能跑而且在小数组下结果完全正确。processed这个 map 保证了每个元素只会被判断一次避免重复累加。2.3 暴力解法的问题出在哪暴力解法最大的问题当然是时间复杂度。外层循环遍历n个元素内层循环又遍历n个元素整体复杂度是O(n^2)。当数组长度是几千时还能凑合一旦到了几万、几十万运行时间会急剧上升。举个例子n 100000时内层循环总共要执行约10^10次比较。Go 虽然性能不错但这样的操作量在普通机器上也轻松超过一秒甚至更久。如果题目限制时间复杂度为O(n)或O(n log n)暴力解法直接超时。另外processedmap 虽然解决了重复累加的问题但也带来了额外的内存开销和哈希计算。整体来看暴力解法的价值在于帮助理解题意而不是作为最终提交版本。3. 哈希表统计 整除判断标准解法拆解3.1 核心思路一次遍历完成统计优化的方向很明确用一次遍历统计所有元素的出现次数然后再遍历频次表判断哪些元素的出现次数能被k整除累加对应的元素值。这个过程分两个阶段第一阶段遍历nums用一个map[int]int记录每个元素出现的次数。这一步是O(n)。第二阶段遍历 map对每个键值对(element, count)判断count % k 0。满足条件就把element加到结果里。这一步的复杂度取决于不同元素的数量最多也是O(n)。总时间复杂度O(n)空间复杂度O(m)其中m是数组中不同元素的个数。这个方案在绝大多数场景下都是最优解。3.2 完整Go代码实现直接上代码func sumOfElementsDivisibleByK(nums []int, k int) int { if k 0 { return 0 } freq : make(map[int]int) for _, num : range nums { freq[num] } total : 0 for element, count : range freq { if count%k 0 { total element } } return total }整个实现非常简洁核心就是freq[num]和count%k 0这两件事。有人可能会问为什么不直接在第一个循环里累加答案是频次没统计完之前你无法判断某个元素最终的出现次数是否能被k整除。比如遍历到某个元素的第一次出现时它的频次是 1如果k 2你此时判断不满足条件但后面它可能还会出现第三次最终频次变成 3依然不满足但也可能后面只再出现一次最终频次变成 2满足了。只有等整个数组遍历完频次才是最终值所以必须先完成统计再进行判断。3.3 为什么这里用map[int]int而不是数组计数刚开始刷题的人容易想到“用数组下标作为元素值”来计数类似count[num]。这在元素值范围很小且非负的情况下确实更快比如元素值都在0~1000之间。但这里有个问题题目并没有限制nums[i]的取值范围。元素可能是负数也可能达到10^9甚至更大。用数组计数时数组长度至少要覆盖元素值的范围。如果最大值是10^9你不可能开一个长度为10^9的数组内存直接爆掉。map[int]int只存储实际出现的元素内存占用与不同元素的数量成正比而不是与元素值的范围成正比。这是它在这种场景下的核心优势。当然map 的哈希计算比数组索引慢一些但换来的是对元素取值范围的完全容忍。如果题目额外说明元素值范围很小比如0 nums[i] 100那用[]int数组计数是更好的选择代码可以写成func sumOfElementsDivisibleByK(nums []int, k int) int { maxVal : 100 freq : make([]int, maxVal1) for _, num : range nums { freq[num] } total : 0 for element, count : range freq { if count 0 count%k 0 { total element } } return total }注意这里的total element因为element实际上是数组下标也就是元素值本身。3.4 判断“能被k整除”的细节count % k 0是判断整除的标准写法。要注意的是k必须为正整数因为取模运算在k 0时会导致运行时 panic在k为负数时虽然 Go 允许取模但“能被负数整除”在数学上通常没有实际意义。所以我在实现里加了一行if k 0 { return 0 }一是防 panic二是提前处理非法输入。有些在线评测平台不会给你k 0的用例但自己写单元测试时还是得防一手。另外Go 的取模运算结果符号与被除数一致比如-3 % 2 -1-4 % 2 0。但因为我们在判断里用的是出现次数count它一定是非负数所以不存在负数取模的困惑。4. 复杂度分析与边界用例验证4.1 时间复杂度和空间复杂度标准解法的时间复杂度是O(n)因为两个循环都是线性遍历。空间复杂度是O(m)m是不同元素的个数。最坏情况下数组中所有元素都不相同m n空间复杂度退化为O(n)。这已经是最优的空间表现了因为要统计频次不可能完全不使用额外空间。暴力解法的时间复杂度是O(n^2)空间复杂度同样是O(m)。两者对比非常明显解法时间复杂度空间复杂度适用场景暴力双重循环O(n^2)O(m)数组长度极小仅用于理解思路map统计O(n)O(m)常规场景推荐使用当n 100000时暴力解法大约需要10^10次操作而 map 解法只需要2 * 10^5次操作左右差距是数量级的。4.2 测试用例设计写完代码后我会习惯性地用一组覆盖边界的用例来验证。这里分享几个我常用的测试用例func main() { tests : []struct { nums []int k int want int }{ {[]int{1, 2, 1, 3, 2, 1}, 2, 2}, {[]int{5, 5, 5, 5}, 2, 5}, {[]int{}, 3, 0}, {[]int{1, 2, 3}, 1, 6}, {[]int{1, 2, 3}, 2, 0}, {[]int{-1, -1, 2}, 2, -1}, {[]int{1, 1, 2, 2, 3, 3}, 3, 0}, {[]int{1, 1, 1, 2, 2, 2}, 3, 3}, } for _, tt : range tests { got : sumOfElementsDivisibleByK(tt.nums, tt.k) if got ! tt.want { fmt.Printf(失败: nums%v k%d 期望%d 实际%d\n, tt.nums, tt.k, tt.want, got) } } fmt.Println(测试完成) }逐个分析这些用例[1,2,1,3,2,1],k2元素 1 出现 3 次不满足元素 2 出现 2 次满足元素 3 出现 1 次不满足结果 2。[5,5,5,5],k2元素 5 出现 4 次满足累加元素值 5结果 5。这个用例专门用来验证“加元素值而不是加次数”。[],k3空数组结果 0。[1,2,3],k1每个元素出现 1 次都能被 1 整除全部累加结果 1236。[1,2,3],k2每个元素的出现次数都是 1不能被 2 整除结果 0。[-1,-1,2],k2元素 -1 出现 2 次满足累加 -1元素 2 出现 1 次不满足。结果 -1。这验证了负数值的累加。[1,1,2,2,3,3],k3每个元素出现 2 次都不能被 3 整除结果 0。[1,1,1,2,2,2],k3元素 1 出现 3 次满足累加 1元素 2 出现 3 次满足累加 2。结果 3。这组用例涵盖了空数组、k1、无满足元素、负数元素、重复元素、频次恰好多倍等场景跑完基本能确定代码逻辑没有大问题。4.3 大数与大数组的表现如果nums里元素值很大比如[1000000000, 1000000000, -1000000000]map 依然能正常工作。Go 的int在 64 位系统上是 64 位累加过程中不太可能溢出。万一题目把数组长度和数据范围放到极端比如n 10^6元素值达到10^9那么所有元素的和可能超过2^31 - 1。在 Go 中int类型在 64 位平台上能表示远大于这个范围的值所以不用担心。但在 32 位平台上int只有 32 位累加可能会溢出。实际面试或竞赛环境中通常会用int64存储结果更保险。如果不想依赖平台位数可以显式使用int64func sumOfElementsDivisibleByK(nums []int, k int) int64 { freq : make(map[int]int) for _, num : range nums { freq[num] } var total int64 for element, count : range freq { if count%k 0 { total int64(element) } } return total }这个版本更稳特别是在大型数据场景下。不过也要注意如果题目要求返回值是int那就按题目的签名来不要自己改成int64导致类型不匹配。5. 实战中容易踩的坑与变体题提示5.1 坑一把出现次数当成了求和对象这是我在讨论区看到最多人犯的错。题目要求“元素所贡献的总和”有相当一部分人写成了if count%k 0 { total count // 错误这里是出现次数不是元素值 }为什么容易写错因为很多频次统计题的经典问法是“求出现次数满足某条件的元素个数”或者“求这些元素的出现次数之和”。这类题做多了看到count % k 0就条件反射地累加count。我的经验是在编码前先把“我要累加什么”写在注释里// 注意满足条件时累加的是元素值 element而不是频次 count if count%k 0 { total element }写注释不是为了给别人看是为了提醒自己。尤其在时间紧张的笔试环节这种细节非常致命。5.2 坑二k0或负数没有处理理论上k应该是正整数。但实际写代码时如果输入的k 0直接执行count % 0会触发 panic程序直接崩溃。我在本地测试时偶尔会手滑传个 0吃过一次亏后就在函数开头加了防御if k 0 { return 0 }对竞赛场景来说这样的防御可能多余但作为一个健壮性习惯我还是保留着。因为这段代码不仅可能被评测系统调用也可能被同事、朋友在不知情的情况下传入异常参数。还有一种情况k为负数比如k -2。虽然 Go 里count % -2不会报错结果也和count % 2一致但“能被 -2 整除”的说法本身就有点别扭。加一层k 0判断逻辑更清晰。5.3 坑三map遍历顺序与结果无关Go 的 map 遍历顺序是随机的这是语言设计的一部分。但在这个题目里遍历顺序完全不影响最终结果因为我们只是对每个键值对独立做判断和累加加法满足交换律。不过要注意如果你在累加过程中依赖了某种顺序比如“先处理小的再处理大的”那就不能直接遍历 map而是要先把 key 取出来排序。这个题目不需要排序所以直接遍历即可。但如果你把这段代码扩展到其他场景比如按元素值从小到大输出符合条件的元素就必须显式排序。5.4 变体题可能变成“次数能被k整除的元素个数”算法题经常会换皮。这个题目的变体至少有这几种求满足条件的元素个数把total element改成count就行。求满足条件的元素出现次数之和把total element改成total count。求满足条件的元素值中的最大值或最小值遍历时用max、min函数替换累加。如果k很大超过数组长度那几乎没有元素满足条件直接返回 0 即可可以在统计前先判断k len(nums)这样连 map 都不用初始化。我在实际面试中遇到过一个类似的题“找出数组中出现次数为奇数的所有元素之和”。这就是k 2的特殊情况。解法完全一样只是把count % 2 0的条件改成count % 2 1。有了这个通用模板遇到这类题基本上就是改一行条件的事。再分享一个后期优化的小技巧如果数组很大但k远大于不同元素数量可以在统计过程中提前剪枝。每当我们发现某个元素的出现次数已经超过k且不是k的倍数时理论上它之后还可能增加到k的倍数。所以提前剪枝反而容易出错。不如老老实实统计完再判断逻辑最简单也最不容易出 bug。最后说点实战体会这个题目本身不难但它是很好的“读题训练题”。我后来在复盘时发现自己第一次写错不是因为不会用 map而是因为没搞明白“谁贡献总和”。从这以后我做所有算法题都会先花一两分钟圈定题目里的“主语”确认最后要输出的是值、次数、个数还是索引。这个习惯帮我避免了很多低级错误。代码写到后期我会把sumOfElementsDivisibleByK这个函数放到一个公共工具包里配合单元测试一起维护。这样下次遇到变体题时只需要复制过来改一行条件比每次从头写要快得多。如果你也想复用建议把测试用例也一起留下来改动后直接跑一遍比肉眼检查靠谱得多。
返回列表