[排序 Set] 217. 219. 220 存在重复元素 I II III(哈希表、Set模拟滑动窗口、TreeSet、桶排序)
217. 存在重复元素
题目链接:https://leetcode-cn.com/problems/contains-duplicate/(简单题)
分类:
- 数组(排序 + 遍历)
- 哈希表
题目分析
这题是本篇博客介绍的三个题目中最简单的一题,没有做任何限制,只需要寻找重复元素,所以方法有:
- 思路1:对数组先排序,后遍历;
- 思路2:使用哈希表存放已出现过的元素。
思路1:排序 + 遍历
先将数组排序,相同数字就会排在一起,遍历一次数组即可。
- 注意:记得增加特殊用例 的判断
class Solution {
public boolean containsDuplicate(int[] nums) {
//特殊用例的判断
if(nums.length < 2) return false;
Arrays.sort(nums);
int pre = nums[0];
for(int i = 1; i < nums.length; i++){
if(pre == nums[i]) return true;
else pre = nums[i];
}
return false;
}
}
- 时间复杂度:O(NlogN)
- 空间复杂度:O(logN))
思路2:Set
使用一个哈希set保存每一个出现过的数字,如果遍历到一个set里存在的数字,说明遇到重复元素。
class Solution {
public boolean containsDuplicate(int[] nums) {
//特殊用例
if(nums.length < 2) return false;
Set<Integer> set = new HashSet<>();
for(int i = 0; i < nums.length; i++){
if(set.contains(nums[i])) return true;
set.add(nums[i]);
}
return false;
}
}
- 时间复杂度:O(N)
- 空间复杂度:O(N)
219. 存在重复元素 II
题目链接:https://leetcode-cn.com/problems/contains-duplicate-ii/
分类:
- 哈希表
- set模拟滑动窗口
题目分析
和217题相比,这题要查找的不仅是值相等的元素,还进一步要求这两个元素的下标差值的绝对值要 <= k,这相当于堵死了先排序后查找的思路,而哈希表的思路依然是可用的。
除此之外,思路2介绍了一种利用set来模拟滑动窗口的方法,更巧妙,还可以应用到220题上。
思路1:哈希表
使用一个哈希表,key=数值,value=下标,遍历数组时,未在map中出现的数字就加入map,并记录它的下标,在map中出现的数字就取出对应的value下标,计算两者的差值的绝对值:
- 如果 <= k,则返回true;
- 如果 > k,则把这个value更新为当前元素的下标,继续遍历数组,重复上面的工作。
class Solution {
public boolean containsNearbyDuplicate(int[] nums, int k) {
if(nums.length < 2 || k <= 0) return false;
Map<Integer, Integer> map = new HashMap<>();
for(int i = 0; i < nums.length; i++){
if(map.containsKey(nums[i])){
int index = map.get(nums[i]);
if(i - index <= k) return true;
}
map.put(nums[i], i);
}
return false;
}
}
思路2:利用set构建滑动窗口(思路更巧妙)
开辟一个set,根据题目要求,让set存放的元素个数最多只能有k + 1个,如果set存放的元素超过k + 1个,就将当前set中最早加入的元素删除。
这相当于用set实现一个滑动窗口,窗口内存放的是数组中的k + 1个连续元素,这样如果两个值相等的元素还包含在同一个窗口内,就能保证两个元素的下标差值的绝对值一定是<=k。
算法流程为:当前待处理元素为nums[i],
- 如果set存在当前值和nums[i]相等的元素,就认为找到一个下标差值 <= k的重复元素,返回true;
- 如果set不存在该元素,就先判断set的元素个数,如果元素个数==k,就将最早加入的元素删除,然后该元素再加入,如果元素个数 < k,则直接加入即可。
如何找出当前set里最早加入的元素?
元素加入set的顺序就是按数组的排列顺序,设当前处理的元素是nums[i],则此时set滑动窗口中最早加入的元素就是nums[i - k - 1],所以可以指定删除set.remove(nums[i - k - 1])来删除最早加入的元素。
class Solution {
public boolean containsNearbyDuplicate(int[] nums, int k) {
if(nums.length < 2 || k <= 0) return false;
Set<Integer> set = new HashSet<>();
for(int i = 0; i < nums.length; i++){
if(set.contains(nums[i])) return true;
if(set.size() == k) set.remove(nums[i - k]);
set.add(nums[i]);
}
return false;
}
}
220. 存在重复元素 III
题目链接:https://leetcode-cn.com/problems/contains-duplicate-iii/
分类:
- 平衡二叉搜索树帮助查找(TreeSet)
- 排序(HashMap实现桶排序)
- 哈希表模拟滑动窗口
题目分析
本题在219 要求|i - j| <= k 的基础上,又增加了一个条件要求|nums[i] - nums[j]| <= t,其中第一个条件仍然可以用滑动窗口来解决,第二个条件的实现和优化是本题的考点。
思路1:暴力解
遍历数组,对于每个元素i,都枚举下标为[i+1 ~ i + k]中的每个元素,寻找是否存在与nums[i]差值 <= t的元素。
因为对元素 i 枚举的是 [i+1 ~ i + k] 的元素,所以一定满足条件1。
实现遇到的问题:字面量溢出问题
计算差值时可能出现字面量超过int范围,所以两个元素相减之前先分别转成long型:
long diff = Math.abs(((long)nums[i] - (long)nums[j]));
实现代码:
class Solution {
public boolean containsNearbyAlmostDuplicate(int[] nums, int k, int t) {
//特殊用例
if(nums.length < 2 || k < 0 || t < 0) return false;
for(int i = 0; i < nums.length; i++){
for(int j = i + 1; j < nums.length && j <= i + k; j++){
long diff = Math.abs(((long)nums[i] - (long)nums[j]));
if(diff <= (long)t){
return true;
}
}
}
return false;
}
}
-
时间复杂度:O(N*K)
-
存在的问题:用例超时。
思路2:TreeSet + 另一种角度寻找|差值|<=t的元素 (推荐)
思路1的性能消耗大部分在于 在窗口里寻找与nums[j]差值<=t的元素,思路1是暴力枚举,时间复杂度为O(K)。要比这个效率高,那只能是二分查找了。但二分查找需要建立的有序数组的基础上,所以问题转化为如何对窗口内的元素进行排序。
-
方法1(不可取):如果直接在数组上对窗口内的元素进行排序,窗口滑动时需要删除左边界元素,加入右边界元素,元素的加入有可能会导致整个数组的元素都需要移位,这样虽然查询时只需要O(logK),但元素的变动可能会带来O(K)的时间消耗,得不偿失。
-
方法2:利用平衡二叉搜索树,把窗口内的元素拷贝存放在一棵二叉搜索树中,这样元素的插入和删除、查找都只需要O(logK)。注意:构造的二叉搜索树要平衡,否则性能就会下降为O(K)。
其中,java提供了TreeSet,里面存放的数据是有序的,默认是自然顺序。
如何用set模拟滑动窗口的效果?
滑动窗口的大小为k+1,所以set存放的元素数量最多不能超过k+1,所以在新元素加入之前,先判断set存放的元素个数,如果set.size()>k,说明滑动窗口已满,需要先把最早加入的元素移除,设当前待处理元素为nums[i],则此时窗口内最早加入的元素是nums[i-k-1],需要将其移除:
set.remove(nums[i-k-1])。
如何使用TreeSet在滑动窗口中寻找差值的绝对值<=t的元素?( 另一种角度寻找|差值|<=t的元素)
这里使用了和思路1完全不一样的角度,设当前要处理的元素是x,TreeSet中存放了前k+1个元素。
TreeSet提供了ceiling函数,可以返回set中 >= 指定数值的最小元素,我们可以寻找set中大于等于 x - t 中最小元素,只要这个元素<=x+t,就说明找到一个元素满足:和x的差值的绝对值 <= t,返回true。
否则就将该元素加入set中。
实现遇到的问题:TreeSet存放的是包装类
TreeSet这类集合存放的元素都是对象,如果存放的是基本数据类型,也会自动装箱成对应的包装类,所以在调用ceiling寻找set中的目标元素时,虽然传入的参数是基本数据类型,但返回值要用对应的包装类来接收,否则会导致nullpointer错误,
-
ceiling如果找不到满足条件的元素就会返回null。
-
包装类和基本数据类型比较时会自动拆箱。
class Solution {
public boolean containsNearbyAlmostDuplicate(int[] nums, int k, int t) {
//特殊用例
if(nums.length < 2 || k < 0 || t < 0) return false;
//使用TreeSet提供的平衡二叉搜索树结构
TreeSet<Long> treeSet = new TreeSet<>();
for(int i = 0; i < nums.length; i++){
//如果当前set元素个数>k,就将最左端元素从set中移除
if(i > k){
treeSet.remove((long)nums[i - k - 1]);
}
Long minCeil = treeSet.ceiling((long)nums[i] - t);
if(minCeil != null && minCeil <= (long)nums[i] + t) return true;
treeSet.add((long)nums[i]);
}
return false;
}
}
- 时间复杂度:O(NlogK)
- 空间复杂度:O(K)
思路3:桶排序(推荐)
桶排序的思想也可以用来在滑动窗口中寻找满足第二个条件的元素。题目要求对于元素nums[i],要在滑动窗口中找到|nums[i] - nums[j]| <= t,我们设置每个桶存放的t+1个元素,每个桶都有一个序号,例如:
桶0:[0,t],桶1:[t+1,2t+1],桶2:[2t+2,3t+2]
每个元素根据元素值大小进桶,这样做的好处是,桶内任意两个数之间的差一定是小于等于 t 的,所以一个元素nums[i]要进桶之前要先做判断:
-
如果桶内已经有元素,说明找到一个满足条件的元素,返回true。
-
如果桶内没有元素,则判断和这个桶相邻的左右两个桶内是否存在和nums[i]差值<=t的元素,如果有则返回true,如果都没有则将该元素加入对应的桶中。
举个例子:
例如:k=3,t=2,有桶0:[0,2],桶1:[3,5],桶2:[6,8], nums=[1,7,5],
1,7分别加入桶0,桶2, 5要加入的是桶1,
但5加入之前,桶1内没有元素,所以判断相邻桶里是否存在差值<=t的元素,相邻的桶是桶0和桶2.
桶0里的|1-5|=4>t,桶2里的|7-5|=2<=t,所以5和7就是满足题目要求的两个元素,可以返回true.
用什么数据结构做桶?
由上面的算法设计可知,每个桶最多只需存放一个元素,且不需要预先把所有可能的桶都开辟出来,所以使用map做桶,key=桶序号,value存放元素。
如何根据元素值得到对应的桶序号?(难点)
例如:t=2,则有桶0:[0,2],桶1:[3,5],桶2:[6,8],可以发现桶序号是逢3进一,所以当元素值>=0时,桶序号=元素值/(t+1);
但是如果元素值是负数,则有所不同:(易忽略)
因为元素可能为负数,所以也完全可能存在桶序号为负的情况,例如:t=2,桶-1:[-3,-1],桶-2:[-6,-4],桶-3:[-9,-7],桶序号=(元素值+1)/(t+1) - 1, 元素值+1是为了把负元素值的范围设置成从0开始,外部再-1是因为桶序号0已经被占用了,所以存放负元素的桶序号从-1开始。
所以可以设置一个函数getId()用于根据元素值返回桶序号:
//根据元素值获取桶序号
public long getId(long value, long t){
if(value >= 0) return value / (t + 1);
else return (value + 1) / (t + 1) - 1;
}
如何用map模拟滑动窗口的效果?
前面的算法设计只满足题目要求的第二个条件,但还需要保证差值<=t的两个元素的下标差值<=k,可以设置一个滑动窗口,窗口内最多存放k+1个元素,当元素数量==k+1时,如果再有新的元素加入,就将当前窗口内最早加入的元素移除,这样就能保证窗口内元素下标和当前处理的元素下标差值始终<=k。
我们用map作为桶,map存放的元素个数 == 窗口存放的元素个数,所以当map.size() == k+1时,每新加入一个元素,就将最早加入的元素移除,设当前新加入的元素下标为i,则map内最早加入的元素下标就是i-k-1,将该元素移除。
- 注意:map中要移除的不是nums[i-k-1],而是nums[i-k-1]对应的桶序号map.remove(getId(nums[i-k-1])。(易错点))
- 可能存在int溢出问题,所以把map存放的元素类型设置为Long。
实现代码:
class Solution {
public boolean containsNearbyAlmostDuplicate(int[] nums, int k, int t) {
//特殊用例
if(nums.length < 2 || k < 0 || t < 0) return false;
//使用map作为桶
Map<Long, Long> map = new HashMap<>();
//枚举数组每个元素
for(int i = 0; i < nums.length; i++){
//如果map所模拟的滑动窗口大小==k+1,则先弹出最早加入到元素
if(map.size() > k){
map.remove(getId(nums[i - k - 1], t));
}
long idx = getId(nums[i], t);
//如果对应桶里有元素存在,则说明找到满足条件的元素
if(map.containsKey(idx)) return true;
//如果相邻桶里有元素存在,且存在的元素和nums[i]的差值<=t,说明找到满足条件的元素
if(map.containsKey(idx - 1) && Math.abs(map.get(idx - 1) - nums[i]) <= t) return true;
if(map.containsKey(idx + 1) && Math.abs(map.get(idx + 1) - nums[i]) <= t) return true;
//上述条件都不满足,则将当前元素加入对应桶中
map.put(idx, (long)nums[i]);
}
return false;
}
//根据元素值获取桶序号
public long getId(long value, long t){
if(value >= 0) return value / (t + 1);
else return (value + 1) / (t + 1) - 1;
}
}