Skip to main content

Sliding Puzzle

HardProblemExternal link

Description

A 2 × 3 board holds the tiles 1 to 5 and one empty square, written as 0.

In one move, slide a tile that sits next to the empty square into it: from above, below, the left or the right, but not diagonally. Put another way, the 0 swaps places with one of its neighbouring tiles.

The board is solved when it reads [[1,2,3],[4,5,0]]. Return the fewest moves that solve the given board, or -1 if no sequence of moves ever solves it.

Throughout this page a board is also written on one line, row by row with a slash between the rows. The solved board is 123/450.

Examples

Example 1

Input: board = [[1,3,5],[4,2,0]]

Output: 4

Explanation: One shortest route:

  1. 135/420 → the 5 slides down → 130/425
  2. the 3 slides right → 103/425
  3. the 2 slides up → 123/405
  4. the 5 slides left → 123/450, solved.

No route with 3 moves or fewer exists; every search on this page confirms 4.

Example 2

Input: board = [[1,2,3],[0,4,5]]

Output: 2

Explanation: Slide the 4 left, then the 5 left: 123/045 → 123/405 → 123/450.

Example 3

Input: board = [[2,1,3],[4,5,0]]

Output: -1

Explanation: Only the 1 and the 2 are in the wrong places, yet no number of moves can fix them. The optimal approach below shows a simple test that proves it: count the pairs of tiles that are out of order. Every move keeps that count even or keeps it odd. Here it is 1 (the 2 comes before the 1), which is odd, while the solved board has 0, which is even.

Constraints

  • board.length == 2
  • board[i].length == 3
  • 0 ≤ board[i][j] ≤ 5
  • Each value board[i][j] is unique.

Editorial

Brute Force

Intuition

The first idea most people have is to try moves recursively. From the current board, try each possible move. From each new board, try each move again, and so on.

Two problems appear at once.

  • Moves can undo each other. Sliding a tile and then sliding it back returns to the same board, so plain recursion would loop forever. The fix is to remember, for every board, the fewest moves used to reach it so far. If the search arrives at a board again with no fewer moves, nothing new can come from there, and the call stops.
  • The first route to the goal is not the shortest. Recursion follows one route as deep as it goes before trying another, so it can reach the solved board by a long detour first. It must keep exploring and keep the smallest count it ever records for the solved board.

To make boards easy to compare and store, read the 2 × 3 board row by row into a 6-character string, so [[1,2,3],[0,4,5]] becomes "123045". The cells are numbered 0 to 5, and a fixed list says which cells are next to each other. Cell 4, the bottom middle, touches cells 1, 3 and 5.

Step-by-Step Explanation

We trace Example 2, 123/045, which is only 2 moves from solved. The first eight calls are shown one by one; after that the pattern repeats thousands of times.

Step 1: Call 1 starts on 123/045 with 0 moves and records it.

Step 2: Call 2: slide the 1 down → 023/145, a new board, recorded at 1 move.

Step 3: Call 3: slide the 2 left → 203/145, a new board, recorded at 2 moves.

Step 4: Call 4: slide the 2 right → 023/145 in 3 moves. It was already reached in 1, so this call stops at once.

Step 5: Call 5: slide the 3 left → 230/145, a new board, recorded at 3 moves.

Step 6: Call 6: slide the 3 right → 203/145 in 4 moves. It was already reached in 2, so this call stops at once.

Step 7: Call 7: slide the 5 up → 235/140, a new board, recorded at 4 moves.

Step 8: Call 8: slide the 5 down → 230/145 in 5 moves. It was already reached in 3, so this call stops at once.

Step 9: The search keeps diving and first reaches the solved board at call 99, by a route 52 moves long. It cannot stop there, because a shorter route might exist.

Step 10: It keeps finding shorter routes: 46, 40, 36, 34, 32, 26, 22, 20, 12 moves. Only at call 21,348, the last one, does it reach the goal in 2 moves.

