MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++ 实现Dijkstra算法

2022-09-021.6k 阅读

一、Dijkstra算法简介

Dijkstra算法是由荷兰计算机科学家Edsger W. Dijkstra在1956年提出的,是一种用于在加权有向图中寻找从给定源顶点到其他所有顶点的最短路径的贪心算法。该算法的核心思想是,每次从尚未确定最短路径的顶点中选择距离源点最近的顶点,并将其纳入已确定最短路径的顶点集合中,然后更新其邻接顶点到源点的距离。通过不断重复这个过程,最终可以得到从源点到所有顶点的最短路径。

(一)算法的基本原理

  1. 初始化:首先,我们需要初始化每个顶点到源点的距离为无穷大(在实际代码中可以用一个很大的数来表示),除了源点到自身的距离为0。同时,我们使用一个集合(例如C++中的std::set)来记录已经确定最短路径的顶点。
  2. 选择距离源点最近的未确定顶点:在未确定最短路径的顶点中,选择距离源点最近的顶点。这个顶点就是当前能够确定其最短路径的顶点。
  3. 更新邻接顶点的距离:对于刚确定最短路径的顶点的所有邻接顶点,如果通过该顶点到达邻接顶点的距离比当前记录的距离更短,那么就更新邻接顶点到源点的距离。
  4. 重复步骤2和3:不断重复上述两个步骤,直到所有顶点的最短路径都被确定。

(二)算法的时间复杂度

Dijkstra算法的时间复杂度主要取决于数据结构的选择。如果使用普通的邻接表来存储图,并且每次从未确定顶点集合中选择距离源点最近的顶点时使用线性搜索,那么时间复杂度为$O(V^2)$,其中$V$是图中顶点的数量。如果使用二叉堆(例如C++中的std::priority_queue)来优化距离源点最近顶点的选择过程,时间复杂度可以降低到$O((V + E)\log V)$,其中$E$是图中边的数量。

二、C++ 实现Dijkstra算法

(一)图的表示

在C++中,我们可以使用多种方式来表示图,比如邻接矩阵和邻接表。邻接矩阵适合表示稠密图,而邻接表更适合表示稀疏图。由于Dijkstra算法通常应用于稀疏图,这里我们选择使用邻接表来表示图。

  1. 定义顶点结构 首先,我们定义一个结构体来表示图中的顶点。每个顶点包含一个编号和到该顶点的距离。
struct Vertex {
    int id;
    int distance;
    Vertex(int i, int d) : id(i), distance(d) {}
    // 重载比较运算符,用于在优先队列中比较距离
    bool operator<(const Vertex& other) const {
        return distance > other.distance;
    }
};
  1. 定义图的结构 然后,我们定义一个图的结构体,其中包含顶点的数量和邻接表。邻接表是一个std::vector,每个元素又是一个std::vector,用于存储该顶点的邻接顶点及其边的权重。
struct Graph {
    int numVertices;
    std::vector<std::vector<std::pair<int, int>>> adjList;
    Graph(int n) : numVertices(n), adjList(n) {}
    // 添加边的函数
    void addEdge(int u, int v, int weight) {
        adjList[u].push_back({v, weight});
        // 如果是无向图,还需要添加反向边
        adjList[v].push_back({u, weight});
    }
};

(二)Dijkstra算法的实现

  1. 核心算法代码 下面是使用C++实现Dijkstra算法的核心代码。我们使用std::priority_queue来优化距离源点最近顶点的选择过程。
std::vector<int> dijkstra(Graph& graph, int source) {
    std::vector<int> distances(graph.numVertices, INT_MAX);
    distances[source] = 0;
    std::priority_queue<Vertex> pq;
    pq.push(Vertex(source, 0));
    while (!pq.empty()) {
        Vertex current = pq.top();
        pq.pop();
        int currentVertex = current.id;
        if (current.distance > distances[currentVertex]) {
            continue;
        }
        for (const auto& neighbor : graph.adjList[currentVertex]) {
            int neighborVertex = neighbor.first;
            int edgeWeight = neighbor.second;
            int newDistance = distances[currentVertex] + edgeWeight;
            if (newDistance < distances[neighborVertex]) {
                distances[neighborVertex] = newDistance;
                pq.push(Vertex(neighborVertex, newDistance));
            }
        }
    }
    return distances;
}
  1. 完整代码示例 下面是一个完整的C++代码示例,展示了如何使用上述定义的图结构和Dijkstra算法。
#include <iostream>
#include <vector>
#include <queue>
#include <limits>

