ai assignment 2

Download as docx, pdf, or txt
Download as docx, pdf, or txt
You are on page 1of 19

Artificial Intelligence

BSCS- 6th
Department of Computer Science, Bahria University, Lahore Campus
Assignment: [2] Due date: October 28, 2024

Name: Muhammad Hamza Nawaz Roll No: 03-134221-055

CODE:
INITIALIZATION:
import random
import math
import numpy as np
import networkx as nx
import time
import sys
import heapq
import matplotlib.pyplot as plt
import warnings

# Suppress warnings for cleaner output


warnings.filterwarnings('ignore')

# 1.2 Define Global Constants and Configurations

# Number of cities in each map


NUM_CITIES = 30
# Dimensions of the map (1000km x 1000km)
MAP_WIDTH = 1000
MAP_HEIGHT = 1000

# Number of iterations/maps to generate


NUM_ITERATIONS = 1000

# Minimum and maximum number of connections per city


MIN_CONNECTIONS = 2
MAX_CONNECTIONS = 5

# Percentage increase for road distances over straight-line distances


MIN_DISTANCE_INCREASE = 0.10 # 10%
MAX_DISTANCE_INCREASE = 0.20 # 20%

# Seed for reproducibility


RANDOM_SEED = 42
random.seed(RANDOM_SEED)
np.random.seed(RANDOM_SEED)

# Define search algorithms


SEARCH_ALGORITHMS = [
'Breadth-First Search',
'Depth-First Search',
'Uniform Cost Search',
'Bi-directional Search',
'Best-First Search',
'A* Search',
'IDA* Search'
]

# Initialize data structures to store performance metrics


performance_metrics = {
'time': {algo: [] for algo in SEARCH_ALGORITHMS},
'space': {algo: [] for algo in SEARCH_ALGORITHMS}
}

Display configurations
print("Configuration:")
print(f"Number of Cities: {NUM_CITIES}")
print(f"Map Dimensions: {MAP_WIDTH}km x {MAP_HEIGHT}km")
print(f"Number of Iterations: {NUM_ITERATIONS}")
print(f"Connections per City: {MIN_CONNECTIONS} to {MAX_CONNECTIONS}")
print(f"Distance Increase: {MIN_DISTANCE_INCREASE*100}% to
{MAX_DISTANCE_INCREASE*100}%")
print(f"Random Seed: {RANDOM_SEED}")
print("\nSearch Algorithms to be Implemented:")
for algo in SEARCH_ALGORITHMS:
print(f"- {algo}")
Map Generation

import networkx as nx
import random
import math
import numpy as np

def generate_random_cities(num_cities, width, height):


"""
Generate random (x, y) coordinates for a given number of cities within the
specified width and height.
"""
cities = {}
for city_id in range(num_cities):
x = random.uniform(0, width)
y = random.uniform(0, height)
cities[city_id] = (x, y)
return cities

def calculate_straight_line_distance(coord1, coord2):


"""
Calculate the Euclidean distance between two coordinates.
"""
return math.hypot(coord1[0] - coord2[0], coord1[1] - coord2[1])

def assign_road_distances(cities, G, min_increase=0.10, max_increase=0.20):


"""
Assign road distances to each edge in the graph by increasing the straight-line
distance by 10%-20%.
"""
for edge in G.edges():
city1, city2 = edge
straight_distance = calculate_straight_line_distance(cities[city1], cities[city2])
increase_factor = random.uniform(min_increase, max_increase)
road_distance = straight_distance * (1 + increase_factor)
G[city1][city2]['distance'] = road_distance
return G

def ensure_connectivity(G, num_cities, min_connections, max_connections):


"""
Ensure that each city has between min_connections and max_connections.
If a city has fewer connections, add edges randomly.
If a city has more, reduce connections randomly while maintaining connectivity.
"""
# First, ensure minimum connections
for city in G.nodes():
while G.degree(city) < min_connections:
potential_cities = set(G.nodes()) - set(G.neighbors(city)) - {city}
if not potential_cities:
break
new_connection = random.choice(list(potential_cities))
G.add_edge(city, new_connection)

# Then, ensure maximum connections


for city in G.nodes():
while G.degree(city) > max_connections:
neighbors = list(G.neighbors(city))
neighbor_to_remove = random.choice(neighbors)
# Temporarily remove the edge and check connectivity
G.remove_edge(city, neighbor_to_remove)
if nx.is_connected(G):
continue
else:
# If removing the edge disconnects the graph, add it back
G.add_edge(city, neighbor_to_remove)
break
return G

def generate_fully_connected_graph(num_cities, min_connections,


max_connections):
"""
Generate a fully connected graph where each city is connected to between
min_connections and max_connections.
"""
# Start with an empty graph
G = nx.Graph()
G.add_nodes_from(range(num_cities))
# To ensure connectivity, create a random spanning tree first
nodes = list(G.nodes())
random.shuffle(nodes)
for i in range(num_cities - 1):
G.add_edge(nodes[i], nodes[i+1])

# Now, add random edges to meet the connection constraints


G = ensure_connectivity(G, num_cities, min_connections, max_connections)

return G

def generate_map():
"""
Generate a single map with cities and road distances.
"""
# 2.1 Generate random coordinates for cities
cities = generate_random_cities(NUM_CITIES, MAP_WIDTH, MAP_HEIGHT)

# 2.2 & 2.3 Ensure each city is connected to between 2 and 5 others and the
graph is fully connected
G = generate_fully_connected_graph(NUM_CITIES, MIN_CONNECTIONS,
MAX_CONNECTIONS)

# 2.4 Assign actual road distances


G = assign_road_distances(cities, G, MIN_DISTANCE_INCREASE,
MAX_DISTANCE_INCREASE)

return cities, G
cities, G = generate_map()
pos = cities
edge_labels = nx.get_edge_attributes(G, 'distance')
plt.figure(figsize=(10, 10))
nx.draw(G, pos, with_labels=True, node_color='lightblue', node_size=500,
edge_color='black')
nx.draw_networkx_edge_labels(G, pos, edge_labels={(u, v): f"{d['distance']:.1f}"
for u, v, d in G.edges(data=True)}, font_size=8)
plt.title("Randomly Generated Map of Cities")
plt.show()

Search Algorithms Implementation

import heapq
from collections import deque

def bfs(graph, start, goal):


"""
Breadth-First Search (BFS) algorithm.
Finds the shortest path in terms of number of edges.
"""
queue = deque([start])
visited = {start}
parent = {start: None}

while queue:
current = queue.popleft()
if current == goal:
break
for neighbor in graph.neighbors(current):
if neighbor not in visited:
visited.add(neighbor)
parent[neighbor] = current
queue.append(neighbor)

# Reconstruct path
path = []
node = goal
while node is not None:
path.append(node)
node = parent.get(node)
path.reverse()

return path

def dfs(graph, start, goal):


"""
Depth-First Search (DFS) algorithm.
May not find the shortest path in terms of number of edges.
"""
stack = [start]
visited = {start}
parent = {start: None}

while stack:
current = stack.pop()
if current == goal:
break
for neighbor in graph.neighbors(current):
if neighbor not in visited:
visited.add(neighbor)
parent[neighbor] = current
stack.append(neighbor)

# Reconstruct path
path = []
node = goal
while node is not None:
path.append(node)
node = parent.get(node)
path.reverse()

return path

def ucs(graph, start, goal):


"""
Corrected Uniform Cost Search (UCS) algorithm.
Finds the least costly path based on edge weights.
"""
heap = []
heapq.heappush(heap, (0, start, None)) # (cumulative cost, current node, parent
node)
visited = {}
parent = {}

