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

Design and Analysis of Algorithms - Assignment #3

Uploaded by

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

Design and Analysis of Algorithms - Assignment #3

Uploaded by

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

COMSATS University Islamabad

CSC301 – Design and Analysis of Algorithms


Assignment #3
Spring 2024

Submitted by: Munib Ur Rehman Qasim


SP22-BAI-036

Class: BSAI-5A

Submitted to: Sir Tanveer Ahmed

Due date: 19th April 2024


Question #1 (Divide and Conquer Approach):

Design an algorithm using “Divide and Conquer” designing technique for the
following problem.
Problem Statement: Let S1, S2,……. and Sn be n strings of different lengths.
Find common prefix of largest length.
Example:
Input:
S1 = Apple
S2 = Application
S3 = April
S4 = Apply
Output: "Ap"

Sol.

The problem is that given n strings of different lengths, we have to find the common prefix of
the largest length. We can solve this problem using the Divide and Conquer technique. The
idea is to divide the array of strings into two halves and find the common prefix of the left and
right halves. Then, we can find the common prefix of the two halves. We keep dividing until
there is only one string left in one half, then we start finding the largest common prefix of
both the halves.

Page | 1
Pseudo Code:
Algorithm longestCommonPrefixHelper(A[0..n-1], l, r) {
// Input: A non-empty array A[0..n-1] strings. l and r are the indices of start and end
of the array portion for which we have to find longest prefix of.
// Output: A string.

if (l == r) return A[l]

mid ← ⌊(l + r)/2⌋


s1 ← longestCommonPrefixHelper(A, l, mid)
s2 ← longestCommonPrefixHelper(A, mid+1, r)

prefix ← ''
for i ← 0 to min(s1.length, s2.length) - 1 {
if (s1[i] != s2[i]) break

prefix ← prefix + s1[i]


}

return prefix
}
Algorithm longestCommonPrefix(A[0..n-1]) {
// Input: An array A[0..n-1] strings.
// Output: A string.

if (n == 0) return ''

return longestCommonPrefixHelper(A, 0, n-1)


}

Page | 2
Example:
Let input be: ['Apple', 'Application', 'April', 'Apply']

First array gets divided:


['Apple', 'Application', 'April', 'Apply']

['Apple', 'Application'] ['April', 'Apply']

['Apple'] ['Application'] ['April'] ['Apply']


Then its conquered and merged by finding common prefix:
‘Appl’ ‘Ap’

‘Ap’

Output: “Ap”

Time Complexity:
Let n be the number of strings and m be the length of the longest string.

The time complexity will be O(nm)

Page | 3
Question #2 (Divide and Conquer Approach):

We discussed two sorting algorithms namely “Insertion sort” and “Merge sort” in class.
Insertion sort employs “Decrease (by one) and Conquer strategy whereas Merge Sort
employs “Divide and Conquer” Strategy. Insertion sort is efficient for sorting small arrays
or lists. That is if the input array is nearly sorted or already sorted, insertion sort performs
very efficiently. In this scenario its time complexity is O(n). Merge sort has a time
complexity of O(n log n) in all cases. While merge sort is highly efficient for sorting large
data sets, it may not be the most efficient choice for very small subarrays due to its
overhead in recursion and merging. One can improve the overall performance of merge
sort by combining the strengths of both Insertion Sort and Merge Sort algorithms. The idea
is when the size of a subarray falls below the threshold value(say 20), switch to insertion
sort to sort that subarray. After sorting the small subarrays using insertion sort, merge them
back together using the merge operation of merge sort. Design an algorithm
hybrid_merge_sort(array) that leverages the strengths of both merge sort and insertion
sort to achieve better performance overall. Present your solution in pseudocode.

Sol.

The problem is to design an algorithm that leverages the strengths of both merge sort and
insertion sort to achieve better performance overall. The idea is to switch to insertion sort
when the size of a subarray falls below a threshold value (e.g., 20) and then merge the sorted
subarrays back together using the merge operation of merge sort. The hybridMergeSort
algorithm first checks if the size of the subarray is less than the threshold value. If it is, it uses
insertion sort to sort the subarray. Otherwise, it divides the array into two halves and
recursively calls hybridMergeSort on each half. Finally, it merges the two sorted halves using
the merge operation.

Page | 4
Pseudo Code:
Algorithm insertionSort(A[0..n-1], l, r) {
// Input: An array A[0..n-1] of n elements, l and r are the indices of the start and end
of the array portion to be sorted.
// Output: (part of the array A[0..n-1] sorted in non-decreasing order from index l to
r)

for i ← l+1 to r {
key ← A[i]
j ← i-1

while (j ≥ l and A[j] > key) {


A[j+1] ← A[j]
j ← j-1
}

A[j+1] ← key
}
}

Algorithm merge(A[0..n-1], p, q, r) {
// Input: An array A[0..n-1] of n elements, p, q, and r are indices of the array such that
p ≤ q < r.
// Output: (part of the array A[0..n-1] merged in non-decreasing order from index p
to r)
A2 ← [], i ← p, j ← q+1, l←0

while (i ≤ q and j ≤ r) {
if (A[i] ≤ A[j]) A2[l++] ← A[i++]
else A2[l++] ← A[j++]
}

Page | 5
while (i ≤ q) A2[l++] ← A[i++]

while (j ≤ r) A2[l++] ← A[j++]

for i ← 0 to l-1 {
A[p+i] ← A2[i]
}

