The Python Class that landed me an engineering role

My junior year of high school was the first year when (for myriad reasons) I really shifted my mindset from “let’s take as many classes as possible” to “what career path am I even on?” In a bid to decide an answer to that very question, I was in three AP math classes that year: computer science, calculus, and statistics.

But something about AP Computer Science just didn’t resonate with me. I thoroughly enjoyed working with the teacher (shoutout Mrs. Ferran), but whether it was the Java-based curriculum or the verbosity of the language itself, it ultimately turned me away from a developer-focused career path and more toward my pure math classes…which eventually led to me spending my 20s as an actuary.

Fast forward 13 years later to February 2022, when I’m claiming in this article that I wrote a Python class that powered me into my first role as a software engineer…it’s a surreal feeling! I definitely have a ton more to say about the amazing actuarial teams I worked with, the profession as a whole, why it took me a decade to figure out it wasn’t for me, and words of encouragement/caution for those thinking about pursuing it themselves…but those are all better topics for another post.

The Python Bite

When I first started incorporating Python into my actuarial work back in mid-2019, it felt more natural to me than any other programming language I had touched to that point (Java, VBA, SAS…there are likely a couple others). I’m not alone in this sentiment, and I think the reasons for this phenomenon are well-documented by others (especially Michael Kennedy’s excellent Python podcast). Where other languages got in the way with syntax, Python got out of the way at a time when someone like me, still a non-developer-developer, was looking to create actuarial automations that would “just work”.

To be fair, I’m far enough down the engineering path at this point to start appreciating the value provided by more strongly-typed languages, and I also love where Python’s type hinting is going. I just want to emphasize here that Python’s relatively low bar to entry (not to mention the amazing open source community and packages) were essential in igniting the spark that eventually pushed me into career change.

When I started getting into technical interview prep seriously a few months ago, Python became my go-to tool when given the choice of language for the assessment (which was pretty common in my experience). Through a combination of personal projects, automation scripts, and sharing my work with other teams/departments, I had reached a point where I didn’t have to reserve bandwidth for Python syntax anymore because of how natural it felt. I had become comfortable enough to focus entirely on the technical interview questions themselves and how to problem solve them.

At the peak of my interview prep, I was doing a few problems on CodeWars per day, which boosted my problem solving confidence quite a bit more. And one fateful “Kata” I received one day ended up pushing me over the edge in a subsequent interview…

The Brute Force Recursive Sudoku Solver

Old copy of Penguin Sudoku 2006 I had lying around.
Old copy of Penguin Sudoku 2006 I had lying around.

Up to this point in my life, I had written very few actual Python classes. Most of my scripts at work had relied entirely on self-taught functional programming (which I was relieved to learn is a totally valid coding paradigm), in combination with popular open source libraries. Maybe I just never discovered the right learning resources for me, but I always found myself struggling to justify writing a class for anything…could this have been the same mental block that prevented me from gelling with Java all those years ago?

So, I’d like to formally thank the CodeWars algorithm for serving this one up for me right before a technical final interview a couple months back:

Write a function that will solve a 9x9 Sudoku puzzle. The function will take one argument consisting of the 2D puzzle array, with the value 0 representing an unknown square.

The Sudokus tested against your function will be “easy” (i.e. determinable; there will be no need to assume and test possibilities on unknowns) and can be solved with a brute-force approach.

For Sudoku rules, see the Wikipedia article.

I twisted my brain into knots for a few minutes…I suppose it could be easy enough to check a 2D array’s rows and columns for Sudoku validity, but what about those 3x3 mini grids I have to check? Should I do some [i:i+3] shenanigans in a few places?

Then, a spark…just make a Cell class! If I do that, I can give it all three attributes of a Sudoku board that need validation (row, column, and mini-grid), instead of just the inherent two dimensions (row and column) that a 2D array has. But let’s leave no room for error with that extra mini-grid attribute (which I call square in the code): we know it’s a function of the row and column! So we initialize it as such:

class Cell:
    """Represents a single [row, col] location on a Sudoku board."""
    def __init__(self, value, row, col):
        self.value = value
        self.row = row
        self.col = col
        self.square = ((row - 1) // 3) * 3 + ((col - 1) // 3) + 1

    def __repr__(self):
        return f"Cell(Value {self.value} at row {self.row}, col {self.col}, square {self.square})"

    def __str__(self):
        return f"Cell(Value {self.value} at row {self.row}, col {self.col}, square {self.square})"

But why stop at just the Cell? (I had thoughts about reverting to my functional comfort zone here, but I forced myself to power down the object-oriented road and see what it would yield.)

With the Cell’s attributes safely tucked away, I started to write another class to represent the entire Sudoku Board. In my head, it needed to:

  • Contain an array of 81 of our Cells to represent the full board
  • Be capable of validating rows, columns and squares (no matter if the puzzle is unfinished or finished)
  • Be able to iterate through its own empty cells, trying all possible values and progressing/backing up repeatedly until the solution is found

Once I had these goals defined, it was just a matter of working through each goal and realizing/building what I needed! Here’s a sample of my rough thought process:

  • We need to be able to validate entire rows to make sure they contain each of the numbers 1–9…it would be helpful to have a method that returns all Cells in a given row! (Same for columns and squares)
  • Now we can build the validation methods on top of these methods
  • If every validation passes for a given cell, we move on to the next blank cell
  • If we run out of values that work in our current cell, it must mean a value we placed in a prior cell on the Board was wrong — let’s go back and change it
  • Repeat until board is solved!

The full code is below if you’re interested in the exact implementation I cooked up. But more importantly, Python had opened up yet another locked door in my brain. And just two weeks later, it paid off in a big way during a two hour technical interview. The entire second hour? “Design a parking lot using object-oriented principles.” Crushing victory — the job offer came next week!

Now for the twist: I was fortunate enough to have the luxury of choice and actually ended up declining this offer in favor of another one…but I think the article title is still technically true. =]

