0% found this document useful (0 votes)
17 views

Compiler and design lab pdf

Compiler design and lab pdf and notes

Uploaded by

Ashutosh Rana
Copyright
© © All Rights Reserved
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
17 views

Compiler and design lab pdf

Compiler design and lab pdf and notes

Uploaded by

Ashutosh Rana
Copyright
© © All Rights Reserved
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 33

JB INSTITUTE Of TECHNOLOGY

Dehradun-Uttarakhand

Session 2024-2025
Design and Analysis of Algorithm
Practical Lab file
(CSP-010)

Submitted By Submitted To
Shakir Ali Mr. Santosh Kumar Mishra 220530101071
Associate Prof. (CSE)
B.Tech (CSE)-B
INDEX
S.NO Practical Name Page Date Remark
no.
1. 1.Programming that uses recurrence relations to analyse 22/08/2024

recursive algorithms.
2. 2.Computing best, average, and worst-case time complexity of various 23/08/2024
sorting techniques.
3. 3.Performance analysis of different internal and external sorting 23/08/2024
algorithms with different type of data
set.
4. Use of divide and conquer technique to solve some problem that uses 06/09/2024
two different algorithms for
solving small problem.
5. Implementation of different basic computing algorithms like Hash 07/09/2024
tables, including collision-avoidance
strategies, Search trees (AVL and B-trees).
6. Consider the problem of eight queens on an (8x8) chessboard. Two 08/10/2024
queens are said to attack each
other if they are on the same row, column, or diagonal. Write a program
that implements
backtracking algorithm to solve the problem i.e. place eight non-
attacking queens on the board.
7. Write a program to find the strongly connected components in a 15/10/204
digraph.
8. Write a program to implement file compression (and un-compression) 11/11/2024
using Huffman’s algorithm.
9. Write a program to implement dynamic programming algorithm to solve 12/11/2024
the all pairs shortest path
problem.
10. Write a program to solve 0/1 knapsack problem using the following: 05/12/2024
a)
Greedy algorithm.
b) Dynamic programming algorithm.
c)
Backtracking algorithm.
d) Branch and bound algorithm.
11. Write a program that uses dynamic programming algorithm to solve the 06/12/2024
optimal binary search tree
problem.
12. Write a program for solving traveling salespersons problem using the 09/12/2024
following:
a)
Dynamic programming algorithm.
b) The back tracking algorithm.
c)
Branch and bound.
1.Programming that uses recurrence relations to analyses recursive
algorithms.

What is a Recurrence Relation?

A recurrence relation is an equation that defines a sequence of values based on previous terms. In the context of
algorithms, it helps us determine the running time of a recursive function by expressing it as a function of the
input size.

Steps to Analyze Recursive Algorithms Using Recurrence Relations

1. Identify the Recurrence:


a. Write the recursive algorithm.
b. Express the running time T(n)T(n)T(n) of the algorithm in terms of TTT for smaller inputs.
2. Set the Base Case:
a. Define the value of T(n)T(n)T(n) when the input size nnn is very small (usually n=1n = 1n=1).
3. Solve the Recurrence Relation:
a. Use techniques like substitution, recursion tree, or Master Theorem to find the solution for
T(n)T(n)T(n).

Example: Recursive Algorithm for Finding Factorial

python
Copy code
def factorial(n):
if n == 1:
return 1
else:
return n * factorial(n - 1)

Step 1: Identify the Recurrence

• The algorithm calls itself with n−1n - 1n−1 until n=1n = 1n=1.
• Let T(n)T(n)T(n) represent the time complexity for input size nnn.
• The recurrence relation is: T(n)=T(n−1)+cT(n) = T(n-1) + cT(n)=T(n−1)+c Here, ccc is the constant time
for the multiplication and function call.

Step 2: Base Case

• When n=1n = 1n=1, the function does not recurse. So: T(1)=cT(1) = cT(1)=c
Step 3: Solve the Recurrence

Expand T(n)T(n)T(n) step by step:

T(n)=T(n−1)+cT(n) = T(n-1) + cT(n)=T(n−1)+c T(n)=(T(n−2)+c)+cT(n) = (T(n-2) + c) + cT(n)=(T(n−2)+c)+c


T(n)=(T(n−3)+c+c)+cT(n) = (T(n-3) + c + c) + cT(n)=(T(n−3)+c+c)+c T(n)=T(1)+c(n−1)T(n) = T(1) + c(n-
1)T(n)=T(1)+c(n−1)

Substitute T(1)=cT(1) = cT(1)=c:

T(n)=c+c(n−1)T(n) = c + c(n-1)T(n)=c+c(n−1) T(n)=cnT(n) = cnT(n)=cn

Thus, the time complexity is O(n)O(n)O(n).

Conclusion

• Using recurrence relations, we analyzed that the factorial algorithm has linear time complexity
O(n)O(n)O(n).
• This method is useful for evaluating recursive algorithms like merge sort, binary search, etc.
2.Computing best, average, and worst-case time complexity of various sorting
techniques.

