算法分析学习笔记(一) - 动态连通性问题的并查集算法(上)

一. 写在前面的话

     “算法分析学习笔记”系列是我在Coursera上选修Sedgewick教授的“Algorithms”公开课过程中积累的一些学习心得。本篇是该系列的第一篇,主题是动态连通性问题(Dynamic Connectivity)。大概在三年以前,当我正头痛于CLRS上晦涩的红黑树介绍时,无意中在网上发现了Sedgewick教授的一篇讲红黑树的ppt,把个稀奇古怪的红黑树讲得浅显易懂,那个时候我花了一个月的时间啃通了老教授的课件,并用C语言自己实现了一遍,才彻底弄懂红黑树那些旋转操作是怎么来的,以及有什么意义。三年后,我已经告别USTC并工作了一年,成为了一名软件工程师。软件开发行业是一个学无止境的行业,因为我们必须要靠自己的知识来为社会创造价值,所以,我们必须保持一颗如饥似渴的心态,不断地正视自身的缺陷和不足,然后想办法弥补。算法一直是我的薄弱环节,听闻老教授终于在Coursera上开课讲算法了,于是买来Algorithms 4ed,准备跟着讲义把老教授的算法课程扎扎实实地再巩固一遍,于是便有了这一个系列的学习笔记。听一遍课,看一遍书,做一遍习题,再总结和整理一遍学习笔记,辅之以有效的学习方法,一定会收到良好的效果,因为“人一能之,己百之;人十能之,己千之。果能此道矣,虽愚必明,虽柔必强”。
     “理论上解决问题”与“实际上解决问题”是有区别的。而学习算法设计与分析的主要目的,就是为了培养解决实际问题的能力。对于某一具体问题而言,一般都能第一时间想到一个“笨方法”来解决,比如简单粗暴地遍历一遍或者穷举所有可能,但解决实际问题时要处理的数据往往很大,如果给出的方法要花几十年或者上百年才算得出结果,这跟没有解决问题有什么区别?学习算法分析与设计,就是学习如何在尽可能快的时间内对(规模较大的)实际问题给出正确结果。
     作为开宗明义的第一篇,本文主要包括两个方面。首先是通过动态连通性问题来展示从问题定义到算法实现再到效率优化最后付诸实际应用的全过程。介绍算法设计与分析的系统化方法。我们先从问题的说明开始,一步一步建立模型,总结该问题解决的实际过程是怎样的,不同解决方案所采用的策略以及它们的效率,最后给出一个该问题在实际生活中的应用场景。本文的第二个部分则围绕“举一反三”展开,谈谈知识迁移能力在算法学习中发挥的作用。介绍了怎样将匈牙利数学家乔治波利亚的方法用于算法学习,并以二叉搜索算法举例如何将已有的知识用于解决新问题,最后给出一个简单的关于“怎样学习算法才有效”的答案。

二. 动态连通性问题

     动态连通性问题的输入是一个整数的点对序列“p q”,它们各自代表某一物体,每一个整数的点对“p q”表示的含义是“p连接到q”。我们的任务就是,写一个程序,以这一点对序列“p q”为输入,当发现“p q”没有连接在一起时,将它们连接起来并在屏幕上输出;否则就忽略掉,继续处理下一项。
     举个例子来说明吧,假设我们有10个物体编号为0到9,一开始这10个物体之间应该是互相没有连接的,而当处理如图2-1所示的点对序列之后,这10个物体最后的连接形式就如图2-2所示,其中灰色数字表示被忽略掉的点对,即已经建立了连接的点对(两幅图均来自于Sedgewick教授算法公开课的课件,下同)。
算法分析学习笔记(一) - 动态连通性问题的并查集算法(上)_第1张图片
图2-1 点对序列
算法分析学习笔记(一) - 动态连通性问题的并查集算法(上)_第2张图片
图2-2 处理完点对序列后各物体的连接情况
算法分析学习笔记(一) - 动态连通性问题的并查集算法(上)_第3张图片
图2-3 实际可能遇到的动态连通性问题
     当然,面对这样一个微不足道,只有10个节点的问题,用算法解决你可能觉得牛刀杀鸡了——凭肉眼我都能自己判断出来。但如果是形如图2-3所示包含成千上万个节点的复杂网络呢?我闭着眼睛随便找两个节点,问你它们之间有没有通路,你还能用肉眼看得出来吗?当然就看不出来了,这个时候对于这种规模庞大的问题,就要用我们的算法来解决了。
     当我们把“物体”这个有点抽象的词,替换为某些我们看得见,摸得着的具体事物的时候,我们就能发现这个抽象问题不同的应用场景。场景一:计算机网络。若这些数字编号的物体是大型计算机网络中的各计算机设备的话,点对序列即表示了计算机之间的通信连接。因此我们的算法能够判断对于某两台计算机p和q,要使它们能够互相通信,是需要建立新的连接,还是可以利用已有的线路建立一个通信路径?场景二:社交网络。数字编号的物体代表Facebook上的用户,而点对表示朋友关系,那么我们的算法就可以在两个用户之间建立朋友关系,或者查找他们俩有没有“可能认识的人”。场景三:变量名的等价性。在某些编程语言中,可以用我们的算法来检测某两个变量是否是等价的(都指向同一对象)。场景四:数学上的集合论。可以判断某两个整数是否属于同一集合。
     看了问题的定义和这么多实用的应用场景,下面我们就要多迈出几步,深入探讨一下动态连通性问题是怎么解决的。这类定义良好的问题已经得到了很好的解决,解决它们的算法被命名为“并查集算法”。当然,仅仅知道一个名字是远远不够的——这个算法怎么想出来的?它解决问题的策略是什么?有什么招数值得一学?OK,下面我们先从算法分析与设计的系统化方法开始讲起。

2.1 算法分析与设计的系统化方法

     Sedgewick教授有在课件中提到一个系统的,算法分析与设计相辅相成的步骤:
  • Model the system.
  • Find an algorithm to solve it.
  • Fast enough? Fits in memory?
  • If not, figure out why.
  • Find a way to address the problem.
  • Iterate until satisfied.
     对问题进行建模其实是最关键的一步,它的意义在于把有助于解决该问题的信息抽取出来,把与问题无关的一些细节忽略掉(比如节点到底表示变量名还是表示计算机设备),建立抽象的数学模型。之后,我们的注意力就转移到这个数学模型上。设计一个算法来处理问题,算法够快吗?所占用的内存合理吗?若分析得到的结论不尽如人意,就要弄清楚为什么会这样,找到问题的根源然后想办法解决它。之后重新评估,一直不停地迭代这一过程,知道满足要求为止。下面我们就结合动态连通性问题实践一下系统化方法。

2.2 建立问题模型

     把一切无关的细节忽略掉,这个问题其实包含了两个要害:一是物体;二是连接。从问题的描述中可以看到,物体是用整数代表的,且N个物体的编号为0到N-1——自然联想到使用一个数组id[]来建模这些物体;那么连接呢?我们合理地认为两个物体之间的连接是等价关系——满足1)自反性(p与自身相连接);2)对称性(若p连接到q,那么q也连接到p);和3)传递性(若p连接到q,q连接到r,则p连接到r)。等价关系把这些物体分成了一个个的“等价类”,它们构成了一个个的集合,称为“连通组件”(Connected Components)。物体之间的相互连接是一个更重要的方法,并查集算法要实现的两个基本操作都是与连接有关的:1)find操作判断两个物体是否处于同一连通组件中;2)union操作将两个连通组件合并为一个连通组件。
算法分析学习笔记(一) - 动态连通性问题的并查集算法(上)_第4张图片
图2-4 并查集算法的API设计

2.3 API的实现

     于是我们就有了如图2-4所示的并查集算法API设计(Java语言描述)。我们将围绕find和union这两个核心操作来探讨并查集算法的各种实现并对比它们效率的不同。下面的实现方案将使用Go语言来描述,因为Go语言是我最喜欢的语言。首先,拟采用的数据结构如下所示:
type XXXUF struct {
        ids     []int  // access to component id
        count   int    // number of components
}
其中“XXX”表示各种不同的实现,这些类都满足同一个接口:
type UF interface {
        Union(p, q int)
        Find(p int) int
}

2.3.1 最直接的方法——quick-find

     quick-find实现的策略在于将ids[p]解释为节点p的id值,它维护一个不变量:当且仅当ids[p]与ids[q]相等时,p与q相连接。也就是说同一连通组件中的节点都有一个相同的id值。它的find操作的实现很简单,只需要判断ids[p]是否等于ids[q]即可,但union操作的实现需要遍历整个ids数组,将与ids[p]相同的值改为与ids[q]相同的值(或者反过来,将与ids[q]相同的值改为与ids[p]相同的值,这个无所谓)。具体的实现代码如下:
 
type QuickFindUF struct {
        ids             []int  // access to component id
        count   int            // number of components
}

func NewQuickFindUF(N int) *QuickFindUF {
        if (N < 1) {
                panic(fmt.Sprintf("Invalid parameter N %d", N))
        }
        ids := make([]int, N)
        for i := 0; i < len(ids); i++ {
                ids[i] = i;
        }
        // count初始化为节点个数,代表一开始有N个连通组件
        return &QuickFindUF{ids, N}
}

func (qf *QuickFindUF) Union(p, q int) {
        if qf.Connected(p, q) {
                return
        }
        pId := qf.ids[p]
        qId := qf.ids[q]
        for i, id := range qf.ids {
                if id == pId {
                        qf.ids[i] = qId
                }
        }    
        // 每执行一次Union操作就少一个连通组件
        qf.count--
}
 
func (qf *QuickFindUF) Find(p int) int {
        return qf.ids[p]
} 

func (qf *QuickFindUF) Connected(p, q int) bool {
        return qf.Find(p) == qf.Find(q)
} 

func (qf *QuickFindUF) Count() int {
        return qf.count;
} 
     可以看到Find和Connected函数的实现很简单,但Union函数实现就要复杂一些,它先判断一下两个节点是不是已经连接了,若已连接则什么都不做;否则就按照上面描述的那样建立两节点之间的联系,形成新的更大的连通组件,与此同时连通组件的个数减1。
     我们通过该算法对数组的访问次数来评估一下它的效率,每一次调用union操作连接两个节点时都要先访问两次数组来找到pId和qId,然后遍历ids数组(访问N次),至少对数组改变一次(与ids[p]相等的节点只有1个),至多改变N-1次(与ids[p]相等的节点有N-1个),所以总的来说至少访问N+3次,至多访问2N+1次。所以,对于M个union操作的序列,算法的操作至少是M(N+3),当M和N都很大时,这个算法的效率很低,比如要把所有节点连接起来,至少需要N-1次union操作,因此总的操作至少是(N-1)(N+3),复杂度为,当处理数百万甚至数亿级别的节点和连接时,这一算法需要跑上30年才能算出结果。

2.3.2换一种解释——quick-union

     看来问题的症结出在union操作上,我们需要遍历整个数组才能完成此操作,因为我们必须检查每一个节点的id,进行必要的改变。而这一切的根源在于我们的模型解释说“ids[p]为节点p的id值,判断两节点是否相连接就是看id值是否相等”。看来要改进union操作,就必须重新解释模型,而quick-union算法的策略,在于它换了一种思路解释ids数组——ids[p]为节点p的父节点,它们都处于同一连通组件中,若ids[p] == p,则称p为根节点(root),这一模型维护的不变量是:当且仅当两个节点的根节点相等时,两个节点处于同一连通组件中。那么初始状态下的N个节点就可以看成N棵只有一个节点的树构成的“森林”,而ids数组则记录了每个节点的父节点,若两个节点的根节点相等,那么这两个节点就是相连接的。对于find操作,我们只要沿着路径一直向上回溯,直到找到根节点(ids[r] == r)即可;对于union操作,我们先找到节点p和q各自的根节点r1和r2,然后修改其中一个,将其父节点指向另外一个即可,这也就是quick-union这个名字的由来,下面的Go语言实现将p节点的根指向了q节点的根。
func (qu *QuickUnionUF) Union(p, q int) {
        pRoot := qu.Find(p)
        qRoot := qu.Find(q)

        if pRoot == qRoot {
                return
        }

        qu.ids[pRoot] = qRoot
        qu.count--
}

func (qu *QuickUnionUF) Find(p int) int {
        for p != qu.ids[p] {
                p = qu.ids[p]
        }
        return p
}

func (qu *QuickUnionUF) Connected(p, q int) bool {
        pRoot := qu.Find(p)
        qRoot := qu.Find(q)
        return pRoot == qRoot
}

算法分析学习笔记(一) - 动态连通性问题的并查集算法(上)_第5张图片
图2-5 初始状态
算法分析学习笔记(一) - 动态连通性问题的并查集算法(上)_第6张图片
图2-6 最终状态



图2-5和图2-6分别展示了初始状态由只有一个根节点的树组成的森林和最终状态所形成一棵树。可惜的是,quick-union倒是使得union操作速度变快了,但是find操作的速度却慢了,而且因为union操作也要用到find,所以进而使得union操作也变慢了。为什么呢?因为我们可以刻意构造,比如0-1,0-2,0-3……N-1次之后N个节点就连接在了一起,但是是0-1-2-3-4……-N的形式,我们构造了一棵很“高”的树,它退化成了链表的样子,而每次执行0-i的find操作时0的高度都为i,所以find操作执行了2i+1,所以i从1取值到N-1,并求和,得到

也就是说,不是任何时候quick-union算法都比quick-find快,有时候会一样的慢。

2.3.3进一步优化——weighted quick-union

     从上面的分析中可以看到,如果我们不分青红皂白,一股脑地修改p的根节点,让其指向q的根节点,这是很鲁莽的做法,有时候会导致树的高度很高从而降低效率。那么我们就要想个法子避免出现高度很高的树,其中的一种加权策略就是记录每棵树的节点个数,并总是在执行union操作时用小树(节点较少)的根节点指向大树(节点较多)来保持平衡,如图2-7所示。
算法分析学习笔记(一) - 动态连通性问题的并查集算法(上)_第7张图片
图2-7 一种降低树高度的方法——加权
      我们再多用一个数组sz[]来存储每棵树的节点个数,一开始将sz[]全部初始化为1,每次执行union操作时根据sz[]数组进行判断,将小树根节点指向大树的根节点,并更新大树对应的sz[]值,完整的代码实现如下所示。
type WeightedQuickUnionUF struct {
        ids             []int
        sz              []int
        count   int
}

func NewWeightedQuickUnionUF(N int) *WeightedQuickUnionUF {
        if N < 1 {
                fmt.Sprintf("Invalid parameter N: %d", N)
        }
        ids := make([]int, N)
        for i := 0; i < len(ids); i++ {
                ids[i] = i
        }
        sz := make([]int, N)
        for i := 0; i < len(sz); i++ {
                sz[i] = 1
        }
        return &WeightedQuickUnionUF{ids, sz, N}
}

func (wquf *WeightedQuickUnionUF) Union(p, q int) {
        pRoot := wquf.Find(p)
        qRoot := wquf.Find(q)
        if pRoot == qRoot {
                return
        }
        if wquf.sz[pRoot] < wquf.sz[qRoot] {
                wquf.ids[pRoot] = qRoot
                wquf.sz[qRoot] += wquf.sz[pRoot]
        } else {
                wquf.ids[qRoot] = pRoot
                wquf.sz[pRoot] += wquf.sz[qRoot]
        }
        wquf.count--
}

func (wquf *WeightedQuickUnionUF) Find(p int) int {
        for p != wquf.ids[p] {
                p = wquf.ids[p]
        }
        return p
}
     weighted quick-union方案中find和union操作都与节点的深度有关,因此我们对算法效率的分析仍然集中在树的深度上。用加权策略构建出来的树,其任意一个节点的深度最多为lgN,原因很简单,初始状态下,所有的树都只有一个节点,故深度都是0,那么某个节点的深度什么时候增加1呢?一定是它所在的那棵树T1的根节点被连接到另一棵树T2的根节点上的时候。这说明T2包含的节点数一定大于或等于T1,两棵树合并之后,包含这个节点的树的大小至少变为原来的两倍。也就是说,节点深度每增加1,该节点所在树的大小就翻倍,那么对于总共只有N个节点的树来说,深度最多lgN。
     还没反应过来?!若我们知道算法的关键操作与节点深度相关,而深度最多又只有lgN,那么我们的算法find操作和union操作都会很快!这就是对数函数的强大威力,天文数字般的N,只要一lgN就变成小弟弟了。我们的第一个能足够快解决实际问题的算法终于出现了,它处理N个节点上的M个连接操作最多只要MlgN的操作就完成了。Sedgewick教授做过实验,并构造出了加权和非加权算法下构建的树的对比图,如图2-8所示,可以看到加权版本的实现方案得到的树高度是多么低。
算法分析学习笔记(一) - 动态连通性问题的并查集算法(上)_第8张图片
图2-8 加权和非加权版本quick-union算法的对比

2.3.4 还有什么优化策略?

     故事说到这里并没有结束,我们还可以有其他的优化方法,想想我们的目的是考虑怎样降低树的高度,除了记录树的节点个数,我们还可以从路径和高度出发。从路径上考虑我们可以采取压缩或者减半策略——压缩策略干脆把节点p到根节点路径上碰到的节点全部直接指向根节点;而减半策略则是每隔一个节点将其直接指向它的祖父节点。这样随着连接操作的不断进行,一棵高高瘦瘦的树会逐渐变的矮矮胖胖,也能够降低树的高度,路径压缩和路径减半的核心代码实现如下所示。
func (wqupc *WeightedQuickUnionPathCompUF) Find(p int) int {
        root := p
        for root != wqupc.ids[root] {
                root = wqupc.ids[root]
        }
        for p != root {
                t := wqupc.ids[p]
                wqupc.ids[p] = root
                p = t
        }
        return root
}

func (wquph *WeightedQuickUnionPathHalfUF) Find(p int) int {
        for p != wquph.ids[p] {
                wquph.ids[p] = wquph.ids[wquph.ids[p]]  // link to its grandparent
                p = wquph.ids[p]                // jump to grandparent(ignore parent)
        }
        return p
}
     或者我们直接记录每一棵树的高度(初始为0),将较矮的树连接到较高的树上,实现平衡,或者更进一步组合这几种策略,比如在加权的基础上进行路径压缩等等。完整的代码实现如下所示。
type WeightedQuickUnionByHeight struct {
        ids             []int
        hgt             []int
        count   int
}

func NewWeightedQuickUnionByHeight(N int) *WeightedQuickUnionByHeight {
        if N < 1 {
                panic(fmt.Sprintf("Invalid parameter N %d", N))
        }
        ids := make([]int, N)
        hgt := make([]int, N)
        for i := 0; i < N; i++ {
                ids[i] = i
                hgt[i] = 0
        }
        return &WeightedQuickUnionByHeight{ids, hgt, N}
}

func (wquh *WeightedQuickUnionByHeight) Union(p, q int) {
        pRoot := wquh.Find(p)
        qRoot := wquh.Find(q)
        if pRoot == qRoot {
                return
        }
        // make shorter root point to taller one
        if wquh.hgt[pRoot] < wquh.hgt[qRoot] {
                wquh.ids[pRoot] = qRoot
        } else if wquh.hgt[pRoot] == wquh.hgt[qRoot] {
                wquh.ids[qRoot] = pRoot
                wquh.hgt[pRoot]++
        } else {
                wquh.ids[qRoot] = pRoot
        }
        wquh.count--
}

func (wquh *WeightedQuickUnionByHeight) Find(p int) int {
        for p != wquh.ids[p] {
                p = wquh.ids[p]
        }
        return p
}

func (wquh *WeightedQuickUnionByHeight) Connected(p, q int) bool {
        pRoot := wquh.Find(p)
        qRoot := wquh.Find(q)
        return pRoot == qRoot
}

func (wquh *WeightedQuickUnionByHeight) Count() int {
        return wquh.count
}

2.3.5 有什么招数值得一学?

(1)最直接的方案往往不是最好的,但它可以作为算法优化的一个很好的开始;
(2)分析现有算法效率低下的关键问题出在哪里(如quick-find需要遍历数组),才知道怎么改进算法;
(3)要知道算法的渐进效率,特别是重视数学归纳法的应用;
(4)用数组可以实现指向父节点的树结构;
(5)有时候需要创造性地对模型进行重新解释才能找到效率更高的算法;
(6)降低树的高度:节点少的树连接到节点多的树(加权),矮的树连接到高的树(加权),把瘦高树变成矮胖的树(路径减半和路径压缩)。

你可能感兴趣的:(E,数据结构与算法)