連連看棋盤上的兩點連線路徑,象徵演算法搜尋與消除判斷

← INSIGHTS & PERSPECTIVES | 後端開發

連連看演算法介紹:時間複雜度、圖形資料結構與 BFS/DFS 搜尋

從連連看遊戲設計角度介紹演算法、時間複雜度、空間複雜度、圖形資料結構,整理 BFS、DFS 如何用在兩點消除、最短路徑、兩次轉彎限制與僵局判斷,並把棋盤儲存、合法路徑與重新洗牌拆成可實作的後端邏輯,適合作為完整的遊戲搜尋演算法入門技術筆記。

連連看演算法要解決的問題,是把棋盤上的圖塊轉成電腦可以搜尋的資料結構,再判斷兩個相同圖形能不能在兩次轉彎以內連線消除。這篇筆記從演算法的基本定義開始,整理時間複雜度、空間複雜度、圖形資料結構、深度優先搜尋(Depth-first Search,DFS)與廣度優先搜尋(Breadth-first Search,BFS),最後回到連連看遊戲的路徑設計方向。

演算法是什麼?

演算法可以先理解成「輸入 + 處理規則 = 輸出」。在遊戲或後端邏輯裡,演算法的價值不只在算出答案,也在於答案是否能用可預期的時間與記憶體算出來。

演算法的簡單定義:

輸入 + 演算法 = 輸出

一般判斷演算法的好壞,會使用「時間複雜度」和「空間複雜度」評估。時間複雜度關心運算步驟會怎麼隨輸入變大,空間複雜度則關心程式完全執行時需要多少記憶體。

若想先看更完整的入門說明,可以參考 AppWorks School 的〈初學者學演算法:談什麼是演算法和時間複雜度〉。

時間複雜度怎麼判斷?

時間複雜度不是拿碼表量實際秒數,而是估算演算法會執行多少步驟。輸入資料越大,步驟增加越快的演算法,通常越需要小心效能。

所謂時間複雜度,指的是執行一段演算法、跑完整個運算邏輯所要花的時間。雖然這個複雜度名為「時間複雜度」,但真正衡量某個演算法時,通常是以步驟次數來看。

如果一個演算法執行的步驟是固定的,不會因為輸入值而改變,可以記成 `O(1)`:

```text

function(int n) {

print(n);

}

```

如果演算法會依照輸入 `n` 的數量跑 `n` 次,時間複雜度就是 `O(n)`:

```text

function(int n) {

for (i = 0; i < n; i++) {

print(i);

}

}

```

有時候跑的次數不是單純的 `n`。例如下面這個例子會跑 `n * (n - 1) = n^2 - n` 次,但 Big O 表示法會保留最高次方,並拿掉係數與較低次方,所以記成 `O(n^2)`:

```text

function(int n) {

for (i = 0; i < n; i++) {

for (j = 0; j < n - 1; j++) {

print(i * j);

}

}

}

```

常見記法步驟成長方式直覺例子
`O(1)`固定次數印出一個值
`O(n)`隨輸入線性增加掃過一排資料
`O(n^2)`雙層迴圈式增加比對每一組資料

空間複雜度怎麼判斷?

空間複雜度衡量程式執行時需要的記憶體量。判斷空間複雜度時,要看變數、陣列、遞迴呼叫堆疊是否會隨輸入規模變大。

一個程式的空間複雜度,是指完整執行程式所需的記憶體量。例如下面這個函式,不管程式跑了幾遍,都不會影響使用的變數數量,所以空間複雜度記做 `O(1)`:

```text

function(int n) {

for (int i = 0; i < n; i++) {

print(i);

}

}

```

但下面這個函式會隨著丟進去的數字而影響變數的量,因為 `c[n]` 需要跟著 `n` 變大,所以空間複雜度是 `O(n)`:

```text

function(int n) {

int c[n];

for (int i = 0; i < n; i++) {

c[i] = i;

}

}

```

遞迴的空間複雜度一般與最深的呼叫深度成正比。若遞迴中還有局部變數或參數,所需的空間複雜度會更高。