1. Bubble Sort

• Description: Compares adjacent elements and swaps them if they're in the wrong order.
• Complexity:
o Best Case: O(n) (Already sorted, single pass)
o Average Case: O(n²) (Random order)
o Worst Case: O(n²) (Reversed order)

2. Selection Sort

• Description: Repeatedly finds the minimum element and places it in the sorted portion.
• Complexity:
o Best Case: O(n²) (No early stopping mechanism)
o Average Case: O(n²)
o Worst Case: O(n²)

3. Insertion Sort

• Description: Inserts elements into their correct position within a growing sorted portion.
• Complexity:
o Best Case: O(n) (Already sorted)
o Average Case: O(n²) (Random order)
o Worst Case: O(n²) (Reversed order)

4. Merge Sort

• Description: Divides the array into halves, sorts them, and then merges them back.
• Complexity:
o Best Case: O(n log n)
o Average Case: O(n log n)
o Worst Case: O(n log n)

5. Quick Sort

• Description: Selects a pivot, partitions the array, and sorts recursively.


• Complexity:
o Best Case: O(n log n) (Balanced partitions)
o Average Case: O(n log n)
o Worst Case: O(n²) (Unbalanced partitions)
6. Heap Sort

• Description: Builds a heap and repeatedly extracts the maximum/minimum.


• Complexity:
o Best Case: O(n log n)
o Average Case: O(n log n)
o Worst Case: O(n log n)

7. Counting Sort

• Description: Counts occurrences and uses them to place elements in sorted order (suitable for integers).
• Complexity:
o Best Case: O(n + k)
o Average Case: O(n + k)
o Worst Case: O(n + k)
(k is the range of input values.)

8. Radix Sort

• Description: Sorts numbers digit by digit, starting from the least significant digit.
• Complexity:
o Best Case: O(nk)
o Average Case: O(nk)
o Worst Case: O(nk)
(k is the number of digits in the largest number.)

9. Bucket Sort

• Description: Divides the array into buckets, sorts each bucket, and combines.
• Complexity:
o Best Case: O(n + k) (Uniform distribution of elements)
o Average Case: O(n + k)
o Worst Case: O(n²) (All elements in one bucket)

Summary Table:

Algorithm Best Case Average Case Worst Case


Bubble Sort O(n) O(n²) O(n²)
Selection Sort O(n²) O(n²) O(n²)
Insertion Sort O(n) O(n²) O(n²)
Merge Sort O(n log n) O(n log n) O(n log n)
Quick Sort O(n log n) O(n log n) O(n²)
Heap Sort O(n log n) O(n log n) O(n log n)
Counting Sort O(n + k) O(n + k) O(n + k)
Radix Sort O(nk) O(nk) O(nk)
Bucket Sort O(n + k) O(n + k) O(n²)

This concise explanation ensures clarity and provides an easy reference for understanding sorting complexities!
3.Performance analysis of different internal and external sorting algorithms
with different type of data set.

Practical: Performance Analysis of Sorting Algorithms

Objective

To analyze and compare the performance of various internal and external sorting algorithms using different types of datasets, such as:

• Sorted data
• Reverse sorted data
• Random data

Sorting Algorithms Studied

1. Internal Sorting (works on in-memory data):


a. Bubble Sort
b. Insertion Sort
c. Merge Sort
d. Quick Sort
2. External Sorting (used for large datasets that don't fit in memory):
a. Multiway Merge Sort
b. External Quick Sort

Tools and Environment

• Programming Language: Python/C++/Java


• Dataset Size: Small, medium, and large datasets (e.g., 100, 1,000, 10,000 elements)
• System Configuration: Specify your system's CPU, RAM, etc.

Procedure

1. Dataset Preparation:
a. Generate datasets:
i. Sorted order: [1, 2, 3, 4, ..., n]
ii. Reverse order: [n, n-1, ..., 3, 2, 1]
iii. Random order: [5, 2, 8, 1, 3, ..., ]
b. Use random generators or predefined arrays.
2. Implement Sorting Algorithms:
a. Write code for the algorithms listed above.
b. Ensure correctness by testing with small datasets first.
3. Record Execution Time:
a. Use built-in timing functions:
i. Python: time or timeit library
ii. C++: chrono library
iii. Java: System.nanoTime()
b. Measure time taken for each algorithm with different datasets.
4. Analyze Performance:
a. Compare the execution times for:
i. Different algorithms
ii. Different dataset types
b. Note the time complexity and behavior.

Sample Code Snippet (Python)

Here’s an example for Bubble Sort:

import time

def bubble_sort(arr):
n = len(arr)
for i in range(n):
for j in range(0, n-i-1):
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j]

# Example dataset
data = [64, 34, 25, 12, 22, 11, 90]

# Measure execution time


start_time = time.time()
bubble_sort(data)
end_time = time.time()

print("Sorted array:", data)


print("Execution time:", end_time - start_time, "seconds")

Sample Results

Algorithm Dataset Type Time (ms) Remarks


