记一个 golang 切片使用的坑
今天在写 leetcode,做一道比较简单的题,看上去一个 dfs 就可以搞定:https://leetcode.cn/problems/combination-sum
然而交上去以后报了个奇怪的 WA:
鉴于其他 case 都是对的,只有一个答案莫名其妙错了一个数字,而且看我的逻辑,怎么都不应该把这个2写进答案里:
var ans [][]int
func dfs(candidates []int, target int, now []int, index int) {
if target < 0 {
return
}
for i := index; i < len(candidates); i++ {
v := candidates[i]
targetLeft := target - v
if targetLeft == 0 {
newNow := append(now, v)
ans = append(ans, newNow)
continue
}
if targetLeft < 0 {
continue
}
newNow := append(now, v)
dfs(candidates, targetLeft, newNow, i)
}
}
func combinationSum(candidates []int, target int) [][]int {
ans = make([][]int, 0)
now := make([]int, 0)
dfs(candidates, target, now, 0)
return ans
}
于是开始用调试器 debug。需要注意,如果要查看栈外的全局变量,需要使用调试器的 evaluate 功能手动输入全局变量名。
在 append ans数组处打断点,发现对应答案 {3,3,3,3,3,3} 在 append 的时候成功加入了 ans 数组,而不是 {3,3,3,3,3,2}。
继续运行,发现在某一后续递归调用时刻,这个答案居然变成了{3,3,3,3,3,2},但我的逻辑中除了加入的时候,不应该有机会修改已经加入了的 ans 的元素。
于是怀疑这里的切片操作在底层维度上有问题。经过排查,确认了这个问题就是由隐藏的对切片底层数组的修改导致的。
之前版本的核心逻辑,去掉dfs的递归,简化如下:
newNow := append(now, v)
ans = append(ans, newNow)
anotherNow := append(now, v2)
我们的目的是设一个比now多一个v的新数组,然后把这个数组加入 ans。同时,后续会有对now的其他 append操作。这里简化了递归传递dfs(candidates, targetLeft, newNow, i)
,虽然传了一个新的切片newNow
,但这个新的切片也是原来 now 一样的底层数组的引用,所以这里就可以简化为 now。在编写时没有考虑到这一点,所以主观认为在递归时传递了一个新的独立的数组,从而导致后续的问题。
我们知道,golang 的切片是对底层数组的封装,主要维护长度和容量两个属性。在基于一个切片创建新的切片时,runtime 会根据容量是否满足操作需求来决定是沿用同一个底层数组,还是建一个新的。也就是说,使用 append 追加元素时,根据容量,有可能使用同一个数组,也有可能新建一个;而截取子切片时,就不会创建新的底层数组。如果底层数组是同一个,那么修改一个其切片,关联的切片也会受到影响。
最重要的是要理解切片的 struct 本身虽然是值传递,但切片实际表示了数组的引用传递。因此,在明确需要修改时,应当使用传值的方法 copy 一份数据到新切片,而不是直接用 append 或裁剪子切片的方式。
上述代码第一句修改为:
newNow := make([]int,len(now))
copy(newNow, now)
newNow = append(newNow, v)
顺利 ac。
总之要注意,在数据需要相对持久的保存的时候,就需要考虑所有与该切片相关的切片可不可能被隐式或显式的修改。在这个case里,就是在回溯到上一个节点的时候,对 now 进行了重新 append,导致修改到了和储存在答案中的切片的同一个底层数组中的元素。如果有修改的可能,果断用 copy 函数拷贝一份新的。