题目描述

✅ 786. 第 K 个最小的质数分数

image-20260929000250414

image-20260929000250416

题意分析

从严格递增的 arr 中选下标 i < j,组成分数 arr[i] / arr[j],将全部分数按数值从小到大排列,返回第 k 个分数的分子与分母原值。所有数均为正数,因此这些分数都在 $0$ 与 $1$ 之间。

一共有 $n(n-1)/2$ 个合法下标对,不必先构造全部分数再排序。数组由 1 和互不相同的质数组成,各个分数都已约分且彼此不同。下面先用小根堆按次序取数,再补充满足题目进阶要求的二分计数法。

解法:小根堆多路归并分数

核心思路

[!blue]

固定分子下标 i,让分母下标依次取 n-1、n-2、…、i+1。分母逐渐减小,分数就严格增大,于是每个分子对应一条升序链;所有合法分数恰好分布在这 n-1 条链中。

每条尚未耗尽的链只将最小未取项放入小根堆。链中后面的分数都不会小于链首,所以全体剩余分数的最小值,一定就是各链首中的最小值,也就是堆顶。

弹出 (i,j) 后,这条链的下一项是 (i,j-1),只有 j-1 > i 时才存在;其他链的最小未取项没有变化,无需更新。这样每次弹出都是当前全局最小值,连续弹出 k-1 次后,堆顶就是第 k 小的分数。

堆中保存下标对,比较时对正分母交叉相乘:arr[i] / arr[j] < arr[x] / arr[y] 等价于 arr[i] * arr[y] < arr[x] * arr[j]。用 long 或 int64 计算乘积,便不需要把分数转换成浮点数。

解题步骤

  1. 对每个 i = 0..n-2,将 (i,n-1) 加入堆,建立各分子链的首项。
  2. 重复 k-1 次:取出堆顶 (i,j);若 j-1 > i,就加入同一链的下一项 (i,j-1)。
  3. 读取剩余堆顶,并返回该下标对对应的数组值。

k == 1 时不弹出任何元素;即使 k 取最大合法值,也只会弹出总数减一项,最后仍有一个候选可读取。题目保证 k 合法,因此不需要在正常流程中处理空堆结果。

代码实现

class Solution {
    public int[] kthSmallestPrimeFraction(int[] arr, int k) {
        int n = arr.length;
        PriorityQueue<int[]> heap =
                new PriorityQueue<>(
                        (a, b) -> {
                            // 交叉相乘比较分数,乘法前提升到宽整数
                            long left = (long) arr[a[0]] * arr[b[1]];
                            long right = (long) arr[b[0]] * arr[a[1]];

                            return Long.compare(left, right);
                        });

        for (int i = 0; i < n - 1; i++) {
            heap.offer(new int[] {
                i,
                n - 1
            });
        }

        // 只移除前 k 减一项,剩余堆顶就是目标名次
        for (int rank = 1; rank < k; rank++) {
            int[] pair = heap.poll();
            int i = pair[0];
            int j = pair[1];

            // 只推进刚弹出的分子链,分母下标仍须更大
            if (j - 1 > i) {
                heap.offer(new int[] {
                    i,
                    j - 1
                });
            }
        }

        int[] pair = heap.peek();

        return new int[] {
            arr[pair[0]],
            arr[pair[1]]
        };
    }
}
import "container/heap"

type FractionPair struct {
    i int
    j int
}

type FractionHeap struct {
    data []FractionPair
    arr  []int
}

func (h FractionHeap) Len() int { return len(h.data) }

func (h FractionHeap) Less(a int, b int) bool {
    x := h.data[a]
    y := h.data[b]
    // 交叉相乘比较分数,乘法前提升到宽整数
    left := int64(h.arr[x.i]) * int64(h.arr[y.j])
    right := int64(h.arr[y.i]) * int64(h.arr[x.j])
    return left < right
}