Step 11: Return 2. It took 21,348 calls to be sure of an answer that is only 2 moves long.

Depth-First Search — Diving Before Looking Around — The board at each call: indigo marks where the last tile slid, amber marks a call that stops because the board was already reached as quickly.

Algorithm

  1. Turn the board into a 6-character string. Create an empty map best from board to moves.
  2. explore(board, moves):
    • If board is in best with a value ≤ moves, return.
    • Set best[board] = moves.
    • If board is the solved board, return.
    • For each cell next to the 0, swap them to get a new board and call explore(new board, moves + 1).
  3. Call explore(start, 0). Return best[solved board] if it exists, otherwise -1.

Code

#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
using namespace std;

class Solution {
    // The board is read row by row into 6 cells; NEIGHBORS[i] are the cells next to cell i.
    const vector<vector<int>> NEIGHBORS = {{1, 3}, {0, 2, 4}, {1, 5}, {0, 4}, {1, 3, 5}, {2, 4}};
    const string GOAL = "123450";
    unordered_map<string, int> best;  // the fewest moves found so far that reach each board

    void explore(string b, int moves) {
        auto it = best.find(b);
        if (it != best.end() && it->second <= moves) return;  // already reached at least as quickly
        best[b] = moves;
        if (b == GOAL) return;
        int z = b.find('0');
        for (int j : NEIGHBORS[z]) {
            string next = b;
            swap(next[z], next[j]);  // slide the tile at j into the empty square
            explore(next, moves + 1);
        }
    }

public:
    int slidingPuzzle(vector<vector<int>>& board) {
        string start;
        for (auto& row : board)
            for (int x : row) start += char('0' + x);
        best.clear();
        explore(start, 0);
        auto it = best.find(GOAL);
        return it == best.end() ? -1 : it->second;
    }
};
class Solution:
    # The board is read row by row into 6 cells; NEIGHBORS[i] are the cells next to cell i.
    NEIGHBORS = [[1, 3], [0, 2, 4], [1, 5], [0, 4], [1, 3, 5], [2, 4]]
    GOAL = '123450'

    def slidingPuzzle(self, board: list[list[int]]) -> int:
        start = ''.join(str(x) for row in board for x in row)
        best = {}  # the fewest moves found so far that reach each board
        self.explore(start, 0, best)
        return best.get(self.GOAL, -1)

    def explore(self, b: str, moves: int, best: dict) -> None:
        if b in best and best[b] <= moves:
            return  # this board was already reached at least as quickly
        best[b] = moves
        if b == self.GOAL:
            return
        z = b.index('0')
        for j in self.NEIGHBORS[z]:
            s = list(b)
            s[z], s[j] = s[j], s[z]  # slide the tile at j into the empty square
            self.explore(''.join(s), moves + 1, best)
import java.util.HashMap;
import java.util.Map;

class Solution {
    // The board is read row by row into 6 cells; NEIGHBORS[i] are the cells next to cell i.
    private static final int[][] NEIGHBORS = {{1, 3}, {0, 2, 4}, {1, 5}, {0, 4}, {1, 3, 5}, {2, 4}};
    private static final String GOAL = "123450";
    private final Map<String, Integer> best = new HashMap<>();  // the fewest moves found so far that reach each board

    public int slidingPuzzle(int[][] board) {
        StringBuilder start = new StringBuilder();
        for (int[] row : board)
            for (int x : row) start.append(x);
        best.clear();
        explore(start.toString(), 0);
        return best.getOrDefault(GOAL, -1);
    }

    private void explore(String b, int moves) {
        Integer known = best.get(b);
        if (known != null && known <= moves) return;  // already reached at least as quickly
        best.put(b, moves);
        if (b.equals(GOAL)) return;
        int z = b.indexOf('0');
        for (int j : NEIGHBORS[z]) {
            char[] s = b.toCharArray();
            s[z] = s[j];  // slide the tile at j into the empty square
            s[j] = '0';
            explore(new String(s), moves + 1);
        }
    }
}

