Skip to content

Supahands Coding Test Submission #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__/
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
# How to run
```sh
# Main program
python3 main.py

# Alternatively, if there's too many to read:
python3 main.py --first
```

# Assumptions made
1. The graph provided is a Directed Acylic Graph and will always terminate. Therefore, I used a simple recursive approach to get all the possible paths and converted it into a Linked List. This allows for simpler traversal and handling of the hunting simulations. That said, if the graph is changed to one that has cycles, it will break the crawler since the recursion would never end.

2. Regarding hunting ..
> A node can be hunted in multiple times
>
> this allows D&D to bag 2 boars a turn

Since this implies that a node can only be hunted at at most _once_, I've implemented divide and conquer strategy for the Hunters. Meaning, they will never hunt together on the same node. This, however, makes it take way more turns than necessary for the shortest path of [A -> K] since one person will board the chopper and not assist the other Hunter.
# supahands-coding-test
* This is the coding test for prospective supahands engineers.
* It is a modified traveling salesman problem using a graph represented in code as an adjacency matrix in the variable hunting_map.
Expand Down
Empty file added __init__.py
Empty file.
94 changes: 75 additions & 19 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,75 @@
def main():
prey = 0
hunting_map = {
'A':['B','C','K'],
'B':['D','E'],
'C':['E','G','H'],
'D':['E','F'],
'E':['G','I','F'],
'F':['I','J'],
'G':['I','K'],
'H':['I','F'],
'I':['K'],
'J':['K'],
'K':[]
}
print(hunting_map)
print(prey)

main()
from modules import Hunter, Node, Path, Runner
from pprint import pprint
import argparse

parser = argparse.ArgumentParser(description='Supahands coding test')
parser.add_argument('--first', action='store_true',
help='Lists only the first result (default: lists all)')

args = parser.parse_args()


hunting_map = {
'A': ['B', 'C', 'K'],
'B': ['D', 'E'],
'C': ['E', 'G', 'H'],
'D': ['E', 'F'],
'E': ['G', 'I', 'F'],
'F': ['I', 'J'],
'G': ['I', 'K'],
'H': ['I', 'F'],
'I': ['K'],
'J': ['K'],
'K': []
}


def path_crawler(hm, path=['A'], paths=[]):
"""Given a matrix, return a list of all available paths

It works similarly to Depth-First Search. Starting from a
root element, it recursively iterates down a single path
until it reaches the terminal. It returns the given path,
and works its way back up until it gets to a node that has
an unvisited element.

Note: Only works on Directed Acyclic Graphs. If the graph
has a cycle, the recursion will not stop.

Pseudocode based on the given hunting map:

A -> B -> D -> E -> G -> I -> K
Returns to I, it doesn't have another element
Returns to G, goes down to K.
This returns:
A -> B -> D -> E -> G -> I -> K
Returns to I
Returns to G
Returns to E
A -> B -> D -> E -> I -> K
And so on.
"""
d = path[-1]
if d in hm and hm[d]: # Checks for empty array
for val in hm[d]:
new_path = path + [val]
paths = path_crawler(hm, new_path, paths)
else:
paths += [path]
return paths


def path_generator(paths=[]):
"""Given a list of paths, returns a list of Path with Nodes"""
return [Path(nodes) for nodes in paths]


if __name__ == "__main__":
hunters = [Hunter('Dutch'), Hunter('Dylan')]
paths = path_generator(path_crawler(hunting_map, path=['A']))
runner = Runner(hunters=hunters, paths=paths)
runner.run()
if args.first:
pprint(runner.stats[0])
else:
pprint(runner.stats)
56 changes: 56 additions & 0 deletions main_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import unittest

from main import path_crawler

hunting_map = {
'A': ['B', 'C', 'K'],
'B': ['D', 'E'],
'C': ['E', 'G', 'H'],
'D': ['E', 'F'],
'E': ['G', 'I', 'F'],
'F': ['I', 'J'],
'G': ['I', 'K'],
'H': ['I', 'F'],
'I': ['K'],
'J': ['K'],
'K': []
}

# Hand-crawled validation data
expected_paths = [
['A', 'B', 'D', 'E', 'G', 'I', 'K'],
['A', 'B', 'D', 'E', 'G', 'K'],
['A', 'B', 'D', 'E', 'I', 'K'],
['A', 'B', 'D', 'E', 'F', 'I', 'K'],
['A', 'B', 'D', 'E', 'F', 'J', 'K'],
['A', 'B', 'D', 'F', 'I', 'K'],
['A', 'B', 'D', 'F', 'J', 'K'],
['A', 'B', 'E', 'G', 'I', 'K'],
['A', 'B', 'E', 'G', 'K'],
['A', 'B', 'E', 'I', 'K'],
['A', 'B', 'E', 'F', 'I', 'K'],
['A', 'B', 'E', 'F', 'J', 'K'],
['A', 'C', 'E', 'G', 'I', 'K'],
['A', 'C', 'E', 'G', 'K'],
['A', 'C', 'E', 'I', 'K'],
['A', 'C', 'E', 'F', 'I', 'K'],
['A', 'C', 'E', 'F', 'J', 'K'],
['A', 'C', 'G', 'I', 'K'],
['A', 'C', 'G', 'K'],
['A', 'C', 'H', 'I', 'K'],
['A', 'C', 'H', 'F', 'I', 'K'],
['A', 'C', 'H', 'F', 'J', 'K'],
['A', 'K']
]


