def solveProblem(self, problem: Sudoku):
        # Fill in all the possible tiles with educated predictions.
        while True:
            if self.applyNakedSingle(problem):
                continue
            if self.applyNakedTuple(problem):
                continue
            break

        # Check if we find the solution after applying Sudoku.
        if self.goalTest(problem):
            return problem

        # Save updated state of the problem before making guesses with candidates.
        beforeUpdateProblem = copy.deepcopy(problem)

        # Backtrack from populated sudoku problem
        tile = self.findMostConstrainedTile(problem)
        for index in range(len(tile.candidates)):
            if not problem.isConflicting(tile.candidates[index],
                                         rowIndex=tile.rowIndex,
                                         colIndex=tile.colIndex):
                problem.assignValue(value=tile.candidates[index],
                                    row=tile.rowIndex,
                                    col=tile.colIndex)
                updatedProblem = self.solveProblem(problem)
                if self.goalTest(updatedProblem):
                    return updatedProblem
                else:
                    problem = beforeUpdateProblem
                    tile = problem.state[tile.rowIndex][tile.colIndex]
        # No candidates, Hit dead end
        return None