ADT
Abstract Data Type
是一些操作的集合。他们是数学层面上的抽象。ADT 的定义中只含有这些操作的行为而不涉及它们的实现。这些具体定义的行为也可以看成是一种约束(如栈就是设计为 LIFO 的),设计者期望通过这些约束获取一定的好处。
某种 ADT 需要拥有哪些操作取决于具体的需求,但存在一些特别通用的类型,将在后面逐一描述。
实现的部分包括数据结构和算法,不同的数据结构往往决定了不同的算法。
另外关于数据结构和算法之间的关系:某些数据结构的属性导致它们在某些需求上拥有优秀的算法性能,如数组的按序取值(FindKth),哈希的随机取值都拥有 O(1) 的复杂度。因此把一个具体问题拆分成若干基本需求,然后把基本需求化归到使用一种已知数据结构的属性来求解是一种典型的算法思路。
本篇描述 ADT 中的一类:序列类型,和其三个典型的 ADT:列表、栈和队列。
序列二字的**序,**表示本类型的元素排列是有序的,列则表示这是一种线性结构。因此所有的序列类型都可以看成一串元素,区别仅显示在他们某些特定属性(约束)上。
列表 list
列表是型如 A1, A2, A3,... An
的最基本的序列类型,我们只定义它的大小,为 n,和每两个元素之间的相对位置,即 A<sub>n-1</sub> 前驱 A<sub>n</sub>,A<sub>n</sub> 后继 A<sub>n-1</sub>。
基本实现有数组 和 链表,其中链表又可以实现为 单链表(singly linked list)、 双链表(doubly linked list) 或 循环链表(circularly linked list)。他们的 Find
方法都是 O(N) 的,但 Insert/Delete
和 FindKth
依实现各有不同。
数组 array
数组实现的空间因为是预分配的,所以在创建前大小必须已知。
其插入/删除的复杂度为 O(N),因为需要移动插入位置后面的全部元素。而 FindKth 的复杂度为 O(1),这一点在某些需求下尤其好用,但主要因为空间的问题,一般通用实现更偏爱链表。
链表 linked list
链表的结构更接近本节第一句话对列表的定义,因为它确实在每个元素里记录了其相邻元素的地址,这使其可以不占用连续的地址并支持动态大小。
但也因此其 FindKth 的复杂度为 O(N),因为你无法像数组一样通过计算偏移量来直接找到你要的元素,必须从头遍历。但一旦找到位置,其插入/删除的复杂度是 O(1),因为只需要改变两个指针就可以了。
多数地方,如 Wikipedia,对链表的插入复杂度解释为 O(1),是因为没有把找到这个位置的过程算进来。不算的理由是链表的使用通常伴随着一次遍历,在遍历的过程中按需增删。因此如果是在通用概念的列表增删操作下,复杂度的优势(较数组)并不存在。
在编程细节上,双链表实现增加了反向查询的便利;而循环链表或循环双链表使得链表连成了一个圈,又增加了某些情况下的便利性,比如负数索引
>>> a = [1, 2, 3]
>>> a[-1]
3
基数排序 radix sort
基数的意思是一种进制下独立数字的个数,即 n 进制的 n。
基数排序的原型是桶排序(bucket sort)。即假设要给 [0, M) 范围内的 N 个整数排序,那么我们就造一个大小为 M 的数组,然后初始化为 0。然后遍历待排序的数字,将数组对应数字索引的元素 +1,即记为该数字遇到了一次。遍历完后扫一遍桶就得到了排序后的结果。例
lang:python
from array import array
M = 1000
numbers = [random.randint(0, M) for i in range(12)]
print(numbers)
buckets = array('i', [0] * M)
for num in numbers:
buckets[num] += 1
sorted = []
for i in range(M):
sorted.extend([i] * buckets[i])
print(sorted)
执行可得: 注意重复元素 264 也得到了正确处理
[242, 823, 986, 704, 28, 428, 442, 185, 859, 482, 264, 264]
[28, 185, 242, 264, 264, 428, 442, 482, 704, 823, 859, 986]
后面不再特意使用 array 对象,一律以 Python 默认的 list 代替,具体应该用数组还是链表,依上下文而定。
桶排序的问题在于构建桶的时候我们在一个数组里穷举了所有可能的元素,但只排序了少量值,造成了空间的浪费。另一方面在机器资源有限的前提下,单是穷举所有元素这件事都可能是危险的。
解决这个问题的算法方法和人类对自然数的处理方法如出一辙。就像人并没有为数一千头羊发明一千个数字,而是选择了基于位置的基数表示法一样,我们在这个问题上也可以选择构建 log<sub>R</sub>M 个数组。其中每个数组的大小都是 R,即 基数。然后从低位到高位多次排序。
计算系数的公式:
coefficient = (number / (radix ** index)) % radix
e.g.
number = 123
radix = 10
coefficient_0 = 3
coefficient_1 = 2
coefficient_2 = 1
基数排序与桶排序还有一点不同,因为这次的桶只含有原数字的一位,我们需要在排序时把原数带上:
M = 1000
radix = 12
numbers = [242, 823, 986, 704, 28, 428, 442, 185, 859, 482, 264, 264]
for index in range(int(math.ceil(math.log(M, radix)))):
sorted = [[] for i in range(radix)]
for num in numbers:
coefficient = (num / (radix ** index)) % radix
sorted[coefficient].append(num)
numbers = reduce(lambda x, y: x + y, sorted)
print(numbers)
栈(stack) & 队列(queue)
栈和队列是对列表添加特定约束后的数据结构。栈要求每次存取元素都要在列表的一头进行,这一端称为其顶(top)。而队列要求存取元素分别在其两端进行,分别叫做对头(front)和队尾(rear)。
栈的后入先出特性使其适合用作实现某些流程的分步暂存,比如函数调用。递归调用里面有一种情形是总在代码最后进行递归调用,这被称为尾递归,如下
def feb(n):
if n == 0:
return 0
elif n == 1:
return 1
else:
return feb(n-1) + feb(n-2)
这是一种性能非常不好的递归用法,因为递归开始时暂存的变量在递归结束后都没用了,等于纯浪费。因此这种情形有时会被编译器或虚拟机优化掉,称为尾调用优化(Tail Call Optimization, TCO)。这时的调用不会修改调用栈。
手动优化尾递归的方式是把它变成一个循环:
def feb1(n):
if n < 2:
return n
else:
x = 0
y = 1
for i in range(n-1):
x, y = y, x + y
return y
使用 timeit
测试的话会发现 feb1 比 feb 快很多很多。
队列的应用场景就像 queue 的本意一样,多是用于顺序任务处理的缓存。