之前我用不相交集類(並查集)輔助實現了克魯斯卡爾(Kruskal)算法求出圖的最小生成樹,今天我就用並查集來再實現一個其經典的應用:隨機迷宮圖的生成
並查集生成迷宮圖的原理如下,也是迷宮圖算法實現的思路:
- 根據自定義迷宮的寬高,有一系列 寬×高 的矩陣點,從0開始編號
- 剛開始這些矩陣點都是不連通的,對應並查集也是都不相交
- 我們隨機找到兩個相鄰的矩陣點,然後通過並查集的find算法,查看相鄰矩陣點是否連通
- 如果該相鄰矩陣點對連通,則需要繼續尋找不連通的相鄰點對
- 如果該相鄰矩陣點對不連通,則他們之間的牆就拆掉,然後二者在並查集裏面做union操作,表示他們已經連通
- 生成一個迷宮圖,需要拆掉矩陣點-1面牆,也就是N個元素,得求N-1次union,才能全部相交
- 算法也就是不停地尋找隨機的相鄰兩位置,並且不連通,就記錄下來,拆掉二者中間的牆,求union記錄,算法執行矩陣點-1次後即刻終止
- 整理算法的結果,按照HTML格式整理內容,然後IO輸出流寫出HTML文件,用瀏覽器打開即可查看生產的迷宮圖
接下來就是我用Java實現的不相交集類(並查集)實現隨機迷宮生成的算法,算法的思想和精髓都在代碼和其間的詳細註釋中:
import java.io.*;
import java.util.*;
/**
* @author LiYang
* @ClassName DrawMaze
* @Description 迷宮圖生成算法
* @date 2019/11/29 15:55
*/
public class DrawMaze {
/**
* 不相交集工具類,按高度來合併
* 實現代碼根之前的不相交集類一樣
* 這裏作爲內部類,輔助實現迷宮生成算法
*/
static class DisjointSetUnionByHeight {
//不相交集類(並查集)的元素數組
private int[] elements;
/**
* 不相交集類(並查集)的構造方法,入參元素個數
* @param elementNum 元素個數
*/
public DisjointSetUnionByHeight(int elementNum) {
if (elementNum <= 0) {
throw new IllegalArgumentException("元素個數要大於零");
}
//實例化不相交集類(並查集)的元素數組
this.elements = new int[elementNum];
//初始化元素樹的高度都爲-1(如果是根,值就是負數,連通元素組成的樹
//的高度是多少,則根元素就是負幾)
for (int i = 0; i < elements.length; i++) {
this.elements[i] = -1;
}
}
/**
* 查詢不相交集類(並查集)的元素個數
* @return 元素個數
*/
public int size() {
return elements.length;
}
/**
* 查詢不相交集類(並查集)的某個元素的根元素
* 輸入的是下標查,如果兩個元素的根元素相同,
* 則這兩個元素就是等價的。實際中還會有一個
* 與elements等長的數組,裝的是元素的名字,
* elements只是相當於代號,記錄連通關係,
* 二者通過下標,來映射真實元素
* @param element 待查詢的元素
* @return 該元素的根元素
*/
public int find(int element) {
//如果記錄小於0,那就是根元素
if (elements[element] < 0) {
//返回根元素
return element;
//如果記錄不小於0,那還不是根,
//是等價森林中的上一個節點
} else {
//遞歸向上繼續尋找根
return find(elements[element]);
}
}
/**
* 將不相交集類(並查集)的兩個元素進行連通操作
* 注意,兩個元素連通,代表這兩個元素所在的子樹
* 全部變成一個圖中的大的子樹。如果這
* 兩個元素本來就連通,則不進行連通操作,捨棄該邊。
* 注意,這裏同樣是入參下標,下標映射真實元素
* 此實現類,根據樹的高度來決定誰合併到誰上面,
* 矮的樹的根節點,會作爲大的樹的根節點的子節點
* @param element1 元素下標1
* @param element2 元素下標2
*/
public void union(int element1, int element2) {
//找到兩個元素的根元素
int root1 = find(element1);
int root2 = find(element2);
//如果兩個元素本就連通
if (root1 == root2) {
//不作處理
return;
}
//比高度:如果root1比root2的樹要高
if (elements[root1] < elements[root2]) {
//將較矮的root2合併到較高的root1上
elements[root2] = root1;
//比高度:如果root2比root1的樹要高
} else if (elements[root2] < elements[root1]) {
//將較矮的root1合併到較高的root2上
elements[root1] = root2;
//比高度:如果root1和root2一樣高
} else {
//將root1合併到root2上
elements[root1] = root2;
//root2的高度增加1
root2 --;
}
}
}
/**
* 獲取某位置的前後左右四個位置(可能不到四個)
* @param position 需要尋找相鄰位置的當前位置
* @param width 迷宮寬
* @param height 迷宮高
* @return 當前位置的相鄰位置集合
*/
private static List<Integer> getAdjacentPosition(int position, int width, int height) {
//當前位置上面的位置
int top = position - width;
//當前位置下面的位置
int bottom = position + width;
//當前位置左邊的位置
int left = position - 1;
//當前位置右邊的位置
int right = position + 1;
//收集有效的相鄰位置
List<Integer> adjacentPosition = new ArrayList<>();
//上面的會更小,因此需要爲非負數
if (top >= 0) {
adjacentPosition.add(top);
}
//下面的會更大,不能超過最大位置
if (bottom < width * height) {
adjacentPosition.add(bottom);
}
//左邊的不能越界到上一行
if ((left + 1) % width != 0) {
adjacentPosition.add(left);
}
//右邊的不能越界到下一行
if (right % width != 0) {
adjacentPosition.add(right);
}
//返回當前位置所有的有效相鄰位置
return adjacentPosition;
}
/**
* 對迷宮進行拆牆的操作,返回全部需要拆掉的牆
* @param width 迷宮寬
* @param height 迷宮高
* @return 所有需要拆掉的牆,由兩個相鄰位置決定
*/
private static List<String> breakWall(int width, int height) {
//算出總的位置數
int totalPosition = width * height;
//創建不相交集(並查集)類,作爲迷宮生成輔助工具類
DisjointSetUnionByHeight disjointHeight = new DisjointSetUnionByHeight(totalPosition);
//算出需要破壞的牆的數量(n個獨立元素連通,需要合併n-1次)
int wallNeedToBreak = width * height - 1;
//記錄破壞的牆的信息 pos1-pos2,且pos1 < pos2
List<String> breakWallList = new ArrayList<>();
//隨機數類
Random random = new Random();
//一共需要拆掉位置數-1面牆,使得所有位置連通
for (int time = 0; time < wallNeedToBreak; time++) {
//直到遇到一對不連通的相鄰位置,然後拆掉了中間的牆,完成一次拆牆
while (true) {
//隨機找到一個位置
int randomPosition = random.nextInt(totalPosition - 1);
//獲得隨機位置的所有相鄰位置
List<Integer> adjacentPosition = getAdjacentPosition(randomPosition, width, height);
//隨機打亂所有相鄰位置
Collections.shuffle(adjacentPosition);
//打亂的相鄰位置中,取第一個相鄰位置
int anotherPosition = adjacentPosition.get(0);
//並查集重要操作1:查詢二者是否已連通,就看二者的根元素是否相等
boolean isConnected = disjointHeight.find(randomPosition) == disjointHeight.find(anotherPosition);
//如果未連通
if (!isConnected) {
//將二者之間的牆打掉(存的是牆的相鄰兩個位置)
breakWallList.add(Math.min(randomPosition, anotherPosition)
+ "-" + Math.max(randomPosition, anotherPosition));
//並查集重要操作2:設置連通狀態,將兩個位置求並
disjointHeight.union(randomPosition, anotherPosition);
//結束本次打牆操作
//當然,如果isConnected爲true,則代表隨機的相鄰兩位置是連通的
//此時不需要打牆和求並,得重新找兩個隨機相鄰的不連通的頂點,
//直到找到隨機相鄰的不連通兩位置,然後才break掉這個while循環
break;
}
}
}
//返回需要拆的牆,一共需要拆掉位置數-1面牆
return breakWallList;
}
/**
* 初始化未被破壞掉的牆
* @param width 迷宮寬
* @param height 迷宮高
* @return 未被拆牆的原圖
*/
private static int[] initWall(int width, int height) {
//算出矩陣的寬高
int matrixWidth = width * 2 + 1;
int matrixHeight = height * 2 + 1;
//初始化矩陣(本算法,用的是一維數組,HTML的需要)
int[] matrix = new int[matrixWidth * matrixHeight];
//將數組全部賦初值1(同樣是後面HTML的需要)
Arrays.fill(matrix, 1);
//剛開始的位置
int initStart = width * 2 + 2;
//垂直方向的步長
int verticalStep = (width * 2 + 1) * 2;
//水平方向的步長
int horizontalStep = 2;
//遍歷整個初始圖,先摳出初始位置
for (int i = 0; i < height; i++) {
//水平位置的開始位置
int start = initStart + i * verticalStep;
//遍歷當前行
for (int j = 0; j < width; j++) {
//算出初始空格的位置,1是實體,0是空格
int blank = start + j * horizontalStep;
//初始化空格位置
matrix[blank] = 0;
}
}
//返回已經摳掉初始位置的矩陣
return matrix;
}
/**
* 根據位置號,找到方格號
* @param position 位置號
* @return 方格號,也就是對應matrix的下標
*/
private static int findMatrixByPosition(int position, int width, int height) {
//每行matrix的方格數
int lineNum = width * 2 + 1;
//返回計算好的方格下標
return (position / width * 2 + 1) * lineNum + (position % width) * 2 + 1;
}
/**
* 根據相鄰位置對,返回需要拆掉的牆的方格號
* @param positionPair 相鄰位置對的字符串格式
* @return 需要拆掉的牆的方格下標
*/
private static int getBreakWallNo(String positionPair, int width, int height) {
//較小位置
int minPosition = Integer.parseInt(positionPair.split("-")[0]);
//較大位置
int maxPosition = Integer.parseInt(positionPair.split("-")[1]);
//較小位置的方格下標
int minMatrix = findMatrixByPosition(minPosition, width, height);
//如果兩個位置相差1,則表示是左右相鄰
if (maxPosition - minPosition == 1) {
//返回較小位置方格號+1
return minMatrix + 1;
//如果兩個位置不相差1,則表示是上下相鄰
} else {
//返回較小位置方格號下面的方格下標
return minMatrix + width * 2 + 1;
}
}
/**
* 生成迷宮的一維矩陣數組(1是黑方塊,0是白方塊,也就是路)
* @param width 迷宮寬
* @param height 迷宮高
* @return 生成迷宮的一維矩陣數組
*/
private static String generateMazeArray(int width, int height) {
//根據寬高,初始化迷宮圖矩陣
int[] matrix = initWall(width, height);
//求出隨機生成的所有需要拆掉的牆
List<String> breakWall = breakWall(width, height);
//遍歷所有需要拆掉的牆
for (String posPair : breakWall) {
//算出需要拆掉的牆的方格號
int breakIndex = getBreakWallNo(posPair, width, height);
//將對應的位置的牆拆掉,變成路
matrix[breakIndex] = 0;
}
//生成左上角入口
matrix[1] = 0;
//生成右下角出口
matrix[matrix.length - 2] = 0;
//返回迷宮一維矩陣數組的字符串(後面作爲HTML文件的內容)
return Arrays.toString(matrix);
}
/**
* 生成迷宮圖的HTML文件,用瀏覽器打開則可以看到迷宮
* @param width 迷宮寬度
* @param height 迷宮高度
* @param filePath 生成的迷宮HTML文件的路徑
* @throws IOException
*/
private static void generateMazeHTML(int width, int height, String filePath) throws IOException {
//HTML文件的內容的StringBuffer
StringBuffer sbuf = new StringBuffer();
//加入必要的HTML內容
sbuf.append("<!doctype html>\n" +
"<html lang=\"en\">\n" +
"<head>\n" +
" <meta charset=\"UTF-8\">\n" +
" <title>LeeCode生成迷宮圖</title>\n" +
"</head>\n" +
"<body>\n" +
" <canvas id=\"canvas1\" width=\"2000\" height=\"2000\"></canvas>\n" +
" <script>\n" +
" var map={\n" +
"\t\t\t\"data\":");
//這裏加入生成的迷宮圖數組數據
sbuf.append(generateMazeArray(width, height)).append("\n");
//這裏加入迷宮矩陣的寬高
sbuf.append(",\"width\":").append(width * 2 + 1).append(",\n");
sbuf.append("\"height\":").append(height * 2 + 1).append("}\n");
//繼續加入必要的HTML內容
sbuf.append(" var canvas = document.getElementById(\"canvas1\");\n" +
" var ctx = canvas.getContext(\"2d\");\n" +
" var W = 10;var H = 10;var l = 0;var t = 0;\n" +
" for (var i=0; i<map.data.length; i++){ \n" +
" l = i%map.width*W;\n" +
" if (i%map.width==0&&i!=0){\n" +
" t+=H;\n" +
" }\n" +
" if (map.data[i]>0){\n" +
" ctx.fillRect(l, t, W, H);\n" +
" } \n" +
" }\n" +
" </script>\n" +
"</body>\n" +
"</html>");
//HTML文件類
File file = new File(filePath);
//如果HTML文件不存在,則創建新文件
if (!file.exists()) {
file.createNewFile();
}
//生成迷宮圖HTML文件的IO輸出流類
FileWriter fileWriter = new FileWriter(file);
BufferedWriter bufferedWriter = new BufferedWriter(fileWriter);
//將上面StringBuffer的HTML內容寫入HTML文件
bufferedWriter.write(sbuf.toString());
//衝完緩衝區
bufferedWriter.flush();
//關閉輸出流
bufferedWriter.close();
fileWriter.close();
//給出迷宮已生成的提示信息
System.out.println("迷宮已生成!");
}
/**
* 運行迷宮隨機生成算法,在桌面生成自定義寬高的HTML迷宮文件
* @param args
* @throws IOException
*/
public static void main(String[] args) throws IOException {
//自定義迷宮寬
int width = 24;
//自定義迷宮高
int height = 24;
//最終生成的迷宮文件的路徑
String filePath = "C:\\Users\\liyang\\Desktop\\maze_ " + System.currentTimeMillis() + ".html";
//運行迷宮隨機生成算法,在桌面生成HTML迷宮文件
generateMazeHTML(width, height, filePath);
}
}
大家可以在main方法中自定義迷宮的寬高和HTML文件的輸出路徑。我這裏是迷宮寬高都爲24,然後輸出路徑是桌面,文件名是 “maze_” 再加上當前時間戳。運行main方法後,控制檯輸出 “迷宮已生成”,代表迷宮生成代碼運行成功。在桌面找到迷宮的HTML文件,在瀏覽器裏面打開後,看到了生成的迷宮圖,如下所示:
大家有興趣可以走一下這個迷宮!這個難度有點低,我們把width改爲60,height改爲40,再運行main方法,再生成一個更大更難的迷宮:
怎麼樣,是不是有一種眼花繚亂的感覺?是不是很好玩?趕緊複製上面的隨機迷宮生成的算法代碼,到你自己的IDE裏面粘貼改參數運行吧!這個週末我們就走迷宮玩吧!O(∩_∩)O哈哈~