# 15-Puzzle Solver 由於在過去課程曾經學到如何使用A\*演算法來尋找8-Puzzle的最佳解,因此在機緣巧合下決定將原有的程式升級,改成能夠解15-Puzzle的程式。 以下將使用C++作爲範例,完整程式碼可以在 https://github.com/lemtea8/15puzzle 看到。 ## 15-Puzzle ![15 Puzzle](https://upload.wikimedia.org/wikipedia/commons/thumb/9/91/15-puzzle.svg/330px-15-puzzle.svg.png) *15-Puzzle的範例 (圖片來源: 維基百科)* ## Puzzle Implementation 首先需要一個負責對Puzzle進行移動等等功能的class,15-Puzzle的輸入是一個16個字元的16進位字串,例如: 2348170c56ab9def(空格視爲0)。 Puzzle狀態的儲存可以使用一個字元陣列 `char arr[16];` 但是由於15-Puzzle中每個方格的數值皆小於16,因此可以只使用4個bits來表達每個方格,相比之下char使用了1個byte,有一半的空間都浪費了。 16個方格總共只需要16x4=64個bits,因此剛好可以用一個uint64來儲存。 這個技巧叫做bit board,使用bit board有諸多好處,主要是能夠節省記憶體來加速程式。但缺點是bitwise的操作比較不好理解。 以下使用bit board的技巧來建構Puzzle的功能 先定義Puzzle這個class: ```cpp class Puzzle { public: Puzzle(); Puzzle(const std::string &input); Puzzle(uint64_t bits); private: uint64_t bits; }; ``` 建構子(爲了實作方便,方格儲存的順序是從低位元到高位元): ```cpp // "0" stands for the blank Puzzle::Puzzle(const std::string &input) { this->bits = 0; for (int i = 0; i < input.size(); i++) { char c = input[i]; uint64_t num = hex_to_int(c); // each cell takes four bits (0~F) this->bits |= num << (i * 4); } } ``` 讀取某個特定位置的數值(使用mask將uint64中特定位置的4個bit讀取出來): ```cpp uint8_t Puzzle::at(int n) { uint64_t mask = uint64_t(0b1111) << (n * 4); return (this->bits & mask) >> (n * 4); } ``` 設定某個特定位置的數值(先將該位置重設爲0000,再使用"or"將新的數值存入): ```cpp void Puzzle::set(int n, uint8_t val) { uint64_t mask = ~(uint64_t(0b1111) << (n * 4)); this->bits &= mask; // set the target area to zero uint64_t valmask = uint64_t(val) << (n * 4); this->bits |= valmask; } ``` 檢查目前是否是目標狀態(goal): ```cpp bool Puzzle::is_goal() { return this->bits == uint64_t(0x0FEDCBA987654321); } ``` 接下來要實作移動方格的功能,爲了要移動方格首先要先確定0在puzzle中的哪個位置,因此先實作尋找0位置的funcition: ```cpp int Puzzle::find_zero_position() { for (int i = 0; i < 16; i++) { if (this->at(i) == 0) { return i; } } return -1; } ``` 互換兩個位置的值(15-Puzzle中,移動一次就相當於將該方格與0互換位置): ```cpp void Puzzle::swap(int i, int j) { uint64_t tmp = this->at(i); this->set(i, this->at(j)); this->set(j, tmp); } ``` 實作移動的功能,向上移動表示0下面的方格往上移動(如果已經在最邊邊因此不能再移動了,則回傳false表示無法移動): ```cpp // returns false if the move is invalid bool Puzzle::move(Direction dir) { int p0 = this->find_zero_position(); int new_p0; switch (dir) { case UP: new_p0 = p0 + 4; if (new_p0 >= 16) { return false; } break; case DOWN: new_p0 = p0 - 4; if (new_p0 < 0) { return false; } break; case LEFT: new_p0 = p0 + 1; // not movable if zero is at the right most column if ((new_p0 >= 16) || (new_p0 % 4 == 0)) { return false; } break; case RIGHT: new_p0 = p0 - 1; // not movable if zero is at the left most column if ((new_p0 < 0) || ((new_p0 + 1) % 4 == 0)) { return false; } break; default: return false; } this->swap(p0, new_p0); return true; } ``` ## Algorithms ### Solvability 首先,需要先確認輸入的15-Puzzle是有解的(https://en.wikipedia.org/wiki/15_puzzle#Solvability),實作solvable function: ```cpp bool Puzzle::solvable() { int inversion_count = 0; for (uint32_t i = 0; i < 15; i++) { if (at(i) == 0) { continue; } for (uint32_t j = i + 1; j < 16; j++) { if (at(j) != 0 && at(i) > at(j)) { inversion_count++; } } } int zero_row = this->zero_position / 4; return ((zero_row + inversion_count) % 2 != 0); } ``` ### BFS(Breadth-first search) 爲了找出最佳解,第一個可能的方法就是使用BFS來搜尋,但是由於每步會有2~4種選擇性(角落的有2種,邊邊的有3種,中間的有4種),單純使用BFS會導致狀態數量快速增長直到記憶體無法負荷爲止(平均在第17步時就需要儲存超過1億個狀態,而最難的15-Puzzle需要80步才能解開)。 ### A* 爲了解決BFS的不足,A\*引入了一個heuristic function(啟發函數),這個函數可以用來評估當前狀態距離終點還需要多少成本,如果成本是0則表示已經抵達終點。 首先我們需要一個priority queue來儲存目前訪問過的狀態,並根據他們的優先順序來尋找下一個應該訪問的狀態。 這個優先順序表示爲 $f(n)$ ,其值越小越好,而 $f(n)=g(n)+h(n)$ ,其中$g(n)$表示從起始點到目前狀態所花費的成本,$h(n)$則表示heuristic function計算出的值。 ### Heuristic 我們使用Manhattan distance作爲我們的heuristic function,他的計算方式是$h(n)=|x_n - x_g|+|y_n-y_g|$,其中$(x_n, y_n)$表示此方格現在的位置,$(x_g, y_g)$表示方格的終點位置。 將所有方格的值相加,就可以得到heuristic function的值。 *並非所有heuristic都可以找到最佳解,要找到最佳解需要滿足一定的條件( https://en.wikipedia.org/wiki/Admissible_heuristic ) 在Puzzle中實作計算heuristic的function: ```cpp int manhattan_distance(int i, int j) { int ix = i % 4, iy = i / 4; int jx = j % 4, jy = j / 4; return abs(ix - jx) + abs(iy - jy); } int Puzzle::heuristic() { int val = 0; for (int i = 0; i < 16; i++) { uint8_t c = this->at(i); // does not include 0 if (c != 0) { val += manhattan_distance(c - 1, i); } } return val; } ``` ## Solver Solver中包含兩個關鍵元件: 一個是用來表示當前狀態的class,另一個是priority queue。 ### State State中需要保存: 1. 當前的Puzzle,以用來往下一個狀態走。 2. 目前走了多遠,也就是$g(n)$。這對找出最佳解至關重要。 3. 當前Puzzle的heuristic。 4. 上一個State的指標,能夠在抵達終點後回溯到起始狀態,找出最佳解的路徑。 5. 上一個State到目前這個State是採取了什麼動作,用途同4. ```cpp class State { public: State(State *parent, uint64_t bits, int walked, int heuristic, Direction dir) { this->parent = parent; this->bits = bits; this->walked = walked; this->heuristic = heuristic; this->dir = dir; } uint64_t get_bits() { return this->bits; } int heuristic; int walked; State *parent; Direction dir; private: uint64_t bits; }; bool state_compare(State *i, State *j) { return i->walked + i->heuristic < j->walked + j->heuristic; } ``` ### Priority Queue 使用binary heap實作一個簡單的priority queue(想要更好的效能可以使用fibonacci heap): ```cpp template <typename T> class PriorityQueue { public: PriorityQueue(bool (*compare)(T i, T j)) { container = std::vector<T>(1); this->compare = compare; } PriorityQueue(bool (*compare)(T i, T j), int capacity) { container = std::vector<T>(1); container.reserve(capacity); this->compare = compare; } bool empty() { return container.size() == 1; } void push(T t) { container.push_back(t); heapify_bottomup(container.size() - 1); } void clear() { container.clear(); } T top() { return container[1]; } void pop() { if (empty()) { return; } container[1] = container[container.size() - 1]; container.pop_back(); heapify_topdown(1); } int size() { return container.size() - 1; } private: std::vector<T> container; bool (*compare)(T i, T j); void heapify_bottomup(int curr) { T bottom = container[curr]; while (curr != 1 && compare(bottom, container[curr / 2])) { container[curr] = container[curr / 2]; curr /= 2; } container[curr] = bottom; } void heapify_topdown(int curr) { int size = container.size(); T root = container[curr]; int child; while (curr * 2 < size) { // left child = curr * 2; // right if (child < size && compare(container[child + 1], container[child])) { child += 1; } if (compare(container[child], root)) { container[curr] = container[child]; } else { break; } curr = child; } container[curr] = root; } }; ``` ### A* Implementation 藉由上述兩個元件,使用A\*實作Solver class: ```cpp Solver::Solver() {} std::vector<Direction> Solver::solve(Puzzle puzzle) { PriorityQueue<State *> pq(state_compare); std::vector<State *> used; int walked = 0; State *root = new State(nullptr, puzzle.get_bits(), walked, puzzle.heuristic(), UP); pq.push(root); State *last = nullptr; while (!pq.empty()) { State *curr = pq.top(); last = curr; walked = curr->walked + 1; pq.pop(); used.push_back(curr); Puzzle p(curr->get_bits()); if (p.is_goal()) { break; } for (auto dir : {UP, DOWN, LEFT, RIGHT}) { Puzzle tmp = p; if (tmp.move(dir)) { State *next = new State(curr, tmp.get_bits(), walked, tmp.heuristic(), dir); pq.push(next); } } } std::vector<Direction> answer; while (true) { if (last->parent == nullptr) { break; } answer.push_back(last->dir); last = last->parent; } std::reverse(answer.begin(), answer.end()); while (!pq.empty()) { State *s = pq.top(); pq.pop(); delete s; } for (auto s : used) { delete s; } return answer; } ``` 至此基本的15-Puzzle solver已經完成了,能夠用來解步數較少的puzzle。 ## Optimizations 整個程式還有一些可以最佳化的地方,用來加速程式。 ### Caching 0 Position 在Puzzle class中可以看到,每次要移動的時候都需要重新找一次0的位置,這無疑是做白用工 將0的位置保存起來重複使用,可以節省不少時間。 但需要記得在建構子中初始化0的位置,以及每次進行move之後要更新0的位置。 ```cpp class Puzzle { public: Puzzle(); Puzzle(const std::string &input); Puzzle(uint64_t bits); private: uint64_t bits; int zero_position; }; // "0" stands for the blank Puzzle::Puzzle(const std::string &input) { this->bits = 0; ... this->zero_position = this->find_zero_position(); } // returns false if the move is invalid bool Puzzle::move(Direction dir) { int p0 = this->zero_position; int new_p0; ... this->swap(p0, new_p0); this->zero_position = new_p0; return true; } ``` ### Partially Update Heuristic 另外一個可以減少計算量的是heuristic,因爲每次移動一格只會影響到該格的Manhattan distance,完全可以不用重新計算其他格子的值: ```cpp class Puzzle { public: Puzzle(); Puzzle(const std::string &input); Puzzle(uint64_t bits); private: uint64_t bits; int zero_position; int heuristic_v; }; // "0" stands for the blank Puzzle::Puzzle(const std::string &input) { this->bits = 0; ... this->zero_position = this->find_zero_position(); this->heuristic_v = this->calculate_heuristic(); } int Puzzle::heuristic() { return this->heuristic_v; } // returns false if the move is invalid bool Puzzle::move(Direction dir) { int p0 = this->zero_position; int new_p0; ... int tile = at(new_p0) - 1; this->heuristic_v -= manhattan_distance(tile, new_p0); this->swap(p0, new_p0); this->zero_position = new_p0; this->heuristic_v += manhattan_distance(tile, p0); return true; } ``` ### Avoid Duplicated States 如果檢查在A*過程中走到的State,會發現他其實常常重複訪問同一個Puzzle,這其實是非常沒有效率的。如果使用一個hashmap來記錄已經訪問過的Puzzle,可以大量減少不必要的計算。 這個hashmap的key是Puzzle,剛好Puzzle是用一個uint64來存,可以不需要重新寫一個hash function。而value則是儲存這個Puzzle是走多遠(walked),因爲可能會發現一個比較短的路徑到達當前這個Puzzle,爲了避免找到次佳解,當遇到重複的Puzzle時且路徑較短時,需要更新他的值。 ```cpp std::vector<Direction> Solver::solve(Puzzle puzzle) { PriorityQueue<State *> pq(state_compare); std::vector<State *> used; // record visited states with its priority std::unordered_map<uint64_t, int> visited; int walked = 0; State *root = new State(nullptr, puzzle.get_bits(), walked, puzzle.heuristic(), UP); visited[puzzle.get_bits()] = 0; pq.push(root); State *last = nullptr; while (!pq.empty()) { State *curr = pq.top(); last = curr; walked = curr->walked + 1; pq.pop(); used.push_back(curr); Puzzle p(curr->get_bits()); if (p.is_goal()) { break; } for (auto dir : {UP, DOWN, LEFT, RIGHT}) { Puzzle tmp = p; tmp.move(dir); State *next = new State(curr, tmp.get_bits(), walked, tmp.heuristic(), dir); // if not visited before if (visited.count(tmp.get_bits()) == 0) { pq.push(next); visited[tmp.get_bits()] = walked; } else { // update if the path is shorter if (curr->walked < visited[tmp.get_bits()]) { visited[tmp.get_bits()] = walked; pq.push(next); } } } } std::vector<Direction> answer; while (true) { if (last->parent == nullptr) { break; } answer.push_back(last->dir); last = last->parent; } std::reverse(answer.begin(), answer.end()); while (!pq.empty()) { State *s = pq.top(); pq.pop(); delete s; } for (auto s : used) { delete s; } return answer; } ``` ## Limitations A\*能比BFS更有效率的找出解答,但是在問題比較困難的時候,A\*依然會因爲需要記憶太多狀態導致記憶體不足。例如:`0291ca8d6574feb3`的解需要60步,這導致A*在解的過程中需要使用超過8GiB的記憶體,因而成爲一個主要的限制。爲了解決這個問題,我們改使用IDA\*演算法。 ## IDA\* (Iterative Deepening A\*) IDA\*的本質其實比較偏向DFS(Depth First Search),他主要的概念是給與一個預算,並使用DFS往下搜尋,直到超出預算則返回繼續從上一個狀態開始。當遍歷完成後,根據這次遍歷的結果給出一個新的預算,重新使用這個預算用DFS搜尋。IDA\*會重複這個過程直到找到解爲止。 IDA\*的優點: 不需要記憶大量狀態,因此不會有記憶體爆炸的問題。 缺點: 會大量重複訪問曾經訪問過的狀態。 雖然IDA\*相較於A\*而言會大量訪問重複的狀態,但是在實務上由於A\*需要維護一個龐大的priority queue(動輒幾百、千萬),消耗了大量的運算成本。最終的結果是IDA\*有更好的效率以及更短的執行時間,同時記憶體使用量甚少。 IDA\*的程式碼如下: ```cpp const int FOUND = -1; int Solver::dfs(Puzzle p, int walked, int limit) { this->count++; int h = p.heuristic(); int f = walked + h; if (f > limit) { return f; } if (p.is_goal()) { return FOUND; } int min = INT_MAX; for (auto dir : {UP, DOWN, LEFT, RIGHT}) { Puzzle tmp = p; if (!tmp.move(dir)) { continue; } auto iter = std::find(this->visited.begin(), this->visited.end(), tmp.get_bits()); if (iter != this->visited.end()) { continue; } this->visited.push_back(tmp.get_bits()); int res = dfs(tmp, walked + 1, limit); if (res == FOUND) { this->path.push_back(dir); return FOUND; } this->visited.pop_back(); if (res < min) { min = res; } } return min; } std::vector<Direction> Solver::solve(Puzzle puzzle) { this->path.clear(); this->visited.clear(); this->count = 0; int budget = puzzle.heuristic(); // IDA* while (true) { int new_budget = dfs(puzzle, 0, budget); if (new_budget == FOUND) { break; } budget = new_budget; } std::reverse(this->path.begin(), this->path.end()); return this->path; } ``` 其中,Solver.visited是一個vector,用來記錄目前DFS路上訪問過的狀態,避免重複訪問。 不使用unordered_set的原因是由於這些狀態的數量通常很小,使用set反而會減速。 Solver.path則是在找到終點狀態時,在遞迴返回時將找到的路徑一一填入解答中(但最後要reverse才是答案)。 至此,整個15-puzzle solver就算是完成了。雖然他在解較難的問題時(>60幾步)需要花費大量時間(成指數增長),但不會吃記憶體導致甚至沒辦法解開。 還有其他的改進方法就是找一個更強大的heuristic function(在搜尋演算法中,好的heuristic是相當重要的),讓程式在搜尋中少走許多歪路,才能快速找到解答。 ## Test Data - 2348170c56ab9def (15 moves) - 51309af4d7b826ec (25 moves) - 1f3452d8a70b96ec (30 moves) - 12345dc8796eba0f (33 moves) - 1f345dc8796eab20 (44 moves) - 0bd45c329671af8e (50 moves) - 58d9ebc1324a70f6 (56 moves) - 38abfe7951024c6d (58 moves) - cbfed67a13248095 (64 moves)