while heap:
cost, current, from_node = heapq.heappop(heap)
if current in visited:
continue
visited[current] = cost
parent[current] = from_node
if current == goal:
break
for neighbor in graph.neighbors(current):
if neighbor not in visited:
total_cost = cost + graph[current][neighbor]['distance']
heapq.heappush(heap, (total_cost, neighbor, current))

# Reconstruct path
path = []
node = goal
while node is not None:
path.append(node)
node = parent.get(node)
path.reverse()

return path

def bidirectional_search(graph, start, goal):


"""
Bi-directional Search algorithm.
Searches from both start and goal simultaneously.
"""
if start == goal:
return [start]

# Initialize frontiers
front_start = deque([start])
front_goal = deque([goal])

# Visited dictionaries
visited_start = {start: None}
visited_goal = {goal: None}

meeting_node = None

while front_start and front_goal:


# Expand from start
current_start = front_start.popleft()
for neighbor in graph.neighbors(current_start):
if neighbor not in visited_start:
visited_start[neighbor] = current_start
front_start.append(neighbor)
if neighbor in visited_goal:
meeting_node = neighbor
break
if meeting_node:
break

# Expand from goal


current_goal = front_goal.popleft()
for neighbor in graph.neighbors(current_goal):
if neighbor not in visited_goal:
visited_goal[neighbor] = current_goal
front_goal.append(neighbor)
if neighbor in visited_start:
meeting_node = neighbor
break
if meeting_node:
break
if not meeting_node:
return None # No path found

# Reconstruct path
path_start = []
node = meeting_node
while node is not None:
path_start.append(node)
node = visited_start[node]
path_start.reverse()

path_goal = []
node = visited_goal[meeting_node]
while node is not None:
path_goal.append(node)
node = visited_goal[node]

return path_start + path_goal

def best_first_search(graph, start, goal, heuristic):


"""
Best-First Search algorithm.
Expands nodes based on the heuristic function.
"""
heap = []
heapq.heappush(heap, (heuristic(start, goal), start))
visited = set()
parent = {start: None}

while heap:
_, current = heapq.heappop(heap)
if current == goal:
break
if current in visited:
continue
visited.add(current)
for neighbor in graph.neighbors(current):
if neighbor not in visited:
heapq.heappush(heap, (heuristic(neighbor, goal), neighbor))
if neighbor not in parent:
parent[neighbor] = current

