100% found this document useful (1 vote)
10 views36 pages

Search Code

The document outlines the implementation and evaluation of various uninformed search algorithms, including Breadth-First Search (BFS), Depth-First Search (DFS), and their variations. It details the requirements for each algorithm, including pathfinding, performance metrics, and the exploration of nodes. Additionally, it provides example implementations and performance comparisons for these algorithms in graph-based problems.

Uploaded by

septianhtma
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
100% found this document useful (1 vote)
10 views36 pages

Search Code

The document outlines the implementation and evaluation of various uninformed search algorithms, including Breadth-First Search (BFS), Depth-First Search (DFS), and their variations. It details the requirements for each algorithm, including pathfinding, performance metrics, and the exploration of nodes. Additionally, it provides example implementations and performance comparisons for these algorithms in graph-based problems.

Uploaded by

septianhtma
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 36

SearchCode

March 14, 2025

1 Search Example
1.1 Tree Diagram Image
1.1.1 Uninformed Search Algorithms**
You have been provided with a graph-based problem where the goal is to navigate from a
starting node to a goal node without any prior knowledge of the path costs. The graph may be
a tree, a grid, or an arbitrary connected structure with nodes and edges. Since no heuristic
information is available, the problem requires the application of uninformed search strategies
to explore the graph systematically.
Your task is to implement and evaluate various uninformed search algorithms, including:
1. Breadth-First Search (BFS): Expands all nodes at the current depth before moving
deeper, ensuring the shortest path in an unweighted graph.
2. Depth-First Search (DFS): Explores as deep as possible before backtracking, which can
be memory efficient but does not guarantee the shortest path.
3. Depth-Limited Search (DLS): A variation of DFS with a predefined depth limit to avoid
infinite recursion and unnecessary exploration.
4. Iterative Deepening Depth-First Search (IDDFS): Repeatedly applies DLS with in-
creasing depth limits, combining the benefits of BFS and DFS.
5. Uniform Cost Search (UCS): Expands the least-cost node first (if edge costs are provided),
ensuring optimality in weighted graphs.
For each algorithm, you need to:
• Implement the search logic to find a path from the given start node to the goal node.
• Track and display the order of nodes explored during the search process.
• Evaluate performance based on:
– Completeness: Whether the algorithm guarantees finding a solution if one exists.
– Optimality: Whether the algorithm guarantees finding the shortest or least-cost path.
– Time Complexity: The number of nodes expanded based on branching factor (b) and
solution depth (d).
– Space Complexity: The amount of memory required during execution.
– Total Nodes Expanded: The number of nodes processed before reaching the goal.
The output should include: - The path found by each algorithm (or failure if no path is found). -
The list of explored nodes in order. - The performance metrics to compare the efficiency of
different search methods.
This ensures that each uninformed search algorithm is systematically evaluated for its

1
effectiveness, trade-offs, and suitability for different graph structures, such as trees, grids, and
networks.
Would you like an example implementation for all uninformed search algorithms? �

1.2 1. Breadth-First Search (BFS)


[61]: from collections import deque

class BFS:
def __init__(self, graph):
self.graph = graph
self.explored_nodes = [] # Track explored nodes
self.nodes_expanded = 0 # Count of expanded nodes
self.solution_depth = 0 # Store depth of the solution

def bfs_search(self, start, goal):


queue = deque([[start]]) # Queue stores paths
visited = set() # Track visited nodes
level = {start: 0} # Track depth levels

while queue:
path = queue.popleft() # Get the first path
node = path[-1] # Last node in the path

self.explored_nodes.append(node) # Log explored nodes


self.nodes_expanded += 1 # Increment expanded nodes count

if node == goal:
self.solution_depth = level[node] # Store depth of the solution
return path, self.performance_metrics()

if node not in visited:


visited.add(node)
for neighbor in self.graph.get(node, []):
if neighbor not in level:
level[neighbor] = level[node] + 1 # Set depth level
new_path = list(path) # Copy path
new_path.append(neighbor)
queue.append(new_path) # Enqueue new path

return None, self.performance_metrics() # No path found

def performance_metrics(self):
num_nodes = len(self.graph)
max_branching_factor = max(len(v) for v in self.graph.values()) #␣
↪Maximum number of children per node

depth = self.solution_depth # Depth where solution was found

2
# Calculate theoretical time and space complexities
if max_branching_factor > 0 and depth > 0:
time_complexity = max_branching_factor ** depth # O(b^d)
space_complexity = max_branching_factor ** depth # O(b^d)
else:
time_complexity = space_complexity = num_nodes # Worst case␣
↪scenario

return {
"Completeness": "Yes (BFS always finds a solution if one exists)",
"Optimality": "Yes (BFS guarantees the shortest path in an␣
↪unweighted graph)",

"Time Complexity": f"O({max_branching_factor}^{depth}) �␣


↪{time_complexity}",

"Space Complexity": f"O({max_branching_factor}^{depth}) �␣


↪{space_complexity}",

"Nodes Explored": self.explored_nodes,


"Total Nodes Expanded": self.nodes_expanded
}

Explanation Tracking Explored Nodes: The self.explored_nodes list stores the sequence of nodes
explored during the search.
Nodes Expanded: The self.nodes_expanded counter tracks how many nodes were dequeued and
checked.
Performance Metrics:
Completeness: Always finds a path if one exists.
Optimality: BFS guarantees the shortest path.
Time Complexity: O(b^d), where b is the branching factor and d is the depth of the solution.
Space Complexity: O(b^d), since BFS needs to store all nodes at depth d.

[62]: # Define the tree structure as an adjacency list


graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['F'],
'D': ['G'],
'E': [],
'F': [],
'G': []
}

# Initialize BFS class


bfs_solver = BFS(graph)

# Run BFS from 'A' to 'G'


path, metrics = bfs_solver.bfs_search('A', 'G')

3
# Print results
print("BFS Path from A to G:", path)
print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

BFS Path from A to G: ['A', 'B', 'D', 'G']

Performance Metrics:
Completeness: Yes (BFS always finds a solution if one exists)
Optimality: Yes (BFS guarantees the shortest path in an unweighted graph)
Time Complexity: O(2^3) � 8
Space Complexity: O(2^3) � 8
Nodes Explored: ['A', 'B', 'C', 'D', 'E', 'F', 'G']
Total Nodes Expanded: 7

[63]: # Define the tree structure as an adjacency list


graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['F'],
'D': ['G'],
'E': [],
'F': [],
'G': []
}

# Initialize BFS class


bfs_solver_2 = BFS(graph)

# Run BFS from 'A' to 'C'


path, metrics = bfs_solver_2.bfs_search('A', 'G')

# Print results
print("BFS Path from A to C:", path)
print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

BFS Path from A to C: ['A', 'B', 'D', 'G']

Performance Metrics:
Completeness: Yes (BFS always finds a solution if one exists)
Optimality: Yes (BFS guarantees the shortest path in an unweighted graph)
Time Complexity: O(2^3) � 8
Space Complexity: O(2^3) � 8
Nodes Explored: ['A', 'B', 'C', 'D', 'E', 'F', 'G']

4
Total Nodes Expanded: 7

[64]: class DFS:


