LeetCode 双周赛 108(2023/07/08)渐入佳境

703 阅读4分钟

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

学习数据结构与算法的关键在于掌握问题背后的算法思维框架,你的思考越抽象,它能覆盖的问题域就越广,理解难度也更复杂。在这个专栏里,小彭与你分享每场 LeetCode 周赛的解题报告,一起体会上分之旅。

本文是 LeetCode 上分之旅系列的第 31 篇文章,往期回顾请移步到文章末尾~

T1. 最长交替子序列(Easy)

  • 标签:模拟、同向双指针

T2. 重新放置石块(Medium)

  • 标签:模拟、散列表

T3. 将字符串分割为最少的美丽子字符串(Medium)

  • 标签:记忆化递归、动态规划

T4. 黑格子的数目(Medium)

  • 标签:枚举、贡献


T1. 最长交替子序列(Easy)

https://leetcode.cn/problems/longest-alternating-subarray/

题解一(模拟)

这道题与上周周赛 T1 还是比较相似的。

使用两层循环,枚举从每个元素 nums[i] 为起点开始的最长交替子序列长度。

class Solution {
    fun alternatingSubarray(nums: IntArray): Int {
        var ret = -1
        for (i in 0 until nums.size) {
            var target = 1
            for (j in i + 1 until nums.size) {
                if (nums[j] - nums[j - 1] != target) break
                ret = Math.max(ret, j - i + 1)
                target *= -1
            }
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度:O(n2)O(n^2) 其中 n 为 nums 数组的长度;
  • 空间复杂度:仅使用常量级别空间。

题解二(同向双指针)

这个解法基于 KMP 思想。

在题解一中,我们会重复计算同一段交替子序列的,我们可以使用一次遍历,再交替子序列终止时避免重复回退到该子序列内部。需要注意的是,由于不同的交替子序列可能存在 1 位重叠,所以要把 i 指针指向 j 指针,而不是指向 j 指针的下一位,才能保证没有缺失。例如 [3,4,3,4,5,4,5] 数组,第一组交替子数组为 [3,4,3,4] 和第二组交替子数组为 [4,5,4,5] 这两组有重叠部分。

class Solution {
    fun alternatingSubarray(nums: IntArray): Int {
        val n = nums.size
        var ret = -1
        var i = 0
        while (i < n - 1) {
            // 寻找起点
            while (i < n - 1 && nums[i + 1] - nums[i] != 1) {
                i++
            }
            var target = 1
            var j = i
            while (j < n - 1 && nums[j + 1] - nums[j] == target)  {
                ret = Math.max(ret, ++j - i + 1)
                target *= -1
            }
            i = j
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度:O(n)O(n) 线性遍历
  • 空间复杂度:O(1)O(1) 仅使用常量级别空间。

T2. 重新放置石块(Medium)

https://leetcode.cn/problems/relocate-marbles/

题解(模拟 + 散列表)

在每部操作中,我们会将位置 moveFrom[i] 上所有的石头移动到 moveTo[i] 上,「所有」的含义意味着石头的数量是无关紧要的,我们可以使用散列表维护剩余的石头,最后对剩余石头排序。

class Solution {
    fun relocateMarbles(nums: IntArray, moveFrom: IntArray, moveTo: IntArray): List<Int> {
        if (moveFrom.size != moveTo.size) return Collections.emptyList()
        val set = nums.toHashSet()
        for (i in moveFrom.indices) {
            set.remove(moveFrom[i])
            set.add(moveTo[i])
        }
        return set.toMutableList().sorted()
    }
}

复杂度分析:

  • 时间复杂度:O(nlgn)O(nlgn) 瓶颈在排序上;
  • 空间复杂度:O(n)O(n) 散列表空间。

T3. 将字符串分割为最少的美丽子字符串(Medium)

https://leetcode.cn/problems/partition-string-into-minimum-beautiful-substrings/

题解一(记忆化递归)

比较直观的子集问题,我们枚举所有分割点(可以构造 5 的幂)的位置并记录最短结果。由于题目的数据范围比较小,我们可以预处理出数据范围内所有 5 的幂。

  • 定义 backTrack(i) 表示从 [i] 为起点的最少美丽字符串个数,枚举以 [i] 为起点的所有可行方案,从中得出最优解。
class Solution {
    
    companion object {
        // 预处理
        private val U = 15
        private val INF = Integer.MAX_VALUE
        private val set = HashSet<Int>()
        init {
            var x = 1
            while (x.toString(2).length <= U) {
                set.add(x)
                x *= 5
            }
        }
    }
    
    fun minimumBeautifulSubstrings(s: String): Int {
        return backTrack(s, HashMap<Int,Int>(), 0)
    }
    
    private fun backTrack(s: String, memo: MutableMap<Int, Int>, i: Int): Int {
        // 终止条件
        if (i == s.length) return 0
        // 剪枝(不允许前导零)
        if (s[i] == '0') return -1
        // 读备忘录
        if (memo.contains(i)) return memo[i]!!
        // 枚举
        var x = 0
        var ret = INF
        for (j in i until s.length) {
            x = x.shl(1) + (s[j] - '0')
            if (set.contains(x)) {
                // 递归
                val childRet = backTrack(s, memo, j + 1)
                if (-1 != childRet) ret = Math.min(ret, childRet)
            }
        }
        val finalRet = if (INF == ret) -1 else ret + 1
        memo[i] = finalRet
        return finalRet
    }
}

复杂度分析:

  • 时间复杂度:O(n2)O(n^2) 一共 n 个分割点,每个分割点有「选和不选」两种方案,看起来总共有 2n2^n 种子状态,其实并没有。我们的 backTrack(i) 的定义是以 [i] 为起点可以构造的最少美丽字符串数,因此总共只有 n 种状态,而每种状态需要检查 O(n)O(n) 种子状态,因此整体时间复杂度是 O(n2)O(n^2)
  • 空间复杂度:O(n)O(n) 备忘录空间。

题解二(动态规划)

可以把记忆化递归翻译为动态规划的版本:

class Solution {
    
    companion object {
        // 预处理
        private val U = 15
        private val INF = Integer.MAX_VALUE
        private val set = HashSet<Int>()
        init {
            var x = 1
            while (x.toString(2).length <= U) {
                set.add(x)
                x *= 5
            }
        }
    }
    
    fun minimumBeautifulSubstrings(s: String): Int {
        val INF = 0x3F3F3F3F // 便于判断
        val n = s.length
        val dp = IntArray(n + 1) { INF }
        dp[n] = 0
        // 倒序遍历(先求小问题)
        for (i in n - 1 downTo 0) {
            // 不允许前导零
            if (s[i] == '0') continue
            // 枚举
            var x = 0
            for (j in i until n) {
                x = x.shl(1) + (s[j] - '0')
                if (set.contains(x)) dp[i] = Math.min(dp[i], dp[j + 1] + 1)
            }
        }
        return if (dp[0] != INF) dp[0] else -1
    }
}

复杂度分析:

  • 时间复杂度:O(n2)O(n^2) 同上;
  • 空间复杂度:O(n)O(n) DP 数组空间。

T4. 黑格子的数目(Medium)

https://leetcode.cn/problems/number-of-black-blocks/

题解(枚举黑格 + 贡献度)

直接枚举所有块的时间复杂度是 O(nm) 会超时,我们发现真正影响结果的是黑格格子,但是暴力枚举块的方法会枚举到那些完全是白色的块。

因此,我们将枚举维度从所有块调整到黑色格子附近的块,对于每一个黑色格子 [x, y] 最多仅会对 4 个块产生影响(贡献)。所以我们的算法是:枚举所有黑色格子,并记录黑色格子可以产生贡献的块,最后统计出所有可以被影响到的块以及的贡献度,这可以用散列表来记录。

剩下一个问题是怎么表示一个唯一的块,我们可以规定块中 4 个点中的其中一个点作为块的代表元(以右下角的点为例),然后将该点的行和列压缩到一个 Long 变量中来唯一标识不同的块。


class Solution {
    fun countBlackBlocks(m: Int, n: Int, coordinates: Array<IntArray>): LongArray {
        val U = 100000
        val map = HashMap<Long, Int>()
        // 以右下角为代表元的块
        val blocks = arrayOf(intArrayOf(0,0), intArrayOf(0, 1), intArrayOf(1,1), intArrayOf(1,0))
        for (e in coordinates) {
            // 枚举 4 个块
            for (block in blocks) {
                val x = e[0] + block[0]
                val y = e[1] + block[1]
                // 检查块有效性
                if (x >= 1 && x < m && y >= 1 && y < n) {
                    // 记录贡献度
                    val key = 1L * x * U + y
                    map[key] = map.getOrDefault(key, 0) + 1
                }
            }
        }
        val ret = LongArray(5)
        for ((_, cnt) in map) {  
            ret[cnt] ++
        }
        ret[0] = 1L * (n - 1) * (m - 1) - map.size
        return ret
    }
}

复杂度分析:

  • 时间复杂度:O(m)O(m) 其中 m 为黑格格子数
  • 空间复杂度:O(m)O(m) 其中 m 为黑格格子数

推荐阅读

LeetCode 上分之旅系列往期回顾:

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~