听风是风

学或不学,知识都在那里,只增不减。

导航

JS Leetcode 220. 存在重复元素 III 题解分析,暴力解法与桶排序

壹 ❀ 引

今天的题目来自LeetCode 220. 存在重复元素 III,难度中等,题目描述如下:

给你一个整数数组 nums 和两个整数 k 和 t 。请你判断是否存在 两个不同下标 i 和 j,使得 abs(nums[i] - nums[j]) <= t ,同时又满足 abs(i - j) <= k 。

如果存在则返回 true,不存在返回 false。

示例 1:

输入:nums = [1,2,3,1], k = 3, t = 0
输出:true

示例 2:

输入:nums = [1,0,1,1], k = 1, t = 2
输出:true

示例 3:

输入:nums = [1,5,9,1,5,9], k = 2, t = 3
输出:false

提示:

  • 0 <= nums.length <= 2 * 104
  • -231 <= nums[i] <= 231 - 1
  • 0 <= k <= 104
  • 0 <= t <= 231 - 1

贰 ❀ 暴力解法

我们简单分析题意,给定一个数组以及两个整数k与t,判断是否存在两个不同的下标i,j,满足abs(nums[i] - nums[j]) <= t ,同时又满足 abs(i - j) <= k,存在返回true,反之返回false。

那么根据提议,我们完全可以根据题目要求的条件直接暴力解决:

/**
 * @param {number[]} nums
 * @param {number} k
 * @param {number} t
 * @return {boolean}
 */
var containsNearbyAlmostDuplicate = function (nums, k, t) {
    // i从0开始
    for (let i = 0; i < nums.length; i++) {
        // j永远从i后一位开始
        for (let j = i + 1; j < nums.length; j++) {
            // 直接抽取题目要求的条件
            if (Math.abs(nums[i] - nums[j]) <= t && Math.abs(i - j) <= k) {
                return true;
            };
        };
    };
    return false;
};

代码就不解释了,暴力解法非常简单。

贰 ❀ 桶排序

老实说,桶排序的做法比较难理解(当然也许是因为我太菜了),我在题解区逛了一圈,发现大部分题解说的基本都半知半解,看的我是非常难受和....也不知道是不是语言差异,部分思路在JS中也行不通,这里我也是读了好几篇题解,综合了一下他们的思想,整理下我最终的理解,其中思路来源主要来自C++ 利用桶分组, 详细解释

在我贴的参考思路题解中,举了一个我觉得十分恰当的例子,这里我简单重复下,假设有一批学生都是同一年不同月出生,老师要找出出生期相差在30天以内的学生,这里的30天可以理解为题目中的参数t,即两个学生出生日期的差小于等于30。那么我们大致能掌握这样一个规律:

  • 同一个月出生的学生一定满足条件,比如3月5号,3月15,都在一个月内总不能超出一个月的时间差吧。
  • 某个月相邻的前后两个月的学生可能满足条件,比如3月5号,2月15号就满足一个月内,但2月1号就不行了,超出了一个月,所以是可能满足。
  • 间隔超过2个月的学生绝对不可能满足条件,比如3月出生的学生和1月或者5月的学生都不可能满足条件。

那么知道了这个规则,我们要做的就是将这些学生按月份进行分配,3月出生的学生都在一起,4月的也都在一起,这一个个月份就像一个桶,我们将学生装进了桶里。相同桶里学生的日期差一定小于30(t),我们可以总是至多维护3个桶,这个3可以理解为题意中的参数k,因为规则2也说了,维护四个桶没意义,要找有没有符合条件的还是得从自己,活着相邻的桶里去看有没有符合规则的。

OK,上面的例子整体会比较抽象,但大致阐述了这么一个想法,你能大概明白是什么意思就足够了,那么我们现在要思考一个问题,我们怎么知道哪些学生应该放在某个桶呢?

这里我先给出一个公式,再论证它:

// x可以理解成学生的出生日期,t可以理解成我们定下的规则,也就是小于等于30天
let bucketNum = Math.floor(x / (t + 1))

现在开始论证,我们假设学生的年龄为[0,1,2,3,4,5,6],t是3,即数字之间相差小于等于3的应该放在一起。

Math.floor(0 / (3 + 1))//0
Math.floor(1 / (3 + 1))//0
Math.floor(2 / (3 + 1))//0
Math.floor(3 / (3 + 1))//0

Math.floor(4 / (3 + 1))//1
Math.floor(5 / (3 + 1))//1
Math.floor(6 / (3 + 1))//1

你会发现[0,1,2,3]四个数在这个公式中都等于0,说明它们四个应该放在一起,而且仔细推敲,这四个数的差的绝对值还真是小于等于3。但是4就不能加进去了,因为4-0>3[4,5,6]同理又被分配在了一个桶里。

我看一些题解给的公式是x / t然后又说要加1,但没具体说为什么要加个1,其实根据题意中0 <= t <= 231 - 1,假设t=0,万物除以0都是无限大,就无法区分了,而且站在JS的角度,如果我们不加1,你会发现[0,1,2]会被丢在一个桶,因为:

Math.floor(0 / 3)//0
Math.floor(1 / 3)//0
Math.floor(2 / 3)//0
Math.floor(3 / 3)//1

