Lesson 7

Welcome to this insightful session, where we aim to master the complexities of the illustrious applications of **sorting algorithms**. Today's voyage links two problems: **"Find the K-th Ordinal Statistic in a List"** and **"Count the Number of Inversions in a List"**. These problems mirror practical scenarios, and the efficient techniques used to solve them present valuable demonstrations of the application of sort algorithms. Solving these two problems, we'll see how Quick Sort and Merge Sort knowledge applies here and helps provide efficient implementations for both questions.

Let's dive into these captivating problems!

Our first problem presents a list of integers and the number `k`

. The challenge is finding the `k`

-th smallest element in that given list. To further elucidate, `k`

starts from `1`

, so for `k = 1`

, you are seeking to find the smallest element; if `k = 2`

, you're searching for the second smallest element, and so on. By the conclusion of this lesson, you'll be highly skilled at performing this task!

A primary instinctive solution could involve iteratively identifying and discarding the smallest element from the list until you reach the `k`

-th smallest element. While it sounds straightforward, this approach, unfortunately, incurs high time complexity due to the repetitive scans of the list to locate the smallest element. This solution has a $O(n^2)$ complexity.

Another straightforward solution is just to sort the input array and return the `k`

-th element:

Java`1Arrays.sort(inputArray); 2return inputArray[k - 1];`

This approach has $O(n \log n)$ complexity and is as simple as two lines of code. However, there are more efficient approaches than this, as there is an $O(n)$ solution to this problem, using Quick Sort techniques we covered earlier in this course.

Sorting steps in here to offer an efficient solution! The Quick Sort algorithm, a splendid application of divide and conquer, can solve this problem more efficiently. By selecting the right pivot for partitioning, the input list is divided into two: a left partition, which contains elements less than the pivot, and a right partition, which includes elements greater than the pivot.

If the pivot's position after elements repartition is the same as `k`

, we have the `k`

-th smallest element. If `k`

is less than the pivot's position, the task is carried forward on the left partition; otherwise, on the right partition.

Our Java solution mirrors this efficient approach as follows. Firstly, we define the partition procedure, the same way we do it in the quick sort:

Java`1static int partition(int[] arr, int start, int end) { 2 int pivot = arr[start]; 3 int i = start; 4 5 for(int j = start + 1; j <= end; j++) { 6 if(arr[j] <= pivot) { 7 i++; 8 int temp = arr[i]; 9 arr[i] = arr[j]; 10 arr[j] = temp; 11 } 12 } 13 14 int temp = arr[i]; 15 arr[i] = arr[start]; 16 arr[start] = temp; 17 18 return i; 19}`

Then, we define the `findKthSmallest`

algorithm, essentially working as a quicksort, but chasing a different goal:

Java`1import java.util.*; 2 3class Solution { 4 public static int findKthSmallest(int[] numbers, int k) { 5 if(numbers == null || numbers.length < k) 6 return Integer.MIN_VALUE; 7 return kthSmallest(numbers, 0, numbers.length - 1, k); 8 } 9 10 static int kthSmallest(int[] arr, int start, int end, int k) { 11 if (k > 0 && k <= end - start + 1) { 12 int pos = partition(arr, start, end); 13 14 if (pos - start == k - 1) { 15 return arr[pos]; 16 } 17 18 if (pos - start > k - 1) { 19 return kthSmallest(arr, start, pos - 1, k); 20 } 21 22 return kthSmallest(arr, pos + 1, end, k - pos + start - 1); 23 } 24 25 return Integer.MAX_VALUE; 26 } 27 28 public static void main(String[] args) { 29 int[] numbers = {1, 7, 2, 4, 2, 1, 6}; 30 System.out.println(findKthSmallest(numbers, 5)); // It should print 4 31 } 32}`

After we form the partitions, we compare the pivot's position to `k`

. If the positions match, our pivot is the `k`

-th smallest element. If our `k`

is smaller, we look at the left partition; otherwise, the right one.

Our second problem entails a list of integers; your task is to deduce the number of inversions in the list.

An inversion is a pair of elements where the larger element appears before the smaller one. In other words, if we have two indices `i`

and `j`

, where `i < j`

and the element at position `i`

is greater than the element at position `j`

(`numbers[i] > numbers[j]`

), we have an inversion.

For example, for `numbers = {4, 2, 1, 3}`

, there are four inversions: `(4, 2)`

, `(4, 1)`

, `(4, 3)`

, and `(2, 1)`

.

In our quest for efficiency, the Merge Sort algorithm comes into play. At its core, Merge Sort is a divide-and-conquer-based sorting algorithm, providing an optimal efficiency of $O(n \log n)$. However, we can cleverly modify this algorithm to count the number of inversions in the array while sorting it. This additional functionality doesn't impact the time complexity; therefore, it remains $O(n \log n)$. So, how does this work?

The process starts by dividing the array into two halves, similar to how Merge Sort operates. Then, we recursively sort both array halves and merge them back. Here comes the twist in the tale: while merging these sorted halves, we add additional counting logic to keep track of inversions.

As the halves are already sorted, if an element of the right half is smaller than that of the left half, it's inverted. This is because the element from the right half should have been after 'all' the remaining elements of the left half in a sorted array. Thus, we don't just have a single inversion here, we have as many inversions as there are remaining elements in the left half.

By counting these inversions at each merge and adding them up, we get the total number of inversions in the array.

Here is the Java solution based on the Merge Sort algorithm. First, let's define a supportive class to store our counter for each subarray:

Java`1public class Result { 2 public int[] sorted; 3 public long inversions; 4 5 public Result(int[] sorted, long inversions) { 6 this.sorted = sorted; 7 this.inversions = inversions; 8 } 9}`

Then, implement the merge sort that keeps track of the amount of inversions:

Java`1public Result countInversions(int[] arr) { 2 if (arr.length <= 1) { 3 return new Result(arr, 0); 4 } 5 int middle = arr.length / 2; 6 Result left = countInversions(Arrays.copyOfRange(arr, 0, middle)); 7 Result right = countInversions(Arrays.copyOfRange(arr, middle, arr.length)); 8 Result result = mergeAndCountInversions(left.sorted, right.sorted); 9 return new Result(result.sorted, left.inversions + right.inversions + result.inversions); 10} 11 12private Result mergeAndCountInversions(int[] left, int[] right) { 13 int[] merged = new int[left.length + right.length]; 14 int i = 0, j = 0; 15 long inversions = 0; 16 for(int k = 0; k < merged.length; k++) { 17 if (i < left.length && (j >= right.length || left[i] <= right[j])) { 18 merged[k] = left[i++]; 19 } else { 20 merged[k] = right[j++]; 21 inversions += left.length - i; 22 } 23 } 24 return new Result(merged, inversions); 25}`

While performing the merge in Merge Sort, we have added an additional counter to track inversions. If we come across an element in the right array that is smaller than an element in the left, we increment the counter by the number of items remaining in the left array. These all form inversions as per our problem's definition.

During today's lesson, we thoroughly inspected the advanced applications of Quick Sort and Merge Sort algorithms through the dissection of two exciting problems. We went from recognizing the issues, proposing naive methods, progressing towards efficient approaches, and executing the Java solutions.

You're now ready to flex those coding muscles! We have covered a substantial amount of theory and strongly advocate that real-world application solidifies the lessons learned. Be prepared for the forthcoming exercises, where you'll apply the logic imbibed in this session to similar problems. Let's get hands-on!