Algorithm hybridMergeSort(A[0..n-1], p, r) {
// Input: An array A[0..n-1] of n elements, p and r are the indices of the start and end
of the array portion to be sorted.
// Output: A[0..n-1] sorted in non-decreasing order from index p to r.

if (p < r) {
if (r - p < 20) {
insertionSort(A, p, r)
} else {
q ← p + ⌊(r - p)/2⌋
hybridMergeSort(A, p, q)
hybridMergeSort(A, q+1, r)
merge(A, p, q, r)
}
}
}

Page | 6
Explanation:
It works like normal mergeSort except for the fact that instead of dividing until 1 element, it
divides the array into two halves until the size reduces to below 20, then it sorts that portion
using insertion sort and then the merging happens of the sorted portions, like in normal
mergeSort

Time Complexity:
The time complexity is still O(nlog(n)) in the worst case for large n.

Page | 7
Question #3 (Divide and Conquer Approach):

One common issue with Quicksort is its vulnerability to worst-case scenarios


when selecting a bad pivot, such as the smallest or largest element in the array.
To mitigate this, randomize the pivot selection process by choosing a random
element as the pivot. This helps distribute the data more evenly, reducing the
likelihood of encountering worst-case scenarios. Design an algorithm for this
improvement.

Sol.
The problem is to design an algorithm that improves the Quicksort algorithm by randomizing
the pivot selection process. By choosing a random element as the pivot, the algorithm helps
distribute the data more evenly, reducing the likelihood of encountering worst-case
scenarios. The randomPartition algorithm randomly selects an index within the range [l, r]
and swaps the element at that index with the pivot element at index r. It then calls the
partition algorithm to partition the array based on the pivot element.

Page | 8
Pseudo Code:
Algorithm partition(A[0..n-1], l, r) {
// Input: An array A[0..n-1] of n elements, l and r are the indices of the start and end
of the array portion to be partitioned.
// Output: A partitioned array A[0..n-1] with elements less than the pivot to the left
and elements greater than the pivot to the right.

pivot ← A[r]
i ← l-1
for j ← l to r-1 {
if (A[j] ≤ pivot) {
i ← i+1
swap A[i] and A[j]
}
}
swap A[i+1] and A[r]
return i+1
}

Algorithm randomPartition(A[0..n-1], l, r) {
// Input: An array A[0..n-1] of n elements, l and r are the indices of the start and end
of the array portion to be partitioned.
// Output: A partitioned array A[0..n-1] with elements less than the pivot to the left
and elements greater than the pivot to the right.

randomIndex ← random(l, r)

swap A[randomIndex] and A[r]

return partition(A, l, r)
}

Page | 9
Algorithm randomizedQuickSort(A[0..n-1], l, r) {
// Input: An array A[0..n-1] of n elements, l and r are the indices of the start and end
of the array portion to be sorted.
// Output: A[0..n-1] sorted in non-decreasing order from index l to r.

if (l < r) {
q ← randomPartition(A, l, r)
randomizedQuickSort(A, l, q-1)
randomizedQuickSort(A, q+1, r)
}
}

Explanation:
It works like normal quicksort except for the fact that it randomly selects a pivot and swaps it
with the last element in the portion to be partitioned. Then it performs normal partition
algorithm with selecting last element as the pivot (as the random element was swapped with
the last element).

Time Complexity:

Even though in practical sense the algorithm got better, its worst case complexity is still
O(n^2) and O(nlog(n)) in best case.

Page | 10
Question #4 (Divide and Conquer Approach):

Instead of choosing a pivot arbitrarily or randomly, select the pivot as the


median of three elements: the first, middle, and last elements of the array.
This approach helps in selecting a pivot that is closer to the true median of the
array, improving the algorithm's performance on partially sorted or nearly
sorted arrays. Design an algorithm for this improvement.

Sol.

The problem is to design an algorithm that improves the Quicksort algorithm by selecting the
pivot as the median of three elements: the first, middle, and last elements of the array. This
approach helps in selecting a pivot that is closer to the true median of the array, improving
the algorithm's performance on partially sorted or nearly sorted arrays.

The medianOfThreePartition algorithm first compares the first, middle, and last elements of
the array and swaps them to ensure that smallest element is at the first index, the largest
element is at the middle index, and the median element is at the last index. It then calls the
partition algorithm to partition the array based on the pivot element (it selects last element
as the pivot thats why we put the median at the last index).

Page | 11
Pseudo Code:
Algorithm partition(A[0..n-1], l, r) {
// Input: An array A[0..n-1] of n elements, l and r are the indices of the start and end
of the array portion to be partitioned.
// Output: A partitioned array A[0..n-1] with elements less than the pivot to the left
and elements greater than the pivot to the right.

pivot ← A[r]
i ← l-1
for j ← l to r-1 {
if (A[j] ≤ pivot) {
i ← i+1
swap A[i] and A[j]
}
}
swap A[i+1] and A[r]
return i+1
}
Algorithm medianOfThreePartition(A[0..n-1], l, r) {
// Input: An array A[0..n-1] of n elements, l and r are the indices of the start and end
of the array portion to be partitioned.
// Output: A partitioned array A[0..n-1] with elements less than the pivot to the left
and elements greater than the pivot to the right.

mid ← ⌊(l+r)/2⌋
// puts median element at the last index
if (A[l] > A[r]) swap A[l] and A[r]
if (A[r] > A[mid]) swap A[r] and A[mid]
if (A[l] > A[r]) swap A[l] and A[r]
return partition(A, l, r)
}

Page | 12
Algorithm medianOfThreeQuickSort(A[0..n-1], l, r) {
// Input: An array A[0..n-1] of n elements, l and r are the indices of the start and end
of the array portion to be sorted.
// Output: A[0..n-1] sorted in non-decreasing order from index l to r.

if (l < r) {
q ← medianOfThreePartition(A, l, r)
randomizedQuickSort(A, l, q-1)
randomizedQuickSort(A, q+1, r)
}
}