def __init__(self, graph):
self.graph = graph
self.explored_nodes = [] # Track explored nodes
self.nodes_expanded = 0 # Count of expanded nodes
self.solution_depth = 0 # Store depth of the solution (A to G)
self.max_depth = 0 # Track maximum depth reached

def dfs_search(self, start, goal, path=None, visited=None, depth=0):


if path is None:
path = [] # List to store current path
if visited is None:
visited = set() # Set to track visited nodes

path.append(start)
visited.add(start)
self.explored_nodes.append(start) # Log explored nodes
self.nodes_expanded += 1 # Increment expanded node count
self.max_depth = max(self.max_depth, depth) # Track max depth reached

if start == goal:
self.solution_depth = depth # Store solution depth
return path, self.performance_metrics()

for neighbor in self.graph.get(start, []):


if neighbor not in visited:
result, metrics = self.dfs_search(neighbor, goal, path.copy(),␣
↪visited, depth + 1)

if result:
return result, metrics # Return path if found

return None, self.performance_metrics() # No path found

def performance_metrics(self):
num_nodes = len(self.graph)
max_branching_factor = max(len(v) for v in self.graph.values()) #␣
↪Maximum number of children per node

depth = self.solution_depth # Depth where solution was found

# Calculate Time Complexity O(b^d)


if max_branching_factor > 0 and depth > 0:
time_complexity = max_branching_factor ** depth
else:
time_complexity = num_nodes # Worst case scenario

5
# Space complexity in worst case is O(V) (recursion stack depth)
space_complexity = self.max_depth

return {
"Completeness": "Yes" if self.solution_depth > 0 else "No",
"Optimality": self.is_optimal(),
"Time Complexity": f"O({max_branching_factor}^{depth}) �␣
↪{time_complexity}",

"Space Complexity": f"O({space_complexity}) (recursion depth =␣


↪{space_complexity})",

"Nodes Explored": self.explored_nodes,


"Total Nodes Expanded": self.nodes_expanded
}

def is_optimal(self):
"""Check if DFS found the shortest path by comparing with BFS."""
bfs_solver = BFS(self.graph) # Use BFS to find shortest path
bfs_path, _ = bfs_solver.bfs_search('A', 'G') # Get BFS shortest path
dfs_path, _ = self.dfs_search('A', 'G') # Get DFS path

if dfs_path and bfs_path:


return "Yes" if len(dfs_path) == len(bfs_path) else "No"
return "No"

1.3 2. Depth-First Search (DFS)


[65]: from collections import deque

class DFS:
def __init__(self, graph):
self.graph = graph
self.explored_nodes = [] # Track explored nodes
self.nodes_expanded = 0 # Count of expanded nodes
self.solution_depth = 0 # Store depth of the solution
self.max_depth = 0 # Track maximum depth reached

def dfs_search(self, start, goal):


stack = [(start, [start], 0)] # Stack stores (node, path, depth)
visited = set()

while stack:
node, path, depth = stack.pop()
self.explored_nodes.append(node) # Log explored nodes
self.nodes_expanded += 1 # Increment expanded node count
self.max_depth = max(self.max_depth, depth) # Track max depth␣
↪reached

6
if node == goal:
self.solution_depth = depth
return path, self.performance_metrics(path)

if node not in visited:


visited.add(node)
for neighbor in reversed(self.graph.get(node, [])): # Reverse␣
↪to maintain order

if neighbor not in visited:


stack.append((neighbor, path + [neighbor], depth + 1))

return None, self.performance_metrics(None) # No path found

def bfs_search(self, start, goal):


"""Finds the shortest path using BFS for optimality comparison."""
queue = deque([[start]])
visited = set()

while queue:
path = queue.popleft()
node = path[-1]

if node == goal:
return path # BFS always finds the shortest path

if node not in visited:


visited.add(node)
for neighbor in self.graph.get(node, []):
queue.append(path + [neighbor])

return None # No path found

def performance_metrics(self, dfs_path):


num_nodes = len(self.graph)
max_branching_factor = max(len(v) for v in self.graph.values()) # Max␣
↪children per node

depth = self.solution_depth # Depth where solution was found

# Time Complexity O(b^d)


time_complexity = max_branching_factor ** depth if max_branching_factor␣
↪> 0 and depth > 0 else num_nodes

# Space Complexity O(V) (explicit stack usage)


space_complexity = self.max_depth

return {
"Completeness": "Yes" if dfs_path else "No",
"Optimality": self.is_optimal(dfs_path),

7
"Time Complexity": f"O({max_branching_factor}^{depth}) �␣
↪{time_complexity}",
"Space Complexity": f"O({space_complexity}) (stack depth =␣
↪{space_complexity})",

"Nodes Explored": self.explored_nodes,


"Total Nodes Expanded": self.nodes_expanded
}

def is_optimal(self, dfs_path):


"""Check if DFS found the shortest path by comparing with BFS."""
if not dfs_path:
return "No"

bfs_path = self.bfs_search('A', 'G') # Find the shortest path using BFS

if dfs_path and bfs_path:


return "Yes" if len(dfs_path) == len(bfs_path) else "No"
return "No"

[66]: # Define the tree structure as an adjacency list


graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['F'],
'D': ['G'],
'E': [],
'F': [],
'G': []
}

# Initialize DFS class


dfs_solver = DFS(graph)

# Run DFS from 'A' to 'G'


path, metrics = dfs_solver.dfs_search('A', 'G')

# Print results
print("DFS Path from A to G:", path)
print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

DFS Path from A to G: ['A', 'B', 'D', 'G']

Performance Metrics:
Completeness: Yes
Optimality: Yes
Time Complexity: O(2^3) � 8

8
Space Complexity: O(3) (stack depth = 3)
Nodes Explored: ['A', 'B', 'D', 'G']
Total Nodes Expanded: 4

[67]: # Define the tree structure as an adjacency list


graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['F'],
'D': ['G'],
'E': [],
'F': [],
'G': []
}

# Initialize DFS class


dfs_solver = DFS(graph)

# Run DFS from 'A' to 'C'


path, metrics = dfs_solver.dfs_search('A', 'C')

# Print results
print("DFS Path from A to C:", path)
print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

DFS Path from A to C: ['A', 'C']

Performance Metrics:
Completeness: Yes
Optimality: No
Time Complexity: O(2^1) � 2
Space Complexity: O(3) (stack depth = 3)
Nodes Explored: ['A', 'B', 'D', 'G', 'E', 'C']
Total Nodes Expanded: 6

1.4 3. Depth-Limited Search (DLS)


[68]: from collections import deque

class DepthLimitedSearch:
def __init__(self, graph):
self.graph = graph
self.explored_nodes = [] # Track explored nodes
self.nodes_expanded = 0 # Count of expanded nodes
self.solution_depth = None # Depth where solution was found

9
def dls_search(self, start, goal, depth_limit, depth=0, path=None,␣
↪visited=None):
if path is None:
path = [] # Store current path
if visited is None:
visited = set() # Track visited nodes

path.append(start)
visited.add(start)
self.explored_nodes.append(start)
self.nodes_expanded += 1

# Goal found
if start == goal:
self.solution_depth = depth
return path, self.performance_metrics(path)

# If depth limit is reached, stop searching


if depth >= depth_limit:
return None, self.performance_metrics(None)

# Explore neighbors
for neighbor in self.graph.get(start, []):
if neighbor not in visited:
result, metrics = self.dls_search(neighbor, goal, depth_limit,␣
↪depth + 1, path.copy(), visited)

