Skip to content

Commit 65923e1

Browse files
committed
feat(algorithms, backtracking, word-search): word search two
1 parent aa7a075 commit 65923e1

File tree

15 files changed

+399
-167
lines changed

15 files changed

+399
-167
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Word Search
2+
3+
Create a program to solve a word search puzzle.
4+
5+
In word search puzzles you get a square of letters and have to find specific words in them.
6+
7+
For example:
8+
9+
```
10+
jefblpepre
11+
camdcimgtc
12+
oivokprjsm
13+
pbwasqroua
14+
rixilelhrs
15+
wolcqlirpc
16+
screeaumgr
17+
alxhpburyi
18+
jalaycalmp
19+
clojurermt
20+
```
21+
22+
There are several programming languages hidden in the above square.
23+
24+
Words can be hidden in all kinds of directions: left-to-right, right-to-left, vertical and diagonal.
25+
26+
Create a program that given a puzzle and a list of words returns the location of the first and last letter of each word.
27+
28+
You will be provided with a Point(x, y) class which will be used to display the points of the first and last words of
29+
the found words.
30+
31+
You will be required to create a method `search` of class WordSearch that takes in a parameter `word` and searches
32+
through the provided grid for this word. It must return the Points of thw first and last letter of the word if found
33+
else return None.
34+
35+
An e.g.
36+
37+
``` python
38+
puzzle = ('jefblpepre\n'
39+
'camdcimgtc\n'
40+
'oivokprjsm\n'
41+
'pbwasqroua\n'
42+
'rixilelhrs\n'
43+
'wolcqlirpc\n'
44+
'screeaumgr\n'
45+
'alxhpburyi\n'
46+
'jalaycalmp\n'
47+
'clojurermt')
48+
49+
>>> example = WordSearch(puzzle)
50+
>>> example.search('clojure')
51+
(Point(0, 9), Point(6, 9))
52+
```
53+
54+
From the above, from the word `clojure`, **c** can be found at point 0,9 and the last letter **e** can be found at poin
55+
6, 9
56+
57+
> Note: indexes start counting from 0.
58+
59+
----
60+
61+
# Word Search 2
62+
63+
You are given a list of strings that you need to find in a 2D grid of letters such that the string can be constructed
64+
from letters in sequentially adjacent cells. The cells are considered sequentially adjacent when they are neighbors to
65+
each other either horizontally or vertically. The solution should return a list containing the strings from the input
66+
list that were found in the grid.
67+
68+
## Constraints
69+
70+
- 1 <= rows, columns <= 12
71+
- 1 <= words.length <= 3 * 10^3
72+
- 1 <= words[i].length <= 10
73+
- grid[i][j] is an uppercase English letter
74+
- words[i] consists of uppercase English letters
75+
- All the strings are unique
76+
77+
> Note: The order of the strings in the output does not matter.
78+
79+
## Examples
80+
81+
![Example 1](images/examples/word_search_two_example_1.png)
82+
![Example 2](images/examples/word_search_two_example_2.png)
83+
![Example 3](images/examples/word_search_two_example_3.png)
84+
![Example 4](images/examples/word_search_two_example_4.png)
85+
![Example 5](images/examples/word_search_two_example_5.png)
86+
87+
## Solution
88+
89+
By using backtracking, we can explore different paths in the grid to search the string. We can backtrack and explore
90+
another path if a character is not a part of the search string. However, backtracking alone is an inefficient way to
91+
solve the problem, since several paths have to be explored to search for the input string.
92+
93+
By using the trie data structure, we can reduce this exploration or search space in a way that results in a decrease in
94+
the time complexity:
95+
96+
- First, we’ll construct the Trie using all the strings in the list. This will be used to match prefixes.
97+
- Next, we’ll loop over all the cells in the grid and check if any string from the list starts from the letter that
98+
matches the letter of the cell.
99+
- Once an letter is matched, we use depth-first-search recursively to explore all four possible neighboring directions.
100+
- If all the letters of the string are found in the grid. This string is stored in the output result array.
101+
- We continue the steps of all our input strings.
102+
103+
### Time Complexity
104+
105+
The time complexity will be O(n*3^l), where n is equal to rows * columns, and l is the length of the longest string in
106+
the list. The factor 3^l means that, in the dfs() function, we have four directions to explore initially, but only three
107+
choices remain in each cell because one has already been explored. In the worst case, none of the strings will have the
108+
same prefix, so we cannot skip any string from the list.
109+
110+
### Space Complexity
111+
112+
The space complexity of this solution is O(m) where m is the total count of all the characters in all the strings
113+
present in the input list. This is actually the size of the trie data structure that is built on the list of words
114+
provided in the input.
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from copy import copy
2+
from typing import List, Set, Tuple
3+
from algorithms.backtracking.word_search.point import Point
4+
from algorithms.backtracking.word_search.constants import PLANE_LIMITS
5+
from datastructures.trees.trie import Trie, TrieNode
6+
7+
8+
class WordSearch:
9+
def __init__(self, puzzle):
10+
"""
11+
Creates a new word search object
12+
:ivar self.width will be the length of the width for this word-search object, which is the length of the
13+
first item in the list.
14+
It is assumed that all items will have same length
15+
:ivar self.height will be the height of thw object, in this case, just the length of the list
16+
:param puzzle: the puzzle which will be a tuple of words separated by newline characters
17+
"""
18+
self.rows = puzzle.split()
19+
self.width = len(self.rows[0])
20+
self.height = len(self.rows)
21+
22+
def search(self, word):
23+
"""
24+
Searches for a word in the puzzle
25+
:param word: word to search for in puzzle
26+
:return: the points where the word can be found, None if the word does not exist in the puzzle
27+
:rtype: Point
28+
"""
29+
# creates a generator object of points for each letter in the puzzle
30+
positions = (Point(x, y) for x in range(self.width) for y in range(self.height))
31+
for pos in positions:
32+
for plane_limit in PLANE_LIMITS:
33+
result = self.find_word(
34+
word=word, position=pos, plane_limit=plane_limit
35+
)
36+
if result:
37+
return result
38+
return None
39+
40+
def find_word(self, word, position, plane_limit):
41+
"""
42+
Finds the word on the puzzle given the word itself, the position (Point(x, y)) and the plane limit
43+
:param word: the word we are currently searching for, e.g python
44+
:param position: the current point on cartesian plan for the puzzle e.g Point(0, 0)
45+
:param plane_limit: the current plan limit, e.g Point(1, 0)
46+
:return: The Point where the whole word can be found
47+
:rtype: Point
48+
"""
49+
# create a copy of the passed in position
50+
curr_position = copy(position)
51+
for let in word:
52+
if self.find_char(coord_point=curr_position) != let:
53+
return
54+
curr_position += plane_limit
55+
return position, curr_position - plane_limit
56+
57+
def find_char(self, coord_point):
58+
"""
59+
finds a character on the given puzzle
60+
:param coord_point: The current copy of the current point position being sought through
61+
:return:
62+
"""
63+
if coord_point.x < 0 or coord_point.x >= self.width:
64+
return
65+
if coord_point.y < 0 or coord_point.y >= self.height:
66+
return
67+
# return the particular letter in the puzzled
68+
return self.rows[coord_point.y][coord_point.x]
69+
70+
71+
def find_strings(grid: List[List[str]], words: List[str]) -> List[str]:
72+
"""
73+
Finds the strings in the grid
74+
Args:
75+
grid (List[List[str]]): The grid to search through
76+
words (List[str]): The words to search for
77+
Returns:
78+
List[str]: The words that were found in the grid
79+
"""
80+
trie = Trie()
81+
for word in words:
82+
trie.insert(word)
83+
84+
rows_count, cols_count = len(grid), len(grid[0])
85+
result = []
86+
87+
visited: Set[Tuple[int, int]] = set()
88+
89+
# directions to move in the grid horizontally and vertically from a given cell
90+
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
91+
92+
# lambda function to check if the current cell is within the grid
93+
is_cell_within_grid = lambda r, c: 0 <= r < rows_count and 0 <= c < cols_count
94+
95+
def dfs(row: int, col: int, node: TrieNode, path: str):
96+
"""
97+
Depth-first search to find the words in the grid
98+
Args:
99+
row (int): The row of the current cell
100+
col (int): The column of the current cell
101+
node (TrieNode): The current node in the trie
102+
path (str): The current path of the word
103+
"""
104+
# check if the current node is a word
105+
if node.is_end:
106+
result.append(path)
107+
# prevent duplicates
108+
node.is_end = False
109+
# prune the word from the trie
110+
trie.remove_characters(path)
111+
112+
# We don't want to exit early, we want to continue searching for other words this is because from this node
113+
# other words can potentially be found.
114+
115+
# mark visited
116+
visited.add((row, col))
117+
118+
# explore neighbors
119+
for dr, dc in directions:
120+
new_row, new_col = row + dr, col + dc
121+
# three specific conditions must be met before calling dfs recursively
122+
# 1. the new cell must be within the grid
123+
# 2. the new cell must not be visited
124+
# 3. the new cell must be a child of the current node
125+
if (
126+
is_cell_within_grid(new_row, new_col)
127+
and (new_row, new_col) not in visited
128+
and grid[new_row][new_col] in node.children
129+
):
130+
new_character = grid[new_row][new_col]
131+
dfs(
132+
new_row, new_col, node.children[new_character], path + new_character
133+
)
134+
135+
# backtracking, remove the visited cell
136+
# so that we can explore other paths
137+
# By removing it, we ensure the cell is available again when the algorithm explores a completely different path
138+
# from a different starting point
139+
visited.remove((row, col))
140+
141+
for row in range(rows_count):
142+
for col in range(cols_count):
143+
char = grid[row][col]
144+
if char in trie.root.children:
145+
dfs(row, col, trie.root.children[char], char)
146+
147+
return result
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from algorithms.backtracking.word_search.point import Point
2+
3+
# points on cartesian plan enclosing the word grid
4+
PLANE_LIMITS = (
5+
Point(1, 0),
6+
Point(1, -1),
7+
Point(1, 1),
8+
Point(-1, -1),
9+
Point(0, -1),
10+
Point(0, 1),
11+
Point(-1, 1),
12+
Point(-1, 0),
13+
)
57.4 KB
Loading
59.5 KB
Loading
60.6 KB
Loading
53.4 KB
Loading
62 KB
Loading
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
class Point:
2+
"""
3+
Defines the blueprint of a specific point on the word grid. This point will be used to mark the position of
4+
a word on the cartesian plane.
5+
"""
6+
7+
def __init__(self, x, y):
8+
"""
9+
Creates a new cartesian point object
10+
:param x: point on x-axis
11+
:param y: point on y-axis
12+
"""
13+
self.x = x
14+
self.y = y
15+
16+
def __repr__(self):
17+
return "Point({}:{})".format(self.x, self.y)
18+
19+
def __add__(self, other):
20+
return Point(self.x + other.x, self.y + other.y)
21+
22+
def __sub__(self, other):
23+
return Point(self.x - other.x, self.y - other.y)
24+
25+
def __eq__(self, other):
26+
return self.x == other.x and self.y == other.y
27+
28+
def __ne__(self, other):
29+
return not (self == other)

tests/algorithms/test_word_search.py renamed to algorithms/backtracking/word_search/test_word_search.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import unittest
2-
3-
from algorithms.word_search import Point, WordSearch
2+
from algorithms.backtracking.word_search.point import Point
3+
from algorithms.backtracking.word_search import WordSearch
44

55

66
class WordSearchTests(unittest.TestCase):

0 commit comments

Comments
 (0)