3根本就不会放进0号桶,这根本就满足不了条件了,所以公式是一定得加个1(这也是为什么我看一些人题解看的特别恼火的原因,写的不明不白,看的很无语)。

那么我们假设有数组[1,2,3,1]k=3t=0,即在这个数组中,是否存在两个数的下标差绝对值小于等于3,且两个数的差的绝对值小于等于0。我们尝试推导这个过程:

i=0时,取到数字1,由于Math.floor(1 / (0 + 1))为1,我们需要创建1号桶,并把0这个元素作为value存起来。

i=1时,取到数字2,由于Math.floor(2 / (0 + 1))为2,桶不同说明数字相差绝对超过2了,如果满足0,绝对在同一个桶,一样创建2号桶,把2存起来

继续,当i=2时,取到了数字3,由于Math.floor(3 / (0 + 1))为3,说明相差又超过0了,继续上面的操作。

i=3时,取到了数字1,由于Math.floor(3 / (0 + 1))为0,我们发现0号桶已经存在了,说明0号桶和当前的数字相差一定小于等于0,而且i此时为3,3并不比k大,说明下标没超过条件,满足最终返回true。

等等,这个例子好像过于理想,假设我们再多一点呢?比如[1,2,3,4,1],k和t不变:

前三步还是一样,创建了1,2,3,一共三个桶。

i=3时,由于Math.floor(4 / (0 + 1))为4,所以创建了4号桶,我们发现此时仍然没找到符合条件的数字,注意由于此时i>=k已经是下标差的最大极限了,在最大极限情况下还没找到满足条件,我们得删除掉一个桶。为什么?你想想,假设我们不删除,走到i=4时,由于Math.floor(1 / (0 + 1))为1,我们发现它和1=0时得到的是相同的桶,那么说明两个元素差的绝对值一定<=t,但是很遗憾i>k了,此时的i是4,所以就算桶相同,你的下标已经不符合了啊,那这个桶的比较又有什么意义呢?所以在i>=k的时候,此时你的桶还有参与比较的资格,但如果还不满足,那就得删除掉最早创建的桶,为下次比较做准备。

有同学就有疑问了,删掉了不会对后续的比较产生影响吗?我们假设[1,2,3,4,1,1],kt不变,前面几步还是相同,当i=3时不满足,比较完成我们把i=0创建的1号桶给删除了。当i=4其实又创建了1号桶,而当i=5我们还是找到了符合规则的数字,最终返回了true。

以上长篇大论,其实我们还只是推导了学生出生日期的规则1,即如果一个桶被重复创建两次(两个数字在一个桶和一个桶被创建两次是同一个意思),然后下标差还小于等于k,那么这两个数一定符合条件。

别忘了,我们还有规则2,也就是去相邻的桶里找。比如[3][4,5,6]k=3t=1,3和4虽然在不同的桶,但是它们缺满足下标差以及数字差的条件限制。

总结一下:

当创建一个桶,如果桶已存在(说明数字差小于等于t),且下标没超出k,那就返回true。

如果创建一个桶,桶不存在,那就创建好这个桶,记录好数字,同时看看左右相邻的桶里的数求差,看看能不能满足条件。注意,左右相邻的桶一定也只会有一个数字,为啥呢?因为如果左右相邻的桶存在2个数字,那就满足了上一条规则,已经返回true了...

如果以上都比较完了,这时候看看i跟k的关系,如果满足了i>=k,那你得删除最早创建的桶了。

那么,贴上代码:

/**
 * @param {number[]} nums
 * @param {number} k
 * @param {number} t
 * @return {boolean}
 */
var containsNearbyAlmostDuplicate = function (nums, k, t) {
    // 计算桶编号
    function getBucketNum(x) {
        return Math.floor(x / (t + 1));
    };
    // 创建一个大桶,里面用于存放一些小桶
    let buckets = new Map();
    for (let i = 0; i < nums.length; i++) {
        // m是当前遍历元素将要在的桶
        const bucket = getBucketNum(nums[i]);
        // 此时桶的数量一定是被维护好的,如果一个桶已经存在,说明一定满足条件。
        if (buckets.has(bucket)) {
            return true;
            // 比较右边的桶,看看差是否满足条件
        } else if (buckets.has(bucket + 1) && Math.abs(buckets.get(bucket + 1) - nums[i]) <= t) {
            return true;
            // 同理比较左边
        } else if (buckets.has(bucket - 1) && Math.abs(buckets.get(bucket - 1) - nums[i]) <= t) {
            return true;
        }
        // 保存这个桶,以及桶的数字
        buckets.set(bucket, nums[i]);
        // 如果i>=k,能走到这一步,满足条件的最后一个桶都比较完了,还不符合,那就得删除最早的桶,为下次比较做准备
        if (i >= k) {
            buckets.delete(getBucketNum(nums[i - k]));
        }
    }
    return false;
};

这道题桶排序的题解,我想想,前前后后大概整理加思考了差不多花了4个小时,确实很绕...如果有幸看到这篇题解,还是静下心来理一理,那么本文结束。

posted on 2021-04-21 00:34  听风是风  阅读(204)  评论(0编辑  收藏  举报