题目描述
思路解析
一句话答案:LeetCode 368 最大整除子集:排序后做类似最长递增子序列的 O(n²) 动态规划,dp[i] 记以 nums[i] 结尾的最大整除子集长度,前面能整除它的都接上取最长,再用 parent 指针回溯还原子集。空间 O(n)。
最大整除子集到底在找什么
给一个各不相同的正整数数组,挑出尽量多的数,要求任意两个都满足大的能被小的整除,返回一个最大子集(可跳着挑、不要求连续)。比如 nums = [3,4,16,8] 的答案是 [4,8,16]。答案不唯一,返回任一最大的即可。
为什么不能把所有子集都试一遍
最直接是列出所有子集逐个检查是否两两整除,留最大的。但 n 个数有 2^n 个子集,n=30 就十亿量级,跑不完。
两两整除有结构可挖:数从小到大排好后,一个数只可能接在比它小、又能整除它的数后面。问题从「挑一堆数」变成「从左到右每个数往前找一条最长链」。
为什么要先排序,dp[i] 又代表什么
先把 nums 从小到大排序,排完后能整除某个数的因子一定在它左边,每个数只需往左看。
定义 dp[i] 为「以排序后第 i 个数结尾的最大整除子集有多长」,至少是 1(自己单独成子集)。算 dp[i] 就扫左边所有 j:只要 nums[i] % nums[j] == 0,nums[i] 就能接在以 j 结尾的链后面变成 dp[j] + 1,在能接的 j 里挑 dp[j] 最大的即为 dp[i]。这就是最长递增子序列(LIS,最长的逐个变大的子序列)的套路,只把「后一个更大」换成「后一个能被前一个整除」。
只验证相邻两数整除,为什么整个子集就都整除
链上只验证了相邻两数整除,隔着的两数凭什么也整除?靠整除的传递性——a 整除 b、b 整除 c,则 a 一定整除 c。相邻都整除一路传下去,任意两个也就都整除。
只算长度还不够,题目要的是具体子集,所以再开一个 parent 数组(parent[i] 记 nums[i] 前一个数的下标,即前驱指针),更新 dp[i] 时记下它接在谁后面。最后从 dp 最大处顺着 parent 往回跳,沿途的数就是整条链。
拿 [3,4,16,8] 亲手算一遍
拿题面 nums = [3,4,16,8] 走一遍:排序成 [3,4,8,16],dp 全初始化为 1,parent 全是 -1。
3:左边没数,dp[0]=1。4:4 % 3 ≠ 0,dp[1]=1。8:8 % 4 == 0,接到 4,dp[2]=dp[1]+1=2,parent[2]=1。16:接 4 只到长度 2,接 8(16 % 8 == 0)能到 3,取更长,dp[3]=dp[2]+1=3,parent[3]=2。
四个 dp 值 [1,1,2,3],最大 dp[3]=3。从下标 3(数 16)回溯:parent 依次指到 8、4,再到 -1 到头。收集到 16、8、4,倒过来是 [4,8,16],正是答案。
复杂度是多少,不排序和不记 parent 会怎样
排序 O(n log n);主体 i、j 两层循环、每对一次取模 O(1),合起来 O(n²),回溯 O(n),整体 O(n²) 主导。空间上 dp、parent 各 O(n)。
两处最容易踩坑:一是不排序直接 DP,「因子在左边」的前提没了,顺序乱的整除关系会漏掉;二是只记 dp 长度、忘了 parent,最后只知子集多长,拼不回具体哪几个数。
▶ 动画逐步走查(文字版)——想跟着上方动画一帧帧对照就展开
记住「排序 → dp[i] 看前面能整除它的、取最长 +1 并记前驱 → 从最长处回溯」,下面逐步套它。
先排序:[5, 9, 18, 54, 108]。排序后只需考虑「大数能否被前面的小数整除」,把问题变成从左到右的链式 DP。
轮到 5(紫)。它自己至少是长度 1 的链。回看前面的数,谁能整除它、就尝试接到谁后面变更长。
定下 dp[0]=1。目前全局最长链的结尾是 5(长度 1)。
轮到 9(紫)。它自己至少是长度 1 的链。回看前面的数,谁能整除它、就尝试接到谁后面变更长。
9 不能被 5 整除(灰),跳过这个候选。
定下 dp[1]=1。目前全局最长链的结尾是 5(长度 1)。
轮到 18(紫)。它自己至少是长度 1 的链。回看前面的数,谁能整除它、就尝试接到谁后面变更长。
18 不能被 5 整除(灰),跳过这个候选。
18 能被 9 整除,且接到 9 的链(长 1)后更长。更新 dp[2]=2,记 18 的前驱是 9。
定下 dp[2]=2。目前全局最长链的结尾是 18(长度 2)。
轮到 54(紫)。它自己至少是长度 1 的链。回看前面的数,谁能整除它、就尝试接到谁后面变更长。
54 不能被 5 整除(灰),跳过这个候选。
54 能被 9 整除,且接到 9 的链(长 1)后更长。更新 dp[3]=2,记 54 的前驱是 9。
54 能被 18 整除,且接到 18 的链(长 2)后更长。更新 dp[3]=3,记 54 的前驱是 18。
定下 dp[3]=3。目前全局最长链的结尾是 54(长度 3)。
轮到 108(紫)。它自己至少是长度 1 的链。回看前面的数,谁能整除它、就尝试接到谁后面变更长。
108 不能被 5 整除(灰),跳过这个候选。
108 能被 9 整除,且接到 9 的链(长 1)后更长。更新 dp[4]=2,记 108 的前驱是 9。
108 能被 18 整除,且接到 18 的链(长 2)后更长。更新 dp[4]=3,记 108 的前驱是 18。
108 能被 54 整除,且接到 54 的链(长 3)后更长。更新 dp[4]=4,记 108 的前驱是 54。
定下 dp[4]=4。目前全局最长链的结尾是 108(长度 4)。
回溯第 1 步:收下 108,再跳到它的前驱 54。绿色是已收集进最大整除子集的数。
回溯第 2 步:收下 54,再跳到它的前驱 18。绿色是已收集进最大整除子集的数。
回溯第 3 步:收下 18,再跳到它的前驱 9。绿色是已收集进最大整除子集的数。
回溯第 4 步:收下 9,它没有前驱,链到头了。绿色是已收集进最大整除子集的数。
回溯结束,最大整除子集 = [9, 18, 54, 108]。链上相邻两数整除(9→18→54→108),靠整除的传递性,任意两数也都整除。
边界:单数返回它;互不整除返回任一个;成链返回全部。
两个延伸:是 LIS 把比较换成整除的变体;整除无全序无法二分到 O(n log n)。
参考代码
from typing import Listclass Solution: def largestDivisibleSubset(self, nums: List[int]) -> List[int]: nums.sort() n = len(nums) dp = [1] * n parent = [-1] * n best = 0 for i in range(n): for j in range(i): if nums[i] % nums[j] == 0 and dp[j] + 1 > dp[i]: dp[i] = dp[j] + 1 parent[i] = j if dp[i] > dp[best]: best = i ans = [] while best != -1: ans.append(nums[best]) best = parent[best] return ans复杂度
- 时间:O(n²),n 是数组长度。排序 O(n log n);DP 是双重循环 O(n²),取模判整除是 O(1);回溯 O(n)。整体由 O(n²) 主导
- 空间:O(n),dp 与 parent 数组各 O(n),答案子集 O(n)
易错点
面试追问把动画讲成自己的话
追问这道题和最长递增子序列(LIS)有什么异同?
追问能不能像 LIS 那样用二分把复杂度降到 O(n log n)?
这道题到这就讲完了。动画和文字是同一套思路——别光看,关掉页面自己默写一遍。然后顺着主线继续:
旋转函数
LeetCode 396 · 中等 · 沿着 动态规划套路 继续往下推进
把这道题真正学会,再走
图解算法年卡 ¥99 /年
- ✓本题每步动画的吴师兄语音讲解(全站陆续覆盖)
- ✓小欧带 8 步通关训练——追问到不看答案也能写对、能 30 秒讲给面试官听
- ✓学习报告,记录每道题的掌握程度
76k+ GitHub Star · 吴师兄开源图解算法,几十万开发者在看的算法讲解
想成体系刷透这类套路?去图解算法专题