題目描述
給定一個整數數組,判斷數組中是否有兩個不同的索引i
和j
,使得nums[i]
和nums[j]
的差的絕對值最大為t
,并且i
和j
之間的差的絕對值最大為k。
示例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
題解源自官方:https://leetcode-cn.com/problems/contains-duplicate-iii/solution/cun-zai-zhong-fu-yuan-su-iii-by-leetcode/
方法一(線性搜索)【超時】
思路
將每個元素與它之前的 kk 個元素比較,查看它們的數值之差是不是在 t 以內。
public Boolean containsNearbyAlmostDuplicate(int[] nums, int k, int t){
for(int i=0; i<nums.length; ++i){
for(int j=Math.max(i-k, 0); j<i; ++j){
if(Math.abs(nums[i]-nums[j])<=t)
return true;
}
}
return false;
復雜度分析
- 時間復雜度:
O(nmin(k,n))
每次搜索都將花費O(min(k,n))
的時間,需要注意的是一次搜索中我們最多比較 n 次,哪怕 k 比 n 大。 - 空間復雜度:
O(1)
額外開辟的空間為常數個。
方法二 (二叉搜索樹)【通過】
思路
- 如果窗口中維護的元素是有序的,只需要用二分搜索檢查條件二是否是滿足的就可以了。
- 利用自平衡二叉搜索樹,可以在對數時間內通過
插入
和刪除
來對滑動窗口內元素排序。
算法
方法一真正的瓶頸在于檢查第二個條件是否滿足需要掃描滑動窗口中所有的元素。因此我們需要考慮的是有沒有比全掃描更好的方法。
如果窗口內的元素是有序的,那么用兩次二分搜索就可以找到 x+t 和 x?t 這兩個邊界值了。
然而不幸的是,窗口中的元素是無序的。這里有一個初學者非常容易犯的錯誤,那就是將滑動窗口維護成一個有序的數組。雖然在有序數組中搜索
只需要花費對數時間,但是為了讓數組保持有序,我們不得不做插入
和刪除
的操作,而這些操作是非常不高效的。想象一下,如果你有一個kk大小的有序數組,當你插入一個新元素x的時候。雖然可以在O(logk)
時間內找到這個元素應該插入的位置,但最后還是需要O(k)
的時間來將x
插入這個有序數組。因為必須得把當前元素應該插入的位置之后的所有元素往后移一位。當你要刪除一個元素的時候也是同樣的道理。在刪除了下標為i
的元素之后,還需要把下標i
之后的所有元素往前移一位。因此,這種做法并不會比方法一更好。
為了能讓算法的效率得到真正的提升,我們需要引入一個支持插入
,搜索
,刪除
操作的動態
數據結構,那就是自平衡二叉搜索樹
。自平衡
這個詞的意思是,這個樹在隨機進行插入,刪除操作之后,它會自動保證樹的高度最小。為什么一棵樹需要自平衡呢?這是因為在二叉搜索樹上的大部分操作需要花費的時間跟這顆樹的高度直接相關。可以看一下下面這棵嚴重左偏的非平衡二叉搜索樹。
在上面這棵二叉搜索樹上查找一個元素需要花費 線性 時間復雜度,這跟在鏈表中搜索的速度是一樣的。現在我們來比較一下下面這棵平衡二叉搜索樹。
假設這棵樹上節點總數為
n
,一個平衡樹能把高度維持在h=logn
。因此這棵樹上支持在O(h)=O(logn)
時間內完成插入
,搜索
,刪除
操作。下面給出整個算法的偽代碼:
- 初始化一棵空的二叉搜索樹set
- 對于每個元素x,遍歷整個數組
-- 在set
上查找大于等于x
的最小的數,如果s-x≤t
則返回true
-- 在set
上查找小于等于x
的最大的數,如果x?g≤t
則返回true
-- 在set
中插入x
-- 如果樹的大小超過了k
, 則移除最早加入樹的那個數。 - 返回
false
我們把大于等于x
的最小的數s
當做x
在 BST 中的后繼節點。同樣的,我們能把小于等于x
最大的數g
當做x
在 BST 中的前繼節點。s
和g
這兩個數是距離x
最近的數。因此只需要檢查它們和x
的距離就能知道條件二是否滿足了。
public Boolean containsNearbyAlmostDuplicate(int[] nums, int k, int t){
TreeSet<Integer> set = new TreeSet<>();
for(int i=0; i<nums.length; ++i){
//Find the successor of current element
Integer s = set.ceiling(nums[i]);
if(s != null && s <= nums[i]+t)
return true;
//Find the predecessor of current element
Integer g = set.floor(nums[i]);
if(g != null && nums[i] <= g + t)
return true;
set.add(nums[i]);
if (set.size() > k){
set.remove(nums[i-k]);
}
}
return false;
}
復雜度分析
- 時間復雜度:
O(nlog(min(n,k)))
我們需要遍歷這個n
長度的數組。對于每次遍歷,在 BST 中 搜索,插入
或者刪除
都需要花費O(logmin(k,n))
的時間。 - 空間復雜度:
O(min(n,k))
空間復雜度由 BST 的大小決定,其大小的上限由k
和n
共同決定。
筆記
當數組中的元素非常大的時候,進行數學運算可能造成溢出。所以可以考慮使用支持大數的數據類型,例如 long。
C++ 中的std::set
,std::set::upper_bound
和std::set::lower_bound
分別等價于 Java 中的TreeSet
,TreeSet::ceiling
和TreeSet::floor
。Python 標準庫不提供自平衡 BST。
方法三(桶)【通過】
思路
受桶排序
的啟發,我們可以把桶當做窗口來實現一個線性復雜度的解法。
算法
桶排序是一種把元素分散到不同桶中的排序算法。接著把每個桶再獨立地用不同的排序算法進行排序。
回到這個問題,我們嘗試去解決的最大的問題在于:
1.對于給定的元素x, 在窗口中是否有存在區間 [x?t,x+t] 內的元素?
2.我們能在常量時間內完成以上判斷嘛?
我們不妨把把每個元素當做一個人的生日來考慮一下吧。假設你是班上新來的一位學生,你的生日在 三月 的某一天,你想知道班上是否有人生日跟你生日在t=30
天以內。在這里我們先假設每個月都是30天,很明顯,我們只需要檢查所有生日在 二月,三月,四月 的同學就可以了。
之所以能這么做的原因在于,我們知道每個人的生日都屬于一個桶,我們把這個桶稱作月份!每個桶所包含的區間范圍都是t
,這能極大的簡化我們的問題。很顯然,任何不在同一個桶或相鄰桶的兩個元素之間的距離一定是大于t
的。
我們把上面提到的桶的思想應用到這個問題里面來,我們設計一些桶,讓他們分別包含區間...,[0,t],[t+1,2t+1],...。我們把桶來當做窗口,于是每次我們只需要檢查x
所屬的那個桶和相鄰桶中的元素就可以了。終于,我們可以在常量時間解決在窗口中搜索的問題了。
還有一件值得注意的事,這個問題和桶排序的不同之處在于每次我們的桶里只需要包含最多一個元素就可以了,因為如果任意一個桶中包含了兩個元素,那么這也就是意味著這兩個元素是 足夠接近的 了,這時候我們就直接得到答案了。因此,我們只需使用一個標簽為桶序號的散列表就可以了。
public class Solution{
// Get the ID of the bucket from element value x and bucket width w
// In Java, `-3 / 5 = 0` and but we need `-3 / 5 = -1`.
private long getID(long x, long w){
return x < 0 ? (x + 1) / w - 1 : x / w; // w, 每個桶包含的范圍
}
public boolean containsNearbyAlmostDuplicate(int [] nums, int k, int t){
if(t<0)
return false;
Map<Long, Long> d = new HashMap<>();
long w = (long) t + 1;
for(int i=0; i<nums.length; ++i){
long m = getID(nums[i], w);
// check if bucket m is empty, each bucket may contain at most one element
if(d.containsKey(m))
return true;
// check the nei***or buckets for almost duplicate
if (d.containsKey(m - 1) && Math.abs(nums[i] - d.get(m - 1)) < w)
return true;
if (d.containsKey(m + 1) && Math.abs(nums[i] - d.get(m + 1)) < w)
return true;
// now bucket m is empty and no almost duplicate in nei***or buckets
d.put(m, (long)nums[i]);
if (i >= k)
d.remove(getID(nums[i - k], w));
}
return false;
}
}
復雜度分析
- 時間復雜度:O(n)
對于這 n 個元素中的任意一個元素來說,我們最多只需要在散列表中做三次 搜索,一次 插入 和一次 刪除。這些操作是常量時間復雜度的。因此,整個算法的時間復雜度為 O(n)。 - 空間復雜度:-O(min(n,k))
需要開辟的額外空間取決了散列表的大小,其大小跟它所包含的元素數量成線性關系。散列表的大小的上限同時由 n 和 k 決定。因此,空間復雜度為 O(min(n,k))。