連連看曾經(jīng)是一款非常受歡迎的游戲,同時(shí)它也是一款比較古老的游戲??吹竭@里你千萬(wàn)不要認為本篇文章打算討論《連連看》的歷史以及它取得的豐功偉績(jì)。恰恰相反,在這篇文章中我們打算討論該游戲背后的實(shí)現思想,包括它定義的游戲規則,以及游戲的實(shí)現算法。作為應用,我們還將利用Java代碼實(shí)現一個(gè)通用的《連連看》算法,并使用Java Swing框架創(chuàng )建一個(gè)演示實(shí)例。
1《連連看》的游戲規則是如何定義的?
連連看的游戲界面和游戲規則都非常簡(jiǎn)單。游戲界面可以簡(jiǎn)單看作一個(gè)具有M×N個(gè)單元格的棋盤(pán),每個(gè)單元格內部顯示著(zhù)各種圖片,游戲的最終目的是消除所有圖片。但是在消除的過(guò)程中,我們需要遵守以下規則:
直觀(guān)感受,第一條和第二條規則不應該是算法完成的任務(wù),因為這兩條規則實(shí)現起來(lái)比較簡(jiǎn)單,應該盡量放在游戲邏輯中完成,避免算法與游戲邏輯產(chǎn)生強依賴(lài)關(guān)系。實(shí)現第三條和第四條規則有一個(gè)非常經(jīng)典的算法理論,該算法就是接下來(lái)我們要講的分類(lèi)搜索算法。
2 分類(lèi)搜索算法的原理
分類(lèi)搜索算法的基本原理是一種遞歸思想。假設我們要判斷A單元與B單元格是否可以通過(guò)一條具有N個(gè)拐點(diǎn)的路徑相連,該問(wèn)題可以轉化為能否找到一個(gè)C單元格,C與A可以直線(xiàn)連接(0折連接),且C與B可以通過(guò)一條具有N-1個(gè)拐點(diǎn)的路徑連接。下面截圖解釋了這一思想。圖中,白色和淺灰色的單元格表示沒(méi)有內容,可以連通??梢园l(fā)現,A與B連接必須經(jīng)過(guò)①②③④⑤⑥個(gè)拐點(diǎn)。假設我們找到了一個(gè)可以直接與A連接的C點(diǎn),那么只需要搜索C與B連接需要經(jīng)過(guò)的②③④⑤⑥個(gè)拐點(diǎn)即可。
基于連連看要求的拐點(diǎn)數不能超過(guò)2個(gè)的規則,我們可以將上述思想簡(jiǎn)化為三種情況。
1)0折連接
0折連接表示A與B的X坐標或Y坐標相等,可以直線(xiàn)連接,不需要任何拐點(diǎn),且連通的路徑上沒(méi)有任何阻礙,具體可以分為下面兩種情況。
2)1折連接
1折連接與0折連接恰好相反,要求A單元格與B單元格的X軸坐標與Y軸坐標都不能相等。此時(shí)通過(guò)A與B可以畫(huà)出一個(gè)矩形,而A與B位于矩形的對角點(diǎn)上。判斷A與B能否一折連接只需要判斷矩形的另外兩個(gè)對角點(diǎn)是否有一個(gè)能同時(shí)與A和B滿(mǎn)足0折連接。下面截圖說(shuō)明了1折連通的原理:3)2折連接
根據遞歸的思想,判斷A單元格與B單元格能否經(jīng)過(guò)兩個(gè)拐點(diǎn)連接,可以轉化為判斷能否找到一個(gè)C單元格,該C單元格可以與A單元格0折連接,且C與B可以1折連接。若能找到這樣一個(gè)C單元格,那么A與B就可以2折連接,下面截圖解釋了2折連接的情況:
判斷A單元格和B單元格是否可以2折連接時(shí)需要完成水平和豎直方向上的掃描。觀(guān)察下面兩幅截圖,A與B單元格的連接屬于典型的2折連接,首先我們需要找到圖中的C單元格,然后判斷C與B單元格是否可以1折連接。在搜索C單元格時(shí)我們必須從A單元格開(kāi)始,分別向右、向左掃描,尋找同時(shí)可以滿(mǎn)足與A單元格0折連接,與B單元格1折連接的C單元格。
同樣,如果A與B單元格的位置關(guān)系是下面兩幅截圖展示的那樣。那么我們就需要在垂直方向完成向上、向下搜索,找到符合要求的C單元格。