func (h FractionHeap) Swap(a int, b int) { h.data[a], h.data[b] = h.data[b], h.data[a] }

func (h *FractionHeap) Push(x any) { h.data = append(h.data, x.(FractionPair)) }

func (h *FractionHeap) Pop() any {
    pair := h.data[len(h.data)-1]
    h.data = h.data[:len(h.data)-1]
    return pair
}

func kthSmallestPrimeFraction(arr []int, k int) []int {
    h := &FractionHeap{arr: arr}
    for i := 0; i < len(arr)-1; i++ {
        h.data = append(h.data, FractionPair{i: i, j: len(arr) - 1})
    }
    heap.Init(h)

    // 只移除前 k 减一项,剩余堆顶就是目标名次
    for rank := 1; rank < k; rank++ {
        pair := heap.Pop(h).(FractionPair)
        // 只推进刚弹出的分子链,分母下标仍须更大
        if pair.j-1 > pair.i {
            heap.Push(h, FractionPair{i: pair.i, j: pair.j - 1})
        }
    }
    pair := h.data[0]
    return []int{
        arr[pair.i],
        arr[pair.j],
    }
}

复杂度分析

  • 时间复杂度:Java 为 $O((n+k)\log n)$;Go 批量建堆为 $O(n)$,后续 $O(k\log n)$。
  • 空间复杂度:$O(n)$,每个分子最多一个活动候选。

当 k 接近分数总数时,堆法最坏需要 $O(n^2\log n)$ 时间,不能满足小于 $O(n^2)$ 的进阶要求。下面用计数跳过整段排名。

关键点总结

[!green]

  • 固定分子、递减分母才能形成升序链,堆始终保存每条链的最小未取项。
  • 只推进刚弹出元素所在的链,既不会漏掉候选,也不会重复插入同一个下标对。
  • 弹出 k-1 项后查看堆顶;若已经弹出了 k 项,就应返回最后弹出项,不能再读堆顶。

解法二:整数阈值二分 + 双指针计数

核心思路

[!blue]

设 count(x) 是不大于阈值 $x$ 的合法分数数量。阈值越大,数量越多,因此可以二分“第一个使数量至少为 k 的阈值”,而不用依次取出前 k 个分数。

为了精确比较,令最大数组值为 $M$,取整数尺度 $D=M^2$,只考虑阈值 $t/D$,其中整数 $t$ 位于 $[0,D]$。任意两个不同分数 $a/b<c/d$ 的间距满足:

\[\frac{c}{d}-\frac{a}{b}=\frac{bc-ad}{bd}\ge\frac{1}{bd}\ge\frac{1}{M^2}\]

所以每个网格区间 $((t-1)/D,t/D]$ 至多新包含一个分数。设最小的合格阈值为 t,它满足 count((t-1)/D) < k、count(t/D) >= k,而数量最多增加一,因此后者恰好等于 k。阈值下的最大分数就是答案,不需要设置浮点误差或把阈值本身当作答案。

对一次计数,按分母下标 j 从小到大扫描,用 i 表示满足 arr[i] / arr[j] <= t/D 的分子个数。比较条件改写为 arr[i] * D <= t * arr[j],并始终限制 i < j。当分母增大,原先合格的分子仍然合格,所以 i 只向右移动;该分母贡献的分数数量就是 i。

若 i > 0,当前分母下的最大合格分数是 arr[i-1] / arr[j]。将它与本轮记录的最大分数用交叉乘法比较,就能在计数的同时得到阈值下的最大分数。每当数量至少为 k,保存这个分数并继续向更小的整数阈值搜索;最后一次保存时正好对应最小合格阈值。

解题步骤

  1. 令 D = arr[n-1]²,在整数闭区间 [0,D] 上二分。阈值 0 不包含分数,阈值 D/D = 1 包含全部合法分数。
  2. 对中点 mid,重置 i、累计数量和本轮最大分数;扫描所有分母,用单调右移的 i 统计不大于 mid/D 的分数。
  3. 若数量小于 k,令 left = mid+1;否则保存本轮最大分数,并令 right = mid-1,继续寻找更小的合格阈值。
  4. 区间为空后返回最后保存的分子和分母。

