《数据结构、算法与应用 —— C++语言描述》学习笔记 — 贪婪算法(二)
一、单源最短路径
1、问题描述
给定一个加权有向图G,它的每条边 (i,j) 都有一个非负的成本 a[i][j]。一条路径的长度是该路径所有边的成本之和。寻找一条从一个给定的源顶点出发到达其他任意一个顶点的最短路径。
2、贪婪法求解
dijkstra算法本质上就是一种贪婪算法,可以分步产生最短路径。在每一步中,该算法都会选找一个到达新的目的顶点的最短路径。其所依据的贪婪准则如下:从一条最短路径还没有到达的顶点中,选择一个可以产生最短路径的目的顶点。也就是说,该方法按照路径长度递增的顺序产生最短路径。
我们可以使用一种简便的算法来存储最短路径。使用数组 predecessor 保存每个点的前驱节点,这样我们可以方便地从目的顶点反向生成路径数组。
3、实现
(1)增加获取边权接口
我们需要获取边的权重以比较不同路径的距离:
template<class T>
inline int adjacencyWDGraph<T>::edgeWeight(int row, int column) const
{
return elements[row][column];
}
(2)dijkstra 算法
#pragma once
#include "../graph/adjacencyWDGraph.h"
#include "../graph/WEdge.h"
/*
* @brief 查找最短路径
* @param graph 二分图
* @param src 源点
* @param distance 从源点到各顶点的距离
* @param predecessor 各顶点的前驱结点
*/
template<class T>
void shortestPaths(Graph<T>& graph, int src, std::vector<T>& distance, std::vector<int>& predecessor)
{
using namespace std;
typedef pair<int, int> vertexDistance;
int vertexNum = graph.numberOfVertices();
if (src >= vertexNum || src < 0)
{
throw invalid_argument("invalid source point");
}
predecessor.resize(vertexNum);
distance.resize(vertexNum);
std::vector<vertexDistance> toBeVisit;
// 1. 初始化所有节点距离及前驱结点,并将可访问节点放入向量中
for (int i = 0; i < vertexNum; i++)
{
distance[i] = graph.edgeWeight(src, i);
if (distance[i] == 0)
{
predecessor[i] = -1;
}
else
{
predecessor[i] = src;
toBeVisit.push_back(make_pair(i, distance[i]));
}
}
predecessor[src] = 0;
// 2. 构造小顶堆(末尾为顶端)
auto makeHeapFunc = [](const vertexDistance& left, const vertexDistance& right) {return left.second > right.second; };
make_heap(toBeVisit.rbegin(), toBeVisit.rend(), makeHeapFunc);
// 3. 遍历所有待访问节点
while (!toBeVisit.empty())
{
// 3.1 取出当前最小的距离及其顶点
int vertex = toBeVisit.rbegin()->first;
int currentDisantance = toBeVisit.rbegin()->second;
toBeVisit.pop_back();
// 3.2 遍历该顶点所有相邻顶点
bool updated = false;
auto iter = graph.iterator(vertex);
int nextVertex;
while ((nextVertex = iter->next()) != -1)
{
// 3.2.1 如果 nextVertex 已经被访问过(即有前驱结点)且通过 vertex 到达 nextVertex 的距离更大,不需要做任何事
if (predecessor[nextVertex] != -1 && distance[nextVertex] <= currentDisantance + graph.edgeWeight(vertex, nextVertex))
{
continue;
}
// 3.2.2 更新前驱结点
updated = true;
predecessor[nextVertex] = vertex;
// 3.2.3 更新(插入)修改后的节点距离
distance[nextVertex] = currentDisantance + graph.edgeWeight(vertex, nextVertex);
auto vertexIter = find_if(toBeVisit.rbegin(), toBeVisit.rend(),
[&nextVertex](const vertexDistance& element) {return element.first == nextVertex; });
if (vertexIter == toBeVisit.rend())
{
toBeVisit.push_back(make_pair(nextVertex, distance[nextVertex]));
}
else
{
vertexIter->second = distance[nextVertex];
}
}
// 3.3 重新构造堆
if (updated)
{
make_heap(toBeVisit.rbegin(), toBeVisit.rend(), makeHeapFunc);
}
}
}
(3)测试代码
void test()
{
using namespace std;
adjacencyWDGraph<int> graph(5);
graph.insertEdge(new WEdge<int>(0, 1, 4));
graph.insertEdge(new WEdge<int>(0, 2, 2));
graph.insertEdge(new WEdge<int>(0, 4, 8));
graph.insertEdge(new WEdge<int>(1, 3, 4));
graph.insertEdge(new WEdge<int>(1, 4, 5));
graph.insertEdge(new WEdge<int>(2, 3, 1));
graph.insertEdge(new WEdge<int>(3, 4, 3));
vector<int> distance;
vector<int> predecessor;
shortestPaths(graph, 0, distance, predecessor);
copy(predecessor.begin(), predecessor.end(), ostream_iterator<int>(cout, " "));
cout << endl;
copy(distance.begin(), distance.end(), ostream_iterator<int>(cout, " "));
cout << endl << endl;
graph.insertEdge(new WEdge<int>(0, 3, 2));
shortestPaths(graph, 0, distance, predecessor);
copy(predecessor.begin(), predecessor.end(), ostream_iterator<int>(cout, " "));
cout << endl;
copy(distance.begin(), distance.end(), ostream_iterator<int>(cout, " "));
cout << endl;
}
二、最小成本生成树
1、问题描述
如果我们把图看做一个网络,顶点(V)表示城市的集合,边(E)表示通信线路集合。V的每一对城市之间可以通信,当且仅当G是连通的。最小生成树就是要在边集中选取一个最小的子集,使得在该子集内,G仍是连通的。
在 n 个顶点的无向网络 G 中,每棵生成树都刚好有 n − 1 n-1 n−1条边,所以现在的问题是如何选择 n − 1 n-1 n−1条边使他们形成 G 的最小成本生成树。
2、Kruskal算法
(1)步骤
Kruskal算法分步骤选择 n − 1 n-1 n−1条边,每步选择一条边,所依据的贪婪准则是:从剩下的边中选择一条成本最小且不会产生环路的边加入已选择的边集。Kruskal算法分 e 步,其中 e 是网络中边的数目。它按成本递增顺序考察 e 条边。当考察一条边时,如果这条边已经加入已选的边集中会产生环路,则将其抛弃,否则,将其加入已选的边集中。
(2)正确性证明
我们需要证明:
① 只要存在生成树,该算法总能产生一棵生成树
② 产生的生成树具有最小成本
对于①,令G为任意一个加权无向图,T是算法选定的边集,在Kruskal算法中,被丢弃的是产生环路的边。而在连通图的一个环路删除一条边,结果仍是连通图。因此如果G是一个连通图,那么T总能形成一个连通图。
对于②,我们使用反证法证明。假设 W 是最小生成树集合中与 T 交集最大的顶点集合。令 k 是 W 和 T 的交集元素数目。因为二者的元素个数都为
n
−
1
n-1
n−1,而二者又不可能相同。因此,
k
<
n
−
1
k < n-1
k<n−1。我们从T中取出一条边 e 加入W,从 W 中减去一条边 f。其中:
e 是属于 T 而不属于 W 的成本最小的边。因为 T 和 W 不完全相同,所以这样的边一定存在。
f 是将 e 加入 W 之后形成的环路(因为 W 是生成树,所以任意加入一条边都会形成环路)上不属于 T 的任意一条边。因为 T 中不存在环路,所以这样的边一定存在。
不难看出,修改后的 W 仍然是一棵生成树,且 W 和 T 的交集变为 k + 1 k+1 k+1。由于 W 本来就是最小生成树,因此 w e ≥ w f w_e \ge w_f we≥wf。若 e 的成本大于 f 的成本,那么在Kruskal算法中一定是先考察 f 后考察 e。然而 f 并不在 T 中,所以该边是被评估后抛弃的。因此那些成本小于或等于 f 的边和边 f 形成环路。而根据 e 的选择方法,e 是一条属于 T 而不属于 W 的成本最小的边。因此在T中所有成本小于 e 的边,进而,所有成本小于 f 的边,都在 W 中。那么 W 中将出现环路。因此, e 和 f 一定具有相同的成本。这与我们最初的假设矛盾。因为,修改后的 W 集合仍是最小生成树,但其和 T 的交集元素个数 却超过 k。
3、Prim算法
与Kruskal算法类似,Prim算法也是通过分步选边来创建最小生成树,而且每次选择一条边。其所依赖的贪婪准则是:==从剩余的边中,选择一条成本最小的边,使得该边的两个顶点中有且只有一个于已选择点集合中。==这样可以保证不会出现环路。