Page | 13
Explanation:
This also works as normal quicksort except for the selection of pivot. When selecting pivot, it
finds median of first, mid and last element, and then places smallest element at start, largest
element at mid and median element at the last index. Then it calls the normal partition
algorithm which selects the last element as pivot.

Time Complexity:

Even though in practical sense the algorithm got better, its worst case complexity is still
O(n^2) and O(nlog(n)) in best case.

Page | 14
Question #5 (Divide and Conquer Approach):

We can improve the performance of Quick sort algorithm by combining it with


other sorting algorithm such as Heapsort. The idea is “ partition the input array
into two subarrays based on a pivot element. For larger subarrays, we
recursively apply Quicksort to partition them further until they reach the
threshold size. Once the subarrays reach a certain size threshold (typically a
small value say 10 ), we switch to Heapsort, to sort these smaller subarrays
efficiently. Heapsort is efficient for small arrays and has a guaranteed worst-
case time complexity of O(n log n). Once all subarrays are sorted, we merge
them together into a single sorted array if necessary”. Design an algorithm for
this improvement.

Sol.

The problem is to design an algorithm that improves the Quick sort algorithm by combining it
with Heapsort. The idea is to partition the input array into two subarrays based on a pivot
element. For larger subarrays, we recursively apply Quicksort to partition them further until
they reach the threshold size. Once the subarrays reach a size 10 or less, we switch to
Heapsort to sort these smaller subarrays efficiently.

The hybridQuickSort algorithm first checks if the size of the subarray is less than the threshold
value. If it is, it uses Heapsort to sort the subarray and then merges it with the original array.
Otherwise, it divides the array into two halves and recursively calls hybridQuickSort on each
half.

Page | 15
Pseudo Code:
Algorithm heapify(A[0..n-1], n, i) {
// Input: An array A[0..n-1] of n elements, n is the size of the heap, i is the index of the
root node.
// Output: A heapified array A[0..n-1] with the root node at index i.

largest ← i
l ← 2*i + 1
r ← 2*i + 2

if (l < n and A[l] > A[largest]) largest ← l

if (r < n and A[r] > A[largest]) largest ← r

if (largest ≠ i) {
swap A[i] and A[largest]
heapify(A, n, largest)
}
}

Algorithm heapSort(A[0..n-1]) {
// Input: An array A[0..n-1] of n elements.
// Output: A sorted array A[0..n-1] in non-decreasing order.

n ← A.length

for i ← ⌊n/2⌋-1 down to 0 {


heapify(A, n, i)
}

Page | 16
for i ← n-1 down to 1 {
swap A[0] and A[i]
heapify(A, i, 0)
}
}

Algorithm partition(A[0..n-1], l, r) {
// Input: An array A[0..n-1] of n elements, l and r are the indices of the start and end
of the array portion to be partitioned.
// Output: A partitioned array A[0..n-1] with elements less than the pivot to the left
and elements greater than the pivot to the right.

pivot ← A[r]
i ← l-1
for j ← l to r-1 {
if (A[j] ≤ pivot) {
i ← i+1
swap A[i] and A[j]
}
}
swap A[i+1] and A[r]
return i+1
}

Algorithm hybridQuickSort(A[0..n-1], l, r) {
// Input: An array A[0..n-1] of n elements, l and r are the indices of the start and end
of the array portion to be sorted.
// Output: A[0..n-1] sorted in non-decreasing order from index l to r.

if (l < r) {
if (r - l < 10) {

Page | 17
tempArr ← A[l..r]
heapSort(tempArr)

for i ← l to r {
A[i] ← tempArr[i-l]
}
} else {
q ← partition(A, l, r)
hybridQuickSort(A, l, q-1)
hybridQuickSort(A, q+1, r)
}
}
}

Page | 18
Explanation:
It works like normal quicksort except for the fact that when a portion reaches 10 or less length,
then it places that portion in a new array and calls heapsort on it. Once the sub array is sorted
then it merges with the original array.

Time Complexity:
Time complexity is still O(n^2) in the worst case and O(nlog(n)) in the best case.

Page | 19
Question #6 (Divide and Conquer Approach):

Instead of selecting a single pivot, choose multiple pivots and partition the
array into multiple segments. This approach can improve the algorithm's
performance, especially for large arrays, by reducing the number of recursive
calls and comparisons needed. Design an algorithm for this improvement.

Sol.

The problem is to design an algorithm that improves the Quick sort algorithm by choosing
multiple pivots and partitioning the array into multiple segments.

The multiPivotPartition algorithm selects two pivot elements from the first and last indices of
the array and partitions the array into three segments: elements less than the first pivot,
elements between the two pivots, and elements greater than the second pivot. It then
rearranges the elements in the array based on the pivot elements and returns the indices of
the two pivots.

Page | 20
Pseudo Code:
Algorithm multiPivotPartition(A[0..n-1], l, r) {
// Input: An array A[0..n-1] of n elements, l and r are the indices of the start and end
of the array portion to be partitioned.
// Output: A partitioned array A[0..n-1] with multiple pivots.

pivot1 ← A[l], pivot2 ← A[r]

if (pivot1 > pivot2) swap pivot1 and pivot2

arr1 ← [], arr2 ← [], arr3 ← []

for i ← l+1 to r-1 {


if (A[i] < pivot1) {
arr1.push(A[i])
} else if (A[i] > pivot2) {
arr3.push(A[i])
} else {
arr2.push(A[i])
}
}

i←l

for j ← 0 to arr1.length-1 {
A[i++] ← arr1[j]
}

A[i++] ← pivot1

Page | 21
for j ← 0 to arr2.length-1 {
A[i++] ← arr2[j]
}

A[i++] ← pivot2

for j ← 0 to arr3.length-1 {
A[i++] ← arr3[j]
}

return [l+arr1.length, r-arr3.length]


}

