程序员老鬼

去年离职的一位同事,今天突然出现在公司,他和大家打招呼,嘴上说“我又回来上班了”,同事们一个个面无表情,没有回应。

我在网上看到一个帖子。说是去年离职的一位同事,今天突然又出现在公司,进门还挺自然,笑嘻嘻跟大家打招呼,嘴里来一句:“我又回来上班了。”结果没人接话,空气都快凝固了。

Image

这事看着尴尬,其实太真实了。走的时候多潇洒啊,嫌公司这不行那不行,背影都透着“爷要去更大的世界看看”。结果才四个月,又兜兜转转回来了。网友们看完也忍不住吐槽:原来不是前公司真差,是外面的饭更难吃。

这时候公司要是愿意收,他能老老实实干,其实也挺实在。毕竟职场混到最后,面子这玩意儿,有时候真没工资条好使。

面试题:最大整除子集

给你一个无重复的正整数数组,找出一个子集,要求子集里任意两个数,较大的那个都能被较小的整除。

比如:

int[] nums = {1, 2, 3};

结果可以是 [1,2],也可以是 [1,3]。

再看一组:

int[] nums = {1, 2, 4, 8};

这组就很顺了,答案直接是 [1,2,4,8]。

但这题如果真按“选或不选”去暴力搜,分支会很多,而且判断一个集合是否合法也不便宜。实际做的时候,先把数组排个序,思路会清楚很多。

先看关键点:如果数组已经升序,那么当 nums[i] % nums[j] == 0 且 j < i 时,说明 nums[i] 可以接在 nums[j] 对应的整除链后面。这个味道其实就是 LIS,最长递增子序列那套动态规划,只不过“能不能接”从大小比较变成了整除关系。

核心状态可以这样定义:

dp[i] = 以 nums[i] 结尾的最大整除子集长度
prev[i] = 这个位置往前接的是谁,方便最后回溯答案

转移也不复杂,枚举 i 前面的每个 j:

if (nums[i] % nums[j] == 0 && dp[j] + 1 > dp[i]) {
    dp[i] = dp[j] + 1;
    prev[i] = j;
}

这题真正有用的地方,不是公式多漂亮,而是你得把“结果集怎么还原”一起想明白。很多人 dp 能写出来,最后答案还原就开始乱。

我自己会直接把完整代码写成这样:

import java.util.*;

classSolution{
public List<Integer> largestDivisibleSubset(int[] nums){
        Arrays.sort(nums);

int n = nums.length;
int[] dp = newint[n];
int[] prev = newint[n];
        Arrays.fill(dp, 1);
        Arrays.fill(prev, -1);

int maxLen = 1;
int maxIdx = 0;

for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] % nums[j] != 0) {
continue;
                }
if (dp[j] + 1 > dp[i]) {
                    dp[i] = dp[j] + 1;
                    prev[i] = j;
                }
            }
if (dp[i] > maxLen) {
                maxLen = dp[i];
                maxIdx = i;
            }
        }

        List<Integer> ans = new ArrayList<>();
for (int i = maxIdx; i != -1; i = prev[i]) {
            ans.add(nums[i]);
        }
        Collections.reverse(ans);
return ans;
    }
}

拿 nums = [1,2,3,4,8] 走一下会更直观点。

排序后还是 [1,2,3,4,8]。

到 2 的时候,能接 1,所以链长变成 2。 到 3 的时候,也只能接 1,链长还是 2。 到 4 的时候,可以接 1,也可以接 2,显然接 2 更长。 到 8 的时候,又能接 1、2、4,这里接 4 最合适,最后得到 [1,2,4,8]。

中间状态大概长这样:

nums : [1, 2, 3, 4, 8]
dp   : [1, 2, 2, 3, 4]
prev : [-1, 0, 0, 1, 3]

最后从 maxIdx = 4 往前追:

8 -> 4 -> 2 -> 1

再反转一下,就是答案。

这题还有个容易写错的小地方:题目要求的是“任意一组最大子集”即可,不要求字典序最小,也不要求把所有答案都列出来。所以我们只要保住最长长度,再能把其中一条链回溯出来就够了,没必要把问题做复杂。

时间复杂度是 O(n^2),空间复杂度 O(n)。 这个复杂度在这题里是够用的,别一看到双层循环就想着硬优化。因为它本质上要判断两两之间是否存在整除关系,这一步很难像普通 LIS 那样用二分去替掉。

顺手再给个测试片段,自己本地跑也方便:

publicstaticvoidmain(String[] args){
    Solution s = new Solution();
    System.out.println(s.largestDivisibleSubset(newint[]{1, 2, 3}));
    System.out.println(s.largestDivisibleSubset(newint[]{1, 2, 4, 8}));
    System.out.println(s.largestDivisibleSubset(newint[]{3, 4, 16, 8}));
}

输出类似这样:

[1, 2]
[1, 2, 4, 8]
[4, 8, 16]

这题做完以后,脑子里最好留一个印象: 只要题目里出现“先排序,再判断当前元素能不能接到前面的某个状态后面”,大概率就可以往 DP 链式转移上想。