Sorting and Seaeching Array Tail Recursion
Sorting and Seaeching Array Tail Recursion
1
What are Data Structures?
Term Description
Data Structure A way of organizing data so it can be used efficiently. Common data
structures include arrays, linked lists, and binary trees.
Time Complexity A measure of the amount of time an algorithm takes to run, depending on
the amount of data the algorithm is working on.
Big O Notation A mathematical notation that describes the limiting behavior of a function
when the argument tends towards a particular value or infinity. Used in
this tutorial to describe the time complexity of an algorithm.
2
Divide and Conquer A method of solving complex problems by breaking them into smaller,
more manageable sub-problems, solving the sub-problems, and
combining the solutions. Recursion is often used when using this method
in an algorithm.
Brute Force A simple and straight forward way an algorithm can work by simply trying
all possible solutions and then choosing the best one.
Bubble Sort
n = len(my_array)
for i in range(n-1):
3
for j in range(n-i-1):
Selection Sort
How it works:
Example
n = len(my_array)
for i in range(n-1):
min_index = i
4
if my_array[j] < my_array[min_index]:
min_index = j
min_value = my_array.pop(min_index)
my_array.insert(i, min_value)
Insertion Sort
The Insertion Sort algorithm uses one part of the array to hold
the sorted values and the other part of the array to hold values
that are not sorted yet.
How it works:
Take the first value from the unsorted part of the array.
Move the value into the correct place in the sorted part of
the array.
Go through the unsorted part of the array again as many times
as there are values.
Example
n = len(my_array)
for i in range(1,n):
5
insert_index = i
current_value = my_array.pop(i)
insert_index = j
my_array.insert(insert_index, current_value)
Iteration 1 (i=1):
Iteration 2 (i=2):
Iteration 3 (i=3):
Iteration 4 (i=4):
Iteration 5 (i=5):
6
• Insert 11 at index 0.
• my_array = [11, 12, 22, 25, 34, 64, 90, 5]
Iteration 6 (i=6):
Iteration 7 (i=7):
• current_value = 5, insert_index = 7
• Compare all elements and update insert_index = 0.
• Insert 5 at index 0.
• my_array = [5, 11, 12, 22, 25, 34, 64, 90]
Merge Sort
How it works:
Divide the unsorted array into two sub-arrays, half the size
of the original.
Continue to divide the sub-arrays as long as the current piece
of the array has more than one element.
Merge two sub-arrays together by always putting the lowest
value first.
Keep merging until there are no sub-arrays left
7
Step 1: We start with an unsorted array, and we know that it splits
in half until the sub-arrays only consist of one element. The Merge
Sort function calls itself two times, once for each half of the
array. That means that the first sub-array will split into the
smallest pieces first.
[ 12, 8, 9, 3, 11, 5, 4]
[ 12, 8, 9] [ 3, 11, 5, 4]
[ 12] [ 8, 9] [ 3, 11, 5, 4]
[ 12] [ 8] [ 9] [ 3, 11, 5, 4]
[ 12] [ 8, 9] [ 3, 11, 5, 4]
[ 8, 9, 12] [ 3, 11, 5, 4]
[ 8, 9, 12] [ 3, 11, 5, 4]
[ 8, 9, 12] [ 3, 11] [ 5, 4]
[ 8, 9, 12] [ 3] [ 11] [ 5, 4]
[ 8, 9, 12] [ 3, 11] [ 5, 4]
[ 8, 9, 12] [ 3, 11] [ 5] [ 4]
[ 8, 9, 12] [ 3, 11] [ 4, 5]
1. 3 is lower than 4
2. 4 is lower than 11
8
3. 5 is lower than 11
4. 11 is the last remaining value
[ 8, 9, 12] [ 3, 4, 5, 11]
Step 8: The two last remaining sub-arrays are merged. Let's look
at how the comparisons are done in more detail to create the new
merged and finished sorted array:
3 is lower than 8:
9
Another function that merges the sub-arrays back together in
a sorted way.
Example
def mergeSort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
leftHalf = arr[:mid]
rightHalf = arr[mid:]
sortedLeft = mergeSort(leftHalf)
sortedRight = mergeSort(rightHalf)
result = []
i = j = 0
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
10
result.extend(right[j:])
return result
sortedArr = mergeSort(unsortedArr)
Step-by-Step Explanation
unsortedArr = [3, 7, 6, -10, 15, 23.5, 55, -13]
1. Splitting the Array
3. Continue Merging
4. Final Merge
11
[-13, -10, 3, 6, 7, 15, 23.5, 55]
Code Walkthrough
Input:
Execution:
Output:
Linear Search
How it works:
12
After the loop, return -1, because at this point we know the
target value has not been found.
Example
def linearSearch(arr, targetVal):
for i in range(len(arr)):
if arr[i] == targetVal:
return i
return -1
arr = [3, 7, 2, 9, 5]
targetVal = 9
if result != -1:
print("Value",targetVal,"found at index",result)
else:
print("Value",targetVal,"not found")
Code Explanation
1. Function Definition
def linearSearch(arr, targetVal):
13
• If the loop completes without finding the targetVal, the function returns -1, indicating that
the value is not present.
Example Walkthrough
Input
arr = [3, 7, 2, 9, 5]
targetVal = 9
Execution Steps
Output
Value 9 found at index 3
Edge Cases
arr = [3, 7, 2, 9, 5]
targetVal = 10
Output
Value 10 not found
Binary Search
How it works:
14
If the value is found, return the target value index. If the
target value is not found, return -1.
15
Example
left = 0
right = len(arr) - 1
if arr[mid] == targetVal:
return mid
left = mid + 1
else:
right = mid - 1
return -1
myTarget = 15
if result != -1:
else:
Code Explanation
1. Function Definition
def binarySearch(arr, targetVal):
left = 0
right = len(arr) - 1
16
• left and right pointers mark the bounds of the current search range.
2. While Loop
while left <= right:
• The loop continues as long as the search range is valid (left ≤ right).
3. Calculate Midpoint
mid = (left + right) // 2
Example Walkthrough
Input
myArray = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
myTarget = 15
Execution Steps
1. Initial State:
o left = 0, right = 9 (indices of the first and last elements).
o Calculate mid = (0 + 9) // 2 = 4. Check arr[4] = 9.
2. First Comparison:
o arr[4] < 15, so adjust the range: left = mid + 1 = 5.
3. Second Iteration:
o Calculate mid = (5 + 9) // 2 = 7. Check arr[7] = 15.
4. Match Found:
o arr[7] == 15. Return 7.
17
Output
Value 15 found at index 7
Recursion
Recusion is the process where a function calls itself until the
base condition or termination condition occurs.
Array 1D & 2D
Q. Explain the difference between a list and an array in Python. Which is more
flexible, and why?
List: Can store elements of mixed data types (e.g., [1, "two",
3.0]). It is flexible but slower for numerical operations.
Array: Requires all elements to be of the same data type
(using the array module or numpy), which is faster for
numerical computations.
More flexible: Lists, because of their ability to hold mixed
data types.
print(arr[-1]) # Output: 40
18
Q. How can you reverse a 1D array in Python? Mention two methods.
Method 1: Using slicing:
arr = [1, 2, 3, 4]
print(arr[::-1]) # Output: [4, 3, 2, 1]
Method 2: Using reverse():
arr.reverse()
Problem-Solving Questions:
Q. Write a Python program to find the second largest number in a given array.
def second_largest(arr):
unique_arr = list(set(arr)) # Remove duplicates
unique_arr.sort() # Sort in ascending order
return unique_arr[-2] if len(unique_arr) >= 2 else None
19
Q. Create a program to count the frequency of each element in a 1D array.
def count_frequency(arr):
return Counter(arr)
print(count_frequency([1, 2, 2, 3, 4, 4, 4]))
# Output: Counter({4: 3, 2: 2, 1: 1, 3: 1})
def rearrange(arr):
return [x for x in arr if x < 0] + [x for x in arr if x >= 0]
2D Array Questions
Theory Questions:
Q. Explain how a 2D array is represented in memory.
2D array representation:
A 2D array is represented as a list of lists in Python, where each
inner list represents a row. Example:
Accessing elements:
20
rows, cols = len(matrix), len(matrix[0])
transposed = [[0 for _ in range(rows)] for _ in range(cols)]
return transposed
Applications of 2D arrays:
Problem-Solving Questions:
Transpose of a 2D array:
def transpose(matrix):
return [list(row) for row in zip(*matrix)]
def max_sum_row(matrix):
max_sum = 0
max_row = -1
for i, row in enumerate(matrix):
row_sum = sum(row)
if row_sum > max_sum:
21
max_sum = row_sum
max_row = i + 1
return max_row, max_sum
Matrix multiplication:
def boundary_elements(matrix):
rows, cols = len(matrix), len(matrix[0])
if rows == 1: # Single row
return matrix[0]
if cols == 1: # Single column
22
return [row[0] for row in matrix]
top = matrix[0]
bottom = matrix[-1]
left = [matrix[i][0] for i in range(1, rows - 1)]
right = [matrix[i][-1] for i in range(1, rows - 1)]
return top + right + bottom[::-1] + left[::-1]
Solution:
def sum_of_diagonals(matrix):
n = len(matrix) # Assuming the matrix is square
primary_sum = sum(matrix[i][i] for i in range(n))
secondary_sum = sum(matrix[i][n - i - 1] for i in range(n))
return primary_sum, secondary_sum
# Example
matrix = [[1, 2, 3],
[4, 5, 6],
[7, 8, 9]]
1. Primary diagonal: Elements where the row index equals the column index (matrix[i][i]).
o For example: [1, 5, 9] from the given matrix.
2. Secondary diagonal: Elements where the row index and column index add up to n-1
(matrix[i][n - i - 1]).
o For example: [3, 5, 7] from the given matrix.
23
Tail Recursion
Tail recursion is a type of recursion where the recursive call is the last operation performed in
the function before it returns a value. In tail-recursive functions, there are no pending operations
left after the recursive call. This allows some programming languages (like Python's competitors
such as Scheme, or optimized languages like Java or C++) to optimize the recursive calls and
avoid consuming additional stack space for each recursive call.
Tail-Recursive Factorial
def factorial_tail_recursive(n, accumulator=1):
if n == 0:
return accumulator # Final result directly returned
return factorial_tail_recursive(n - 1, n * accumulator) # Recursive call
is the last operation
24
Advantages of Tail Recursion
1. Memory Efficiency: Reduces the function call stack, as some compilers/languages optimize tail
recursion to iterative form.
2. Prevents Stack Overflow: Tail-recursive functions can handle deep recursion without exhausting
stack space.
Example in Python
Although Python does not perform tail-call optimization, you can still write tail-recursive
functions:
# Test
print(fibonacci_tail_recursive(10)) # Output: 55
25