Algorithm multiPivotQuickSort(A[0..n-1], l, r) {
// Input: An array A[0..n-1] of n elements, l and r are the indices of the start and end
of the array portion to be sorted.
// Output: A[0..n-1] sorted in non-decreasing order from index l to r.

if (l < r) {
[q1, q2] ← multiPivotPartition(A, l, r)

multiPivotQuickSort(A, l, q1-1)
multiPivotQuickSort(A, q1+1, q2-1)
multiPivotQuickSort(A, q2+1, r)
}
}

Page | 22
Explanation:
Works like a normal quicksort but selects 2 pivots when partitioning algorithm. It selects the
first element as first pivot and last element as second pivot. It swaps the pivots if pivot1 is
greater than pivot2. Then it loops through the array portion and adds values appropriately in
3 arrays given that values in first array are smaller than pivot1, values in 3rd array are greater
than pivot2 and rest of the values go in array2. After that’s finished, we substitute the values
from the arrays in the original array such that values less than the pivots are on the left and
values greater than the pivot are on the right. Then we return the 2 pivots and call the
quicksort on the 3 created sub-portions.

Time Complexity:
Time Complexity is still O(n^2) in worst case and O(nlog(n)) in best case.

Page | 23
Question #7 (Dynamic Programming):

Consider an N * M grid, where some cells have coins in them and others do
not. A robot starts at the upper-left corner, can move only one cell right or one
cell down at every step, and picks up every coin it encounters. The goal is to
collect the most coins possible by the time the robot gets to the lower-right
corner of the board. Design an algorithm using dynamic programming that
returns the maximum collectible number of coins. What is the complexity of
your algorithm?

Sol.
The problem is to design an algorithm that calculates the maximum number of coins that a
robot can collect by moving from the upper-left corner to the lower-right corner of an N * M
grid. The robot can only move one cell right or one cell down at each step and collects coins
from each cell it visits.

The collectMaxCoins algorithm uses dynamic programming to calculate the maximum


number of coins that can be collected by the robot. It initializes a 2D array dp to store the
maximum number of coins that can be collected at each cell. It then iterates through the grid
to calculate the maximum number of coins that can be collected at each cell based on the
maximum number of coins collected in the cells to the left and above it. Finally, it returns the
maximum number of coins that can be collected at the lower-right corner of the grid.

Page | 24
Pseudo Code:
Algorithm collectMaxCoins(A[0..n-1][0..m-1]) {

// Input: A 2D array A[0..n-1][0..m-1] of n rows and m columns.


// Output: The maximum number of coins that can be collected by the robot.

dp[n][m]

dp[0][0] ← A[0][0]

for i ← 1 to n-1 {
dp[i][0] ← A[i][0] + dp[i-1][0]
}

for j ← 1 to m-1 {
dp[0][j] ← A[0][j] + dp[0][j-1]
}

for i ← 1 to n-1 {
for j ← 1 to m-1 {
dp[i][j] ← max(dp[i][j-1], dp[i-1][j]) + A[i][j]
}
}

return dp[n-1][m-1]
}

Page | 25
Example:
Given this input:

0 0 1 1

0 1 0 0

1 0 1 0

0 1 1 0

Where 1 represents coins. We first create a n*m table which stores the maximum number
of coins that can be collected upto each corresponding cell. We used the recursive formula
f(i, j) = max(f(i, j-1), f(i-1, j)) + A[i][j] to calculate the maximum number of coins that can be
collected at each cell. We first filled out the base case which is the first row and first column.
We then iterated from cell (1, 1) and used the recurrence relation to solve the rest of the
table.

0 0 1 2 0 0 1 2

0 0 1 1 2

1 1 1 2 2

1 1 2 3 3

Output: 3

Time Complexity:
The time complexity is O(nm) where n is the number of rows and m is the number of columns.

Page | 26
Question #8 (Dynamic Programming):

Design an algorithm based on Dynamic Programming that find minimum


number of deletion required to convert a string into a palindrome. For
example, you need to remove at least 2 characters of string “abbaccdaba” to
get the palindrome “abaccaba”.

Design an algorithm based on Dynamic Programming that find minimum


number of insertions required to convert a string into a palindrome. For
example, S: Ab3bd. we can get “dAb3bAd” or “Adb3bdA” by inserting 2
characters (one ‘d’, one ‘A’)

Sol.

The problem is to design algorithms based on Dynamic Programming that find the minimum
number of deletions and insertions required to convert a string into a palindrome.

To solve this problem, we can use the concept of the longest common subsequence, which is
the longest sequence of characters that appear in the same order in both strings. The longest
common subsequence between a string and its reverse will also be the longest palindromic
subsequence. Another note is that to convert a string into a palindrome with minimum
deletions or insertions, we would need to delete or insert characters that are not part of the
longest palindromic subsequence. Therefore, the minimum number of deletions or insertions
required to convert a string into a palindrome can be calculated as the difference between
the length of the string and the length of the longest palindromic subsequence between the
string and its reverse.

Page | 27
Pseudo Code:
Algorithm longestCommonSubsequenceLength(s1, s2) {
// Input: Two strings s1 and s2.
// Output: The length of the longest common subsequence between s1 and s2.

m ← s1.length
n ← s2.length

dp[n+1][m+1] ← 0

for i ← 1 to n {
for j ← 1 to m {
if (s1[j-1] == s2[i-1]) {
dp[i][j] ← dp[i-1][j-1] + 1
} else {
dp[i][j] ← max(dp[i-1][j], dp[i][j-1])
}
}
}

return dp[n][m]
}

