Many great ideas here:
- using a hashable data structure to be able to store it in a set
- using a dequeue to generate the various possible states
- storing the neighboors in a dictionnary
However, various points can be improved.
Initialisation of the board
Having 2 consecutive assignments to init_state
makes things more complicated than needed.
Starting with "[] + " is not required.
Using _
as a variable name is pretty common but it usually corresponds to a value that is not going to be used. In your case, I'd use a more normal name.
Thus, I'd recommend:
init_state = "".join(str(c) for c in board[0] + board[1])
Stopping as soon as possible
Because of the way the queue is built, elements with be in increasing order regarding the step
element. One of the implication is that once we've found a solution, there is no need to continue, no solution will ever be better. You can return at that point. That also removes the need for a special value corresponding to "no solution found so far".
def sliding_puzzle(board): """ :type board: List[List[int]] :rtype: int """ # need to convert board to a string so that we can add it as a state in the set # construct the graph based on the positions of the next place it can swap graph = {0:[1, 3], 1:[0, 2, 4], 2:[1, 5], 3:[0, 4], 4:[1, 3, 5], 5:[2, 4]} # convert init board to an initial state init_state = "".join(str(c) for c in board[0] + board[1]) visited = {init_state} queue = deque([[init_state, 0]]) while queue: top = queue.popleft() current_state, step = top # check results if current_state == "123450": return step for index1 in graph: for index2 in graph[index1]: new_state = get_new_state(index1, index2, current_state) if new_state is not None and new_state not in visited: queue.append([new_state, step + 1]) visited.add(new_state) return -1
This makes the code way faster : twice faster on my machine on the test cases provided, more than twice on a more comprehensive test suite:
def find_new_tests(): import random board = "123450" values_found = {} for i in range(1000): board_lst = list(board) random.shuffle(board_lst) ret = sliding_puzzle([board_lst[0:3], board_lst[3:]]) if ret not in values_found: values_found[ret] = ''.join(board_lst) print(values_found) start = time.time() for i in range(10): # Provided in the question assert sliding_puzzle([[1,2,3],[4,0,5]]) == 1 assert sliding_puzzle([[1,2,3],[5,4,0]]) == -1 assert sliding_puzzle([[4,1,2],[5,0,3]]) == 5 assert sliding_puzzle([[3,2,4],[1,5,0]]) == 14 # Found randomly assert sliding_puzzle([[1,2,0],[4,5,3]]) == 1 assert sliding_puzzle([[1,2,3],[0,4,5]]) == 2 assert sliding_puzzle([[1,3,0],[4,2,5]]) == 3 assert sliding_puzzle([[1,5,2],[0,4,3]]) == 4 assert sliding_puzzle([[4,1,3],[2,0,5]]) == 5 assert sliding_puzzle([[4,1,2],[5,3,0]]) == 6 assert sliding_puzzle([[2,3,5],[1,0,4]]) == 7 assert sliding_puzzle([[5,2,3],[1,4,0]]) == 8 assert sliding_puzzle([[4,2,3],[5,0,1]]) == 9 assert sliding_puzzle([[5,0,3],[1,2,4]]) == 10 assert sliding_puzzle([[1,2,5],[3,0,4]]) == 11 assert sliding_puzzle([[4,0,1],[3,2,5]]) == 12 assert sliding_puzzle([[3,1,0],[4,5,2]]) == 13 assert sliding_puzzle([[1,4,3],[5,2,0]]) == 14 assert sliding_puzzle([[0,1,3],[2,5,4]]) == 15 assert sliding_puzzle([[5,1,3],[0,4,2]]) == 16 assert sliding_puzzle([[1,3,0],[5,4,2]]) == 17 assert sliding_puzzle([[2,0,1],[3,5,4]]) == 18 assert sliding_puzzle([[0,2,1],[3,5,4]]) == 19 assert sliding_puzzle([[3,2,1],[0,5,4]]) == 20 assert sliding_puzzle([[4,2,3],[0,1,5]]) == -1 print(time.time() - start)
Finding the sliding pieces
At the moment, to generate new state, you try each cell and for each cell, each neighboor then eventually you check than one or the other is empty.
You just need to find the empty cell and consider its neighboor.
This makes the code almost 3 times faster (and 7 times faster than the original code) and more concise:
def get_new_state(index1, index2, current_state): current_state = list(current_state) current_state[index1], current_state[index2] = current_state[index2], current_state[index1] return "".join(current_state) def sliding_puzzle(board): """ :type board: List[List[int]] :rtype: int """ # need to convert board to a string so that we can add it as a state in the set # construct the graph based on the positions of the next place it can swap graph = {0:[1, 3], 1:[0, 2, 4], 2:[1, 5], 3:[0, 4], 4:[1, 3, 5], 5:[2, 4]} # convert init board to an initial state init_state = "".join(str(c) for c in board[0] + board[1]) visited = {init_state} queue = deque([[init_state, 0]]) while queue: current_state, step = queue.popleft() # check results if current_state == "123450": return step empty = current_state.find("0") for candidate in graph[empty]: new_state = get_new_state(empty, candidate, current_state) if new_state not in visited: queue.append([new_state, step + 1]) visited.add(new_state) return -1
Other optimisation ideas
When pieces on the left border are in place, there is no need to move them anymore. (On a 3x3 board, this would apply also to the top/bottom borders). Thus, we can reduce the search space by not trying to move them in these cases. I did not find any noticeable improvement by doing so:
pieces_to_keep = set() if current_state[0] == "1" and current_state[3] == "4": pieces_to_keep.add(0) pieces_to_keep.add(3) empty = current_state.find("0") for candidate in graph[empty]: if candidate not in pieces_to_keep: new_state = get_new_state(empty, candidate, current_state) if new_state not in visited: queue.append((new_state, step + 1)) visited.add(new_state)
Micro optimisation
We could try to save a bit of time by avoiding calling the get_new_state
function and inlining the corresponding code.
for candidate in graph[empty]: tmp_state = list(current_state) tmp_state[empty], tmp_state[candidate] = tmp_state[candidate], "0" new_state = ''.join(tmp_state) if new_state not in visited: queue.append((new_state, step + 1)) visited.add(new_state)
This leads to a significant improvement in performances.
More extreme caching
We can easily notice two interesting points:
there are not so many reachable positions (360)
when looking for a non reachable position, we have to generate all reachable position.
This leads to an idea: we may as well compute all the positions and the number of steps required once and for all. This is an expensive initialisation step but as soon as we look for a single non reachable position, it is worth it. The more requests we perform, the more amortised the upfront operations are as each request takes a constant time.
Corresponding code is:
from collections import deque def generate_cache(): graph = {0:[1, 3], 1:[0, 2, 4], 2:[1, 5], 3:[0, 4], 4:[1, 3, 5], 5:[2, 4]} init_state = '123450' results = {init_state: 0} queue = deque([[init_state, 0]]) while queue: current_state, step = queue.popleft() empty = current_state.find("0") for candidate in graph[empty]: tmp_state = list(current_state) tmp_state[empty], tmp_state[candidate] = tmp_state[candidate], "0" new_state = ''.join(tmp_state) if new_state not in results: queue.append((new_state, step + 1)) results[new_state] = step + 1 return results cache = generate_cache() def sliding_puzzle(board): """ :type board: List[List[int]] :rtype: int """ init_state = "".join(str(c) for c in board[0] + board[1]) return cache.get(init_state, -1)
This got me:
Runtime: 32 ms, faster than 100.00% of Python3 online submissions for Sliding Puzzle. Memory Usage: 13.3 MB, less than 15.86% of Python3 online submissions for Sliding Puzzle.
Hardcoded cache
This would probably be a right place to stop but for some reason, after a few days, I got a bit curious of the performance gain we'd have by having the cache hardcoded:
Runtime: 32 ms, faster than 100.00% of Python3 online submissions for Sliding Puzzle. Memory Usage: 13.1 MB, less than 80.58% of Python3 online submissions for Sliding Puzzle.
I must confess that I am a bit disappointed.
Additional note: at this level of details, resubmitting the same solution can lead to different performances.