Bootstrap

数据结构第26节 广度优先搜索

广度优先搜索(Breadth-First Search,简称BFS)是一种用于遍历或搜索树或图数据结构的算法。其主要特性是以层级顺序遍历图的所有节点,从一个指定的起点开始,首先访问所有直接相连的邻居节点,然后再访问它们的邻居,以此类推,直至遍历完所有的可达节点。

BFS的工作原理:

  1. 选择起点:BFS从一个指定的起点开始。

  2. 初始化队列:创建一个空队列,并将起点插入队列中。

  3. 标记节点:起点被标记为“已访问”,以避免重复访问。

  4. 遍历队列:从队列中取出第一个节点,并访问它所有未被访问过的邻居节点。将这些邻居节点标记为“已访问”并插入队列的末尾。

  5. 重复过程:继续从队列中取出下一个节点,重复步骤4,直到队列为空。

  6. 终止条件:当队列为空时,算法结束,此时所有可达的节点都被遍历过了。

时间复杂度:

BFS的时间复杂度通常是O(V + E),其中V是图中的顶点数,E是边的数量。这是因为每个顶点和每条边都会被访问一次。

空间复杂度:

BFS的空间复杂度取决于队列的最大大小,最坏情况下,如果所有的节点都在队列中,则空间复杂度为O(V)。

应用场景:

  • 最短路径:在无权图或权重相等的图中寻找两个节点之间的最短路径。
  • 连通性:确定图是否连通,以及找出连通分量。
  • 层次遍历:在树形结构中,按层次顺序访问节点。
  • 拓扑排序:在有向无环图中进行排序。
  • 迷宫求解:寻找从起点到终点的路径。
  • 社交网络分析:例如计算用户之间的距离或找到共同朋友。

实现示例:

下面是一个使用Python实现的BFS算法示例,用于在图中寻找两个顶点之间的路径:

from collections import deque

def bfs(graph, start, goal):
    # 创建一个队列并插入起点
    queue = deque([start])
    # 创建一个字典来存储每个节点的父节点
    parents = {start: None}

    while queue:
        current = queue.popleft()
        
        if current == goal:
            # 目标节点已找到,构建并返回路径
            path = []
            while current is not None:
                path.append(current)
                current = parents[current]
            return list(reversed(path))

        # 遍历当前节点的所有邻居
        for neighbor in graph[current]:
            if neighbor not in parents:
                # 标记邻居节点,并将其父节点设为当前节点
                parents[neighbor] = current
                queue.append(neighbor)

    # 如果没有找到目标节点
    return None

在上述示例中,graph是一个邻接列表,表示图的结构,startgoal分别表示起点和终点。该函数返回从startgoal的路径,如果没有找到路径则返回None

广度优先搜索(Breadth-First Search,简称BFS)是一种用于遍历或搜索树或图数据结构的算法。在游戏开发中,BFS经常用来解决诸如寻路、迷宫求解、计算连通性、寻找最近的敌人或资源点等问题。

下面我将以一个典型的迷宫游戏作为案例,来详细讲解如何在Java中实现广度优先搜索。

游戏案例:迷宫寻路

假设我们有一个二维迷宫,由一系列的格子组成,每个格子可以是墙或者空地。玩家需要从起点找到到达终点的最短路径。我们可以使用BFS来解决这个问题。

1. 定义迷宫

首先,我们需要定义迷宫的结构。我们可以用一个二维数组来表示迷宫,其中0代表可通行的空地,1代表墙。

int[][] maze = {
    {1, 1, 1, 1, 1, 1, 1},
    {1, 0, 0, 0, 0, 0, 1},
    {1, 0, 1, 1, 1, 0, 1},
    {1, 0, 0, 0, 0, 0, 1},
    {1, 1, 1, 1, 1, 1, 1}
};
2. 定义节点类

为了存储迷宫中的每个位置状态,我们需要创建一个节点类,包含位置坐标和从起点到该位置的距离。

class Node {
    int x;
    int y;
    int distance;

    Node(int x, int y, int distance) {
        this.x = x;
        this.y = y;
        this.distance = distance;
    }
}
3. 实现BFS

接下来,我们实现BFS算法。我们将使用队列来存储待访问的节点,并且维护一个布尔型的二维数组来标记已经访问过的节点。

