解密迷宮問題:三種高效演算法Java實現,讓你輕鬆穿越未知迷宮

程式設計碼農發表於2023-04-29

問題背景

迷宮問題是一個經典的演演算法問題,目標是找到從迷宮的起點到終點的最短路徑,在程式中可以簡單的抽象成一個M*N的二維陣列矩陣,然後我們需要從這個二維矩陣中找到從起點到終點的最短路徑。其中,通常使用 0 表示可行走的路,用 1 表示障礙物,起點和終點分別標記為 S 和 E。例如,下圖是一個簡單的迷宮問題:

0 0 0 0 0 0
0 1 0 1 1 0
0 1 0 0 0 0
0 0 1 1 0 0
0 1 0 1 0 0
S 0 0 0 E 0

在這個迷宮中,數字 0 表示可行走的路,數字 1 表示障礙物,S 表示起點,E 表示終點。

應用場景

迷宮問題在現實生活中有很多實際應用例子:

  1. 機器人導航:在機器人導航中,機器人需要根據感測器獲取的資訊來規劃路徑,從起點到終點。這個過程可以使用迷宮問題的演演算法來完成,如使用 A* 演演算法來找到最短路徑。
  2. 遊戲設計:迷宮問題可以應用於各種型別的遊戲中,如謎題解決遊戲和角色扮演遊戲。在這些遊戲中,玩家需要找到一條從起點到終點的路徑,同時避免遇到障礙物或危險。
  3. 自動駕駛:在自動駕駛汽車中,汽車需要遵循交通規則、避免障礙物並找到最短路徑。這也可以使用迷宮問題的演演算法來完成,如使用 A* 演演算法來找到最短路徑。
  4. 網路路由:網路路由器需要在各種網路拓撲中尋找最佳路徑,以確保資料包在網路中傳輸時儘可能快速和可靠。這也可以使用迷宮問題的演演算法來完成,如使用 A* 演演算法來找到最短路徑。
  5. 地圖應用:在地圖應用中,使用者需要根據起點和終點尋找最佳路徑。這可以使用迷宮問題的演演算法來完成,如使用 A* 演演算法來找到最短路徑。

常用演演算法

求解迷宮問題的演演算法有多種,其中最常見的是深度優先搜尋(DFS)演演算法廣度優先搜尋(BFS)演演算法A*搜尋演演算法。本文將分別介紹這兩種演演算法的實現方式及其優缺點。

深度優先搜尋(DFS)演演算法

深度優先搜尋(DFS)是一種基於棧或遞迴的搜尋演演算法,從起點開始,不斷地往深處遍歷,直到找到終點或無法繼續往下搜尋。在迷宮問題中,DFS 會先選取一個方向往前走,直到無法前進為止,然後返回上一個節點,嘗試其他方向。

DFS 的核心思想是回溯,即在走到死路時,返回上一個節點,從而探索其他方向。具體實現上,可以使用遞迴函式或棧來維護待訪問的節點。

import java.util.*;

public class MazeSolver {
    // 迷宮的行數和列數
    static final int ROW = 5;
    static final int COL = 5;

    // 迷宮的地圖,0 表示可以透過的路,1 表示牆壁,2 表示已經走過的路
    static int[][] map = new int[][]{
        {0, 1, 1, 1, 1},
        {0, 0, 0, 1, 1},
        {1, 1, 0, 0, 1},
        {1, 1, 1, 0, 1},
        {1, 1, 1, 0, 0}
    };

    // 迷宮的起點和終點
    static final int startX = 0;
    static final int startY = 0;
    static final int endX = 4;
    static final int endY = 4;

    // 儲存搜尋路徑
    static List<int[]> path = new ArrayList<>();

