编程题-课程表(中等-重点)

题目:

你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses - 1

在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] ,表示如果要学习课程 ai必须 先学习课程  bi

  • 例如,先修课程对 [0, 1] 表示:想要学习课程 0 ,你需要先完成课程 1

请你判断是否可能完成所有课程的学习?如果可以,返回 true ;否则,返回 false

前言

本题是一道经典的【拓扑排序】问题。给定一个包含n个节点的有向图,我们给出它的节点编号的一种排列,如果满足:

对于图G中的任意一条有向边(u, v),u在排列中都出现在v的前面

那么称该排列是图G的拓扑排序。根据上述的定义,我们可以得出两个结论:

1、如果图G中存在环(即图G不是【有向无环图】,那么图G不存在拓扑排序。这是因为假设图中存在环x_{1}, x_{2},\cdots ,x_{n}, x_{1}),那么x_{1}在排序中必须出现在x_{n}的前面,但x_{n}同时也必须出现在x_{1}的前面,因此不存在一个满足要求的排列,也就不存在拓扑排序;

2、如果图G是有向无环图,那么它的拓扑排序可能不止一种。举一个最极端的例子,如果图G值包含n个节点却没有任何边,那么任意一种编号的排列都可以作为拓扑排序。

有了上述的节点分析,我们就可以将本题建模成一个求拓扑排序的问题了:

1、我们将每一门课看出一个节点;

2、如果想要学习课程A之前必须完成课程B,那么我们从B到A连接一条有向边。这样以来,在拓扑排序中,B一定出现在A的前面。

求出该图是否存在拓扑排序,就可以判断是否有一种符合要求的课程学习顺序。事实上,由于求出一种拓扑排序方法的最优时间复杂度是O(n+m),其中n和m分别是有向图G的节点数和边数。而判断图G是否存在拓扑排序,至少也要对其进行一次完整的遍历,时间复杂度为O(n+m)。因此不可能存在一种仅判断图是否存在拓扑排序的方法,它的时间复杂度在渐进意义上严格优于O(n+m)。

解法一(深度优先搜索):

我们可以将深度优先搜索的流程与拓扑排序的求解联系起来,用一个栈来存储所有已经搜索完成的节点。对于一个节点u,如果它的所有相邻节点都已经搜索完成,那么在搜索回溯到u的时候,u本身也会变成一个已经搜索完成的节点。这里的【相邻节点】指的是从u出发通过一条有向边可以到达的所有节点

假设我们当前搜索到了节点u,如果它的所有相邻节点都已经搜索完成,那么这些节点都已经在栈中了,此时我们就可以把u入栈。可以发现,如果我们从栈顶往栈底的顺序看,由于u处于栈顶的位置,那么u出现在所有u的相邻节点的前面。因此对于u这个节点而言,它是满足拓扑排序要求的。这样以来,我们对图进行一遍深度优先搜索。当每个节点进行回溯的时候,我们把该节点放入栈中。最终从栈顶到栈底的序列就是一种拓扑排序。

深度优先搜索算法思路:对于图中的任意一个节点,它在搜索的过程中有三种状态,即:

1、【未搜索】:我们还没有搜索到这个节点;

2、【搜索中】:我们搜索过这个节点,但还没有回溯到该节点,即该节点还没有入栈,还有相邻的节点没有搜索完成;

3、【已完成】:我们搜索过并且回溯过这个节点,即该节点已经入栈,并且所有该节点的相邻节点都出现在栈的更底部的位置,满足拓扑排序的要求。

通过上述的三种状态,我们就可以给出使用深度优先搜索得到拓扑排序的算法流程,在每一轮的搜索开始时,我们任取一个【未搜索】的节点开始进行深度优先搜索。我们将当前搜索的节点u标记为【搜索中】,遍历该节点的每一个相邻节点v

1、如果v为【未搜索】,那么我们开始搜索v,待搜索完成回溯到u;

2、如果v为【搜索中】,那么我们就找到了图中的一个环,因此是不存在拓扑排序的;

