blog/algorithms/04-recursion/2022-11-29-karel/2023-12-24-solution.md
Matej Focko 2cf4a3efba
chore: run pre-commit
Signed-off-by: Matej Focko <me@mfocko.xyz>
2024-01-03 19:38:35 +01:00

8.8 KiB

id slug title description tags last_update
solution /recursion/karel/solution Solution to the problem Solving the problem introduced in the previous post.
python
karel
recursion
backtracking
solution
date
2023-12-24

Solving the maze problem

We will go through the given problem the same way as I have suggested in the previous post.

Summary of the problem

We have a robot in some kind of a maze and we have to find our way out that is marked with a so-called “beeper”. We've been given a restriction not to use any variables, we can use just backtracking and recursion.

Brainstorming the idea

Let's start with some brainstorming of the solution.

  • How will I know what I've checked without any variables?
    • answer: recursion will need to take care of that, cause I'm not allowed anything else
  • How will I pass around the fact I've found the exit?
    • answer: I can return values from helper functions, so I should be able to indicate found/not found
  • How is the exit marked?
    • answer: there is one “beeper” as a mark
  • Can I reduce my problem somehow?
    • answer: I could check each possible direction as a reduced search space

»Rough« pseudocode

We should be able to construct a skeleton of our solution at least. Pseudocode follows:

def find_exit
    if found the exit then
        signal others
        terminate
    end

    check left
    check front
    check right
end

As you can see, we only mention what we want to do very roughly, technical details are left out, except for the early return (which is the base of our recursive function).

»Proper« pseudocode

In the proper pseudocode we will need to dive into the technical details like the way we check for exit, move around, etc.

We can start by cleaning up and decomposing the function written above:

def find_exit
    # BASE: found exit
    if found_exit() then
        return true
    end

    # check left
    if left_is_clear() then
        turn_left()
        step()
        if find_exit() then
            return true
        end

        turn_around()
        step()
        turn_left()
    end

    # check front
    if front_is_clear() then
        step()
        if find_exit() then
            return true
        end

        turn_around()
        step()
        turn_around()
    end

    # check right
    if right_is_clear() then
        turn_right()
        step()
        if find_exit() then
            return true
        end

        turn_around()
        step()
        turn_right()
    end

    return false
end

We are missing few of the functions that we use in our pseudocode above:

  • found_exit()
  • turn_around()
  • turn_right()

We can implement those easily:

def found_exit
    if not beepers_present() then
        return false
    end

    pick_beeper()
    if beepers_present() then
        put_beeper()
        return false
    end

    put_beeper()
    return true
end

def turn_around
    turn_left()
    turn_left()
end

def turn_right
    turn_around()
    turn_left()
end

Now we have everything ready for implementing it in Python.

Actual implementation

It's just a matter of rewriting the pseudocode into Python1:

class SuperKarel(Karel):
    # you can define your own helper functions on Karel here, if you wish to

    def found_exit(self) -> bool:
        if not self.beepers_present():
            return False

        self.pick_beeper()
        if self.beepers_present():
            self.put_beeper()
            return False

        self.put_beeper()
        return True

    def turn_around(self):
        for _ in range(2):
            self.turn_left()

    def turn_right(self):
        for _ in range(3):
            self.turn_left()

    def find_exit(self) -> bool:
        if self.found_exit():
            return True

        # check left
        if self.left_is_clear():
            self.turn_left()
            self.step()
            if self.find_exit():
                return True

            self.turn_around()
            self.step()
            self.turn_left()

        # check front
        if self.front_is_clear():
            self.step()
            if self.find_exit():
                return True

            self.turn_around()
            self.step()
            self.turn_around()

        # check right
        if self.right_is_clear():
            self.turn_right()
            self.step()
            if self.find_exit():
                return True

            self.turn_around()
            self.step()
            self.turn_right()

        return False

    def run(self):
        self.find_exit()

We have relatively repetitive code for checking each of the directions, I would propose to refactor a bit, in a fashion of checkin just forward, so it's more readable:

def find_exit(self) -> bool:
    if self.found_exit():
        return True

    self.turn_left()
    for _ in range(3):
        if self.front_is_blocked():
            self.turn_right()
            continue

        self.step()
        if self.find_exit():
            return True

        self.step()
        self.turn_around()
        self.turn_right()

    return False

We can also notice that turning around takes 2 left turns and turning to right does 3. We get 5 left turns in total when we turn around and right afterwards… Taking 4 left turns just rotates us back to our initial direction, therefore it is sufficient to do just one left turn (5 % 4 == 1). That way we get:

def find_exit(self) -> bool:
    if self.found_exit():
        return True

    self.turn_left()
    for _ in range(3):
        if self.front_is_blocked():
            self.turn_right()
            continue

        self.step()
        if self.find_exit():
            return True

        self.step()
        # turning around and right is same as one turn to the left
        self.turn_left()

    return False

Testing

In the skeleton, with the previous post, I have included multiple mazes that can be tested. I have tried this solution with all of the given mazes, and it was successful in finding the exit. However there is one precondition of our solution that we haven't spoken about.

We are silently expecting the maze not to have any loops. Example of such maze can be the maze666.kw:

┌─────────┬─┐
│. . . . .│.│
│ ┌─────┐ │ │
│.│. . .│.│.│
│ │ │   │ │ │
│.│.│. . .│.│
│ │ │     └─┤
│.│.│. . . 1│
│ │ │   │ ┌─┤
│.│. . .│.│.│
│ └─────┘ │ │
│. > . . .│.│
└─────────┴─┘

If you try running our solution on this map, Karel just loops and never finds the solution. Let's have a look at the loop he gets stuck in:

┌─────────┬─┐
│* * * * *│.│
│ ┌─────┐ │ │
│*│* * *│*│.│
│ │ │   │ │ │
│*│*│. * *│.│
│ │ │     └─┤
│*│*│. * * 1│
│ │ │   │ ┌─┤
│*│* * *│*│.│
│ └─────┘ │ │
│* * * * *│.│
└─────────┴─┘

He walks past the exit, but can't see it, cause there's always a feasible path that is worth trying.

:::tip Algorithm

The algorithm we have written to find the exit is a depth-first search (DFS). However, as opposed to the usual implementation, we have no notion of paths that are being (or have already been) explored.

:::

Fixing the issue

Since we are not allowed to use variables, the only way to resolve this issue is to mark the “cells” that we have tried. We can easily use beepers for this, but we need to be careful not to confuse the exit with already visited cell.

To do that we'll use 2 beepers instead of the one. Implementation follows:

def visited(self) -> bool:
    if not self.beepers_present():
        return False

    self.pick_beeper()
    if not self.beepers_present():
        self.put_beeper()
        return False

    self.pick_beeper()
    if self.beepers_present():
        assert False, "no cell shall be marked with 3 beepers"

    self.put_beeper()
    self.put_beeper()
    return True


def find_exit(self) -> bool:
    # BASE: already tried
    if self.visited():
        self.turn_around()
        return False

    # BASE
    if self.found_exit():
        return True

    # mark the cell as visited
    for _ in range(2):
        self.put_beeper()

    self.turn_left()
    for _ in range(3):
        if self.front_is_blocked():
            self.turn_right()
            continue

        self.step()
        if self.find_exit():
            return True

        self.step()
        # turning around and right is same as one turn to the left
        self.turn_left()

    return False

Now our solution works also for mazes that have loops.


  1. which is usually very easy matter ↩︎