Difficulty: Medium, Asked In Microsoft, Amazon, Adobe
Key takeaway: An excellent problem to learn problem-solving using the divide and conquer idea similar to the merge sort algorithm.
Given an array of n integers X[], write a program to find the total number of inversion counts in X[]. An inversion occurs when there are two elements in the array such that i < j and X[i] > X[j]. In this situation, the pair (i, j) is called an inversion of X[] and the inversion count represents the count of such inversions present in the array.
Input: X[] = [5, 2, 6, 3] Output: 3
Explanation: There are 3 inversions: (5, 2), (5, 3), and (6, 3).
Input: X[] = [3, 2, 1] Output: 3
Explanation: There are 3 inversions: (3, 2), (3, 1), and (2, 1).
Input: X[] = [1, 2, 3] Output: 0
Explanation: The array is already sorted in increasing order, so there are no inversions.
One basic idea is to traverse the array and find the inversion count for every element. To do this, we count the number of smaller elements on its right side. During this process, we also keep adding the inversion count for each element to obtain the overall inversion count.
We can implement the above idea using two nested loops.
int countInversions(int X[], int n)
{
int invCount = 0;
for (int i = 0; i < n - 1; i = i + 1)
{
int smallerCount = 0;
for (int j = i + 1; j < n; j = j + 1)
{
if (X[j] < X[i])
smallerCount = smallerCount + 1;
}
invCount = invCount + smallerCount;
}
return invCount;
}
The idea is to compare each pair of elements (X[i] and X[j] where i < j). If the element at index i is greater than the element at index j, it indicates an inversion. So, we increase the inversion count by one.
int countInversions(int X[], int n)
{
int invCount = 0;
for (int i = 0; i < n - 1; i = i + 1)
{
for (int j = i + 1; j < n; j = j + 1)
{
if (X[i] > X[j])
invCount = invCount + 1;
}
}
return invCount;
}
We are using two nested loops and performing an O(1) operation at each iteration. The total number of loop iterations = (n - 1) + (n - 2) + … + 2 + 1 = n(n - 1)/2. So time complexity = O(n²). As we are using constant extra space, the space complexity = O(1).
In this approach, we divide the unsorted array into two equal halves and recursively find the inversion count of the first half (invCountLeft) and the inversion count of the second half (invCountRight). The total inversion count up to this point is equal to invCountLeft + invCountRight.
To calculate the overall inversion count, we need to consider an additional scenario: the type of inversion where one element is in the left part and another element is in the right part (invCountCrossing). To find this, we traverse the left half and, for each element x in the left half, we count all elements y in the right half that are less than x. This operation takes O(n²) time because both arrays have a size of n/2.
Total inversion count = invCountLeft + invCountRight + invCountCrossing.
int countInversions(int X[], int left, int mid, int right)
{
int invCount = 0;
for (int i = left; i <= mid; i = i + 1)
{
for (int j = mid + 1; j <= right; j = j + 1)
{
if (X[i] > X[j])
invCount = invCount + 1;
}
}
return invCount;
}
int findInversionCount(int X[], int left, int right)
{
int mid;
int invCountLeft = 0;
int invCountRight = 0;
int invCountCrossing = 0;
if (right > left)
{
mid = left + (right - left)/ 2;
// Find inversion counts in the two halves recursively
invCountLeft = findInversionCount(X, left, mid);
invCountRight = findInversionCount(X, mid + 1, right);
// Count inversions across the two halves
invCountCrossing = countInversions(X, left, mid, right);
}
return (invCountLeft + invCountRight + invCountCrossing);
}
We solve the problem of size n by recursively solving the two sub-problems of size n/2 and performing extra O(n²) operations to find invCountCrossing. So the recurrence relation for time complexity is T(n) = 2T(n/2) + O(n^2), which is a divide-and-conquer recurrence. We can apply the master theorem to solve it. Note: If you want to learn about recursion analysis, explore this blog: Analysis of recursion.
Master theorem
If T(n) = aT(n/b) + O(n^k), where a ≥ 1 and b > 1, there are three cases of the master theorem:
Comparing the given recurrence T(n) = 2T(n/2) + O(n^2) with the master theorem recurrence:
Here, a = 2, b = 2, and k = 2 => logb(a) = log2(2) = 1. Since k > logb(a), it satisfies the third case of the master theorem. So the time complexity = O(n^k) = O(n^2). There is no improvement in time complexity compared to the previous approach!
The space complexity depends on the size of the recursion call stack, which is equal to the maximum depth of the recursion tree. Since the input parameters decrease by a factor of 2, the maximum depth of the recursion tree is O(logn). So, the space complexity is O(logn).
This is an interesting approach that leverages the idea of merge sort to optimize the previous divide and conquer approach. The key question is: What is the thought process behind this idea? Let's think!
The previous divide and conquer approach does not work well because its time complexity is O(n²). This is primarily due to the time-consuming process of counting inversions across the two halves. Can we think of a way to optimize this approach? Let's think!
Consider the following scenario: After finding the inversion count of both the left and right halves, suppose both halves also get sorted in increasing order. Now, sorting will help us to easily count the inversions across the two halves in O(n) time. The idea is simple: since both halves are sorted, we can use a two-pointer merging process similar to merge sort!
Upon further observation, we can realize that this idea aligns closely with the merge sort algorithm, with a slight modification:
Here is an efficient approach that utilizes an idea similar to merge sort:
int mergeSort(int X[], int l, int r)
{
int invCountLeft = 0;
int invCountRight = 0;
int invCountCrossing = 0;
if (l < r)
{
int mid = l + (r - l) / 2;
invCountLeft = mergeSort(X, l, mid);
invCountRight = mergeSort(X, mid + 1, r);
invCountCrossing = merge(X, l, mid, r);
}
return (invCountLeft + invCountRight + invCountCrossing);
}
During the merge process of merge sort, we can determine the inversion count by using a two-pointer approach to compare elements from both sorted halves. How? Let's think!
Suppose the size of the left and right halves are n1 and n2, respectively. Here n1 = mid - l + 1 and n2 = r - mid. Now, following the merging process of merge sort, we create two auxiliary memory spaces, A[n1] and B[n2], to store copies of both sorted halves.
Now, we initialize two pointers, i and j, where i is used to traverse the first sorted half and j is used to traverse the second sorted half. We traverse both sorted halves simultaneously using a loop condition: while (i < n1 && j < n2).
int merge(int X[], int l, int mid, int r)
{
int n1 = mid - l + 1;
int n2 = r - mid;
int invCount = 0;
int A[n1];
int B[n2];
for (int i = 0; i < n1; i = i + 1)
A[i] = X[l + i];
for (int j = 0; j < n2; j = j + 1)
B[j] = X[mid + 1 + j];
int i = 0, j = 0, k = l;
while (i < n1 && j < n2)
{
if (A[i] > B[j])
{
X[k] = B[j];
j = j + 1;
invCount = invCount + (n1 - i); // Update inversion count
}
else
{
X[k] = A[i];
i = i + 1;
}
k = k + 1;
}
while (i < n1)
{
X[k] = A[i];
i = i + 1;
k = k + 1;
}
while (j < n2)
{
X[k] = B[j];
j = j + 1;
k = k + 1;
}
return invCount;
}
The time and space complexity of this approach is the same as the time complexity of the merge sort algorithm. Time complexity = O(nlogn), space complexity = O(n).
If you have any queries or feedback, please write us at contact@enjoyalgorithms.com. Enjoy learning, Enjoy algorithms!