Bubble Sort Random Data 120 Slow for large datasets
Insertion Sort Sorted Data 10 Very efficient for sorted
Merge Sort Reverse Sorted 15 Consistent performance
Quick Sort Random Data 8 Fastest for random data
External Merge Sort Large Dataset 200 Suitable for disk-based ops

Observations

1. Internal Sorting:
a. Bubble Sort is inefficient for large datasets due to O(n2)O(n^2)O(n2) complexity.
b. Merge Sort performs well and has consistent O(nlog⁡n)O(n \log n)O(nlogn) time.
c. Quick Sort is faster but may degrade to O(n2)O(n^2)O(n2) in the worst case.
2. External Sorting:
a. Effective for datasets larger than available memory.
b. Requires additional storage and I/O operations.
3. Impact of Dataset Types:
a. Sorted datasets improve performance in algorithms like Insertion Sort.
b. Random and reverse datasets highlight the efficiency of divide-and-conquer algorithms like Quick Sort and Merge Sort.
Conclusion

Sorting algorithms perform differently based on:

• Data type (sorted, reverse sorted, random)


• Dataset size
• Memory availability
4. Use of divide and conquer technique to solve some problem that
uses two different algorithms for solving small problem.

Divide and Conquer Technique Using Two Algorithms to Solve a Problem

Problem: Sorting and Searching in an Array


We will solve the problem using a Divide and Conquer approach, applying Merge Sort for sorting the array and Binary Search
for searching within it.

Steps of Divide and Conquer

1. Divide:
a. Break the problem into smaller subproblems.
b. Here, the array is divided into smaller subarrays for sorting using Merge Sort.
2. Conquer:
a. Solve the subproblems.
b. Use Merge Sort to sort the array and Binary Search to efficiently find an element in the sorted array.
3. Combine:
a. Combine the results to form the final solution.
b. The sorted array and the search result provide the complete solution.

Algorithm Overview

Step 1: Sorting Using Merge Sort

Merge Sort divides the array into smaller parts, sorts them, and merges them back.

1. Divide the Array: Split the array into two halves until each part has one element.
2. Conquer: Sort each half recursively.
3. Combine: Merge the two sorted halves into a single sorted array.

Step 2: Searching Using Binary Search

Binary Search works on the sorted array to find a target element efficiently.

1. Divide the Array: Find the middle element of the array.


2. Conquer: Compare the middle element with the target.
a. If the middle element is equal to the target, return its index.
b. If the target is smaller, search in the left half.
c. If the target is larger, search in the right half.
3. Combine: Return the result of the search (found or not found).

Example Implementation

def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
def merge(left, right):
sorted_arr = []
i=j=0
while i < len(left) and j < len(right):
if left[i] < right[j]:
sorted_arr.append(left[i])
i += 1
else:
sorted_arr.append(right[j])
j += 1
sorted_arr.extend(left[i:])
sorted_arr.extend(right[j:])
return sorted_arr

def binary_search(arr, target):


left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid # Element found
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1 # Element not found

# Example Usage
array = [38, 27, 43, 3, 9, 82, 10]
target = 43

# Step 1: Sort the array using Merge Sort


sorted_array = merge_sort(array)
print("Sorted Array:", sorted_array)

# Step 2: Search for the target using Binary Search


result = binary_search(sorted_array, target)
if result != -1:
print(f"Element {target} found at index {result} in the sorted array.")
else:
print(f"Element {target} not found.")

Why is This Divide and Conquer?

1. Divide:
a. Merge Sort splits the array into smaller parts.
b. Binary Search divides the array to find the target.
2. Conquer:
a. Sorting and searching are handled using recursive or iterative methods.
3. Combine:
a. The sorted array and search result provide the complete solution.

This approach demonstrates how two different algorithms (Merge Sort and Binary Search) are used effectively in a Divide and Conquer
framework.
5. Implementation of different basic computing algorithms like Hash tables, including
collision-avoidance
strategies, Search trees (AVL and B-trees).

Here's a clear and simplified explanation of implementing basic computing algorithms like hash tables with collision-avoidance strategies and search trees (AVL
and B-trees). I'll provide a step-by-step approach for better understanding.

1. Hash Tables with Collision Avoidance

A hash table is a data structure that maps keys to values using a hash function. Collision avoidance is essential to ensure that two keys do not map to the same
index.

Key Concepts

• Hash Function: A function that converts a key into an index.


• Collision: Occurs when two keys hash to the same index.
• Collision Avoidance Strategies:
o Chaining: Use a linked list to store multiple elements at the same index.
o Open Addressing: Find another open slot using techniques like linear probing.
o
o Python Implementation
class HashTable:
def __init__(self, size):
self.size = size
self.table = [[] for _ in range(size)] # Chaining method

def hash_function(self, key):


return hash(key) % self.size

def insert(self, key, value):


index = self.hash_function(key)
for pair in self.table[index]:
if pair[0] == key:
pair[1] = value
return
self.table[index].append([key, value])

def get(self, key):


