2
\$\begingroup\$

This is a Leetcode problem:

On a 2 x 3 board, there are 5 tiles represented by the integers 1 through 5 and an empty square represented by 0.

A move consists of choosing 0 and a 4-directionally adjacent number and swapping it.

The state of the board is \$solved\$ if and only if the board is [[1,2,3],[4,5,0]].

Given a puzzle board, return the least number of moves required so that the state of the board is solved. If it is impossible for the state of the board to be solved, return -1.

Note -

  • board will be a 2 x 3 array as described above.
  • board[i][j] will be a permutation of [0, 1, 2, 3, 4, 5].

Here is my solution to this challenge:

from collections import deque

class Solution:

    def get_new_state(self, index1, index2, current_state):
        if current_state[index1] == "0" or current_state[index2] == "0":
            current_state = list(current_state)
            current_state[index1], current_state[index2] = current_state[index2], current_state[index1]
            return "".join(current_state)
        return None   

    def sliding_puzzle(self, board):
        """
        :type board: List[List[int]]
        :rtype: int
        """
        min_step = 1 << 31
        # 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 = [] + board[0] + board[1]
        init_state = "".join(str(_) for _ in init_state)

        visited = {init_state}
        queue = deque([[init_state, 0]])
        while queue:
            top = queue.popleft()
            current_state, step = top

            # check results
            if current_state == "123450":
                 min_step = min(min_step, step)

            for index1 in graph:
                for index2 in graph[index1]:
                    new_state = self.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)
        if min_step == 1<< 31:
            return -1
        return min_step

Explanation

Convert the board to a list so that we can have a visit set to track which state is visited.

Construct an adjacency list to mark which position we can go to. For example, [[1, 2, 3], [4, 5, 0]], as it is a board value, 1 can swap with 4 or 2. If we make it a string "123450", that means position 0 (so-called index) can swap with index value 0 and index value 3 => 0:[1, 3], same for 1:[0, 2, 4] for so on so forth.

Now that we have the graph, we just need to do a regular BFS.

Here are some example outputs:

#print(sliding_puzzle([[1,2,3],[4,0,5]]))

>>> 1

#Explanation: Swap the 0 and the 5 in one move.

#print(sliding_puzzle([[1,2,3],[5,4,0]]))

>>> -1

#Explanation: No number of moves will make the board solved.

#print(sliding_puzzle([[4,1,2],[5,0,3]]))

>>> 5

#Explanation: 5 is the smallest number of moves that solves the board.

#An example path -
#After move 0: [[4,1,2],[5,0,3]]
#After move 1: [[4,1,2],[0,5,3]]
#After move 2: [[0,1,2],[4,5,3]]
#After move 3: [[1,0,2],[4,5,3]]
#After move 4: [[1,2,0],[4,5,3]]
#After move 5: [[1,2,3],[4,5,0]]

#print(sliding_puzzle([[3,2,4],[1,5,0]]))

>>> 14

Here are the times taken for each output:

%timeit output.sliding_puzzle([[1,2,3],[4,0,5]])
3.24 ms ± 629 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

%timeit output.sliding_puzzle([[1,2,3],[5,4,0]])
3.17 ms ± 633 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

%timeit output.sliding_puzzle([[4,1,2],[5,0,3]])
3.32 ms ± 719 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

%timeit output.sliding_puzzle([[3,2,4],[1,5,0]])
2.75 ms ± 131 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Here is my Leetcode result (32 test cases):

Leetcode result

So, I would like to know whether I could make my program shorter and more efficient.

\$\endgroup\$
2
\$\begingroup\$

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.

\$\endgroup\$
  • 1
    \$\begingroup\$ Upvoted! Amazing answer. Here is the Leetcode result for your code - Runtime: 44 ms, faster than 97.46% of Python 3 online submissions for Sliding Puzzle. \$\endgroup\$ – Justin May 30 at 20:50
  • 1
    \$\begingroup\$ Thanks for the feedback. You got me eager to find how 2.54% did :) My answer will probably get update in the next minutes! \$\endgroup\$ – Josay May 30 at 20:56
  • 1
    \$\begingroup\$ @Justin You can see a new optimisation on the edited version of my answer. \$\endgroup\$ – Josay May 30 at 20:59
  • \$\begingroup\$ Thanks a lot! Here's the Leetcode result (keeps changing actually, but it's the same as the previous one) - Runtime: 44 ms, faster than 97.46% of Python3 online submissions for Sliding Puzzle. But all in all, it is way faster than mine, which is all I needed. Accepted! \$\endgroup\$ – Justin May 30 at 21:06
  • 1
    \$\begingroup\$ New status: "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." \$\endgroup\$ – Josay May 31 at 14:50

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.