【leetcode】658.找到K个最接近的元素(内置函数,二分法,双指针等多种方法,图文详解)

658. 找到 K 个最接近的元素

给定一个排序好的数组,两个整数 kx,从数组中找到最靠近 x(两数之差最小)的 k 个数。返回的结果必须要是按升序排好的。如果有两个数与 x 的差值一样,优先选择数值较小的那个数。

示例 1:

输入: [1,2,3,4,5], k=4, x=3
输出: [1,2,3,4]

示例 2:

输入: [1,2,3,4,5], k=4, x=-1
输出: [1,2,3,4]

说明:

  1. k 的值为正数,且总是小于给定排序数组的长度。
  2. 数组不为空,且长度不超过 104
  3. 数组里的每个元素与 x 的绝对值不超过 104

更新(2017/9/19):
这个参数 arr 已经被改变为一个整数数组(而不是整数列表)。 *请重新加载代码定义以获取最新更改。*

分析

方法 1: 使用 Collection.sort()

算法

直观地,我们可以将数组中的元素按照与目标 x 的差的绝对值排序,排好序后前 k 个元素就是我们需要的答案。

  • Java
class Solution {
     
    public List<Integer> findClosestElements(int[] arr, int k, int x) {
     
        List<Integer> ret = Arrays.stream(arr).boxed().collect(Collectors.toList());
        Collections.sort(ret, (a,b) -> a == b ? a - b : Math.abs(a-x) - Math.abs(b-x));
        ret = ret.subList(0, k);
        Collections.sort(ret);
        return ret;
    }
}

复杂度分析

  • 时间复杂度: O*(nlog*n)。 Collections.sort() 使用二叉排序所以时间复杂度是 O(nlog n)
  • 空间复杂度:O*(k)。就地排序不需要额外的空间。但是生成长度为 k 的子列表需要消耗空间。

方法2:排除法(双指针)

arr = [1, 2, 3, 4, 5, 6, 7] , x = 5, k = 3 为例。

思路分析

1、一个一个删,因为是有序数组,且返回的是连续升序子数组,所以每一次删除的元素一定是位于边界

2、一共 7 个元素,要保留3 个元素,因此要删除 4 个元素;

3、因为要删除的元素都位于边界,于是可以使用双指针对撞的方式确定保留区间,即「最优区间」。

【leetcode】658.找到K个最接近的元素(内置函数,二分法,双指针等多种方法,图文详解)_第1张图片

我们再分析一个 x 不在数组中的例子,例如:

数组 arr = [0, 1, 2, 3, 3, 4, 7, 7, 8]k = 3x = 5。数组中一共 9 个数,保留 3 个数,则需要删除 6 个数,这里 6 = len(arr) - k

1、因为 5 - 0 > 8 - 5,所以将 0 删去;
2、因为 5 - 1 > 8 - 5,所以将 1 删去;
3、因为 5 - 2 = 8 - 5,根据题目意思,保留左边的 2 ,所以将 8 删去;
4、因为 5 - 2 > 7 - 5,所以将 2 删去;
5、因为 5 - 3 = 7 - 5,根据题目意思,保留左边的 3 ,所以将 7 删去;
6、因为 5 - 2 = 7 - 5,根据题目意思,保留左边的 3 ,所以将 7 删去;

已经删除了 6 个数,剩下的 [3, 3, 4] 就是最接近 5 的 3 个数。

参考代码 1

import java.util.ArrayList;
import java.util.List;

public class Solution {
     