index = self.hash_function(key)
for pair in self.table[index]:
if pair[0] == key:
return pair[1]
return None

def display(self):
for i, slot in enumerate(self.table):
print(f"Index {i}: {slot}")

# Example Usage
hash_table = HashTable(5)
hash_table.insert("Shakir", 25)
hash_table.insert("Ali", 30)
hash_table.insert("Data", 45)
hash_table.display()

2. AVL Trees

An AVL tree is a self-balancing binary search tree where the height difference between left and right subtrees (balance factor) is at most 1.

Key Operations

• Rotation: Used to rebalance the tree.


o Left Rotation (LL)
o Right Rotation (RR)
o Left-Right (LR)
o Right-Left (RL)
• Balance Factor: Height difference between left and right subtrees.
• Python Implementation
class Node:
def __init__(self, key):
self.key = key
self.left = None
self.right = None
self.height = 1

class AVLTree:
def get_height(self, node):
return node.height if node else 0

def balance_factor(self, node):


return self.get_height(node.left) - self.get_height(node.right)

def rotate_right(self, y):


x = y.left
y.left = x.right
x.right = y
y.height = 1 + max(self.get_height(y.left), self.get_height(y.right))
x.height = 1 + max(self.get_height(x.left), self.get_height(x.right))
return x

def rotate_left(self, x):


y = x.right
x.right = y.left
y.left = x
x.height = 1 + max(self.get_height(x.left), self.get_height(x.right))
y.height = 1 + max(self.get_height(y.left), self.get_height(y.right))
return y

def insert(self, node, key):


if not node:
return Node(key)
if key < node.key:
node.left = self.insert(node.left, key)
else:
node.right = self.insert(node.right, key)

node.height = 1 + max(self.get_height(node.left), self.get_height(node.right))


balance = self.balance_factor(node)

# LL Case
if balance > 1 and key < node.left.key:
return self.rotate_right(node)
# RR Case
if balance < -1 and key > node.right.key:
return self.rotate_left(node)
# LR Case
if balance > 1 and key > node.left.key:
node.left = self.rotate_left(node.left)
return self.rotate_right(node)
# RL Case
if balance < -1 and key < node.right.key:
node.right = self.rotate_right(node.right)
return self.rotate_left(node)

return node

def pre_order(self, root):


if not root:
return
print(root.key, end=" ")
self.pre_order(root.left)
self.pre_order(root.right)

# Example Usage
tree = AVLTree()
root = None
keys = [10, 20, 30, 40, 50, 25]

for key in keys:


root = tree.insert(root, key)

tree.pre_order(root)

3. B-Trees

B-Trees are general tree data structures used for storing large amounts of sorted data and allowing efficient insertion, deletion, and search operations.

Key Features

• A node can have multiple keys.


• Balances itself by splitting nodes when necessary.
• Python Implementation
• class BTreeNode:
def __init__(self, t, leaf=False):
self.t = t # Minimum degree
self.keys = []
self.children = []
self.leaf = leaf

def insert_non_full(self, key):


i = len(self.keys) - 1

if self.leaf:
while i >= 0 and key < self.keys[i]:
i -= 1
self.keys.insert(i + 1, key)
else:
while i >= 0 and key < self.keys[i]:
i -= 1
i += 1
if len(self.children[i].keys) == 2 * self.t - 1:
self.split_child(i)
if key > self.keys[i]:
i += 1
self.children[i].insert_non_full(key)

def split_child(self, i):


t = self.t
child = self.children[i]
new_child = BTreeNode(t, child.leaf)
self.keys.insert(i, child.keys[t - 1])
self.children.insert(i + 1, new_child)

new_child.keys = child.keys[t:]
child.keys = child.keys[:t - 1]

if not child.leaf:
new_child.children = child.children[t:]
child.children = child.children[:t]

class BTree:
def __init__(self, t):
self.root = BTreeNode(t, True)
self.t = t

def insert(self, key):


root = self.root
if len(root.keys) == 2 * self.t - 1:
new_root = BTreeNode(self.t)
new_root.children.append(root)
new_root.leaf = False
new_root.split_child(0)
self.root = new_root
self.root.insert_non_full(key)
else:
root.insert_non_full(key)

# Example Usage
b_tree = BTree(3)
keys = [10, 20, 5, 6, 12, 30, 7, 17]

for key in keys:


b_tree.insert(key)

These implementations are beginner-friendly and focus on clarity for understanding basic algorithms. Let me know if you'd like detailed explanations for any
specific part!
6. Consider the problem of eight queens on an (8x8) chessboard. Two queens are said to attack
each
other if they are on the same row, column, or diagonal. Write a program that implements
backtracking algorithm to solve the problem i.e. place eight non-attacking queens on the board.
Python Program for 8-Queens Problem
# Function to print the chessboard
def print_board(board):
for row in board:
print(" ".join("Q" if col else "." for col in row))
print("\n")

# Function to check if placing a queen is safe


def is_safe(board, row, col, n):
# Check column
for i in range(row):
if board[i][col]:
return False

