第k小元问题 | 刷题打卡

260 阅读2分钟

Date: 2021/03/06

题目描述

求第k小元问题(k-smallest),又称为选择问题

该问题可以描述为:在一个有 n 个元素的集合中找出第 k(1 <= k <= n) 小的元素,

  • k==1 时,就是求集合的最小值
  • k==2 时,就是求集合的最大值
  • k==(n+1)/2 时,就是求集合的中位数

例如集合 {23,12,5,15,11,6,1} 的第 1 小元为 1,第 4 小元为 11

思路分析

先排序可行吗?

这个问题最大的障碍就是元素是乱序的,如果能够让给定的元素升序排列,我们很容易就可以得到第 k 小元就是下标为 k-1 的元素值。

那么最简单直接的想法就是将元素先进行升序排序,然后通过下标索引到第 k 小元。但是如果只是为了得到某一个值,就要对整个集合排序,是不是略显多余,而且当元素过多时,效率也并不高。

先排序不是我们要寻找的理想操作。

核心思想

虽然不建议整体排序,但可以借用排序的思想。

在学过的排序算法中,快速排序 有一个特点:每一轮排序(升序)之后,能够确定基准值 pivot 最终的位置,并且基准值左边的元素都比它小,基准值右边的元素都比他大。也就是说,如果基准值对应的下标为 i,那么基准值就是集合的第 i小元

这个特点称为分区 partitioning

根据分区的特点,对 left ~ right(left = 0,right = n-1)范围内元素一次分区后,有以下情况:

  • k == i+1,则第 k 小元就是基准值,算法结束
  • k > i+1,则第 k 小元为基准值右侧所有元素的第 k-(i+1) 小元,left = i+1,right = n-1
  • k < i+1,则第 k 小元为基准值左侧所有元素的第 k 小元,left = 0,right = i-1

按照以上规律多次进行分区操作,最终可以找到第 k 小元。

下面我们讨论另一个问题,如何选取基准值?

选取基准值

随机选择基准值

在最简单的快速排序中,基准值的选择总是默认为第一个元素,但是这并不是最优方案。稍加改进,可以随机选择一个基准值,为了方便操作,随机选择的基准值和第一个元素交换。

代码实现如下:

/**
 * 随机选择基准值(主元)
 * 迭代调用
 * @param values 目标数组
 * @param kth 第k小
 * @return 第k小元下标
 */