    public List<Integer> findClosestElements(int[] arr, int k, int x) {
     
        int size = arr.length;

        int left = 0;
        int right = size - 1;

        int removeNums = size - k;
        while (removeNums > 0) {
     
            if (x - arr[left] <= arr[right] - x) {
     
                right--;
            } else {
     
                left++;
            }
            removeNums--;
        }

        List<Integer> res = new ArrayList<>();
        for (int i = left; i < left + k; i++) {
     
            res.add(arr[i]);
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(N),这里 N是数组的长度。
  • 空间复杂度:O(1),只使用了常数个额外的辅助空间。

题目中说有序数组,又易知:

1、题目要求返回的是区间,并且是连续区间;

2、区间长度是固定的,并且 k 的值为正数,且总是小于给定排序数组的长度,即 k 的值「不违规」;

因此,只要我们找到了左边界的索引,从左边界开始数 k 个数,返回就好了。我们把这件事情定义为「寻找最优区间」,「寻找最优区间」等价于「寻找最优区间的左边界」。因此本题使用二分查找法在有序数组中定位含有 k 个元素的连续子区间的左边界,即使用二分法找「最优区间的左边界」。

方法3:二分查找最优区间的左边界

可以看图,也可以看文字,建议先看草稿图,思路会比较清晰。

【leetcode】658.找到K个最接近的元素(内置函数,二分法,双指针等多种方法,图文详解)_第2张图片

由排除法,我们知道:如果 x 的值就在长度为 size 的区间内(不一定相等),要得到 size - 1 个符合题意的最接近的元素,此时看左右边界:

  1. 如果左边界与 x 的差值的绝对值较小,删除右边界;
  2. 如果右边界与 x 的差值的绝对值较小,删除左边界;
  3. 如果左、右边界与 x 的差值的绝对值相等,删除右边界。

讨论「最优区间的左边界」的取值范围

首先我们讨论左区间的取值范围,使用具体的例子,就很很清楚地找到规律:

1、假设一共有 5 个数,不管 x 的值是多少,在 [0, 1, 2, 3, 4],找 3 个数,左边界最多到 2;

2、假设一共有 8 个数,不管 x 的值是多少,在 [0, 1, 2, 3, 4, 5, 6, 7],找 5 个数,左边界最多到 3。

因此,「最优区间的左边界」的下标的搜索区间为 [0, size - k]。注意,这个区间的左右都是闭区间,都能取到。

定位左区间的下标,有一点技巧性,但并不难理解。由排除法的结论,我们从 [0, size - k] 这个区间的任意一个位置(用二分法就是当前候选区间的中位数)开始,定位一个长度为 (k + 1) 的区间,根据这个区间是否包含 x 开展讨论。

1、如果区间包含 x,我们尝试删除 1 个元素,好让区间发生移动,便于定位「最优区间的左边界」的下标;
2、如果区间不包含 x,就更简单了,我们尝试把区间进行移动,以试图包含 x,但也有可能区间移动不了(极端情况下)。

以下的讨论,对于记号 leftrightmid 说明如下:

1、leftright 是候选区间的左右边界的下标,根据上面的分析,初始时,left = 0right = size - k
2、而 mid 是候选区间的中位数的下标,它的取值可能是

mid = left + (right - left) // 2

也可能是

mid = left + (right - left + 1) // 2

后面的文字可能会非常绕,在这里建议读者通读,前后来回看,不太清楚的地方先跳过,且不一定全看我的叙述,看明白一小段,在草稿纸上写写画画一点,卡壳了再看我的叙述,这样就不会太晕。

我们先从最简单的情况开始讨论:

情况 1:如果区间不包含 x

  1. 区间的右端点在 x 的左边,即 xarr 中最大的元素还要大,由于要去掉 1 个元素,显然去掉左端点,因此「最优区间的左边界」的下标至少是 mid + 1,即 left = mid + 1因为区间不可能再往左边走了,如图;

image.png

说明:极端情况是此时中位数位于索引 size - k,区间不能右移。

  1. 区间的左端点在 x 的左边,即 xarr 中最小的元素还要小,当前的区间左端点的下标至多是 mid,此时 right = mid因为区间不可能再往右偏了,如图;

image.png

说明:极端情况是此时 mid 位于索引 0,区间不能左移。

情况 2:如果区间包含 x,我们尝试删掉一个元素,以便让区间发生移动,缩小搜索范围:

易知,我们要比较长度为 k + 1 的区间的左右端点的数值与 x 的差值的绝对值。此时这个区间的左边界的下标是 mid,右边界的下标是 mid + k。根据方法一(排除法)的结论,分类讨论如下:

  1. 如果右边界与 x 的差值的绝对值较小,左边界收缩,可以肯定的是「最优区间的左边界」的下标 left 至少是 mid + 1,即 left = mid + 1,如图;

image.png

说明:「右边界与 x 的差值绝对值较小」同样适用于「情况 1.1」,因此它们二者可以合并;

  1. 如果左边界与 x 的差值的绝对值较小,右边界收缩,此时区间不移动,注意:此时有可能收缩以后的区间就是待求的区间,也有可能整个区间向左移动,这件事情叫做,right = mid 不能排除 mid(下一轮搜索区间是 [left, mid]),如图;

image.png

说明 1:这一点比较难想,但实际上也可以不想,根据「情况 2.1」的结论,左区间收缩的反面即是右区间不收缩,因此,这一分支的逻辑一定是 right = mid

说明 2:「左边界与 x 的差的绝对值较小」同样适用于 「情况 1.2」,因此它们二者可以合并。

  1. 如果左、右边界与 x 的差的绝对值相等,删除右边界,结论同「情况 2.2」,也有 right = mid,可以合并到 「情况 2.2」。

以上看晕的朋友们,建议你在草稿纸上写写画画,思路就非常清晰了。很坦白的说这个代码我没有写出来,我只是在尽力解释代码的意思,在网上搜了一下,刚开始的时候,一直不能理解下面这段代码的意思。

if x - arr[mid] > arr[mid + k] - x:
    left = mid + 1
else:
    right = mid

写个草稿就清楚多了,原来并不困难,只是稍显复杂。

import java.util.ArrayList;
import java.util.List;

public class Solution {
     

    public List<Integer> findClosestElements(int[] arr, int k, int x) {
     
        int size = arr.length;

        int left = 0;
        int right = size - k;

        while (left < right) {
     
            // int mid = left + (right - left) / 2;
            int mid = (left + right) >>> 1;
            // 尝试从长度为 k + 1 的连续子区间删除一个元素
            // 从而定位左区间端点的边界值
            if (x - arr[mid] > arr[mid + k] - x) {
     
                left = mid + 1;
            } else {
     
                right = mid;
            }
        }

        List<Integer> res = new ArrayList<>();
        for (int i = left; i < left + k; i++) {
     
            res.add(arr[i]);
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O*(logN+*K),这里 N 是数组的长度,使用二分法的时间复杂度是对数级别的。
  • 空间复杂度:O(1),只使用了常数个额外的辅助空间。

你可能感兴趣的:(LeetCode,二分法,数据结构,python,java,算法)