# Check upper left diagonal


for i, j in zip(range(row, -1, -1), range(col, -1, -1)):
if board[i][j]:
return False

# Check upper right diagonal


for i, j in zip(range(row, -1, -1), range(col, n)):
if board[i][j]:
return False

return True

# Backtracking function to place queens


def solve_n_queens(board, row, n):
# Base case: If all queens are placed
if row == n:
print_board(board)
return True

# Try placing queen in each column of the current row


for col in range(n):
if is_safe(board, row, col, n):
# Place the queen
board[row][col] = 1

# Recur to place the rest of the queens


if solve_n_queens(board, row + 1, n):
return True

# Backtrack: Remove the queen if placing here doesn't lead to a solution


board[row][col] = 0

return False

# Main function
def eight_queens():
n = 8 # Chessboard size (8x8)
board = [[0] * n for _ in range(n)] # Initialize an empty board

if not solve_n_queens(board, 0, n):


print("No solution exists.")
else:
print("Solution found!")

# Run the program


eight_queens()
Explanation of the Code

1. print_board(board):
a. Displays the chessboard, showing "Q" for queens and "." for empty cells.
2. is_safe(board, row, col, n):
a. Checks whether placing a queen at (row, col) is safe. It ensures no queen exists in:
i. The same column.
ii. The upper-left diagonal.
iii. The upper-right diagonal.
3. solve_n_queens(board, row, n):
a. A recursive function that attempts to place queens row by row.
b. If placing a queen leads to a valid solution, it returns True. Otherwise, it backtracks and removes the queen.
4. eight_queens():
a. Initializes the chessboard and calls the solver function.
7.Write a program to find the strongly connected components in a digraph.
from collections import defaultdict

# Class to represent a directed graph


class Digraph:
def __init__(self, vertices):
self.V = vertices
self.graph = defaultdict(list)

# Add edge to the graph


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

# Perform DFS and store the finish times


def dfs(self, v, visited, stack=None):
visited[v] = True
for neighbor in self.graph[v]:
if not visited[neighbor]:
self.dfs(neighbor, visited, stack)
if stack is not None:
stack.append(v) # Append vertex to stack after all neighbors are visited

# Transpose the graph


def get_transpose(self):
transposed = Digraph(self.V)
for v in self.graph:
for neighbor in self.graph[v]:
transposed.add_edge(neighbor, v)
return transposed

# Find and print all SCCs


def find_sccs(self):
# Step 1: Perform DFS and store vertices by finish time
stack = []
visited = [False] * self.V
for i in range(self.V):
if not visited[i]:
self.dfs(i, visited, stack)

# Step 2: Transpose the graph


transposed_graph = self.get_transpose()

# Step 3: Perform DFS on transposed graph in order of finish times


visited = [False] * self.V
print("Strongly Connected Components are:")
while stack:
v = stack.pop()
if not visited[v]:
scc = []
transposed_graph.dfs(v, visited, scc) # Collect SCC
print(scc)

# Example usage
if __name__ == "__main__":
g = Digraph(5) # Create a graph with 5 vertices (0 to 4)
g.add_edge(0, 2)
g.add_edge(2, 1)
g.add_edge(1, 0)
g.add_edge(0, 3)
g.add_edge(3, 4)

g.find_sccs()

Explanation:

1. Graph Representation:
a. The graph is represented as an adjacency list using Python's defaultdict.
2. Kosaraju's Algorithm:
a. Step 1: Perform a DFS on the original graph and store vertices in a stack based on their finishing times.
b. Step 2: Transpose the graph (reverse all edges).
c. Step 3: Perform DFS on the transposed graph, starting with the vertices in the order defined by the stack
from Step 1. Each DFS traversal gives one SCC.
3. Input Example:
a. The program assumes a graph with 5 vertices. You can change edges or add more vertices as needed.
4. Outpu

t Example:
8.Write a program to implement file compression (and un-compression)
using Huffman’s algorithm.
import heapq
import os

class HuffmanNode:
def __init__(self, char, freq):
self.char = char # Character
self.freq = freq # Frequency
self.left = None # Left child
self.right = None # Right child

# Comparison method for priority queue


def __lt__(self, other):
return self.freq < other.freq

# 1. Build Frequency Dictionary


def build_frequency_table(data):
frequency = {}
for char in data:
frequency[char] = frequency.get(char, 0) + 1
return frequency

# 2. Build Huffman Tree


def build_huffman_tree(frequency):
priority_queue = [HuffmanNode(char, freq) for char, freq in frequency.items()]
heapq.heapify(priority_queue) # Create a priority queue (min-heap)

while len(priority_queue) > 1:


node1 = heapq.heappop(priority_queue) # Least frequent node
node2 = heapq.heappop(priority_queue) # Second least frequent node

# Merge two nodes


merged = HuffmanNode(None, node1.freq + node2.freq)
merged.left = node1
merged.right = node2

heapq.heappush(priority_queue, merged)