if result:
return result, metrics # Return path if found

return None, self.performance_metrics(None) # Goal not found within␣


↪limit

def bfs_search(self, start, goal):


"""Finds the shortest path using BFS for optimality comparison."""
queue = deque([[start]])
visited = set()

while queue:
path = queue.popleft()
node = path[-1]

if node == goal:
return path # BFS always finds the shortest path

if node not in visited:


visited.add(node)
for neighbor in self.graph.get(node, []):

10
queue.append(path + [neighbor])

return None # No path found

def performance_metrics(self, dls_path):


num_nodes = len(self.graph)
max_branching_factor = max(len(v) for v in self.graph.values()) # Max␣
↪children per node

depth = self.solution_depth if self.solution_depth is not None else "N/


↪A"

# Time Complexity O(b^d)


time_complexity = max_branching_factor ** int(depth) if␣
↪isinstance(depth, int) else "N/A"

# Space Complexity O(d) (since DLS only stores nodes up to the depth␣
↪limit)

space_complexity = depth if isinstance(depth, int) else "N/A"

return {
"Completeness": "Yes" if dls_path else "No",
"Optimality": self.is_optimal(dls_path),
"Time Complexity": f"O({max_branching_factor}^{depth}) �␣
↪{time_complexity}" if isinstance(depth, int) else "N/A",

"Space Complexity": f"O({space_complexity}) (stack depth =␣


↪{space_complexity})" if isinstance(depth, int) else "N/A",

"Nodes Explored": self.explored_nodes,


"Total Nodes Expanded": self.nodes_expanded
}

def is_optimal(self, dls_path):


"""Check if DLS found the shortest path by comparing with BFS."""
if not dls_path:
return "No"

bfs_path = self.bfs_search('A', 'G') # Find the shortest path using BFS

if dls_path and bfs_path:


return "Yes" if len(dls_path) == len(bfs_path) else "No"
return "No"

[69]: # Define the tree structure as an adjacency list


graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['F'],
'D': ['G'],
'E': [],

11
'F': [],
'G': []
}

# Initialize Depth-Limited Search class


dls_solver = DepthLimitedSearch(graph)

# Run DLS with an insufficient depth limit (2)


path, metrics = dls_solver.dls_search('A', 'G', depth_limit=2)

print("DLS Path from A to G (Limit = 2):", path)


print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

# Run DLS with a sufficient depth limit (3)


path, metrics = dls_solver.dls_search('A', 'G', depth_limit=3)

print("\nDLS Path from A to G (Limit = 3):", path)


print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

DLS Path from A to G (Limit = 2): None

Performance Metrics:
Completeness: No
Optimality: No
Time Complexity: N/A
Space Complexity: N/A
Nodes Explored: ['A', 'B', 'D', 'E', 'C', 'F']
Total Nodes Expanded: 6

DLS Path from A to G (Limit = 3): ['A', 'B', 'D', 'G']

Performance Metrics:
Completeness: Yes
Optimality: Yes
Time Complexity: O(2^3) � 8
Space Complexity: O(3) (stack depth = 3)
Nodes Explored: ['A', 'B', 'D', 'E', 'C', 'F', 'A', 'B', 'D', 'G']
Total Nodes Expanded: 10

[70]: # Define the tree structure as an adjacency list


graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],

12
'C': ['F'],
'D': ['G'],
'E': [],
'F': [],
'G': []
}

# Initialize Depth-Limited Search class


dls_solver_2 = DepthLimitedSearch(graph)

# Run DLS with an insufficient depth limit (2)


path, metrics = dls_solver_2.dls_search('A', 'C', depth_limit=2)

print("DLS Path from A to G (Limit = 2):", path)


print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

# Run DLS with a sufficient depth limit (3)


path, metrics = dls_solver_2.dls_search('A', 'C', depth_limit=3)

print("\nDLS Path from A to C (Limit = 3):", path)


print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

DLS Path from A to G (Limit = 2): ['A', 'C']

Performance Metrics:
Completeness: Yes
Optimality: No
Time Complexity: O(2^1) � 2
Space Complexity: O(1) (stack depth = 1)
Nodes Explored: ['A', 'B', 'D', 'E', 'C']
Total Nodes Expanded: 5

DLS Path from A to C (Limit = 3): ['A', 'C']

Performance Metrics:
Completeness: Yes
Optimality: No
Time Complexity: O(2^1) � 2
Space Complexity: O(1) (stack depth = 1)
Nodes Explored: ['A', 'B', 'D', 'E', 'C', 'A', 'B', 'D', 'G', 'E', 'C']
Total Nodes Expanded: 11

13
1.5 4.Iterative Deepening Depth-First Search (IDDFS)
[71]: class IDDFS:
def __init__(self, graph):
self.graph = graph
self.explored_nodes = [] # Track explored nodes across all iterations
self.nodes_expanded = 0 # Count of total nodes expanded

def dls_search(self, start, goal, depth_limit, depth=0, path=None,␣


↪visited=None):

"""Performs Depth-Limited Search (DLS) up to a given depth."""


if path is None:
path = [] # Store current path
if visited is None:
visited = set() # Track visited nodes

path.append(start)
visited.add(start)
self.explored_nodes.append(start)
self.nodes_expanded += 1

# Goal found
if start == goal:
return path

# If depth limit is reached, stop searching


if depth >= depth_limit:
return None

# Explore neighbors
for neighbor in self.graph.get(start, []):
if neighbor not in visited:
result = self.dls_search(neighbor, goal, depth_limit, depth +␣
↪1, path.copy(), visited)

if result:
return result # Return path if found

return None # Goal not found within limit

def iddfs_search(self, start, goal, max_depth=10):


"""Performs IDDFS by iterating over increasing depth limits."""
for depth_limit in range(max_depth + 1):
self.explored_nodes = [] # Reset explored nodes for each iteration
self.nodes_expanded = 0 # Reset count for each iteration
result = self.dls_search(start, goal, depth_limit)
if result:
return result, self.performance_metrics(depth_limit)

14
return None, self.performance_metrics(max_depth) # Goal not found␣
↪within max depth

def performance_metrics(self, depth_reached):


return {
"Completeness": "Yes" if self.nodes_expanded > 0 else "No",
"Optimality": "Yes" if self.nodes_expanded > 0 else "No",
"Time Complexity": f"O(b^{depth_reached})",
"Space Complexity": f"O({depth_reached}) (stack depth =␣
↪{depth_reached})",

"Nodes Explored": self.explored_nodes,


"Total Nodes Expanded": self.nodes_expanded
}

[72]: # Define the tree structure as an adjacency list


graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['F'],
'D': ['G'],
'E': [],
'F': [],
'G': []
}

# Initialize IDDFS class


iddfs_solver = IDDFS(graph)

# Run IDDFS from 'A' to 'G' with max depth limit 5


path, metrics = iddfs_solver.iddfs_search('A', 'G', max_depth=5)

# Print results
print("IDDFS Path from A to G:", path)
print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

IDDFS Path from A to G: ['A', 'B', 'D', 'G']

Performance Metrics:
Completeness: Yes
Optimality: Yes
Time Complexity: O(b^3)
Space Complexity: O(3) (stack depth = 3)
Nodes Explored: ['A', 'B', 'D', 'G']
Total Nodes Expanded: 4