Complexity Analysis

Let S be the number of possible boards: 6! = 720, of which 360 can be reached from any given board.

Time Complexity: O(S²) in the worst case. A board is explored again every time the search reaches it with fewer moves than before, and each time everything beyond it is explored again too. Each board can improve at most as many times as the longest route is long. Measured on every one of the 720 boards, this code makes about 21,000 calls, and fully explores about 9,100 boards, every single time. That is roughly 25 times the 360 boards that can actually be reached.

Space Complexity: O(S) for the best map, plus the depth of the recursion. The deepest chain of calls we measured was 218.

Why This Approach Is Not Efficient

Depth-first search dives along one route as far as it can go before looking at the alternatives. It reaches boards by long detours first, and every time it later finds a shorter way to a board, it has to explore everything beyond that board again.

Example 2 is only 2 moves from solved. This search first reaches the solved board at call 99 by a 52-move route. It then improves that 10 times and finds the 2-move route only at call 21,348, its very last call. In Python, running it on all 720 possible boards takes about 3 seconds.

The real problem is the order. If we looked at every board 1 move away before any board 2 moves away, and so on outward, the first time we met a board would already be the fastest way to reach it. Nothing would ever need re-exploring, and the first time we met the solved board we could stop.

Better Approach - Breadth-First Search Over Boards

Intuition

Explore the boards in rings: first every board 1 move from the start, then every board 2 moves away, then 3, and so on outward. The first ring that contains the solved board gives the answer, because no closer ring had it.

A queue produces exactly this order. Take the board at the front, and add each neighbouring board that has not been seen yet to the back, recording one more move. Every board of ring 1 joins the queue before any board of ring 2, and so on, so boards leave the queue in order of distance.

Example 2 explored ring by ring: the start board 123/045, then the two boards one move away, 023/145 and 123/405, then three boards two moves away, 203/145, 103/425 and the solved board 123/450, which is framed in green. Arrows show each move.

Two things follow.

  • Each board is handled once. The first time a board is reached, it is reached by a shortest route, because every closer board was handled before it. A seen set can safely ignore every later arrival. That caps the work at the 360 boards that are reachable.
  • The first time the solved board leaves the queue, its move count is the fewest possible. There is no need to keep searching.

Step-by-Step Explanation

We trace Example 2 again, 123/045. The row shows the queue from front to back; boards that have been taken off are listed underneath.

Step 1: Put the start board 123/045 in the queue, at 0 moves, and mark it seen.

Step 2: Take 123/045 off the front — 0 moves from the start, not solved.

Step 3: Slide the 1 down → 023/145: new, add it at 1 move.

Step 4: Slide the 4 left → 123/405: new, add it at 1 move.

Step 5: Take 023/145 off the front — 1 move from the start, not solved.

Step 6: Slide the 2 left → 203/145: new, add it at 2 moves. The other move leads back to 123/045, which is already seen.

Step 7: Take 123/405 off the front — 1 move from the start, not solved.

Step 8: Slide the 2 down → 103/425: new, add it at 2 moves.

Step 9: Slide the 5 left → 123/450: new, add it at 2 moves. (This is the goal; it waits its turn in the queue.) The other move leads back to 123/045, which is already seen.

Step 10: Take 203/145 off the front — 2 moves from the start, not solved.

Step 11: Slide the 3 left → 230/145: new, add it at 3 moves.

Step 12: Slide the 4 up → 243/105: new, add it at 3 moves. The other move leads back to 023/145, which is already seen.

Step 13: Take 103/425 off the front — 2 moves from the start, not solved.

Step 14: Slide the 1 right → 013/425: new, add it at 3 moves.

Step 15: Slide the 3 left → 130/425: new, add it at 3 moves. The other move leads back to 123/405, which is already seen.

Step 16: Take 123/450 off the front: it is the solved board. Return 2.