class PathCrawlerTest(unittest.TestCase):
def setUp(self):
self.paths = path_crawler(hunting_map)

def test_full_crawl(self):
self.assertEqual(self.paths, expected_paths)


if __name__ == "__main__":
unittest.main()
187 changes: 187 additions & 0 deletions modules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
class Hunter:
"""Hunters

Was supposed to be rescuing hostages, ended up
hunting boars instead.
"""

def __init__(self, name):
self.name = name
self.stamina = 3
self.prey = 0
self.current_node = None
self.in_choppa = False
# Actions and energy costs
self.actions = {
'REST': 2,
'SOLO_HUNT': -2,
'GROUP_HUNT': -1,
'TRAVEL': -1
}

def __repr__(self):
return f'Hunter: {self.name}'

def rest(self):
"""Restores 2 stamina to Hunter"""
# print(f'{self.name} is RESTING')
self._set_stamina(self._action('REST'))

def hunt(self):
"""Removes 1 boar from current_node. Adds it to Hunter's prey count"""
if self.current_node.num_of_hunters > 1:
# print(f'{self.name} is HUNTING IN GROUP')
self._set_stamina(self._action('GROUP_HUNT'))
else:
# print(f'{self.name} is HUNTING ALONE')
self._set_stamina(self._action('SOLO_HUNT'))

self.current_node.boars -= 1
self.prey += 1

def travel(self):
"""Travels to the next node.

If the current_node has no next_node, it is the final node.
In that case, the Hunter boards the chopper and will not take
any further action
"""
self._set_stamina(self._action('TRAVEL'))
# print(f'{self.name} is TRAVELLING')
self.current_node.num_of_hunters -= 1
self.current_node = self.current_node.next_node
self.current_node.num_of_hunters += 1
if self.current_node.next_node is None:
self.in_choppa = True

def take_action(self):
assert self.stamina >= 0 # Ensures stamina is always above 0 before an action
if self.current_node is None:
raise Exception("You should be in a Node before taking action")

# print(f'{self.name} is ON NODE: {self.current_node}')

if self.in_choppa:
return

if self.stamina <= 1:
self.rest()
return

if self.current_node.boars == 0 or self.current_node.num_of_hunters > 1:
# Working on the assumption that each node cannot be hunted more than
# once per turn, instead of tracking whether each node has been hunted,
# simply send the Hunter away if the node is occupied. Divide and conquer.
self.travel()
return

if self.current_node.boars > 0:
self.hunt()
return

def _action(self, action):
return self.actions[action]

def _set_stamina(self, stamina):
# Prevents stamina overflow
if (self.stamina + stamina > 3):
self.stamina = 3
else:
self.stamina += stamina


class Runner:
"""Simulation Runner. Accepts a list of Path and Hunters
and runs the hunting simulation.
"""

def __init__(self, paths, hunters):
self.paths = paths
self.hunters = hunters
self.turns = 1
self.stats = []

def run(self):
"""Runs the actual hunting simulation on the given list of Path(s)

For every iteration, first thing is to (re)set the Hunter(s) into a Path.
Since the Path is a linked-list of Nodes object, all we need to check
is if all Hunter(s) are on the chopper.

If they're not, the Hunter(s) will continue acting till all of them
reached the chopper. Afterwards, the stats will be tallied up.
"""
for path in self.paths:
self._reset_run(path)
while not self._choppa_check():
for hunter in self.hunters:
hunter.take_action()
self.turns += 1

self.stats.append({
'path': path,
'hunters': self.hunters,
'turns': self.turns,
'total_prey': sum([h.prey for h in self.hunters])
})

def _reset_run(self, path):
self.turns = 1
for hunter in self.hunters:
hunter.current_node = path.start
hunter.current_node.num_of_hunters += 1
hunter.stamina = 3
hunter.in_choppa = False
hunter.prey = 0

def _choppa_check(self):
in_choppa = 0
hunters = len(self.hunters)
for hunter in self.hunters:
if hunter.in_choppa:
in_choppa += 1

return in_choppa == hunters


class Node:
"""Node object for Hunter to hunt in.

Keeps track of the number of hunters and boars.
"""

def __init__(self, node_id):
self.node_id = node_id
self.boars = 3
self.num_of_hunters = 0
self.next_node = None

def __eq__(self, other):
return self.node_id == other.node_id

def __repr__(self):
return f'Node: {self.node_id}'


class Path:
"""Path for the Hunter to travese. Consists of a list of Node(s)

This is essentially a singly-linked list for the Hunter to traverse.
"""

def __init__(self, nodes=[]):
self.start = None
if nodes:
node = Node(node_id=nodes.pop(0)) # remove the first one
self.start = node
for e in nodes:
node.next_node = Node(node_id=e)
node = node.next_node

def __repr__(self):
node = self.start
nodes = []
while node is not None:
nodes.append(node.node_id)
node = node.next_node
return f'Path: {" -> ".join(nodes)}'