Prerequisites

Before attempting this problem, you should be comfortable with:

  • BFS (Breadth-First Search) - Finding shortest paths in unweighted graphs by level-order traversal
  • Bidirectional BFS - Optimizing BFS by searching from both start and target simultaneously
  • Recursion with Memoization - Caching results to avoid redundant computations in DFS
  • Coordinate Symmetry - Exploiting problem symmetry to reduce search space

Intuition

Finding the minimum moves for a knight is a classic shortest path problem on an unweighted graph. Each cell is a node, and knight moves define the edges. BFS naturally finds the shortest path because it explores all positions at distance k before any position at distance k+1.

Starting from the origin (0, 0), we expand outward level by level. The first time we reach the target coordinates, we've found the minimum number of moves.

Algorithm

  1. Define the eight possible knight move offsets.
  2. Initialize a queue with the starting position (0, 0) and a visited set.
  3. Process the queue level by level, tracking the current step count.
  4. For each position, check if it matches the target; if so, return the step count.
  5. Otherwise, add all unvisited positions reachable by one knight move to the queue.
  6. Mark each new position as visited to avoid revisiting.
class Solution:
    def minKnightMoves(self, x: int, y: int) -> int:
        # the offsets in the eight directions
        offsets = [(1, 2), (2, 1), (2, -1), (1, -2),
                   (-1, -2), (-2, -1), (-2, 1), (-1, 2)]

        def bfs(x, y):
            visited = set()
            queue = deque([(0, 0)])
            steps = 0

            while queue:
                curr_level_cnt = len(queue)
                # iterate through the current level
                for i in range(curr_level_cnt):
                    curr_x, curr_y = queue.popleft()
                    if (curr_x, curr_y) == (x, y):
                        return steps

                    for offset_x, offset_y in offsets:
                        next_x, next_y = curr_x + offset_x, curr_y + offset_y
                        if (next_x, next_y) not in visited:
                            visited.add((next_x, next_y))
                            queue.append((next_x, next_y))

                # move on to the next level
                steps += 1

        return bfs(x, y)

Time & Space Complexity

  • Time complexity: O((max(x,y))2)O\left(\left(\max(|x|, |y|)\right)^2\right)
  • Space complexity: O((max(x,y))2)O\left(\left(\max(|x|, |y|)\right)^2\right)

Where (x,y)(x,y) is the coordinate of the target.


2. Bidirectional BFS

Intuition

Standard BFS explores an ever-growing circle from the origin. Bidirectional BFS reduces the search space by expanding from both the origin and the target simultaneously. When the two search frontiers meet, we've found the shortest path.

This optimization works well because the area explored grows quadratically with distance. By meeting in the middle, each search only needs to cover roughly half the distance, significantly reducing the total positions explored.

Algorithm

  1. Initialize two queues and two distance maps: one starting from (0, 0), one from (x, y).
  2. Alternate expanding from both ends, one position at a time.
  3. After processing a position from one side, check if it exists in the other side's distance map.
  4. If found, the answer is the sum of distances from both directions.
  5. For each position, add all unvisited knight moves to the respective queue and distance map.
class Solution:
    def minKnightMoves(self, x: int, y: int) -> int:
        # the offsets in the eight directions
        offsets = [(1, 2), (2, 1), (2, -1), (1, -2),
                   (-1, -2), (-2, -1), (-2, 1), (-1, 2)]

        # data structures needed to move from the origin point
        origin_queue = deque([(0, 0, 0)])
        origin_distance = {(0, 0): 0}

        # data structures needed to move from the target point
        target_queue = deque([(x, y, 0)])
        target_distance = {(x, y): 0}

        while True:
            # check if we reach the circle of target
            origin_x, origin_y, origin_steps = origin_queue.popleft()
            if (origin_x, origin_y) in target_distance:
                return origin_steps + target_distance[(origin_x, origin_y)]

            # check if we reach the circle of origin
            target_x, target_y, target_steps = target_queue.popleft()
            if (target_x, target_y) in origin_distance:
                return target_steps + origin_distance[(target_x, target_y)]

            for offset_x, offset_y in offsets:
                # expand the circle of origin
                next_origin_x, next_origin_y = origin_x + offset_x, origin_y + offset_y
                if (next_origin_x, next_origin_y) not in origin_distance:
                    origin_queue.append((next_origin_x, next_origin_y, origin_steps + 1))
                    origin_distance[(next_origin_x, next_origin_y)] = origin_steps + 1

                # expand the circle of target
                next_target_x, next_target_y = target_x + offset_x, target_y + offset_y
                if (next_target_x, next_target_y) not in target_distance:
                    target_queue.append((next_target_x, next_target_y, target_steps + 1))
                    target_distance[(next_target_x, next_target_y)] = target_steps + 1

Time & Space Complexity

  • Time complexity: O((max(x,y))2)O\left(\left(\max(|x|, |y|)\right)^2\right)
  • Space complexity: O((max(x,y))2)O\left(\left(\max(|x|, |y|)\right)^2\right)

Where (x,y)(x,y) is the coordinate of the target.


3. DFS (Depth-First Search) with Memoization

Intuition

Due to the knight's movement symmetry, we only need to consider the first quadrant (positive x and y). The minimum moves to reach (-x, y), (x, -y), or (-x, -y) are the same as reaching (x, y).

We can define a recursive relation: the minimum moves to reach (x, y) equals 1 plus the minimum of reaching (|x-1|, |y-2|) or (|x-2|, |y-1|). The absolute values keep us in the first quadrant, and memoization prevents redundant calculations.

Algorithm

  1. Take absolute values of x and y to work in the first quadrant.
  2. Define base cases: (0, 0) requires 0 moves; positions where x+y = 2 require 2 moves.
  3. For other positions, recursively compute: min(dfs(|x-1|, |y-2|), dfs(|x-2|, |y-1|)) + 1.
  4. Use memoization to cache results for each (x, y) pair.
  5. Return the computed minimum moves.
class Solution:
    def minKnightMoves(self, x: int, y: int) -> int:

        @lru_cache(maxsize=None)
        def dfs(x, y):
            if x + y == 0:
                # base case: (0, 0)
                return 0
            elif x + y == 2:
                # base case: (1, 1), (0, 2), (2, 0)
                return 2
            else:
                return min(dfs(abs(x - 1), abs(y - 2)), dfs(abs(x - 2), abs(y - 1))) + 1

        return dfs(abs(x), abs(y))

Time & Space Complexity

  • Time complexity: O(xy)O(|x \cdot y|)
  • Space complexity: O(xy)O(|x \cdot y|)

Where (x,y)(x,y) is the coordinate of the target.


Common Pitfalls

Not Exploiting Symmetry to Reduce Search Space

The knight's movement is symmetric across both axes. Searching the entire coordinate plane when only the first quadrant (using absolute values of x and y) is necessary wastes time and memory. Always convert to positive coordinates first.

Unbounded BFS Without Coordinate Limits

Without restricting the search area, BFS can explore positions far from both origin and target, leading to memory exhaustion. The optimal path rarely strays far beyond the target coordinates, so limiting exploration to a reasonable bounding box improves efficiency.

Missing Base Cases in Memoized DFS

The recursive solution requires proper base cases. The position (0, 0) needs 0 moves, and positions where x + y = 2 (like (1, 1), (2, 0), (0, 2)) need exactly 2 moves. Missing these base cases causes infinite recursion or incorrect results.