为什么 [[0]]*3 改一个元素其他都变了?Python 列表乘法的陷阱
简化版
因为 [[0]] * 3 复制的是「引用」而不是「对象」——它创建的列表里三个元素指向的是「同一个内层列表对象」,所以改其中一个(m[0][0] = 1)会同时反映到另外两个上。这是 Python 列表乘法(*)的经典陷阱:[x] * n 生成的是 [x, x, ..., x],如果 x 是可变对象(列表、字典、集合),那么 n 个位置全都是「同一个 x」的引用,改动任何一个就是改那个共享对象。例子:m = [[0]] * 3 → m[0][0] = 1 → m 变成 [[1], [1], [1]](三个都变)。为什么:* 只是「重复引用」不复制对象(浅层重复),三个槽位存的是同一个内层列表的地址。正确做法(每行独立):用列表推导式 [[0] for _ in range(3)]——每次迭代都新建一个独立的 [0]。不受影响的情况:[0] * 3 → [0, 0, 0] 没问题,因为 0 是不可变的(int),「改」一个位置是重新赋值 lst[0] = 1(替换引用)而非修改共享对象。核心记忆:[可变对象] * n 让 n 个位置共享同一对象(改一个全变),二维数组用 [[0] for _ in range(n)];不可变元素(int/str)的 * 没问题。
详细版
列表乘法的陷阱:
| 写法 | 结果 | 是否共享 |
|---|---|---|
[0] * 3 | [0, 0, 0] | 不共享(int 不可变,安全) |
[[0]] * 3 | [[0],[0],[0]] | 共享(三个指向同一列表) |
[[0] for _ in range(3)] | [[0],[0],[0]] | 不共享(各自独立) |
# 陷阱:[[0]]*3 三个元素是同一个列表
m = [[0]] * 3
print(m) # [[0], [0], [0]](看起来没问题)
m[0][0] = 1 # 改第一行第一个
print(m) # [[1], [1], [1]] —— 三行全变了!
print(m[0] is m[1]) # True(是同一个对象!)
# 验证:三个元素 id 相同
print([id(row) for row in m]) # 三个 id 一样
# 正确:列表推导式,每行独立
m = [[0] for _ in range(3)]
m[0][0] = 1
print(m) # [[1], [0], [0]] —— 只改了第一行 ✓
print(m[0] is m[1]) # False(不同对象)
# 不可变元素不受影响
lst = [0] * 3 # [0, 0, 0]
lst[0] = 1 # 重新赋值(替换引用,不是改共享对象)
print(lst) # [1, 0, 0] ✓
# 一维乘法安全,多维才有坑
row = [0] * 5 # ✓ 安全(int 不可变)
grid = [[0] * 5] * 3 # ✗ 3 行共享同一个 [0,0,0,0,0]
grid = [[0] * 5 for _ in range(3)] # ✓ 每行独立
# 同样的坑:可变默认值、字典/集合
board = [{}] * 3 # ✗ 三个字典共享
board[0]['x'] = 1 # 三个都有 x
⚠️ 抓住本质:Python 里变量/容器元素存的都是「对象的引用(地址)」,
[x] * n做的是「把 x 这个引用复制 n 份」,而不是「把 x 指向的对象复制 n 份」——所以 n 个位置指向的是同一个对象。是否出问题取决于对象「可不可变」。对不可变对象(int、str、tuple),「共享」无所谓——因为你根本改不了它,lst[0] = 1是把那个位置的引用换成指向新对象1,不影响其他位置;但对可变对象(list、dict、set),「共享」就是灾难——m[0].append(x)或m[0][0] = 1是在「原地修改那个被三方共享的对象」,改动立刻体现在所有引用它的位置上。判断口诀:*复制的元素如果是可变的,就都是同一个,改一个全变。解决方案统一用列表推导式[新建表达式 for _ in range(n)],因为推导式每轮迭代都重新执行一次表达式、生成全新的独立对象。这个坑和「可变默认参数」def f(x=[])、「浅拷贝」是同一个根源:Python 的引用语义 + 可变对象。
完整版教学
一、现象:改一个全变
先看清楚这个反直觉的现象:
现象:
m = [[0]] * 3
m[0][0] = 1
print(m) → [[1], [1], [1]] # 只改了 m[0],三行全变
初看很诡异:我只改了第一行,为什么三行都变?
关键验证:
m[0] is m[1] → True # m[0] 和 m[1] 是同一个对象!
m[0] is m[2] → True
[id(r) for r in m] → 三个相同的 id
→ 三行其实是"同一个列表"的三个引用
所以改 m[0][0] = 改那个共享列表的第 0 个
→ 通过任何一行看,都是改了同一个对象
→ 三行"看起来"都变了(其实是一个对象)
类比:
三个人手里拿着同一把房子的钥匙(引用)
一个人进去搬了家具(改对象)
→ 另外两个人开门看到的也是搬过的(同一个房子)
对比不出问题的情况:
lst = [0] * 3 # [0,0,0]
lst[0] = 1 # 这是"换钥匙"(指向新对象 1),不是"改房子"
→ 只有 lst[0] 变,因为 int 不可变、只能换引用
所以现象根源:[[0]]*3 三行是同一列表的引用,改一个=改共享对象=全变
现象:m = [[0]] * 3 → m[0][0] = 1 → m 变成 [[1],[1],[1]](只改第一行、三行全变)。关键验证:m[0] is m[1] 为 True(m[0] 和 m[1] 是同一个对象)、三个 id 相同——三行其实是「同一个列表」的三个引用。所以改 m[0][0] = 改那个共享列表的第 0 个(通过任何一行看都是同一个对象、三行看起来都变)。类比:三个人拿着同一把房子的钥匙(引用),一个人搬了家具(改对象),另外两个开门看到的也是搬过的。对比不出问题的情况:lst = [0]*3 → lst[0]=1 是「换钥匙」(指向新对象 1)不是「改房子」,只有 lst[0] 变(int 不可变只能换引用)。理解「现象:[[0]]*3 三行是同一列表的引用(m[0]is m[1]为 True);改一个=改共享对象=全变;类比三人共享一把钥匙;[0]*3 改是换引用不出问题」,就看清了现象。
二、根源:引用语义 + 列表乘法
理解陷阱的根本原因:
根源1:Python 的引用语义
变量、容器的元素,存的都是"对象的引用(地址)"
不是"对象本身"
→ a = [1]; b = a → a、b 指向同一列表
根源2:列表乘法 [x] * n 复制"引用"
[x] * n 生成 [x, x, ..., x]
→ 把 x 这个"引用"复制 n 份
→ n 个位置指向"同一个 x 对象"(不复制对象!)
[[0]] * 3 展开:
内层 [0] 先求值一次 → 得到一个列表对象 L
* 3 → [L, L, L](三个位置都是 L 的引用)
→ 不是三个独立的 [0],是同一个 L 三次
图示:
m = [[0]] * 3
m ──> [ •, •, • ]
│ │ │
└──┴──┴──> [0] ← 三个指针指向同一个列表
对比推导式 [[0] for _ in range(3)]:
每轮迭代都"重新执行 [0]" → 每次新建一个列表
m ──> [ •, •, • ]
│ │ │
│ │ └──> [0] (L3)
│ └─────> [0] (L2)
└────────> [0] (L1) ← 三个独立列表
所以根源:引用语义(存地址)+[x]*n 复制引用(n 个指向同一 x,不复制对象)
根源1:Python 的引用语义——变量、容器的元素存的都是「对象的引用(地址)」不是「对象本身」(a = [1]; b = a 指向同一列表)。根源2:列表乘法 [x] * n 复制「引用」——[x] * n 生成 [x, x, ..., x]、把 x 这个引用复制 n 份、n 个位置指向「同一个 x 对象」(不复制对象)。[[0]] * 3 展开:内层 [0] 先求值一次得到列表对象 L、* 3 → [L, L, L](三个位置都是 L 的引用、不是三个独立的 [0])。图示:三个指针指向同一个列表。对比推导式 [[0] for _ in range(3)]:每轮迭代重新执行 [0]、每次新建一个列表(三个独立列表)。理解「根源:①引用语义(变量/元素存地址不是对象)②[x]*n 复制引用(n 个位置指向同一 x 对象、不复制);[[0]]*3=内层求值一次得 L 再复制三次引用[L,L,L];推导式每轮新建独立对象」,就理解了根源。
三、可变 vs 不可变——为什么有的没事
理解为什么 [0]*3 安全、[[0]]*3 出事:
关键:元素"可不可变"决定有没有坑
不可变元素(int、str、tuple、frozenset):安全
[0] * 3 → [0, 0, 0](三个都是同一个 0 的引用)
改 lst[0] = 1:
→ 不是"修改 0"(0 不可变、改不了)
→ 是"把 lst[0] 换成指向新对象 1"(替换引用)
→ 只影响 lst[0],其他不变
✓ 共享无所谓,因为你只能"换引用"、改不了对象本身
可变元素(list、dict、set):有坑
[[0]] * 3 → 三个指向同一列表
改 m[0][0] = 1 或 m[0].append(x):
→ 是"原地修改那个共享列表"(可变对象能改)
→ 改动体现在所有引用它的位置
✗ 共享是灾难,因为"改对象"影响所有引用
判断口诀:
[x] * n 里 x 可变 → 都是同一个,改一个全变(坑)
[x] * n 里 x 不可变 → 安全(只能换引用,不能改对象)
常见可变元素的坑:
[[0]] * n # 二维列表
[[0]*m] * n # 二维数组(内层也可能有坑)
[{}] * n # 列表里的字典
[set()] * n # 列表里的集合
[[]] * n # 空列表的列表
[0]*n, ["a"]*n, [(1,2)]*n # 安全(元素不可变)
所以可变元素(list/dict/set)共享才出坑(能原地改)、不可变(int/str)安全(只能换引用)
关键:元素「可不可变」决定有没有坑。不可变元素(int、str、tuple、frozenset):安全——[0]*3 三个都是同一个 0 的引用,改 lst[0]=1 不是「修改 0」(0 改不了)而是「把 lst[0] 换成指向新对象 1」(替换引用、只影响 lst[0]);共享无所谓(只能换引用、改不了对象本身)。可变元素(list、dict、set):有坑——[[0]]*3 三个指向同一列表,改 m[0][0]=1 或 m[0].append(x) 是「原地修改那个共享列表」(改动体现在所有引用);共享是灾难。判断口诀:[x]*n 里 x 可变就都是同一个(改一个全变)、x 不可变就安全。常见可变元素的坑:[[0]]*n、[{}]*n、[set()]*n、[[]]*n;安全的:[0]*n、["a"]*n、[(1,2)]*n。理解「元素可不可变决定有没有坑:不可变(int/str/tuple)安全(改是换引用)、可变(list/dict/set)有坑(改是原地修改共享对象);口诀[x]*n 里 x 可变就全变」,就理解了为什么有的没事。
四、正确做法:列表推导式
理解正确构造独立元素的方法:
正确做法:用列表推导式(每次新建独立对象)
一维(不可变元素,其实 * 也行):
[0] * 5 # ✓ 安全
[0 for _ in range(5)] # 也行(但没必要)
二维/嵌套(必须用推导式):
[[0] for _ in range(3)] # ✓ 每行独立的 [0]
[[0] * m for _ in range(n)] # ✓ n 行,每行独立的 [0]*m
(内层 [0]*m 安全因为 0 不可变,外层用推导式保证行独立)
[{} for _ in range(3)] # ✓ 独立字典
[[] for _ in range(3)] # ✓ 独立空列表
为什么推导式有效:
[expr for _ in range(n)]
每轮迭代都"重新执行 expr" → 每次求值出一个新对象
→ n 个独立的对象(不共享)
构造二维数组的标准写法:
# n 行 m 列的 0 矩阵
grid = [[0] * m for _ in range(n)]
grid[0][0] = 1 # ✓ 只改第一行
# 错误写法(会共享行)
grid = [[0] * m] * n # ✗ n 行共享同一个 [0]*m
其他构造方式:
copy.deepcopy:deepcopy 一个模板(较重,一般不必)
numpy.zeros((n, m)):数值矩阵用 numpy(更专业)
所以正确做法用列表推导式[expr for _ in range(n)](每轮新建独立对象)
正确做法:用列表推导式(每次新建独立对象)。一维(不可变元素其实 * 也行):[0] * 5 安全。二维/嵌套(必须用推导式):[[0] for _ in range(3)](每行独立的 [0])、[[0] * m for _ in range(n)](n 行、每行独立的 [0]*m,内层安全因 0 不可变、外层推导式保证行独立)、[{} for _ in range(3)](独立字典)。为什么推导式有效:[expr for _ in range(n)] 每轮迭代重新执行 expr、每次求值出新对象(n 个独立对象不共享)。构造二维数组的标准写法:grid = [[0] * m for _ in range(n)](正确)、[[0] * m] * n(错误、n 行共享)。其他:copy.deepcopy(较重)、numpy.zeros((n, m))(数值矩阵更专业)。理解「正确用列表推导式[expr for _ in range(n)](每轮重新执行 expr 新建独立对象);二维标准写法[0]*m for _ in range(n);错误[[0]*m]*n;数值矩阵用 numpy」,就掌握了正确做法。
五、同源的坑——可变默认参数、浅拷贝
理解这个坑和其他 Python 引用坑的联系:
这个坑和几个 Python 经典坑同源:都是"引用语义 + 可变对象"
坑1:可变默认参数
def f(x, lst=[]): # ✗ 默认值 [] 只创建一次,多次调用共享
lst.append(x)
return lst
f(1) → [1]
f(2) → [1, 2] # 共享了同一个默认列表!
正确:def f(x, lst=None):
if lst is None: lst = []
坑2:浅拷贝
import copy
a = [[1,2],[3,4]]
b = a.copy() # 或 a[:]、list(a) —— 浅拷贝
b[0][0] = 99 # a[0][0] 也变了!(内层列表共享)
正确:b = copy.deepcopy(a)(深拷贝,内层也复制)
坑3:列表乘法(本题)
[[0]] * 3 # ✗ 共享内层列表
共同根源:
Python 的赋值/复制默认是"复制引用"(浅层)
可变对象被多处引用时,一处改动影响所有引用
统一的心智模型:
① 变量/元素存的是引用(地址)
② 复制引用 ≠ 复制对象
③ 可变对象被共享 → 改一处全变
→ 要独立就"新建对象"(推导式/deepcopy/工厂函数)
判断是否安全:
问"这几个位置是同一个对象吗?"(用 is 或 id 验证)
是 + 可变 → 有共享风险
所以同源坑:可变默认参数/浅拷贝/列表乘法,根源引用语义+可变对象共享
这个坑和几个 Python 经典坑同源:都是「引用语义 + 可变对象」。坑1:可变默认参数——def f(x, lst=[]) 默认值 [] 只创建一次、多次调用共享(f(1)→[1]、f(2)→[1,2]),正确用 lst=None + if lst is None: lst=[]。坑2:浅拷贝——b = a.copy()(或 a[:]、list(a))是浅拷贝、b[0][0]=99 会改到 a[0][0](内层列表共享),正确用 copy.deepcopy(a)。坑3:列表乘法(本题)——[[0]]*3 共享内层列表。共同根源:Python 的赋值/复制默认是「复制引用」(浅层)、可变对象被多处引用时一处改动影响所有引用。统一心智模型:① 变量/元素存引用、② 复制引用 ≠ 复制对象、③ 可变对象被共享改一处全变、④ 要独立就新建对象。理解「同源坑:可变默认参数(lst=[]共享)/浅拷贝(内层共享)/列表乘法;根源引用语义+可变对象共享;要独立就新建对象(推导式/deepcopy/工厂);用 is/id 验证是否同对象」,就理解了这些坑的联系。
六、总结与实践
总结列表乘法的陷阱:
核心陷阱:
[x] * n 复制"引用"不复制"对象"
→ n 个位置指向同一个 x
→ x 可变(list/dict/set)时,改一个全变
判断:
[0] * n、["a"] * n、[(1,2)] * n → 安全(元素不可变)
[[0]] * n、[{}] * n、[[]] * n → 坑(元素可变、共享)
正确做法:
二维数组:[[0] * m for _ in range(n)] ← 标准写法
独立元素:[factory() for _ in range(n)]
数值矩阵:numpy.zeros((n, m))
验证是否共享:
m[0] is m[1] # True → 共享(危险)
[id(x) for x in m] # id 相同 → 共享
同源坑(一并记住):
可变默认参数 def f(x=[]) → 用 None 哨兵
浅拷贝 a.copy() → 深拷贝 copy.deepcopy
→ 都是"引用语义 + 可变对象共享"
核心总结:
[[0]]*3 三行是同一列表的引用,改一个全变
根源:* 复制引用不复制对象、可变对象共享
二维数组用 [[0] for _ in range(n)](推导式新建独立行)
不可变元素的 * 安全
所以[可变]*n 共享改一个全变,二维用推导式[[0]for _ in range(n)],不可变*安全
核心陷阱:[x] * n 复制「引用」不复制「对象」——n 个位置指向同一个 x、x 可变(list/dict/set)时改一个全变。判断:[0]*n、["a"]*n、[(1,2)]*n 安全(元素不可变)、[[0]]*n、[{}]*n、[[]]*n 坑(元素可变共享)。正确做法:二维数组 [[0] * m for _ in range(n)](标准写法)、独立元素 [factory() for _ in range(n)]、数值矩阵 numpy.zeros((n, m))。验证是否共享:m[0] is m[1](True 则共享)、id 相同则共享。同源坑:可变默认参数(用 None 哨兵)、浅拷贝(用 deepcopy)——都是「引用语义 + 可变对象共享」。理解「[可变]*n 共享改一个全变;安全[0]*n、坑[[0]]*n;二维用[[0]*m for _ in range(n)];验证 m[0]is m[1];同源坑可变默认参数/浅拷贝」,就掌握了总结与实践。
记忆钩子:「[[0]]*3 改一个元素其他都变,因为★[x]n 复制的是『引用』不是『对象』——内层[0]先求值一次得到列表 L,再3 得[L,L,L](三个位置都指向同一个 L,不是三个独立的[0]),所以 m[0][0]=1 是修改那个被三方共享的列表(m[0]is m[1]为 True、id 相同);★是否出坑取决于元素『可不可变』:不可变(int/str/tuple)安全——[0]*3 改 lst[0]=1 是『换引用』(指向新对象 1)而非改对象本身,只影响那个位置;可变(list/dict/set)有坑——m[0].append 或 m[0][0]=1 是『原地修改共享对象』,所有引用它的位置都变;★正确做法用列表推导式[0]for _ in range(3),二维数组标准写法[[0]m for _ in range(n)](内层安全因 0 不可变、外层推导式保证行独立),错误写法[[0]*m]*n(n 行共享);这个坑和『可变默认参数 def f(x=[])』『浅拷贝 a.copy()内层共享』同源,根源都是 Python 的引用语义+可变对象被多处共享;数值矩阵用 numpy.zeros((n,m))」。
七、常见误区与追问
- 误区:[[0]] * 3 会创建三个独立的 [0] 列表。 不会——
*复制的是引用不是对象:内层[0]只求值一次、得到一个列表对象,然后* 3把这个对象的引用复制三份,所以三个位置指向的是「同一个列表」(m[0] is m[1]为 True);改其中任何一个(m[0][0] = 1)都是在改这个共享列表、三行都会跟着变;要三个独立的列表用推导式[[0] for _ in range(3)]。 - 误区:[0] * 3 也有同样的共享问题。 没有——虽然
[0] * 3里三个位置也是「同一个 0 的引用」,但 int 是不可变的:你没法「修改 0」,lst[0] = 1是把 lst[0] 这个位置换成指向新对象 1(替换引用),只影响 lst[0]、不影响其他;只有元素是可变对象(list、dict、set)时,「原地修改共享对象」才会波及所有引用;所以一维的[0]*n、["a"]*n都安全,多维的[[0]]*n才有坑。 - 误区:用 [[0] * 5] * 3 创建 3×5 矩阵是对的。 错的——内层
[0] * 5是安全的(得到[0,0,0,0,0]、元素是不可变的 0),但外层* 3又把这个「行列表」的引用复制了 3 份,导致 3 行共享同一个行列表,grid[0][0] = 1会让三行的第一个都变 1;正确写法是[[0] * 5 for _ in range(3)](外层用推导式保证每行是独立的列表,内层[0]*5可以用乘法因为 0 不可变)。 - 误区:这个坑只出现在列表乘法上。 不是——它的根源是「Python 的引用语义 + 可变对象被共享」,同样的坑出现在多个地方:可变默认参数
def f(x, lst=[])(默认列表只创建一次、多次调用共享)、浅拷贝b = a.copy()(嵌套的内层对象仍共享、b[0][0]=9会改到 a)、dict.fromkeys(keys, [])(所有键共享同一个列表);理解了「复制引用 ≠ 复制对象、可变对象共享则一处改全变」,这些坑就能统一识别和规避。 - 追问:为什么 [[0]] * 3 里三个元素是同一个对象,而 [[0] for _ in range(3)] 里是三个不同对象? 因为求值方式不同:
[[0]] * 3中,内层的[0]只在写这行代码时被求值「一次」、生成一个列表对象,然后* 3只是把这一个对象的引用重复放三次([L, L, L]),所以三个位置是同一个 L;而[[0] for _ in range(3)]是列表推导式,for _ in range(3)循环三次、每次都「重新执行一遍表达式[0]」、每次都新建一个全新的列表对象,所以得到三个互相独立的列表;关键区别是「表达式被求值几次」——乘法只求值一次再复制引用,推导式每轮都求值一次。 - 追问:怎么快速判断一个
[x] * n写法会不会踩坑,以及怎么修? 判断标准:看 x 是不是「可变对象」——如果 x 是 list、dict、set 等可变对象(或包含可变对象的结构),那么[x] * n的 n 个位置会共享同一个 x、改一个全变,有坑;如果 x 是 int、str、tuple、frozenset 等不可变对象,就安全(只能替换引用、改不了对象);验证方法是lst[0] is lst[1](返回 True 且元素可变就危险)或看[id(e) for e in lst]是否相同。修法:把[x] * n换成列表推导式[新建x的表达式 for _ in range(n)],让每个元素都是独立新建的,比如[[0] for _ in range(n)]、[{} for _ in range(n)]、[[0]*m for _ in range(n)](二维);数值矩阵可以直接用numpy.zeros((n, m))。
八、加强记忆
[[0]] * 3 改一个元素其他都变,因为 [x] * n 复制的是「引用」不是「对象」——内层 [0] 先求值一次得到列表 L、再 * 3 得 [L, L, L](三个位置都指向同一个 L,不是三个独立的 [0]),所以 m[0][0] = 1 是修改那个被三方共享的列表(m[0] is m[1] 为 True、id 相同)。是否出坑取决于元素「可不可变」:不可变(int/str/tuple)安全——[0]*3 改 lst[0]=1 是「换引用」(指向新对象 1)而非改对象本身、只影响那个位置;可变(list/dict/set)有坑——m[0].append(x) 或 m[0][0]=1 是「原地修改共享对象」、所有引用它的位置都变。正确做法用列表推导式 [[0] for _ in range(3)](每轮迭代重新执行 [0]、新建独立对象),二维数组标准写法 [[0] * m for _ in range(n)](内层 * 安全因 0 不可变、外层推导式保证行独立),错误写法 [[0] * m] * n(n 行共享)。这个坑和「可变默认参数 def f(x=[])」「浅拷贝 a.copy() 内层共享」同源,根源都是引用语义 + 可变对象被多处共享;数值矩阵用 numpy.zeros((n, m))。一句话「**[x]*n 复制引用不复制对象(n 个位置同一个 x),x 可变(list/dict/set)就改一个全变、x 不可变(int/str)安全;二维数组用[[0]for _ in range(n)]或[[0]m for _ in range(n)]、别用[[0]]n;同源坑可变默认参数/浅拷贝」。