引言
大家好!今天我们来解决 LeetCode 的第 1262 题:"可被三整除的最大和"。这是一道中等难度的贪心算法题,适合初学者练习模运算和贪心策略。题目要求从数组中选出一些元素,使它们的和最大,且这个和能被 3 整除。
题目描述
给你一个整数数组 nums,请你找出并返回能被三整除的元素最大和。
示例 1:
text
输入:nums = [3,6,5,1,8]
输出:18
解释:选出数字 3,6,1 和 8,它们的和是 18(可被 3 整除)。
示例 2:
text
输入:nums = [4]
输出:0
解释:4 不能被 3 整除,所以返回 0。
示例 3:
text
输入:nums = [1,2,3,4,4]
输出:12
解释:选出数字 1,3,4 和 4,它们的和是 12(可被 3 整除)。
提示:
1 <= nums.length <= 4*10^41 <= nums[i] <= 10^4
解题思路
1. 初步思考:暴力法?
最直观的想法是:我们需要从数组中选出一些数,让它们的和最大,且能被 3 整除。
如果尝试所有可能的组合(选或不选),时间复杂度是 O(2n)O(2^n)O(2n),这对于 n=40000n=40000n=40000 的数据规模显然是不可接受的。
2. 逆向思维:做减法
既然求"选出一些数"很难,不如反过来想:我们先求出所有数的总和 S,然后看看能不能减去一些数,使得剩下的和能被 3 整除。
- 如果
S % 3 == 0:太棒了!不需要减去任何数,S就是最大和。 - 如果
S % 3 == 1:说明总和多了 1。我们需要减去一些数,使得减去的部分模 3 也余 1。- 方案 A:减去 一个 模 3 余 1 的数(如 1, 4, 7...)。
- 方案 B:减去 两个 模 3 余 2 的数(如 2+2=4,4%3=1)。
- 如果
S % 3 == 2:说明总和多了 2。我们需要减去一些数,使得减去的部分模 3 也余 2。- 方案 A:减去 一个 模 3 余 2 的数(如 2, 5, 8...)。
- 方案 B:减去 两个 模 3 余 1 的数(如 1+1=2,2%3=2)。
3. 贪心策略
为了让剩下的和最大,我们减去的数必须 尽可能小 。
所以,我们只需要找到数组中模 3 余 1 和模 3 余 2 的最小的几个数即可。
- 对数组进行排序,或者遍历一遍记录最小的几个数。
- 根据总和的余数情况,比较不同方案(减去一个数 vs 减去两个数),选择损失最小的那个。
4. 算法流程
- 计算数组总和
total。 - 如果
total % 3 == 0,直接返回total。 - 将数组中的数按模 3 的余数分为两组:
mod1(余1的数)和mod2(余2的数)。 - 对这两组数分别排序(或者只维护最小的两个数)。
- 如果
total % 3 == 1:比较 "移除最小的一个mod1" 和 "移除最小的两个mod2",取剩余和较大的。 - 如果
total % 3 == 2:比较 "移除最小的一个mod2" 和 "移除最小的两个mod1",取剩余和较大的。
代码实现 (Go) - 贪心解法
go
package main
import (
"fmt"
"math"
"sort"
)
func maxSumDivThree(nums []int) int {
// 1. 排序,方便取最小的数
sort.Ints(nums)
// 2. 计算总和
total := 0
for _, v := range nums {
total += v
}
// 3. 如果总和能被3整除,直接返回
r := total % 3
if r == 0 {
return total
}
// 4. 收集模1和模2的元素
var mod1 []int
var mod2 []int
for _, v := range nums {
if v%3 == 1 {
mod1 = append(mod1, v)
} else if v%3 == 2 {
mod2 = append(mod2, v)
}
}
// 5. 根据余数情况,尝试移除最小的元素组合
if r == 1 {
// 方案A: 移除一个最小的模1数
cand1 := math.MaxInt32
if len(mod1) > 0 {
cand1 = mod1[0]
}
// 方案B: 移除两个最小的模2数
cand2 := math.MaxInt32
if len(mod2) >= 2 {
cand2 = mod2[0] + mod2[1]
}
// 取损失最小的方案
minRemove := min(cand1, cand2)
if minRemove == math.MaxInt32 {
return 0 // 无法构造
}
return total - minRemove
} else { // r == 2
// 方案A: 移除一个最小的模2数
cand1 := math.MaxInt32
if len(mod2) > 0 {
cand1 = mod2[0]
}
// 方案B: 移除两个最小的模1数
cand2 := math.MaxInt32
if len(mod1) >= 2 {
cand2 = mod1[0] + mod1[1]
}
minRemove := min(cand1, cand2)
if minRemove == math.MaxInt32 {
return 0
}
return total - minRemove
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func main() {
// ...existing code...
// 示例1
nums1 := []int{3, 6, 5, 1, 8}
fmt.Println(maxSumDivThree(nums1)) // 18
// 示例2
nums2 := []int{4}
fmt.Println(maxSumDivThree(nums2)) // 0
// 示例3
nums3 := []int{1, 2, 3, 4, 4}
fmt.Println(maxSumDivThree(nums3)) // 12
}
示例分析
-
示例 1 :
nums = [3,6,5,1,8]- 总和
23,23 % 3 = 2。 - 我们需要减去模 3 余 2 的数。
mod1组:[1](1%3=1)mod2组:[5, 8](5%3=2, 8%3=2)- 方案 A(减一个
mod2):减去5,剩余18。 - 方案 B(减两个
mod1):mod1只有一个数,无法实施。 - 结果:
18。
- 总和
-
示例 3 :
nums = [1,2,3,4,4]- 总和
14,14 % 3 = 2。 mod1组:[1, 4, 4]mod2组:[2]- 方案 A(减一个
mod2):减去2,剩余12。 - 方案 B(减两个
mod1):减去1 + 4 = 5,剩余9。 - 比较:
12 > 9,结果12。
- 总和
进阶解法:动态规划 (DP)
贪心算法虽然直观,但需要排序,时间复杂度是 O(NlogN)O(N \log N)O(NlogN)。有没有 O(N)O(N)O(N) 的方法呢?
这就需要用到 动态规划。
1. 状态定义
我们在遍历数组时,不仅仅关心"当前能被 3 整除的最大和",还需要关心"余 1 的最大和"和"余 2 的最大和"。
为什么?因为:
- 当前"余 1 的最大和" + 一个"余 2 的数" = "新的被 3 整除的最大和"
- 当前"余 2 的最大和" + 一个"余 1 的数" = "新的被 3 整除的最大和"
所以,我们定义 dp[0], dp[1], dp[2] 分别表示:
dp[0]: 当前所有选取的数之和模 3 余 0 的最大值。dp[1]: 当前所有选取的数之和模 3 余 1 的最大值。dp[2]: 当前所有选取的数之和模 3 余 2 的最大值。
2. 状态转移
假设我们当前的状态是 dp,现在来了一个新数字 num,它的余数是 mod = num % 3。
我们可以选择 不选这个数 (状态不变),或者 选这个数 。
如果选这个数,它会把之前的状态转移到新的状态:
dp[0] + num会变成新的余数(0 + mod) % 3的和。dp[1] + num会变成新的余数(1 + mod) % 3的和。dp[2] + num会变成新的余数(2 + mod) % 3的和。
我们需要把这些新产生的和,与原有的状态进行比较,取最大值。
3. DP 代码实现 (Go)
go
func maxSumDivThreeDP(nums []int) int {
const INF = 1 << 30
// 初始化:余0的和为0,其余为负无穷(表示不可达)
dp := [3]int{0, -INF, -INF}
for _, num := range nums {
mod := num % 3
// 复制一份当前状态,用于计算下一轮状态
// 为什么要复制?因为更新 dp[0] 后,计算 dp[1] 时不能用已经更新过的 dp[0]
oldDp := dp
for j := 0; j < 3; j++ {
if oldDp[j] != -INF {
// 当前状态 j 加上 num 后,新的余数是 (j + mod) % 3
newMod := (j + mod) % 3
// 尝试更新最大值
dp[newMod] = max(dp[newMod], oldDp[j] + num)
}
}
}
return dp[0]
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
4. 复杂度对比
| 算法 | 时间复杂度 | 空间复杂度 | 备注 |
|---|---|---|---|
| 贪心 + 排序 | O(NlogN)O(N \log N)O(NlogN) | O(N)O(N)O(N) | 思路简单,易于理解 |
| 动态规划 | O(N)O(N)O(N) | O(1)O(1)O(1) | 效率最高,无需排序 |
对于本题的数据规模 (N=40000N=40000N=40000),两种方法都能通过,但 DP 显然更胜一筹。
总结
这道题展示了从 暴力 -> 逆向思维(贪心) -> 状态机(DP) 的思考过程。
- 当正向求解困难时,试着从总和中"减去"多余的部分。
- 当需要优化复杂度时,思考状态之间的转移关系,尝试 DP。
希望这篇深入浅出的解析能帮你彻底掌握这道题!