
简介一份基于《Python中的数据结构和算法》原书整理的Python源码实现包适合正在学习数据结构、准备算法面试或希望提升Python面向对象编程能力的开发者。资源以书中各章节为主线提供123个可运行的.py脚本涵盖二叉搜索树、红黑树、表达式树、图、链表位置列表、排序映射、欧拉遍历等经典主题每个文件均保持一致的面向对象设计便于对照原书理解抽象数据类型与算法设计思想。压缩包仅89KB总文件数124个除Python源码外仅含一个gitignore文件结构轻量清晰。目前已有733人下载学习适合随书逐章阅读或作为期末复习、上机练习的代码参考。通过阅读这些实现读者既能掌握各类数据结构接口的分层设计与继承复用技巧也能学习如何在Python中用简洁代码表达复杂算法逻辑是一份实用且紧凑的电子资源。1. 用Python学数据结构和算法先别急着背答案把每个容器亲手实现一遍用Python学数据结构和算法最容易被低估的不是语法而是「把数据结构当成类型系统来设计」这件事。很多人直接刷题、背解法代码能过用例却说不清底层是数组还是链表更不敢碰继承、接口这些设计层面的东西。这个方向的核心主张恰恰相反利用Python的美感和简单性把每一类抽象数据类型ADT用可执行的源代码呈现出来再用继承最大化代码复用让读者看清不同容器之间的相似与不同。它适合三类人想补计算机基础但啃不动大部头的开发者、准备技术面试但只会贴答案的求职者以及想把自己的代码从脚本升到库的工程师。它解决的是让你动手实现每一类容器和算法并对自己的实现有底气。下文从为什么选Python、怎么写一个类开始一路到避坑和验证目标是让你看完能自己搭一套可运行、可测试、可对照标准库的小型容器库。2. 选择Python学数据结构和算法为什么一致的面向对象视角反而是捷径2.1 抽象数据类型与Python类先定接口再谈实现抽象数据类型也就是ADT核心思路很朴素先描述「这个类型能做什么操作」再决定「内部怎么存」。比如栈这个ADT规定好了push、pop、peek三个操作的语义至于底层用数组还是链表调用方根本不关心。这个思路放到Python里几乎是为类设计量身定做的类名就是类型名方法就是操作集合__init__里隐藏内部表示。Python的抽象基类模块abc可以把这条约束落到代码上。定义一个Container基类把「所有容器都应该有」的接口先钉死子类不实现就实例化不了从根上减少「漏写方法」的翻车。from abc import ABC, abstractmethod from typing import Iterator class Container(ABC): 所有容器的抽象基类定义统一的容器操作。 abstractmethod def __len__(self) - int: 返回元素个数所有子类必须实现。 abstractmethod def __iter__(self) - Iterator[object]: 返回迭代器所有子类必须实现。 abstractmethod def add(self, value: object) - None: 向容器加入一个元素具体语义由子类定义。这里有个值得反复琢磨的细节基类里的方法叫add而不是append或push。为什么因为不同容器的写入语义不一样——数组列表叫append栈叫push队列叫enqueue。统一叫add让外部代码可以写出「只面向抽象基类」的逻辑不用关心当前具体是哪种容器。这正是ADT设计里「接口与实现分离」在Python里最实在的落地方式。参数value用object类型标注刻意放宽它因为容器不该限制元素的具体类型。2.2 用继承最大化代码复用一份代码跑通N种容器有了抽象基类之后继承真正的价值才显现出来。很多通用行为是可以用基类一次性实现然后让所有子类免费继承的。最常见的例子是__contains__和__str__它们只需要依赖__iter__和__len__就能工作没有必要在每个子类里重写一遍。from abc import ABC, abstractmethod from typing import Iterator class Container(ABC): # 抽象方法同上一节此处省略 def __contains__(self, value: object) - bool: # 基类借助 __iter__ 实现成员判断子类无需重写 for item in self: if item value: return True return False def __str__(self) - str: return f{type(self).__name__}({list(self)!r})这段代码展示了标题里「一致的面向对象观点」的实操含义把公共行为提升到基类子类只写自己差异化的部分。__contains__用了线性扫描虽然对链表和动态数组都适用但如果你将来实现哈希集合就应该重写它这就叫「按需覆盖」。__str__里的type(self).__name__可以自动显示当前子类的名字ArrayList打印出来就是ArrayList([...])LinkedList就是LinkedList([...])同一份代码在不同子类里输出不同的类名这就是复用的收益。但这里要补一句提醒继承是复用手段不是复用目的。后面第5章会专门讲继承层级过深的坑现在的克制是为了给后面留余地。这个Container基类只放两三层足够了。2.3 搭建最小项目骨架虚拟环境、目录与第一次测试动手之前先把工程搭干净。我用一个叫dsa的目录当项目根里面按职责拆成三个包containers放数据结构algorithms放算法tests放测试。布局越简单后面每次跑测试时的心智负担越小。mkdir dsa cd dsa python -m venv .venv source .venv/bin/activate # Windows 下执行 .venv\Scripts\activate pip install pytest目录结构长这样containers/ ├── __init__.py ├── container.py ├── array_list.py └── linked_list.py algorithms/ ├── __init__.py ├── sorting.py └── recursion.py tests/ ├── __init__.py └── test_containers.py虚拟环境这一步看着琐碎但它是所有后续实验的隔离带。不建虚拟环境等装到第三个第三方库时就会开始踩依赖冲突的坑。测试框架我选pytest断言语感更接近自然语言对新手友好。先写一条最基础的测试验证Container确实是抽象类# tests/test_containers.py from containers.container import Container def test_container_is_abstract(): try: Container() # 抽象基类不允许实例化 except TypeError: pass else: raise AssertionError(Container should be abstract)在项目根目录运行pytest tests/ -v看到1 passed就说明骨架通了。这一步很关键环境、导入路径、测试链路全部验证过后面每写一个类都可以立刻用同样的方式验证不用等写完再回头查为什么import失败。3. 从数组到链表亲手实现的容器类与它们的共同接口3.1 实现ArrayList扩容策略、负索引与迭代器第一个完整实现选动态数组因为它最贴近Python内置list又有值得拆解的扩容逻辑。先写一个不依赖内置list扩容细节的版本自己管理capacity目的是把「翻倍扩容均摊O(1)」这个经典结论变成看得见的代码。from typing import Iterator, Optional from containers.container import Container class ArrayList(Container): 基于 Python list 的动态数组展示翻倍扩容的逻辑。 def __init__(self, capacity: int 8) - None: self._data: list [] self._capacity capacity def add(self, value: object) - None: if len(self._data) self._capacity: self._capacity * 2 # 翻倍扩容均摊后 add 仍是 O(1) self._data.append(value) def __len__(self) - int: return len(self._data) def __getitem__(self, index: int) - object: if index 0: index len(self._data) if not 0 index len(self._data): raise IndexError(index out of range) return self._data[index] def __iter__(self) - Iterator[object]: return iter(self._data)代码逻辑拆开看add先判断当前元素数是否触及容量上限触发了就把容量翻倍再真正追加元素。capacity参数默认8意思是这个小容器起步就预留8个元素的逻辑容量这个数值你可以改成4或16影响的是扩容频率不影响正确性。__getitem__支持负索引还做了边界检查这样外面访问越界时抛的是我们自己定义的IndexError而不是底层list的原始报错。__iter__直接委托内置list的迭代器最省事。这里值得说一句「玄学」之外的道理为什么扩容要翻倍而不是加固定值翻倍意味着从空到n个元素的总分配次数是O(log n)分摊到每次add上约等于O(1)如果每次固定加8总分配次数是O(n)均摊就成了O(n)。同样的操作扩容策略不同整体复杂度完全不同这就是算法分析不是玄学、但确实反直觉的地方。3.2 单向链表实现节点与哨兵的设计链表的实现重点在节点设计和指针维护。节点用内部类_Node声明外部代码不需要也不应该直接碰它。给_Node加__slots__可以显著减少每个节点的内存占用每个Python对象默认带__dict__对海量节点来说那是实打实的开销。from typing import Iterator, Optional from containers.container import Container class _Node: 链表的私有节点使用 __slots__ 减少每个节点占用的内存。 __slots__ (value, next) def __init__(self, value: object, next_node: Optional[_Node] None) - None: self.value value self.next next_node class LinkedList(Container): 带头部引用的单向链表。 def __init__(self) - None: self._head: Optional[_Node] None self._size: int 0 def add(self, value: object) - None: new_node _Node(value) if self._head is None: self._head new_node else: cur self._head while cur.next is not None: cur cur.next cur.next new_node self._size 1 def __len__(self) - int: return self._size def __iter__(self) - Iterator[object]: cur self._head while cur is not None: yield cur.value cur cur.next链表的add是O(n)因为每次都要从头遍历到尾找到最后一个节点。这不是实现偷懒而是单向链表的结构天然如此。想要O(1)尾部插入有两个常见改法一是维护尾指针二是改成头插但每次取尾部元素。头插会破坏队列语义所以这个实现里选了尾指针方向的简单版本。__iter__用yield写成生成器每yield一次才返回一个值遍历大链表时内存占用是O(1)的。对比ArrayList和LinkedList两个实现能看到标题里说的「明显相似之处和不同之处」它们实现同一个Container接口都有add、__len__、__iter__这是相似但ArrayList按下标访问O(1)链表按下标访问必须从头走这是不同。接口相似、性能不同正是设计抽象时最需要看清的部分。3.3 统一的容器接口为什么三行遍历代码能跑三种结构接口统一带来的实际收益写一段绕不开容器类型细节的代码就看得最清楚。假设我们要对容器做同一件事加两个元素、遍历打印、输出长度。from containers.array_list import ArrayList from containers.linked_list import LinkedList def show(container: Container) - None: container.add(python) container.add(dsa) for item in container: print(item) print(len(container)) show(ArrayList()) show(LinkedList())同一个show函数传入ArrayList和LinkedList都能正确工作靠的就是基类约定的add、__iter__、__len__这三个接口。函数不需要知道底层是连续内存还是分散节点。这就是面向对象视角对算法学习最大的帮助你可以先站在调用方的角度看接口再切换到实现方看内部最后用同一套测试验证两个视角的一致性。这种写法有个边界要明白show能通用前提是它只调用基类约定的接口。如果哪天你在show里写了container[2]这种按下标访问LinkedList就会失效因为基类根本没有定义__getitem__。所以统一接口的收益是「有限度的通用」超出约定就要显式判断类型或重新抽象这也是很多框架设计里接口文档那么重要的原因。4. 栈、队列与递归组合优先还是继承优先4.1 栈与队列用组合包住原生list暴露最小操作集到栈和队列这里要做一个重要的设计决策继承list还是组合list常见的错误是class Stack(list)然后直接继承所有list方法结果栈能insert、能reverse、能按下标访问ADT语义完全被破坏。正确的姿势是组合内部持有list外部只暴露栈该有的操作。from typing import Iterator from containers.container import Container class Stack(Container): 后进先出LIFO容器内部组合一个 list。 def __init__(self) - None: self._items: list [] def add(self, value: object) - None: self._items.append(value) def pop(self) - object: if not self._items: raise IndexError(pop from empty stack) return self._items.pop() def peek(self) - object: if not self._items: raise IndexError(peek from empty stack) return self._items[-1] def __len__(self) - int: return len(self._items) def __iter__(self) - Iterator[object]: return reversed(self._items)class Queue(Container): 先进先出FIFO容器展示一个带有工程缺陷的教学实现。 def __init__(self) - None: self._items: list [] def add(self, value: object) - None: self._items.append(value) def pop(self) - object: if not self._items: raise IndexError(pop from empty queue) return self._items.pop(0) # 注意pop(0) 是 O(n)工程上应改用 collections.deque def __len__(self) - int: return len(self._items) def __iter__(self) - Iterator[object]: return iter(self._items)两个实现都组合了list但行为完全不同栈的__iter__用reversed从栈顶到底部遍历队列的__iter__按入队顺序。这个差异不是随便定的而是由ADT语义决定的——外部看到的是「栈后进先出、队列先进先出」内部却都只是一个list。组合优于继承在这里体现得最彻底组合让你精确控制暴露给外部的操作集继承则会把你不需要的方法也泄露出去。Queue里pop(0)是O(n)这个坑教学场景故意保留是为了让复杂度对比有真实素材。工程上请直接换成collections.deque它的popleft()是O(1)。学习阶段自己实现一遍再用标准库替换印象比直接看文档深得多。4.2 递归算法与调用栈用装饰器把递归深度画出来递归是很多人的知识盲区因为看不到调用栈。常见做法是直接打印日志但那样会污染函数逻辑。更好的办法是用装饰器包装函数在函数对象上记录当前递归深度和最大深度。import functools def trace_recursion(func): func._depth 0 func._max_depth 0 functools.wraps(func) def wrapper(*args, **kwargs): func._depth 1 func._max_depth max(func._max_depth, func._depth) try: return func(*args, **kwargs) finally: func._depth - 1 return wrapper trace_recursion def fib(n: int) - int: if n 2: return n return fib(n - 1) fib(n - 2) print(fib(10)) print(max depth:, fib._max_depth)这个装饰器的关键点有三个finally保证无论函数是否抛异常深度都会递减functools.wraps保留原函数名和文档字符串调试时不会看到一层wrapper壳_max_depth记录整个递归过程的峰值反映出「最深处占了多少层调用栈」。运行结果里max depth是10恰好等于输入参数因为fib(n)每次递归只下降一层。Python的递归深度默认限制在1000左右这不是玄学是解释器防止C栈溢出的安全措施。自己实现数据结构和算法时递归版本通常用来理解思路工程版本要换成显式栈或迭代。这个装饰器对你的价值在于把「看不见的调用栈」变成「看得见的数字」每次写递归都能立刻观测到递归占用情况。4.3 排序策略的封装一个可切换策略的Sorter类排序算法很适合做策略模式的教学案例接口不变算法可以运行时替换。先定义两个排序函数再写一个Sorter类负责调度。def insertion_sort(data: list) - None: 插入排序原地修改列表。 for i in range(1, len(data)): key data[i] j i - 1 while j 0 and data[j] key: data[j 1] data[j] j - 1 data[j 1] key def quick_sort(data: list) - None: 快速排序教学版使用递归与列表推导。 if len(data) 1: return pivot data[len(data) // 2] left [x for x in data if x pivot] mid [x for x in data if x pivot] right [x for x in data if x pivot] data[:] left mid right class Sorter: 策略模式排序算法可以在运行时切换。 def __init__(self, algorithminsertion_sort) - None: self._algorithm algorithm def set_algorithm(self, algorithm) - None: self._algorithm algorithm def sort(self, data: list) - list: result data[:] self._algorithm(result) return resultSorter维护一个_algorithm引用sort方法先拷贝原数据再调用当前算法原地排序。为什么拷贝为了不修改调用方传入的原始列表这是工程上的防御习惯。set_algorithm允许运行时替换策略数据量小时用插入排序数据量大时切到快速排序调用方完全不用改业务代码。快速排序的教学版本用了三个列表推导分别收集小于、等于、大于基准值的元素清晰但空间复杂度是O(n)。这就够用来理解分治思想了。想优化空间可以去实现原地分区版但那是另一个主题——在刚起步的阶段先保证「思路写得清楚」比「内存用得最省」更重要。这里的策略切换能力也为后面做排序算法的性能对比提供了现成的试验台。5. 算法实现避坑指南Python对象模型与复杂度误判的血泪经验5.1 坑1可变默认参数让所有栈实例共享同一个列表现象创建两个Stack实例往第一个里面push一个元素第二个实例的len也变成了1好像两个栈在共享同一块内存。原因Python的函数默认参数在定义时只求值一次。写成def __init__(self, items[])那个[]是全局唯一的对象所有实例的_items都指向它。解决默认参数一律用None函数体内再新建列表。class Stack: def __init__(self, itemsNone): self._items items if items is not None else []这段修正的逻辑是items is not None才复用传入的列表否则创建全新列表。代价是几乎为零收益是杜绝共享状态。这个坑在链表和树这类递归结构里更隐蔽——默认参数是父节点、默认参数是字典都会踩。写类时凡是默认值涉及可变对象条件反射改成None加判断。5.2 坑2递归深度的天花板与重复计算的浪费现象直接实现fib(35)要等好几秒fib(1000)直接抛RecursionError。代码逻辑没错但慢到没法用深到没法跑。原因第一个问题的根子是重复计算——fib(n)递归调用fib(n-1)和fib(n-2)同一个子问题被反复求解调用次数以指数增长。第二个问题的根子是Python的递归深度限制默认约1000层再深就保护性报错。解决用记忆化缓存中间结果把指数级降到线性级。from functools import lru_cache lru_cache(maxsizeNone) def fib(n: int) - int: if n 2: return n return fib(n - 1) fib(n - 2)lru_cache把每次调用的入参和返回值存进缓存maxsizeNone表示不限制缓存条目数。代价是用内存换时间缓存了n个结果换来fib(1000)秒回。如果不想引入第三方依赖手写一个字典做缓存也完全可行。这个坑的通用启示是遇到递归先问自己两个问题每个子问题是否被重复计算、递归深度是否可控。两个答案都是否再放心写递归版本。5.3 坑3把in list当成O(1)现象手写一个成员判断数据量到几十万以后慢得离谱但自己觉得只是简单查一下。原因item in list对list是线性扫描复杂度O(n)对set和dict是哈希查找平均O(1)。两者差了一个数量级数据量越大差距越明显。解决高频成员判断用set或者把数据建成索引结构。import timeit lst list(range(200_000)) st set(range(200_000)) t_list timeit.timeit(lambda: 199_999 in lst, number100) t_set timeit.timeit(lambda: 199_999 in st, number100) print(flist: {t_list:.4f}s, set: {t_set:.4f}s)跑出来的结果通常是list比set慢几十到上百倍。这个验证本身是个好习惯不要凭感觉猜复杂度timeit一测就知道真实差距。还要注意另一个隐藏点放进set的元素必须可哈希list、dict这类可变对象放不进去这时要么转成不可变类型要么自己设计索引结构。复杂度分析的结论必须在自己的机器上验证过才算真正内化。5.4 坑4继承层级过深变成一个黑匣子现象容器类三层以上的继承调用某个方法时不知道走的是哪个父类的实现想换行为又怕影响旁支子类改代码像拆雷。原因继承最大的问题在于它把多个层级的行为隐式叠加。第2章讲继承复用是有效的但那是「一两层的复用」。一旦超过三层每个方法都要沿着MRO链追溯来源阅读成本成倍上升重构起来没有后悔药。解决优先组合继承只保留在「is-a」关系明显且层级不超过两层的地方。第4章的Stack和Queue就是组合的正面教材内部持有一个list行为完全自己控制不暴露也不需要暴露list的方法。如果发现自己在用继承只是为了省几行代码先停一下想想改成组合是不是更清楚。数据结构和算法的学习阶段继承的用途应该集中在「抽象接口」和「一两层公共代码复用」而不是搭建复杂的类型树。5.5 坑5测试欠债太多重构时没有后悔药现象改完ArrayList的add逻辑发现LinkedList的测试也挂了或者改完某个细节后自己也说不清是否破坏了原有行为。原因测试只覆盖了正常路径或者每个容器各写各的测试没有统一的契约。容器之间的行为有一致约定测试却各搞一套回归时漏网之鱼特别多。解决为所有容器定义一套共用契约测试让每个实现都跑同一份考卷。import unittest from containers.array_list import ArrayList from containers.linked_list import LinkedList class ContainerContractTest(unittest.TestCase): 所有容器必须通过同一组契约测试。 def make(self): raise NotImplementedError def test_add_len_iter(self): c self.make() c.add(a) c.add(b) self.assertEqual(len(c), 2) self.assertEqual(list(c), [a, b]) class TestArrayListContract(ContainerContractTest): def make(self): return ArrayList() class TestLinkedListContract(ContainerContractTest): def make(self): return LinkedList()make方法由子类各自实现返回要测试的容器test_add_len_iter则断言所有容器共有的行为。新增一种容器时只要继承ContainerContractTest并实现make立刻获得全部契约测试。这个模式把「接口统一」和「测试复用」绑在了一起是学习阶段最值得养成的工程习惯。测试不是写完代码之后的装饰而是你自己改动代码时的安全网。6. 把这本书读成工程能力用标准库对照与性能回归验证你的实现6.1 用标准库验证接口设计读collections.abc源码自己实现的Container到底设计得好不好最好的评判标准是看标准库怎么定义同类接口。用Python一条命令就能调出标准库源码import collections.abc, inspect print(inspect.getsource(collections.abc.Container))读标准库的抽象基类定义和自己写的版本逐行对照标准库里Container只要求__contains__Sized要求__len__Iterable要求__iter__——它们是按能力拆分的多个小接口而不是一个大而全的基类。这个观察很重要标准库分层更细组合更灵活。你可以保留自己这份简单基类用于学习但心里要清楚工程上的接口划分应该更精细。这就是把「源码分析」变成学习材料的最短路径不用找任何外部资料标准库就在你的解释器里。6.2 复杂度对照用timeit验证你学过的每个结论数据结构学完了大O分析也背了但到底对不对要实测。下面这张表是常见操作的复杂度对比操作ArrayListLinkedListPython标准库对应尾部插入O(1) 均摊O(n)list.append()头部插入O(n)O(1) 头插deque.appendleft()按下标访问O(1)O(n)list[i]成员判断O(n)O(n)set平均O(1)用timeit跑一下头部插入的对比验证表里最后两行的差距import timeit from collections import deque lst list(range(10_000)) dq deque(range(10_000)) t_list timeit.timeit(lambda: lst.insert(0, -1), number1000) t_deque timeit.timeit(lambda: dq.appendleft(-1), number1000) print(flist.insert(0): {t_list:.4f}s, deque.appendleft: {t_deque:.4f}s)结果必然是deque.appendleft远快于list.insert(0)差异直接印证复杂度分析的结论。这个验证习惯值得长期保留每学一个复杂度结论就写一段timeit去证实它。复杂度分析不是考试题是你的性能预算表。6.3 组合与继承的判断标准一个可持续用的决策习惯学完这一整套实现到最后真正沉淀下来的东西是一套决策习惯。遇到一个新容器需求时我自己的执行顺序是第一步写出这个ADT必须暴露的操作清单第二步判断能否用组合包住现有类型第三步才考虑继承自哪个基类。继承只用在「多个类确实共享同一段行为逻辑」的场合而且层级不超过两层。这个顺序帮我绕过了很多设计上的弯路也让每一份容器代码都保持可读、可测、可替换。复盘整个过程我最大的教训是最初学这部分时一心求快跳过了亲手实现直接刷题结果面试时被问到底层结构一句话都答不上来。后来把每个容器按这个路径——定义接口、实现核心方法、写契约测试、对照标准库——完整过了一遍才算真正有了底气。希望你也能从亲手实现第一个ArrayList开始把数据结构和算法变成自己的工程底子希望帮到你。本文还有配套的精品资源点击获取