return priority_queue[0] # Root of the Huffman Tree

# 3. Generate Huffman Codes


def generate_huffman_codes(root, code='', huffman_codes={}):
if root is None:
return

if root.char is not None: # Leaf node


huffman_codes[root.char] = code
generate_huffman_codes(root.left, code + '0', huffman_codes)
generate_huffman_codes(root.right, code + '1', huffman_codes)

return huffman_codes

# 4. Compress the File


def compress_file(data):
frequency = build_frequency_table(data)
huffman_tree_root = build_huffman_tree(frequency)
huffman_codes = generate_huffman_codes(huffman_tree_root)

# Encode data
encoded_data = ''.join(huffman_codes[char] for char in data)

return encoded_data, huffman_tree_root

# 5. Decompress the File


def decompress_file(encoded_data, huffman_tree_root):
decoded_data = []
current_node = huffman_tree_root

for bit in encoded_data:


if bit == '0':
current_node = current_node.left
else:
current_node = current_node.right

# If it's a leaf node, add the character to the output


if current_node.char is not None:
decoded_data.append(current_node.char)
current_node = huffman_tree_root

return ''.join(decoded_data)

# Main function
if __name__ == "__main__":
# Example: Compress and decompress a simple text
text = "shakir ali huffman coding example"
print("Original text:", text)

# Compress
encoded, tree = compress_file(text)
print("\nEncoded data:", encoded)

# Decompress
decoded = decompress_file(encoded, tree)
print("\nDecoded text:", decoded)

# Verify if the original and decoded data are the same


print("\nCompression successful:", text == decoded)

Explanation of the Program


1. Frequency Table: Counts how often each character appears in the input text.
2. Huffman Tree: Builds a binary tree where lower-frequency characters are farther from the root.
3. Huffman Codes: Assigns a binary code (e.g., '0' and '1') to each character based on its position in the tree.
4. Compression: Encodes the input text into a binary string using the Huffman codes.
5. Decompression: Traverses the Huffman tree using the binary string to decode the original text.

Input and Output Example

Input:

Original text: shakir ali huffman coding example

Output:
9.Write a program to implement dynamic programming algorithm to solve
the all pairs shortest path
Problem.
# Floyd-Warshall Algorithm to find All-Pairs Shortest Path

def floyd_warshall(graph):
"""
Function to find shortest distances between all pairs of vertices
using the Floyd-Warshall Algorithm.

Parameters:
graph (list of lists): Adjacency matrix representing the graph.
Use a large number (like float('inf')) for no direct edge.

Returns:
list of lists: Matrix with shortest distances between all pairs of vertices.
"""
# Number of vertices in the graph
V = len(graph)

# Create a copy of the graph to store shortest distances


dist = [[graph[i][j] for j in range(V)] for i in range(V)]

# Adding vertices one by one to the set of intermediate vertices


for k in range(V):
for i in range(V):
for j in range(V):
# Update the shortest distance between i and j
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])

return dist

# Example usage
if __name__ == "__main__":
# Adjacency matrix representation of the graph
# Replace float('inf') with a very large number for no direct edge
graph = [
[0, 3, float('inf'), 7],
[8, 0, 2, float('inf')],
[5, float('inf'), 0, 1],
[2, float('inf'), float('inf'), 0]
]

print("Original Graph (Adjacency Matrix):")


for row in graph:
print(row)

# Calculate shortest paths


shortest_paths = floyd_warshall(graph)

print("\nAll Pairs Shortest Paths:")


for row in shortest_paths:
print(row)

Explanation:

1. Input Graph: Represented as an adjacency matrix.


a. graph[i][j] holds the weight of the edge from vertex i to vertex j.
b. Use float('inf') for no direct edge between i and j.
2. Dynamic Programming Table:
a. dist[i][j] holds the shortest distance from i to j.
b. Initially, it is the same as the input adjacency matrix.
3. Algorithm Steps:
a. For each vertex k as an intermediate vertex, update the shortest path from i to j as min(dist[i][j], dist[i][k] + dist[k][j]).
4. Output:
a. The dist matrix after all iterations contains the shortest distances between all pairs of vertices.

Example Input and Output:


10. Write a program to solve 0/1 knapsack problem using the following:
a)
Greedy algorithm.
b) Dynamic programming algorithm.
c)
Backtracking algorithm.
d) Branch and bound algorithm.

a) Greedy Algorithm

The Greedy algorithm doesn't guarantee the optimal solution for 0/1 Knapsack but can provide a quick approximation by maximizing the value-
to-weight ratio.

def knapsack_greedy(weights, values, capacity):


items = sorted([(v / w, w, v) for w, v in zip(weights, values)], reverse=True)
total_value = 0
for ratio, weight, value in items:
if capacity >= weight:
capacity -= weight
total_value += value
else:
break # Greedy fails here for 0/1 Knapsack
return total_value

weights = [10, 20, 30]


values = [60, 100, 120]
capacity = 50
print("Greedy Approach Result:", knapsack_greedy(weights, values, capacity))