struct Vertex {
    int id;
    int distance;
    Vertex(int i, int d) : id(i), distance(d) {}
    bool operator<(const Vertex& other) const {
        return distance > other.distance;
    }
};

struct Graph {
    int numVertices;
    std::vector<std::vector<std::pair<int, int>>> adjList;
    Graph(int n) : numVertices(n), adjList(n) {}
    void addEdge(int u, int v, int weight) {
        adjList[u].push_back({v, weight});
        adjList[v].push_back({u, weight});
    }
};

std::vector<int> dijkstra(Graph& graph, int source) {
    std::vector<int> distances(graph.numVertices, INT_MAX);
    distances[source] = 0;
    std::priority_queue<Vertex> pq;
    pq.push(Vertex(source, 0));
    while (!pq.empty()) {
        Vertex current = pq.top();
        pq.pop();
        int currentVertex = current.id;
        if (current.distance > distances[currentVertex]) {
            continue;
        }
        for (const auto& neighbor : graph.adjList[currentVertex]) {
            int neighborVertex = neighbor.first;
            int edgeWeight = neighbor.second;
            int newDistance = distances[currentVertex] + edgeWeight;
            if (newDistance < distances[neighborVertex]) {
                distances[neighborVertex] = newDistance;
                pq.push(Vertex(neighborVertex, newDistance));
            }
        }
    }
    return distances;
}

int main() {
    int numVertices = 5;
    Graph graph(numVertices);
    graph.addEdge(0, 1, 10);
    graph.addEdge(0, 2, 3);
    graph.addEdge(1, 2, 1);
    graph.addEdge(1, 3, 2);
    graph.addEdge(2, 1, 4);
    graph.addEdge(2, 3, 8);
    graph.addEdge(2, 4, 2);
    graph.addEdge(3, 4, 7);
    graph.addEdge(4, 3, 9);

    int source = 0;
    std::vector<int> distances = dijkstra(graph, source);
    std::cout << "最短距离从源点 " << source << " 到其他顶点:" << std::endl;
    for (int i = 0; i < numVertices; ++i) {
        std::cout << "到顶点 " << i << ": " << distances[i] << std::endl;
    }
    return 0;
}

(三)代码解析

  1. Vertex结构体:定义了顶点的编号id和到源点的距离distance,并重载了小于运算符<,用于在优先队列中按照距离从小到大排序。
  2. Graph结构体:包含顶点数量numVertices和邻接表adjListaddEdge函数用于向图中添加边,这里实现的是无向图,所以需要同时添加正向边和反向边。
  3. dijkstra函数
    • 初始化一个distances向量,用于存储每个顶点到源点的距离,初始值为INT_MAX,源点到自身的距离为0。
    • 使用std::priority_queue来存储顶点及其距离,优先队列会自动按照距离从小到大排序。
    • 在循环中,每次取出距离源点最近的顶点current,如果current的距离大于当前记录的该顶点的距离,说明该顶点已经被处理过,跳过。
    • 遍历current的邻接顶点,计算通过current到达邻接顶点的新距离newDistance,如果新距离更短,则更新distances向量,并将邻接顶点加入优先队列。

三、Dijkstra算法的优化

(一)使用斐波那契堆

虽然使用std::priority_queue(二叉堆)已经可以显著提高Dijkstra算法的效率,但是在理论上,使用斐波那契堆可以进一步优化时间复杂度。斐波那契堆在删除最小元素和减少键值操作上具有更低的时间复杂度。在最坏情况下,使用斐波那契堆实现的Dijkstra算法时间复杂度为$O(V\log V + E)$,而使用二叉堆的时间复杂度为$O((V + E)\log V)$。

然而,在C++标准库中并没有直接提供斐波那契堆的实现,需要自行实现或者使用第三方库。实现一个高效的斐波那契堆是比较复杂的,涉及到复杂的数据结构和算法,包括树的合并、节点的删除和插入等操作。下面简单介绍一下使用斐波那契堆优化Dijkstra算法的大致思路。

  1. 斐波那契堆的基本操作

    • 插入:将新节点插入到堆中,时间复杂度为$O(1)$。
    • 删除最小元素:找到并删除堆中的最小元素,然后进行堆的调整,时间复杂度为$O(\log V)$。
    • 减少键值:降低某个节点的键值,并相应地调整堆结构,时间复杂度为$O(1)$平摊时间复杂度(amortized time complexity)。
  2. 优化Dijkstra算法 在Dijkstra算法中,当更新邻接顶点的距离时,如果使用斐波那契堆,我们可以直接调用减少键值操作,而在二叉堆中,我们需要先删除原节点,再插入新节点,这在一定程度上增加了时间复杂度。