    // DFS 搜尋迷宮
    public static void dfs(int x, int y) {
        // 如果當前位置是終點,則搜尋完成
        if (x == endX && y == endY) {
            // 列印搜尋路徑
            for (int[] p : path) {
                System.out.print("(" + p[0] + "," + p[1] + ") ");
            }
            System.out.println("(" + x + "," + y + ")");
            return;
        }

        // 標記當前位置已經走過
        map[x][y] = 2;

        // 將當前位置加入搜尋路徑
        path.add(new int[]{x, y});

        // 分別搜尋當前位置的上下左右四個方向
        if (x > 0 && map[x-1][y] == 0) {
            dfs(x-1, y);
        }
        if (y > 0 && map[x][y-1] == 0) {
            dfs(x, y-1);
        }
        if (x < ROW-1 && map[x+1][y] == 0) {
            dfs(x+1, y);
        }
        if (y < COL-1 && map[x][y+1] == 0) {
            dfs(x, y+1);
        }

        // 如果沒有找到終點,將當前位置從搜尋路徑中移除
        path.remove(path.size()-1);
    }

    public static void main(String[] args) {
        dfs(startX, startY);
    }
}

深度優先搜尋(DFS)的優點:

  1. 實現簡單,不需要額外的資料結構。
  2. 對於有解的迷宮問題,深度優先搜尋能夠保證找到一條路徑,且路徑長度可能會比廣度優先搜尋短。
  3. 在空間較大的情況下,深度優先搜尋可以佔用更少的記憶體,因為它只需要維護當前路徑上的節點,而不需要維護所有已訪問過的節點。

深度優先搜尋的缺點:

  1. 搜尋的路徑可能會非常複雜,可能會陷入死迴圈或長時間不停的搜尋。
  2. 對於無解的迷宮問題,深度優先搜尋可能會無限地搜尋下去,直到棧溢位或程式崩潰。
  3. 當要求找到最短路徑時,深度優先搜尋不能保證一定能找到最短路徑,因為它是基於回溯的思想,可能會跳過一些更短的路徑。
  4. 當搜尋樹的深度很大時,深度優先搜尋可能會導致棧溢位的問題。

廣度優先搜尋(BFS)

廣度優先搜尋(BFS)演演算法是一種樸素的搜尋演演算法,它從起點開始逐步擴充套件搜尋範圍,直到找到目標節點為止。在搜尋過程中,BFS 會先訪問起點周圍的所有節點,再訪問這些節點周圍的所有節點,以此類推。因此,BFS 可以保證找到的路徑是最短的,但它的時間複雜度可能很高,尤其是在搜尋空間較大時。

下面是一個基於 BFS 演演算法的示例程式碼,用於在一個圖中搜尋從起點到目標節點的最短路徑:

import java.util.*;

public class MazeSolver {

    public static void main(String[] args) {

        // 定義迷宮
        int[][] maze = {
            {0, 1, 0, 0, 0},
            {0, 1, 0, 1, 0},
            {0, 0, 0, 0, 0},
            {0, 1, 1, 1, 0},
            {0, 0, 0, 1, 0}
        };

        // 尋找路徑
        List<int[]> path = solve(maze, new int[]{0, 0}, new int[]{4, 2});

        // 輸出路徑
        if (path != null) {
            for (int[] point : path) {
                System.out.println(Arrays.toString(point));
            }
        } else {
            System.out.println("No solution found.");
        }
    }

    public static List<int[]> solve(int[][] maze, int[] start, int[] end) {

        // 定義寬度優先搜尋所需的佇列
        Queue<int[]> queue = new LinkedList<>();
        queue.add(start);

        // 定義路徑跟蹤陣列
        Map<int[], int[]> trace = new HashMap<>();
        trace.put(start, null);

        // 定義已經訪問過的點集合
        Set<int[]> visited = new HashSet<>();
        visited.add(start);

        // 定義方向陣列,分別表示上下左右四個方向
        int[][] directions = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};

