Merge Sort, Quicksort and Divide-n-Conquer Algorithms in Python
Data Structures and Algorithms in Python is a beginner-friendly introduction to common data structures (linked lists, stacks, queues, graphs) and algorithms (search, sorting, recursion, dynamic programming) in Python, designed to help you prepare for coding interviews and assessments.
This tutorial is an executable Jupyter notebook. Click the Run on Colab button at the top of this page to execute the code.
Jupyter Notebooks: This notebook is made of cells. Each cell can contain code written in Python or explanations in plain English. You can execute code cells and view the results instantly within the notebook. Jupyter is a powerful platform for experimentation and analysis. Don't be afraid to mess around with the code & break things - you'll learn a lot by encountering and fixing errors. You can use the "Kernel > Restart & Clear Output" menu option to clear all outputs and start again from the top.
Problem
In this notebook, we'll focus on solving the following problem:
QUESTION 1: You're working on a new feature on Jovian called "Top Notebooks of the Week". Write a function to sort a list of notebooks in decreasing order of likes. Keep in mind that up to millions of notebooks can be created every week, so your function needs to be as efficient as possible.
The problem of sorting a list of objects comes up over and over in computer science and software development, and it's important to understand common approaches for sorting, and the trade-offs they offer. Before we solve the above problem, we'll solve a simplified version of the problem:
QUESTION 2: Write a program to sort a list of numbers.
"Sorting" usually refers to "sorting in ascending order", unless specified otherwise.
The Method
Here's a systematic strategy we'll apply for solving problems:
- State the problem clearly. Identify the input & output formats.
- Come up with some example inputs & outputs. Try to cover all edge cases.
- Come up with a correct solution for the problem. State it in plain English.
- Implement the solution and test it using example inputs. Fix bugs, if any.
- Analyze the algorithm's complexity and identify inefficiencies, if any.
- Apply the right technique to overcome the inefficiency. Repeat steps 3 to 6.
1. State the problem clearly. Identify the input & output formats.
Problem
We need to write a function to sort a list of numbers in increasing order.
Input
nums: A list of numbers e.g.[4, 2, 6, 3, 4, 6, 2, 1]
Output
sorted_nums: The sorted version ofnumse.g.[1, 2, 2, 3, 4, 4, 6, 6]
The signature of our function would be as follows:
def sort(nums):
pass2. Come up with some example inputs & outputs.
Here are some scenarios we may want to test out:
- Some lists of numbers in random order.
- A list that's already sorted.
- A list that's sorted in descending order.
- A list containing repeating elements.
- An empty list.
- A list containing just one element.
- A list containing one element repeated many times.
- A really long list.
Let's create some test cases for these scenarios. We'll represent each test case as a dictionary for easier automated testing.
# List of numbers in random order
test0 = {
'input': {
'nums': [4, 2, 6, 3, 4, 6, 2, 1]
},
'output': [1, 2, 2, 3, 4, 4, 6, 6]
}# List of numbers in random order
test1 = {
'input': {
'nums': [5, 2, 6, 1, 23, 7, -12, 12, -243, 0]
},
'output': [-243, -12, 0, 1, 2, 5, 6, 7, 12, 23]
}# A list that's already sorted
test2 = {
'input': {
'nums': [3, 5, 6, 8, 9, 10, 99]
},
'output': [3, 5, 6, 8, 9, 10, 99]
}# A list that's sorted in descending order
test3 = {
'input': {
'nums': [99, 10, 9, 8, 6, 5, 3]
},
'output': [3, 5, 6, 8, 9, 10, 99]
}# A list containing repeating elements
test4 = {
'input': {
'nums': [5, -12, 2, 6, 1, 23, 7, 7, -12, 6, 12, 1, -243, 1, 0]
},
'output': [-243, -12, -12, 0, 1, 1, 1, 2, 5, 6, 6, 7, 7, 12, 23]
}# An empty list
test5 = {
'input': {
'nums': []
},
'output': []
}# A list containing just one element
test6 = {
'input': {
'nums': [23]
},
'output': [23]
}# A list containing one element repeated many times
test7 = {
'input': {
'nums': [42, 42, 42, 42, 42, 42, 42]
},
'output': [42, 42, 42, 42, 42, 42, 42]
}To create the final test case (a really long list), we can start with a sorted list created using range and shuffle it to create the input.
import random
in_list = list(range(10000))
out_list = list(range(10000))
random.shuffle(in_list)
test8 = {
'input': {
'nums': in_list
},
'output': out_list
}tests = [test0, test1, test2, test3, test4, test5, test6, test7, test8]3. Come up with a correct solution. State it in plain English.
It's easy to come up with a correct solution. Here's one:
- Iterate over the list of numbers, starting from the left
- Compare each number with the number that follows it
- If the number is greater than the one that follows it, swap the two elements
- Repeat steps 1 to 3 till the list is sorted.
We need to repeat steps 1 to 3 at most n-1 times to ensure that the array is sorted. Can you explain why? Hint: After one iteration, the largest number in the list is moved to the end of list.
This method is called bubble sort, as it causes smaller elements to bubble to the top and larger to sink to the bottom. Here's a visual representation of the process:

4. Implement the solution and test it using example inputs.
The implementation is straightforward. We'll create a copy of the list inside our function, to avoid changing it while sorting.
def bubble_sort(nums):
# Create a copy of the list, to avoid changing it
nums = list(nums)
# 4. Repeat the process n-1 times
for _ in range(len(nums) - 1):
# 1. Iterate over the array (except last element)
for i in range(len(nums) - 1):
# 2. Compare the number with
if nums[i] > nums[i+1]:
# 3. Swap the two elements
nums[i], nums[i+1] = nums[i+1], nums[i]
# Return the sorted list
return numsNotice how we're a tuple assignment to swap two elements in a single line of code.
x, y = 2, 3
x, y = y, x
x, y(3, 2)Let's test it with an example.
nums0, output0 = test0['input']['nums'], test0['output']
print('Input:', nums0)
print('Expected output:', output0)
result0 = bubble_sort(nums0)
print('Actual output:', result0)
print('Match:', result0 == output0)Input: [4, 2, 6, 3, 4, 6, 2, 1]
Expected output: [1, 2, 2, 3, 4, 4, 6, 6]
Actual output: [1, 2, 2, 3, 4, 4, 6, 6]
Match: True
result0 == output0TrueWe can evaluate all the cases together using the evaluate_test_cases helper function.
from timeit import default_timer as timer
from textwrap import dedent
import math
def _str_trunc(data, size=100):
data_str = str(data)
if len(data_str) > size + 3:
return data_str[:size] + '...'
return data_str
def _show_test_case(test_case):
inputs = test_case['input']
if 'outputs' in test_case:
expected_text = "Outputs"
expected = test_case.get('outputs')
else:
expected_text = "Output"
expected = test_case.get('output')
print(dedent("""
Input:
{}
Expected {}:
{}
""".format(_str_trunc(inputs), expected_text, _str_trunc(expected))))
def _show_result(result):
actual_output, passed, runtime = result
message = "\033[92mPASSED\033[0m" if passed else "\033[91mFAILED\033[0m"
print(dedent("""
Actual Output:
{}
Execution Time:
{} ms
Test Result:
{}
""".format(_str_trunc(actual_output), runtime, message)))
def evaluate_test_case(function, test_case, display=True):
"""Check if `function` works as expected for `test_case`"""
inputs = test_case['input']
if display:
_show_test_case(test_case)
start = timer()
actual_output = function(**inputs)
end = timer()
runtime = math.ceil((end - start)*1e6)/1000
if 'outputs' in test_case:
passed = actual_output in test_case.get('outputs')
else:
passed = actual_output == test_case.get('output')
result = actual_output, passed, runtime
if display:
_show_result(result)
return result
def evaluate_test_cases(function, test_cases, error_only=False, summary_only=False):
results = []
for i, test_case in enumerate(test_cases):
if not error_only:
print("\n\033[1mTEST CASE #{}\033[0m".format(i))
result = evaluate_test_case(function, test_case, display=False)
results.append(result)
if error_only and not result[1]:
print("\n\033[1mTEST CASE #{}\033[0m".format(i))
if not error_only or not result[1]:
_show_test_case(test_case)
_show_result(result)
total = len(results)
num_passed = sum([r[1] for r in results])
print("\n\033[1mSUMMARY\033[0m")
print("\nTOTAL: {}, \033[92mPASSED\033[0m: {}, \033[91mFAILED\033[0m: {}".format(
total, num_passed, total - num_passed))
return resultsresults = evaluate_test_cases(bubble_sort, tests)
TEST CASE #0
Input:
{'nums': [4, 2, 6, 3, 4, 6, 2, 1]}
Expected Output:
[1, 2, 2, 3, 4, 4, 6, 6]
Actual Output:
[1, 2, 2, 3, 4, 4, 6, 6]
Execution Time:
0.027 ms
Test Result:
PASSED
TEST CASE #1
Input:
{'nums': [5, 2, 6, 1, 23, 7, -12, 12, -243, 0]}
Expected Output:
[-243, -12, 0, 1, 2, 5, 6, 7, 12, 23]
Actual Output:
[-243, -12, 0, 1, 2, 5, 6, 7, 12, 23]
Execution Time:
0.028 ms
Test Result:
PASSED
TEST CASE #2
Input:
{'nums': [3, 5, 6, 8, 9, 10, 99]}
Expected Output:
[3, 5, 6, 8, 9, 10, 99]
Actual Output:
[3, 5, 6, 8, 9, 10, 99]
Execution Time:
0.014 ms
Test Result:
PASSED
TEST CASE #3
Input:
{'nums': [99, 10, 9, 8, 6, 5, 3]}
Expected Output:
[3, 5, 6, 8, 9, 10, 99]
Actual Output:
[3, 5, 6, 8, 9, 10, 99]
Execution Time:
0.018 ms
Test Result:
PASSED
TEST CASE #4
Input:
{'nums': [5, -12, 2, 6, 1, 23, 7, 7, -12, 6, 12, 1, -243, 1, 0]}
Expected Output:
[-243, -12, -12, 0, 1, 1, 1, 2, 5, 6, 6, 7, 7, 12, 23]
Actual Output:
[-243, -12, -12, 0, 1, 1, 1, 2, 5, 6, 6, 7, 7, 12, 23]
Execution Time:
0.053 ms
Test Result:
PASSED
TEST CASE #5
Input:
{'nums': []}
Expected Output:
[]
Actual Output:
[]
Execution Time:
0.003 ms
Test Result:
PASSED
TEST CASE #6
Input:
{'nums': [23]}
Expected Output:
[23]
Actual Output:
[23]
Execution Time:
0.003 ms
Test Result:
PASSED
TEST CASE #7
Input:
{'nums': [42, 42, 42, 42, 42, 42, 42]}
Expected Output:
[42, 42, 42, 42, 42, 42, 42]
Actual Output:
[42, 42, 42, 42, 42, 42, 42]
Execution Time:
0.012 ms
Test Result:
PASSED
TEST CASE #8
Input:
{'nums': [1039, 1678, 9704, 6265, 4727, 7996, 2005, 9984, 748, 4649, 7544, 5507, 9293, 6984, 7469, 3...
Expected Output:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 2...
Actual Output:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 2...
Execution Time:
17400.899 ms
Test Result:
PASSED
SUMMARY
TOTAL: 9, PASSED: 9, FAILED: 0
Great, looks like all our test cases passed! Although the last test case (a list of 10,000 numbers) took about 12 seconds to execute.
5. Analyze the algorithm's complexity and identify inefficiencies
The core operations in bubble sort are "compare" and "swap". To analyze the time complexity, we can simply count the total number of comparisons being made, since the total number of swaps will be less than or equal to the total number of comparisons (can you see why?).
for _ in range(len(nums) - 1):
for i in range(len(nums) - 1):
if nums[i] > nums[i+1]:
nums[i], nums[i+1] = nums[i+1], nums[i]
There are two loops, each of length n-1, where n is the number of elements in nums. So the total number of comparisons is $(n-1)*(n-1)$ i.e. $(n-1)^2$ i.e. $n^2 - 2n + 1$.
Expressing this in the Big O notation, we can conclude that the time complexity of bubble sort is $O(n^2)$ (also known as quadratic complexity).
Exercise: Verify that the bubble sort requires $O(1)$ additional space.
The space complexity of bubble sort is $O(n)$, even thought it requires only constant/zero additional space, because the space required to store the inputs is also considered while calculating space complexity.
As we saw from the last test, a list of 10,000 numbers takes about 12 seconds to be sorted using bubble sort. A list of ten times the size will 100 times longer i.e. about 20 minutes to be sorted, which is quite inefficient. A list of a million elements would take close to 2 days to be sorted.
The inefficiency in bubble sort comes from the fact that we're shifting elements by at most one position at a time.

Insertion Sort
Before we look at explore more efficient sorting techniques, here's another simple sorting technique called insertion sort, where we keep the initial portion of the array sorted and insert the remaining elements one by one at the right position.
def insertion_sort(nums):
nums = list(nums)
for i in range(len(nums)):
cur = nums.pop(i)
j = i-1
while j >=0 and nums[j] > cur:
j -= 1
nums.insert(j+1, cur)
return nums nums0, output0 = test0['input']['nums'], test0['output']
print('Input:', nums0)
print('Expected output:', output0)
result0 = insertion_sort(nums0)
print('Actual output:', result0)
print('Match:', result0 == output0)Input: [4, 2, 6, 3, 4, 6, 2, 1]
Expected output: [1, 2, 2, 3, 4, 4, 6, 6]
Actual output: [1, 2, 2, 3, 4, 4, 6, 6]
Match: True
Exercises:
- Read the source code of the
insertion_sortfunction and describe the algorithm in plain English. Reading source code is an essential skill for software development. - Determine the time and space complexity of insertion sort. Is it any better than bubble sort? Why or why not?
6. Apply the right technique to overcome the inefficiency. Repeat Steps 3 to 6.
To performing sorting more efficiently, we'll apply a strategy called Divide and Conquer, which has the following general steps:
- Divide the inputs into two roughly equal parts.
- Recursively solve the problem individually for each of the two parts.
- Combine the results to solve the problem for the original inputs.
- Include terminating conditions for small or indivisible inputs.
Here's a visual representation of the strategy:
Merge Sort
Following a visual representation of the divide and conquer applied for sorting numbers. This algorithm is known as merge sort:

7. Come up with a correct solution. State it in Plain English.
Here's a step-by-step description for merge sort:
- If the input list is empty or contains just one element, it is already sorted. Return it.
- If not, divide the list of numbers into two roughly equal parts.
- Sort each part recursively using the merge sort algorithm. You'll get back two sorted lists.
- Merge the two sorted lists to get a single sorted list
Can you guess how the "merge" operation step 4 works? Hint: Watch this animation: https://youtu.be/GW0USDwhBgo?t=28
QUESTION 3: Write a function to merge two sorted arrays.
Try to explain how the merge operation works in your own words below:
- ???
- ???
- ???
- ???
8. Implement the solution and test it using example inputs
Let's implement the merge sort algorithm assuming we already have a helper function called merge for merging two sorted arrays.
def merge_sort(nums):
# Terminating condition (list of 0 or 1 elements)
if len(nums) <= 1:
return nums
# Get the midpoint
mid = len(nums) // 2
# Split the list into two halves
left = nums[:mid]
right = nums[mid:]
# Solve the problem for each half recursively
left_sorted, right_sorted = merge_sort(left), merge_sort(right)
# Combine the results of the two halves
sorted_nums = merge(left_sorted, right_sorted)
return sorted_numsTwo merge two sorted arrays, we can repeatedly compare the two least elements of each array, and copy over the smaller one into a new array.
Here's a visual representation of the merge operation:

def merge(nums1, nums2):
# List to store the results
merged = []
# Indices for iteration
i, j = 0, 0
# Loop over the two lists
while i < len(nums1) and j < len(nums2):
# Include the smaller element in the result and move to next element
if nums1[i] <= nums2[j]:
merged.append(nums1[i])
i += 1
else:
merged.append(nums2[j])
j += 1
# Get the remaining parts
nums1_tail = nums1[i:]
nums2_tail = nums2[j:]
# Return the final merged array
return merged + nums1_tail + nums2_tailLet's test the merge operation, before we test merge sort.
merge([1, 4, 7, 9, 11], [-1, 0, 2, 3, 8, 12])It seems to work as expected. We can now test the merge_sort function.
nums0, output0 = test0['input']['nums'], test0['output']
print('Input:', nums0)
print('Expected output:', output0)
result0 = merge_sort(nums0)
print('Actual output:', result0)
print('Match:', result0 == output0)Let's test all the cases using the evaluate_test_cases function.
results = evaluate_test_cases(merge_sort, tests)All the test cases have passed! Our function works as expected.
Notice the last test case, the merge sort function took just 50 milliseconds to sort 10,000 numbers, which took bubble sort about 12 seconds.
9. Analyze the algorithm's complexity and identify inefficiencies
Analyzing the complexity of recursive algorithms can be tricky. It helps to track and follow the chain of recursive calls. We'll add some print statements to our merge_sort and merge_functions to display the tree of recursive function calls.
def merge(nums1, nums2, depth=0):
print(' '*depth, 'merge:', nums1, nums2)
i, j, merged = 0, 0, []
while i < len(nums1) and j < len(nums2):
if nums1[i] <= nums2[j]:
merged.append(nums1[i])
i += 1
else:
merged.append(nums2[j])
j += 1
return merged + nums1[i:] + nums2[j:]
def merge_sort(nums, depth=0):
print(' '*depth, 'merge_sort:', nums)
if len(nums) < 2:
return nums
mid = len(nums) // 2
return merge(merge_sort(nums[:mid], depth+1),
merge_sort(nums[mid:], depth+1),
depth+1)merge_sort([5, -12, 2, 6, 1, 23, 7, 7, -12])We can now see that each merge_sort call itself invokes merge_sort twice (but with an array half the size), and also invokes the merge function once to merge the two resulting arrays. The two calls to merge_sort themselves make two recursive calls followed by an invocation of merge. The division continues till we reach an list of size 1 or 0. Thus, the merge sort algorithm ultimately boils down to a series of merge operations performed on arrays of varying sizes. Inside the merge function we perform comparisons and add numbers to a new array.
Exercise: Verify that time complexity of the merge operation is $O(n)$, where $n$ is the sum of the sizes of the two input lists. Hint: Count the number of comparisons.
To find the overall complexity of merge_sort, we simply need to count how many times the merge function was invoked and the size of the input list for each invocation. Here's how all the subproblems can be visualized:

Counting from the top and starting from 0, the $k^{th}$ level of the above tree involves $2^k$ invocations of merge with sublists of size roughly $n / 2^k$, where $n$ is the size of the original input list. Therefore the total number of comparisons at each level of the tree is $2^k * n/2^k = n$.
Thus, if the height of the tree is $h$, the total number of comparisons is $n*h$. Since there are $n$ sublists of size 1 at the lowest level, it follows that $2^{(h-1)} = n$ i.e. $h = \log n + 1$. Thus the time complexity of the merge sort algorithms is $O(n \log n)$.
As we already saw, it took just 50 ms to sort an array of 10,000 elements. Even an array of 1 million elements will take only a few seconds to be sorted.
Space Complexity
To find the space complexity of merge sort, it helps to recall that a new list with equal to the sum of the sizes of the two lists is created in each invocation of merge.
i, j, merged = 0, 0, []
while i < len(nums1) and j < len(nums2):
if nums1[i] <= nums2[j]:
merged.append(nums1[i])
i += 1
else:
merged.append(nums2[j])
j += 1
At first glance, it may seem that $O(n)$ space is required for each level of the tree, so the space complexity of merge sort is $O(n \log n)$.

However, since the original sublists can be discarded after the merge operation, the additional space can be freed or reused for future merge calls. Thus, merge sort requires $O(n)$ additional space i.e. the space complexity is $O(n)$.
There are several extensions and variations and extensions of merge sort:
- K-way merge sort: https://en.wikipedia.org/wiki/K-way_merge_algorithm
- Counting inversions in an array: https://www.geeksforgeeks.org/counting-inversions/
- Merge sort and insertion sort hybrids: https://en.wikipedia.org/wiki/Timsort
10. Apply the right technique to overcome the inefficiency. Repeat Steps 3 to 6.
The fact that merge sort requires allocating additional space as large as the input itself makes it somewhat slow in practice because memory allocation is far more expensive than comparisons or swapping.
Quicksort
To overcome the space inefficiencies of merge sort, we'll study another divide-and-conquer based sorting algorithm called quicksort, which works as follows:
- If the list is empty or has just one element, return it. It's already sorted.
- Pick a random element from the list. This element is called a pivot.
- Reorder the list so that all elements with values less than or equal to the pivot come before the pivot, while all elements with values greater than the pivot come after it. This operation is called partitioning.
- The pivot element divides the array into two parts which can be sorted independently by making a recursive call to quicksort.

The key observation here is that after the partition, the pivot element is at its right place in the sorted array, and the two parts of the array can be sorted independently in-place.
Here's an implementation of quicksort, assuming we already have a helper function called partitions which picks a pivot, partitions the array in-place, and returns the position of the pivot element.
def quicksort(nums, start=0, end=None):
# print('quicksort', nums, start, end)
if end is None:
nums = list(nums)
end = len(nums) - 1
if start < end:
pivot = partition(nums, start, end)
quicksort(nums, start, pivot-1)
quicksort(nums, pivot+1, end)
return numsHere's how the partition operation works(source):
Here's an implementation of partition, which uses the last element of the list as a pivot:
def partition(nums, start=0, end=None):
# print('partition', nums, start, end)
if end is None:
end = len(nums) - 1
# Initialize right and left pointers
l, r = start, end-1
# Iterate while they're apart
while r > l:
# print(' ', nums, l, r)
# Increment left pointer if number is less or equal to pivot
if nums[l] <= nums[end]:
l += 1
# Decrement right pointer if number is greater than pivot
elif nums[r] > nums[end]:
r -= 1
# Two out-of-place elements found, swap them
else:
nums[l], nums[r] = nums[r], nums[l]
# print(' ', nums, l, r)
# Place the pivot between the two parts
if nums[l] > nums[end]:
nums[l], nums[end] = nums[end], nums[l]
return l
else:
return endLet's see the partition function in action:
l1 = [1, 5, 6, 2, 0, 11, 3]
pivot = partition(l1)
print(l1, pivot)As expected the list was partitioned using 3 as the pivot element, which finally ends up at the index 2 within the partitioned list.
Exercise: Add print statements inside the partition function to display the list, the left pointer and the right pointer at the beginning and end of every loop, to study how the partitioning works.
We can now see quicksort in action:
nums0, output0 = test0['input']['nums'], test0['output']
print('Input:', nums0)
print('Expected output:', output0)
result0 = quicksort(nums0)
print('Actual output:', result0)
print('Match:', result0 == output0)Let's test all the cases using the evaluate_test_cases function.
results = evaluate_test_cases(quicksort, tests)All the test cases have passed successfully! You will also notice that is also marginally faster than merge sort for larger lists.
Time Complexity of Quicksort
If we assume that
Best case partitioning:
If we partition the list into two nearly equal parts, then the complexity analysis is similar to that of merge sort and quicksort has the complexity $O(n \log n)$. This is called the average-case complexity.
Worst case partitioning:
In this case, the partition is called n times with lists of sizes n, n-1... so that total comparisions are $n + (n-1) + (n-2) + ... + 2 + 1 = n * (n-1) / 2$. So the worst-case complexity of quicksort is $O(n^2)$.
Exercise: Verify that quicksort requires $O(1)$ additional space.
Despite the quadratic worst case time complexity Quicksort is preferred in many situations because its running time is closer to $O(n \log n)$ in practice, especially with a good strategy for picking a pivot. Some these are:
Here are some other problems you can solve using the partitioning strategy of Quicksort: https://www.techiedelight.com/problems-solved-using-partitioning-logic-quicksort/
Custom Comparison Functions
Let's return to our original problem statement now.
QUESTION 1: You're working on a new feature on Jovian called "Top Notebooks of the Week". Write a function to sort a list of notebooks in decreasing order of likes. Keep in mind that up to millions of notebooks can be created every week, so your function needs to be as efficient as possible.
First, we need to sort objects, not just numbers. Also, we want to sort them in the descending order of likes. To achieve this, all we need is a custom comparison function to compare two notebooks.
Let's create a class that captures basic information about notebooks.
class Notebook:
def __init__(self, title, username, likes):
self.title, self.username, self.likes = title, username, likes
def __repr__(self):
return 'Notebook <"{}/{}", {} likes>'.format(self.username, self.title, self.likes)Let's create some sample notebooks
nb0 = Notebook('pytorch-basics', 'aakashns', 373)
nb1 = Notebook('linear-regression', 'siddhant', 532)
nb2 = Notebook('logistic-regression', 'vikas', 31)
nb3 = Notebook('feedforward-nn', 'sonaksh', 94)
nb4 = Notebook('cifar10-cnn', 'biraj', 2)
nb5 = Notebook('cifar10-resnet', 'tanya', 29)
nb6 = Notebook('anime-gans', 'hemanth', 80)
nb7 = Notebook('python-fundamentals', 'vishal', 136)
nb8 = Notebook('python-functions', 'aakashns', 74)
nb9 = Notebook('python-numpy', 'siddhant', 92)notebooks = [nb0, nb1, nb2, nb3, nb4, nb5,nb6, nb7, nb8, nb9]notebooksNext, we'll define a custom comparison function for comparing two notebooks. It will return the strings 'lesser', 'equal' or 'greater' to establish order between the two objects.
def compare_likes(nb1, nb2):
if nb1.likes > nb2.likes:
return 'lesser'
elif nb1.likes == nb2.likes:
return 'equal'
elif nb1.likes < nb2.likes:
return 'greater'Note that we say nb1 is lesser than nb2 if it has higher likes, because we want to sort the notebooks in decreasing order of likes.
Here's an implementation of merge sort which accepts a custom comparison function.
def default_compare(x, y):
if x < y:
return 'less'
elif x == y:
return 'equal'
else:
return 'greater'
def merge_sort(objs, compare=default_compare):
if len(objs) < 2:
return objs
mid = len(objs) // 2
return merge(merge_sort(objs[:mid], compare),
merge_sort(objs[mid:], compare),
compare)
def merge(left, right, compare):
i, j, merged = 0, 0, []
while i < len(left) and j < len(right):
result = compare(left[i], right[j])
if result == 'lesser' or result == 'equal':
merged.append(left[i])
i += 1
else:
merged.append(right[j])
j += 1
return merged + left[i:] + right[j:]sorted_notebooks = merge_sort(notebooks, compare_likes)sorted_notebooksAs you can see, the notebooks are now sorted by likes. Since we have written a generic merge_sort function that works with any compare function, we can also use it to sort the notebooks by title.
def compare_titles(nb1, nb2):
if nb1.title < nb2.title:
return 'lesser'
elif nb1.title == nb2.title:
return 'equal'
elif nb1.title > nb2.title:
return 'greater'merge_sort(notebooks, compare_titles)Exercise: Implement and test generic versions of bubble sort, insertion sort and quicksort using the empty cells below.
Summary and Exercises
We've covered the following sorting algorithms in this tutorial:
- Bubble sort
- Insertion sort
- Merge sort
- Quick sort
There are several other sorting algorithms, and most languages provide library functions for sorting that use a hybrid approach depending on the size and type of element in the list/array.
Try out some problems on sorting and divide-n-conquer here:
- https://leetcode.com/tag/sort/
- https://www.techiedelight.com/sorting-interview-questions/
- HackerRank
- https://leetcode.com/tag/divide-and-conquer/
- https://www.geeksforgeeks.org/divide-and-conquer/
Use this problem solving template: https://github.com/JovianHQ/notebooks/blob/main/data-structures-and-algorithms-in-python/python-problem-solving-template.ipynb