Go语言实现三元素表达式最大值:从排序到线性扫描的算法实践

📅 发布时间:2026/9/16 3:45:44
Go语言实现三元素表达式最大值:从排序到线性扫描的算法实践
最近在 Go 语言练习群里看到有人发了一道很有意思的数组题给定一个数组nums从里面挑三个下标互不相同的元素a、b、c让表达式a b - c的值尽可能大。乍一看就是个排序取数的题目但实际上手之后才发现“下标互不相同”这个约束才是真正需要想清楚的地方。这篇文章就围绕这个三元素表达式最大值问题把我的思考过程、Go 代码实现以及踩过的坑完整记录下来。题目本身不复杂但很适合拿来巩固 Go 语言基础尤其是结构体切片排序、边界条件处理、测试用例设计这些东西。无论你是刚开始学 Go 的初学者还是刷题想找点手感的开发者这篇都能给你一些可以直接抄作业的思路。1. 题目分析与直觉建立1.1 表达式拆解谁在帮忙谁在拖后腿先看这个式子a b - c。如果我们想让最终结果最大最直观的想法当然是让a和b尽量大让c尽量小。因为a和b是加分项越大越好c是减分项越小越好。但事情没这么简单题目加了三个字下标互不相同。也就是说你不能拿同一个元素又当a又当c哪怕它的值再合适也不行。这个约束直接排除了很多“看起来很美”的组合。举个例子假如数组是[5, 1, 5]全局最大值是 5最小值是 1看起来a5, b5, c1得到 9。确实这里有两个 5 且下标不同完全合法。但如果是[5, 1, 4]最大值 5 和次大值 4 的下标不同最小值 1 的下标也不同所以5 4 - 1 8就是答案。这里约束没有带来任何麻烦。真正需要思考的是是否存在一种情况全局最小的那个数它的下标正好是我们要选的两个最大数之一如果真有这种情况那简单粗暴的“取最大两个减最小一个”就失效了。1.2 下标互不相同的本质我们来认真分析一下这个下标冲突问题。假设nums里全局最大值是max1下标是i1全局次大值是max2下标是i2全局最小值是min1下标是i0。如果i0既不是i1也不是i2那就万事大吉直接max1 max2 - min1就是答案。如果i0和i1重合说明什么说明同一个下标i0对应的元素既是整个数组的最大值又是整个数组的最小值。一个数要想同时当最大和最小那只有一个可能数组里所有元素都等于这个值。也就是说nums的所有元素都相等。那这时候我们怎么办呢既然所有元素都相等那么任意选三个不同下标的元素值都一样。比如数组是[7, 7, 7]你选a7, b7, c7结果就是 7跟max1 max2 - min1 7 7 - 7 7完全一样。所以即便下标重合最终结果也不受影响。同理如果i0和i2重合也是同样的情况。所以结论是在数组长度至少为 3 的前提下我们其实永远可以直接用“最大的两个不同下标的元素之和减去最小的那个元素”作为答案。下标互不相同的限制虽然存在但不会改变结果的取值。这算是一个比较反直觉的结论也是这道题最妙的地方。1.3 这道题适合练什么这类题非常适合拿来练 Go 语言的基础操作因为它的数据规模没给但按常规编程题来看很可能n很大甚至大到不能接受O(n^3)的暴力枚举。我们需要找到一个稳定、可扩展的解法。同时它也是一个很好的“先证明再写代码”的例子如果上来就写暴力三重循环代码虽然简单但跑大数组直接超时如果直接排序取数又担心下标冲突心里不踏实。只有把数学性质想清楚了写出来的代码才干净且正确。2. 算法设计方案2.1 方案一结构体切片排序取首尾最简单的思路是对数组进行一次排序。但排序会丢掉原始下标所以我们需要把值和下标绑在一起用结构体切片来排。具体做法构造一个结构体数组每个元素包含value和index。按value从小到大排序。排序后倒数第一个和倒数第二个就是两个最大的不同下标的元素。正数第一个就是最小的元素。答案就是倒数第一个.value 倒数第二个.value - 正数第一个.value。时间复杂度是O(n log n)空间复杂度O(n)。这个方案最直观代码也最好写。为什么不用 Go 自带的sort.Ints直接排序因为如果只对值排序下标信息就丢了没法去重。结构体切片配合sort.Slice是标准做法。2.2 方案二线性扫描找最值最优解既然我们已经证明了其实就是找三个特殊值最大的、次大的、最小的那完全不需要排序一次线性扫描就能搞定。维护三个变量max1、max2、min1分别记录最大、次大、最小。遍历数组的时候更新这三个值但这里有个小坑更新max1时原来的max1要顺移给max2而且要小心值相同的情况比如[10, 10, 1]遍历到第二个 10 时它应该变成max2而不是被忽略。线性扫描的细节比排序多一点但时间复杂度只有O(n)空间复杂度O(1)更适合追求极致性能的场景。2.3 方案三候选枚举防御性写法还有一种更稳妥、代码上更“无脑正确”的写法取出数组中值最大的前三个元素和最小的前三个元素然后在这些候选值里枚举组合检查下标是否互不相同取最大值。为什么取前三就够因为如果最优答案里的a不在最大的前三个里那我们肯定能找到至少一个比它更大的元素可以替换它结果不会变差。同理b也只需要从前三大里找c只需要从前三小里找。所以最多枚举3 * 3 * 3 27种组合a从三个大值里选b从三个大值里选c从三个小值里选但需要保证a和b下标不同且c的下标和a、b都不同。这个方案的好处是即使你懒得去证明“最大两个减最小”一定合法也能得到正确答案因为候选集合已经包含了所有可能的最优解枚举时又强制检查下标绝对不可能漏。代价是代码稍微长一点。2.4 方案对比方案时间复杂度空间复杂度代码量适用场景排序取首尾O(n log n)O(n)最少常规场景易读易维护线性扫描最值O(n)O(1)中等数据量极大追求速度候选枚举O(n log n)O(n)稍多想避免证明或想写得防御性强我个人在写博文示例时会更喜欢排序取首尾因为它直观适合教学而且O(n log n)对绝大多数场景都够用。但如果你在面试或竞赛中遇到这道题建议用线性扫描因为O(n)的复杂度更亮眼。3. Go 语言实现与核心代码3.1 定义元素结构体Go 里没有内置的 Pair 类型所以我们自己定义一个Element结构体用来同时保存值和原始下标。type Element struct { value int index int }这里要注意value和index的字段名不要用大写吗其实在包内使用完全没问题但如果你打算返回给 json 或者导出就需要大写。我们这里只是本地算法题用大写Value和Index会更规范一点避免后续 lint 提示。type Element struct { Value int Index int }为了让sort.Slice排序时更清晰我一般会把比较逻辑写在sort.Slice里而不是给Element实现接口。Go 的sort.Slice用起来是真的方便。3.2 排序函数的实现我们先构造一个切片然后按Value升序排序。func maxExpression(nums []int) int { n : len(nums) if n 3 { return 0 } elems : make([]Element, n) for i, v : range nums { elems[i] Element{Value: v, Index: i} } sort.Slice(elems, func(i, j int) bool { if elems[i].Value elems[j].Value { return elems[i].Index elems[j].Index } return elems[i].Value elems[j].Value }) a : elems[n-1].Value b : elems[n-2].Value c : elems[0].Value return a b - c }这段代码看起来很简单但有三个细节需要注意n 3时直接返回 0或者你觉得不合适也可以返回一个错误或者用math.MinInt表示无解。这里图省事返回 0但实际工程里建议返回error或者用哨兵值。排序时当Value相等时按Index排序这不是必须的但能让排序结果更稳定不会因为切片初始顺序不同导致结果不稳定。取elems[n-1]和elems[n-2]作为a、b取elems[0]作为c这依赖我们前面证明的结论不会出现下标冲突到影响结果的情况。虽然理论正确但代码里还是可以加一道防御性检查万一以后题目改动或者有人拿这个函数处理特殊数据也能及时发现问题。3.3 完整的可运行代码我习惯把输入解析、核心计算、输出结果都放在一个main函数里方便本地跑。下面是一个完整的示例package main import ( fmt sort ) type Element struct { Value int Index int } func maxExpression(nums []int) int { n : len(nums) if n 3 { return 0 } elems : make([]Element, n) for i, v : range nums { elems[i] Element{Value: v, Index: i} } sort.Slice(elems, func(i, j int) bool { if elems[i].Value elems[j].Value { return elems[i].Index elems[j].Index } return elems[i].Value elems[j].Value }) a : elems[n-1].Value b : elems[n-2].Value c : elems[0].Value return a b - c } func main() { testCases : [][]int{ {1, 2, 3}, {3, 1, 2}, {5, 1, 5}, {100, 1, 50}, {-1, -2, -3}, {10, 10, 10}, {7, 7, 1}, } for _, nums : range testCases { fmt.Println(nums, , maxExpression(nums)) } }这里我顺手写了一组测试用例跑一下看看结果[1, 2, 3]输出 4因为 23-14。[3, 1, 2]输出 4因为 32-14。[5, 1, 5]输出 9因为 55-19。[100, 1, 50]输出 149因为 10050-1149。[-1, -2, -3]输出 0因为 -1 (-2) - (-3) 0。[10, 10, 10]输出 10因为任意组合结果都是 10。[7, 7, 1]输出 13因为 77-113。3.4 进阶线性扫描实现如果你想秀一把操作可以用一次循环找最大、次大、最小完全避免排序。代码如下func maxExpressionLinear(nums []int) int { n : len(nums) if n 3 { return 0 } // 初始化最大、次大、最小注意不能直接都用 nums[0] max1, max2 : nums[0], nums[1] if max2 max1 { max1, max2 max2, max1 } min1 : nums[0] if nums[1] min1 { min1 nums[1] } for i : 2; i n; i { v : nums[i] if v max1 { max2 max1 max1 v } else if v max2 { max2 v } if v min1 { min1 v } } return max1 max2 - min1 }这里有个隐患如果nums[0]和nums[1]中有一个是全局最小值那么min1的初始值可能已经是nums[1]或nums[0]没问题。但如果你一股脑把max1、max2、min1都初始化成nums[0]遇到[1, 2, 3]时会出错因为max2初始也是 1遍历到 2 的时候v max1不成立v max2成立max2变成 2但max1还是 1最后最大两个数变成 1 和 2正确结果应该是 3 和 2。所以初始化时一定要把前两个元素先处理掉从下标 2 开始遍历。另一种更稳妥的初始化方式是用math.MinInt和math.MaxInt这样即使数组里有负数也能正确处理只是代码要长一点。4. 测试用例与边界场景验证4.1 常规用例先跑几个常规用例验证函数正确性。比如随机生成一个长度为 10 的数组用暴力三重循环枚举所有组合和我们的排序解法对比看结果是否一致。我在本地写了一个简单的暴力函数func bruteForce(nums []int) int { n : len(nums) ans : -1 63 for i : 0; i n; i { for j : 0; j n; j { if j i { continue } for k : 0; k n; k { if k i || k j { continue } val : nums[i] nums[j] - nums[k] if val ans { ans val } } } } return ans }然后随机造 1000 组数据每组长度在 3 到 15 之间数值范围在 -100 到 100 之间对比maxExpression和bruteForce的结果。我只改了一行代码接了个返回 bool 的函数跑了十分钟没发现不一致的情况。这说明排序取首尾的方案在这个数据范围内是稳的。4.2 边界场景边界场景才是最容易翻车的地方我总结了几个典型的第一数组长度正好为 3。这种情况下只有一种合法组合直接三个数全用上nums[0] nums[1] - nums[2]。我们的排序解法取a和b是最大的两个c是最小的那个结果一定和唯一组合一致。第二数组里有负数甚至全负数。比如[-1, -2, -3]暴力枚举发现最大组合是-1 (-2) - (-3) 0。排序解法同样能得到 0因为max1-1max2-2min1-3结果是 0。第三数组里有大量重复值。比如[10, 10, 10, 10]最大两个取两个 10最小取一个 10结果 10。这是对的。第四数组里最小值下标恰好是最大值下标之一。前面证明过这种情况下所有元素相等但我还是写了个测试用例去验证nums : []int{5, 5, 5}结果输出 5符合预期。第五数组非常大比如长度为 100000全是随机数。排序解法能秒出结果暴力解法直接卡死。这也说明选择正确的算法非常重要。4.3 如何写一个自动验证的小工具如果你不想在本地手动构造用例可以用 Go 的testing包写一个小测试函数专门用暴力结果和优化结果做对比。package main import ( math/rand testing ) func TestMaxExpressionRandom(t *testing.T) { r : rand.New(rand.NewSource(42)) for k : 0; k 1000; k { n : r.Intn(13) 3 nums : make([]int, n) for i : range nums { nums[i] r.Intn(201) - 100 } got : maxExpression(nums) want : bruteForce(nums) if got ! want { t.Fatalf(nums%v got%d want%d, nums, got, want) } } }这个测试跑起来很快相当于给你自己的实现加了一道保险。每次改完代码直接go test就能知道有没有破坏逻辑。5. 常见问题与避坑指南5.1 问题速查表容易踩的坑原因解决办法数组长度小于 3无法选出三个不同下标的元素函数开头判断 n 3返回 0 或 error排序后忘记记录原始下标后续无法区分下标是否相同用结构体保存Value和Index直接用sort.Ints排序下标信息丢失用sort.Slice配合结构体切片线性扫描时初始化不当max2初始成了nums[0]导致结果偏小先处理前两个元素或使用math.MinInt没有考虑负数初始值用 0 会导致负数数组结果错误初始值用nums[0]或math.MinInt全相等数组可能觉得下标冲突结果算错理解并接受最大值减最小值仍正确没有验证边界用例看起来正确实际上有隐藏 bug用暴力解法做随机对比测试5.2 实战心得我在写第一版实现时想当然地认为必须处理“最小值下标和最大值下标相同”的情况于是在排序之后加了一堆 if-else代码变得很丑。后来把数学性质想透了才发现根本不需要。这个经历告诉我遇到这种带着约束的题目先别急着写代码静下来想一想约束到底会不会影响结果。另外Go 语言里sort.Slice的排序稳定性其实不是保证的但因为我们同时用值和下标做了排序所以即使不稳定也不影响结果。如果你对排序稳定性有执念可以自己实现一个稳定排序或者用一个自定义的Less函数在值相同时比较下标这样就能保证排序结果是稳定的。还有一个实用技巧如果是在 LeetCode 或牛客这种平台写题输入数组可能用[]int返回值可能要求int64之类的需要根据题目要求调整。这里我用的是int在 64 位机器上足够但如果题目明确说数值范围大最好用int64或者提前判断溢出。表达式a b - c最多可能溢出到2 * MaxInt - MinInt不过在一般的编程题约束下int是安全的。最后再分享一个小技巧当你对一个算法不够自信的时候写一个暴力解法作为基准用随机数据测试对比。尤其是在 Go 语言里写暴力解法太香了闭包、切片、多重循环都很顺手几分钟就能写完跑一天都不累。这个方法帮我躲过了很多“看似正确实则漏了边界”的坑。这道三元素表达式最大值题核心就一句话让a和b尽量大让c尽量小。而下标互不相同的约束在数组长度足够时并不会改变这个结论。用 Go 语言实现时结构体切片排序是最清晰的选择线性扫描是更高级的玩法。希望这篇文章能帮你彻底搞懂这道题以后再遇到类似的三元素组合最值问题都能做到心里有底。