b) Dynamic Programming Algorithm

Dynamic Programming finds the optimal solution by storing the results of subproblems in a table. def knapsack_dp(weights,
values, capacity):
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]

for i in range(1, n + 1):


for w in range(1, capacity + 1):
if weights[i - 1] <= w:
dp[i][w] = max(dp[i - 1][w], dp[i - 1][w - weights[i - 1]] + values[i - 1])
else:
dp[i][w] = dp[i - 1][w]

return dp[n][capacity]

print("Dynamic Programming Result:", knapsack_dp(weights, values, capacity))


c) Backtracking Algorithm
Backtracking explores all possible combinations and backtracks when constraints are violated.

def knapsack_backtracking(weights, values, capacity, index=0, current_value=0):


if index == len(weights) or capacity == 0:
return current_value

if weights[index] > capacity:


return knapsack_backtracking(weights, values, capacity, index + 1, current_value)

# Choose item or not


include = knapsack_backtracking(weights, values, capacity - weights[index], index + 1, current_value + values[index])
exclude = knapsack_backtracking(weights, values, capacity, index + 1, current_value)

return max(include, exclude)

print("Backtracking Result:", knapsack_backtracking(weights, values, capacity))


d) Branch and Bound Algorithm

Branch and Bound uses a priority queue to keep track of promising nodes and calculates bounds to prune unnecessary branches.

from queue import PriorityQueue

class Node:
def __init__(self, level, value, weight, bound):
self.level = level
self.value = value
self.weight = weight
self.bound = bound

def bound(node, n, weights, values, capacity):


if node.weight >= capacity:
return 0
profit_bound = node.value
j = node.level + 1
tot_weight = node.weight

while j < n and tot_weight + weights[j] <= capacity:


tot_weight += weights[j]
profit_bound += values[j]
j += 1

if j < n:
profit_bound += (capacity - tot_weight) * values[j] / weights[j]

return profit_bound

def knapsack_branch_and_bound(weights, values, capacity):


n = len(weights)
pq = PriorityQueue()
u = Node(-1, 0, 0, 0)
v = Node(-1, 0, 0, 0)
pq.put((-u.bound, u))
max_profit = 0

while not pq.empty():


_, u = pq.get()
if u.level == n - 1:
continue

v.level = u.level + 1
v.weight = u.weight + weights[v.level]
v.value = u.value + values[v.level]

if v.weight <= capacity and v.value > max_profit:


max_profit = v.value

v.bound = bound(v, n, weights, values, capacity)


if v.bound > max_profit:
pq.put((-v.bound, v))

v.weight = u.weight
v.value = u.value
v.bound = bound(v, n, weights, values, capacity)
if v.bound > max_profit:
pq.put((-v.bound, v))

return max_profit

print("Branch and Bound Result:", knapsack_branch_and_bound(weights, values, capacity))


Summary
• Greedy Algorithm: Fast but may not be optimal.
• Dynamic Programming: Finds the optimal solution efficiently for small-to-medium datasets.
• Backtracking: Explores all options, ensuring optimal results but can be slow.
• Branch and Bound: Combines optimization and pruning, suitable for larger datasets.
11. Write a program that uses dynamic programming algorithm to solve the
optimal binary search tree
problem.
The Optimal Binary Search Tree (OBST) problem is solved using dynamic programming to minimize the cost of searching in a binary search tree given a set of
keys with associated probabilities of access. Here’s a Python program that implements the dynamic programming approach to solve this problem:

Explanation of the Approach:

1. Input:
a. A sorted array of keys.
b. Probability arrays p[i] (search probabilities) and q[i] (dummy node probabilities).
2. Output:
a. The minimum cost of the optimal binary search tree.
b. The structure of the tree.

Dynamic Programming Steps:

• Use a cost table cost[i][j] to store the minimal cost for a subtree spanning i to j.
• Use a root table root[i][j] to store the optimal root for each subtree.
• Compute the optimal cost for each possible subtree size iteratively.

Python Implementation:

python
Copy code
def optimal_bst(keys, p, q, n):
# Initialize cost and weight matrices
cost = [[0 for _ in range(n + 1)] for _ in range(n + 1)]
weight = [[0 for _ in range(n + 1)] for _ in range(n + 1)]
root = [[0 for _ in range(n)] for _ in range(n)]

# Base case: cost for dummy nodes


for i in range(n + 1):
cost[i][i] = q[i]
weight[i][i] = q[i]

# Fill cost and root tables


for l in range(1, n + 1): # l is the chain length
for i in range(n - l + 1):
j=i+l
cost[i][j] = float('inf')
weight[i][j] = weight[i][j - 1] + p[j - 1] + q[j]

# Find the optimal root for this range


for r in range(i, j):
c = cost[i][r] + cost[r + 1][j] + weight[i][j]
if c < cost[i][j]:
cost[i][j] = c
root[i][j - 1] = r

return cost[0][n], root

def print_optimal_bst(root, keys, i, j, parent, direction):


