(二)基础(Fundamentals):算法分析

观察

运行时间和输入本身相对无关,主要取决于问题规模
例:统计文件中三个数和为0的数量

public class ThreeSum{
    public static int count(int a[]){
        int N = a.length, cnt = 0;
        for(int i = 0; i < N; i++){
            for(int j = i + 1; j < N; j++){
                for (int k = j + 1; k  < N; k++){
                    if(a[i] + a[j] + a[k] == 0)
                        cnt += 1;
                }
            }
        }
    }
}

一种表示计时器的抽象数据类型:

public class StopWatch{
    private final long start;
    public StopWatch(){
        start = System.currentTimeMillis();
    }
    public double elapsedTime(){
        long now = System.currentTimeMillis();
        return (now - start) / 1000.0;
    }
}

数学模型

程序运行总时间主要和两点有关:

  • 执行每条语句的耗时(取决于计算机、Java编译器和操作系统)
  • 执行每条语句的频率(取决于程序本身和输入)

对于执行频率,有些分析很容易,如在 Three.count() 中将 cnt 设为 0 的语句只会执行一次;有些需要深入分析,如 Three.count() 中的 if 语句会执行 N(N-1)(N-2)/6 次。

近似

用 ~f(N) 表示所有随着N增大除以f(N)的结果趋近于1的函数。用g(N)~f(N)表示随着g(N)/f(N) 随着N增大趋近于1。

一般用到的近似方式都是g(N)~f(N),其中 f(N)=Nb(logN)c,其中a,b和c均为常数,将f(N)称为g(N)的增长数量级。
常见的增长数量级函数:

描述 函数 B
常数级 1
对数级 logN
线性级 N
线性对数级 NlogN
平方级 N^2
立方级 N^3
指数级 2^N

执行最频繁的执行决定了程序执行的总时间——称这些指令为程序的内循环

成本模型

成本模型来评估算法的性质,这个模型定义了所研究算法中的基本操作。例如,3-Sum问题的成本模型是访问数组元素的次数。

构建运行时间的数学模型所需步骤:

  1. 确定输入模型,定义问题规模
  2. 识别内循环
  3. 根据内循环中的操作确定成本模型
  4. 对给定输入,判断这些操作的执行频率

如二分查找,它的输入模型是大小为N的数组a[],内循环是while循环中所有语句,成本模型是比较操作(比较两个数组元素的值)

设计更快的算法

2-Sum 问题

假设所有整数各不相同,这个问题很容易在平方级别——双重循环来解决,下面利用归并排序和二分查找在线性对数级别解决 2-Sum 问题:

思路:当且仅当 -a[i] 存在于数组中(且a[i]非0)时,a[i] 存在于某个和为0的整数对中。

public class TwoSumFast{
    public static int count(int[] a){
        //先排序
        Arrays.sort(a);
        int cnt = 0;
        for(int i = 0; i < a.length; i++){
            if(BinarySearch.rank(-a[i], a) > i)
                cnt++;
        }
        return cnt;
    }
}

归并排序所需时间与NlogN成正比,二分查找所需时间和logN成正比,因此整个算法运行时间和NlogN成正比。

3-Sum 问题

同样假设所有整数各不相同,同上面一样,当且仅当 -(a[i] + a[j]) 在数组中(不是a[i] 也不是 a[j])时,整数对(a[i] 和 a[j])为某个和为0的三元组的一部分。

public class ThreeSumFast{
    public static int count(int[] a){
        //先排序
        Arrays.sort(a);
        int cnt = 0;
        for(int i = 0; i < a.length; i++){
            for(int j = i + 1; j < a.length; j++)
                if(BinarySearch.rank(-(a[i] + a[j]), a) > j)
                    cnt++;
        }
        return cnt;
    }
}

内存

对象

典型对象的内存需求.png

数组

数组对内存的典型需求.png

字符串对象

字符串的内存需求.png

当调用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 算法

代码
public class UF{
    private int[] id;
    private int count;
    public UF(int N){
        id = new int[N];
        count = N;
        for(int i = 0; i < N; i++){
            id[i] = i;
        }
    }
    public void union(int p, int q){
        int pid = find(p);
        int qid = find(q);
        if(pid == qid){
            return;
        }
        for(int i = 0; i < id.length; i++){
            if(id[i] == pid){
                id[i] = qid;
            }
        }
        count--;
    }
    public int find(int p){
        return id[p];
    }
    public boolean connected(int p, int q){
        return id[p] == id[q];
    }
    public int count(){
        return count;
    }
}
分析

