Bidirectional Search in Python

Bidirectional (1)

Hello readers, in this article let’s try to understand what is bidirectional search, its advantages, disadvantages, and its implementation in python.

Also read: Depth First Iterative Deepening (DFID) Algorithm in Python

What is Bidirectional Search?

A graph search algorithm called bidirectional search conducts two searches simultaneously. When the two searches meet at the midway, one stops moving forward from the starting point and the other stops moving backwards from the destination. For issues with a single start state and a single objective state, it is helpful.

When implementing a bidirectional search for k = 1, 2,…, the Depth First Iterative Deepening Search (DFID) can be used. In the kth iteration, instead of storing states but rather simply matching against the stored states generated from the forwarding direction, all states in the forward direction are generated from the start state up to depth k using breadth-first search and from the goal state up to depth k and depth k+ 1.

Here, to identify odd-length answers, a reverse or backward search to depth k+ 1 is required. If a match is identified, the route from the beginning to the matched state and from the matched state to the objective state can then be determined. It should be noted that each node has a link to its successors as well as to its parent. These links will help generate a complete path from start to goal states.

How does Bidirectional Search work?

Let us illustrate the working of this method using an existing graph. Consider the following graph as shown in Figure. Consider the graph to find a route from the 1st node that is 1 to the last element 16.

Bidirectional Graph 1
Bidirectional Graph 1

Tracing nodes in both directions simultaneously.

Iteration
Iteration

Implementing Bidirectional Search in Python

class adjacent_Node:
	
	def __init__(self, v):
		
		self.vertex = v
		self.next = None


class bidirectional_Search:
	
	def __init__(self, vertices):
		
		self.vertices = vertices
		self.graph = [None] * self.vertices
		
		self.source_queue = list()
		self.last_node_queue = list()
		
		
		self.source_visited = [False] * self.vertices
		self.last_node_visited = [False] * self.vertices
		
		
		self.source_parent = [None] * self.vertices
		self.last_node_parent = [None] * self.vertices
		
	
	def AddEdge(self, source, last_node):
		
		node = adjacent_Node(last_node)
		node.next = self.graph[source]
		self.graph[source] = node

		node = adjacent_Node(source)
		node.next = self.graph[last_node]
		self.graph[last_node] = node
		
	
	def breadth_fs(self, direction = 'forward'):
		
		if direction == 'forward':
		
			current = self.source_queue.pop(0)
			connected_node = self.graph[current]
			
			while connected_node:
				vertex = connected_node.vertex
				
				if not self.source_visited[vertex]:
					self.source_queue.append(vertex)
					self.source_visited[vertex] = True
					self.source_parent[vertex] = current
					
				connected_node = connected_node.next
		else:
			
			current = self.last_node_queue.pop(0)
			connected_node = self.graph[current]
			
			while connected_node:
				vertex = connected_node.vertex
				
				if not self.last_node_visited[vertex]:
					self.last_node_queue.append(vertex)
					self.last_node_visited[vertex] = True
					self.last_node_parent[vertex] = current
					
				connected_node = connected_node.next
				
	
	def is_intersecting(self):
		
		#
		for i in range(self.vertices):
			if (self.source_visited[i] and
				self.last_node_visited[i]):
				return i
				
		return -1

	
	def path_st(self, intersecting_node,
				source, last_node):
						
	
		path = list()
		path.append(intersecting_node)
		i = intersecting_node
		
		while i != source:
			path.append(self.source_parent[i])
			i = self.source_parent[i]
			
		path = path[::-1]
		i = intersecting_node
		
		while i != last_node:
			path.append(self.last_node_parent[i])
			i = self.last_node_parent[i]
			
		path = list(map(str, path))
		
		print(' '.join(path))
	
	def bidirectional_search(self, source, last_node):
		
		self.source_queue.append(source)
		self.source_visited[source] = True
		self.source_parent[source] = -1
		
		self.last_node_queue.append(last_node)
		self.last_node_visited[last_node] = True
		self.last_node_parent[last_node] = -1

		while self.source_queue and self.last_node_queue:
			
		
			self.breadth_fs(direction = 'forward')
			
			self.breadth_fs(direction = 'backward')
				
			intersecting_node = self.is_intersecting()
			
			if intersecting_node != -1:
				print("Path exists between {} and {}".format(source, last_node))
				print("Intersection at : {}".format(intersecting_node))
				self.path_st(intersecting_node,
								source, last_node)
				exit(0)
		return -1


if __name__ == '__main__':
	
	n = 17
	
	source = 1
	
	last_node = 16
	
	my_Graph = bidirectional_Search(n)
	my_Graph.AddEdge(1, 2)
	my_Graph.AddEdge(1, 3)
	my_Graph.AddEdge(1, 4)
	my_Graph.AddEdge(2, 5)
	my_Graph.AddEdge(2, 6)
	my_Graph.AddEdge(3, 7)
	my_Graph.AddEdge(4, 8)
	my_Graph.AddEdge(4, 9)
	my_Graph.AddEdge(5, 10)
	my_Graph.AddEdge(6, 10)
	my_Graph.AddEdge(10, 11)
	my_Graph.AddEdge(7, 11)
	my_Graph.AddEdge(7, 12)
	my_Graph.AddEdge(8, 13)
	my_Graph.AddEdge(9, 13)
	my_Graph.AddEdge(10, 6)
	my_Graph.AddEdge(11, 14)
	my_Graph.AddEdge(12, 15)
	my_Graph.AddEdge(13, 15)
	my_Graph.AddEdge(14, 16)
	my_Graph.AddEdge(15, 16)
	
	out = my_Graph.bidirectional_search(source, last_node)
	
	if out == -1:
		print("No path between {} and {}".format(source, last_node))

OUTPUT:

Path exists between 1 and 16

Intersection at: 8

1 4 8 13 15 16

Complexity of Bidirectional Search

The reason for this approach is that each of the two searches has a time complexity of O(b^d/2), and O(b^d/2+b^d/2) is much less than the running time of one search from the beginning to the goal, which would be O(b^d). This search can be made in an already existing graph/tree or a search graph/ tree can be generated as a part of the search.

Advantages

  • The speed with which we receive the desired results is one of the key advantages of bidirectional searches.
  • By conducting multiple searches simultaneously, the search time is significantly shortened.
  • Users can also conserve resources because less memory is necessary to store all of the searches.

Disadvantages

  • There is a chance of an infinite loop if the algorithm isn’t robust enough to recognise the intersection at which the search should terminate.
  • A further challenge is that this algorithm’s implementation requires additional code and instructions, and each node and step should be carefully implemented in order to conduct such searches.
  • Bidirectional search has a basic problem in that the user must be aware of the objective state in order to use it, thus reducing the use cases for it.

Summary

It does have some drawbacks, a bidirectional search is among the most popular and extensively researched search algorithms because it is the most effective and rapid approach to arrive at desired search results when the destination state is known before the search begins.