Page | 28
Algorithm minDeletionsToPalindrome(s) {
// Input: A string s.
// Output: The minimum number of deletions required to convert s into a palindrome.

n ← s.length

rev ← reverse(s)

return n - longestCommonSubsequenceLength(s, rev)


}

Algorithm minInsertionsToPalindrome(s) {
// Input: A string s.
// Output: The minimum number of insertions required to convert s into a palindrome.

n ← s.length

rev ← reverse(s)

return n - longestCommonSubsequenceLength(s, rev)


}

Page | 29
Example:
Given an input string str=”aczlyxcla”
First it would find the length of the longest common subsequence between the string and its
reverse.

str=”aczlyxcla”
rev=”alcxylzca”

which is 5 (with a possible sequence “aczca”). Clearly it can be seen that this is a palindromic
subsequence, its also the longest palindromic subsequence which would always be the case
when finding the longest common subsequence between a string and its reverse. Now if we
delete all the other characters except for this subsequence, we would get a palindrome, or
we could insert the characters not included in this subsequence in the appropriate place and
that would also result in a palindrome. Both of these require the same amount of actions
which is equal to the number of characters not included in the longest palindromic
subsequence we just found.

So, to get the answer we can just subtract the length of the longest palindromic subsequence
from the length of the string.

Output: len(string) – len(longestPalindromicSubsequence)


=9–5=4

Time Complexity:
The time complexity of finding the longest common subsequence is O(n*m) where n and m
are the lengths of the 2 strings. Since we are passing a string and its reverse to the function,
both strings would be of the same length i.e ‘n’. So the overall time complexity will be O(n^2).

Page | 30
Question #9 (Dynamic Programming):

In biological applications, we often want to compare the DNA of two (or more) different
organisms. A part of DNA consists of a string (sequence) of molecules called bases, where
the possible bases are “Adenine”, “Cytosine”, “Guanine”, “Thymine”. Suppose we
represent each of the bases by their initial letters, then a part of DNA can be expressed as
a string over the finite set {A, C, G, T}. For example, the DNA of one organism may be
S1=CCGGTCGAGTGCGCGGAAGCCGGCCGAA, while the DNA of another organism may be S2
=GTCGTTCGGAATGCCGTTGCTCTGTAAA.
One goal of comparing two parts of DNA is to determine how “similar” two parts are. We
can measure the similarity between two DNA strings by finding a third part S3 that appears
in both S1 and S2, such that the bases must preserve order but may not be consecutive. The
longer S3 we can find, the more similar S1 and S2 are. In the above DNA strings, S3 is
GTCGTCGGAAGCCGGCCGAA.S1 and S2 are sequences, whereas S3 is a subsequence of both
S1 and S2. A subsequence is a sequence that appears in the same relative order but not
necessarily contiguously. Design an algorithm using Dynamic Programming approach to find
the longest common subsequence present in both DNA sequences as well as its length. You
should clearly state:
1. Optimal Substructure property
2. Overlapping Subproblems: Recurrence formula that compute subproblems
repeatedly.
3. Look up table filling pattern.
4. A description of the algorithm in pseudo code.
5. At least one worked example or diagram to show more precisely how your
algorithm works.
6. An analysis of the running time of the algorithm.

Sol.

The problem is to design an algorithm using Dynamic Programming to find the longest
common subsequence present in both DNA sequences as well as its length.

The optimal substructure property of this problem is that the longest common subsequence
between two DNA sequences can be constructed from the longest common subsequence

Page | 31
between their prefixes, for example to find the longest common subsequence between
"ACGT" and "AGT", we can consider the longest common subsequence between "ACGT" and
"AG" or "ACG" and "AGT" or "ACG" and "AG". The recurrence formula that computes
subproblems repeatedly is f(i, j) = f(i-1, j-1) + 1 if the last characters of the two sequences
match, otherwise f(i, j) = max(f(i-1, j), f(i, j-1)). The lookup table filling pattern is to fill the table
from the top-left corner to the bottom-right corner. The algorithm uses a 2D array dp to store
the length of the longest common subsequence between the prefixes of the two sequences.
It then constructs the longest common subsequence by backtracking from the bottom-right
corner of the table. The algorithm returns the longest common subsequence between the
two DNA sequences.

Page | 32
Pseudo Code:
Algorithm longestCommonSubsequence(s1, s2) {
// Input: Two DNA sequences s1 and s2.
// Output: The longest common subsequence between s1 and s2.

m ← s1.length n ← s2.length
dp[n+1][m+1] ← 0

for i ← 1 to n {
for j ← 1 to m {
if (s1[j-1] == s2[i-1]) {
dp[i][j] ← dp[i-1][j-1] + 1
} else {
dp[i][j] ← max(dp[i-1][j], dp[i][j-1])
}
}
}

s ← '', i ← n, j ← m
while (i > 0 and j > 0) {
if (s1[j-1] == s2[i-1]) {
s ← s1[j-1] + s
i ← i-1
j ← j-1
} else if (dp[i-1][j] > dp[i][j-1]) i ← i-1
else j ← j-1
}
return s // s.length holds the length
}

Page | 33
Example:
Given s1 = "CGTA" and s2 = "GCTA"

It will construct the following table:

“” C G T A

“” 0 0 0 0 0

G 0 0 1 1 1

C 0 1 1 1 1

T 0 1 1 2 2

A 0 1 1 2 3