        // 開始搜尋
        while (!queue.isEmpty()) {

            // 取出佇列中的下一個點
            int[] current = queue.poll();

            // 如果當前點是終點,返回路徑
            if (Arrays.equals(current, end)) {
                List<int[]> path = new ArrayList<>();
                while (current != null) {
                    path.add(current);
                    current = trace.get(current);
                }
                Collections.reverse(path);
                return path;
            }

            // 遍歷四個方向
            for (int[] direction : directions) {
                int[] neighbor = new int[]{current[0] + direction[0], current[1] + direction[1]};

                // 如果鄰居在迷宮範圍內,且沒有被訪問過,且不是牆,加入佇列和訪問集合,並記錄路徑
                if (neighbor[0] >= 0 && neighbor[0] < maze.length &&
                    neighbor[1] >= 0 && neighbor[1] < maze[0].length &&
                    !visited.contains(neighbor) && maze[neighbor[0]][neighbor[1]] == 0) {
                    queue.add(neighbor);
                    visited.add(neighbor);
                    trace.put(neighbor, current);
                }
            }
        }

        // 如果搜尋結束還沒有找到路徑,返回null
        return null;
    }
}

廣度優先搜尋(BFS)的優點:

  1. 找到的第一條路徑一定是最短的,因為BFS是按照層級逐一搜尋的,一旦搜尋到目標狀態,那麼就可以保證這是最短路徑。
  2. 可以搜尋出所有可行的路徑,而不是僅僅找到一條路徑。這對於一些需要獲取所有解的問題非常有用。
  3. 在搜尋樹比較小的情況下,BFS的搜尋速度非常快。

廣度優先搜尋(BFS)的缺點:

  1. 空間佔用比較大。在搜尋過程中,需要將所有已經擴充套件出的狀態都儲存在記憶體中,所以BFS需要較多的記憶體空間,尤其是在搜尋樹比較大的情況下。
  2. 在搜尋樹比較大的情況下,BFS的時間複雜度很高。當搜尋樹非常大時,BFS需要搜尋大量的狀態,因此時間複雜度會非常高。
  3. 不能處理無限狀態空間問題,即狀態空間無限大的問題,例如無限大的圖。

A*搜尋演演算法

A搜尋演演算法是一種啟發式搜尋演演算法,它在廣度優先搜尋的基礎上引入了啟發函式,以更快速、更準確地搜尋最短路徑。啟發函式可以評估每個搜尋節點到目標節點的估計距離,從而最佳化搜尋方向。具體實現時,可以用一個優先佇列來儲存搜尋節點,並按照優先順序依次取出每個節點進行搜尋。其中,優先順序的計算方式為 f(n) = g(n) + h(n),其中 g(n) 表示從起點到節點 n 的實際距離,h(n) 表示從節點 n 到終點的估計距離。使用啟發函式的最佳化能夠大幅減少搜尋時間。

下面是一個基於 A* 演演算法的示例程式碼


import java.util.*;

public class AStar {
    public static int[] solve(int[][] maze, int[] start, int[] end) {
        int n = maze.length;
        int m = maze[0].length;

        // 將起點加入 openSet 集合中
        PriorityQueue<int[]> openSet = new PriorityQueue<>((a, b) -> (a[2] + a[3]) - (b[2] + b[3]));
        openSet.offer(new int[]{start[0], start[1], 0, estimateDistance(start, end)});

        // 記錄每個點是否已經被訪問過
        Set<Integer> visited = new HashSet<>();
        visited.add(start[0] * m + start[1]);

        while (!openSet.isEmpty()) {
            // 取出 f 值最小的點
            int[] cur = openSet.poll();
            int x = cur[0];
            int y = cur[1];

            // 如果該點是終點,則返回路徑
            if (x == end[0] && y == end[1]) {
                return new int[]{cur[2], cur[3]};
            }

            // 將該點的所有鄰居加入 openSet 中
            int[][] neighbors = new int[][]{{x - 1, y}, {x + 1, y}, {x, y - 1}, {x, y + 1}};
            for (int[] neighbor : neighbors) {
                int nx = neighbor[0];
                int ny = neighbor[1];

                // 判斷鄰居是否越界或者是障礙物
                if (nx < 0 || nx >= n || ny < 0 || ny >= m || maze[nx][ny] == 1) {
                    continue;
                }

                // 如果鄰居已經被訪問過,則跳過
                int code = nx * m + ny;
                if (visited.contains(code)) {
                    continue;
                }

                // 計算鄰居的 g 值和 h 值
                int g = cur[2] + 1;
                int h = estimateDistance(neighbor, end);

                // 將鄰居加入 openSet 中
                openSet.offer(new int[]{nx, ny, g, h});
                visited.add(code);
            }
        }

        // 如果 openSet 集合為空,則說明不存在可行路徑
        return new int[]{-1, -1};
    }

