文章目录
1、地铁是如何实现收费的?
我们平时坐地铁去上班的时候,是否有想过,地铁是怎么计费的呢?因为地铁的线路都比较复杂,一般大城市的地铁网都是错综复杂的,比如广州,目前就有15条线路,假设一条线有20个站点,那么整个地铁网络就会有300个站点,因为地铁可以换乘,从一个起点到达一个终点,会有很多路线。我们都知道,地铁的收费是根据距离来决定的(我们这里先假设每两个站的距离都是相等的),也就是站点越多,收费就越多,通常来说,地铁从一个起点到另外一个点,都是按照最短路径来收费的(不可能按照最长吧?),如何找到这个最短路径呢?我们首先来看看以下简图:
1.1 数组实现方式
首先来看起点站和目的地是同一条地铁线的情况,比如从A到C站,这种计算距离是十分简单的,我们只要把整条一号线的站点组合成一个数组,就可以计算从其中某个站点到另一个站点的路程。如上图的从A到C,假设A站的id是1,C站的id是3,我们把数组的元素设置为id值,那么总费用S=每站收费*(3-1),如果一站的收费是2元,那么从A到C就是4元。这个过程十分简单!时间复杂度是O(1),十分高效。
1.2 无向图实现方式
起点和目的点在同一条地铁线的情况计算是十分简单的,只需要构造一个数组就能实现。但如果起点和目的地不是同一条线,那么就比较复杂了。比如从一号线的A站作为起点,去二号线的F站,可选择的走法比较多,途中就有以下两种走法:
路线一:A--->B--->C--->D--->E--->F
路线二:A--->B--->F
然而在现实中,两个站点的路线可能不止两条路线了,按照地铁的计费原则,理应是选择最短路径来进行计费的,也就是上面说的路线二。那么系统是怎么找到路线二,并确定它就是最短路径呢?其实,熟悉图论的童鞋早就会想到其实地铁线路,就是个无向图。而无向图中有个经典的算法是广度优先搜索,它可以找到无向图中两点之间的最短路径。
2、广度优先搜索找到两个站点最短路径
为了方便简单实现,我们这里用站点的ID作为图的各个顶点,0代表A,1代表B,2代表C,3代表D,4代表E,5代表F,这样上面的地铁线路图就简化成下面的无向图。于是,求A—>F的最短距离问题就变成求顶点0到顶点5的最短路径。
2.1 构建站点对象
我们先构造站点对象Station,这里我们主要用到的是站点的stationId,如下代码:
/**
* 站点
*/
class Station{
int stationId;//站点id
String stationName;//站名
public Station(int stationId,String stationName) {
this.stationId = stationId;
this.stationName = stationName;
}
}
2.2 用邻接表构造地铁无向图
我们使用邻接表来表示这个无向图,以下是构造出的站点邻接表。左边的数组代表所有的站点,右边的一个个链表元素代表数组中的站点邻接的站点,比如数组中id为2的站点,有两个邻接站点,分别是id为2和4。
我们用链表数组stationTable表示这张邻接表,由传入的站点列表stationList作为stationTable的数据源。光是站点还不够,站点是通过一组线路来连接起来的,这个可以通过传入两个站点调用addEdge方法,构造站点与站点的连线。
public class SubwayGraph {
int stationCount;//站点总数量
LinkedList<Integer>[] stationTable;//站点邻接表
public SubwayGraph(List<Station> stationList) {
int size = stationList.size();
this.stationCount = size;
this.stationTable = new LinkedList[size];
for(Station s : stationList) {
stationTable[s.stationId] = new LinkedList<>();
}
}
public void addEdge(int stationId1, int stationId2) {
stationTable[stationId1].add(stationId2);
stationTable[stationId1].add(stationId2);
}
public int distance(int station1, int station2) {
int distance = new StationSearch(this, station1).distanceTo(station2);
System.out.println(String.format("站点id为[%1$s]到站点id为[%2$s]的距离为[%3$s]", station1, station2, distance));
return distance;
}
2.3 建立搜索类
为了把职责分开,所以把搜索另外建一个类StationSearch,专门处理站点距离的计算。
/**
*站点搜索
*/
static class StationSearch{
boolean[] visited;//已经经过的站点
Queue<Integer> preToVisit;//准备经过的站点
int sourceStationId;//起点
int prevStationId[];//数组下标为站点id,元素为当前下标站点的上一个站点
public StationSearch(SubwayGraph g, int sourceStationId) {
int bound = g.stationCount;
this.visited = new boolean[bound];
this.prevStationId = new int[bound];
this.sourceStationId = sourceStationId;
preToVisit = new LinkedList<>();
bfs(g,sourceStationId);//广度优先搜索
}
/**
* 广度优先搜索
* @param g
* @param sourceStationId 起点
*/
private void bfs(SubwayGraph g, int sourceStationId) {
visited[sourceStationId] = true;//标识为已经过
preToVisit.add(sourceStationId);
while(!preToVisit.isEmpty()) {
int toVisit = preToVisit.poll();
for(int i : g.stationTable[toVisit]) {
if(!visited[i]) {
visited[i] = true;//标识为已访问
prevStationId[i] = toVisit;//记录上一个站点
preToVisit.add(i);//邻接站点进入队列
}
}
}
}
/**
* 是否可达站点
* @param targetStationId
* @return
*/
public boolean hasPathTo(int targetStationId) {
return visited[targetStationId];
}
/**
* 到指定的站点距离
*/
public int distanceTo(int targetStationId) {
if(!hasPathTo(targetStationId)) return 0;
int distance = 0;
Stack<Integer> path = new Stack<>();
for(int i = targetStationId;i != this.sourceStationId;i = prevStationId[i]) {
distance += 1;
path.push(i);
}
path.push(this.sourceStationId);
System.out.print("经过站点:");
int size = path.size();
for(int p = 0;p<size;p++) {
System.out.print(path.pop() + " ");
}
System.out.println();
return distance;
}
}
以上代码有几个成员变量:
- visited;boolean数组,默认所有元素都是false。可以想象成列车已经经过的站点,把该下标等于该经过的站点的元素记为true。记录访问记录的原因是避免重复访问。
- preToVisit;计划要经过(访问)的站点,是一个队列。可以想象成列车可能要去往的站点。
- sourceStationId;起点站。开始计算的站点。
- prevStationId;是个整型数组,当前下标是当前站点,下标对应的数组元素是当前站点的上一个站点。这个变量是为了记录轨迹用的。
搜索类中有个最核心的方法,就是dfs方法。这个方法实现的就是广度优先搜索。这个方法的大概逻辑分为以下几个步骤:
- 先把起点设置为已经过(访问),即visited[sourceStation]=true;
- 把起点放进待访问队列,即preToVisit.add(sourceStationId);
- 把preToVisit队列中的站点出列;
- 将出列的站点的邻接点遍历出来,如果遍历出的站点未访问过,则把这些节点记录为已访问,把它们放到队列preToVisit中,并且把它们的上一个站点(即从队列中poll出来的那个站点)记录下来;
- 重复3、4步骤,直到队列preToVisit为空。即优先把当前站点的邻接站点优先找出来,再把这些站点放进队列,再分别把这些站点的邻接站点优先找出来,以此类推,直到所有站点都访问到。
再来关注一下搜索类中distanceTo方法,这里prevStationId数组就派上用场了,因为这个数组是记录了当前站点访问的上一个站点,于是可以很容易的把从起点到当前站点的轨迹打出来。这里用了个for循环,指针变量设置为当前的站点,不断的自底向上的通过prevStationId[i]把所经过的站点遍历出来,直到指针变量到达了起始点才结束。因为整个遍历过程是从目的地到起点的倒序,所以用栈结构来存储这些遍历出的站点,以便从栈中弹出来的是起点到目的地的顺序。这里还用一个distance变量来计算整个过程经历过的“边数”,因为每一次遍历可以看作是找到一根边,一直到起点,就是所有边的总数,也就是起点到目的站点的距离。
int distance = 0;
Stack<Integer> path = new Stack<>();
for(int i = targetStationId;i != this.sourceStationId;i = prevStationId[i]) {
distance += 1;
path.push(i);
}
path.push(this.sourceStationId);
2.4 测试站点距离计算
核心代码写完了,可以测试一下。我们先根据文章开头那些站点建立几个站点对象,再用这些站点构造出地铁线路图,把所有的站点通过调用addEdge彼此连接起来。最后一步就是计算距离,并且打印出从起点到目的地的轨迹。
public static void main(String[] args) {
List<Station> stationList = new ArrayList<SubwayGraph.Station>();
//建立站点
stationList.add(new Station(0, "A"));
stationList.add(new Station(1, "B"));
stationList.add(new Station(2, "C"));
stationList.add(new Station(3, "D"));
stationList.add(new Station(4, "E"));
stationList.add(new Station(5, "F"));
//构造地铁线路图
SubwayGraph g = new SubwayGraph(stationList);
g.addEdge(0, 1);//A-B
g.addEdge(1, 2);//B-C
g.addEdge(1, 4);//B-E
g.addEdge(2, 3);//C-D
g.addEdge(3, 4);//D-E
g.addEdge(4, 5);//E-F
g.distance(0, 5);//计算站点0到5的距离
}
以下为打印结果:
经过站点:0 1 4 5
站点id为[0]到站点id为[5]的距离为:3
2.5 为什么广度优先搜索的路径是最短路径
为什么广度优先搜索的路径是最短路径呢?换句话说,为什么没有其他线路比当前计算出来的路线短?因为从广度优先搜索算法的实现来看,我们可以发现一个特点:队列preToVisit中的元素,都是按照它们和起点的距离顺序进队列的,距离起点站越近的总会最先出列。所以在目的站点targetStationId进队列到它出队列期间,不可能有别的更短路径到达targetStation。
3、广度优先搜索的性能
3.1 时间复杂度
这种广度优先搜索的时间复杂度是多少呢?我们假设最坏的情况,就是无向图中最后一步才找到目的站点,这时候意味着图中每个顶点,以及每个顶点的邻接点都访问了一次,也就是相当于每条边都访问了两次,如果无向图的边数是E,那么广度优先搜索最坏的情况所需要的时间是2E。而平均来看,广度优先的搜索时间复杂度是线性的O(E)。
3.2 空间复杂度
实现一个无向图,用了邻接表的存储结构。假设总的顶点数是N,邻接表除了要存储各个顶点,而且还要存储顶点的邻接点,另外还有额外的visited、preToVisit、prevStationId这几个结构,但空间规模都不会超过N,所以广度优先算法的空间复杂度是O(N)。
4、实现更好的地铁计费系统
其实上面的实现,不是最优的计费系统实现方式。正如我们刚开始假设的,如果起点和目的地是同一条路线,那么我们可以通过数组访问实现时间复杂度为常数的计算,而不用经历广度优先搜索。
5、本文源码地址
戳这里获取本文源代码:点我