(二)双向Dijkstra算法

双向Dijkstra算法是另一种优化思路。传统的Dijkstra算法从源点出发,逐步扩展到所有顶点。而双向Dijkstra算法同时从源点和终点出发,分别进行Dijkstra搜索。当两个搜索的区域相遇时,就找到了最短路径。

  1. 算法步骤

    • 从源点和终点分别初始化两个距离向量和两个优先队列,分别用于记录从源点和终点到各个顶点的距离。
    • 同时从源点和终点开始进行Dijkstra搜索,每次从两个优先队列中取出距离最小的顶点进行扩展。
    • 当两个搜索的区域相遇时,即某个顶点同时在两个搜索区域中被访问到,就可以计算出从源点到终点的最短路径。
  2. 优势 双向Dijkstra算法在某些情况下可以显著减少搜索空间,特别是在图的直径相对较小的情况下。它的时间复杂度在理论上可以接近$O(E + V\log V)$,相比于传统Dijkstra算法在某些场景下有更好的性能表现。

四、应用场景

(一)网络路由

在计算机网络中,Dijkstra算法常用于计算网络路由。网络中的节点可以看作是图的顶点,节点之间的链路可以看作是图的边,链路的带宽、延迟等属性可以作为边的权重。通过Dijkstra算法,可以计算出从源节点到目标节点的最优路径,从而实现数据的高效传输。

(二)地理信息系统(GIS)

在地理信息系统中,Dijkstra算法可以用于计算两点之间的最短路径,比如在地图上计算两个地点之间的最短行车路线、最短步行路线等。地图中的道路网络可以建模为图,路口可以看作是顶点,道路可以看作是边,道路的长度、交通流量等可以作为边的权重。

(三)游戏开发

在游戏开发中,Dijkstra算法可以用于实现游戏角色的寻路功能。游戏地图中的地形可以建模为图,可通行区域和不可通行区域分别对应不同的顶点和边的属性。通过Dijkstra算法,游戏角色可以找到从当前位置到目标位置的最短路径,避免碰撞障碍物,实现智能寻路。

五、常见问题与解决方案

(一)负权边问题

Dijkstra算法的一个重要限制是不能处理包含负权边的图。因为Dijkstra算法基于贪心策略,一旦一个顶点被确定为最短路径,就不会再更新。如果存在负权边,可能会导致已经确定最短路径的顶点通过负权边得到更短的路径,从而破坏算法的正确性。

  1. 解决方案
    • 使用Bellman - Ford算法:Bellman - Ford算法可以处理包含负权边的图,其时间复杂度为$O(VE)$。它通过对所有边进行$V - 1$次松弛操作来找到最短路径,能够检测并处理负权边。
    • 使用Floyd - Warshall算法:Floyd - Warshall算法是一种用于求解所有顶点对之间最短路径的算法,也可以处理负权边。其时间复杂度为$O(V^3)$。

(二)内存消耗问题

当图的规模非常大时,使用邻接表或邻接矩阵表示图可能会消耗大量的内存。特别是对于稀疏图,如果使用邻接矩阵,会浪费大量的空间。

  1. 解决方案
    • 使用压缩稀疏行(CSR)格式:CSR格式是一种用于存储稀疏矩阵的高效格式,可以大大减少内存消耗。在图的表示中,可以将邻接表进一步优化为CSR格式。
    • 动态内存管理:在程序运行过程中,根据实际需要动态分配和释放内存,避免一次性分配过多内存导致内存不足。

(三)性能优化问题

尽管使用优先队列等数据结构已经对Dijkstra算法进行了优化,但在处理大规模图时,性能仍然可能成为问题。

  1. 解决方案
    • 并行计算:利用多核处理器的优势,将图分割成多个子图,并行执行Dijkstra算法,然后合并结果。
    • 分层图优化:对于具有层次结构的图,可以采用分层图的方法,减少搜索空间,提高算法效率。例如,在交通网络中,可以根据道路的等级将图分层,优先在高等级道路上进行搜索。

通过以上对Dijkstra算法在C++中的实现、优化、应用场景以及常见问题的讨论,希望能帮助读者更深入地理解和应用该算法,在实际的计算机开发中解决与最短路径相关的问题。无论是在网络路由、地理信息系统还是游戏开发等领域,Dijkstra算法都有着广泛的应用前景,通过合理的优化和应用,可以提高系统的性能和效率。