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.
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 and the inversion count is 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 get the overall inversion count.
We can implement the above idea using two nested loops.
countInversions(X, n)
inversionCount = 0
for i from 0 to n - 2
smallerCount = 0
for j from i + 1 to n - 1
if X[j] < X[i]
smallerCount = smallerCount + 1
inversionCount = inversionCount + smallerCount
return inversionCountThe 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.
countInversions(X, n)
inversionCount = 0
for i from 0 to n - 2
for j from i + 1 to n - 1
if X[i] > X[j]
inversionCount = inversionCount + 1
return inversionCountWe are using two nested loops and performing an O(1) operation at each iteration. 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 extra 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.
countCrossingInversions(X, left, mid, right)
inversionCount = 0
for i from left to mid
for j from mid + 1 to right
if X[i] > X[j]
inversionCount = inversionCount + 1
return inversionCount
findInversionCount(X, left, right)
if left >= right
return 0
mid = left + (right - left) / 2
leftCount = findInversionCount(X, left, mid)
rightCount = findInversionCount(X, mid + 1, right)
crossingCount = countCrossingInversions(X, left, mid, right)
return leftCount + rightCount + crossingCount
countInversions(X, n)
if n == 0
return 0
return findInversionCount(X, 0, n - 1)We are solving 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 critical 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?
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!
If we observe, this idea aligns closely with the merge sort, with a slight modification:
mergeSort(X, left, right)
leftInversions = 0
rightInversions = 0
crossingInversions = 0
if left < right
mid = left + (right - left) / 2
leftInversions = mergeSort(X, left, mid)
rightInversions = mergeSort(X, mid + 1, right)
crossingInversions = merge(X, left, mid, right)
return leftInversions + rightInversions + crossingInversionsDuring the merge process, we can find the inversion count using two-pointers approach.
Suppose the size of the left and right halves are n1 and n2. 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, to traverse the first and second sorted halves. We traverse sorted halves simultaneously using a loop condition: while (i < n1 && j < n2).
merge(X, l, mid, r)
n1 = mid - l + 1
n2 = r - mid
inversionCount = 0
A = copy X[l ... mid]
B = copy X[mid + 1 ... r]
i = 0, j = 0, k = l
while i < n1 and j < n2
if A[i] > B[j]
X[k] = B[j]
j = j + 1
// A[i ... n1 - 1] are all greater than B[j - 1]
inversionCount = inversionCount + (n1 - i)
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 inversionCountThe 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!