LeetCode 周赛上分之旅 #39 结合中心扩展的单调栈贪心问题

news/2024/11/18 3:49:03/

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

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

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

周赛 358

T1. 数组中的最大数对和(Easy)

  • 标签:数学、分桶

T2. 翻倍以链表形式表示的数字(Medium)

  • 标签:链表

T3. 限制条件下元素之间的最小绝对差(Medium)

  • 标签:双指针、平衡树

T4. 操作使得分最大(Hard)

  • 标签:贪心、排序、中心扩展、单调栈、快速幂


T1. 数组中的最大数对和(Easy)

https://leetcode.cn/problems/max-pair-sum-in-an-array/

题解一(分桶 + 数学)

  • 枚举每个元素,并根据其最大数位分桶;
  • 枚举每个分桶,计算最大数对和。
class Solution {
public:int maxSum(vector<int>& nums) {int U = 10;// 分桶vector<int> buckets[U];for (auto& e: nums) {int x = e;int m = 0;while (x > 0) {m = max(m, x % 10);x /= 10;}buckets[m].push_back(e);}// 配对int ret = -1;for (int k = 0; k < U; k++) {if (buckets[k].size() < 2) continue;sort(buckets[k].rbegin(), buckets[k].rend());ret = max(ret, buckets[k][0] + buckets[k][1]);}return ret;}
};

复杂度分析:

  • 时间复杂度: O ( n l g n ) O(nlgn) O(nlgn) 瓶颈在排序,最坏情况下所有元素进入同一个分桶;
  • 空间复杂度: O ( n ) O(n) O(n) 分桶空间;

题解二(一次遍历优化)

  • 最大数对和一定是分桶中的最大两个数,我们只需要维护每个分桶的最大值,并在将新元素尝试加入分桶尝试更新结果。
class Solution {
public:int maxSum(vector<int>& nums) {int U = 10;int ret = -1;int buckets[U];memset(buckets, -1, sizeof(buckets));for (auto& e: nums) {int x = e;int m = 0;while (x > 0) {m = max(m, x % 10);x /= 10;}if (-1 != buckets[m]) {ret = max(ret, buckets[m] + e);}buckets[m] = max(buckets[m], e);}return ret;}
};

复杂度分析:

  • 时间复杂度: O ( n ) O(n) O(n) 线性遍历;
  • 空间复杂度: O ( U ) O(U) O(U) 分桶空间。

T2. 翻倍以链表形式表示的数字(Medium)

https://leetcode.cn/problems/double-a-number-represented-as-a-linked-list/

题解一(模拟)

面试类型题,有 O ( 1 ) O(1) O(1) 空间复杂度的写法:

  • 先反转链表,再依次顺序翻倍,最后再反转回来;
  • 需要注意最后剩余一个进位的情况需要补足节点。
class Solution {fun doubleIt(head: ListNode?): ListNode? {// 反转val p = reverse(head)// 翻倍var cur = pvar append = 0while (cur != null) {append += cur.`val` * 2cur.`val` = append % 10append = append / 10cur = cur.next}// 反转if (0 == append) return reverse(p)return ListNode(append).apply {next = reverse(p)}}fun reverse(head: ListNode?): ListNode? {var p: ListNode? = nullvar q = headwhile (null != q) {val next = q.nextq.next = pp = qq = next}return p}
}

复杂度分析:

  • 时间复杂度: O ( n ) O(n) O(n) 反转与翻倍是线性时间复杂度;
  • 空间复杂度: O ( 1 ) O(1) O(1) 仅使用常量级别空间。

题解二(一次遍历优化)

我们发现进位只发生在元素值大于 4 的情况,我们可以提前观察当前节点的后继节点的元素值是否大于 4,如果是则增加进位 1。特别地,当首个元素大于 4 时需要补足节点。

class Solution {fun doubleIt(head: ListNode?): ListNode? {if (head == null) return null// 补足val newHead = if (head.`val` > 4) {ListNode(0).also { it.next = head}} else {head}// 翻倍var cur: ListNode? = newHeadwhile (null != cur) {cur.`val` *= 2if ((cur?.next?.`val` ?: 0) > 4) cur.`val` += 1cur.`val` %= 10cur = cur.next}return newHead}
}

复杂度分析:

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

相似题目:

  • 445. 两数相加 II

T3. 限制条件下元素之间的最小绝对差(Medium)

https://leetcode.cn/problems/minimum-absolute-difference-between-elements-with-constraint/

题解(双指针 + 平衡树 )

