LeetCode 1262. 可被三整除的最大和 - 解题思路与代码

引言

大家好!今天我们来解决 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^4
  • 1 <= 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. 算法流程

  1. 计算数组总和 total
  2. 如果 total % 3 == 0,直接返回 total
  3. 将数组中的数按模 3 的余数分为两组:mod1(余1的数)和 mod2(余2的数)。
  4. 对这两组数分别排序(或者只维护最小的两个数)。
  5. 如果 total % 3 == 1:比较 "移除最小的一个 mod1" 和 "移除最小的两个 mod2",取剩余和较大的。
  6. 如果 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
}

示例分析

  • 示例 1nums = [3,6,5,1,8]

    • 总和 2323 % 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
  • 示例 3nums = [1,2,3,4,4]

    • 总和 1414 % 3 = 2
    • mod1 组:[1, 4, 4]
    • mod2 组:[2]
    • 方案 A(减一个 mod2):减去 2,剩余 12
    • 方案 B(减两个 mod1):减去 1 + 4 = 5,剩余 9
    • 比较:12 > 9,结果 12

进阶解法:动态规划 (DP)

贪心算法虽然直观,但需要排序,时间复杂度是 O(Nlog⁡N)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(Nlog⁡N)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。

希望这篇深入浅出的解析能帮你彻底掌握这道题!

相关推荐
保持低旋律节奏2 小时前
算法——冗余!哈希表、vector、string适配器的混合使用
数据结构·算法·散列表
weixin_457760002 小时前
OpenCV 图像处理基础算法详解(一)
图像处理·opencv·算法
做怪小疯子3 小时前
LeetCode 热题 100——链表——相交链表
算法·leetcode·链表
while(努力):进步4 小时前
5G与物联网:连接万物的数字化未来
leetcode
立志成为大牛的小牛4 小时前
数据结构——五十一、散列表的基本概念(王道408)
开发语言·数据结构·学习·程序人生·算法·散列表
Coovally AI模型快速验证5 小时前
去噪扩散模型,根本不去噪?何恺明新论文回归「去噪」本质
人工智能·深度学习·算法·机器学习·计算机视觉·数据挖掘·回归
歌_顿5 小时前
attention、transform、bert 复习总结 1
人工智能·算法
MicroTech20255 小时前
MLGO微算法科技时空卷积与双重注意机制驱动的脑信号多任务分类算法
科技·算法·分类
txp玩Linux5 小时前
rk3568上解析webrtc音频降噪算法处理流程
算法·音视频·webrtc