题目中 $M\le 3\times 10^4$,计数时的乘积最多达到 $M^3$,需要用 long 或 int64,并在乘法前完成类型提升。每轮最多统计 $n(n-1)/2$ 个分数,普通整数即可保存数量。

代码实现

class Solution {
    public int[] kthSmallestPrimeFraction(int[] arr, int k) {
        int n = arr.length;
        long scale = (long) arr[n - 1] * arr[n - 1];
        long left = 0;
        long right = scale;
        int[] answer = new int[2];

        while (left <= right) {
            long mid = left + (right - left) / 2;
            int count = 0;
            int i = 0;
            int numerator = 0;
            int denominator = 1;

            for (int j = 1; j < n; j++) {
                while (i < j && (long) arr[i] * scale <= mid * arr[j]) {
                    i++;
                }

                count += i;
                if (i > 0
                        && (long) arr[i - 1] * denominator > (long) numerator * arr[j]) {
                    numerator = arr[i - 1];
                    denominator = arr[j];
                }
            }

            if (count >= k) {
                answer[0] = numerator;
                answer[1] = denominator;
                right = mid - 1;
            } else {
                left = mid + 1;
            }
        }

        return answer;
    }
}
func kthSmallestPrimeFraction(arr []int, k int) []int {
    n := len(arr)
    scale := int64(arr[n-1]) * int64(arr[n-1])
    left, right := int64(0), scale
    answer := make([]int, 2)

    for left <= right {
        mid := left + (right-left)/2
        count, i := 0, 0
        numerator, denominator := 0, 1

        for j := 1; j < n; j++ {
            for i < j && int64(arr[i])*scale <= mid*int64(arr[j]) {
                i++
            }

            count += i
            if i > 0 && int64(arr[i-1])*int64(denominator) > int64(numerator)*int64(arr[j]) {
                numerator = arr[i-1]
                denominator = arr[j]
            }
        }

        if count >= k {
            answer[0] = numerator
            answer[1] = denominator
            right = mid - 1
        } else {
            left = mid + 1
        }
    }

    return answer
}

复杂度分析

  • 时间复杂度:$O(n\log M)$。整数二分范围大小为 $M^2+1$,迭代次数为 $O(\log M)$;每次扫描中分母与分子指针都只前进 $O(n)$ 次。
  • 空间复杂度:$O(1)$,只维护二分边界、计数、两个指针和一个候选分数。

关键点总结

[!green]

  • 二分的是精确的整数阈值,利用分数间距下界,保证最小合格阈值只包含前 k 个分数。
  • 分母从小到大时,合格分子的数量不减,因此每轮计数可以用一个不回退的指针完成。
  • 保存真实的分子和分母,阈值只用于计数,返回值不需要做任何近似还原。

易错点总结

[!yellow]

  • 分母下标允许等于分子,会混入非法的一。
  • 只初始化一条链,会漏掉其他分子。
  • 推进其他链,破坏每链最小未取项的含义。
  • 二分计数时,每换一个阈值都要重置 i 和累计数量;只有同一阈值下扫描不同分母时,才能复用单调右移的 i。
  • 先用普通整数算完乘积再转为宽整数,可能已经溢出;尺度计算和阈值比较都要在乘法前提升类型。

相似题目

题目 难度 关联与区别
373. 查找和最小的 K 对数字 中等 同样在隐式有序数对中求前k项,可按一侧固定形成多路候选并用堆归并。
378. 有序矩阵中第 K 小的元素 中等 同样可二分数值并计不超过阈值的候选,本题候选为有效下标对的分数而非完整矩阵格子。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2023/46989450
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!