上述代码的find方法十分高效,因为仅仅需要一次数组读取操作就能够找到该节点的组号,但是问题随之而来,对于需要添加新路径的情况,就涉及到对于组号的修改,因为并不能确定哪些节点的组号需要被修改,因此就必须对整个数组进行遍历,找到需要修改的节点,逐一修改,每次添加新路径带来的复杂度就是线性关系了,如果要添加的新路径的数量是M,节点数量是N,那么最后的时间复杂度就是MN,显然是一个平方阶的复杂度,对于大规模的数据而言,平方阶的算法是存在问题的,这种情况下,每次添加新路径就是“牵一发而动全身”,想要解决这个问题,关键就是要提高union方法的效率,让它不再需要遍历整个数组。

2 quick-union 算法

代码

id[] 数组用父链接的形式表示了一片森林,从任何触点所对应的节点开始跟随链接,最终都将到达含有该节点的树的根节点。quick-union 中 union() 的实现只用了一条语句就将一个根节点变为另一个根节点的父节点,从而归并了两棵树。

public class QuickUnionUF{
    private int[] id;
    private int count;
    public QuickUnionUF(int N){
        id = new int[N];
        count = N;
        for(int i = 0; i < N; i++){
            id[i] = i;
        }
    }
    public void union(int p, int q){
        int pRoot = find(p);
        int qRoot = find(q);
        if(pRoot == qRoot){
            return;
        }
        id[pRoot] = qRoot;
        count--;
    }
    public int find(int p){
        while(p != id[p]){
            p = id[p];
        }
        return p;
    }
    public boolean connected(int p, int q){
        return find(p) == find(q);
    }
    public int count(){
        return count;
    }
}
分析
quick-union算法概述.png

quick-union 算法看起来比 quick-find 算法更快,因为它不需要为每对输入遍历整个数组。但树这种数据结构容易出现极端情况,因为在建树的过程中,树的最终形态严重依赖于输入数据本身的性质,比如数据是否排序,是否随机分布等等。比如在输入数据是有序的情况下,构造的BST会退化成一个链表。如下图所示。

quick-union最坏情况.png

定义:一棵树的大小是它的节点的数量。树中一个节点的深度是它到根节点的路径上的链接(也就是边)数树的高度是它的所有节点中的最大深度。
命题: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)~N^2。

3 加权 quick-union 算法

只需简单修改quick-union算法就能保证像这样的最坏情况不会出现。与其在 union() 中随意将一棵树连接到另一棵树,现在记录每棵树的大小并总是将较小的树连接到大数上。此时需添加一个数组来记录树中节点数,将这种算法称为加权 quick-union 算法。该算法构造的树的高度也远远小于未加权版本构造的树的高度。

代码
public class WeightedUnionUF{
    private int[] id;
    private int count;
    private int[] sz;
    public WeightedUnionUF(int N){
        id = new int[N];
        count = N;
        for(int i = 0; i < N; i++){
            id[i] = i;
            sz[i] = 1;
        }
    }
    public void union(int p, int q){
        int pRoot = find(p);
        int qRoot = find(q);
        if(pRoot == qRoot){
            return;
        }
        //比较树大小,小树指向大树,修改大树大小
        if(sz[pRoot] < sz[qRoot]){
            id[pRoot] = qRoot;
            sz[qRoot] += sz[pRoot];
        }else{
            id[qRoot] = pRoot;
            sz[pRoot] += sz[qRoot];
        }
        count--;
    }
    public int find(int p){
        while(p != id[p]){
            p = id[p];
        }
        return p;
    }
    public boolean connected(int p, int q){
        return find(p) == find(q) ;
    }
    public int count(){
        return count;
    }
}
分析

命题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次形成鲜明对比。

各种union-find算法性能特点.png

4 最优算法——基于quick-union 算法进行路径压缩

理想情况下,我们希望每个结点都直接链接到它的根节点,但又不想像quick-find那样通过修改大量链接实现,实际实现很简单——在检查节点时将它们直连到根节点。

public int find(int p){
    while(p != id[p]){
        //若当前节点非根节点
        //则使当前节点指向父节点的父节点/或直接指向根节点find(index)
        id[p] = id[id[p]];
        p = id[p];
    }
    return p;
}

所得到的结果几乎是完全扁平化的树,即路径压缩的加权quick-union算法是最优的算法

参考:

并查集(Union-Find)算法介绍