3、如果v为【已完成】,那么说明v已经在栈中了,而u还不在栈中,因此u无论何时入栈都不会影像到(u, v)之间的拓扑关系,以及不用进行任何操作。

4、当u的所有相邻节点都为【已完成】时,我们将u放入栈中,并将其标记为【已完成】。

在整个深度优先搜索的过程结束后,如果我们没有找到图中的环,那么栈中存储这所有的n个节点,从栈顶到栈底的顺序即为一种拓扑排序。由于我们只需要判断是否存在一种拓扑排序,而栈的作用仅仅是存放最终的拓扑排序结果,因此我们可以只记录每个节点的状态,而省去对应的栈。如下为实现代码:

class Solution {
private:
    //edges为有向边,visited为遍历的节点,valid表示是否是有向无环图
    //edges中第一层元素表示起点为u节点的所有相邻节点vector<int>(第二层元素)
    vector<vector<int>> edges;
    vector<int> visited;
    bool valid = true;
public:
    void dfs(int u) {
        //搜索中设置为1
        visited[u] = 1;
        //遍历所有其相邻节点,如果相邻节点均完成遍历则设置为2
        //若在遍历相邻节点时,其他相邻节点的visited状态为搜索中,则构成了环,不是有向无环图了
        for (int v: edges[u]){
            if (visited[v] == 0){
                dfs(v);
                if (!valid) {
                    return;
                }
            }
            else if (visited[v] == 1) {
                //设置valid为false,即不是有向无环图了
                valid = false;
                return;
            }
        }
        visited[u] = 2;
    }
    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        //通过对vector进行resize,可以用来增加或减少vector的元素个数
        edges.resize(numCourses);
        visited.resize(numCourses);
        //构建edges有向边,外层元素表示节点信息,内层元素表示外层节点所对应的其所有相邻节点
        for (const auto& info: prerequisites) {
            edges[info[1]].push_back(info[0]);
        }
        for (int i = 0; i < numCourses && valid; ++i) {
            //深度搜索遍历未访问的节点
            if (!visited[i]) {
                dfs(i);
            }
        }
        return valid;
    }
};

时间复杂度: O(n+m),其中 n 为课程数,m 为先修课程的要求数。这其实就是对图进行深度优先搜索的时间复杂度。空间复杂度: O(n+m)。题目中是以列表形式给出的先修课程关系,为了对图进行深度优先搜索,我们需要存储成邻接表的形式,空间复杂度为 O(n+m)。在深度优先搜索的过程中,我们需要最多 O(n) 的栈空间(递归)进行深度优先搜索,因此总空间复杂度为 O(n+m)。

解法二(广度优先搜索):

深度优先搜索方法是一种「逆向思维」:最先被放入栈中的节点是在拓扑排序中最后面的节点。我们也可以使用正向思维,顺序地生成拓扑排序,这种方法也更加直观。我们考虑拓扑排序中最前面的节点,该节点一定不会有任何入边,也就是它没有任何的先修课程要求。当我们将一个节点加入答案中后,我们就可以移除它的所有出边代表着它的相邻节点少了一门先修课程的要求。如果某个相邻节点变成了【没有任何入边的节点】,那么就代表着这门课可以开始学习了。按照这样的流程,我们不断地将没有入边的节点加入答案,直到答案中包含所有的节点(得到一种拓扑排序)或者不存在没有入边的节点(图中包含环)。上面的想法类似于广度优先搜索,因此我们可以将广度优先搜索的流程与拓扑排序的求解联系起来。

广度优先搜索算法:我们使一个队列来进行广度优先搜索。初始时,所有入度为0的节点都被放入队列中,它们就是可以作为拓扑排序最前面的节点,并且它们之间的相对顺序是无关紧要。

在广度优先搜索的每一步中,我们取出队首的节点u:

1、我们将u放入答案中;

2、我们移除u的所有出边,也就是将u的所有相邻节点的入度减少1。如果某个相邻节点v的入度变为0,那么我们就将v放入队列中。

