786. K-th Smallest Prime Fraction
Problem Description
You have a sorted array arr
that contains the number 1 and prime numbers. All numbers in the array are unique. You also have an integer k
.
The task is to consider all possible fractions formed by dividing elements in the array. Specifically, for every pair of indices i
and j
where i < j
, you form the fraction arr[i] / arr[j]
.
Once you have all these fractions, you need to find the k
-th smallest fraction among them. The answer should be returned as an array of two integers [arr[i], arr[j]]
, representing the numerator and denominator of that k
-th smallest fraction.
For example, if arr = [1, 2, 3, 5]
, the possible fractions would be:
1/2
,1/3
,1/5
(fractions with numerator 1)2/3
,2/5
(fractions with numerator 2)3/5
(fraction with numerator 3)
These fractions in ascending order would be: 1/5
, 1/3
, 2/5
, 1/2
, 3/5
, 2/3
. So if k = 3
, the answer would be [2, 5]
since 2/5
is the 3rd smallest fraction.
The solution uses a min-heap approach where it starts with the smallest possible fractions (those with numerator arr[0] = 1
) and incrementally explores larger fractions by advancing the numerator index while keeping the denominator fixed, maintaining the k
smallest fractions seen so far.
Intuition
The key insight is that since the array is sorted, for any fixed denominator arr[j]
, the fractions arr[0]/arr[j], arr[1]/arr[j], ..., arr[j-1]/arr[j]
are in increasing order. This property allows us to avoid generating and sorting all possible fractions.
Think of it this way: if we were to list all fractions with denominator arr[j]
, they would increase as we move the numerator from left to right in the array. Similarly, for a fixed numerator arr[i]
, the fractions decrease as we move the denominator from left to right (since we're dividing by larger numbers).
This leads us to a pattern similar to merging k
sorted lists. We can start with the smallest possible fractions - those with the smallest numerator (arr[0] = 1
) paired with each denominator. These are 1/arr[1], 1/arr[2], ..., 1/arr[n-1]
, which we know are sorted in decreasing order.
We use a min-heap to always extract the smallest fraction among our candidates. When we pop a fraction arr[i]/arr[j]
from the heap, we know the next larger fraction with the same denominator arr[j]
would be arr[i+1]/arr[j]
(if it exists). By adding this next fraction to the heap, we ensure we're always considering the smallest unprocessed fractions.
This approach is efficient because:
- We don't generate all
O(n²)
fractions upfront - We only generate fractions as needed, maintaining at most
O(n)
fractions in the heap at any time - We leverage the sorted property of the array to systematically explore fractions in increasing order
The process continues until we've popped k-1
fractions from the heap. The fraction at the top of the heap after k-1
pops is our answer - the k
-th smallest fraction.
Learn more about Two Pointers, Binary Search, Sorting and Heap (Priority Queue) patterns.
Solution Approach
The implementation uses a min-heap data structure to efficiently find the k-th smallest fraction without generating all possible fractions.
Step 1: Initialize the heap
h = [(1 / y, 0, j + 1) for j, y in enumerate(arr[1:])]
We start by creating initial heap entries. For each element arr[j]
where j > 0
, we create a tuple containing:
- The fraction value:
1/arr[j]
(sincearr[0] = 1
) - The numerator index:
0
(pointing toarr[0]
) - The denominator index:
j + 1
(adjusted because we enumerate fromarr[1:]
)
This gives us fractions 1/arr[1], 1/arr[2], ..., 1/arr[n-1]
- all fractions with the smallest possible numerator.
Step 2: Convert to heap
heapify(h)
The heapify
operation arranges the list into a min-heap based on the fraction values. The smallest fraction 1/arr[n-1]
will be at the root (since arr
is sorted and arr[n-1]
is the largest).
Step 3: Extract k-1 smallest fractions
for _ in range(k - 1):
_, i, j = heappop(h)
if i + 1 < j:
heappush(h, (arr[i + 1] / arr[j], i + 1, j))
We pop the smallest fraction k-1
times. Each time we pop a fraction arr[i]/arr[j]
:
- We check if there's a next fraction with the same denominator:
arr[i+1]/arr[j]
- The condition
i + 1 < j
ensures we don't create invalid fractions where the numerator index meets or exceeds the denominator index - If valid, we push this next fraction into the heap
This process maintains the invariant that the heap always contains the smallest unprocessed fraction for each "chain" of fractions with the same denominator.
Step 4: Return the k-th smallest fraction
return [arr[h[0][1]], arr[h[0][2]]]
After popping k-1
fractions, the root of the heap contains the k-th smallest fraction. We extract the numerator and denominator indices from the tuple and return the corresponding array values.
Time Complexity: O(k log n)
where we perform at most k
heap operations, each taking O(log n)
time.
Space Complexity: O(n)
for storing at most n-1
fractions in the heap at any time.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through finding the 4th smallest fraction with arr = [1, 2, 3, 5]
and k = 4
.
Step 1: Initialize heap with fractions having numerator 1
We start with all fractions where numerator is arr[0] = 1
:
1/2
= 0.5 (numerator index: 0, denominator index: 1)1/3
≈ 0.333 (numerator index: 0, denominator index: 2)1/5
= 0.2 (numerator index: 0, denominator index: 3)
After heapify, our min-heap looks like:
Heap: [(0.2, 0, 3), (0.333, 0, 2), (0.5, 0, 1)] 1/5 1/3 1/2
Step 2: Extract first smallest (k=1)
Pop (0.2, 0, 3)
which represents 1/5
.
- Check if we can add next fraction with same denominator:
arr[0+1]/arr[3]
=2/5
- Since
0+1 < 3
, we add(0.4, 1, 3)
to heap
Heap: [(0.333, 0, 2), (0.4, 1, 3), (0.5, 0, 1)] 1/3 2/5 1/2
Step 3: Extract second smallest (k=2)
Pop (0.333, 0, 2)
which represents 1/3
.
- Check next fraction:
arr[0+1]/arr[2]
=2/3
- Since
0+1 < 2
, we add(0.667, 1, 2)
to heap
Heap: [(0.4, 1, 3), (0.5, 0, 1), (0.667, 1, 2)] 2/5 1/2 2/3
Step 4: Extract third smallest (k=3)
Pop (0.4, 1, 3)
which represents 2/5
.
- Check next fraction:
arr[1+1]/arr[3]
=3/5
- Since
1+1 < 3
, we add(0.6, 2, 3)
to heap
Heap: [(0.5, 0, 1), (0.6, 2, 3), (0.667, 1, 2)] 1/2 3/5 2/3
Step 5: Return the 4th smallest
After extracting k-1 = 3 fractions, the root of heap (0.5, 0, 1)
represents the 4th smallest fraction.
- Numerator:
arr[0] = 1
- Denominator:
arr[1] = 2
- Answer:
[1, 2]
The fractions we encountered in order were: 1/5
, 1/3
, 2/5
, and our answer 1/2
- exactly the 4th smallest fraction.
Solution Implementation
1from typing import List
2from heapq import heapify, heappop, heappush
3
4class Solution:
5 def kthSmallestPrimeFraction(self, arr: List[int], k: int) -> List[int]:
6 # Initialize heap with fractions where numerator is arr[0] and denominators are arr[1:]
7 # Each tuple contains: (fraction_value, numerator_index, denominator_index)
8 heap = [(arr[0] / arr[j], 0, j) for j in range(1, len(arr))]
9
10 # Convert list to a min-heap based on fraction values
11 heapify(heap)
12
13 # Pop k-1 smallest fractions to get to the k-th smallest
14 for _ in range(k - 1):
15 fraction_value, numerator_idx, denominator_idx = heappop(heap)
16
17 # If we can form a new fraction with the next larger numerator
18 # and the same denominator, add it to the heap
19 if numerator_idx + 1 < denominator_idx:
20 new_fraction = arr[numerator_idx + 1] / arr[denominator_idx]
21 heappush(heap, (new_fraction, numerator_idx + 1, denominator_idx))
22
23 # The k-th smallest fraction is now at the top of the heap
24 _, final_numerator_idx, final_denominator_idx = heap[0]
25
26 # Return the numerator and denominator as a list
27 return [arr[final_numerator_idx], arr[final_denominator_idx]]
28
1class Solution {
2 public int[] kthSmallestPrimeFraction(int[] arr, int k) {
3 int n = arr.length;
4 // Min heap to store fractions, ordered by their values
5 PriorityQueue<Fraction> minHeap = new PriorityQueue<>();
6
7 // Initialize heap with smallest fractions arr[0]/arr[j] for j from 1 to n-1
8 // Since arr is sorted, arr[0] is the smallest numerator
9 for (int j = 1; j < n; j++) {
10 minHeap.offer(new Fraction(arr[0], arr[j], 0, j));
11 }
12
13 // Extract k-1 smallest fractions to get to the kth smallest
14 for (int count = 1; count < k; count++) {
15 Fraction current = minHeap.poll();
16
17 // If we can form a larger fraction with the same denominator
18 // by incrementing the numerator index
19 if (current.numeratorIndex + 1 < current.denominatorIndex) {
20 minHeap.offer(new Fraction(
21 arr[current.numeratorIndex + 1],
22 arr[current.denominatorIndex],
23 current.numeratorIndex + 1,
24 current.denominatorIndex
25 ));
26 }
27 }
28
29 // The kth smallest fraction is now at the top of the heap
30 Fraction result = minHeap.peek();
31 return new int[] {result.numerator, result.denominator};
32 }
33
34 // Inner class representing a fraction with its indices in the array
35 static class Fraction implements Comparable<Fraction> {
36 int numerator; // Actual numerator value
37 int denominator; // Actual denominator value
38 int numeratorIndex; // Index of numerator in original array
39 int denominatorIndex; // Index of denominator in original array
40
41 public Fraction(int numerator, int denominator, int numeratorIndex, int denominatorIndex) {
42 this.numerator = numerator;
43 this.denominator = denominator;
44 this.numeratorIndex = numeratorIndex;
45 this.denominatorIndex = denominatorIndex;
46 }
47
48 @Override
49 public int compareTo(Fraction other) {
50 // Compare fractions: a/b < c/d if a*d < c*b
51 // Returns negative if this < other, positive if this > other
52 return this.numerator * other.denominator - other.numerator * this.denominator;
53 }
54 }
55}
56
1class Solution {
2public:
3 vector<int> kthSmallestPrimeFraction(vector<int>& arr, int k) {
4 // Define pair type for storing indices of numerator and denominator
5 using IndexPair = pair<int, int>;
6
7 int n = arr.size();
8
9 // Custom comparator for min-heap based on fraction values
10 // Compare fractions a/b and c/d by cross multiplication: a*d vs b*c
11 auto fractionComparator = [&](const IndexPair& a, const IndexPair& b) {
12 return arr[a.first] * arr[b.second] > arr[b.first] * arr[a.second];
13 };
14
15 // Min-heap to store fraction indices, ordered by fraction value
16 priority_queue<IndexPair, vector<IndexPair>, decltype(fractionComparator)> minHeap(fractionComparator);
17
18 // Initialize heap with smallest fractions for each denominator
19 // For each denominator arr[j], start with numerator arr[0]
20 for (int j = 1; j < n; ++j) {
21 minHeap.push({0, j}); // {numerator_index, denominator_index}
22 }
23
24 // Extract k-1 smallest fractions to reach the kth smallest
25 for (int i = 1; i < k; ++i) {
26 IndexPair currentFraction = minHeap.top();
27 minHeap.pop();
28
29 // Add next fraction with same denominator but larger numerator
30 int nextNumeratorIndex = currentFraction.first + 1;
31 int denominatorIndex = currentFraction.second;
32
33 if (nextNumeratorIndex < denominatorIndex) {
34 minHeap.push({nextNumeratorIndex, denominatorIndex});
35 }
36 }
37
38 // The kth smallest fraction is now at the top of the heap
39 IndexPair kthFraction = minHeap.top();
40 return {arr[kthFraction.first], arr[kthFraction.second]};
41 }
42};
43
1function kthSmallestPrimeFraction(arr: number[], k: number): number[] {
2 // Type definition for storing indices of numerator and denominator
3 type IndexPair = [number, number];
4
5 const n = arr.length;
6
7 // Custom comparator for min-heap based on fraction values
8 // Compare fractions a/b and c/d by cross multiplication: a*d vs b*c
9 const fractionComparator = (a: IndexPair, b: IndexPair): number => {
10 return arr[a[0]] * arr[b[1]] - arr[b[0]] * arr[a[1]];
11 };
12
13 // Min-heap to store fraction indices, ordered by fraction value
14 // Using an array-based implementation with manual heap operations
15 const minHeap: IndexPair[] = [];
16
17 // Helper function to maintain heap property after insertion
18 const heapifyUp = (index: number): void => {
19 while (index > 0) {
20 const parentIndex = Math.floor((index - 1) / 2);
21 if (fractionComparator(minHeap[index], minHeap[parentIndex]) < 0) {
22 [minHeap[index], minHeap[parentIndex]] = [minHeap[parentIndex], minHeap[index]];
23 index = parentIndex;
24 } else {
25 break;
26 }
27 }
28 };
29
30 // Helper function to maintain heap property after extraction
31 const heapifyDown = (index: number): void => {
32 while (true) {
33 let smallest = index;
34 const leftChild = 2 * index + 1;
35 const rightChild = 2 * index + 2;
36
37 if (leftChild < minHeap.length && fractionComparator(minHeap[leftChild], minHeap[smallest]) < 0) {
38 smallest = leftChild;
39 }
40 if (rightChild < minHeap.length && fractionComparator(minHeap[rightChild], minHeap[smallest]) < 0) {
41 smallest = rightChild;
42 }
43
44 if (smallest !== index) {
45 [minHeap[index], minHeap[smallest]] = [minHeap[smallest], minHeap[index]];
46 index = smallest;
47 } else {
48 break;
49 }
50 }
51 };
52
53 // Helper function to push element into heap
54 const heapPush = (pair: IndexPair): void => {
55 minHeap.push(pair);
56 heapifyUp(minHeap.length - 1);
57 };
58
59 // Helper function to pop minimum element from heap
60 const heapPop = (): IndexPair => {
61 const min = minHeap[0];
62 minHeap[0] = minHeap[minHeap.length - 1];
63 minHeap.pop();
64 if (minHeap.length > 0) {
65 heapifyDown(0);
66 }
67 return min;
68 };
69
70 // Initialize heap with smallest fractions for each denominator
71 // For each denominator arr[j], start with numerator arr[0]
72 for (let j = 1; j < n; j++) {
73 heapPush([0, j]); // [numerator_index, denominator_index]
74 }
75
76 // Extract k-1 smallest fractions to reach the kth smallest
77 for (let i = 1; i < k; i++) {
78 const currentFraction = heapPop();
79
80 // Add next fraction with same denominator but larger numerator
81 const nextNumeratorIndex = currentFraction[0] + 1;
82 const denominatorIndex = currentFraction[1];
83
84 if (nextNumeratorIndex < denominatorIndex) {
85 heapPush([nextNumeratorIndex, denominatorIndex]);
86 }
87 }
88
89 // The kth smallest fraction is now at the top of the heap
90 const kthFraction = minHeap[0];
91 return [arr[kthFraction[0]], arr[kthFraction[1]]];
92}
93
Time and Space Complexity
Time Complexity: O(k * log n)
where n
is the length of the input array and k
is the given parameter.
- Initial heap construction: Creating the initial heap with
n-1
elements (fractions with numeratorarr[0]
and all other elements as denominators) takesO(n)
time for list creation andO(n)
for heapification. - Main loop: The loop runs exactly
k-1
times. In each iteration:heappop()
operation:O(log n)
since the heap can contain at mostn-1
elementsheappush()
operation:O(log n)
for the same reason
- Final answer extraction:
O(1)
to access the minimum element from the heap - Overall:
O(n) + O(k * log n) = O(n + k * log n)
- Since typically
k ≤ n(n-1)/2
and oftenk
is much smaller, the dominant term is usuallyO(k * log n)
whenk > n
, otherwiseO(n)
dominates for smallk
.
Space Complexity: O(n)
where n
is the length of the input array.
- The heap initially contains
n-1
elements (one fraction for each element except the first as denominator) - Throughout execution, the heap size never exceeds
n-1
elements because:- We start with
n-1
fractions - Each
heappop()
removes one element and eachheappush()
adds at most one element - The heap maintains at most one fraction for each possible denominator
- We start with
- Therefore, the space complexity is
O(n)
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Incorrect Index Mapping When Initializing the Heap
One of the most common mistakes is incorrectly mapping indices when creating the initial heap entries. The original solution uses:
h = [(1 / y, 0, j + 1) for j, y in enumerate(arr[1:])]
The pitfall occurs when developers forget that enumerate(arr[1:])
starts counting from 0, but these indices correspond to elements starting from position 1 in the original array. This leads to confusion about whether to use j
or j + 1
for the denominator index.
Incorrect attempt:
# Wrong: Using j directly instead of j + 1
heap = [(arr[0] / arr[j], 0, j) for j in range(1, len(arr))]
# This would incorrectly map to arr[1], arr[2], ... arr[n-2] as denominators
Correct solution:
# Clear and explicit indexing
heap = [(arr[0] / arr[j], 0, j) for j in range(1, len(arr))]
# Now j directly represents the index in the original array
2. Off-by-One Error in the Boundary Check
Another common mistake is using the wrong comparison operator when checking if we can create the next fraction:
Incorrect:
if i + 1 <= j: # Wrong: allows i + 1 == j heappush(h, (arr[i + 1] / arr[j], i + 1, j))
This would allow creating fractions where the numerator and denominator are the same element (when i + 1 == j
), resulting in a fraction equal to 1, which violates the problem constraint that i < j
.
Correct:
if i + 1 < j: # Ensures numerator index is strictly less than denominator index heappush(h, (arr[i + 1] / arr[j], i + 1, j))
3. Accessing Heap After All Elements Are Popped
If k
equals the total number of possible fractions, the loop might pop all elements from the heap, leaving it empty:
Problematic scenario:
for _ in range(k - 1):
_, i, j = heappop(h)
if i + 1 < j:
heappush(h, (arr[i + 1] / arr[j], i + 1, j))
# If heap is empty here, h[0] will raise an IndexError
return [arr[h[0][1]], arr[h[0][2]]]
Robust solution:
# Store the last popped element
for _ in range(k - 1):
fraction_value, i, j = heappop(h)
if i + 1 < j:
heappush(h, (arr[i + 1] / arr[j], i + 1, j))
# Check if heap is empty (edge case)
if not h:
# Return the last popped fraction
return [arr[i], arr[j]]
else:
return [arr[h[0][1]], arr[h[0][2]]]
4. Integer Division Instead of Float Division
In Python 2 or when using floor division, this could be a problem:
Incorrect (Python 2 style):
# This would perform integer division in Python 2
heap = [(1 / arr[j], 0, j) for j in range(1, len(arr))]
Correct (Python 3 compatible):
# Ensure float division
heap = [(float(arr[0]) / arr[j], 0, j) for j in range(1, len(arr))]
# Or use: arr[0] * 1.0 / arr[j]
5. Not Handling Edge Cases
The solution should handle edge cases like:
k = 1
: Should return the smallest fraction without any heap pops- Array with only 2 elements: Only one possible fraction
- Very large arrays where numerical precision might be an issue
Enhanced solution with edge case handling:
def kthSmallestPrimeFraction(self, arr: List[int], k: int) -> List[int]:
n = len(arr)
# Edge case: only one fraction possible
if n == 2:
return arr
# Initialize heap
heap = [(arr[0] / arr[j], 0, j) for j in range(1, n)]
heapify(heap)
# Handle k = 1 case efficiently
if k == 1:
return [arr[0], arr[-1]]
# Process k-1 smallest fractions
for _ in range(k - 1):
if not heap:
break
_, i, j = heappop(heap)
if i + 1 < j:
heappush(heap, (arr[i + 1] / arr[j], i + 1, j))
# Return the k-th smallest
return [arr[heap[0][1]], arr[heap[0][2]]] if heap else [arr[i], arr[j]]
What does the following code do?
1def f(arr1, arr2):
2 i, j = 0, 0
3 new_arr = []
4 while i < len(arr1) and j < len(arr2):
5 if arr1[i] < arr2[j]:
6 new_arr.append(arr1[i])
7 i += 1
8 else:
9 new_arr.append(arr2[j])
10 j += 1
11 new_arr.extend(arr1[i:])
12 new_arr.extend(arr2[j:])
13 return new_arr
14
1public static List<Integer> f(int[] arr1, int[] arr2) {
2 int i = 0, j = 0;
3 List<Integer> newArr = new ArrayList<>();
4
5 while (i < arr1.length && j < arr2.length) {
6 if (arr1[i] < arr2[j]) {
7 newArr.add(arr1[i]);
8 i++;
9 } else {
10 newArr.add(arr2[j]);
11 j++;
12 }
13 }
14
15 while (i < arr1.length) {
16 newArr.add(arr1[i]);
17 i++;
18 }
19
20 while (j < arr2.length) {
21 newArr.add(arr2[j]);
22 j++;
23 }
24
25 return newArr;
26}
27
1function f(arr1, arr2) {
2 let i = 0, j = 0;
3 let newArr = [];
4
5 while (i < arr1.length && j < arr2.length) {
6 if (arr1[i] < arr2[j]) {
7 newArr.push(arr1[i]);
8 i++;
9 } else {
10 newArr.push(arr2[j]);
11 j++;
12 }
13 }
14
15 while (i < arr1.length) {
16 newArr.push(arr1[i]);
17 i++;
18 }
19
20 while (j < arr2.length) {
21 newArr.push(arr2[j]);
22 j++;
23 }
24
25 return newArr;
26}
27
Recommended Readings
Tech Interview Pattern Two Pointers Introduction If you prefer videos here's a super quick introduction to Two Pointers div class responsive iframe iframe src https www youtube com embed xZ4AfXHQ1VQ title YouTube video player frameborder 0 allow accelerometer autoplay clipboard write encrypted media gyroscope picture in picture allowfullscreen iframe div Two pointers is a common interview
Binary Search Speedrun For each of the Speedrun questions you will be given a binary search related problem and a corresponding multiple choice question The multiple choice questions are related to the techniques and template s introduced in the binary search section It's recommended that you have gone through at
Sorting Summary Comparisons We presented quite a few sorting algorithms and it is essential to know the advantages and disadvantages of each one The basic algorithms are easy to visualize and easy to learn for beginner programmers because of their simplicity As such they will suffice if you don't know any advanced
Want a Structured Path to Master System Design Too? Don’t Miss This!