# Reconstruct path
path = []
node = goal
while node is not None and node in parent:
path.append(node)
node = parent.get(node)
path.reverse()

return path

def a_star_search(graph, start, goal, heuristic):


"""
Corrected A* Search algorithm.
Combines UCS and Best-First Search by considering both cost and heuristic.
"""
heap = []
heapq.heappush(heap, (0 + heuristic(start, goal), 0, start, None)) # (f_cost,
g_cost, current node, parent node)
visited = {}
parent = {}

while heap:
f_cost, g_cost, current, from_node = heapq.heappop(heap)
if current in visited:
continue
visited[current] = g_cost
parent[current] = from_node
if current == goal:
break
for neighbor in graph.neighbors(current):
if neighbor not in visited:
edge_cost = graph[current][neighbor]['distance']
new_g_cost = g_cost + edge_cost
new_f_cost = new_g_cost + heuristic(neighbor, goal)
heapq.heappush(heap, (new_f_cost, new_g_cost, neighbor, current))

# Reconstruct path
path = []
node = goal
while node is not None:
path.append(node)
node = parent.get(node)
path.reverse()

return path

def ida_star_search(graph, start, goal, heuristic):


"""
Corrected Iterative Deepening A* (IDA*) Search algorithm.
Combines the space efficiency of DFS with the optimality of A*.
"""
def dfs(node, goal, g, threshold, path, visited):
f = g + heuristic(node, goal)
if f > threshold:
return f
if node == goal:
return 'FOUND'
min_threshold = float('inf')
for neighbor in graph.neighbors(node):
if neighbor not in visited:
visited.add(neighbor)
path.append(neighbor)
temp = dfs(neighbor, goal, g + graph[node][neighbor]['distance'],
threshold, path, visited)
if temp == 'FOUND':
return 'FOUND'
if temp < min_threshold:
min_threshold = temp
path.pop()
visited.remove(neighbor)
return min_threshold

threshold = heuristic(start, goal)


path = [start]
visited = set([start])

while True:
temp = dfs(start, goal, 0, threshold, path, visited)
if temp == 'FOUND':
return path
if temp == float('inf'):
return None # No path found
threshold = temp

# Heuristic Function: Straight-Line Distance


def heuristic(node, goal, cities=None):
"""
Heuristic function for A* and Best-First Search.
Uses straight-line (Euclidean) distance between two cities.
"""
if cities is None:
raise ValueError("Cities coordinates must be provided for heuristic
calculation.")
x1, y1 = cities[node]
x2, y2 = cities[goal]
return math.hypot(x2 - x1, y2 - y1)

# Wrapper Functions to Include Cities Coordinates


def bfs_wrapper(graph, start, goal, cities=None):
return bfs(graph, start, goal)

def dfs_wrapper(graph, start, goal, cities=None):


return dfs(graph, start, goal)

def ucs_wrapper(graph, start, goal, cities=None):


return ucs(graph, start, goal)

def bidirectional_search_wrapper(graph, start, goal, cities=None):


return bidirectional_search(graph, start, goal)

def best_first_search_wrapper(graph, start, goal, cities=None):


return best_first_search(graph, start, goal, lambda n, g: heuristic(n, g, cities))

def a_star_search_wrapper(graph, start, goal, cities=None):


return a_star_search(graph, start, goal, lambda n, g: heuristic(n, g, cities))

def ida_star_search_wrapper(graph, start, goal, cities=None):


return ida_star_search(graph, start, goal, lambda n, g: heuristic(n, g, cities))

# Dictionary of Search Algorithm Wrappers


SEARCH_FUNCTIONS = {
'Breadth-First Search': bfs_wrapper,
'Depth-First Search': dfs_wrapper,
'Uniform Cost Search': ucs_wrapper,
'Bi-directional Search': bidirectional_search_wrapper,
'Best-First Search': best_first_search_wrapper,
'A* Search': a_star_search_wrapper,
'IDA* Search': ida_star_search_wrapper
}

Table for time complexity.

Breadth Depth Uniform Best first Bi- A* IDA*


first first cost direction
al
Best 0.00377 0.00164 0.00547 0.00332 0.001275 0.00862 0.07574
time 9 5 8 7 7 2
(unit)
Worst 0.00001 0.00007 0.00001 0.00001 0.000009 0.00001 0.00003
time 0 3 6 6 0
(unit)
Averag 0.00007 0.00007 0.00016 0.00012 0.000061 0.00022 0.00302
e time 9 0 8 5 5 7
(unit)

Table for space complexity.

Breadt Depth Unifor Best first Bi- A* IDA*


h first first m cost direction
al
Best 4.1484 149.1708 3.5312 148.9521 1.75781 146.8662 151.2724
time 38 98 50 48 2 11 61
(unit)
Worst 0.3750 0.34375 0.1406 0.257812 0.25781 0.351562 0.843750
time 00 0 25 2
(unit)
Avera 2.5799 3.10077 1.9786 2.740324 0.99636 2.383894 6.838996
ge 69 9 95 7
time
(unit)

You might also like