Each cell in the table represents the length of the longest common subsequence between the
prefixes of the two sequences. The bottom-right cell contains the length of the longest
common subsequence between the two sequences, which is 3. The algorithm then backtracks
from the bottom-right corner to construct the longest common subsequence, which is "GTA".

Output: “GTA”

Time Complexity:
Time complexity is O(n*m) where n is the length of the first DNA sequence and m is the length
of the second DNA sequence.

Page | 34
Question #10 (Dynamic Programming):

In the development of a machine translation system from one language to another, the
software development team faces the challenge of efficiently looking up words or phrases
in the source language to find their corresponding translations in the target language. To
address this challenge, the team has decided to utilize Optimal Binary Search Trees (OBSTs)
due to their ability to ensure logarithmic time complexity for search operations. By storing
the vocabulary in an OBST, translation systems can quickly find translations, thus facilitating
smoother and faster translation processes. Your task is to design an algorithm for
constructing an OBST using Dynamic Programming based on the given probabilities of
successful and unsuccessful search operations. The probabilities of successful searches for
k1,k2,...,kn are represented by p1,p2,...,pn, respectively, while the probabilities of
unsuccessful searches in the subtrees are represented by q0,q1,...,qn, where q0 represents
the probability of searching in an empty subtree, and qi represents the probability of
searching in the subtree rooted at key ki. Your solution must include:
1. Optimal Substructure property
2. Overlapping Subproblems: Recurrence formula that compute subproblems
repeatedly.
3. Look up table filling pattern.
4. A description of the algorithm in pseudo code.
5. At least one worked example or diagram to show more precisely how your
algorithm works.
6. An analysis of the running time of the algorithm.

Sol.

The problem is to design an algorithm for constructing an Optimal Binary Search Tree (OBST)
using Dynamic Programming based on the given probabilities of successful and unsuccessful
search operations. The objective is to minimize the expected search cost of the binary search
tree. The optimal substructure property of this problem is that the optimal solution to the
problem can be constructed from the optimal solutions to its subproblems which are the
optimal binary search trees for the subtrees for a chosen root. The recurrence formula that
computes subproblems repeatedly is f(i, j) = min(f(i, k-1) + f(k+1, j) + sum(p[k] + q[k]) + q[0], i
≤ k ≤ j) fpr i = 1 to n. The lookup table filling pattern is to fill the table from the top-left corner
to the bottom-right corner in a diagonal manner. The algorithm uses two 2D arrays C and R
to store the cost and root of the optimal binary search tree. It then constructs the optimal

Page | 35
binary search tree by backtracking from the top-right corner of the table. The algorithm
returns the root array R of the optimal binary search tree.

Page | 36
Pseudo Code:
Algorithm optimalBinarySearchTree(p[0..n-1], q[0..n]) {
// Input: An array p[0..n-1] of probabilities of successful searches, and an array q[0..n]
of probabilities of unsuccessful searches.
// Output: The root array R of the optimal binary search tree.

C[n+1][n+1] ← 0
R[n+1][n+1] ← 0

for i ← 1 to n {
C[i-1][i] ← p[i-1] + q[i] + q[i+1]
R[i-1][i] ← i-1
}

for d ← 1 to n-1 {
for i ← 1 to n-d {
j ← i+d
min ← INFINITY
sum ← q[j]

for k ← i to j {
sum ← sum + p[k-1] + q[k-1]
}

for k ← i to j {
temp ← C[i-1][k-1] + C[k][j] + sum
if (temp < min) {
min ← temp
R[i-1][j] ← k-1
}

Page | 37
}

C[i-1][j] ← min
}
}

R ← R[1..n+1][1..n+1]

return R
}

Algorithm buildOBST(R, i, j, keys) {


// Input: The root array R of the optimal binary search tree, the start index i, the end
index j, and the keys array.
// Output: The optimal binary search tree.

if (i > j) return null

if (i == j) {
return keys[R[i][j]]
} else {
root ← R[i][j]

left ← buildOBST(R, i, root-1, keys)


right ← buildOBST(R, root+1, j, keys)

return [keys[root], left, right]


}
}

Page | 38
Time Complexity:
Time complexity of the algorithm is O(n^3) where n is the number of keys in the binary search
tree.

Page | 39
Question #11 (Dynamic Programming):

Consider a scenario where a team of graphic designers and software engineers is working
on developing a cuttingedge virtual reality (VR) application. The application aims to
simulate a dynamic and realistic 3D environment where users can interact with virtual
objects in real-time. To achieve this, the team needs to efficiently apply complex sequences
of transformations such as scaling, rotation, and translation to render objects accurately
and maintain smooth frame rates. These transformations are represented by matrices.
Instead of applying each transformation individually to every vertex of an object, the team
can precompute the composite transformation matrix. This composite matrix represents
the concatenation of all individual transformation matrices, allowing for a single efficient
transformation operation. For instance, the following diagram illustrates a composite 2D
transformation applied to a unit square which is typically described as a combination of
translation, rotation, and scaling.

Given a sequence of n transformation matrices, where each matrix represents a specific


transformation operation (e.g., scaling, rotation, translation). Design an algorithm using
dynamic programming that determines the optimal parenthesizing of these matrices to
minimize the total number of scalar multiplications required to compute the composite
transformation matrix.
Your solution must include:
1. Optimal Substructure property
2. Overlapping Subproblems: Recurrence formula that compute subproblems
repeatedly.
3. Look up table filling pattern.
4. A description of the algorithm in pseudo code.
5. At least one worked example or diagram to show more precisely how your
algorithm works.
6. An analysis of the running time of the algorithm.

Page | 40
Note: This optimization technique plays a vital role in reducing the computational cost of
applying transformations to graphical objects, ultimately enhancing rendering performance
and improving the overall quality of virtual environments in applications like VR
simulations.