在广度优先搜索的过程结束后。如果答案中包含了这n个节点,那么我们就找到了一种拓扑排序,否则说明图中存在环,也就不存在拓扑排序了。由于我们只需要判断是否存在一种拓扑排序,因此我们省去存放答案数组,而是只用一个变量记录被放入答案数组的节点个数。在广度优先搜索结束之后,我们判断该变量的值是否等于课程数,就能知道是否存在一种拓扑排序,如下为实现代码:

class Solution {
private:
    vector<vector<int>> edges;
    vector<int> indeg;
public:
    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        //通过对vector进行resize,可以用来增加或减少vector的元素个数
        edges.resize(numCourses);
        indeg.resize(numCourses);
        //构建edges有向边,外层元素表示节点(先修课程)信息,内层元素表示外层节点所对应的其所有相邻节点(需要修的课程)
        for (const auto& info: prerequisites) {
            edges[info[1]].push_back(info[0]);
            //通过indeg记录每个节点入度的数量情况(需要先修多少课程)
            ++indeg[info[0]];
        }
        //遍历所有初始节点关系,将初始所有入度为0的节点添加到队列q中
        queue<int> q;
        for (int i = 0; i < numCourses; ++i) {
            if (indeg[i] == 0) {
                q.push(i);
            }
        }
        //通过visited记录满足入度为0的所有节点的数量(在广度搜索时不断添加更新)
        int visited = 0;
        while (!q.empty()) {
            ++visited;
            int u = q.front();
            q.pop();
            //取出队列中一个入度为0的节点,并剔除其所有入度节点相邻节点的边
            for (int v: edges[u]) {
                --indeg[v];
                if (indeg[v] == 0) {
                    q.push(v);
                }
            }
        }
        return visited == numCourses;
    }
};

时间复杂度: O(n+m),其中 n 为课程数,m 为先修课程的要求数。这其实就是对图进行广度优先搜索的时间复杂度。空间复杂度: O(n+m)。题目中是以列表形式给出的先修课程关系,为了对图进行广度优先搜索,我们需要存储成邻接表的形式,空间复杂度为 O(n+m)。在广度优先搜索的过程中,我们需要最多 O(n) 的队列空间(迭代)进行广度优先搜索。因此总空间复杂度为 O(n+m)。

笔者小记:

1、拓扑排序:是一种对有向图的顶点进行排序的方法,特别适用于有向无环图。拓扑排序的主要目标是排列图中的节点,使得每个节点都排在它所有的后继节点之前。拓扑排序在很多实际问题中都有应用,例如:1、任务调度:比如项目管理中的任务依赖关系,某个任务必须在另一个任务完成后才能开始;2、编译顺序:编程语言的编译过程中的模块依赖关系;3、课程安排:例如,课程安排中,某些课程需要先修其他课程。特性:有向图必须是无环图不唯一:对于一个图,可能有多个有效的拓扑排序。

拓扑排序的两种常见算法:

1. Kahn算法(基于入度的算法):
  • 计算图中所有顶点的入度(即有多少条边指向该顶点)。
  • 将所有入度为0的顶点加入队列。
  • 每次从队列中取出一个入度为0的顶点,加入拓扑排序结果中,并将其所有的邻接顶点的入度减1。如果某个邻接顶点的入度变为0,将其加入队列。
  • 这个过程持续进行,直到队列为空。

时间复杂度:O(V + E),其中V是图中的顶点数,E是图中的边数。

2. 深度优先搜索(DFS)法
  • 对每个未被访问过的顶点,进行深度优先搜索。
  • 在递归回溯的过程中,将顶点添加到栈中。
  • 最终栈中的顶点顺序即为拓扑排序的结果。

时间复杂度:O(V + E),其中V是图中的顶点数,E是图中的边数。

2、拓扑排序实现通过:深度优先搜索结合栈的数据结构实现,广度优先搜索结合队列数据结构实现。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值