public static int kthSmallestInRandomPivot(int[] values, int kth) {
    if (values == null) {
        throw new IllegalArgumentException("values == null");
    }
    if (kth > values.length) {
        return -1;
    }
    int left = 0, right = values.length - 1;
    while (true) {
        // 分区操作,true表示使用随机策略
        int mid = partitioning(values, left, right);
        if (kth == mid + 1) {
            return values[mid];
        } else if (kth < mid + 1) {
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
}

/**
* 随机选择基准值进行分区
* @param values 目标数组
* @param left 分区左范围
* @param right 分区右范围
*/
public static int partitioning(int[] values, int left, int right) {
    int pivotIndex = left + new Random().nextInt(right - left + 1);
    if (pivotIndex != left) {
        SortUtils.swap(values, pivotIndex, left);
    }
    /** 基准值 */
    int pivot = values[left];
    int i = left, j = right;
    while (i != j) {
        while (i < j && values[j] >= pivot) {
            j--;
        }
        while (i < j && values[i] <= pivot) {
            i++;
        }
        if (i < j) {
            SortUtils.swap(values, i, j);
        }
    }
    values[left] = values[i];
    values[i] = pivot;
    return i;
}

二次取中选取基准值

n 个元素组成分组,确定每组的中间值,分别依次排列在数组的首部;再对首部的各个中间值取中间值作为基准值,并和第一个元素交换,然后进行分区操作。这就是二次取中法(Median of Median Rule)的基本思想。

例如一组 n=15 个数的数组:[41,76,55,19,59,63,12,47,67,45,26,76,74,33,18],分为3 组,每组元素设为 5 个:

  • 首先对三组数据分别排序,中值分别为 55,47,33

  • 然后依次将 55,47,33 分别和前三个位置的元素交换

  • 然后对 55,47,33 进行排序,取中值为 47,与第一个元素交换,基准值为 47

找到了基准值后就可以进行分区求解了。

另外,要说明的是,当数据规模足够小时,也可以理解为元素还不够分为一组时,可以直接使用排序算法确定第 k 小元,在确定分组元素中值时也可以使用排序算法来实现(我这里使用的是 插入排序)。

/**
 * 线性时间的选择算法:二次取中法
 * @param values 目标数组
 * @param kth 第k小
 * @return 第k小元下标
 */
public static int kthSmallestInMMR(int[] values, int kth) {
    return kthSmallestInLinearTime(values, kth, 0, values.length - 1, 5);
}

/**
 * 二次取中法 Median of Median Rule
 * 递归调用
 * @param values 目标数组
 * @param kth 第k小
 * @param left 当前数据范围左下标
 * @param right 当前数据范围右下标
 * @param numInGroup 分组中每组的元素个数
 * @return 第k小元下标
 */
public static int kthSmallestInMMR(int[] values, int kth, int left, int right, int numInGroup) {
    int currentRange = right - left + 1;    
    /** 分组数 */
    int groups = currentRange / numInGroup; 
    if (currentRange <= numInGroup) {
        /** 当前范围的元素无法分为一组时,直接使用插入排序排序,返回第k小元下标 */
        InsertSort.insertSort(values, left, right);
        return left + kth - 1;
    }
    for (int i = 0; i < groups; i++) {
        /** 使用插入排序对每个组排序,并将中值放在前面 */
        InsertSort.insertSort(values, left + i * numInGroup, left + (i + 1) * numInGroup - 1);
        SortUtils.swap(values, left + i, left + i * numInGroup + (int) Math.ceil(numInGroup / 2.0) - 1);
    }
    /** 递归调用,进行二次取中 */
    int pivotIndex = kthSmallestInMMR(values, (int) Math.ceil(groups / 2.0), left, left + groups - 1, numInGroup);
    SortUtils.swap(values, pivotIndex, left);

    pivotIndex = partitioning(values, left, right);     /** 进行分区,获取基准值下标 */
    if (kth == pivotIndex - left + 1) {
        return pivotIndex;
    } else if (kth < pivotIndex - left + 1) {
        return kthSmallestInMMR(values, kth, left, pivotIndex - 1, numInGroup);
    } else {
        return kthSmallestInMMR(values, kth - (pivotIndex - left + 1), pivotIndex + 1, right, numInGroup);
    }
}

/**
* 随机选择基准值进行分区
* @param values 目标数组
* @param left 分区左范围
* @param right 分区右范围
*/
public static int partitioning(int[] values, int left, int right) {
    /** 基准值 */
    int pivot = values[left];
    int i = left, j = right;
    while (i != j) {
        while (i < j && values[j] >= pivot) {
            j--;
        }
        while (i < j && values[i] <= pivot) {
            i++;
        }
        if (i < j) {
            SortUtils.swap(values, i, j);
        }
    }
    values[left] = values[i];
    values[i] = pivot;
    return i;
}

总结

对于随机选取基准值的方式,该问题的平均时间复杂度为 O(n),最坏时间复杂度为 O(n*n)。对于二次取中法选取基准值求解该问题,效率更高,最坏时间复杂度为 O(n)

求第 k 小元问题实际上是 分治法 思想的体现,分治法往往伴随这使用递归,虽然能够解决很多问题,但由于递归会带来性能损耗,非必要情况下不建议使用递归实现,可以考虑借助来消除递归。


本文正在参与「掘金 2021 春招闯关活动」, 点击查看 活动详情