Sol.

The problem is to design an algorithm using dynamic programming that determines the
optimal parenthesizing of a sequence of n transformation matrices to minimize the total
number of scalar multiplications required to compute the composite transformation matrix.
The algorithm uses a bottom-up approach to fill a 2D array dp where dp[i][j] represents the
minimum number of scalar multiplications required to compute the composite
transformation matrix of matrices i to j. The algorithm iterates through the matrices and
computes the minimum cost of parenthesizing the matrices at each step. The optimal
substructure property of this problem is that the optimal solution to the problem can be
constructed from the optimal solutions to its subproblems which are the optimal
parenthesizing of the matrices for a partition at a chosen index k. The recurrence formula that
computes subproblems repeatedly is f(i, j) = min(f(i, k) + f(k+1, j) + p[i-1] * p[k] * p[j]) for i ≤ k
< j. The lookup table filling pattern is to fill the table from the top-left corner to the bottom-
right corner in a diagonal manner. The algorithm returns the minimum number of scalar
multiplications required to compute the composite transformation matrix and the optimal
parenthesizing of the matrices.

Page | 41
Pseudo Code:
Algorithm matrixChainMultiplication(p[0..n - 1]) {
// Input: An array p[0..n - 1] of dimensions of n - 1 matrices. The dimensions of the ith
matrix are p[i-1] x p[i].
// Output: The minimum number of scalar multiplications required to compute the
composite transformation matrix. Also, return the optimal parenthesizing of the
matrices.

dp[n][n] ← 0
s[n][n] ← 0

for i ← n-1 to 1 {
for j ← i+1 to n {
min ← INFINITY

for k ← i to j-1 {
cost ← dp[i][k] + dp[k+1][j] + p[i-1] * p[k] * p[j]

if (cost < min) {


min ← cost
s[i][j] ← k
}
}

dp[i][j] ← min
}
}

return [dp[1][n-1], s]
}

Page | 42
Time Complexity:
The time complexity of the algorithm is O(n^3) where n is the number of matrices.

Page | 43
Question #12 (Dynamic Programming):

A telecommunications company operates a large-scale communication network, consisting


of routers, switches, and various interconnected nodes. The network facilitates the
transmission of a significant volume of data traffic, including voice, video, and internet data
packets, between different endpoints. The company has a limited capacity W representing
the available resources in the network. For transmission within the limited capacity, data
traffic is divided into set of n data packets, each associated with a value vi representing the
size of data packet and a weight wi, representing the utilization of bandwidth. Your task is
to design an algorithm using dynamic programming that determines the optimal
combination of data packets to route through the network. The objective is to maximize
the total value of the selected items while ensuring that the total weight (i.e., total
bandwidth usage) does not exceed the capacity W of the network resources. The objective
of the algorithm is to maximize the total value of the selected items (data packets) while
ensuring that the total weight (i.e., total bandwidth usage) does not exceed the capacity W
of the network resources.
1. Optimal Substructure property
2. Overlapping Subproblems: Recurrence formula that compute subproblems
repeatedly.
3. Look up table filling pattern.
4. A description of the algorithm in pseudo code.
5. At least one worked example or diagram to show more precisely how your
algorithm works.
6. An analysis of the running time of the algorithm.

Sol.

The problem is to design an algorithm using dynamic programming that determines the
optimal combination of data packets to route through the network. The objective is to
maximize the total value of the selected items while ensuring that the total weight (i.e., total
bandwidth usage) does not exceed the capacity W of the network resources.

The optimal substructure property of this problem is that the optimal solution to the problem
can be constructed from the optimal solutions to its subproblems which are the maximum
value that can be obtained by selecting a subset of data packets within lesser capacities. The

Page | 44
recurrence formula that computes subproblems repeatedly is f(i, j) = max(vi + f(i-1, j-wi), f(i-
1, j)) if the weight of the current item is less than or equal to the capacity, otherwise f(i, j) =
f(i-1, j). The lookup table filling pattern is to fill the table from the top-left corner to the
bottom-right corner. The algorithm uses a 2D array dp to store the maximum value that can
be obtained by selecting a subset of data packets within the capacity W. It then constructs
the selected items by backtracking from the bottom-right corner of the table. The algorithm
returns the maximum value that can be obtained by selecting a subset of data packets within
the capacity W and the selected items.

Page | 45
Pseudo Code:
Algorithm optimalDataPacketsCombination(W, wt[0..n-1], val[0..n-1]) {
// Input: The capacity W of the network, an array wt[0..n-1] of weights, and an array
val[0..n-1] of values.
// Output: The maximum value that can be obtained by selecting a subset of data
packets within the capacity W.

dp[n+1][W+1] ← 0

for i ← 1 to n {
for j ← 1 to W {
if (wt[i-1] ≤ j) {
dp[i][j] ← max(val[i-1] + dp[i-1][j-wt[i-1]], dp[i-1][j])
} else {
dp[i][j] ← dp[i-1][j]
}
}
}

selectedItems[n] ← 0, i ← n, j←W

while (i > 0 and j > 0) {


if (dp[i][j] != dp[i-1][j]) {
selectedItems[i-1] ← 1
j ← j-wt[i-1]
}

i ← i-1
}
return [dp[n][W], selectedItems]
}

Page | 46
Example:
Given W = 5 and data packets with weights [2, 3, 4] and values [1, 2, 5].
It will construct the following table:

0 1 2 3 4 5

0 0 0 0 0 0 0

1 0 0 1 1 1 1

2 0 0 1 2 2 3

3 0 0 1 2 5 5