哪些問題是經典的演算法例子?

旅行推銷員問題與一筆畫問題都是經典演算法議題。兩個問題都能讓人看到:演算法不只是寫程式,而是先把真實問題抽象成資料、限制條件與搜尋目標。

人工智慧也是演算法的一種。著名的旅行推銷員問題(Travelling Salesman Problem,TSP)假定已知每個城市間的距離,要求在不重複訪問同一個城市的情況下,找出最快速的行程組合。旅行推銷員問題是電腦科學中著名的難解問題。

旅行推銷員問題的 nearest neighbor 路徑示意

Source:Saurabh.harsh,引用自 TechNews〈從演算法到人工智慧,淺談計算機的真正威力〉。

一筆畫問題也是很經典的演算法議題。一筆畫問題是柯尼斯堡七橋問題經抽象化後的推廣,也是圖遍歷問題的一種。

柯尼斯堡七橋問題的圖形抽象示意

Source:維基百科〈柯尼斯堡七橋問題〉。

在柯尼斯堡問題中,如果將橋所連接的地區視為點,將每座橋視為一條邊,問題會變成:對於一個有四個頂點和七條邊的連通圖 `G(S, E)`,能否找到一條恰好包含所有邊、且沒有重複的路徑?

歐拉將柯尼斯堡七橋問題推廣成:對於一個給定的圖,怎樣判斷是否存在一條恰好包含所有邊、且沒有重複的路徑?一筆畫問題的解答是:如果一個圖能一筆畫成,奇頂點的數目必須是 `0` 或 `2`。

連連看遊戲需要解決哪些演算法問題?

連連看遊戲至少要處理初始盤面、可消除判斷、最短路徑搜尋與僵局解除。這些問題背後都能轉成圖形資料結構與搜尋演算法。

《程式之美:微軟技術面試心得》裡給了許多經典演算法題目,例如構造數獨遊戲、一疊蔥油餅的排序、一排石頭的遊戲、俄羅斯方塊遊戲、踩地雷遊戲的機率、連連看遊戲。因為我小時候很喜歡玩 kawai 連連看,所以選擇連連看作為這次鐵人賽的題目。

若要設計一個連連看遊戲的演算法,可以從這幾個問題開始:

  • 如何用簡單的電腦模型描述連連看盤面?
  • 如何判斷兩個圖形能否相消?
  • 如何求出兩個相同圖形間的最短路徑,也就是轉彎數最少、路徑經過的格子數目最少?
  • 如何確定目前處於僵局狀態,並設計演算法解除僵局?

在經典最短路徑問題中,目標通常是求出經過格子數目最少的路徑。連連看還多了一個限制:為了確保轉彎次數最少,需要把最短路徑問題的目標函數改成「轉彎數優先」。雖然目標函數修改了,演算法框架仍然可以保持不變;廣度優先搜尋就是解決經典最短路徑問題的一個思考方向。

連連看的消除條件怎麼拆成程式邏輯?

連連看的消除條件可以拆成三件事:圖形相同、路徑沒有被阻擋、轉彎次數少於三次。只要其中一項不成立,兩個圖塊就不能消除。

根據上面的說明,若想要做一個連連看遊戲,需要先處理下列問題:

問題程式設計方向
產生遊戲初始局面用電腦資料格式儲存每一格圖形資料
判斷是否可相消使用者選擇兩個圖形後,確認兩個圖形相同,且兩者之間存在少於三個轉彎的路徑
判斷僵局搜尋全部現有圖案,判斷是否還有任何可消除組合
解除僵局當玩家無法再消除任意兩個圖像時,隨機打亂局面

因此,連連看至少會需要用到圖形(Graph)演算法,以及搜尋演算法,包含深度優先搜尋與廣度優先搜尋。

Graph 資料結構可以怎麼表示?

圖形資料結構由頂點與邊組成。程式實作時,常見表示法包含相鄰矩陣、相鄰串列與邊集合;選擇哪一種表示法,會影響記憶體使用量與搜尋效率。