if i > j:
return
r = root[i][j]
print(f"Key {keys[r]} is the {direction} child of {parent}")
print_optimal_bst(root, keys, i, r - 1, keys[r], "left")
print_optimal_bst(root, keys, r + 1, j, keys[r], "right")

# Example usage
if __name__ == "__main__":
keys = [10, 20, 30, 40] # Sorted keys
p = [0.1, 0.2, 0.4, 0.3] # Probabilities for actual keys
q = [0.05, 0.1, 0.05, 0.05, 0.05] # Probabilities for dummy keys
n = len(keys)

min_cost, root = optimal_bst(keys, p, q, n)


print(f"Minimum cost of Optimal BST: {min_cost}\n")
print("Optimal BST structure:")
print_optimal_bst(root, keys, 0, n - 1, "root", "root")

Example Output:

Explanation of Output:

• Cost Table: The minimum search cost.


• Tree Structure: Shows the keys as left or right children of their parent, forming the optimal binary search tree.
12. Write a program for solving traveling salespersons problem using the following:
a)
Dynamic programming algorithm.
b) The back tracking algorithm.
c)
Branch and bound.

The Traveling Salesperson Problem (TSP) is a classic problem in optimization. The goal is to find the shortest possible route that visits each city once and returns
to the starting city. Below are solutions for TSP using Dynamic Programming, Backtracking, and Branch and Bound in Python:

a) Dynamic Programming Algorithm

This solution uses the Held-Karp algorithm, a well-known dynamic programming approach for solving TSP.

python
Copy code
import sys

def tsp_dynamic_programming(graph, start=0):


n = len(graph)
dp = [[None] * (1 << n) for _ in range(n)]

def visit(city, visited):


if visited == (1 << n) - 1: # All cities visited
return graph[city][start] or sys.maxsize
if dp[city][visited] is not None:
return dp[city][visited]

min_cost = sys.maxsize
for next_city in range(n):
if visited & (1 << next_city) == 0 and graph[city][next_city] > 0:
min_cost = min(
min_cost,
graph[city][next_city] + visit(next_city, visited | (1 << next_city))
)
dp[city][visited] = min_cost
return min_cost

return visit(start, 1 << start)

# Example graph
graph = [
[0, 10, 15, 20],
[10, 0, 35, 25],
[15, 35, 0, 30],
[20, 25, 30, 0]
]
print("TSP cost using Dynamic Programming:", tsp_dynamic_programming(graph))
b) Backtracking Algorithm

Backtracking explores all possible routes and selects the optimal one.

python
Copy code
def tsp_backtracking(graph, start=0):
n = len(graph)
visited = [False] * n
min_cost = sys.maxsize

def solve(curr_pos, count, cost):


nonlocal min_cost
if count == n and graph[curr_pos][start] > 0:
min_cost = min(min_cost, cost + graph[curr_pos][start])
return

for i in range(n):
if not visited[i] and graph[curr_pos][i] > 0:
visited[i] = True
solve(i, count + 1, cost + graph[curr_pos][i])
visited[i] = False

visited[start] = True
solve(start, 1, 0)
return min_cost

# Example graph
graph = [
[0, 10, 15, 20],
[10, 0, 35, 25],
[15, 35, 0, 30],
[20, 25, 30, 0]
]
print("TSP cost using Backtracking:", tsp_backtracking(graph))

c) Branch and Bound Algorithm

This method maintains a lower bound and prunes branches that exceed it.

python
Copy code
import heapq

def tsp_branch_and_bound(graph, start=0):


n = len(graph)
min_cost = sys.maxsize
priority_queue = []

def calculate_bound(path, visited):


bound = 0
for i in range(n):
if not visited[i]:
min_edge = sys.maxsize
for j in range(n):
if not visited[j] and graph[i][j] > 0:
min_edge = min(min_edge, graph[i][j])
if min_edge != sys.maxsize:
bound += min_edge
return bound

def solve(curr_pos, path, cost, visited):


nonlocal min_cost
if len(path) == n:
return_cost = graph[curr_pos][start]
if return_cost > 0:
total_cost = cost + return_cost
min_cost = min(min_cost, total_cost)
return

for next_city in range(n):


if not visited[next_city] and graph[curr_pos][next_city] > 0:
visited[next_city] = True
bound = calculate_bound(path + [next_city], visited)
if cost + bound < min_cost:
heapq.heappush(priority_queue, (cost + graph[curr_pos][next_city], next_city, path + [next_city]))
visited[next_city] = False

heapq.heappush(priority_queue, (0, start, [start]))


while priority_queue:
cost, curr_pos, path = heapq.heappop(priority_queue)
visited = [False] * n
for city in path:
visited[city] = True
solve(curr_pos, path, cost, visited)

return min_cost

# Example graph
graph = [
[0, 10, 15, 20],
[10, 0, 35, 25],
[15, 35, 0, 30],
[20, 25, 30, 0]
]
print("TSP cost using Branch and Bound:", tsp_branch_and_bound(graph))

Output Example

You might also like