← 返回题目列表

为什么 [[0]]*3 改一个元素其他都变了?Python 列表乘法的陷阱

中等 第 16 / 21 题 更新于 2026/07/31
Python列表乘法浅拷贝引用

简化版

因为 [[0]] * 3 复制的是「引用」而不是「对象」——它创建的列表里三个元素指向的是「同一个内层列表对象」,所以改其中一个(m[0][0] = 1)会同时反映到另外两个上。这是 Python 列表乘法(*)的经典陷阱:[x] * n 生成的是 [x, x, ..., x],如果 x可变对象(列表、字典、集合),那么 n 个位置全都是「同一个 x」的引用,改动任何一个就是改那个共享对象。例子:m = [[0]] * 3m[0][0] = 1m 变成 [[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]] * 3m[0][0] = 1m 变成 [[1],[1],[1]](只改第一行、三行全变)。关键验证m[0] is m[1] 为 True(m[0] 和 m[1] 是同一个对象)、三个 id 相同——三行其实是「同一个列表」的三个引用。所以改 m[0][0] = 改那个共享列表的第 0 个(通过任何一行看都是同一个对象、三行看起来都变)。类比:三个人拿着同一把房子的钥匙(引用),一个人搬了家具(改对象),另外两个开门看到的也是搬过的。对比不出问题的情况lst = [0]*3lst[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]=1m[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]*3lst[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;同源坑可变默认参数/浅拷贝」。