今天力扣刷到 LeetCode131(分割回文串) 的时候,将相同的C++代码转换为Go代码居然出问题了,经过我的一番调试,我发现一开始result
切片里的内容是正确的,对于"aab"
是["a", "a", "b"]
,但当回溯返回上一层的时候result
居然被改成了["a", "a", "b"]
,我想可能是Go切片引用导致的问题。
1 | var ( |
最终的result
结果居然是是[["aa", "b", "b"], ["aa", "b"]]
正确的做法应该是使用在放入result
的时候拷贝,也就是使用Go中的copy
函数,防止后续修改影响到之前的结果
1 | if startIndex == len(s) { |
使用append
也可以拷贝
1 | if startIndex == len(s) { |
这里需要注意copy
前,tmp
使用make
初始化时应该是make([]string, len(path))
而不是make([]string, 0)
为什么这里不能使用make([]string, 0)
?切片不是可以自动扩容吗?
在 Go 中,切片的扩容机制确实存在。扩容发生在调用 append
函数时,如果切片的容量不足以容纳新的元素,Go 会自动分配更大的底层数组,并将旧数组的内容复制到新数组中。
使用 make([]string, 0)
创建的切片 tmp
初始长度为 0。虽然 append
函数可以让切片自动扩容,但 copy
函数不会触发扩容。copy
函数只会在两个切片的现有容量内进行元素复制。
具体来说:
copy
函数的行为:copy(dst, src)
函数只会复制dst
和src
中较小长度的元素数量。它不会自动扩容目标切片dst
。所以,如果dst
的长度为 0,copy
函数不会复制任何元素。append
函数的行为:append
函数会在需要时扩容目标切片,但它的前提是你必须明确地使用append
函数。copy
函数并不会自动扩容目标切片。
具体示例
使用 make([]string, 0)
和 copy
1 | path := []string{"a", "b", "c"} |
在这个例子中,tmp
的长度为 0,copy(tmp, path)
不会复制任何元素,因为 copy
函数不会自动扩容 tmp
。
使用 append
来扩容
如果你希望切片自动扩容,你需要使用 append
函数:
1 | path := []string{"a", "b", "c"} |
在这个例子中,append(tmp, path...)
会自动扩容 tmp
以容纳 path
中的所有元素。
全局变量初始化
这里还有一个问题就是全局变量path和result需要在使用前初始化
1 | func partition(s string) [][]string { |
如果不初始化,对于s = "a"
这个测试用例,LeetCode上的输出居然是[["a","a","b"],["aa","b"],["a"]]
本地IDE没有这样的问题,可能是因为多次调用partition
函数导致的,导致之前["a","a","b"],["aa","b"]
的结果还在
原因如下:
当声明一个切片但没有使用 make
或其他方式初始化它时,该切片的值是 nil
,这意味着它没有底层数组。尝试向一个 nil
切片追加元素是可以的,因为 append
函数会处理这种情况并分配必要的底层数组。
- 初始化
path
和result
:path
和result
在partition
函数中被初始化为新的空切片。这样做的目的是确保每次调用partition
函数时都从空的状态开始,而不会受之前调用的影响。- 虽然
path
和result
在全局范围内声明时已经被自动初始化为nil
,但为了避免潜在的错误和确保函数的健壮性,显式初始化是一个好的编程习惯。
- 自动初始化:
- 如果你没有显式地用
make
初始化path
和result
,代码仍然可以工作,因为append
函数会为nil
切片分配新的底层数组。然而,显式初始化可以提高代码的可读性和维护性,避免任何潜在的误解或错误。
- 如果你没有显式地用
那为什么看似相同的C++代码却没有问题呢?
1 | class Solution { |
因为这里的path和result并不是引用,在 backtracking
函数中,path
的修改不会影响已经存储在 result
中的路径,因为每次 result.push_back(path)
时,path
的当前状态会被拷贝一份存储在 result
中。
在C++中:
- path 和 result 是类成员变量:
它们是Solution
类的私有成员变量,不是引用。它们通常分配在堆上(如果对象是通过new
创建的)或栈上(如果对象是局部变量)。 - 生命周期:
这些成员变量的生命周期与Solution
对象的生命周期相同。
还有可以优化的一点是回溯和递归应该一直放一起
1 | class Solution { |
这样可读性也更好
为什么 Go 中的 path
修改会影响 result
?
- path 和 result 是包级变量:
它们在包级别声明,因此它们的生命周期贯穿整个程序的运行时间。 - 切片的底层数组:
Go 切片是引用类型,切片本身是一个三元组(指针、长度和容量),指向底层数组。当你在path
中添加或删除元素时,可能会影响存储在result
中的切片,因为result
中存储的只是path
底层数组的引用。
在 Go 中,当你将 path
切片追加到 result
时,result
只是保存了 path
的引用,而不是其内容的副本。因此,后续对 path
的修改会影响到 result
中已经存储的 path
。
同样的对于Java
这种容器都是引用类型的语言也需要创建新的 ArrayList
1 | class Solution { |
- 引用类型:
ArrayList
是引用类型,直接添加path
会将path
的引用添加到res
中。如果之后修改path
,会影响res
中已经添加的path
内容。 - 深拷贝:
通过new ArrayList<>(path)
创建一个新的ArrayList
,它是path
的一个副本。这样一来,res
中存储的是当时path
内容的一个快照,而不是原始path
的引用。
所以,引用和拷贝都是不可或缺的,引用的优点是对引用的修改可以影响到之前的结果,缺点也是修改可以影响到之前的结果,凡事都有两面性。