    // 計算估價函式值(曼哈頓距離)
    private static int estimateDistance(int[] start, int[] end) {
        return Math.abs(start[0] - end[0]) + Math.abs(start[1] - end[1]);
    }

    public static void main(String[] args) {
        // 定義迷宮
        int[][] maze = {
                {0, 1, 0, 0, 0},
                {0, 1, 0, 1, 0},
                {0, 0, 0, 0, 0},
                {0, 1, 1, 1, 0},
                {0, 0, 0, 1, 0}
        };

        // 尋找路徑
        int[] path = solve(maze, new int[]{0, 0}, new int[]{4, 2});

        // 輸出路徑
        if (path != null) {
            System.out.println(Arrays.toString(path));
        } else {
            System.out.println("No solution found.");
        }
        solve(maze, maze[1], maze[3]);
    }
}

A*演演算法的優點:

  1. A*演演算法綜合考慮了啟發式函式和實際代價,因此搜尋效率比較高。
  2. A*演演算法可以找到最短路徑,並且能夠保證找到的第一條路徑一定是最優路徑。

A*演演算法的缺點:

  1. 啟發式函式的選擇非常關鍵,不同的啟發式函式會導致不同的搜尋結果。如果啟發式函式不夠準確,那麼搜尋結果可能不是最優的。
  2. A演演算法需要儲存OPEN表和CLOSED表,佔用的記憶體比較大。如果狀態空間比較大,那麼A演演算法的效率會變得非常低。
  3. A*演演算法的實現比較複雜,需要對每個狀態進行估價和排序,因此演演算法的實現難度比較大。

總之,A*演演算法是一種非常實用的搜尋演演算法,在路徑規劃、遊戲AI等領域得到廣泛應用。在實際應用中,我們需要根據具體問題的特點選擇合適的啟發式函式,並且需要考慮演演算法的記憶體佔用和搜尋效率。

總結

我們總結一下,在迷宮問題中,深度優先搜尋(DFS)、廣度優先搜尋(BFS)和 A* 都可以用來尋找最短路徑或最優解。

DFS 適用於以下情況:

  • 空間要求低,不需要儲存整個搜尋樹,只需要儲存當前路徑;
  • 所有解的路徑長度差別不大,或者只需要找到其中一個解;
  • 迷宮比較大,而且有很多死路,採用 DFS 可以快速探索大面積空間。

BFS 適用於以下情況:

  • 需要找到最短路徑或最優解;
  • 迷宮中大部分路徑長度差別不大;
  • 可以承受較大的空間複雜度,需要儲存整個搜尋樹。

A* 演演算法適用於以下情況:

  • 需要找到最短路徑或最優解;
  • 需要考慮迷宮中的障礙物,即尋找一條避開障礙物的路徑;
  • 迷宮比較大,但是大多數路徑都很長,採用 BFS 不現實;
  • 啟發函式選取得當的話,搜尋效率很高。

總體來說,DFS 適合探索大面積空間,BFS 適合尋找最短路徑,A* 演演算法綜合了 BFS 和啟發式搜尋的優點,更適合尋找最短路徑且迷宮中有障礙物的情況。

相關文章