文章目錄
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、本文源碼地址
戳這裏獲取本文源代碼:點我