3 如何實(shí)現通用的分類(lèi)搜索算法
前面多次強調,我們需要實(shí)現一個(gè)通用的分類(lèi)搜索算法。通用意味著(zhù)算法與具體的實(shí)現分離。上面介紹的分類(lèi)搜索算法建立在一個(gè)二維數組的前提下,但是我們應該使用何種類(lèi)型的二維數組呢?為了滿(mǎn)足上述要求,我們應該定義一個(gè)所有希望使用該算法的應用都應該實(shí)現的一個(gè)接口,然后在算法中使用該接口類(lèi)型的二維數組。
那么該接口應該包含些什么方法呢?根據上面對算法的分析,分類(lèi)搜索算法唯一需要判斷的就是每個(gè)單元格的連通性,即單元格是否已經(jīng)填充。理解了這些內容,下面我們創(chuàng )建該接口。
- public interface LinkInterface {
- public boolean isEmpty();
- public void setEmpty();
- public void setNonEmpty();
- }
上面我們將該接口起名為L(cháng)inkInterface,并且聲明了三個(gè)方法,分別用于設置或判斷單元格的連通性。
為了保證算法的獨立性,我們還應該創(chuàng )建一個(gè)用于表示單元格位置的Point類(lèi):
- public class Point {
- public int x;
- public int y;
- public Point(){}
- public Point(int x, int y) {
- this.x = x;
- this.y = y;
- }
- }
1)0折連通算法
接下來(lái)我們來(lái)實(shí)現0折連通的算法。首先我們需要聲明一個(gè)類(lèi),這里我們將該類(lèi)聲明為L(cháng)inkSerach。下面我們需要思考0折連通需要些什么參數,以及返回值應該是什么?首先,我們必須傳遞一個(gè)實(shí)現了LinkInterface接口的類(lèi)的數組對象。其次我們還必須傳遞A和B的位置坐標。搜索算法的一個(gè)重要功能就是返回搜索的路徑。對于0折連接,即使搜索到可用路徑,我們也不用返回任何路徑,因為整個(gè)連通路徑就是A和B點(diǎn)的連線(xiàn)。但是我們必須返回一個(gè)可以表明搜索是否成功的boolean類(lèi)型值。接下來(lái)創(chuàng )建該方法:
- public class LinkSearch {
- private static boolean MatchBolck(LinkInterface[][] datas,
- final Point srcPt, final Point destPt) {
- // 如果不屬于0折連接則返回false
- if(srcPt.x != destPt.x && srcPt.y != destPt.y)
- return false;
- int min, max;
- // 如果兩點(diǎn)的x坐標相等,則在水平方向上掃描
- if(srcPt.x == destPt.x) {
- min = srcPt.y < destPt.y ? srcPt.y : destPt.y;
- max = srcPt.y > destPt.y ? srcPt.y : destPt.y;
- for(min++; min < max; min++) {
- if(!datas[srcPt.x][min].isEmpty())
- return false;
- }
- }
- // 如果兩點(diǎn)的y坐標相等,則在豎直方向上掃描
- else {
- min = srcPt.x < destPt.x ? srcPt.x : destPt.x;
- max = srcPt.x > destPt.x ? srcPt.x : destPt.x;
- for(min++; min < max; min++) {
- if(!datas[min][srcPt.y].isEmpty())
- return false;
- }
- }
- return true;
- }
- }
0折連通算法的核心思想是根據A、B單元格的相對位置將掃描過(guò)程分解為水平和豎直兩個(gè)方向。
2)1折連接
1折連接算法與0折連接算法輸入參數相同,但是1折連接算法應該返回搜索路徑。那么應該如何處理呢?由于1折連接算法最多只有1個(gè)拐點(diǎn),而整個(gè)路徑就是兩個(gè)搜索單元格的位置加上該拐點(diǎn)的位置,需要搜索的單元格位置用戶(hù)已經(jīng)知道,因此我們只需要返回拐點(diǎn)的位置即可,當沒(méi)有搜索到連接路徑時(shí)可以返回null值,下面是1折連接的搜索算法:
- private static Point MatchBolckOne(LinkInterface[][] datas,
- final Point srcPt, final Point destPt) {
- // 如果不屬于1折連接則返回null
- if(srcPt.x == destPt.x || srcPt.y == destPt.y)
- return null;
- // 測試對角點(diǎn)1
- Point pt = new Point(srcPt.x,destPt.y);
- if(datas[pt.x][pt.y].isEmpty()) {
- boolean stMatch = MatchBolck(datas, srcPt, pt);
- boolean tdMatch = stMatch ?
- MatchBolck(datas, pt, destPt) : stMatch;
- if (stMatch && tdMatch) {
- return pt;
- }
- }
- // 測試對角點(diǎn)2
- pt = new Point(destPt.x, srcPt.y);
- if(datas[pt.x][pt.y].isEmpty()) {
- boolean stMatch = MatchBolck(datas, srcPt, pt);
- boolean tdMatch = stMatch ?
- MatchBolck(datas, pt, destPt) : stMatch;
- if (stMatch && tdMatch) {
- return pt;
- }
- }
- return null;
- }
3)2折連接
可以發(fā)現,0折算法和1折算法都是獨立,如果是1折連接的情況,我們是不能使用0折算法進(jìn)行判斷的,同樣,屬于0折的情況,我們也是不能使用1折算法進(jìn)行判斷的。為了建立一種通用的方法,我們必須在2折連接算法里包含上述兩種算法的判斷,這也是為什么我們將上述兩個(gè)方法聲明為private的原因,因為我們只需要為用戶(hù)暴露2折算法的方法即可。還有,2折連接需要返回一個(gè)包含兩個(gè)拐點(diǎn)的路徑,此處我們使用Java基礎庫的LinkedList來(lái)實(shí)現,具體代碼如下:
- public static List<Point> MatchBolckTwo(LinkInterface[][] datas,
- final Point srcPt, final Point destPt) {
- if(datas == null || datas.length == 0)
- return null;
- if(srcPt.x < 0 || srcPt.x > datas.length)
- return null;
- if(srcPt.y < 0 || srcPt.y > datas[0].length)
- return null;
- if(destPt.x < 0 || destPt.x > datas.length)
- return null;
- if(destPt.y < 0 || destPt.y > datas[0].length)
- return null;
- // 判斷0折連接
- if(MatchBolck(datas, srcPt, destPt)) {
- return new LinkedList<>();
- }
- List<Point> list = new LinkedList<Point>();
- Point point;
- // 判斷1折連接
- if((point = MatchBolckOne(datas, srcPt, destPt)) != null) {
- list.add(point);
- return list;
- }
- // 判斷2折連接
- int i;
- for(i = srcPt.y + 1; i < datas[srcPt.x].length; i++) {
- if(datas[srcPt.x][i].isEmpty()) {
- Point src = new Point(srcPt.x, i);
- Point dest = MatchBolckOne(datas, src, destPt);
- if(dest != null) {
- list.add(src);
- list.add(dest);
- return list;
- }
- } else break;
- }
- for(i = srcPt.y - 1; i > -1; i--) {
- if(datas[srcPt.x][i].isEmpty()) {
- Point src = new Point(srcPt.x, i);
- Point dest = MatchBolckOne(datas, src, destPt);
- if(dest != null) {
- list.add(src);
- list.add(dest);
- return list;
- }
- } else break;
- }
- for(i = srcPt.x + 1; i < datas.length; i++) {
- if(datas[i][srcPt.y].isEmpty()) {
- Point src = new Point(i, srcPt.y);
- Point dest = MatchBolckOne(datas, src, destPt);
- if(dest != null) {
- list.add(src);
- list.add(dest);
- return list;
- }
- } else break;
- }
- for(i = srcPt.x - 1; i > -1; i--) {
- if(datas[i][srcPt.y].isEmpty()) {
- Point src = new Point(i, srcPt.y);
- Point dest = MatchBolckOne(datas, src, destPt);
- if(dest != null) {
- list.add(src);
- list.add(dest);
- return list;
- }
- } else break;
- }
- return null;
- }