Breadth-First Search — One Ring at a Time — Boards join at the back of the queue and leave from the front, so they are handled in order of how many moves they are from the start.

Algorithm

  1. Turn the board into a 6-character string. Put (start, 0) in a queue and start in a set seen.
  2. While the queue is not empty:
    • Take (board, moves) off the front.
    • If board is the solved board, return moves.
    • For each cell next to the 0, swap them to get a new board. If it is not in seen, add it to seen and put (new board, moves + 1) at the back.
  3. Return -1. The queue ran dry without reaching the solved board.

Code

#include <queue>
#include <string>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;

class Solution {
public:
    int slidingPuzzle(vector<vector<int>>& board) {
        // The board is read row by row into 6 cells; neighbors[i] are the cells next to cell i.
        const vector<vector<int>> neighbors = {{1, 3}, {0, 2, 4}, {1, 5}, {0, 4}, {1, 3, 5}, {2, 4}};
        const string goal = "123450";
        string start;
        for (auto& row : board)
            for (int x : row) start += char('0' + x);
        unordered_set<string> seen{start};
        queue<pair<string, int>> q;
        q.push({start, 0});
        while (!q.empty()) {
            auto [b, moves] = q.front();
            q.pop();
            if (b == goal) return moves;  // boards leave the queue in order of distance, so this is the fewest
            int z = b.find('0');
            for (int j : neighbors[z]) {
                string next = b;
                swap(next[z], next[j]);  // slide the tile at j into the empty square
                if (seen.insert(next).second) q.push({next, moves + 1});
            }
        }
        return -1;
    }
};
from collections import deque


class Solution:
    def slidingPuzzle(self, board: list[list[int]]) -> int:
        # The board is read row by row into 6 cells; neighbors[i] are the cells next to cell i.
        neighbors = [[1, 3], [0, 2, 4], [1, 5], [0, 4], [1, 3, 5], [2, 4]]
        goal = '123450'
        start = ''.join(str(x) for row in board for x in row)
        seen = {start}
        queue = deque([(start, 0)])
        while queue:
            b, moves = queue.popleft()
            if b == goal:
                return moves  # boards leave the queue in order of distance, so this is the fewest
            z = b.index('0')
            for j in neighbors[z]:
                s = list(b)
                s[z], s[j] = s[j], s[z]  # slide the tile at j into the empty square
                nxt = ''.join(s)
                if nxt not in seen:
                    seen.add(nxt)
                    queue.append((nxt, moves + 1))
        return -1
import java.util.ArrayDeque;
import java.util.HashSet;
import java.util.Set;

class Solution {
    public int slidingPuzzle(int[][] board) {
        // The board is read row by row into 6 cells; neighbors[i] are the cells next to cell i.
        int[][] neighbors = {{1, 3}, {0, 2, 4}, {1, 5}, {0, 4}, {1, 3, 5}, {2, 4}};
        String goal = "123450";
        StringBuilder sb = new StringBuilder();
        for (int[] row : board)
            for (int x : row) sb.append(x);
        String start = sb.toString();
        Set<String> seen = new HashSet<>();
        seen.add(start);
        ArrayDeque<String> queue = new ArrayDeque<>();
        ArrayDeque<Integer> dist = new ArrayDeque<>();
        queue.add(start);
        dist.add(0);
        while (!queue.isEmpty()) {
            String b = queue.poll();
            int moves = dist.poll();
            if (b.equals(goal)) return moves;  // boards leave the queue in order of distance, so this is the fewest
            int z = b.indexOf('0');
            for (int j : neighbors[z]) {
                char[] s = b.toCharArray();
                s[z] = s[j];  // slide the tile at j into the empty square
                s[j] = '0';
                String next = new String(s);
                if (seen.add(next)) {
                    queue.add(next);
                    dist.add(moves + 1);
                }
            }
        }
        return -1;
    }
}

Complexity Analysis

