Perl Weekly Challenge 281: Knight's Move

These are some answers to the Week 281, Task 2, of the Perl Weekly Challenge organized by Mohammad S. Anwar.

Spoiler Alert: This weekly challenge deadline is due in a few days from now (on August 11, 2024, at 23:59). This blog post provides some solutions to this challenge. Please don’t read on if you intend to complete the challenge on your own.

Task 2: Knight’s Move

A Knight in chess can move from its current position to any square two rows or columns plus one column or row away. So in the diagram below, if it starts a S, it can move to any of the squares marked E.

Write a script which takes a starting position and an ending position and calculates the least number of moves required.

week_281_task_2.png

Example 1

Input: $start = 'g2', $end = 'a8'
Ouput: 4

g2 -> e3 -> d5 -> c7 -> a8

Example 2

Input: $start = 'g2', $end = 'h2'
Ouput: 3

g2 -> e3 -> f1 -> h2

This is a classical computer science topic. In fact, I had to implement almost the same task in C and in Pascal as a homework exercise in the beginning of my CS studies many years ago. It is an opportunity to study and understand First-In First-Out (FIFO) data structures such as queues, as opposed to Last-in First Out (LIFO) data structures, aka stacks. It is also an occasion to work on Breadth-First Search (BFS) algorithms (as opposed to Depth-First Search (DFS) algorithms) for creating and traversing a tree.

BFS traverses a tree by visiting all possible moves level by level, so that as soon as we find a solution, we know it is the shortest path (or, rather, one of the shortest paths) and can stop iteration and return the level value. In DFS, by contrast, you would need to explore essentially all possible paths to make sure you've found the shortest one.

A final point before we go on: how do we model the chess board? I considered various solutions and decided to transform the chess notation a to h abscissas to a 0 to 7 range. For the ordinates, we subtract 1 to convert the 1 to 8 range to a 0 to 7 range. For example, the c2 square would be transformed to rectangular or Cartesian coordinates (2, 1). This conversion is performed by the to-coordinates subroutine. week_281_task_1.png

Knight’s Move in Raku

The authorized moves for a knight are modeled by @moves, an array of eight pairs representing the values to be added to the coordinates of one square to find the next square.

The @to-be-explored array contains subarrays describing the next squares to be visited along with the level depth (i.e. the number of moves to reach this square). The @to-be-explored array is initialized with the starting position (and a depth of 0). The %seen hash contains the squares that have already been visited (we simply stringify the pair of coordinates to build the hash key). The %seen hash is also initialized with the starting position.

The process traverses the @to-be-explored array and return the depth if it is the target square. Otherwise, for each item, it computes the next position that would be reached with each of the eight possible moves. This next position is dismissed if it falls out of the chess board (unauthorized move) or if it has already been visited. Else, the next position is added to the %seen hash and to the @to-be-explored array.

my @moves = <2 1>, <2 -1>, <1 2>, <1 -2>, 
            <-1 2>, <-1 -2>, <-2 1>, <-2 -1>;

sub to-coordinates ($in) {
    my ($col, $row) = $in.comb;
    return $col.ord - 'a'.ord, $row - 1;
}

sub find-shortest ($st-in, $end-in) {
    # convert input to Cartesian coordinates
    my @start = to-coordinates $st-in;
    my @end = to-coordinates $end-in;

    my @to-be-explored;  # a queue of squares to be visited    
    push @to-be-explored, (0, @start).flat; 
    my %seen = "@start[]" => 1;  # already visited squares

    while @to-be-explored {
        my @node = shift @to-be-explored;
        my ($depth, @current) = @node[0];
        return $depth if "@current[]" eq "@end[]";
        for @moves -> @move {
            my @next = @current[0] + @move[0], 
                       @current[1] + @move[1];
            # dismiss if computed position not on chessboard
            next if @next.any > 7 or @next.any < 0;
            # dismiss if computed position already visited
            next if %seen{"@next[]"}:exists;
            # update seen hash and to-be-explored queue
            %seen{"@next[]"} = 1;
            push @to-be-explored, ($depth + 1, @next).flat;
        }
    }
}

my @tests = <g2 a8>, <g2 h2>;
for @tests -> @test {
    printf "%-6s => ", "@test[]"; 
    say find-shortest @test[0], @test[1];
}

This program displays the following output:

$ raku ./shortest-knight-path.raku
g2 a8  => 4
g2 h2  => 3

Knight’s Move in Perl

This is a port to Perl of the Raku program above. Please refer to the rather large chunks of information provided in the two sections above if you need further information.

use strict;
use warnings;
use feature 'say';

my @moves = ( [<2 1>], [<2 -1>], [<1 2>], [<1 -2>], 
              [<-1 2>], [<-1 -2>], [<-2 1>], [<-2 -1>] );

sub to_coordinates {
    my ($col, $row) = split //, shift;
    return ord($col) - ord('a'), $row - 1;
}

sub find_shortest  {
    my ($st_in, $end_in) = @_;
    # convert input to Cartesian coordinates
    my @start = to_coordinates $st_in;
    my @end = to_coordinates $end_in;

    my @to_be_explored;  # a queue of squares to be visited    
    push @to_be_explored, [0, @start]; 
    my %seen = ("@start" => 1);  # already visited squares
    while (@to_be_explored) {
        my $node = shift @to_be_explored;
        my ($depth, @current) = @$node;
        return $depth if "@current" eq "@end";
        for my $move (@moves) {
            my @next = ( $current[0] + $move->[0], 
                         $current[1] + $move->[1] );
            # dismiss if computed position not on chessboard
            next if $next[0] > 7 or $next[0] < 0 or
                    $next[1] > 7 or $next[1] < 0;
            # dismiss if computed position already visited
            next if exists $seen{"@next"};
            # update seen hash and to_be_explored queue
            $seen{"@next"} = 1;
            push @to_be_explored, [$depth + 1, @next];
        }
    }
}

my @tests = ([<g2 a8>], [<g2 h2>]);
for my $test (@tests) {
    printf "%-6s => ", "@$test"; 
    say find_shortest @$test;;
}

This program displays the following output:

$ perl  ./shortest-knight-path.pl
g2 a8  => 4
g2 h2  => 3

Wrapping up

The next week Perl Weekly Challenge will start soon. If you want to participate in this challenge, please check https://perlweeklychallenge.org/ and make sure you answer the challenge before 23:59 BST (British summer time) on August 18, 2024. And, please, also spread the word about the Perl Weekly Challenge if you can.

Leave a comment

About laurent_r

user-pic I am the author of the "Think Perl 6" book (O'Reilly, 2017) and I blog about the Perl 5 and Raku programming languages.