15
[73]: # Define the tree structure as an adjacency list
graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['F'],
'D': ['G'],
'E': [],
'F': [],
'G': []
}

# Initialize IDDFS class


iddfs_solver = IDDFS(graph)

# Run IDDFS from 'A' to 'C' with max depth limit 5


path, metrics = iddfs_solver.iddfs_search('A', 'C', max_depth=5)

# Print results
print("IDDFS Path from A to C:", path)
print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

IDDFS Path from A to C: ['A', 'C']

Performance Metrics:
Completeness: Yes
Optimality: Yes
Time Complexity: O(b^1)
Space Complexity: O(1) (stack depth = 1)
Nodes Explored: ['A', 'B', 'C']
Total Nodes Expanded: 3

1.6 5. Uniform Cost Search (UCS)


[74]: import heapq

class UniformCostSearch:
def __init__(self, graph):
self.graph = graph
self.explored_nodes = [] # Track explored nodes
self.nodes_expanded = 0 # Count of expanded nodes
self.history = [] # Track history of visited paths

def ucs_search(self, start, goal):


"""Performs Uniform Cost Search (UCS) using a priority queue."""
priority_queue = [(0, start, [start])] # (cost, node, path)

16
visited = {} # Dictionary to store the minimum cost to reach a node

while priority_queue:
cost, node, path = heapq.heappop(priority_queue)
self.explored_nodes.append(node)
self.nodes_expanded += 1

# Save history of paths explored


self.history.append((path, cost))

# If goal is reached, return the path and performance metrics


if node == goal:
return path, self.performance_metrics(cost)

# If node was never visited or found a cheaper cost, expand it


if node not in visited or cost < visited[node]:
visited[node] = cost
for neighbor, edge_cost in self.graph.get(node, []):
total_cost = cost + edge_cost
heapq.heappush(priority_queue, (total_cost, neighbor, path␣
↪+ [neighbor]))

return None, self.performance_metrics(None) # Goal not found

def performance_metrics(self, final_cost):


return {
"Completeness": "Yes" if final_cost is not None else "No",
"Optimality": "Yes" if final_cost is not None else "No",
"Time Complexity": "O((V + E) log V)", # Using a priority queue
"Space Complexity": f"O(V) (Priority queue size � number of nodes)",
"Total Cost": final_cost if final_cost is not None else "N/A",
"Nodes Explored": self.explored_nodes,
"Total Nodes Expanded": self.nodes_expanded,
"Exploration History": self.history
}

[75]: # Define the weighted graph as an adjacency list (node: [(neighbor, cost), ...])
graph = {
'A': [('B', 1), ('C', 4)],
'B': [('D', 2), ('E', 5)],
'C': [('F', 3)],
'D': [('G', 1)],
'E': [],
'F': [],
'G': []
}

17
# Initialize UCS class
ucs_solver = UniformCostSearch(graph)

# Run UCS from 'A' to 'G'


path, metrics = ucs_solver.ucs_search('A', 'G')

# Print results
print("UCS Path from A to G:", path)
print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

# Print exploration history