  • 滑动窗口的变型题,常规的滑动窗口是限定在窗口大小在 x 内,而这道题是排除到窗口外。万变不离其宗,还得是双指针。
  • 其次,为了让元素配对的差值绝对值尽可能小,应该使用与其元素值相近最大和最小的两个数,可以用平衡树在 O(lgn) 时间复杂度内求得,整体时间复杂度是 O(ngln);
class Solution {fun minAbsoluteDifference(nums: List<Int>, x: Int): Int {if (x == 0) return 0 // 特判var ret = Integer.MAX_VALUEval n = nums.sizeval set = TreeSet<Int>()for (i in x until n) {// 滑动set.add(nums[i - x])val q = set.floor(nums[i])val p = set.ceiling(nums[i])if (p != null) ret = Math.min(ret, Math.abs(p - nums[i]))if (q != null) ret = Math.min(ret, Math.abs(nums[i] - q))}return ret }
}

复杂度分析:

  • 时间复杂度: O ( m l g m ) O(mlgm) O(mlgm) 其中 m = n - x,内层循环二分搜索的时间复杂度是 O ( l g m ) O(lgm) O(lgm)
  • 空间复杂度: O ( m ) O(m) O(m) 平衡树空间。

T4. 操作使得分最大(Hard)

https://leetcode.cn/problems/apply-operations-to-maximize-score/

题解一(贪心 + 排序 + 中心扩展 + 单调栈 + 快速幂)

这道题难度不算高,但使用到的技巧还挺综合的。

  • 阅读理解: 可以得出影响结果 3 点关键信息,我们的目标是选择 k 个子数组,让其中质数分数最大的元素 nums[i] 尽量大:

    • 1、元素大小
    • 2、元素的质数分数
    • 3、左边元素的优先级更高
  • 预处理: 先预处理数据范围内每个数的质数分数,避免在多个测试用例间重复计算;

  • 质因数分解: 求解元素的质数分数需要质因数分解,有两种写法:

    • 暴力写法,时间复杂度 O ( n ⋅ n ) O(n·\sqrt{n}) O(nn )