Let S = 720 be the number of possible boards.

Time Complexity: O(S). Each board enters the queue at most once, has at most 3 moves, and building a neighbouring board copies 6 characters. Measured on all 720 boards, the queue hands out 184 boards on average before a solvable board is solved, and all 360 reachable boards before an impossible one is rejected. Running it on all 720 boards takes 0.08 s in Python, against about 3 s for the depth-first search.

Space Complexity: O(S) for the queue and the seen set.

Why This Approach Is Not Efficient

Breadth-first search never wastes a move on a board twice, but it still does more than it needs in two places.

  • Impossible boards cost the most. Exactly half of all boards, 360 of the 720, can never be solved. For each of them the search can only answer -1 after it has handled every one of the 360 boards it can reach.
  • Far goals mean big rings. The rings grow as they move outward. For the hardest board, 21 moves from solved, the search handles all 360 reachable boards before the goal comes out.

Both have a fix. Impossible boards can be recognised without searching at all. For the rest, two searches that start at both ends and each go about halfway meet long before a single search could cross the whole distance, because the rings near each end are small.

Optimal Approach - Even/Odd Check, Then Search From Both Ends

Intuition

Part 1 — spotting impossible boards at once. Read the tiles row by row and skip the 0. Call a pair of tiles out of order when the larger one comes first. The solved board, 1 2 3 4 5, has zero such pairs. Now see what a move does to that count.

  • A sideways move slides a tile along its own row. The tiles are still read in the same order, so the count does not change.
  • A vertical move is the only other kind. In the row-by-row order, the tile jumps three cells, passing the two tiles between its old and new place. Only those two pairs can change, and each one flips between in order and out of order. So the count goes up by 2, down by 2, or stays the same.
The board 135/420 becomes 130/425 when the 5 slides down. Read row by row, the tiles go from 1 3 5 4 2, with 4 out-of-order pairs, to 1 3 4 2 5, with 2. The 5 jumped over the 4 and the 2, so only those two pairs flipped and the count stayed even.

So every move keeps the count even if it was even, or odd if it was odd. The solved board's count is 0, which is even, so a board whose count is odd can never be solved. Return -1 at once. Example 3 has exactly one out-of-order pair, 2 before 1, so it is one of these. The other direction also holds on this 2 × 3 board: we checked all 360 boards with an even count, and every one of them can be solved.

Part 2 — searching from both ends. For a solvable board, run breadth-first search from the start and from the solved board at the same time. Moves can always be undone, so searching backwards from the goal uses exactly the same moves. Grow one whole ring at a time, always on the side whose current ring is smaller. The moment a new board from one side is one the other side has already reached, the two routes join:

answer = (moves from the start to one board) + 1 + (moves from the next board to the goal)

That first meeting is the shortest route. Each side has already handled every board closer than its current ring, so any shorter route would have made the searches meet earlier.

Step-by-Step Explanation

We trace Example 1, 135/420. The first step counts the out-of-order pairs; after that each cell is a board in one search's current ring, labelled with its side and its distance from that side's end.

Step 1: Read the tiles row by row, skipping the 0: 1 3 5 4 2. Four pairs are out of order (3>2, 5>4, 5>2, 4>2). Four is even, so the board is solvable and the search can begin.

Step 2: Start one search at 135/420 and one at the solved board 123/450, each holding just its own board.

Step 3: Expand 135/420 (start side). Next ring so far: 130/425, 135/402. No meeting yet.

Step 4: The start side moves to its next ring: 130/425, 135/402.

Step 5: Expand 123/450 (goal side). Next ring so far: 120/453, 123/405. No meeting yet.

Step 6: The goal side moves to its next ring: 120/453, 123/405.

Step 7: Expand 120/453 (goal side). Next ring so far: 102/453. No meeting yet.

Step 8: Expand 123/405 (goal side). Next ring so far: 102/453, 103/425, 123/045. No meeting yet.