print("\nExploration History:")
for step, (visited_path, path_cost) in enumerate(metrics["Exploration␣
↪History"], 1):

print(f"Step {step}: Path Explored {visited_path}, Cost: {path_cost}")

UCS Path from A to G: ['A', 'B', 'D', 'G']

Performance Metrics:
Completeness: Yes
Optimality: Yes
Time Complexity: O((V + E) log V)
Space Complexity: O(V) (Priority queue size � number of nodes)
Total Cost: 4
Nodes Explored: ['A', 'B', 'D', 'C', 'G']
Total Nodes Expanded: 5
Exploration History: [(['A'], 0), (['A', 'B'], 1), (['A', 'B', 'D'], 3), (['A',
'C'], 4), (['A', 'B', 'D', 'G'], 4)]

Exploration History:
Step 1: Path Explored ['A'], Cost: 0
Step 2: Path Explored ['A', 'B'], Cost: 1
Step 3: Path Explored ['A', 'B', 'D'], Cost: 3
Step 4: Path Explored ['A', 'C'], Cost: 4
Step 5: Path Explored ['A', 'B', 'D', 'G'], Cost: 4

[76]: # Define the weighted graph as an adjacency list (node: [(neighbor, cost), ...])
graph = {
'A': [('B', 1), ('C', 4)],
'B': [('D', 2), ('E', 5)],
'C': [('F', 3)],
'D': [('G', 1)],
'E': [],
'F': [],
'G': []

18
}

# Initialize UCS class


ucs_solver = UniformCostSearch(graph)

# Run UCS from 'A' to 'C'


path, metrics = ucs_solver.ucs_search('A', 'C')

# Print results
print("UCS Path from A to C:", path)
print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

# Print exploration history


print("\nExploration History:")
for step, (visited_path, path_cost) in enumerate(metrics["Exploration␣
↪History"], 1):

print(f"Step {step}: Path Explored {visited_path}, Cost: {path_cost}")

UCS Path from A to C: ['A', 'C']

Performance Metrics:
Completeness: Yes
Optimality: Yes
Time Complexity: O((V + E) log V)
Space Complexity: O(V) (Priority queue size � number of nodes)
Total Cost: 4
Nodes Explored: ['A', 'B', 'D', 'C']
Total Nodes Expanded: 4
Exploration History: [(['A'], 0), (['A', 'B'], 1), (['A', 'B', 'D'], 3), (['A',
'C'], 4)]

Exploration History:
Step 1: Path Explored ['A'], Cost: 0
Step 2: Path Explored ['A', 'B'], Cost: 1
Step 3: Path Explored ['A', 'B', 'D'], Cost: 3
Step 4: Path Explored ['A', 'C'], Cost: 4

2 � Informed Search Algorithms


You have been provided with a graph-based problem where the goal is to navigate from a
starting node to a goal node while leveraging heuristic information to guide the search process.
Unlike uninformed search, these algorithms use a heuristic function h(n) that estimates the cost
to reach the goal, allowing for more efficient pathfinding. The graph may be a tree, a grid, or
an arbitrary connected structure with nodes and edges, and edge costs may be provided
for certain algorithms.

19
2.1 Your Task
Implement and evaluate various informed search algorithms, including:
1. Greedy Best-First Search (GBFS):
• Expands the node that appears to be closest to the goal based on heuristic values h(n).

• Ignores actual path cost, which may lead to suboptimal solutions.


2. A* Search:
• Expands the node with the lowest total estimated cost f(n) = g(n) + h(n), where:
– g(n) is the actual cost from the start node to n.

– h(n) is the heuristic estimate from n to the goal.

• Guaranteed to find the shortest path if h(n) is admissible and consistent.


3. **Iterative Deepening A* (IDA*)**:
• Combines iterative deepening with A*.

• Applies depth-first search while using **

2.2 1. Greedy Best-First Search (GBFS)


[83]: import heapq
import time

class GreedyBestFirstSearch:
def __init__(self, graph, heuristic):
self.graph = graph
self.heuristic = heuristic
self.explored_nodes = [] # Track explored nodes
self.nodes_expanded = 0 # Count of expanded nodes
self.max_queue_size = 0 # Track max priority queue size
self.execution_time = 0 # Track time complexity
self.exploration_history = [] # Track path exploration with heuristic␣
↪values

def gbfs_search(self, start, goal):


"""Performs Greedy Best-First Search (GBFS) using a priority queue."""
start_time = time.time()
priority_queue = [(self.heuristic[start], start, [start])] #␣
↪(heuristic cost, node, path)

visited = set() # Track visited nodes

while priority_queue:
self.max_queue_size = max(self.max_queue_size, len(priority_queue))␣
↪ # Track queue size
heuristic_cost, node, path = heapq.heappop(priority_queue)
self.explored_nodes.append(node)

20
self.nodes_expanded += 1

# Store exploration history


self.exploration_history.append((node, heuristic_cost, path))

# If goal is reached, calculate execution time and return path


if node == goal:
self.execution_time = (time.time() - start_time) * 1000 #␣
↪Convert to milliseconds

return path, self.performance_metrics(path)

if node not in visited:


visited.add(node)
for neighbor, _ in self.graph.get(node, []): # Ignore edge␣
↪costs in GBFS

if neighbor not in visited:


heapq.heappush(priority_queue, (self.
↪heuristic[neighbor], neighbor, path + [neighbor]))

self.execution_time = (time.time() - start_time) * 1000 # Convert to␣


↪milliseconds
return None, self.performance_metrics(None) # No path found

def performance_metrics(self, gbfs_path):


num_nodes = len(self.graph)
num_edges = sum(len(v) for v in self.graph.values())

# Calculate theoretical complexity


time_complexity = num_nodes + num_edges
space_complexity = self.max_queue_size

return {
"Completeness": "Yes" if gbfs_path else "No",
"Optimality": "No (GBFS does not guarantee the shortest path)",
"Time Complexity": f"O({time_complexity} log {num_nodes}) �␣
↪{round(self.execution_time, 2)} ms",

"Space Complexity": f"O({space_complexity}) (Max queue size =␣


↪{space_complexity})",

"Nodes Explored": self.explored_nodes,


"Total Nodes Expanded": self.nodes_expanded,
"Exploration History": self.exploration_history # Track node,␣
↪heuristic, and path

[80]: # Define the graph as an adjacency list (node: [(neighbor, cost), ...])
graph = {

21
'A': [('B', 6), ('C', 4)],
'B': [('D', 3), ('E', 2)],
'C': [('F', 5)],
'D': [('G', 1)],
'E': [],
'F': [],
'G': []
}

# Define the heuristic values for each node (estimated cost to goal)
heuristic = {
'A': 6, 'B': 4, 'C': 2,
'D': 3, 'E': 2, 'F': 5,
'G': 0 # Goal node has heuristic value 0
}

# Initialize GBFS class


gbfs_solver = GreedyBestFirstSearch(graph, heuristic)

# Run GBFS from 'A' to 'G'


path, metrics = gbfs_solver.gbfs_search('A', 'G')

# Print results
print("GBFS Path from A to G:", path)
print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

# Print exploration history with heuristic values


print("\nExploration History (Node, Heuristic, Path Explored):")
for step, (node, heuristic_value, visited_path) in␣
↪enumerate(metrics["Exploration History"], 1):

print(f"Step {step}: Node {node} (h={heuristic_value}), Path Explored:␣


↪{visited_path}")

GBFS Path from A to G: ['A', 'B', 'D', 'G']

Performance Metrics:
Completeness: Yes
Optimality: No (GBFS does not guarantee the shortest path)
Time Complexity: O(13 log 7) � 0.02 ms
Space Complexity: O(3) (Max queue size = 3)
Nodes Explored: ['A', 'C', 'B', 'E', 'D', 'G']
Total Nodes Expanded: 6
Exploration History: [('A', 6, ['A']), ('C', 2, ['A', 'C']), ('B', 4, ['A',
'B']), ('E', 2, ['A', 'B', 'E']), ('D', 3, ['A', 'B', 'D']), ('G', 0, ['A', 'B',
'D', 'G'])]

22
Exploration History (Node, Heuristic, Path Explored):
Step 1: Node A (h=6), Path Explored: ['A']
Step 2: Node C (h=2), Path Explored: ['A', 'C']
Step 3: Node B (h=4), Path Explored: ['A', 'B']
Step 4: Node E (h=2), Path Explored: ['A', 'B', 'E']
Step 5: Node D (h=3), Path Explored: ['A', 'B', 'D']
Step 6: Node G (h=0), Path Explored: ['A', 'B', 'D', 'G']

[84]: # Define the graph as an adjacency list (node: [(neighbor, cost), ...])
graph = {
'A': [('B', 6), ('C', 4)],
'B': [('D', 3), ('E', 2)],
'C': [('F', 5)],
'D': [('G', 1)],
'E': [],
'F': [],
'G': []
}

# Define the heuristic values for each node (estimated cost to goal)
heuristic = {
'A': 2, 'B': 3, 'G': 4 ,
'D': 3, 'E': 2, 'F': 5,
'C': 0, # Goal node has heuristic value 0
}

# Initialize GBFS class


gbfs_solver_2 = GreedyBestFirstSearch(graph, heuristic)

# Run GBFS from 'A' to 'C'


path, metrics = gbfs_solver_2.gbfs_search('A', 'C')

# Print results
print("GBFS Path from A to C:", path)
print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

# Print exploration history with heuristic values


print("\nExploration History (Node, Heuristic, Path Explored):")
for step, (node, heuristic_value, visited_path) in␣
↪enumerate(metrics["Exploration History"], 1):

print(f"Step {step}: Node {node} (h={heuristic_value}), Path Explored:␣


↪{visited_path}")

GBFS Path from A to C: ['A', 'C']

Performance Metrics:

23
Completeness: Yes
Optimality: No (GBFS does not guarantee the shortest path)
Time Complexity: O(13 log 7) � 0.01 ms
Space Complexity: O(2) (Max queue size = 2)
Nodes Explored: ['A', 'C']
Total Nodes Expanded: 2
Exploration History: [('A', 2, ['A']), ('C', 0, ['A', 'C'])]

Exploration History (Node, Heuristic, Path Explored):


Step 1: Node A (h=2), Path Explored: ['A']
Step 2: Node C (h=0), Path Explored: ['A', 'C']

2.3 2. **A* Search*:


[91]: import heapq
import time

class AStarSearch:
def __init__(self, graph, heuristic):
self.graph = graph
self.heuristic = heuristic
self.explored_nodes = [] # Track explored nodes
self.nodes_expanded = 0 # Count of expanded nodes
self.max_queue_size = 0 # Track max priority queue size
self.execution_time = 0 # Track execution time
self.exploration_history = [] # Track path exploration with heuristic␣
↪values

def a_star_search(self, start, goal):


"""Performs A* Search using a priority queue."""
start_time = time.time()
priority_queue = [(self.heuristic[start], 0, start, [start])] # (f(n),␣
↪g(n), node, path)

visited = {} # Stores the minimum cost to reach each node

while priority_queue:
self.max_queue_size = max(self.max_queue_size, len(priority_queue))␣
↪ # Track queue size
_, g_cost, node, path = heapq.heappop(priority_queue)
self.explored_nodes.append(node)
self.nodes_expanded += 1

# Store exploration history


self.exploration_history.append((node, g_cost, self.
↪heuristic[node], path))

# If goal is reached, calculate execution time and return path

24
if node == goal:
self.execution_time = (time.time() - start_time) * 1000 #␣
↪Convert to milliseconds

return path, self.performance_metrics(path, g_cost)

if node not in visited or g_cost < visited[node]:


visited[node] = g_cost
for neighbor, edge_cost in self.graph.get(node, []):
total_g = g_cost + edge_cost
total_f = total_g + self.heuristic[neighbor] # f(n) = g(n)␣
↪+ h(n)
heapq.heappush(priority_queue, (total_f, total_g, neighbor,␣
↪path + [neighbor]))

self.execution_time = (time.time() - start_time) * 1000 # Convert to␣


↪milliseconds
return None, self.performance_metrics(None, None) # No path found

def performance_metrics(self, astar_path, total_cost):


num_nodes = len(self.graph)
num_edges = sum(len(v) for v in self.graph.values())

# Calculate theoretical complexity


time_complexity = num_nodes + num_edges
space_complexity = self.max_queue_size

return {
"Completeness": "Yes" if astar_path else "No",
"Optimality": "Yes (A* guarantees shortest path if h is admissible␣
↪and consistent)",

"Time Complexity": f"O({time_complexity} log {num_nodes}) �␣


↪{round(self.execution_time, 2)} ms",

"Space Complexity": f"O({space_complexity}) (Max queue size =␣


↪{space_complexity})",

"Total Cost": total_cost if total_cost is not None else "N/A",


"Nodes Explored": self.explored_nodes,
"Total Nodes Expanded": self.nodes_expanded,
"Exploration History": self.exploration_history # Track node,␣
↪heuristic, and path

[92]: # Define the graph as an adjacency list (node: [(neighbor, cost), ...])
graph = {
'A': [('B', 6), ('C', 4)],
'B': [('D', 3), ('E', 2)],
'C': [('F', 5)],

25
'D': [('G', 1)],
'E': [],
'F': [],
'G': []
}

# Define the heuristic values for each node (estimated cost to goal)
heuristic = {
'A': 6, 'B': 4, 'C': 2,
'D': 3, 'E': 2, 'F': 5,
'G': 0 # Goal node has heuristic value 0
}

# Initialize A* Search class


astar_solver_1 = AStarSearch(graph, heuristic)

# Run A* from 'A' to 'G'


path, metrics = astar_solver_1.a_star_search('A', 'G')

# Print results
print("A* Path from A to C:", path)
print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

# Print exploration history with heuristic values


print("\nExploration History (Node, g(n), h(n), Path Explored):")
for step, (node, g_value, h_value, visited_path) in␣
↪enumerate(metrics["Exploration History"], 1):

print(f"Step {step}: Node {node} (g={g_value}, h={h_value}), Path Explored:␣


↪{visited_path}")

A* Path from A to C: ['A', 'B', 'D', 'G']

Performance Metrics:
Completeness: Yes
Optimality: Yes (A* guarantees shortest path if h is admissible and consistent)
Time Complexity: O(13 log 7) � 0.02 ms
Space Complexity: O(3) (Max queue size = 3)
Total Cost: 10
Nodes Explored: ['A', 'C', 'B', 'E', 'D', 'G']
Total Nodes Expanded: 6
Exploration History: [('A', 0, 6, ['A']), ('C', 4, 2, ['A', 'C']), ('B', 6, 4,
['A', 'B']), ('E', 8, 2, ['A', 'B', 'E']), ('D', 9, 3, ['A', 'B', 'D']), ('G',
10, 0, ['A', 'B', 'D', 'G'])]

Exploration History (Node, g(n), h(n), Path Explored):


Step 1: Node A (g=0, h=6), Path Explored: ['A']

26
Step 2: Node C (g=4, h=2), Path Explored: ['A', 'C']
Step 3: Node B (g=6, h=4), Path Explored: ['A', 'B']
Step 4: Node E (g=8, h=2), Path Explored: ['A', 'B', 'E']
Step 5: Node D (g=9, h=3), Path Explored: ['A', 'B', 'D']
Step 6: Node G (g=10, h=0), Path Explored: ['A', 'B', 'D', 'G']

[93]: # Define the graph as an adjacency list (node: [(neighbor, cost), ...])
graph = {
'A': [('B', 6), ('C', 4)],
'B': [('D', 3), ('E', 2)],
'C': [('F', 5)],
'D': [('G', 1)],
'E': [],
'F': [],
'G': []
}

# Define the heuristic values for each node (estimated cost to goal)
heuristic = {
'A': 2, 'B': 3, 'G': 4 ,
'D': 3, 'E': 2, 'F': 5,
'C': 0, # Goal node has heuristic value 0
}

# Initialize A* Search class


astar_solver = AStarSearch(graph, heuristic)

# Run A* from 'A' to 'C'


path, metrics = astar_solver.a_star_search('A', 'C')

# Print results
print("A* Path from A to C:", path)
print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

# Print exploration history with heuristic values


print("\nExploration History (Node, g(n), h(n), Path Explored):")
for step, (node, g_value, h_value, visited_path) in␣
↪enumerate(metrics["Exploration History"], 1):

print(f"Step {step}: Node {node} (g={g_value}, h={h_value}), Path Explored:␣


↪{visited_path}")

A* Path from A to C: ['A', 'C']

Performance Metrics:
Completeness: Yes
Optimality: Yes (A* guarantees shortest path if h is admissible and consistent)

27
Time Complexity: O(13 log 7) � 0.01 ms
Space Complexity: O(2) (Max queue size = 2)
Total Cost: 4
Nodes Explored: ['A', 'C']
Total Nodes Expanded: 2
Exploration History: [('A', 0, 2, ['A']), ('C', 4, 0, ['A', 'C'])]

Exploration History (Node, g(n), h(n), Path Explored):


Step 1: Node A (g=0, h=2), Path Explored: ['A']
Step 2: Node C (g=4, h=0), Path Explored: ['A', 'C']

2.4 3. **Iterative Deepening A* (IDA*):


[ ]: import time

class IDAStar:
def __init__(self, graph, heuristic):
self.graph = graph
self.heuristic = heuristic
self.explored_nodes = [] # Track explored nodes
self.nodes_expanded = 0 # Count of expanded nodes
self.execution_time = 0 # Track execution time
self.exploration_history = [] # Store explored nodes with costs
self.threshold_count = 0 # Count the number of threshold updates

def ida_star_search(self, start, goal):


"""Performs IDA* Search with iterative deepening and cost thresholds."""
start_time = time.time()
threshold = self.heuristic[start] # Initial threshold
path = [start]

while True:
result, new_threshold = self.depth_limited_search(path, 0,␣
↪threshold, goal)

if result is not None: # Goal found


self.execution_time = (time.time() - start_time) * 1000 #␣
↪Convert to milliseconds

return result, self.performance_metrics(result)


if new_threshold == float("inf"): # No solution
return None, self.performance_metrics(None)

self.threshold_count += 1 # Increment threshold update count


threshold = new_threshold # Update threshold

def depth_limited_search(self, path, g, threshold, goal):


"""Recursive depth-first search with a cost threshold."""
node = path[-1]

28
f_cost = g + self.heuristic[node] # f(n) = g(n) + h(n)

# Store exploration history


self.exploration_history.append((node, g, self.heuristic[node], f_cost,␣
↪list(path)))

self.explored_nodes.append(node)
self.nodes_expanded += 1

if f_cost > threshold:


return None, f_cost # Exceeded threshold, return new min threshold
if node == goal:
return path, None # Goal reached

min_threshold = float("inf") # Track smallest threshold over limit


for neighbor, edge_cost in self.graph.get(node, []):
if neighbor not in path: # Avoid cycles
path.append(neighbor)
result, new_threshold = self.depth_limited_search(path, g +␣
↪edge_cost, threshold, goal)

if result is not None:


return result, None # Goal found
min_threshold = min(min_threshold, new_threshold) # Update min␣
↪threshold

path.pop() # Backtrack

return None, min_threshold # Return new minimum threshold

def performance_metrics(self, ida_path):


num_nodes = len(self.graph)
num_edges = sum(len(v) for v in self.graph.values())

return {
"Completeness": "Yes" if ida_path else "No",
"Optimality": "Yes (IDA* guarantees shortest path if h is␣
↪admissible and consistent)",

"Time Complexity": f"O({num_nodes + num_edges} log {num_nodes}) �␣


↪{round(self.execution_time, 2)} ms",

"Space Complexity": f"O(d) (Memory-efficient recursive DFS)",


"Total Nodes Expanded": self.nodes_expanded,
"Threshold Updates": self.threshold_count, # Number of times␣
↪threshold was increased

"Nodes Explored": self.explored_nodes,


"Exploration History": self.exploration_history # Track␣
↪exploration details

IDA* Path from A to C: ['A', 'C']

29
Performance Metrics:
Completeness: Yes
Optimality: Yes (IDA* guarantees shortest path if h is admissible and
consistent)
Time Complexity: O(13 log 7) � 0.01 ms
Space Complexity: O(d) (Memory-efficient recursive DFS)
Total Nodes Expanded: 3
Threshold Updates: 0
Nodes Explored: ['A', 'B', 'C']
Exploration History: [('A', 0, 6, 6, ['A']), ('B', 6, 4, 10, ['A', 'B']), ('C',
4, 0, 4, ['A', 'C'])]

Exploration History (Node, g(n), h(n), f(n), Path Explored):


Step 1: Node A (g=0, h=6, f=6), Path Explored: ['A']
Step 2: Node B (g=6, h=4, f=10), Path Explored: ['A', 'B']
Step 3: Node C (g=4, h=0, f=4), Path Explored: ['A', 'C']

[99]: # Define the graph as an adjacency list (node: [(neighbor, cost), ...])
graph = {
'A': [('B', 6), ('C', 4)],
'B': [('D', 3), ('E', 2)],
'C': [('F', 5)],
'D': [('G', 1)],
'E': [],
'F': [],
'G': []
}

# Define the heuristic values for each node (estimated cost to goal)
heuristic = {
'A': 6, 'B': 4, 'C': 2,
'D': 3, 'E': 2, 'F': 5,
'G': 0 # Goal node has heuristic value 0
}

# Initialize IDA* Search class


ida_solver = IDAStar(graph, heuristic)

# Run IDA* from 'A' to 'G'


path, metrics = ida_solver.ida_star_search('A', 'G')

# Print results
print("IDA* Path from A to G:", path)
print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

30
# Print exploration history
print("\nExploration History (Node, g(n), h(n), f(n), Path Explored):")
for step, (node, g_value, h_value, f_value, visited_path) in␣
↪enumerate(metrics["Exploration History"], 1):

print(f"Step {step}: Node {node} (g={g_value}, h={h_value}, f={f_value}),␣


↪Path Explored: {visited_path}")

IDA* Path from A to G: ['A', 'B', 'D', 'G']

Performance Metrics:
Completeness: Yes
Optimality: Yes (IDA* guarantees shortest path if h is admissible and
consistent)
Time Complexity: O(13 log 7) � 0.03 ms
Space Complexity: O(d) (Memory-efficient recursive DFS)
Total Nodes Expanded: 14
Threshold Updates: 2
Nodes Explored: ['A', 'B', 'C', 'F', 'A', 'B', 'D', 'E', 'C', 'F', 'A', 'B',
'D', 'G']
Exploration History: [('A', 0, 6, 6, ['A']), ('B', 6, 4, 10, ['A', 'B']), ('C',
4, 2, 6, ['A', 'C']), ('F', 9, 5, 14, ['A', 'C', 'F']), ('A', 0, 6, 6, ['A']),
('B', 6, 4, 10, ['A', 'B']), ('D', 9, 3, 12, ['A', 'B', 'D']), ('E', 8, 2, 10,
['A', 'B', 'E']), ('C', 4, 2, 6, ['A', 'C']), ('F', 9, 5, 14, ['A', 'C', 'F']),
('A', 0, 6, 6, ['A']), ('B', 6, 4, 10, ['A', 'B']), ('D', 9, 3, 12, ['A', 'B',
'D']), ('G', 10, 0, 10, ['A', 'B', 'D', 'G'])]

Exploration History (Node, g(n), h(n), f(n), Path Explored):


Step 1: Node A (g=0, h=6, f=6), Path Explored: ['A']
Step 2: Node B (g=6, h=4, f=10), Path Explored: ['A', 'B']
Step 3: Node C (g=4, h=2, f=6), Path Explored: ['A', 'C']
Step 4: Node F (g=9, h=5, f=14), Path Explored: ['A', 'C', 'F']
Step 5: Node A (g=0, h=6, f=6), Path Explored: ['A']
Step 6: Node B (g=6, h=4, f=10), Path Explored: ['A', 'B']
Step 7: Node D (g=9, h=3, f=12), Path Explored: ['A', 'B', 'D']
Step 8: Node E (g=8, h=2, f=10), Path Explored: ['A', 'B', 'E']
Step 9: Node C (g=4, h=2, f=6), Path Explored: ['A', 'C']
Step 10: Node F (g=9, h=5, f=14), Path Explored: ['A', 'C', 'F']
Step 11: Node A (g=0, h=6, f=6), Path Explored: ['A']
Step 12: Node B (g=6, h=4, f=10), Path Explored: ['A', 'B']
Step 13: Node D (g=9, h=3, f=12), Path Explored: ['A', 'B', 'D']
Step 14: Node G (g=10, h=0, f=10), Path Explored: ['A', 'B', 'D', 'G']

[101]: # Define the graph as an adjacency list (node: [(neighbor, cost), ...])
graph = {
'A': [('B', 6), ('C', 4)],
'B': [('D', 3), ('E', 2)],

31
'C': [('F', 5)],
'D': [('G', 1)],
'E': [],
'F': [],
'G': []
}
# Define the heuristic values for each node (estimated cost to goal)
heuristic = {
'A': 2, 'B': 3, 'G': 4 ,
'D': 3, 'E': 2, 'F': 5,
'C': 0, # Goal node has heuristic value 0
}

# Initialize IDA* Search class


ida_solver_1 = IDAStar(graph, heuristic)

# Run IDA* from 'A' to 'C'


path, metrics = ida_solver_1.ida_star_search('A', 'C')

# Print results
print("IDA* Path from A to C:", path)
print("\nPerformance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

# Print exploration history


print("\nExploration History (Node, g(n), h(n), f(n), Path Explored):")
for step, (node, g_value, h_value, f_value, visited_path) in␣
↪enumerate(metrics["Exploration History"], 1):

print(f"Step {step}: Node {node} (g={g_value}, h={h_value}, f={f_value}),␣


↪Path Explored: {visited_path}")

IDA* Path from A to C: ['A', 'C']

Performance Metrics:
Completeness: Yes
Optimality: Yes (IDA* guarantees shortest path if h is admissible and
consistent)
Time Complexity: O(13 log 7) � 0.03 ms
Space Complexity: O(d) (Memory-efficient recursive DFS)
Total Nodes Expanded: 6
Threshold Updates: 1
Nodes Explored: ['A', 'B', 'C', 'A', 'B', 'C']
Exploration History: [('A', 0, 2, 2, ['A']), ('B', 6, 3, 9, ['A', 'B']), ('C',
4, 0, 4, ['A', 'C']), ('A', 0, 2, 2, ['A']), ('B', 6, 3, 9, ['A', 'B']), ('C',
4, 0, 4, ['A', 'C'])]

Exploration History (Node, g(n), h(n), f(n), Path Explored):

32
Step 1: Node A (g=0, h=2, f=2), Path Explored: ['A']
Step 2: Node B (g=6, h=3, f=9), Path Explored: ['A', 'B']
Step 3: Node C (g=4, h=0, f=4), Path Explored: ['A', 'C']
Step 4: Node A (g=0, h=2, f=2), Path Explored: ['A']
Step 5: Node B (g=6, h=3, f=9), Path Explored: ['A', 'B']
Step 6: Node C (g=4, h=0, f=4), Path Explored: ['A', 'C']

2.5 Example Application


� Python Example: AI Search Application for Route Planning This example demonstrates an AI
search application using A Search* for route planning in a city map. The goal is to find the shortest
path from a start location to a destination using a graph representation of city roads.
� Problem Statement A city is represented as a graph, where:
Nodes represent locations (e.g., landmarks or intersections).
Edges represent roads with distances (costs).
A heuristic function estimates the remaining distance to the goal.
The task is to:
Find the shortest route from a given start to a destination using A* Search.
Track and display the order of explored locations.
Provide insights on AI search efficiency, including execution time and memory usage.

[102]: import heapq


import time

class AStarRoutePlanner:
def __init__(self, city_graph, heuristic):
self.city_graph = city_graph
self.heuristic = heuristic
self.explored_nodes = [] # Track explored locations
self.nodes_expanded = 0 # Count of expanded locations
self.execution_time = 0 # Track execution time
self.exploration_history = [] # Store explored nodes with costs

def a_star_search(self, start, goal):


"""Performs A* Search for route planning."""
start_time = time.time()
priority_queue = [(self.heuristic[start], 0, start, [start])] # (f(n),␣
↪g(n), location, path)

visited = {}

while priority_queue:
_, g_cost, location, path = heapq.heappop(priority_queue)
self.explored_nodes.append(location)
self.nodes_expanded += 1

# Store exploration history

33
self.exploration_history.append((location, g_cost, self.
↪heuristic[location], path))

if location == goal: # If destination reached


self.execution_time = (time.time() - start_time) * 1000 #␣
↪Convert to milliseconds

return path, self.performance_metrics(path, g_cost)

if location not in visited or g_cost < visited[location]:


visited[location] = g_cost
for neighbor, travel_cost in self.city_graph.get(location, []):
total_g = g_cost + travel_cost
total_f = total_g + self.heuristic[neighbor] # f(n) = g(n)␣
↪+ h(n)
heapq.heappush(priority_queue, (total_f, total_g, neighbor,␣
↪path + [neighbor]))

self.execution_time = (time.time() - start_time) * 1000 # Convert to␣


↪milliseconds

return None, self.performance_metrics(None, None)

def performance_metrics(self, route, total_cost):


return {
"Route Found": route if route else "No path found",
"Total Distance": total_cost if total_cost is not None else "N/A",
"Execution Time": f"{round(self.execution_time, 2)} ms",
"Nodes Explored": self.explored_nodes,
"Total Nodes Expanded": self.nodes_expanded,
"Exploration History": self.exploration_history
}

# Define the city road network as a graph (location: [(neighbor, travel␣


↪distance), ...])

city_graph = {
'Home': [('Mall', 10), ('Library', 6)],
'Mall': [('Office', 15), ('Restaurant', 8)],
'Library': [('Park', 5)],
'Office': [('Gym', 12)],
'Restaurant': [('Gym', 10), ('Park', 7)],
'Park': [('Gym', 4)],
'Gym': []
}

# Define heuristic values (estimated straight-line distance to the goal: Gym)


heuristic = {

34
'Home': 14, 'Mall': 12, 'Library': 9, 'Office': 8,
'Restaurant': 7, 'Park': 4, 'Gym': 0 # Goal (Gym) has h(Gym) = 0
}

# Initialize A* route planner


route_planner = AStarRoutePlanner(city_graph, heuristic)

# Find the shortest route from 'Home' to 'Gym'


route, metrics = route_planner.a_star_search('Home', 'Gym')

# Print results
print("\n� A* Route from Home to Gym:", route)
print("\n� Performance Metrics:")
for key, value in metrics.items():
print(f"{key}: {value}")

# Print exploration history


print("\n� Exploration History (Location, g(n), h(n), Path Explored):")
for step, (location, g_value, h_value, visited_path) in␣
↪enumerate(metrics["Exploration History"], 1):

print(f"Step {step}: Location {location} (g={g_value}, h={h_value}), Path␣


↪Explored: {visited_path}")

� A* Route from Home to Gym: ['Home', 'Library', 'Park', 'Gym']

� Performance Metrics:
Route Found: ['Home', 'Library', 'Park', 'Gym']
Total Distance: 15
Execution Time: 0.07 ms
Nodes Explored: ['Home', 'Library', 'Park', 'Gym']
Total Nodes Expanded: 4
Exploration History: [('Home', 0, 14, ['Home']), ('Library', 6, 9, ['Home',
'Library']), ('Park', 11, 4, ['Home', 'Library', 'Park']), ('Gym', 15, 0,
['Home', 'Library', 'Park', 'Gym'])]

� Exploration History (Location, g(n), h(n), Path Explored):


Step 1: Location Home (g=0, h=14), Path Explored: ['Home']
Step 2: Location Library (g=6, h=9), Path Explored: ['Home', 'Library']
Step 3: Location Park (g=11, h=4), Path Explored: ['Home', 'Library', 'Park']
Step 4: Location Gym (g=15, h=0), Path Explored: ['Home', 'Library', 'Park',
'Gym']

2.6 � Explanation of the Application


2.6.1 � City as a Graph
• Nodes = Places (e.g., Home, Mall, Gym).

35
• Edges = Travel distances between places.

2.6.2 � A* Search Expansion (f(n) = g(n) + h(n))


• g(n): Actual distance traveled from the start.
• h(n): Estimated remaining distance to the goal (Gym).
• Expands the lowest-cost location first (i.e., the location with the smallest f(n) = g(n)
+ h(n)).

2.6.3 � Performance Metrics


• Total Distance: Actual cost of the shortest path.
• Execution Time: Time taken for computation.
• Nodes Explored: Order of locations expanded.
• Exploration History: Tracks how locations were prioritized.

36

You might also like