最近在 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,看起来a=5, b=5, c=1得到 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],你选a=7, b=7, c=7,结果就是 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,因为 2+3-1=4。[3, 1, 2]输出 4,因为 3+2-1=4。[5, 1, 5]输出 9,因为 5+5-1=9。[100, 1, 50]输出 149,因为 100+50-1=149。[-1, -2, -3]输出 0,因为 -1 + (-2) - (-3) = 0。[10, 10, 10]输出 10,因为任意组合结果都是 10。[7, 7, 1]输出 13,因为 7+7-1=13。
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=-1,max2=-2,min1=-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 语言实现时,结构体切片排序是最清晰的选择,线性扫描是更高级的玩法。希望这篇文章能帮你彻底搞懂这道题,以后再遇到类似的三元素组合最值问题,都能做到心里有底。