From 5f9a0d1990855ec70197d4360c8fcf96b12fca92 Mon Sep 17 00:00:00 2001 From: Cameron Blankenbuehler Date: Tue, 30 Aug 2022 18:00:04 -0400 Subject: [PATCH] Refactor to account for multi-letter nodes Updates WordTree and WordNodes to build trees with WordNodes containing blocks that have multiple letters such as "Qu". --- boggler_utils.py | 175 ++++++++++++++++++++++++++++++----------------- 1 file changed, 112 insertions(+), 63 deletions(-) diff --git a/boggler_utils.py b/boggler_utils.py index 140bd2f..d9a33b2 100644 --- a/boggler_utils.py +++ b/boggler_utils.py @@ -2,15 +2,16 @@ from __future__ import annotations from os import path +from itertools import islice import sys class BoardCell: '''Boggle Board cell''' - def __init__(self, row: int, col: int, letter: str, adjacent_cells: list[BoardCell] = None) -> BoardCell: + def __init__(self, row: int, col: int, letters: str, adjacent_cells: list[BoardCell] = None) -> BoardCell: self.__row: int = row self.__col: int = col self.__pos: (int, int) = (self.__row, self.__col) - self.__letter: str = letter + self.__letters: str = letters self.__adjacent_cells: list[BoardCell] = adjacent_cells @property @@ -29,9 +30,9 @@ class BoardCell: return self.__pos @property - def letter(self) -> str: + def letters(self) -> str: '''Getter for letter property''' - return self.__letter + return self.__letters @property def adjacent_cells(self) -> list[BoardCell]: @@ -43,7 +44,10 @@ class BoardCell: self.__adjacent_cells = value def __str__(self): - return f"({self.__row}, {self.__col}): {self.__letter}" + return f"({self.__row}, {self.__col}): {self.__letters}" + + def __repr__(self): + return f"({self.__row}, {self.__col}): {self.__letters}" class BoggleBoard: '''Boggle board structure''' @@ -114,23 +118,23 @@ class BoggleBoard: class WordNode: '''A node describing a single letter in a WordTree.''' - def __init__(self, letter: str, is_word: bool = False, parent: WordNode = None, children: dict[str, WordNode] = None, board_pos = None) -> WordNode: - self.__letter = letter + def __init__(self, letters: str, is_word: bool = False, parent: WordNode = None, children: dict[str, WordNode] = None, board_pos = None) -> WordNode: + self.__letters = letters self.__is_word = is_word self.__children = children if children is not None else {} self.__parent = parent self.__board_pos = board_pos @property - def char(self) -> str: - '''Getter for char property''' - return self.__letter + def letters(self) -> str: + '''Getter for letter property''' + return self.__letters @property def is_word(self) -> bool: '''Getter for is_word property''' return self.__is_word - + @is_word.setter def is_word(self, value): self.__is_word = value @@ -151,8 +155,8 @@ class WordNode: return self.__board_pos def add_child_node(self, node): - '''Add child node to `children` dictionary, indexed by the nodes' `char`''' - self.children[node.char] = node + '''Add child node to `children` dictionary, indexed by the nodes' `letter`''' + self.children[node.letters] = node @property def path(self) -> list[WordNode]: @@ -167,7 +171,7 @@ class WordNode: return path def __str__(self): - return f"WordNode: {self.char}, {self.is_word}" + return f"WordNode: {self.letters}, {self.is_word}" def __repr__(self): return self.__str__() @@ -175,16 +179,16 @@ class WordNode: class WordTree: '''A tree populated by WordNode(s) to complete words from a given root letter and wordlist''' def __init__(self, alphabet: str, root: WordNode, words: list[str] = None, max_word_len = 16) -> WordTree: - self.__alphabet = alphabet - self.__wordlist = words - self.__root = root - self.__max_word_len = max_word_len - self.__tree = {} - self.__leaf_nodes = [] + self.__alphabet: str = alphabet + self.__wordlist: list[str] = words + self.__root: WordNode = root + self.__max_word_len: int = max_word_len + self.__tree: dict[str, WordNode] = {} + self.__leaf_nodes: dict[str, list[WordNode]] = [] # Generate root node - self.__tree[root.char] = root - self.active_node: WordNode = self.__tree[root.char] + self.__tree[root.letters] = root + self.active_node: WordNode = self.__tree[root.letters] # Populate tree from wordlist if words is not None: @@ -202,7 +206,7 @@ class WordTree: return self.__wordlist @property - def root(self) -> str: + def root(self) -> WordNode: '''Getter for root property''' return self.__root @@ -228,38 +232,75 @@ class WordTree: def __str__(self): return ", ".join(self.wordlist) - def insert_letter(self, letter: str, parent: WordNode, is_word: bool = False, children: dict[str, WordNode] = None, board_pos = None): - '''Create WordNode for `letter` and into WordTree under `parent`''' - node = WordNode(letter, is_word, parent, children, board_pos) + def insert_node(self, letters: str, parent: WordNode, is_word: bool = False, children: dict[str, WordNode] = None, board_pos = None): + '''Create WordNode for `letters` and into WordTree under `parent`''' + node = WordNode(letters, is_word, parent, children, board_pos) parent.add_child_node(node) def __insert_word(self, word: str) -> bool: - '''Returns True if word was inserted into the tree, otherwise False''' - if word is None or word[0] != self.root.char: + '''Returns True if the word could be inserted into the tree with the given alphabet, otherwise returns False''' + + # Insert root node + prefix = word[0:len(self.root.letters)] # prefix is equivalent to the first letter block in a word + if word is None or prefix != self.root.letters: return False - curr_node = self.tree[word[0]] - for letter in word[1:self.max_word_len]: - # Insert new WordNode for each letter that doesen't already exist in the tree - if letter not in curr_node.children: - self.insert_letter(letter, curr_node) - curr_node = curr_node.children[letter] - # Mark the last node of the word + curr_node = self.tree[prefix] + word_len = len(word) + + # Insert remaining nodes as letter groups based on alphabet + skip_cnt = 0 + for i, letter in enumerate(word[len(prefix):self.max_word_len]): + # Skip word letter iterations for length of a previously inserted letter group + if skip_cnt > 0: + skip_cnt -= 1 + continue + + try: + alpha_index = [x[0] for x in self.alphabet].index(letter) + except ValueError: # letters not in given alphabet + return False + + # Insert letter (single) + if letter == self.alphabet[alpha_index] and letter not in curr_node.children: # letter == letter_group implies single letter + self.insert_node(letter, curr_node) + curr_node = curr_node.children[letter] + # Letter already exists + elif letter == self.alphabet[alpha_index]: + curr_node = curr_node.children[letter] + # Check for letter groups (like "Qu") starting with `letter` in alphabet + else: + alpha = self.alphabet[alpha_index] + # Check that letter group is shorter than and matches in the word remainder + if len(alpha) < word_len - i and alpha == word[i+1:i+1+len(alpha)]: + if alpha not in curr_node.children: + self.insert_node(alpha, curr_node) + curr_node = curr_node.children[alpha] + else: # node already exist + curr_node = curr_node.children[alpha] + + skip_cnt = len(alpha) - 1 + #print(f"LONG GROUP: {alpha}; {word}") + else: + return False + + # Mark the last node as a word curr_node.is_word = len(word) <= self.max_word_len #print(f"Added: {word[:self.max_word_len]}") return True - def search(self, word: str) -> WordNode: - '''Return True if a given word is in the tree otherwise return False''' + def search(self, word: str, curr_node = None) -> WordNode: + '''Return leaf (WordNode) if a given word is in the tree otherwise return None''' if len(word) == 0 or word is None: - return False + return curr_node - curr_node = self.tree[word[0]] - for char in word[1:]: - if char not in curr_node.children: - return None - - curr_node = curr_node.children[char] + if curr_node is None: + curr_node = self.root + for letters in curr_node.children: + if letters == word[len(curr_node.letters):len(curr_node.letters) + len(letters)]: + return self.search(word[len(curr_node.letters):], curr_node.children[letters]) + # Currently only returns single path for word + # TODO: return all possible paths, maybe create minature WordTree? Or just of list of paths. return curr_node # TODO: implement tree walk @@ -275,21 +316,21 @@ class WordTree: dict_node -- a pointer on the dictionary where nodes are read from for validation subtree -- the partial tree passed to the next recursive step for generating branches ''' - - # TODO rework active_node refs for recursion so don't have to be reset to parent at every point of return - # if self.active_node.is_word: - # # word_path = subtree.active_node.path[::-1] - # # print("WORD FOUND:", "".join([board.board[x].letter for x in word_path]), word_path) - # subtree.leaf_nodes.append(self.active_node) + # TODO rework active_node refs for recursion so don't have to be reset to parent at every point of return if self.active_node.is_word and len(self.active_node.children) == 0: - #word_path = subtree.active_node.path[::-1] - # print("WORD FOUND:", "".join([board.board[x].letter for x in word_path]), word_path) + word_path = subtree.active_node.path[::-1] + #print("WORD FOUND:", "".join([board.board[x].letters for x in word_path]), word_path) self.active_node = self.active_node.parent subtree.active_node = subtree.active_node.parent return - elif depth > self.max_word_len: - # print(f"MAX DEPTH REACHED! Depth = {depth}") + elif self.active_node.is_word: + word_path = subtree.active_node.path[::-1] + print("WORD FOUND:", "".join([board.board[x].letters for x in word_path]), word_path) + #subtree.leaf_nodes.append(self.active_node) + + if depth > self.max_word_len: + print(f"MAX DEPTH REACHED! Depth = {depth}") self.active_node = self.active_node.parent subtree.active_node = subtree.active_node.parent return @@ -297,11 +338,11 @@ class WordTree: # Branch for each adjacent board cell for cell in board_cell.adjacent_cells: # Check dictionary and exclude nodes already in path - if cell.letter in self.active_node.children and cell.pos not in subtree.active_node.path: - new_node = WordNode(cell.letter, self.active_node.is_word, subtree.active_node, board_pos = cell.pos) + if cell.letters in self.active_node.children and cell.pos not in subtree.active_node.path: + new_node = WordNode(cell.letters, self.active_node.is_word, subtree.active_node, board_pos = cell.pos) subtree.active_node.add_child_node(new_node) - subtree.active_node = subtree.active_node.children[cell.letter] # update subtree pointer - self.active_node = self.active_node.children[cell.letter] # update dictionary tree pointer + subtree.active_node = subtree.active_node.children[cell.letters] # update subtree pointer + self.active_node = self.active_node.children[cell.letters] # update dictionary tree pointer self.build_boggle_tree(board, board.board[cell.pos], subtree, depth=depth+1) # print(f"DONE SEARCHING at {subtree.active_node}") @@ -313,13 +354,15 @@ class WordTree: def build_full_boggle_tree(board: BoggleBoard, wordlist_path: str) -> dict[str, WordTree]: '''Return dictionary of WordTree(s) for every letter on a BoggleBoard''' # TODO: implement multiprocessing / multitthreading for each start letter - alphabet = "".join(sorted(set([cell.letter for cell in board.board.values()]))) + # TODO: benchmark on laptop and chromebook + alphabet = sorted(set([cell.letters for cell in board.board.values()])) + print(f"alphabet: {alphabet}") board_tree = {} index = {} print("Reading in wordlists...") for letter in alphabet: - filename = "words_" + letter + ".txt" + filename = "words_" + letter[0] + ".txt" print(f">> {letter}: {filename}") wordlist = read_wordlist(path.join(path.abspath(wordlist_path), filename)) index[letter] = wordlist @@ -327,8 +370,8 @@ def build_full_boggle_tree(board: BoggleBoard, wordlist_path: str) -> dict[str, print("Generating WordTrees...") for cell in board.board.values(): print(f">> {cell}") - dict_tree = WordTree(alphabet, WordNode(cell.letter), index[cell.letter]) - sub_tree = WordTree(alphabet, WordNode(cell.letter, False, board_pos=cell.pos)) + dict_tree = WordTree(alphabet, WordNode(cell.letters), index[cell.letters]) + sub_tree = WordTree(alphabet, WordNode(cell.letters, False, board_pos=cell.pos)) board_tree[cell.pos] = dict_tree.build_boggle_tree(board, cell, sub_tree) return board_tree @@ -344,6 +387,12 @@ def read_boggle_file(file): return [x.rstrip().split(',') for x in file.readlines()] if __name__ == "__main__": - b2 = read_boggle_file(sys.argv[1]) + b2 = read_boggle_file("boards/b1.csv") + #b2 = read_boggle_file(sys.argv[1]) b2_board = BoggleBoard(b2) + print([x.letters for x in b2_board.board.values()]) boggle_tree = build_full_boggle_tree(b2_board, "wordlists/dwyl") + print("Search 'quay':", boggle_tree[(1,1)].search('quay').path) + print("Search 'quip':", boggle_tree[(1,1)].search('quip').path) + print("Search 'squall':", boggle_tree[(0,0)].search('squall').path) + print("Search 'quash':", boggle_tree[(1,1)].search('quash').path)