4

LeetCode 周赛 352(2023/07/02)一场关于子数组的专题周赛 - 彭旭锐

 1 year ago
source link: https://www.cnblogs.com/pengxurui/p/17527241.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

单周赛 352 概览

T1. 最长奇偶子数组(Easy)

  • 标签:滑动窗口、枚举

T2. 和等于目标值的质数对(Medium)

  • 标签:质数筛、散列表、数学

T3. 不间断子数组(Medium)

  • 标签:滑动窗口、平衡树、单调队列

T4. 所有子数组中不平衡数字之和(Hard)

  • 标签:平衡树、散列表、前后缀分解、乘法原理

T1. 最长奇偶子数组(Easy)

https://leetcode.cn/problems/longest-even-odd-subarray-with-threshold/

题解一(滑动窗口 + 枚举子数组)

容易想到的方法是枚举每个位置开始的子数组,并计算最长奇偶子数组长度,可以得到时间复杂度为 O(n^2) 的解法。

class Solution {
    fun longestAlternatingSubarray(nums: IntArray, threshold: Int): Int {
        var i = 0
        var j = 0
        val n = nums.size
        var ret = 0
        while (j < n) {
            while (i < n && (nums[i] % 2 != 0 || nums[i] > threshold)) i++
            if (i >= n) break
            j = i + 1
            while (j < n && (nums[j] % 2 != nums[j - 1] % 2 && nums[j] <= threshold)) j++
            ret = Math.max(ret, j - i)
            i ++
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度:$O(n^2)$ 最坏情况整个数组都是奇偶子数组;
  • 空间复杂度:$O(1)$ 仅使用常量级别空间。

题解二(枚举分组)

实际上,数组被分割为若干个满足奇偶子数组的片段,最长奇偶子数组不会被其他更长的奇偶子数组所包含。因此,我们不需要枚举所有位置开始的子数组,而是枚举所有片段,修改仅在于于 ++j 修改为 i = j 而已。

class Solution {
    fun longestAlternatingSubarray(nums: IntArray, threshold: Int): Int {
        var i = 0
        var j = 0
        val n = nums.size
        var ret = 0
        while (j < n) {
            while (i < n && (nums[i] % 2 != 0 || nums[i] > threshold)) i++
            if (i >= n) break
            j = i + 1
            while (j < n && (nums[j] % 2 != nums[j - 1] % 2 && nums[j] <= threshold)) j++
            ret = Math.max(ret, j - i)
            i = j // 唯一修改
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度:$O(n)$ i 指针和 j 指针最多移动 n 次;
  • 空间复杂度:$O(1)$ 仅使用常量级别空间。

T2. 和等于目标值的质数对(Medium)

https://leetcode.cn/problems/prime-pairs-with-target-sum/

题解一(线性筛 + 散列表)

先预处理出数据范围内所有质数,再使用两数之和寻找匹配项。

class Solution {
    companion object {
        private val U = 1000000
        private val primes = generatePrime(U)
        private val primeSet = primes.toHashSet()

        private fun generatePrime(n : Int): LinkedList<Int> {
            // 线性筛
            val primes = LinkedList<Int>()
            val isPrime = BooleanArray(n + 1) { true }
            for (e in 2..n) {
                if (isPrime[e]) {
                    primes.add(e)
                }
                // 标记
                for (prime in primes) {
                    if (prime * e >= n) break
                    isPrime[prime * e] = false
                    if (e % prime == 0) break // 保证被最小的质因子标记
                }
            }
            return primes
        }
    }

    fun findPrimePairs(n: Int): List<List<Int>> {
        val ret = LinkedList<List<Int>>()
        for (x in primes) {
            val y = n - x
            // 去重
            if (y < x) break
            if (primeSet.contains(y)) ret.add(listOf(x, y))   
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度:预处理时间 $O(U)$,每次查询时间为 $O(n)$;
  • 空间复杂度:预处理空间 $O(U)$,每次查询空间为 $O(1)$,不考虑结果数组。

题解二(奇数优化)

根据奇偶数性质,如果 n 为奇数,那么当且仅当 偶数 + 奇数 = 奇数,而在所有质因子中,仅存在唯一的偶数 2。因此,当 n 为奇数时,只需要判断 n - 2 是否为质因子即可,且仅存在唯一的匹配。

class Solution {
    companion object {
        // 预处理 ...
    }

    fun findPrimePairs(n: Int): List<List<Int>> {
        val ret = LinkedList<List<Int>>()
        if (n % 2 == 1) {
            if (primeSet.contains(n - 2)) ret.add(listOf(2, n - 2))
            return ret
        }
        for (x in primes) {
            val y = n - x
            // 去重
            if (y < x) break
            if (primeSet.contains(y)) ret.add(listOf(x, y))   
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度:预处理时间 $O(U)$,每次查询时间为 $O(n)$;
  • 空间复杂度:预处理空间 $O(U)$,每次查询空间为 $O(1)$,不考虑结果数组。

T3. 不间断子数组(Medium)

https://leetcode.cn/problems/continuous-subarrays/

题解一(滑动窗口 + 暴力 · 超出时间限制)

这道题与 1438. 绝对差不超过限制的最长连续子数组 是几乎相同的,区别在于本题固定绝对差至多为 2,且目标结果是方案数而不是最长不间断子数组。

与本周赛 T1 类似,我们使用滑动窗口并维持窗口内的数据特征,从而计算满足条件的子数组方案数。同时我们发现,每个以 nums[i] 为结尾的最长不间断子数组 [i, j],都能提供 j - i + 1 个方案,因此我们只需要求出每段连续的不间断子数组,再累加结果。

class Solution {
    fun continuousSubarrays(nums: IntArray): Long {
        var i = 0
        var ret = 0L
        for (j in nums.indices) {
            // 收缩左指针
            for (k in i until j) {
                if (Math.abs(nums[k] - nums[j]) > 2) i = k + 1
            }
            ret += j - i + 1
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度:$O(n^2)$ 最坏情况下在整个数组都是不间断数组时,时间复杂度是 $O(n^2)$;
  • 空间复杂度:$O(1)$ 仅使用常量级别空间。

题解二(滑动窗口 + 平衡树)

题解一中每次移动右指针,都需要枚举窗口元素检查是否满足绝对差至多为 2,最坏情况下时间复杂度是 O(n^2)。为优化时间复杂度,我们使用有序集合,每次仅需要检查集合中的最小值与 nums[j] 的大小关系:

class Solution {
    fun continuousSubarrays(nums: IntArray): Long {
        var i = 0
        var ret = 0L
        val tree = TreeMap<Int, Int>()
        for (j in nums.indices) {
            // 收缩左指针
            while (!tree.isEmpty() && (nums[j] - tree.firstKey() > 2 || tree.lastKey() - nums[j] > 2)) {
                tree[nums[i]] = tree[nums[i]]!! - 1
                if (0 == tree[nums[i]]!!) tree.remove(nums[i])
                i++
            }
            tree[nums[j]] = tree.getOrDefault(nums[j], 0) + 1
            ret += j - i + 1
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度:$O(n)$ 每个元素最多入队一次,维护有序集合排序的时间复杂度是 $O(nlgn)$,由于绝对差至多为 2,有序集合中最多仅会存储 3 个键值对,排序时间降低为常数,因此时间复杂度是 $O(n)$;
  • 空间复杂度:$O(1)$ 有序集合空间,实际占用空间为常量级别空间。

题解三(滑动窗口 + 双堆)

同理,我们使用双堆也可以实现平衡树相同的功能。

class Solution {
    fun continuousSubarrays(nums: IntArray): Long {
        var ret = 0L
        var i = 0
        val maxHeap = PriorityQueue<Int>() { i1, i2 ->
            nums[i2] - nums[i1]
        }
        val minHeap = PriorityQueue<Int>() { i1, i2 ->
            nums[i1] - nums[i2]
        }
        for (j in nums.indices) {
            // 收缩左指针
            while (!maxHeap.isEmpty() && nums[maxHeap.peek()] - nums[j] > 2) {
                maxHeap.remove(i)
                minHeap.remove(i)
                i++
            }
            while (!minHeap.isEmpty() && nums[j] - nums[minHeap.peek()] > 2) {
                maxHeap.remove(i)
                minHeap.remove(i)
                i++
            }
            maxHeap.offer(j)
            minHeap.offer(j)
            ret += maxHeap.size
            // ret += j - i + 1
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度:$O(n)$ 每个元素最多入堆两次,维护堆排序的时间复杂度是 $O(nlgn)$,由于绝对差至多为 2,堆中最多仅会存储 3 个元素,排序时间降低为常数,因此时间复杂度是 O(n);
  • 空间复杂度:$O(1)$ 双堆空间,实际占用空间为常量级别空间。

题解四(滑动窗口 + 单调队列)

求滑动窗口的极值问题有单调队列的经验解。

在有序集合的解法中,忽略了滑动窗口中元素的顺序关系:当元素 nums[i] 后方出现出现更大的元素时,那么 nums[i] 不可能对滑动窗口的 x - nums[j] 的结果有贡献;同理,当 nums[i] 后方出现更小的元素时,那么 nums[i] 不可能对滑动窗口的 nums[i] - x 的结果有贡献。

对结果没有贡献的元素,应该提前弹出数据结构(在平衡树和堆的解法中,会保留在数据结构中,从而拉低时间复杂度)。

class Solution {
    fun continuousSubarrays(nums: IntArray): Long {
        var ret = 0L
        var i = 0
        // 从队头到队尾递减(维护滑动窗口的最大值)
        val maxQueue = ArrayDeque<Int>()
        // 从队头到队尾递增(维护滑动窗口的最小值)
        val minQueue = ArrayDeque<Int>()
        for (j in nums.indices) {
            // 维护单调性
            while (!maxQueue.isEmpty() && maxQueue.peekLast() < nums[j]) maxQueue.pollLast()
            while (!minQueue.isEmpty() && minQueue.peekLast() > nums[j]) minQueue.pollLast()
            maxQueue.offer(nums[j])
            minQueue.offer(nums[j])
            // 维护滑动窗口极值
            while (maxQueue.peekFirst() - minQueue.peekFirst() > 2) {
                if (nums[i] == maxQueue.peekFirst()) maxQueue.pollFirst()
                if (nums[i] == minQueue.peekFirst()) minQueue.pollFirst()
                i++
            }
            ret += j - i + 1
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度:$O(n)$ 在每次检查仅需要检查队尾元素,每个元素最多出队和出队两次,这是严格 $O(n)$ 的解法;
  • 空间复杂度:$O(1)$ 单调队列空间,实际占用空间为常量级别空间。

T4. 所有子数组中不平衡数字之和(Hard)

https://leetcode.cn/problems/sum-of-imbalance-numbers-of-all-subarrays/

题解一(枚举子数组 + 平衡树)

题目的不平衡度表示子数组排序后与前驱元素的差值大于 1 的个数(长度为 k 的子数组的最大不平衡度为 k - 1),最直接的做法是先排序再计数。我们可以维护子数组的有序集合,并维护插入前后的不平衡度:

  • 如果在有序集合的首部或尾部插入,则直接调整插入后的平衡度;
  • 如果在有序集合的中间插入,则需要减去插入前贡献的不平衡度,再增加插入后贡献的不平衡度:
class Solution {
    fun sumImbalanceNumbers(nums: IntArray): Int {
        var ret = 0
        for (i in 0 until nums.size) {
            var cnt = 0
            val tree = TreeSet<Int>()
            for (j in i until nums.size) {
                val pivot = nums[j]
                val lower = tree.floor(pivot) // 小于等于
                val higher = tree.ceiling(pivot) // 大于等于
                if (null != lower && null != higher && higher - lower > 1) cnt--
                if (null != lower && pivot - lower > 1) cnt++
                if (null != higher && higher - pivot > 1) cnt ++
                tree.add(pivot)
                // 子数组结果
                ret += cnt
            }
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度:$O(n·nlgn)$ 外层循环枚举 n 次,有序集合排序时间为 $O(nlgn)$;
  • 空间复杂度:$O(n)$ 有序集合空间。

题解二(枚举子数组 + 散列表)

由于我们并不需要得到排序后的数组,而是检查每个元素与前驱的关系,因此对于每个元素 nums[i],我们只需要检查 nums[i] + 1 和 nums[i] - 1 是否存在。

枚举子数组元素 i,并维护不平衡度 cnt:

  • 如果 nums[i] 已经存在,那么增加 nums[i] 对平衡度没有影响;
  • 如果 nums[i] 不存在,那么可能构造一个不平衡度,再观察 nums[i] + 1 和 nums[i] - 1 是否出现过来扣除不平衡度。
class Solution {
    fun sumImbalanceNumbers(nums: IntArray): Int {
        var ret = 0
        for (i in 0 until nums.size) {
            var cnt = 0
            val set = HashSet<Int>()
            for (j in i until nums.size) {
                val x = nums[j]
                // 维护不平衡度
                if (!set.contains(x)) {
                    cnt++
                    if (set.contains(x + 1)) cnt--
                    if (set.contains(x - 1)) cnt--
                    set.add(nums[j])
                }
                // 子数组结果
                ret += cnt - 1 // 减 1 是因为最后一个元素不会构造不平衡度
            }
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度:$O(n^2)$ 外层循环枚举 n 次,内层循环是线性时间;
  • 空间复杂度:$O(n)$ 散列表空间。

题解三(中心扩展 + 前后缀分解 + 乘法原理)

思路参考:https://leetcode.cn/problems/sum-of-imbalance-numbers-of-all-subarrays/solutions/2327213/onde-by-dengyun-yyl3/

好棒的思维!

使用逆向思维,我们考虑每个元素 nums[i] 能够贡献的不平衡度,以 nums[i] 为中心点向左右扩展直到遇到最近的 nums[i] - 1,使用乘法原理可以计算出 nums[i] 对多少个子数组产生贡献度。

需要考虑到,如果 nums[i] 是作为子数组的最小值时,是不会产生贡献度的,所以我们要把这部分子数组减去。然而,在使用乘法原理时我们无法方便地知道 nums[i] 在子数组中排序的位置,也就无法知道应该减去多少无效子数组。使用整体思维,我们先忽略无效子数组,同时发现每个子数组中都会存在一个最小值,因此整体来看无效子数组的个数就是子数组的个数,即 N*(N+1)/2;

同时,为了优化时间复杂度,我们可以在第一次线性遍历中预处理出以 nums[i] 开始的后缀中最近的 nums[i] - 1 的位置。在第二次线性遍历中求出以 nums[i] 为中点的前缀中的最近 nums[i] - 1 的位置。

最后还有一个细节,考虑到存在重复数的测试用例 [2,3,1,4,3],排序后 [1,2,3,3,4] 中只有最左边的 3 会贡献不平衡度。为了避免重复计算,我们规定排序后最左边的 3 来自于当前子数组中最右边的 3,因此在预处理后缀数组时,我们要使用 Math.min(ids[nums[i]], ids[nums[i] - 1]) 来中断遍历。

class Solution {
    fun sumImbalanceNumbers(nums: IntArray): Int {
        val n = nums.size
        // 前缀数组和后缀数组
        // ids:记录每个元素最近出现位置
        var ids = IntArray(n + 1) { n }
        val prefix = IntArray(n + 1) { -1 }
        val suffix = IntArray(n + 1) { n }
        // 预处理后缀数组
        for (i in n - 1 downTo 0) {
            suffix[i] = Math.min(ids[nums[i]], ids[nums[i] - 1])
            ids[nums[i]] = i
        }
        // 预处理前缀数组
        ids = IntArray(n + 1) { -1 }
        for (i in 0 until n) {
            prefix[i] = ids[nums[i] - 1]
            ids[nums[i]] = i
        }
        // 乘法原理
        var ret = 0
        for (i in 0 until n) {
            ret += (i - prefix[i]) * (suffix[i] - i)
        }
        return ret - n * (n + 1) / 2
    }
}

在计算前缀数组时累加结果:

class Solution {
    fun sumImbalanceNumbers(nums: IntArray): Int {
        val n = nums.size
        // 前缀数组和后缀数组
        // ids:记录每个元素最近出现位置
        var ids = IntArray(n + 1) { n }
        var prefix = -1
        val suffix = IntArray(n + 1) { n }
        // 预处理后缀数组
        for (i in n - 1 downTo 0) {
            suffix[i] = Math.min(ids[nums[i]], ids[nums[i] - 1])
            ids[nums[i]] = i
        }
        // 预处理前缀数组 + 乘法原理
        var ret = 0
        ids = IntArray(n + 1) { -1 }
        for (i in 0 until n) {
            prefix = ids[nums[i] - 1]
            ids[nums[i]] = i
            ret += (i - prefix) * (suffix[i] - i)
        }
        return ret - n * (n + 1) / 2
    }
}

复杂度分析:

  • 时间复杂度:$O(n)$ 两次线性遍历;
  • 空间复杂度:$O(n)$ 前后缀数组空间。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK