数据结构(二十三) — 贪婪算法 — 拓扑排序

前言

这一部分,数据结构已经不再重要了,开始进入到了算法设计的领域。在《数据结构、算法与应用》一书中,描述了一部分重要的算法设计方法,包括贪婪算法、分治算法、动态规划、回溯和分支定界。并且对于算法的讨论不如《算法导论》中细致周密。数据结构专栏暂时只关注贪婪分治动规三种算法设计方法。

贪婪算法

贪婪算法的算法设计思想,每一步,我们都在一定的标准下,作出一个最优决策。在每一步做出的决策,在以后的步骤中都不可更改。做出决策所依据的标准称为贪婪准则。

在贪婪算法中,我们每一步都依据贪婪准则,作出一个局部最优解,所有局部最优解结合起来便是贪婪算法给出的解。因此贪婪算法“目光短浅”,没有全局意识。在部分不需要结合全局的问题中可以找到最优解,在其它题目中则不一定能找到最优解,作为启发式算法使用。

贪婪算法的应用

1)AOV网的拓扑排序算法

a. 问题描述

在有向无环图(Directed Acycline Graph, DAG)应用领域中,AOV网和AOE网是两种不同形式的重要应用。

AOV网:顶点活动(activity on vertex)网络。顶点代表任务,有向边 ( i , j ) (i, j) 表示认为的先后关系,在任务 j j 开始前,任务 i i 必须完成。

下图是一个典型的AOV网络,任务3开始之前,任务1必须完成;任务4开始之前,任务123必须完成。序列123456、132456、215346都为满足要求的拓扑排序,可见同一AOV图的拓扑排序不唯一。
在这里插入图片描述
拓扑排序的应用领域非常广泛,例如建筑的搭建、汽车的组装、知识学习的顺序等等都包含拓扑的思想。

我们要解决的问题:给出一个AOV网,设计一个算法给出一个拓扑序列。

b. 算法思想选择及求解

在本篇文章中我们当然是选择贪婪算法思想,我们首先要想出一个贪婪准则(这里可以暂停想一想哈哈)。

算法过程:拓扑序列开始是一个空序列,我们每次从满足贪婪准则的未加入序列的顶点中选择一个顶点加入到拓扑序列中,直到找不到满足贪婪准则的顶点,算法结束。

贪婪准则:它没有入边或者存在入边 ( v , w ) (v,w) ,入边可能存在多条,对于每条入边,起始顶点 v v 已经存在于拓扑序列中。

贪婪准则(简洁版):从剩余的顶点中选择一个顶点 w w ,它没有这样的入边 ( v , w ) (v,w) ,其中顶点 v v 不在序列中。

简洁版是书上的定义,很精练,但理解的时候需要绕一下弯。

明确贪婪准则和算法过程后,便可试着写出伪代码。通过伪代码在脑中跑跑程序,在纸上模拟一下过程。

/**
{
令n表示有向图的顶点数
令theOrder是空序列
while(true)
{
    令w是一个满足贪婪准则的顶点
    如果没有这样的顶点w,程序终止
    把w加到theOrder的尾部
}
if(theOrder的顶点数小于n)
    算法失败
else
    theOrder是一个拓扑序列
}
*/

c. 验证算法的正确性

如果上述伪代码算法成功,则图中必有一个拓扑序列。验证算法的正确性,即验证当算法失败时,图中没有拓扑序列。

实际上,当算法失败时,图中必有环路。

证明

  • 当算法结束时,若theOrder的顶点数小于n,则必有一顶点w1不在theOrder中。
  • 且必存在一条边(w2, w1)且w2同样不在theOrder中,否则w1也在theOrder中。
  • 同样地,必存在一条边(w3,w2)且w3不在theOrder中,否则w2也在theOrder中。
  • 若w3 = w1, (w3,w2),(w2,w1)构成一个环路,存在环路的有向图不存在拓扑序列。
  • 若w3 \not= w1, 可继续按照第二三点往下循环,因为图中的顶点为有限数,所以必定可以在图中找到一个环,存在环,拓扑序列就不存在。

d. 数据结构的选择

这一步是进行代码实现的准备工作。
我们根据伪代码来判断哪里需要数据结构和使用什么样的数据结构。

  • 有向图的顶点数通过入参或者类内函数获取。
  • theOrder序列通过int类型一维数组实现。
  • 我们还需要遍历图中的所有顶点,计算顶点的入度并保存下来,使用int类型一维数组inDegree[]进行保存。
  • 在算法过程中,需要使用队列或栈将初始入度为零的点保存在容器中,出容器时该顶点的邻接节点在inDegree数组中数值减一,当数值减为零时,加入容器。

e. C++代码实现


bool topologicalOrder(int* theOrder)
{
    int n = numberOfVertices();// 顶点数
    int* inDegree = new int[n+1];// 存储每个顶点入度的数组
    for(int i=0; i<n+1; i++)// 初始化为0
    {
        inDegree[i] = 0;
    }
    for(int i = 1; i <= n; i++)// 计算每个顶点的入度
    {
        vertexIterator<T> *ii = iterator(i);
        int u;
        while((u = ii->next()) != 0)
        {
            inDegree[u]++;
        }
    }
    Stack<int> s; // 初始入度就为零的顶点,压入栈中。
    for(int i = 1; i <= n; i++)
    {
        if(inDegree[i] == 0)
            s.push(i);
    }
    // 产生拓扑序列
    int j = 0;
    while(!s.empty())
    {
        int nv = s.top();
        s.pop();
        theOrder[j++] = nv;
        vertexIterator<T>* in = iterator(nv);
        int u;
        while((u = in.next()) != 0)
        {
            inDegree[u]--;
            if(inDegree[u] == 0)
                s.push(u);
        }
    }
    
    return (j == n);
}

f. 算法复杂性分析

时间复杂性
通过上一节对图的讨论我们得知,对图进行遍历,邻接矩阵需要的时间复杂度为 O ( n 2 ) O(n^2) , 邻接链表的时间复杂度为 O ( n + e ) O(n+e)

在上述算法中,第二个for循环和最后的两个while嵌套循环,其实是对图做了最多两遍遍历,邻接矩阵和邻接链表的时间复杂度分别为 O ( n 2 ) O(n^2) O ( n + e ) O(n+e) 。第一、三个for循环时间复杂度为 O ( n ) O(n)

所以总时间复杂度:邻接矩阵 O ( n 2 ) O(n^2) ;邻接链表 O ( n + e ) O(n+e)

空间复杂性
由于数组和栈的使用,空间复杂度:O(n)

2)多说一句

一开始学算法的时候,以为只有代码实现是重要的。现在看来大错特错。写出拓扑排序算法的过程看似有些繁琐,但是却完成了从现实问题到抽象数学问题和解决方法实现的过程。上面的每一步都很重要。也怪不得我的算法老师说,学完这个课程,你们如果能记住提出问题、分析问题、解决问题、验证问题这个思想就足够了。

猜你喜欢

转载自blog.csdn.net/qq_41882686/article/details/107834895