观察
运行时间和输入本身相对无关,主要取决于问题规模
例:统计文件中三个数和为0的数量
|
|
一种表示计时器的抽象数据类型:
|
|
数学模型
程序运行总时间主要和两点有关:
- 执行每条语句的耗时(取决于计算机、Java编译器和操作系统)
- 执行每条语句的频率(取决于程序本身和输入)
对于执行频率,有些分析很容易,如在 Three.count() 中将 cnt 设为 0 的语句只会执行一次;有些需要深入分析,如 Three.count() 中的 if 语句会执行 $\frac{N(N-1)(N-2)}{6}$ 次。
近似
用 ~f(N) 表示所有随着N增大除以f(N)的结果趋近于1的函数。用g(N)~f(N)表示随着g(N)/f(N) 随着N增大趋近于1。
一般用到的近似方式都是 $g(N)\sim f(N)$,其中 $f(N)=N^b(logN)^c$,其中a,b和c均为常数,将f(N)称为g(N)的增长数量级。
常见的增长数量级函数:
描述 | 函数 B |
---|---|
常数级 | 1 |
对数级 | $logN$ |
线性级 | $N$ |
线性对数级 | $NlogN$ |
平方级 | $N^2$ |
立方级 | $N^3$ |
指数级 | $2^N$ |
执行最频繁的执行决定了程序执行的总时间——称这些指令为程序的内循环
成本模型
用成本模型来评估算法的性质,这个模型定义了所研究算法中的基本操作。例如,3-Sum问题的成本模型是访问数组元素的次数。
构建运行时间的数学模型所需步骤:
- 确定输入模型,定义问题规模
- 识别内循环
- 根据内循环中的操作确定成本模型
- 对给定输入,判断这些操作的执行频率
如二分查找,它的输入模型是大小为N的数组a[],内循环是while循环中所有语句,成本模型是比较操作(比较两个数组元素的值)
设计更快的算法
2-Sum 问题
假设所有整数各不相同,这个问题很容易在平方级别——双重循环来解决,下面利用归并排序和二分查找在线性对数级别解决 2-Sum 问题:
思路:当且仅当
-a[i]
存在于数组中(且a[i]
非0)时,a[i]
存在于某个和为0的整数对中。
|
|
归并排序所需时间与 NlogN 成正比,二分查找所需时间和 logN 成正比,因此整个算法运行时间和 NlogN 成正比。
3-Sum 问题
同样假设所有整数各不相同,同上面一样,当且仅当 -(a[i] + a[j])
在数组中(不是 a[i]
也不是 a[j]
)时,整数对(a[i]
和 a[j]
)为某个和为0的三元组的一部分。
|
|
内存
对象
数组
字符串对象
当调用 substring()
方法时,就创建了一个新的 String
对象(40字节),但仍然重用了相同的 value[]
数组,因此该字符串的子字符串只会使用40字节的内存,也就是说,一个子字符串的额外内存是一个常数,构造一个子字符串所需时间也是常数。
举例:union-find 算法
动态连通性
问题的输入是一列整数对,其中每个整数都表示一个某种类型的对象,一对整数p q可以被理解为“p和q是相连的”,具有如下性质;
- 自反性:p 和 p 是
- 对称性:若 p 和 q 相连,则 q 和 p 也相连
- 传递性:若 p 和 q 相连且 q 和 r 相连,则 p 和 r 也相连
union-find 算法API
方法名 | 操作 |
---|---|
UF(int N) | 以整数标识(0 到 N-1)初始化N个触点 |
void union(int p, int q) | 在 p 和 q 之间添加一条连接 |
int find(int p) | p 所在的分量的标识符(0 到 N-1) |
boolean connected(int p, int q) | 如果 p 和 q 存在于同一个分量则返回 true |
int count() | 连通分量的数量 |
用一个以触点为索引的数组
id[]
作为基本数据结构来表示所有分量。初始有 N 个分量,每个触点都构成了一个只含有它自己的分量,因此将id[i]
初始化为 i,其中 i 为 0 到 N-1。find()
判定它所在分量所需的信息保存在id[i]
之中。connected()
方法只有一条语句find(p)==find(q)
,它返回一个布尔值。
实现
1 quick-find 算法
代码
|
|
分析
上述代码的 find 方法十分高效,因为仅仅需要一次数组读取操作就能够找到该节点的组号,但是问题随之而来,对于需要添加新路径的情况,就涉及到对于组号的修改,因为并不能确定哪些节点的组号需要被修改,因此就必须对整个数组进行遍历,找到需要修改的节点,逐一修改,每次添加新路径带来的复杂度就是线性关系了,如果要添加的新路径的数量是M,节点数量是N,那么最后的时间复杂度就是MN,显然是一个平方阶的复杂度,对于大规模的数据而言,平方阶的算法是存在问题的,这种情况下,每次添加新路径就是“牵一发而动全身”,想要解决这个问题,关键就是要提高union方法的效率,让它不再需要遍历整个数组。
2 quick-union 算法
代码
id[] 数组用父链接的形式表示了一片森林,从任何触点所对应的节点开始跟随链接,最终都将到达含有该节点的树的根节点。quick-union 中
union()
的实现只用了一条语句就将一个根节点变为另一个根节点的父节点,从而归并了两棵树。
|
|
分析
quick-union 算法看起来比 quick-find 算法更快,因为它不需要为每对输入遍历整个数组。但树这种数据结构容易出现极端情况,因为在建树的过程中,树的最终形态严重依赖于输入数据本身的性质,比如数据是否排序,是否随机分布等等。比如在输入数据是有序的情况下,构造的BST会退化成一个链表。如下图所示。
定义:一棵树的大小是它的节点的数量。树中一个节点的深度是它到根节点的路径上的链接(也就是边)数。树的高度是它的所有节点中的最大深度。
命题:quick-union 算法中 find() 方法访问数组的次数为1加上给定触点所对应的节点的深度的两倍。union() 和 connected() 访问数组的次数为两次find() 操作(若union()中给定的两个触点分别存在于不同的树中则还需要加1)。
由命题G可知,对于整数对0 i,union()操作访问数组的次数为2i+2
(触点0的深度为i,触点i的深度为0)。因此,处理N对整数所需的所有find()操作访问数组的总次数为 $2(1+2+…+N)\sim N^2$。
3 加权 quick-union 算法
只需简单修改quick-union算法就能保证像这样的最坏情况不会出现。与其在 union() 中随意将一棵树连接到另一棵树,现在记录每棵树的大小并总是将较小的树连接到大数上。此时需添加一个数组来记录树中节点数,将这种算法称为加权 quick-union 算法。该算法构造的树的高度也远远小于未加权版本构造的树的高度。
代码
|
|
分析
命题H:对于 N 个触点,加权 quick-union 算法够早的森林中的任意节点的深度最多为 lgN。
推论:对于加权 quick-union 算法和 N 个触点,在最坏情况下find()、connected()
和union()
的成本增长数量级为 logN。
命题H和推论的意义在于加权 quick-union 算法是三种算法中唯一可以用于解决大型实际问题的算法。加权 quick-union 算法处理N个触点和M条连接时最多访问数组 cMlgN 次,c为常数,比 quick-find(和某些情况下的quick-union)需要访问数组至少MN次形成鲜明对比。
4 最优算法——基于quick-union 算法进行路径压缩
理想情况下,我们希望每个结点都直接链接到它的根节点,但又不想像quick-find那样通过修改大量链接实现,实际实现很简单——在检查节点时将它们直连到根节点。
|
|
所得到的结果几乎是完全扁平化的树,即路径压缩的加权 quick-union 算法是最优的算法
参考:
前面两篇文章的相关代码地址:
代码地址