一張圖由數個點(vertex)以及數條邊(edge)構成。點與點之間可以用邊連接,表示這兩點有關聯。若要用程式語言表示一張圖,常見方法如下:

表示法儲存方式優點注意事項
相鄰矩陣(Adjacency Matrix)將節點當作矩陣的行和列索引;若頂點間有連接,`array[i][j] = 1`,反之為 `0`查詢兩點是否相連很直觀遇到稀疏圖會浪費空間,頂點數量改變時較不彈性
相鄰串列(Adjacency List)每個點後方串連所有相鄰點比相鄰矩陣節省空間查詢任兩點是否相連時,需要掃描串列
邊集合(Edge List)用陣列記錄所有點與點之間的邊直觀且節省空間不適合頻繁查詢相鄰點
相鄰矩陣示意圖
相鄰串列示意圖
邊集合示意圖

上面幾種圖形表示法整理自〈演算法筆記:Graph〉。

搜尋演算法在圖裡扮演什麼角色?

搜尋演算法負責決定圖要從哪裡開始讀、依照什麼順序讀、讀到哪裡停止。連連看要找兩個圖塊之間是否存在合法路徑,本質上就是在棋盤圖裡做搜尋。

圖的遍歷,就是通盤讀取圖的資訊:決定好從哪裡開始讀,依照什麼順序讀,要讀到哪裡為止。流程設計得好,在解決圖的問題時,還可以一邊讀取圖的資訊,一邊順手解決問題。

利用最簡單的資料結構 `queue` 和 `stack`,就能製造不同的遍歷順序,得到兩種遍歷演算法:

  • Breadth-first Search(廣度優先搜尋,BFS):通常用 `queue` 處理,先走訪同一層的節點,再往下一層。
  • Depth-first Search(深度優先搜尋,DFS):通常用 `stack` 或遞迴處理,沿著一條路盡量往深處走,再回溯找其他路。

深度優先搜尋 DFS 怎麼運作?

深度優先搜尋會從起點一路往尚未拜訪的節點深入,直到不能再前進才回溯。深度優先搜尋適合遍歷整張圖,也常用遞迴或堆疊實作。

深度優先搜尋法是一種用來遍尋樹(tree)或圖(graph)的演算法。搜尋從樹的根,或圖中的某一點開始,先探尋邊上尚未搜尋的節點,並盡可能往深處搜尋;直到該節點的所有邊上節點都已探尋,就回溯到前一個節點,重複探尋尚未搜尋的節點,直到找到目的節點或遍尋全部節點。

深度優先搜尋屬於盲目搜索(uninformed search),常用堆疊(Stack)處理,也常以遞迴呈現。假設起始點為 `A`,且每一節點由左至右搜尋下個節點,結果會是:`A, B, E, F, D, C, G`。

DFS 深度優先搜尋走訪順序示意

廣度優先搜尋 BFS 怎麼運作?

廣度優先搜尋會先走訪起點周圍所有相鄰節點,再一層一層往外擴張。當每條邊權重相同時,廣度優先搜尋常用來找最短路徑。

廣度優先搜尋法是一種圖形(graph)搜索演算法。搜尋從圖的某一節點開始,接著走訪此節點所有相鄰且未拜訪過的節點,再由走訪過的節點繼續進行先廣後深的搜尋。以樹(tree)來說,就是先把同一深度(level)的節點走訪完,再繼續向下一個深度搜尋,直到找到目的節點或遍尋全部節點。

廣度優先搜尋屬於盲目搜索,常用佇列(Queue)處理,也常以迴圈呈現。假設起始點為 `A`,且每一節點由左至右搜尋下個節點,結果會是:`A, B, C, D, E, F, G`。

BFS 廣度優先搜尋走訪順序示意

連連看棋盤要怎麼儲存?

連連看棋盤可以用二維陣列儲存,每個索引位置代表一個格子。這種表示法容易判斷兩個點是否在同一列或同一行,也方便後續搜尋可連線路徑。

以連連看來說,因為棋盤裡任意兩個同一排或同一列的點都可以做直線相連,若用二維陣列直接儲存棋盤內容,就可以用該陣列第一個索引值或第二個索引值是否相同,判斷兩點是否在同一條線上。

若圖形為 `10 x 10` 的棋盤,陣列可以設計如下:

10 x 10 連連看棋盤的二維陣列表示

使用這樣的陣列儲存方式時,要注意上圖的 `x`、`y` 軸位置,和一般做遊戲時的 `x`、`y` 軸方向剛好相反。計算點與點之間的位置時,如果座標方向沒先統一,很容易在路徑判斷時出錯。

路徑則可以使用圖演算法中的關聯矩陣概念記錄。例如 `[[0, 0], [-1, 0], [-1, 8], [0, 8]]` 表示一條由多個轉折點構成的路徑。在連連看遊戲裡,每一個合法路徑都應由四個或以內的點組成,且每兩個相鄰點之間必定要有相同的 `x` 或相同的 `y`。

連連看路線搜尋為什麼適合用 BFS 思考?

連連看路線搜尋適合用 BFS 思考,因為 BFS 會由近到遠擴張路徑。只要把搜尋成本改成「轉彎數優先,再看格子數」,就能貼近連連看的消除規則。

在一般最短路徑搜尋中,常會以廣度優先搜尋為主。連連看的特殊限制是:連線的線條不可大於兩個轉彎處。兩個轉彎處代表連接兩個圖塊的線,最多只能由三條直線組成。

連連看兩點連線與轉彎限制示意

可以把直線當成搜尋單元,先搜尋 `A` 點上、下、左、右最遠能到達的點,再對比 `B` 點上、下、左、右最遠可以到達的點,判斷是否有可能形成一條連線。若有可能,再繼續尋找中間是否存在可能的第二條線。

這裡的實作判斷可以濃縮成三層:

  1. 先判斷 `A` 到 `B` 是否能直線相連。
  2. 若不能直線相連,判斷是否存在一個轉折點,讓 `A` 到轉折點、轉折點到 `B` 都可直線通過。
  3. 若一次轉折也不成立,再判斷是否存在兩個轉折點,讓路徑由三段直線組成。

延伸閱讀

常見問題

Q演算法和資料結構有什麼關係?

演算法是解決問題的步驟,資料結構是資料在程式裡的存放方式。連連看若用二維陣列存棋盤,路徑搜尋就能直接利用列與行的索引判斷直線是否可通過。

Q連連看為什麼需要圖形演算法?

連連看棋盤上的格子可以視為頂點,格子之間可通過的直線可以視為邊。判斷兩個圖塊能否消除,其實就是判斷兩個頂點之間是否存在符合轉彎限制的路徑。

Q連連看消除一定要用 BFS 嗎?

連連看消除不一定只能用 BFS。BFS 適合拿來思考最短路徑與逐層擴張,但實作時也可以針對直線、一次轉折、兩次轉折分別寫判斷函式。

QDFS 和 BFS 在連連看裡差在哪?

DFS 會沿著一條可能路徑一路深入,適合完整遍歷或回溯搜尋。BFS 會先檢查距離較近或層級較淺的候選路徑,比較貼近「先找最少轉彎數」的需求。

Q連連看的僵局要怎麼判斷?

連連看的僵局可以用全盤搜尋判斷:掃過目前棋盤上的圖塊,找出每一對相同圖形,逐一檢查是否存在合法路徑。若沒有任何一組可以消除,就代表進入僵局,可以重新洗牌打亂盤面。

參考資料

最後更新

2018-10-17(本文保留 2018 年連連看演算法筆記內容,並補上 GEO 結構、FAQ 與站內延伸閱讀。)

關於作者 {#author}

Claire Chang | 企業 AI 導入與流程轉型顧問。專注於 AI Agent 架構設計、ERP 系統整合與企業 AI 治理。

首次發布:2018-10-17