算法笔记
使用视频BV1u441127b5,随时更新。
1. 并查集
1.1 算法设计
问题建模
寻找算法
时空复杂度
迭代
1.1.1 动态连接问题
n个物体的连接,有一个连接命令连接两个物体,如何寻找两个物体间是否有连接?
连通性的例子可以很大,在这一节,首先探究是否连通,之后探讨具体路径是什么。
1.1.2 连通关系
定义一个关系是连通的:满足自反性、对称性和传递性。在一个图中,将彼此相连的元素称为一个连通分量,每个连通分量都互斥,且并集等于整个集合。
1.2 寻找算法
1.2.1 定义类与API
定义一个class UF
,其union
运算合并的是两个元素的所在的连通分量;其connected
运算返回是否。通过元素的总数,将这个类初始化:
class UF:
def __init__(self, N):
self.nums = N
def union(self, p, q):
Pass
def connected(self, p, q):
Pass
上面的代码就是一个在python中的UF类,这个类包含两个方法,查找connected
和合并union
。接下来的两节就围绕如何更快更好地实现这两个方法进行。
在此之前,首先定义一个API,使得程序可以从写有连接数对的txt文档中读取连接关系,txt的第一行写的是元素总数,其余的每一行写有一个数对,表明两个元素是相连的,例如,当txt的内容如下时:
10
4 3
3 8
6 5
可以这样处理:
with open('connections.txt', 'r', encoding='utf-8') as f:
connections = f.readlines()
N = int(connections[0])
uf = UF(N)
for ct in connections[1:]:
p, q = list(map(int, ct.strip().split(' ')))
uf = uf.union(p, q)
此处注意,p和q使用整型或是字符串,其实并没有什么大的影响,只要注意在代码当中统一即可。这里是按照使用int
整型,实际上也可使用字符串。
以上使用python自带的open
方法来操作txt,另一个常用来操作txt数据的是numpy
库,但是此处,使用np.loadtxt('name.txt', dtype=np.int)
是不行的,如下图所示的代码:
import numpy as np
ct = np.loadtxt('example.txt', dtype=np.int)
会报错:
ValueError: Wrong number of columns at line 2
原因是numpy
要求txt中每一行都应有统一的格式。
但是,用pandas
是可以的,原因是pandas
可以跳行。
import pandas as pd
# N已知
uf = UF(N)
df=pd.read_table('/data01/mingyu19/example.txt', sep=' ', header=None, skiprows=[0])
for i in range(len(df)) # 或in range(df.shape[0]):
p, q = df.iloc[i]
uf.union(p, q)
以上代码跳过了txt不规整的第一行,将后续的内容以dataframe
的格式读入。如果已知N值的话可以这样使用,否则,从文件中读取第一行的N值可能还是需要用刚才with open()
的方法,或者总之需要再次打开一遍文件专门读取第一行的元素总数,比较繁琐。
1.2.2 快速查找
第一种思路是对于给每个连通集标号,不同的节点就有不同的编号,处于同一个连通集内的节点,其编号相同。
这种逻辑结构使得查找连通元素变得十分简单,而合并则需要将两个连通集中的元素的编号统一。当数据集很大时,有过多的值需要变化,时间复杂度较高,一次union
的操作时间复杂度为
O
(
N
)
O(N)
O(N)。
class UF:
def __init__(self, N):
self.nums = N
self.data = {}
def union(self, p, q):
value_p = self.data[str(p)]
value_q = self.data[str(q)]
for key in self.data:
if self.data[key] == value_q:
self.data[key] = value_p
return self.data
def connected(self, p, q):
p, q = str(p), str(q)
if p in self.data and q in self.data:
return self.data[p]==self.data[q]
else:
return False
需要注意的是,字典的键key
使用了字符串,是这一数据类型的要求。
此外,可以使用列表,算法上没有区别,之后的展示将以列表形式给出,字典形式可以同理类推。
class UF:
def __init__(self, N):
self.nums = N
self.data = [i for i in range(N)]
def union(self, p, q):
i_p = self.data[p]
i_q = self.data[q]
for index in self.data:
if index == i_q:
self.data[index] = i_p
return self.data
def connected(self, p, q):
return self.data[p]==self.data[q]
虽然名为快速查找,但是这种方法实际上很慢—— O ( N ) O(N) O(N),对于整体的操作则是 O ( N 2 ) O(N^2) O(N2)。
1.2.3 快速合并
应对1.2.2中合并的速度缺陷,一个直观的思路就是甩掉一层for循环。可以通过树的方式组织数据,使得合并时只要连接相应连通树的根节点,非常快速。
在算法上的实现主要依赖以下性质——
因此在此基础上,在类中增加一个“寻根”的代码:
class UF:
def __init__(self, N):
self.nums = N
self.data = [i for i in range(N)]
def union(self, p, q):
r_p = self.root(p)
r_q = self.root(q)
self.data[q] = r_p
return self.data
def connected(self, p, q):
return self.root(p)==self.root(q)
def root(self, i):
while self.data[i] != i:
i = self.data[i]
return i
这种写法表面上去除了一个for
循环,但是引入了一个while
循环,因此虽然在合并时可能会变快(因为通常无需严格地遍历整个数组,而只是一部分),但是在查找connected
时变慢了。
1.2.4 提升方法
1.2.4.1 加权法
如果将小树连在大树上而不是相反,就能在一定程度上避免树过高,这可以通过跟踪每棵树的元素个数实现。
之所以选择跟踪叶子节点个数而不是跟踪高度,是因为树的节点越多,数据点就越有可能落在树上。即便是一颗大而矮的树,如果放在下面,也会因为增加了太多元素的寻根距离而影响时间复杂度。因此大的树在上,比小的树在上更有利。
加权法使用空间换时间,另开一个新的数组,存储每个根节点对应的树的叶子节点个数。对于union
方法,先考察两棵树的大小,再进行连接:将小的树的根连接到大的树的根部,成为大的树的叶子节点。
代码改变了union
方法——
class UF:
def __init__(self, N):
self.nums = N
self.data = [i for i in range(N)]
self.sz = [1 for i in range(N)] # 数字1
def union(self, p, q):
r_p = self.root(p)
r_q = self.root(q)
if sz[r_p] > sz[r_q]:
self.data[r_q] = r_p
self.sz[r_p] += self.sz[r_q]
else:
self.data[r_p] = r_q
self.sz[r_q] += self.sz[r_p]
return self.data, self.sz
def connected(self, p, q):
return self.root(p)==self.root(q)
def root(self, i):
while self.data[i] != i:
i = self.data[i]
return i
经过这样的操作,操作的耗时与节点深度有关,时间复杂度为 O ( l o g 2 N ) O(log_2N) O(log2N)
1.2.4.2 路径压缩法
在寻找某个节点的根节点的过程中,会遇到该节点的根并不是整棵连通树的根的情况,对于这种不直连根节点的中间节点,可以将这个节点的父节点改为整棵树的根节点,从而压缩路径。
在实现时,为了简单操作,可以做一些妥协:只将这个节点的父节点替换为其祖父节点,也就是只缩短一层路径。对于那些直连根节点的叶子,经过操作不受影响。
代码改变了root
方法——
class UF:
def __init__(self, N):
self.nums = N
self.data = [i for i in range(N)]
self.sz = [1 for i in range(N)] # 数字1
def union(self, p, q):
r_p = self.root(p)
r_q = self.root(q)
if sz[r_p] > sz[r_q]:
self.data[r_q] = r_p
self.sz[r_p] += self.sz[r_q]
else:
self.data[r_p] = r_q
self.sz[r_q] += self.sz[r_p]
return self.data, self.sz
def connected(self, p, q):
return self.root(p)==self.root(q)
def root(self, i):
while self.data[i] != i:
self.data[i] = self.data[self.data[i]]
i = self.data[i]
return i
经过树展平,这个算法的时间复杂度已经非常接近于线性。可惜,完全线性的算法是不存在的。
1.2.5 应用
1.2.5.1渗滤
现定义一个系统,具有
N
∗
N
N*N
N∗N个方格,这些方格可能是开放的(白色)或闭塞的(黑色),每个方格开放的概率是
p
p
p. 一个系统是渗滤的,当从矩形的上边到下边存在由白色方格连接的通路。当这种通路不存在时,系统是不渗滤的。
显然的,当开放概率很低是,系统将是闭塞的;当开放概率很高时,系统将是开放的。开放概率与系统开放的关系呈陡峭的S形曲线,当开放概率在0.593左右,系统的开放概率从接近于0陡峭上升至接近于1.
要想求解这个值,需要蒙特卡洛仿真,是快速并查集的一种延伸应用。
1.3.1 算法导论引言
1.3.2 观察:3-总和问题
3-总和问题:给出一个数组,寻找其中的三个元素使得和为零,找到所有组合。
暴力法:
class ThreeSum():
def __init__(a):
self.a = a
self.len = len(a)
def count(a):
num = 0
for i in range(self.len):
for j in range(i+1, self.len):
for k in range(j+1, self.len):
if i+j+k == 0:
num += 1
return num
如果想要测量程序运行的时间可以使用time
库中的time.time()
或time.clock()
实际上,程序运行的时间可以精确测量,只要计算出每个语句的执行次数就可以。比如1-SUM问题和2-SUM问题。