The bottom-right cell contains the maximum value that can be obtained by selecting a subset
of data packets within the capacity W, which is 5. The algorithm then backtracks from the
bottom-right corner to construct the selected items, which are [0, 0, 1].

Time Complexity:
The time complexity of the algorithm is O(n*W) where n is the number of data packets and
W is the capacity of the network.

Page | 47
Question #13 (Greedy Approach):

Let S be a string and t be a positive integer. Some characters may be repeated in S. Design
an algorithm based on Greedy approach that rearrange the characters of the given string S
such that the same characters become t distance away from each other. Note that many
rearrangements can be possible, your algorithm should return one of the possible
rearrangements. Return -1 if no such arrangement is possible. What is the complexity of
your algorithm?
Example
Input: S = "aacbbc", t = 3
Output: "abcabc".

Sol.

The problem is to design an algorithm based on the Greedy approach that rearranges the
characters of the given string S such that the same characters become t distance away from
each other. The algorithm uses a max heap to store the characters based on their frequency
and rearranges the characters by selecting the character with the highest frequency at each
step. The algorithm constructs the rearranged string by extracting the character with the
highest frequency from the max heap and appending it to the result string. It then decrements
the frequency of the character and inserts it back into the max heap if its frequency is greater
than 0. The algorithm continues this process until the max heap is empty or the result string
is of length n. If the result string is not of length n, it means all the remaining elements are in
temp and its not possible to rearrange the string such that the same characters become t
distance away from each other, so it returns -1. Otherwise, it returns the rearranged string.

Page | 48
Pseudo Code:
Algorithm rearrangeCharacters(S, t) {
// Input: A string S and a positive integer t.
// Output: A rearranged string where the same characters are t distance away from
each other.

freq ← {} // A key-value map to store the frequency of characters in the string.

for i ← 0 to n-1 {
if (freq[S[i]]) {
freq[S[i]] ← freq[S[i]] + 1
} else {
freq[S[i]] ← 1
}
}

maxHeap ← MaxHeap() // A max heap to store the characters based on their


frequency.

for key in freq {


maxHeap.insert({key, freq: freq[key]})
}

result ← ''

while not maxHeap.isEmpty() {


temp ← []

for i ← 0 to t-1 {
if maxHeap.isEmpty() {
if result.length != n {

Page | 49
return -1
} else {
return result
}
}

item ← maxHeap.extractMax()
result ← result + item.key

item.freq--

if item.freq > 0 {
temp.push(item)
}
}

for i ← 0 to temp.length-1 {
maxHeap.insert(temp[i])
}
}

return result
}

Page | 50
Example:
Given S = "aacbbc" and t = 3

The algorithm constructs the following max heap based on the frequency of characters:

{ key: 'a', freq: 2 }


{ key: 'b', freq: 2 }
{ key: 'c', freq: 2 }

The algorithm then constructs the rearranged string by extracting the character with the
highest frequency at each step:

1. Extract 'a' from the max heap and append it to the result string: "a"
2. Extract 'b' from the max heap and append it to the result string: "ab"
3. Extract 'c' from the max heap and append it to the result string: "abc"

Then it puts the characters with frequency > 0 back to the max heap and repeats the process
until the max heap is empty. The final rearranged string is "abcabc".

Time Complexity:
Due to the use of a max heap to store the characters based on their frequency, the time
complexity of the algorithm is O(n log n) where n is the length of the input string S.

Page | 51
Question #14 (Greedy Approach):

Let P = {p1,p2,…..pn} be the set of n processes. Let S = {s1,s2,…..sn} and F = {f1,f2,…..fn}


be the start and finish times of given processes respectively. Design an algorithm using
Greedy approach that selects maximum number of processes that can be performed by a
single processor, assuming that the processor can only work on a single process at a time.

Sol.

The problem is to design an algorithm using the Greedy approach that selects the maximum
number of processes that can be performed by a single processor, assuming that the
processor can only work on a single process at a time. The algorithm sorts the processes based
on their finish times in increasing order and selects the processes that do not overlap with
each other. The algorithm constructs the selected processes by iterating through the sorted
processes and selecting the process if its start time is greater than or equal to the finish time
of the previously selected process. The algorithm returns the selected processes that can be
performed by a single processor.

Page | 52
Pseudo Code:
Algorithm maximumProcesses(P[0..n-1], S[0..n-1], F[0..n-1]) {
// Input: A set of n processes P, and the start and finish times of the processes S and
F.
// Output: The maximum number of processes that can be performed by a single
processor.

// sort P, S and F based on the finish times of the processes (stored in F) in increasing
order.
// Sort such that the respective start and finish times of the processes are maintained.

sort(P, S, F)

i←0

selectedProcesses ← [P[i]]

for j ← 1 to n-1 {
if F[j] ≥ F[i] {
selectedProcesses.push(P[j])

i←j
}
}

return selectedProcesses
}

Page | 53
Example:
Given P = {p1, p2, p3} and the start and finish times of the processes S = {1, 3, 0} and F = {4, 6,
2} respectively.

The algorithm sorts the processes based on their finish times in increasing order:
P = {p3, p1, p2}
S = {0, 1, 3}
F = {2, 4, 6}

The algorithm then selects the processes that do not overlap with each other:
1. Select process p3 with start time 0 and finish time 2.
2. Process p1 will be skipped as its start time 1 is less than the finish time 2 of the previously
selected process.
3. Select process p2 with start time 3 and finish time 6.

The maximum number of processes that can be performed by a single processor is 2, and the
selected processes are {p3, p2}.

Time Complexity:
The time complexity of the algorithm is O(n log n) where n is the number of processes. Due
to sorting.

Page | 54

You might also like