最近在刷leetcode hot100的一个回溯题目的时候,发现一个go语言中常见的使用陷阱,这里分享总结出来。
一维切片存的是"值"(或不可变引用),二维切片存的是"一维切片的引用"。
一、核心区别总结
| 场景 | 容器类型 | 存的是什么 | append 的行为 |
是否需要深拷贝 |
|---|---|---|---|---|
[]int |
一维 | 整数值 | 把 int 值复制到底层数组 |
❌ 不需要 |
[]string |
一维 | 字符串头(指针+长度) | 复制字符串头。字符串本身不可变,所以安全 | ❌ 通常不需要 |
[][]int |
二维 | []int 的切片头(指针+长度+容量) |
复制切片头,指向同一块底层数组 | ✅ 必须 copy |
[][]string |
二维 | []string 的切片头 |
复制切片头,指向同一块底层数组 | ✅ 必须 copy |
二、对比示例
示例 1:一维切片(安全,不需要 copy)
package main
import "fmt"
func main() {
var result []int
path := []int{1, 2, 3}
result = append(result, path...) // 把 path 的元素值展开追加
path[0] = 999 // 修改 path
fmt.Println(result) // [1 2 3] ✅ 不受影响,因为 append 复制的是值
fmt.Println(path) // [999 2 3]
}
原因 :append(result, path...) 把 1, 2, 3 这三个整数 复制到了 result 的底层数组里。之后 path 怎么改,和 result 无关。
示例 2:二维切片(危险,必须 copy)
package main
import "fmt"
func main() {
var result [][]int
path := []int{1, 2, 3}
// ❌ 错误做法:直接 append 切片头
result = append(result, path)
path[0] = 999 // 修改 path
fmt.Println(result[0]) // [999 2 3] 😱 被污染了!
}
原因 :result[0] 和 path 指向同一块底层数组 。改 path[0] 就是改 result[0][0]。
示例 3:二维切片的正确做法(copy 深拷贝)
package main
import "fmt"
func main() {
var result [][]int
path := []int{1, 2, 3}
// ✅ 正确做法:先 copy 内层切片
tmp := make([]int, len(path))
copy(tmp, path)
result = append(result, tmp)
path[0] = 999
fmt.Println(result[0]) // [1 2 3] ✅ 安全
fmt.Println(path) // [999 2 3]
}
三、回文分割代码
大家可以做一下这道力扣题目:
答案是:
// partition 将字符串 s 分割成所有可能的回文子串组合,返回所有分割方案。
// 核心算法:回溯法(DFS)+ 双指针判断回文。
// 例如 s = "aab",结果:[["a","a","b"], ["aa","b"]]
// 时间复杂度:O(N * 2^N),最坏情况下每个位置都可以选择"切"或"不切"。
// 空间复杂度:O(N),递归栈深度和 path 长度。
func partition(s string) [][]string {
// result 存储所有满足条件的分割方案,每个方案是一个 []string(多个回文子串)。
var result [][]string
// path 存储当前正在构建的分割方案,记录从开头到当前位置已经切出的回文子串。
var path []string
// isPalindrome 是闭包函数,判断 s[start:end](闭区间)是否为回文串。
// 使用双指针从两端向中间逼近,比较字符是否相等。
isPalindrome := func(start, end int) bool {
// 注意:这里的 start 和 end 是闭包参数,会遮蔽外层变量(如果有同名的话)。
for start < end {
if s[start] != s[end] {
return false
}
start++
end--
}
return true
}
// backtracking 是回溯闭包函数。
// startIndex:当前待处理子串的起始位置(在 s 中的下标)。
// 含义:s[startIndex:] 是还未被分割的剩余部分。
var backtracking func(startIndex int)
backtracking = func(startIndex int) {
// ==================== 终止条件 ====================
// 当 startIndex 到达字符串末尾时,说明所有字符都已经被分割完毕,
// path 中记录的就是一种完整的分割方案。
if startIndex == len(s) {
// ⚠️ 必须拷贝 path!不能直接 append(result, path)。
//
// 【原因详解】
// path 是 []string 类型,在 Go 中属于"引用类型"(底层包含指向数组的指针、长度、容量)。
// 如果直接执行 result = append(result, path),result 中存入的是 path 的"切片头"引用,
// 底层数组仍然是同一块内存。
//
// 后续回溯会继续使用并修改 path(通过 append 添加新字符串、通过截断减少长度),
// 虽然字符串本身不可变,但 path 的底层数组可能被复用。
// 一旦 path 的底层数组在后续递归中被覆盖或重新分配,
// result 中之前保存的"结果"也会随之改变,导致最终结果全部错乱。
//
// 因此必须用 make + copy 创建一份全新的、独立的切片及其底层数组,
// 确保 result 中的每一条记录都是当时那个时刻 path 的"快照"。
tmp := make([]string, len(path))
copy(tmp, path)
result = append(result, tmp)
return
}
// ==================== 枚举所有可能的切割位置 ====================
// 从 startIndex 开始,尝试以每个位置 i 作为当前子串的结尾(闭区间 [startIndex, i])。
// 如果该子串是回文,就将其加入 path,然后递归处理剩余部分 s[i+1:]。
for i := startIndex; i < len(s); i++ {
// 判断 s[startIndex:i+1] 是否为回文。
// Go 切片语法是左闭右开,所以取到 i 需要写成 i+1。
if isPalindrome(startIndex, i) {
// 截取当前回文子串
str := s[startIndex : i+1]
// 做选择:将当前回文子串加入 path
path = append(path, str)
// 递归进入下一层,从 i+1 开始继续分割剩余字符串。
// 注意:不是 startIndex+1,而是 i+1,因为当前子串已经占用了 startIndex 到 i。
backtracking(i + 1)
// ==================== 回溯:撤销选择 ====================
// 递归返回后,将刚才加入的子串从 path 中弹出,
// 恢复状态,以便尝试下一个切割位置 i+1。
path = path[:len(path)-1]
}
// 如果不是回文,continue 跳过,尝试下一个结尾位置 i+1。
// 例如 "aab",startIndex=0,i=1 时子串 "aa" 是回文,i=2 时 "aab" 不是回文,直接跳过。
}
}
// 从字符串开头开始分割
backtracking(0)
return result
}
代码中
// ❌ 如果这样写:
result = append(result, path)
// 相当于:
// result[0] = {ptr: path的指针, len: path的长度, cap: path的容量}
// 后续 path 被修改,result 里存的所有历史记录都会跟着变
// ✅ 正确做法:
tmp := make([]string, len(path))
copy(tmp, path)
result = append(result, tmp)
// 相当于:
// result[0] = {ptr: 全新数组的指针, len: ..., cap: ...}
// 和 path 彻底脱钩
四、一句话总结
一维切片的
append复制的是"元素值";二维切片的append复制的是"一维切片的引用头"。所以当把一维切片收集到二维切片里时,必须先copy内层切片,否则就是"共享内存"的陷阱。