For those of you deep in the arc of career change like me, I wish you the best of luck! No matter who you are, thanks for reading — you can reach out to me anytime and let me know what you thought of this piece. I’ll be sure to share more stories from the transition soon, in addition to my new daily adventures as an actuary-turned-developer.

Full implementation:

def sudoku(puzzle):
    """
    Entry point to the program. Takes in a 9x9 2D array of integers
    representing a Sudoku board, and returns the solved board.
    """
    board = Board(puzzle)
    board.brute_force_board()
    return board.get_board_as_array()


class Board:
    def __init__(self, puzzle):
        """
        The constructor takes in a 9x9 2D array of integers and
        converts them to cell objects.
        """
        self.cells = []
        for i, row in enumerate(puzzle):
            for j, value in enumerate(row):
                self.cells.append(Cell(value, i+1, j+1))

    def get_board_as_array(self):
        """Helper function to convert the Cell objects back to a 2D array."""
        return [self.get_row(i) for i in range(1, 10)]

    def get_cell(self, row, col):
        return self.cells[(row * 9 - 9) + col - 1]

    def set_cell(self, row, col, new_value):
        self.get_cell(row, col).value = new_value

    def get_row(self, row):
        return [cell.value for cell in self.cells if cell.row == row]

    def get_column(self, column):
        return [cell.value for cell in self.cells if cell.col == column]

    def get_square(self, square):
        return [cell.value for cell in self.cells if cell.square == square]

    def get_cell_possibilities(self, row, col):
        """
        For blank Cells, this will return all possible values that could
        work in the space.
        """
        cell = self.get_cell(row, col)
        # if the cell is already filled out, just return that value
        if cell.value > 0:
            return [cell.value]
        # otherwise, return a list of possibilities
        else:
            overlapping_values = list(set(self.get_row(row) + self.get_column(col) + self.get_square(cell.square)))
            return [i for i in [1,2,3,4,5,6,7,8,9] if i not in overlapping_values]

    def get_unsolved_cells(self):
        return [cell for cell in self.cells if cell.value == 0]

    def partial_sudoku_set(self, sudoku_set):
        """
        Validates a partially-finished 1-9 component of the board
        (so blanks are okay, but duplicate values still aren't!
        """
        # remove zeroes and check that the length
        # doesn't change when comparing to a set of itself
        # (that would imply duplicate values)
        sudoku_set_no_zeroes = [value for value in sudoku_set if value > 0]
        if len(set(sudoku_set_no_zeroes)) == len(sudoku_set_no_zeroes):
            return True
        else:
            return False

    def full_sudoku_set(self, sudoku_set):
        """Validates a fully-finished 1-9 component of the board."""
        if set(sudoku_set) == set([1, 2, 3, 4, 5, 6, 7, 8, 9]):
            return True
        else:
            return False

    def valid_board(self):
        """Perform partial validation on the entire board."""
        for i in range (1, 10):
            # check each row, column, and square for validity
            if (
                not self.partial_sudoku_set(self.get_row(i))
                or not self.partial_sudoku_set(self.get_column(i))
                or not self.partial_sudoku_set(self.get_square(i))
            ):
                return False

        # all checks passed!
        return True

    def solved_board(self):
        """Perform full validation on the entire board."""
        for i in range (1, 10):
            # check each row, column, and square for completeness
            if (
                not self.full_sudoku_set(self.get_row(i))
                or not self.full_sudoku_set(self.get_column(i))
                or not self.full_sudoku_set(self.get_square(i))
            ):
                return False

        # all checks passed!
        return True

    def brute_force_board(self):
        """
        Put all of our validation together and go through the board recursively
        to solve it.
        """
        # is the board solved? if so, return the board!
        if self.solved_board():
            return True
        # if not, figure out what's left unsolved
        still_unsolved = self.get_unsolved_cells()
        solving = still_unsolved[0]

        # if there is no valid value for the current cell,
        # return False and go up a layer to try a new value
        values_to_try = self.get_cell_possibilities(solving.row, solving.col)
        if not values_to_try:
            return False

        for value in values_to_try:
            self.set_cell(solving.row, solving.col, value)
            # go a layer deeper if the board is still valid.
            # otherwise, the loop will continue with the next value
            if not self.valid_board():
                continue
            if not self.brute_force_board():
                # if we're out of values, we need to set back to 0 and go up a layer
                self.set_cell(solving.row, solving.col, 0)
                continue
            else:
                # board is solved!
                return True

        # if we couldn't brute force, we need to try the next value
        # self.set_cell(solving.row, solving.col, 0)
        return False

    def __str__(self):
        string_bucket = []
        for i in range(0, 81, 9):
            join_bucket = []
            for cell in self.cells[i:i+9]:
                if cell.value > 0:
                    join_bucket.append(str(cell.value))
                else:
                    join_bucket.append(' ')
            string_bucket.append(" | ".join(join_bucket))

        return ("\n--" + "+---"*7 + "+--\n").join(string_bucket)


class Cell:
    """Represents a single [row, col] location on a Sudoku board."""
    def __init__(self, value, row, col):
        self.value = value
        self.row = row
        self.col = col
        self.square = ((row - 1) // 3) * 3 + ((col - 1) // 3) + 1

    def __repr__(self):
        return f"Cell(Value {self.value} at row {self.row}, col {self.col}, square {self.square})"

    def __str__(self):
        return f"Cell(Value {self.value} at row {self.row}, col {self.col}, square {self.square})"