public static boolean bfs(int[][] maze, int startX, int startY, int endX, int endY) {
    // 队列用于存储待访问的节点
    Queue<Node> queue = new LinkedList<>();
    // 标记哪些节点已经被访问过
    boolean[][] visited = new boolean[maze.length][maze[0].length];

    // 将起点加入队列并标记为已访问
    queue.add(new Node(startX, startY, 0));
    visited[startX][startY] = true;

    // 方向数组,用于四个方向的移动
    int[][] directions = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};

    while (!queue.isEmpty()) {
        Node current = queue.poll();

        // 如果找到了终点,返回true
        if (current.x == endX && current.y == endY) {
            System.out.println("Shortest distance: " + current.distance);
            return true;
        }

        // 检查四个方向
        for (int[] dir : directions) {
            int newX = current.x + dir[0];
            int newY = current.y + dir[1];

            // 检查新位置是否在迷宫内并且未被访问过
            if (newX >= 0 && newX < maze.length && newY >= 0 && newY < maze[0].length &&
                maze[newX][newY] == 0 && !visited[newX][newY]) {
                // 将新位置加入队列并标记为已访问
                queue.add(new Node(newX, newY, current.distance + 1));
                visited[newX][newY] = true;
            }
        }
    }

    // 如果没有找到路径,返回false
    return false;
}
4. 调用BFS

最后,我们可以在主函数中调用bfs方法,传入迷宫和起点、终点的坐标。

public static void main(String[] args) {
    int[][] maze = {
        // 迷宫数组
    };
    boolean found = bfs(maze, 1, 1, maze.length - 2, maze[0].length - 2);
    if (!found) {
        System.out.println("No path found.");
    }
}

这个算法可以确保找到从起点到终点的最短路径,如果存在多条最短路径,它会返回其中任意一条。

让我们将上述概念转化为一个完整的Java程序。我们将添加一些额外的功能,比如输出路径,以及处理没有找到路径的情况。以下是一个完整的示例代码:

import java.util.LinkedList;
import java.util.Queue;

public class MazeSolver {

    private static final int WALL = 1;
    private static final int PATH = 0;

    public static void main(String[] args) {
        int[][] maze = {
            {1, 1, 1, 1, 1, 1, 1},
            {1, 0, 0, 0, 0, 0, 1},
            {1, 0, 1, 1, 1, 0, 1},
            {1, 0, 0, 0, 0, 0, 1},
            {1, 1, 1, 1, 1, 1, 1}
        };

        int startX = 1, startY = 1;
        int endX = maze.length - 2, endY = maze[0].length - 2;

        if (bfs(maze, startX, startY, endX, endY)) {
            printPath(maze, startX, startY, endX, endY);
        } else {
            System.out.println("No path found.");
        }
    }

    public static boolean bfs(int[][] maze, int startX, int startY, int endX, int endY) {
        Queue<Node> queue = new LinkedList<>();
        boolean[][] visited = new boolean[maze.length][maze[0].length];
        queue.add(new Node(startX, startY, 0));
        visited[startX][startY] = true;

        int[][] directions = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
        while (!queue.isEmpty()) {
            Node current = queue.poll();
            if (current.x == endX && current.y == endY) {
                setPath(maze, current);
                return true;
            }
            for (int[] dir : directions) {
                int newX = current.x + dir[0];
                int newY = current.y + dir[1];
                if (isValidMove(maze, newX, newY, visited)) {
                    queue.add(new Node(newX, newY, current.distance + 1));
                    visited[newX][newY] = true;
                    // Store the previous node to reconstruct the path later
                    maze[newX][newY] = current.distance + 1;
                }
            }
        }
        return false;
    }

    public static boolean isValidMove(int[][] maze, int x, int y, boolean[][] visited) {
        return x >= 0 && x < maze.length && y >= 0 && y < maze[0].length &&
               maze[x][y] == PATH && !visited[x][y];
    }

    public static void setPath(int[][] maze, Node endNode) {
        int x = endNode.x;
        int y = endNode.y;
        while (maze[x][y] != 0) {
            maze[x][y] = 2; // Mark the path with 2
            int prevDist = maze[x][y] - 1;
            for (int i = -1; i <= 1; i++) {
                for (int j = -1; j <= 1; j++) {
                    if (i == 0 || j == 0) {
                        int newX = x + i;
                        int newY = y + j;
                        if (newX >= 0 && newX < maze.length && newY >= 0 && newY < maze[0].length &&
                            maze[newX][newY] == prevDist) {
                            x = newX;
                            y = newY;
                            break;
                        }
                    }
                }
            }
        }
    }

    public static void printPath(int[][] maze, int startX, int startY, int endX, int endY) {
        System.out.println("Path from (" + startX + ", " + startY + ") to (" + endX + ", " + endY + "):");
        for (int[] row : maze) {
            for (int cell : row) {
                switch (cell) {
                    case WALL:
                        System.out.print("# ");
                        break;
                    case PATH:
                        System.out.print(". ");
                        break;
                    case 2:
                        System.out.print("* ");
                        break;
                    default:
                        System.out.print("S ");
                }
            }
            System.out.println();
        }
    }

    static class Node {
        int x;
        int y;
        int distance;

        Node(int x, int y, int distance) {
            this.x = x;
            this.y = y;
            this.distance = distance;
        }
    }
}

在这个版本中,我们增加了以下功能:

  1. setPath 方法用于在找到路径后,通过回溯从终点到起点的节点距离来标记出整个路径。
  2. printPath 方法用于打印迷宫,包括起点、终点和找到的路径。
  3. isValidMove 方法用于检查下一步移动是否有效,即不超出迷宫边界、不是墙壁、且尚未访问过。