Step 9: The goal side moves to its next ring: 102/453, 103/425, 123/045.

Step 10: Expand 130/425 (start side): one move gives 103/425, which the other search reached in 2 moves. They meet: 1 + 1 + 2 = 4.

Step 11: Joined, the two halves give 135/420 → 130/425 → 103/425 → 123/405 → 123/450. Return 4 — only 5 boards were expanded.

Even/Odd Check, Then Two Searches That Meet — First the out-of-order pairs are counted; then each cell is a board in one search's current ring, labelled by side and distance, until the two searches touch.

Algorithm

  1. Read the tiles row by row, skipping the 0, and count the out-of-order pairs. If the count is odd, return -1.
  2. If the board is already solved, return 0.
  3. Keep, for each side, a map from board to distance (dist_a starts as {start: 0}, dist_b as {solved: 0}) and the current ring ([start] and [solved]).
  4. Repeat while both rings are non-empty:
    • If side A's ring is larger than side B's, swap the two sides, so A is always the smaller.
    • For each board in A's ring, and each move from it:
      • If the new board is in dist_b, return dist_a[board] + 1 + dist_b[new board].
      • If it is not in dist_a, record dist_a[new board] = dist_a[board] + 1 and add it to A's next ring.
    • Replace A's ring with its next ring.
  5. Return -1. This is never reached for a board that passed step 1.

Code

#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
using namespace std;

class Solution {
public:
    int slidingPuzzle(vector<vector<int>>& board) {
        // The board is read row by row into 6 cells; neighbors[i] are the cells next to cell i.
        const vector<vector<int>> neighbors = {{1, 3}, {0, 2, 4}, {1, 5}, {0, 4}, {1, 3, 5}, {2, 4}};
        const string goal = "123450";
        string start;
        for (auto& row : board)
            for (int x : row) start += char('0' + x);

        // No move changes whether the count of out-of-order tile pairs is even or odd,
        // and the goal has none, so an odd count can never be solved.
        string tiles;
        for (char c : start)
            if (c != '0') tiles += c;
        int outOfOrder = 0;
        for (int i = 0; i < 5; i++)
            for (int j = i + 1; j < 5; j++)
                if (tiles[i] > tiles[j]) outOfOrder++;
        if (outOfOrder % 2 == 1) return -1;
        if (start == goal) return 0;

        // Search from both ends, one whole layer at a time, always growing the smaller layer.
        unordered_map<string, int> distA{{start, 0}}, distB{{goal, 0}};
        vector<string> layerA{start}, layerB{goal};
        while (!layerA.empty() && !layerB.empty()) {
            if (layerA.size() > layerB.size()) {
                swap(distA, distB);
                swap(layerA, layerB);
            }
            vector<string> nextLayer;
            for (const string& b : layerA) {
                int z = b.find('0');
                for (int j : neighbors[z]) {
                    string next = b;
                    swap(next[z], next[j]);  // slide the tile at j into the empty square
                    auto meet = distB.find(next);
                    if (meet != distB.end()) return distA[b] + 1 + meet->second;  // the two searches meet here
                    if (!distA.count(next)) {
                        distA[next] = distA[b] + 1;
                        nextLayer.push_back(next);
                    }
                }
            }
            layerA = nextLayer;
        }
        return -1;
    }
};
class Solution:
    def slidingPuzzle(self, board: list[list[int]]) -> int:
        # The board is read row by row into 6 cells; neighbors[i] are the cells next to cell i.
        neighbors = [[1, 3], [0, 2, 4], [1, 5], [0, 4], [1, 3, 5], [2, 4]]
        goal = '123450'
        start = ''.join(str(x) for row in board for x in row)

        # No move changes whether the count of out-of-order tile pairs is even or odd,
        # and the goal has none, so an odd count can never be solved.
        tiles = [c for c in start if c != '0']
        out_of_order = sum(1 for i in range(5) for j in range(i + 1, 5) if tiles[i] > tiles[j])
        if out_of_order % 2 == 1:
            return -1
        if start == goal:
            return 0

        # Search from both ends, one whole layer at a time, always growing the smaller layer.
        dist_a, dist_b = {start: 0}, {goal: 0}
        layer_a, layer_b = [start], [goal]
        while layer_a and layer_b:
            if len(layer_a) > len(layer_b):
                dist_a, dist_b, layer_a, layer_b = dist_b, dist_a, layer_b, layer_a
            next_layer = []
            for b in layer_a:
                z = b.index('0')
                for j in neighbors[z]:
                    s = list(b)
                    s[z], s[j] = s[j], s[z]  # slide the tile at j into the empty square
                    nxt = ''.join(s)
                    if nxt in dist_b:
                        return dist_a[b] + 1 + dist_b[nxt]  # the two searches meet here
                    if nxt not in dist_a:
                        dist_a[nxt] = dist_a[b] + 1
                        next_layer.append(nxt)
            layer_a = next_layer
        return -1
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

