Python字典get与in操作性能差异致LeetCode代码超时问题排查
问题背景
针对 LeetCode 1155 掷骰子等于目标和的方法数 题目,两段仅缓存判断逻辑存在差异的动态规划代码出现了显著性能差距:一段提交时超出时间限制,另一段可以正常通过。
超时实现代码
class Solution: def numRollsToTarget(self, dices: int, faces: int, target: int) -> int: dp = {} def ways(t, rd): if t == 0 and rd == 0: return 1 if t <= 0 or rd <= 0: return 0 if dp.get((t,rd)): return dp[(t,rd)] dp[(t,rd)] = sum(ways(t-i, rd-1) for i in range(1,faces+1)) return dp[(t,rd)] return ways(target, dices) % (10**9 + 7)
可正常通过的实现代码
class Solution: def numRollsToTarget(self, dices: int, faces: int, target: int) -> int: dp = {} def ways(t, rd): if t == 0 and rd == 0: return 1 if t <= 0 or rd <= 0: return 0 if (t,rd) in dp: return dp[(t,rd)] dp[(t,rd)] = sum(ways(t-i, rd-1) for i in range(1,faces+1)) return dp[(t,rd)] return ways(target, dices) % (10**9 + 7)
核心疑问
两段代码的唯一差异为缓存命中判断逻辑:超时版本使用dp.get(tuple)做判断,通过版本使用tuple in dp做判断。已知两种操作的平均时间复杂度均为O(1),为何会产生如此大的性能差异?
- 初始猜想:调用
dp.get()时如果存在大量哈希冲突,内部需要遍历冲突链表才能返回None,但不确定in操作为何不会产生同样的链表遍历开销。 - 补充验证:将元组类型的key替换为
-分隔的字符串作为字典key测试,性能差异仍然存在,可排除元组key的影响。 - 附加问题:这类性能问题有什么通用调试方法?
问题解答
性能差异根本原因
性能差异和哈希冲突没有关系,核心是超时版本的判断逻辑存在bug,导致缓存部分失效,时间复杂度从多项式级退化为指数级:
- 逻辑错误:将值的真值判断等同于key存在判断
dp.get(key)的行为是key存在时返回对应value,不存在时返回None。直接把返回值放在if条件中,本质是判断返回值的布尔真假,而非判断key是否存在。
本题中大量合法状态的缓存值为0(比如2个6面骰子凑出13点的方法数就是0),而Python中整数0是假值。这种场景下dp.get(key)返回0,if条件判定为不成立,代码会误以为该状态从未计算过,重新进入递归展开计算,直接把记忆化递归O(dices * target)的时间复杂度打回无缓存的指数级,必然超时。 - 额外常数开销
就算缓存值不为0,dp.get(key)已经完成一次哈希查询拿到了结果,后续return dp[(t,rd)]会再做一次哈希查询取同一个值,平白多了一倍字典操作开销,不过这是次要因素,不会直接导致超时。
而(t,rd) in dp是直接判断key是否存在于字典中,和对应value是0还是其他数值完全无关,所有计算过的状态都会正常命中缓存,不会出现重复计算,自然可以顺利通过。把key换成字符串后性能差异仍然存在,也正好印证了问题和key类型、哈希冲突无关,是判断逻辑本身的bug。
如果要修正get写法的逻辑,判断条件需要写成if dp.get((t, rd)) is not None:,但这种写法仍然比in版本多一次后续的字典取值操作,性能更差。
这类性能问题的通用调试方法
- 调用计数:在递归函数入口加全局计数器,统计函数被调用的总次数。正常记忆化递归的调用次数应该和状态总数(即dices*target)处于同一量级,如果计数远大于这个值,就能直接定位到缓存失效问题。
- 性能分析工具:用Python标准库的
cProfile运行测试用例,输出函数调用次数、各函数耗时占比,一眼就能看出递归函数被异常重复调用的问题。 - 小用例单步调试:用极小的测试用例(比如dices=2、faces=6、target=13)单步执行,观察缓存分支的触发逻辑,很容易发现值为0时缓存分支未命中的问题。
- 避免手写缓存:写Python记忆化逻辑时优先用
functools.lru_cache装饰器,不会出现这类判断逻辑错误,原生实现的性能也普遍好于手写字典缓存。
内容的提问来源于stack exchange,提问作者sprksh
相关产品推荐
相关产品推荐