这样,我们不仅能够找出从起点到终点的最短路径,还可以在迷宫中可视化这条路径。

为了进一步完善代码,我们可以做以下几点改进:

  1. 错误处理:增加对输入参数的检查,确保迷宫和起始/结束点的有效性。
  2. 性能优化:在bfs中,我们使用距离来追踪路径,这在小规模迷宫中可行,但在大规模迷宫中可能占用大量内存。我们可以改用前驱节点的方式,这样只需存储每个节点的前一个节点即可。
  3. 代码清晰性:分离关注点,比如将路径设置逻辑独立出来。

下面是根据这些改进后的代码:

import java.util.LinkedList;
import java.util.Queue;

public class MazeSolver {

    private static final int WALL = 1;
    private static final int PATH = 0;
    private static final int START = 3;
    private static final int PATH_MARK = 2;

    public static void main(String[] args) {
        int[][] maze = {
            {1, 1, 1, 1, 1, 1, 1},
            {1, 0, 0, 0, 0, 0, 1},
            {1, 0, 1, 1, 1, 0, 1},
            {1, 0, 0, 0, 0, 0, 1},
            {1, 1, 1, 1, 1, 1, 1}
        };

        int startX = 1, startY = 1;
        int endX = maze.length - 2, endY = maze[0].length - 2;

        if (validateMaze(maze, startX, startY, endX, endY)) {
            if (bfs(maze, startX, startY, endX, endY)) {
                printPath(maze, startX, startY, endX, endY);
            } else {
                System.out.println("No path found.");
            }
        } else {
            System.out.println("Invalid maze or start/end points.");
        }
    }

    private static boolean validateMaze(int[][] maze, int startX, int startY, int endX, int endY) {
        return startX >= 0 && startY >= 0 && endX >= 0 && endY >= 0 &&
               startX < maze.length && startY < maze[0].length &&
               endX < maze.length && endY < maze[0].length &&
               maze[startX][startY] == PATH && maze[endX][endY] == PATH;
    }

    public static boolean bfs(int[][] maze, int startX, int startY, int endX, int endY) {
        Queue<Node> queue = new LinkedList<>();
        boolean[][] visited = new boolean[maze.length][maze[0].length];
        int[][] predecessors = new int[maze.length][maze[0].length];
        for (int[] row : predecessors) {
            java.util.Arrays.fill(row, -1);
        }
        queue.add(new Node(startX, startY, null));
        visited[startX][startY] = true;

        int[][] directions = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
        while (!queue.isEmpty()) {
            Node current = queue.poll();
            if (current.x == endX && current.y == endY) {
                setPath(predecessors, current);
                return true;
            }
            for (int[] dir : directions) {
                int newX = current.x + dir[0];
                int newY = current.y + dir[1];
                if (isValidMove(maze, newX, newY, visited)) {
                    queue.add(new Node(newX, newY, current));
                    visited[newX][newY] = true;
                    predecessors[newX][newY] = current.distance;
                }
            }
        }
        return false;
    }

    public static boolean isValidMove(int[][] maze, int x, int y, boolean[][] visited) {
        return x >= 0 && x < maze.length && y >= 0 && y < maze[0].length &&
               maze[x][y] == PATH && !visited[x][y];
    }

    public static void setPath(int[][] predecessors, Node endNode) {
        int x = endNode.x;
        int y = endNode.y;
        while (predecessors[x][y] != -1) {
            Node prev = predecessors[x][y];
            maze[x][y] = PATH_MARK; // Mark the path with 2
            x = prev.x;
            y = prev.y;
        }
        maze[x][y] = START; // Mark the start point
    }

    public static void printPath(int[][] maze, int startX, int startY, int endX, int endY) {
        System.out.println("Path from (" + startX + ", " + startY + ") to (" + endX + ", " + endY + "):");
        for (int[] row : maze) {
            for (int cell : row) {
                switch (cell) {
                    case WALL:
                        System.out.print("# ");
                        break;
                    case PATH:
                        System.out.print(". ");
                        break;
                    case PATH_MARK:
                        System.out.print("* ");
                        break;
                    case START:
                        System.out.print("S ");
                        break;
                    default:
                        System.out.print(" ");
                }
            }
            System.out.println();
        }
    }

    static class Node {
        int x;
        int y;
        Node distance;

        Node(int x, int y, Node distance) {
            this.x = x;
            this.y = y;
            this.distance = distance;
        }
    }
}

在这个版本中,我们进行了以下改进:

  • 使用predecessors数组来存储每个节点的前驱节点,而不是其距离。
  • setPath方法中,我们通过遍历predecessors数组来重构路径。
  • 添加了validateMaze方法来确保迷宫和起始/结束点的有效性。

这样,我们的代码现在更加健壮,更易于理解和维护。

;