- public class LinkItem extends JComponent implements LinkInterface {
- private static LinkItem selectedItem;
- private static LinkItem targetItem;
- private int rowId = -1;
- private int colId = -1;
- private boolean empty = true;
- private Image image;
- private Stroke defaultStroke;
- public LinkItem() {
- setLayout(new FlowLayout());
- defaultStroke = new BasicStroke(2, BasicStroke.CAP_BUTT,
- BasicStroke.JOIN_ROUND, 1f);
- }
- @Override
- protected void paintComponent(Graphics g) {
- Graphics2D g2 = (Graphics2D)g;
- int width = getWidth();
- int height = getHeight();
- // 激活時(shí)才填充并顯示內容
- if(!empty && image != null) {
- g2.drawImage(image.getScaledInstance(width - 8, height - 8,
- Image.SCALE_SMOOTH), 4, 4, null);
- }
- // 繪制邊框的顏色
- if(selectedItem == this) {
- g2.setColor(Color.RED);
- g2.setStroke(defaultStroke);
- }
- else if(targetItem == this) {
- g2.setColor(Color.ORANGE);
- g2.setStroke(defaultStroke);
- } else {
- g2.setColor(Color.PINK);
- }
- g2.drawRect(1, 1, width - 2, height - 2);
- }
- public static LinkItem getSelectedItem() {
- return selectedItem;
- }
- public static void setSelectedItem(LinkItem selectedComponent) {
- LinkItem.selectedItem = selectedComponent;
- }
- public static LinkItem getTargetItem() {
- return targetItem;
- }
- public static void setTargetItem(LinkItem targetComponent) {
- LinkItem.targetItem = targetComponent;
- }
- @Override
- public boolean equals(Object obj) {
- if(!(obj instanceof LinkItem))return false;
- else {
- LinkItem item = (LinkItem)obj;
- if(image == null || item.getImage() == null)
- return false;
- return (this.image == item.image);
- }
- }
- public void setRow(int row) {
- this.rowId = row;
- }
- public void setCol(int col) {
- this.colId = col;
- }
- public int getRow() {
- return rowId;
- }
- public int getCol() {
- return colId;
- }
- public Image getImage() {
- return image;
- }
- public void setImage(Image image) {
- this.image = image;
- }
- @Override
- public boolean isEmpty() {
- return empty;
- }
- @Override
- public void setEmpty() {
- empty = true;
- }
- @Override
- public void setNonEmpty() {
- empty = false;
- }
- }
- public class LinkGame {
- public static void main(String[] args) {
- EventQueue.invokeLater(new Runnable() {
- @Override
- public void run() {
- // 創(chuàng )建并啟動(dòng)框架
- JFrame frame = new LinkFrame();
- frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
- frame.setVisible(true);
- }
- });
- }
- }
- class LinkFrame extends JFrame {
- private static final long serialVersionUID = 1L;
- private static final int DEFAULT_WIDTH = 500;
- private static final int DEFAULT_HEIGHT = 500;
- // 棋盤(pán)格數 (rows * cols) % 2必須等于0
- private static final int rows = 8;
- private static final int cols = 8;
- // 所有單元格
- private final LinkItem[][] items;
- // 棋子可以選的顯示內容圖片
- private static Image[] optImgs;
- private static int optCount = 7;
- // 選中對象的位置
- private int selRow = -1;
- private int selCol = -1;
- // 是否已經(jīng)選中一個(gè)對象
- private boolean isSelected;
- // 結果路徑
- private List<Point> pathList;
- // 窗口邊框和標題欄的尺寸
- private Insets insets;
- // 繪制路徑時(shí)使用的默認線(xiàn)性
- private Stroke defaultStroke;
- public LinkFrame() {
- setTitle("LinkGame");
- // 設置為網(wǎng)格布局管理器
- setSize(DEFAULT_WIDTH, DEFAULT_HEIGHT);
- setLayout(new GridLayout(rows, cols));
- defaultStroke = new
- BasicStroke(5, BasicStroke.CAP_ROUND,
- BasicStroke.JOIN_BEVEL, 1f);
- // 初始沒(méi)有選中對象
- isSelected = false;
- // 為Item創(chuàng )建鼠標事件處理器
- MouseHandler handler = new MouseHandler();
- // 加載圖片
- optImgs = new Image[optCount];
- for(int i = 0; i < optImgs.length; i++) {
- String path = "assets/images/"+ (i + 1) + ".png";
- File file;
- try {
- file = new File(path);
- optImgs[i] = ImageIO.read(file);
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- // 創(chuàng )建棋盤(pán)并初始化
- items = new LinkItem[rows][cols];
- LinkItem comp;
- for(int i = 0; i < items.length; i++) {
- for(int j = 0; j < items[i].length; j++) {
- comp = items[i][j] = new LinkItem();
- comp.addMouseListener(handler);
- comp.setImage(optImgs[(int)(Math.random() * optImgs.length)]);
- <span style="white-space:pre">comp.setNonEmpty();</span>
- comp.setRow(i);
- comp.setCol(j);
- add(comp);
- }
- }
- }
- @Override
- public void paint(Graphics g) {
- super.paint(g);
- Graphics2D g2 = (Graphics2D)g;
- // 抗鋸齒
- g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
- RenderingHints.VALUE_ANTIALIAS_ON);
- // 更新窗口邊框尺寸
- insets = getInsets();
- // 設置線(xiàn)性和顏色
- g2.setStroke(defaultStroke);
- g2.setColor(Color.CYAN);
- // 如果存在路徑則繪制
- if(pathList != null) {
- Point pre = pathList.get(0); // 前一點(diǎn)
- for(int i = 1; i < pathList.size(); i++) {
- Point next = pathList.get(i); // 下一點(diǎn)
- // 獲得兩點(diǎn)對應的對象
- LinkItem a = items[pre.x][pre.y];
- LinkItem b = items[next.x][next.y];
- int x1 = insets.left + a.getX() + a.getWidth() / 2;
- int x2 = insets.left + b.getX() + b.getWidth() / 2;
- int y1 = insets.top + a.getY() + a.getHeight() / 2;
- int y2 = insets.top + b.getY() + b.getHeight() / 2;
- g2.drawLine(x1, y1, x2, y2);
- // 在最后一個(gè)點(diǎn)處填充一個(gè)圓
- if(i == pathList.size() - 1) {
- g2.draw(new Ellipse2D.Float(x2 - 2, y2 - 2, 4, 4));
- }
- pre = next;
- }
- }
- }
- private class MouseHandler extends MouseAdapter {
- @Override
- public void mouseReleased(MouseEvent e) {
- LinkItem curComp = (LinkItem) e.getSource();
- // 刷新邊框
- curComp.repaint();
- if(!isSelected) {
- // 設置選中對象并取消目標對象
- LinkItem.setSelectedItem(curComp);
- LinkItem.setTargetItem(null);
- selRow = curComp.getRow();
- selCol = curComp.getCol();
- } else {
- // 設置目標對象并取消選中對象
- LinkItem.setSelectedItem(null);
- LinkItem.setTargetItem(curComp);
- // 判斷是否可以連接
- LinkItem srcComp = items[selRow][selCol];
- if(curComp.equals(srcComp) && curComp != srcComp
- && !curComp.isEmpty() && !srcComp.isEmpty()) {
- Point srcPt = new Point(selRow, selCol);
- Point destPt = new Point(curComp.getRow(), curComp.getCol());
- // 搜索路徑
- pathList = LinkSearch.MatchBolckTwo(items, srcPt, destPt);
- // 如果存在鏈接路徑則消除單元格內容
- // 并為搜索路徑添加起止單元格
- if(pathList != null) {
- srcComp.setEmpty();
- curComp.setEmpty();
- srcComp.repaint();
- curComp.repaint();
- pathList.add(0, srcPt);
- pathList.add(destPt);
- LinkFrame.this.repaint();
- }
- }
- }
- // 轉換選中狀態(tài)
- isSelected = !isSelected;
- }
- }
- }
關(guān)于上述代碼,沒(méi)有什么難點(diǎn),著(zhù)重觀(guān)察一下paint()方法和MouseHander內部類(lèi)的處理邏輯。要讓上面代碼運行,你還必須創(chuàng )建一個(gè)assets文件夾,并將上述七張圖片資源分別命名為1.png、2.png...7.png,然后將其拷貝到assets下的images文件夾。

- // 創(chuàng )建棋盤(pán)并初始化
- items = new LinkItem[rows][cols];
- LinkItem comp;
- for(int i = 0; i < items.length; i++) {
- for(int j = 0; j < items[i].length; j++) {
- comp = items[i][j] = new LinkItem();
- comp.addMouseListener(handler);
- comp.setImage(optImgs[(int)(Math.random() * optImgs.length)]);
- comp.setNonEmpty();
- comp.setRow(i);
- comp.setCol(j);
- add(comp);
- }
- }
上述代碼為每個(gè)單元格隨機分配了一張圖片。試想,上述這種方法如何保證每種圖片都出現了偶數次?比如說(shuō)當游戲進(jìn)行到最后出現下面情況該怎么辦:
- public interface LinkInterface<T> {
- public boolean isEmpty();
- public void setEmpty();
- public void setNonEmpty();
- public T getContent();
- public void setContent(T content);
- }
上述代碼T表示需要填充的內容數據類(lèi)型。我們還添加了兩個(gè)用于獲取或設置內容的方法。- public class LinkItem extends JComponent implements LinkInterface<Image> {
- private boolean empty = true;
- private Image image;
- ...
- @Override
- public boolean isEmpty() {
- return empty;
- }
- @Override
- public void setEmpty() {
- empty = true;
- }
- @Override
- public void setNonEmpty() {
- empty = false;
- }
- @Override
- public Image getContent() {
- return image;
- }
- @Override
- public void setContent(Image content) {
- this.image = content;
- }
- }
刪除getImage和setImage方法,實(shí)現getContent和setContent方法,將泛型T設置為Image。- public class LinkSearch {
- private static <T> boolean MatchBolck(LinkInterface<T>[][] datas,
- final Point srcPt, final Point destPt) {
- ...
- }
- private static <T> Point MatchBolckOne(LinkInterface<T>[][] datas,
- final Point srcPt, final Point destPt) {
- ...
- }
- public static <T> List<Point> MatchBolckTwo(LinkInterface<T>[][] datas,
- final Point srcPt, final Point destPt) {
- ...
- }
- public static <T> void generateBoard(LinkInterface<T>[][] datas, T[] optConts) {
- List<Point> list = new LinkedList<>();
- for(int i = 0; i < datas.length; i++) {
- for(int j = 0; j < datas[i].length; j++) {
- list.add(new Point(i, j));
- }
- }
- while (list.size() != 0) {
- Point srcPt = list.remove((int)(Math.random() * list.size()));
- Point destPt = list.remove((int)(Math.random() * list.size()));
- LinkInterface<T> src = datas[srcPt.x][srcPt.y];
- LinkInterface<T> dest = datas[destPt.x][destPt.y];
- src.setNonEmpty();
- dest.setNonEmpty();
- T t = optConts[(int)(Math.random() * optConts.length)];
- src.setContent(t);
- dest.setContent(t);
- }
- }
- }
- // 創(chuàng )建棋盤(pán)并初始化
- items = new LinkItem[rows][cols];
- LinkItem comp;
- for(int i = 0; i < items.length; i++) {
- for(int j = 0; j < items[i].length; j++) {
- comp = items[i][j] = new LinkItem();
- comp.addMouseListener(handler);
- comp.setRow(i);
- comp.setCol(j);
- add(comp);
- }
- }
- LinkSearch.generateBoard(items, optImgs);
- }
將棋盤(pán)修改為4*4格,運行并測試游戲:



聯(lián)系客服