Skip to content
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
Binary file added .DS_Store
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**Name:** Binary Search Time Complexity

**Instruction:** Why is the worst case time complexity of Binary search O(logn) instead of O(n)?

**Type:** Short Answer
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<!--title={Time Complexity of Binary Search}-->

<!--concepts{Depth First Search}-->

<!--badges={Algorithmns:15, Python: 5}-->

Unlike in BFS and DFS, there are three time complexities for Binary search. That is because in BFS and DFS, you have to traverse the whole graph. In Binary search, however, you stop when you find what you are looking for.

Now, let us delve into the code:

```python
def binarySearch (arr, l, r, x):

if r >= l:

mid = l + (r - l)/2

if arr[mid] == x:
return mid

elif arr[mid] > x:
return binarySearch(arr, l, mid-1, x)

else:
return binarySearch(arr, mid + 1, r, x)

else:
return -1
```

### Best Case

The first time complexity is the best case time complexity. This means that this is the best case scenario, or the fastest that the algorithm will run.

![Fast](https://static1.squarespace.com/static/589a480e2e69cf66eedaa46a/593fc94403596e1c313e8ee2/598b3906be42d699de0e52ed/1502298289126/fast-acting.jpg?format=1500w)

In Binary search, the best case scenario is when the first value you look at after cutting the list in half is the value you are looking for. If this were to indeed happen, then the best case time complexity would be O(1) time. This can be seen in the second if statement of the code:

```python
if arr[mid] == x:
return mid
```

As you can see, when the middle of the list arr is equal to what you are searching for, it immediately stops recursing. Therefore, if the value you are looking for is exactly in the middle in the first recursion, then you will immediately stop the algorithm.

### Average Case and Worst Case

The next two time complexities are called Average case and Worst case. Average case is the average of all run times. The worst case is the worst case scenario, or the slowest the algorithm will run in.

![average===slow](https://searchengineland.com/figz/wp-content/seloads/2014/08/speed-slow-snails-ss-1920-800x450.jpg)

In binary search, the worst case is when the algorithm cannot find what it is looking for. In that case, the algorithm will keep on halving the list until it can no longer do so (meaning there is only one element left in the list). In that scenario, the worst case time complexity would be O(logn) time, becuase you will NEVER actually iterate through the whole list.This can be seen in the elif and else statements of the code:

```python
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code formatting

elif arr[mid] > x:
return binarySearch(arr, l, mid-1, x)

else:
return binarySearch(arr, mid + 1, r, x)
```

As you may notice, you will iterate only half of the list in each iteration, thus making it O(logn). And since the best case scenario is very unlikely to happen, then the average case time complexity shall be the same as the worst case time complexity: O(logn).

### Why is binary search so good?

Now that we know the time complexities, the next question is what makes binary search so good? Well, the answer to that question is actually very simple: It is the fastest known searching algorithm.

<img src="https://askdentalgroup.com/wp-content/uploads/2015/08/best-of-the-best.jpg" alt="Best" style="zoom:33%;" />

It has the best Average time complexity amongst all searching algorithms available out there. In fact, it would not be a lie to say that if you were to create a better algorithm than binary search, then you would win an award.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**Name**:BFS Time Complexity

**Instruction**: Why is the Time complexity of BFS O(V + E)? Answer must include where we got V, and were we got E in O(V + E).

**Type**: Short Answer
63 changes: 52 additions & 11 deletions Module4.3_Search_and_Sorting_Algorithms/activities/Act1_SearchingAndSorting/4.md
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,19 +1,60 @@
<!--title={Depth First Search}-->
<!--title={Big O of BFS}-->

<!--concepts{Depth First Search}-->
<!--concepts{Breadth First Search}-->

<!--badges={Algorithmns:10}-->
<!--badges={Algorithmns: 20}-->

**Depth First Search** (**DFS**), similar to the **Breadth-First Search** (**BFS**) we just explored, is an algorithm for traversing tree or graph data structures. The algorithm starts at the root node (selecting some arbitrary node as the root node in the case of a graph) and explores as far as possible along each branch before backtracking. This is different from BFS, where we went across layers of nodes. With DFS, we traverse 'downwards' as far as possible before going back up to the rest of the vertices.
In case you have forgotten, the Big O notation is used to describe the execution time required or the space used of an algorithm or code in computer science. Therefore, since BFS is an algorithm, it naturally has a Big O that can be used to describe its run time speed. We will start with the code from the previous card :

![](https://i.imgur.com/Mty3gRG.jpg)
```python
def BFS(self, s):
visited = [False] * (len(self.graph))
queue = []
queue.append(s)
visited[s] = True
while queue:
s = queue.pop(0)
print (s, end = " ")
for i in self.graph[s]:
if visited[i] == False:
queue.append(i)
visited[i] = True
```

As in the example given above, DFS algorithm traverses from S to A to D to G to E to B first, then to F and lastly to C. It employs the following rules.
As you might have noticed, there is a while loop in the BFS algorithm.

- **Rule 1** − Visit the adjacent unvisited vertex. Mark it as visited. Display it. Push it in a stack.
- **Rule 2** − If no adjacent vertex is found, pop up a vertex from the stack. (It will pop up all the vertices from the stack, which do not have adjacent vertices.)
- **Rule 3** − Repeat Rule 1 and Rule 2 until the stack is empty.
```python
while queue:
```

With DFS we will also employ **recursion**, the act of calling a function within itself. It seems counterintuitive at first, but the function can be called within itself, which is useful to continue using the function until some condition is reached.
What this while loop is doing is going through each and every vertex inside the graph. Therefore, the Big O for this part of the algorithm is O(n). That is because the while loop will loop n times, where n is the size of the queue list that is being iterated over.

![](https://i.imgur.com/bf5i7Gz.png)
The next significant part of the BFS algorithm that affects its time complexity is the for loop after the print statement.

```python
for i in self.graph[s]:
```

What this for loop is doing is that it is visiting the adjacent vertices of the current vertex as stated previously. Therefore it is going through each and every edge present in the graph. Just like the while loop before, the time complexity of this part of the algorithm will be O(n), where n is the size of the graph list.

###Combining Time Complexities

Now, when you combine the two, you would get O(n^2), as the for loop is inside the while loop. The algorithm loops n times because of the while loop, and inside each while loop, you must again iterate another n times dues to the for loop, thus the reasult being O(n^2). However, that is not an accurate time complexity for BFS!

<img src="https://qmaxima.com/uploads/3/4/7/1/34719252/2487755_orig.png" alt="Not Accurate" style="zoom:50%;" />

We got O(n) for the while loop because it is going through each and every vertex inside the graph, and an O(n) for the for loop because it is going through each and every edge inside the graph. However, an important thing to take note of is that the number of vertices and the number of edges are NOT the same. Therefore, a more accurate Big O for the algorithm is O(V +E), where V is the number of vertices being iterated over in the while loop and E being the number of edges being iterated over by the for loop. This again is unfortunately not completely accurate, as it will depend on the data structure that is being used in the BFS implementation

### Adjacency List vs Adjacency Matrix

Depending on if you use an adjacency list or an adjaceny matrix, the time complexity of the BFS will be change. Before we get into that, however, it is best to describe what the two data structures are.

An adjacency list is a collection of unordered lists, where each list are neighbors to each other. This data strucutre is used for sparse graphs. A sparse graph is a graph where the number of edges is small, making the graph "sparse"

![](https://2.bp.blogspot.com/-E84bqwhejuY/Ux5EPUYap5I/AAAAAAAACLk/aIhItchwT34/s1600/Adjacency+List+Representation+of+Graph.JPG)

An adjacency matrix is a matrix that specifies which nodes in a graph are connected to each other. This data structure is used for dense graphs. A dense graph is a graph where the number of edges is big, making the graph "dense"

![](https://www.codesdope.com/staticroot/images/algorithm/graph12.png)

Thus, if you use an adjacency list to implement BFS, then you will get a time complexity of O(V + E) and O(V^2) if you were to use an adjacency matrix. That is because, for an adjacency matrix, you will have to iterate over a matrix V^2 times, but in an adjacency list, it is just V+E times due to it just being a list of nodes.
Original file line number Diff line number Diff line change
@@ -1,105 +1,19 @@
<!--title={DFS in Python}-->
<!--title={Depth First Search}-->

<!--concepts{Depth First Search}-->

<!--badges={Algorithmns:15, Python:5}-->
<!--badges={Algorithmns:10}-->

This is the code used to test our DFS algorithm. We can use this block to check if our function works.
**Depth First Search** (**DFS**), similar to the **Breadth-First Search** (**BFS**) we just explored, is an algorithm for traversing tree or graph data structures. The algorithm starts at the root node (selecting some arbitrary node as the root node in the case of a graph) and explores as far as possible along each branch before backtracking. This is different from BFS, where we went across layers of nodes. With DFS, we traverse 'downwards' as far as possible before going back up to the rest of the vertices.

```python
g = Graph()
g.addEdge(0, 1)
g.addEdge(0, 2)
g.addEdge(1, 2)
g.addEdge(2, 0)
g.addEdge(2, 3)
g.addEdge(3, 3)
![](https://i.imgur.com/Mty3gRG.jpg)

print("Following is DFS from (starting from vertex 2)")
g.DFS(2)
```
As in the example given above, DFS algorithm traverses from S to A to D to G to E to B first, then to F and lastly to C. It employs the following rules.

The DFS testing is essentially identical to the BFS testing as it serves the same purpose: to construct a graph with vertices that we can traverse. The only difference is we call `g.DFS(2)` to start the DFS algorithm from vertex 2, as opposed to calling `g.BFS(2)`, which would start the BFS algorithm.
- **Rule 1** − Visit the adjacent unvisited vertex. Mark it as visited. Display it. Push it in a stack.
- **Rule 2** − If no adjacent vertex is found, pop up a vertex from the stack. (It will pop up all the vertices from the stack, which do not have adjacent vertices.)
- **Rule 3** − Repeat Rule 1 and Rule 2 until the stack is empty.

Identical to BFS, the first step in implementing a DFS algorithm is to construct a `Graph` class with an `addEdge` function.

```python
from collections import defaultdict

class Graph:

def __init__(self):

self.graph = defaultdict(list)

def addEdge(self, u, v):
self.graph[u].append(v)
```

The `DFSUtil` function will be the powerhouse of implementing the DFS algorithm. As arguments, the function takes the vertex we are currently observing (`v`) and the `visited` array. Similar to that of BFS, the `visited` array is used to determine if a certain vertex has already been visited. If `visited[v] = True`, then the vertex `v` has been visited. Likewise, if `visited[v] = False`, `v` has not been visited.

Here is the code for `DFSUtil` in full. We will break it down step-by-step, but first, I wish to bring your attention to something important in these lines. What do you notice about the last line of the code? It seems like `DFSUtil` is calling the function `DFSUtil`. That is, `DFSUtil` is calling itself! When a function calls itself in its own body, it is known as a recursive function. Recognizing this is essential to understanding how the DFS algorithm is implemented.

```python
def DFSUtil(self, v, visited):
visited[v] = True
print(v, end = ' ')
for i in self.graph[v]:
if visited[i] == False:
self.DFSUtil(i, visited)

def DFS(self, v):
visited = [False] * (len(self.graph))
self.DFSUtil(v, visited)
```

Back to the line-by-line breakdown of the code.

`DFSUtil` begins by marking the vertex `v` as visited by changing `visited[v] = True`. It then prints the vertex to the console to let the user know that `v` has just been visited.

```python
visited[v] = True
print(v, end = ' ')
```

The function then enters the `for` loop. Just as in BFS, this `for` loop runs for each of the adjacent vertices of `v`. That is, `self.graph[v]` contains a list of all of the vertices from which there is an edge from `v` to it (see chart labeled *Graph In Terms of Adjacent Vertices* in the diagram below). The variable `i` stores this adjacent vertex throughout the `for` loop. The `for` loop contains an `if` statement that checks if said adjacent vertex (`i`) is unvisited (`visited[i] == False`). If this is the case, we recurse and call `DFSUtil`again, passing as arguments the adjacent vertex (`i`) and our updated `visited` array.

```python
for i in self.graph[v]:
if visited[i] == False:
self.DFSUtil(i, visited)
```

Now, you might be wondering, why does this function call itself? DFS is supposed to have a stack, but I don't see any such data structure? To answer these concerns, we must diverge from the code and discuss how arguments are passed to a function.

Arguments to a function are passed through a stack, which refers to a data structure with the **L**ast-**I**n **F**irst-**O**ut (**LIFO**) functionality. When a function is called, each argument is pushed on to a stack. When a function terminates, all of the arguments are popped from the stack.

While this does indeed occur for all arguments passed to a function, for simplicity's sake, we are going to focus our attention on how the `v` argument is pushed and popped from the stack everytime `DFSUtil` is called and terminates.

The diagram below explains DFS step-by-step with special attention given to how recursion, passing arguments, and the stack play a role:

![](https://i.imgur.com/XcO7ehj.jpg)

Often with recursive functions, we require a driver function to set-up certain values or variables before calling the recursive function. For the DFS algorithm, we have the `DFS` function serving as a driver function for the `DFSUtil` function. It first marks all vertices as not visited (the `visited` array only has `False` values) and then calls `DFSUtil` to visit the nodes.

```python
def DFS(self, v):
visited = [False] * (len(self.graph))
self.DFSUtil(v, visited)
```

Here is the completed code:

```python
def DFSUtil(self, v, visited):
visited[v] = True
print(v, end = ' ')
for i in self.graph[v]:
if visited[i] == False:
self.DFSUtil(i, visited)

def DFS(self, v):
visited = [False] * (len(self.graph))
self.DFSUtil(v, visited)
```
With DFS we will also employ **recursion**, the act of calling a function within itself. It seems counterintuitive at first, but the function can be called within itself, which is useful to continue using the function until some condition is reached.

![](https://i.imgur.com/bf5i7Gz.png)
Loading