LeetCode 举办的 LeetCoding Challenge,悄悄然来到了九月的征程,每日一道经典老题供大家练习。今天就来和大家分享其中一道:找出重复元素 III。
英文版地址:
https://leetcode.com/problems/contains-duplicate-iii/
中文版地址:
https://leetcode-cn.com/problems/contains-duplicate-iii/
题意
给定一个整数数组 nums,判断里面是否存在两个元素,它们的下标相差不超过 k,它们的值相差不超过 t:
- 存在 i,j,满足
- abs(i - j) <= k
- abs(nums[i] - nums[j]) <= t
Two pointers + 树状数组的 O(nlogn) 算法
题目需要我们满足两个限制条件,一个是值,一个是下标,值的范围是无规律的,但下标有范围:当有 n 个元素时,下标范围是 [0, n-1]。
首先我们对数组按升序排序,然后维护一个滑动窗口,左右端点下标分别为 L,R。窗口右端不断右移,并始终保证 nums[R] - nums[L] <= t。
当 nums[R] - nums[L] > t 时,说明窗口过大,开始将 L 右移,直到重新满足 nums[R] - nums[L] <= t。
至此,关于滑动窗口的部分就完成了,我们能保证在滑动窗口内的任意两个元素 A,B 总是满足 abs(A - B) <= t。
由于数组经过排序,因此原本的下标被打乱了,一个滑动窗口内的任意两个元素的下标 x,y 并不一定满足 abs(x - y) <= k。
但我们可以把窗口右端点 R 的右移,看作是添加一个元素,把窗口左端点 L 的右移,看作是删除一个元素。
对此,我们可以开辟一个长度为 n 的 0/1 数组,当 a[i] = 0 时表示原数组下标为 i 的元素不在滑动窗口中,当 a[i] = 1 时表示原数组下标为 i 的元素在滑动窗口中。
当窗口右端点 R 右移,将一个元素添加到滑动窗口,设该元素在原数组中的下标为 x,同时我们将 a[x] 设置为 1。
那么,我们只需要查找 a 数组中 [x-k,x+k] 的元素中是否存在另一个 1(不包括 a[x] 本身)。即存在下标 y,满足 x-k <= y <= x+k,y != x, a[y] = 1, 那么就说明 nums[x] 和 nums[y] 此时都在滑动窗口中,肯定满足 abs(nums[x] - nums[y]) <= t, 同时下标满足 abs(x - y) <= k,这正是我们要找的元素对。
同理,当窗口左端点 L 右移,将一个元素移除出滑动窗口,设该元素在原数组中的下标为 x',同时我们将 a[x'] 设置为 0。
问题最终转化为,不断修改数组 a 中的元素值,同时对于一个下标 idx,要查询 a[idx-k, idx+k] 内是否存在两个元素为 1,这可以借助树状数组做到。
const int N = 20005;int c[N];inline int lowbit(int p) { return p & (-p);}void update(int p, int n, int v) { while (p <= n) { c[p] += v; p += lowbit(p); }}int query(int p) { int sum = 0; while (p) { sum += c[p]; p -= lowbit(p); } return sum;}bool check(int p, int n, int k) { int l = max(0, p - k); int r = min(n, p + k); int cnt = query(r); if (l > 1) { cnt -= query(l-1); } return cnt >= 2;}class Solution {public: bool containsNearbyAlmostDuplicate(vector<int>& nums, int k, int t) { int n = nums.size(); if (n == 0 || k <= 0 || t < 0) return false; memset(c, 0, sizeof c); using pii = pair<int, int>; vector data; for (int i = 0; i < n; ++i) { data.push_back(make_pair(nums[i], i)); } sort(data.begin(), data.end()); int l = 0, r = 0; while (true) { while(r < n && 0LL + data[r].first - data[l].first <= t + 0LL) { update(data[r].second+1, n, 1); if (check(data[r].second+1, n, k)) return true; r++; } if (r >= n) break; while(l <= r && 0LL + data[r].first - data[l].first > 0LL + t) { update(data[l].second+1, n, -1); l++; } if (l >= n) break; } return false; }};
基于数学性质的 O(n) 算法
除了上述算法外,我们还能利用一个数学性质,更高效解决它。另外在这个算法中,我们还是需要用到滑动窗口的思想。
不需要对原数组排序,维护一个滑动窗口,窗口的右端点 R 不断右移,并始终保证 R - L <= k,即窗口大小始终不超过 k;当窗口过大时则右移左端点 L。
这样能保证,窗口中的任意两个元素,它们的下标满足 abs(x - y) <= k。
另外,对于数组中每个元素,我们计算 b[i] = floor(nums[i] / t),表示将 nums[i] 丢入 b[i] 这个桶中。
结合滑动窗口,每次右端点 R 右移,我们就将一个新元素丢入它归属的桶中,左端点 L 右移,则将一个旧元素从桶中剔除。
显然,当 nums[i] 和 nums[j] 都归属于同一个桶时,它们必定满足 abs(nums[i] - nums[j]) <= t。换句话说,当一个桶有两个或以上元素时,就找到了答案。
不仅同一个桶的两个元素满足这个限制条件,两个左右相邻的桶也可能满足,例如 x=7,y=4,t=3:
- bx = floor(7/3) = 2
- by = floor(4/3) = 1
bx 和 by 是相邻的桶,它们的值满足 7-4 <= 3。
因为对于任意一个元素 x,假设它归属于 b 桶,我们看 b-1 桶,b 桶,b+1 桶中的元素是否和 x 的差值小于等于 t,如果满足,就找到了答案。
class Solution {public: void add(int x, int t, map<long long, int>& buckets) { if (!t) buckets[x] = x; else { int b = floor(1.0 * x / t); buckets[b] = x; } } void remove(int x, int t, map<long long, int>& buckets) { if (!t) buckets.erase(x); else { int b = floor(1.0 * x / t); buckets.erase(b); } } bool containsNearbyAlmostDuplicate(vector<int>& nums, int k, int t) { int n = nums.size(); if (n == 0 || k <= 0 || t < 0) return false; int l = 0, r = 0; map<long long, int> buckets; while (r < n) { if (t == 0) { if (buckets.count(nums[r])) return true; } else { int b = floor(1.0 * nums[r] / t); if (buckets.count(b) || (buckets.count(1LL * b-1) && abs(1LL * buckets[b-1] - nums[r]) <= 1LL * t) || (buckets.count(1LL * b+1) && abs(1LL * buckets[b+1] - nums[r]) <= 1LL * t)) { return true; } } add(nums[r], t, buckets); r++; if (r - l > k) { remove(nums[l], t, buckets); l++; } } return false; }};