学习数据结构与算法的关键在于掌握问题背后的算法思维框架,你的思考越抽象,它能覆盖的问题域就越广,理解难度也更复杂。在这个专栏里,小彭与你分享每场 LeetCode 周赛的解题报告,一起体会上分之旅。
本文是 LeetCode 上分之旅系列的第 38 篇文章,往期回顾请移步到文章末尾~
双周赛 110
T1. 取整购买后的账户余额(Easy)
- 标签:模拟
T2. 在链表中插入最大公约数(Medium)
- 标签:链表、数学
T3. 使循环数组所有元素相等的最少秒数(Medium)
- 标签:贪心、散列表
T4. 使数组和小于等于 x 的最少时间(Hard)
- 标签:排序不等式、动态规划、贪心
T1. 取整购买后的账户余额(Easy)
https://leetcode.cn/problems/insert-greatest-common-divisors-in-linked-list/
题解(模拟)
阅读理解题。
其实就是将 purchaseAmount 向最近的 10 的倍数四舍五入,再用 100 减去它。
class Solution {
fun accountBalanceAfterPurchase(purchaseAmount: Int): Int {
return 100 - (purchaseAmount + 5) / 10 * 10
}
}
复杂度分析:
- 时间复杂度:O(1)O(1)O(1)
- 空间复杂度:O(1)O(1)O(1)
T2. 在链表中插入最大公约数(Medium)
https://leetcode.cn/problems/insert-greatest-common-divisors-in-linked-list/
题解(数学)
久违的链表题。
题目相对简单,其实就是依次处理每两个节点,并插入一个新的最大公约数节点。以下提供两个写法:
- 构造新链表:
class Solution {
fun insertGreatestCommonDivisors(head: ListNode?): ListNode? {
val dummy = ListNode(-1)
var rear = dummy
var p = head
while (null != p) {
rear.next = p
rear = p
val next = p.next
if (null != next) {
val newNode = ListNode(gcb(p.`val`, next.`val`))
newNode.next = next
p.next = newNode
rear.next = newNode
rear = newNode
}
p = next
}
return dummy.next
}
private fun gcb(a:Int, b:Int) :Int {
var x = a
var y = b
while (y != 0) {
val temp = x % y
x = y
y = temp
}
return x
}
}
- 在原链表上插入:
class Solution {
fun insertGreatestCommonDivisors(head: ListNode?): ListNode? {
var p = head
while (null != p?.next) {
val next = p.next
val newNode = ListNode(gcb(p.`val`, next.`val`))
newNode.next = next
p.next = newNode
p = next
}
return head
}
private fun gcb(a:Int, b:Int) :Int {
var x = a
var y = b
while (y != 0) {
val temp = x % y
x = y
y = temp
}
return x
}
}
复杂度分析:
- 时间复杂度:O(nlgU)O(nlgU)O(nlgU) 其中单次最大公约数的计算时间复杂度为 O(lgU)O(lgU)O(lgU),U 为数值上界;
- 空间复杂度:O(1)O(1)O(1) 不考虑输出空间。
T3. 使循环数组所有元素相等的最少秒数(Medium)
https://leetcode.cn/problems/minimum-seconds-to-equalize-a-circular-array/
题解(贪心 + 散列表)
根据题目要求,我们可以通过将数字复制到相邻位置上,以实现数组中所有元素都相等。因此,如果我们选择数字 x 为最终元素,那么决定替换秒数的关键在与数组中不等于 x 的最长子数组长度。
所以,我们的算法是计算以每种数字 x 为目标的方案中,最短的不等于 x 的最长子数组长度,并除以 2 向上取整的到结果。
class Solution {
fun minimumSeconds(nums: List<Int>): Int {
// 最大间隔的最小值
val n = nums.size
// lens:记录每种数字的最长间隔
val lens = HashMap<Int, Int>()
// preIndexs:记录每种数字的上次出现位置
val preIndexs = HashMap<Int, Int>()
// 记录最后出现位置(环形数组逻辑)
for ((i, e) in nums.withIndex()) {
preIndexs[e] = i
}
for ((i, e) in nums.withIndex()) {
lens[e] = Math.max(lens.getOrDefault(e, 0), (i - preIndexs[e]!! - 1 + n) % n)
preIndexs[e] = i
}
var ret = n
for ((_, len) in lens) {
ret = Math.min(ret, (len + 1) / 2)
}
return ret
}
}
复杂度分析:
- 时间复杂度:O(n)O(n)O(n) 线性遍历;
- 空间复杂度:O(n)O(n)O(n) 散列表空间。
T4. 使数组和小于等于 x 的最少时间(Hard)
https://leetcode.cn/problems/minimum-time-to-make-array-sum-at-most-x/
题解(DP + 排序不等式)
- 时间的上界: 假设题目的最少时间超过数组长度 n,那么根据抽屉原理必然有某个位置重复置零两次,那么第一次操作的贡献就丢失了,因此,题目的时间上界不应该超过 n,即每个位置最多置零一次;
- 二分答案(X): 数组元素和小于等于 x 与操作时间 t 不具备单调性,因此不能使用二分答案的思路;
- 逆向思维: 令 s1 = sum(nums1), s2 = sum(nums2),假设经过 t 时间且不进行任何操作,那么元素总和将变成 s1 + s2 *t。现在需要从 [0, n-1] 中非重复地选择 t 个位置,假设在第 x 秒选择位置 [i],那么对最终元素总和减少的贡献度为 nums1[i] + x·nums2[i]。
- 排序不等式: 现在的问题是「选择哪些数」以及「如何分配选择时间」使得减少的贡献度尽可能大:假设选择位置 [i]、[j] 和 [k],那么贡献度为:
- nums1[i] + nums2[i] * x
- nums1[j] + nums2[j] * y
- nums1[k] + nums2[k] * z
- 无论如何分配,加法左边的贡献度是恒定的,问题关键在与如何使得加法右边的贡献度尽可能大;
- 直观地观察,容易想到应该将元素值更大的元素分配到更靠后的位置上,使其置零时贡献更多;
- 验证证明可以根据 排序不等式 ,假设有两组有序序列 a 和 b,每一项正序相乘并累加的和是最大的。
- 动态规划(选哪个): 定义 dp[i][j] 表示到第 [i] 个元素为止操作 j 次时的最大贡献度
- 目标:满足 dp[n][j] 小于等于 x 的最小 j 值
- 状态转移方程(选和不选):dp[i][j]=maxdp[i−1][j],dp[i−1][j−1]+nums1[i]+nums2[i]∗jdp[i][j] = max{dp[i - 1][j], dp[i - 1][j - 1] + nums1[i] + nums2[i] * j}dp[i][j]=maxdp[i−1][j],dp[i−1][j−1]+nums1[i]+nums2[i]∗j
- 排序: 将元素按照 nums2 正序排序,对于选择 [i] 位置且选择 j 次的方案,分配在第 j 次选择上的贡献度是最大的。
class Solution {
fun minimumTime(nums1: List<Int>, nums2: List<Int>, x: Int): Int {
val INF = -0x3F3F3F3F // 减少判断
val n = nums1.size
// 排序
val ids = Array<Int>(n) {it}
Arrays.sort(ids) {i1, i2 ->
nums2[i1] - nums2[i2]
}
// 动态规划
val dp = Array(n + 1) { IntArray(n + 1) { INF }}
dp[0][0] = 0 // 初始状态
for (i in 1 .. n) { // 枚举物品
for (j in 0 .. i) { // 枚举次数
dp[i][j] = dp[i - 1][j]
if (j > 0) dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - 1] + nums1[ids[i - 1]] + nums2[ids[i - 1]] * j)
}
}
// println(dp[n].joinToString())
// 输出
val s1 = nums1.sum()
val s2 = nums2.sum()
for (t in 0 .. n) {
if (s1 + s2 * t - dp[n][t] <= x) return t
}
return -1
}
}
滚动数组优化:
class Solution {
fun minimumTime(nums1: List<Int>, nums2: List<Int>, x: Int): Int {
val INF = -0x3F3F3F3F // 减少判断
val n = nums1.size
// 排序
val ids = Array<Int>(n) {it}
Arrays.sort(ids) {i1, i2 ->
nums2[i1] - nums2[i2]
}
// 动态规划
val dp = IntArray(n + 1) { INF }
dp[0] = 0 // 初始状态
for (i in 1 .. n) { // 枚举物品
for (j in i downTo 1) { // 枚举次数(逆序)
dp[j] = Math.max(dp[j], dp[j - 1] + nums1[ids[i - 1]] + nums2[ids[i - 1]] * j)
}
}
// println(dp[n].joinToString())
// 输出
val s1 = nums1.sum()
val s2 = nums2.sum()
for (t in 0 .. n) {
if (s1 + s2 * t - dp[t] <= x) return t
}
return -1
}
}
复杂度分析:
- 时间复杂度:O(n2)O(n^2)O(n2) 其中排序时间为 O(nlgn)O(nlgn)O(nlgn),动态规划时间为 O(n2)O(n^2)O(n2);
- 空间复杂度:O(n)O(n)O(n) DP 数组空间。