      val scores = IntArray(U + 1)
      for (e in 1 .. U) {var cnt = 0var x = evar prime = 2while (prime * prime <= x) {if (x % prime == 0) {cnt ++while (x % prime == 0) x /= prime // 消除相同因子}prime++}if (x > 1) cnt ++ // 剩余的质因子scores[e] = cnt
      }
      
    • 基于质数筛写法,时间复杂度 O(n):

      val scores = IntArray(U + 1)
      for (i in 2 .. U) {if (scores[i] != 0) continue // 合数for (j in i .. U step i) {scores[j] += 1}
      }
      
  • 排序: 根据关键信息 「1、元素大小」 可知,我们趋向于选择包含较大元素值的子数组,且仅包含数组元素最大值的子数组是子数组分数的上界;

  • 中心扩展: 我们先对所有元素降序排序,依次枚举子数组,计算该元素对结果的贡献,直到该元素无法构造更多子数组。以位置 i 为中心向左右扩展,计算左右两边可以记入子数组的元素个数 leftCnt 和 rightCnt。另外,根据 「左边元素的优先级更高」 的元素,向左边扩展时不能包含质数分数相同的位置,向右边扩展时可以包含;

  • 乘法原理: 包含元素 nums[i] 的子数组个数满足乘法法则(leftCnt * rightCnt);

  • 单调栈: 在中心扩展时,我们相当于在求 「下一个更大值」元素,这是典型的 单调栈问题,可以在 O ( n ) O(n) O(n) 时间复杂度内求得所有元素的下一个更大值;

    val stack = ArrayDeque<Int>()
    for (i in 0 until n) {while (!stack.isEmpty() && nums[stack.peek()] < nums[i]) {stack.pop()}stack.push(i)
    }
    
  • 快速幂: 三种写法:

    • 暴力写法,时间复杂度 O(n),由于题目 k 比较大会超出时间限制:

      fun pow(x: Int, n: Int, mod: Int): Int {var ret = 1Lrepeat (n){ret = (ret * x) % mod}return ret.toInt()
      }
      
    • 分治写法,时间复杂度是 O(lgn):

      fun pow(x: Int, n: Int, mod: Int): Int {if (n == 1) return xval subRet = pow(x, n / 2, mod)return if (n % 2 == 1) {1L * subRet * subRet % mod * x % mod} else {1L * subRet * subRet % mod}.toInt()
      }
      
    • 快速幂写法,时间复杂度 O©:

      private fun quickPow(x: Int, n: Int, mod: Int): Int {var ret = 1Lvar cur = nvar k = x.toLong()while (cur > 0) {if (cur % 2 == 1) ret = ret * k % modk = k * k % modcur /= 2}return ret.toInt()
      }
      

组合以上技巧:

class Solution {companion object {private val MOD = 1000000007private val U = 100000private val scores = IntArray(U + 1)init {// 质数筛for (i in 2 .. U) {if (scores[i] != 0) continue // 合数for (j in i .. U step i) {scores[j] += 1}}}}fun maximumScore(nums: List<Int>, k: Int): Int {val n = nums.size// 贡献(子数组数)val gains1 = IntArray(n) { n - it }val gains2 = IntArray(n) { it + 1}// 下一个更大的分数(单调栈,从栈底到栈顶单调递减)val stack = ArrayDeque<Int>()for (i in 0 until n) {while (!stack.isEmpty() && scores[nums[stack.peek()]] < scores[nums[i]]) {val j = stack.pop()gains1[j] = i - j}stack.push(i)}// 上一个更大元素(单调栈,从栈底到栈顶单调递减)stack.clear()for (i in n - 1 downTo 0) {while(!stack.isEmpty() && scores[nums[stack.peek()]] <= scores[nums[i]]) { // <=val j = stack.pop()gains2[j] = j - i}stack.push(i)}// 按元素值降序val ids = Array<Int>(n) { it }Arrays.sort(ids) { i1, i2 ->nums[i2] - nums[i1]}// 枚举每个元素的贡献度var leftK = kvar ret = 1Lfor (id in ids.indices) {val gain = Math.min(gains1[ids[id]] * gains2[ids[id]], leftK)ret = (ret * quickPow(nums[ids[id]], gain, MOD)) % MODleftK -= gainif (leftK == 0) break}return ret.toInt()}// 快速幂private fun quickPow(x: Int, n: Int, mod: Int): Int {var ret = 1Lvar cur = nvar k = x.toLong()while (cur > 0) {if (cur % 2 == 1) ret = ret * k % modk = k * k % modcur /= 2}return ret.toInt()}
}

复杂度分析:

  • 时间复杂度: O ( n l g n ) O(nlgn) O(nlgn) 其中预处理时间为 O ( U ) O(U) O(U),单次测试用例中使用单调栈计算下一个更大质数分数的时间为 O ( n ) O(n) O(n),排序时间为 O ( n l g n ) O(nlgn) O(nlgn),枚举贡献度时间为 O ( n ) O(n) O(n),整体瓶颈在排序;
  • 空间复杂度: O ( n ) O(n) O(n) 预处理空间为 O ( U ) O(U) O(U),单次测试用例中占用 O ( n ) O(n) O(n) 空间。

题解二(一次遍历优化)

在计算下一个更大元素时,在使用 while 维护单调栈性质后,此时栈顶即为当前元素的前一个更大元素:

while (!stack.isEmpty() && nums[stack.peek()] < nums[i]) {stack.pop()
}
// 此时栈顶即为当前元素的前一个更大元素
stack.push(i)

因此我们可以直接在一次遍历中同时计算出前一个更大元素和下一个更大元素:

val right = IntArray(n) { n } // 下一个更大元素的位置
val left = IntArray(n) { -1 } // 上一个更大元素的位置

计算贡献度的方法: ( i − l e f t [ i ] ) ∗ ( r i g h t [ i ] − i ) (i - left[i]) * (right[i] - i) (ileft[i])(right[i]i),其中 l e f t [ i ] left[i] left[i] r i g h t [ i ] right[i] right[i] 位置不包含在子数组中。

class Solution {...fun maximumScore(nums: List<Int>, k: Int): Int {val n = nums.size// 贡献(子数组数)val right = IntArray(n) { n } // 下一个更大元素的位置val left = IntArray(n) { -1 } // 上一个更大元素的位置// 下一个更大的分数(单调栈,从栈底到栈顶单调递减)val stack = ArrayDeque<Int>()for (i in 0 until n) {while (!stack.isEmpty() && scores[nums[stack.peek()]] < scores[nums[i]]) {right[stack.pop()] = i // 下一个更大元素的位置}if (!stack.isEmpty()) left[i] = stack.peek() // 上一个更大元素的位置stack.push(i)}// 按元素值降序val ids = Array<Int>(n) { it }Arrays.sort(ids) { i1, i2 ->nums[i2] - nums[i1]}// 枚举每个元素的贡献度val gains = IntArray(n) { (it - left[it]) * (right[it] - it)}var leftK = kvar ret = 1Lfor (id in ids.indices) {val gain = Math.min(gains[ids[id]], leftK)ret = (ret * quickPow(nums[ids[id]], gain, MOD)) % MODleftK -= gainif (leftK == 0) break}return ret.toInt()}...
}

复杂度分析:

  • 同上

相似题目:

  • 907. 子数组的最小值之和
  • 1856. 子数组最小乘积的最大值
  • 2104. 子数组范围和
  • 2281. 巫师的总力量和

推荐阅读

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

  • LeetCode 单周赛第 358 场 · 结合排序不等式的动态规划
  • LeetCode 单周赛第 357 场 · 多源 BFS 与连通性问题
  • LeetCode 双周赛第 109 场 · 按部就班地解决动态规划问题
  • LeetCode 双周赛第 107 场 · 很有意思的 T2 题

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


http://www.ppmy.cn/news/1028491.html

相关文章

基于grpc从零开始搭建一个准生产分布式应用(3) - GRPC实现

本章开始会进入GRPC子专题&#xff0c;先实现前面章节中提到的例子。然后就使用的知识点展开全面的描述。本章代码任务&#xff1a;1、实现一个简单的GRPC服务&#xff1b;2、实现GRPC拦截器。 本章的代码承接上一章的代码进行迭代。因模块间存在相互依赖关系&#xff0c;读者一…

MySQL表的增删查改(基础)

目录 一&#xff0c;新增数据 &#xff08;1&#xff09;全列插入 &#xff08;2&#xff09;指定列插入 &#xff08;3&#xff09;一次性插入多条数据 二&#xff0c;查询数据 &#xff08;1&#xff09;全列查询 &#xff08;2&#xff09;指定列查询 &#xff08;3&…

接口自动化测试-Requests模块实战详解,一篇打通...

目录&#xff1a;导读 前言一、Python编程入门到精通二、接口自动化项目实战三、Web自动化项目实战四、App自动化项目实战五、一线大厂简历六、测试开发DevOps体系七、常用自动化测试工具八、JMeter性能测试九、总结&#xff08;尾部小惊喜&#xff09; 前言 什么是requests&a…

SpringBoot入职学习

一、前言 公司入职&#xff0c;第一个事是把公司项目运行起来。然后在经过几天的颠沛流离&#xff0c;遇到一个事情。在创建yml文件的时候&#xff0c;需要设置自己的配置文件。当然还是先跑起来项目&#xff0c;就使用别人的yml文件。但是&#xff0c;到springboot配置那里卡…

传统图像算法 - 运动目标检测之KNN运动背景分割算法

以下代码用OpenCV实现了视频中背景消除和提取的建模&#xff0c;涉及到KNN&#xff08;K近邻算法&#xff09;&#xff0c;整体效果比较好&#xff0c;可以用来进行运动状态分析。 原理如下&#xff1a; 背景建模&#xff1a;在背景分割的开始阶段&#xff0c;建立背景模型。 …

【教女朋友 从 0 到 1 学编程系列】三、2048 前端游戏实战

目录 程序思想基本样式实现JavaScript 游戏脚本课后作业从本章节起,内容将首发于 CSDN 付费专栏。同时,视频教程也在筹备中。 程序思想 自定义游戏规则: 自适应全屏,4x4 格子操作只能:上下左右 4 个动作空白处(非碰撞)随机出现一个 2 或 4操作一个方向进行相同数字合并…

ElementUI动态添加表单项

昨天感冒发烧了&#xff0c;脑子不好使。在实现这个动态表单项时一直报错脑瓜子嗡嗡的&#xff01; 不过好在昨天休息好了&#xff0c;今天起来趁脑瓜子好使&#xff0c;一会就弄好了。 这里记录一下 <el-form-itemv-for"(classId,index) in addFom.classIds":lab…

【数据结构与算法——TypeScript】树结构Tree

【数据结构与算法——TypeScript】 树结构(Tree) 认识树结构以及特性 什么是树? &#x1f332; 真实的树&#xff1a;相信每个人对现实生活中的树都会非常熟悉 &#x1f332; 我们来看一下树有什么特点&#xff1f; ▫️ 树通常有一个根。连接着根的是树干。 ▫️ 树干到…