class Solution {
    public int slidingPuzzle(int[][] board) {
        // The board is read row by row into 6 cells; neighbors[i] are the cells next to cell i.
        int[][] neighbors = {{1, 3}, {0, 2, 4}, {1, 5}, {0, 4}, {1, 3, 5}, {2, 4}};
        String goal = "123450";
        StringBuilder sb = new StringBuilder();
        for (int[] row : board)
            for (int x : row) sb.append(x);
        String start = sb.toString();

        // No move changes whether the count of out-of-order tile pairs is even or odd,
        // and the goal has none, so an odd count can never be solved.
        String tiles = start.replace("0", "");
        int outOfOrder = 0;
        for (int i = 0; i < 5; i++)
            for (int j = i + 1; j < 5; j++)
                if (tiles.charAt(i) > tiles.charAt(j)) outOfOrder++;
        if (outOfOrder % 2 == 1) return -1;
        if (start.equals(goal)) return 0;

        // Search from both ends, one whole layer at a time, always growing the smaller layer.
        Map<String, Integer> distA = new HashMap<>(), distB = new HashMap<>();
        distA.put(start, 0);
        distB.put(goal, 0);
        List<String> layerA = new ArrayList<>(List.of(start)), layerB = new ArrayList<>(List.of(goal));
        while (!layerA.isEmpty() && !layerB.isEmpty()) {
            if (layerA.size() > layerB.size()) {
                Map<String, Integer> d = distA; distA = distB; distB = d;
                List<String> l = layerA; layerA = layerB; layerB = l;
            }
            List<String> nextLayer = new ArrayList<>();
            for (String b : layerA) {
                int z = b.indexOf('0');
                for (int j : neighbors[z]) {
                    char[] s = b.toCharArray();
                    s[z] = s[j];  // slide the tile at j into the empty square
                    s[j] = '0';
                    String next = new String(s);
                    Integer meet = distB.get(next);
                    if (meet != null) return distA.get(b) + 1 + meet;  // the two searches meet here
                    if (!distA.containsKey(next)) {
                        distA.put(next, distA.get(b) + 1);
                        nextLayer.add(next);
                    }
                }
            }
            layerA = nextLayer;
        }
        return -1;
    }
}

Complexity Analysis

Let S = 720 be the number of possible boards.

Time Complexity:

  • The even/odd check compares the 10 pairs of tiles: constant time. Every impossible board is answered here with no search at all.
  • The two searches can in the worst case touch as many boards as one search, O(S). In practice they meet halfway. Measured on all 360 solvable boards, they expand 58 boards on average and 150 at most, against 184 on average and 360 at most for one breadth-first search. The hardest board, 21 moves from solved, costs 150 instead of 360.

Space Complexity: O(S) for the two distance maps and the rings.

The check matters for the second part too. Without it, an impossible board would make both searches run until they had nothing left. We measured 549 boards expanded in that case, worse than a single breadth-first search.