码农的智慧:如何在“预算”内,配出最“豪华”的功能套餐?(2311. 小于等于 K 的最长二进制子序列)


😎 码农的智慧:如何在“预算”内,配出最“豪华”的功能套餐?

大家好,我是热爱coding和生活的开发小白!😉

在咱们的日常开发中,总会遇到一些看似棘手,实则充满巧思的场景。今天,我想跟大家聊聊最近我在一个项目中遇到的一个关于“功能配置”的挑战,分享我从一个“学院派”解法到一个“实战派”最优解的心路历程。

我遇到了什么问题:一个“甜蜜的烦恼”

我所在的项目组正在开发一个次时代视频播放器,我们的目标是让它在各种设备上都能提供最佳的观影体验。为此,我们设计了一套非常灵活的视频解码功能集

这套功能集就像一个功能开关列表,比如:

s = "1001010"

这里的每一个’1’或’0’都代表一个解码特性。比如:

  • s[0] = '1': 支持杜比视界 (Dolby Vision)
  • s[2] = '0': 基础H.264解码 (默认开启,算“免费”功能)
  • s[3] = '1': 支持4K分辨率
  • …等等

我们的播放器会根据用户的设备能力,智能地从这个完整的“功能列表” s 中,挑选出一套功能组合(也就是一个子序列),生成一个最终的解码配置。

问题来了:每个高端功能(‘1’)都会增加设备的解码负担,我们可以将其量化为一个数值。而每台设备都有一个解码能力的上限,我们称之为 k。我们的任务是:

为用户的设备,生成一个包含功能最多(长度最长)的配置,同时保证其总解码负担不能超过设备的上限 k

举个例子,如果 s = "1001010", k = 5。我们需要找到 s 的一个子序列,它代表的二进制数小于等于5,并且长度要最长。

这不就是 LeetCode 上的 2311. 小于等于 K 的最长二进制子序列 嘛!现实世界的问题,往往能用优雅的算法模型来解答。

我的探索之旅:从DP到贪心
解法一:学院派的严谨之选 —— 动态规划 (DP)

当我看到“子序列”和“最优化”这两个词,我的DNA动了,大脑里第一个蹦出来的就是——动态规划 (Dynamic Programming)!DP是解决这类问题的“万金油”范式,虽然可能不是最快的,但一定是最稳的。

DP思路是这样的:

我们定义一个 dp[i],表示长度为 i 的有效子序列所对应的最小十进制数值。我们的目标是找到最大的 i,使得 dp[i] 存在且小于等于 k

  1. 初始化dp 数组所有元素初始化为一个极大值(表示无法达到),dp[0] = 0 (长度为0的子序列是空串,值为0)。
  2. 状态转移:我们遍历字符串 s 中的每一个字符 s[j]。对于每个字符,我们再从后往前更新 dp 数组。
    • 遍历 dp 数组的长度 i(从大到小)。
    • 如果 dp[i-1] 是可达的(不是极大值),我们就可以尝试用它来构成一个长度为 i 的新子序列。
    • 新的值就是 newValue = dp[i-1] * 2 + (s[j] - '0')
    • 如果 newValue 比我们已知的 dp[i] 更小,并且 newValue <= k,我们就更新 dp[i] = newValue

为什么这个DP是可行的? 因为我们遍历 s 的每个字符,并尝试将其附加到所有已知的、更短的、最优的子序列后面,从而系统性地构建出所有长度下的最优解。

// 解法一:动态规划 (为了演示思路,非最优解)
// 时间复杂度: O(N^2), 空间复杂度: O(N)
public int longestSubsequenceDP(String s, int k) {
    int n = s.length();
    // dp[i] 表示长度为 i 的子序列的最小值
    int[] dp = new int[n + 1];
    // 初始化为一个大数,表示不可达
    Arrays.fill(dp, Integer.MAX_VALUE);
    dp[0] = 0; // 长度为0的子序列是空串,值为0

    for (int j = 0; j < n; j++) {
        int digit = s.charAt(j) - '0';
        // 从后往前更新,防止本次迭代中的更新影响后续计算
        for (int i = j + 1; i >= 1; i--) {
            if (dp[i - 1] != Integer.MAX_VALUE) {
                // 注意:这里可能会溢出!真实DP需要处理大数,非常复杂
                long newValue = (long) dp[i - 1] * 2 + digit;
                if (newValue <= k && newValue < dp[i]) {
                    dp[i] = (int) newValue;
                }
            }
        }
    }

    // 找到dp值有效的最大长度
    for (int i = n; i >= 0; i--) {
        if (dp[i] != Integer.MAX_VALUE) {
            return i;
        }
    }
    return 0;
}

DP的“痛点” 🤕:

  • 复杂度高O(N^2) 的时间复杂度,当 s 很长时,性能会变差。
  • 实现复杂:要处理大数和溢出问题,代码很容易写得臃肿且易错。
  • 不够“聪明”:它感觉像是在“暴力”枚举所有可能性,没有利用到这个问题的深层特性。

虽然DP能解,但我总觉得,一定有更巧妙的办法!

解法二:实战派的灵光一闪 ✨ —— 贪心算法

我坐在椅子上,喝了口咖啡,重新审视问题。一个功能(‘1’)的“成本”,取决于它在最终配置中的位置,而不是在原始列表 s 中的位置!

关键洞察:越靠右的’1’,在二进制中代表的权重越小(2^0, 2^1…),也就越“便宜”。

那么,最聪明的策略,不就是优先把预算花在“最便宜”的功能上吗

所以,正确的做法应该是从右向左遍历原始的功能列表 s!这样,我们遇到的第一个字符,就会成为我们配置的“最低位”,第二个是“次低位”,以此类推。这天然地保证了我们总是在考虑“成本”最低的选项。

我的贪心策略:

  1. '0’是免费的:任何基础功能(‘0’)都会增加我们的配置长度,而且对解码负担(数值)的增加很小或为零(前导零)。所以,只要遇到’0’,我们无条件地拿下!
  2. '1’是昂贵的:对于高级功能(‘1’),我们需要看“钱包”够不够。只有在加上这个’1’的“成本”后,总负担val不超过预算k,我们才“购买”它。
  3. 成本计算:我们用一个变量 power 来代表当前功能位的“成本”,它从1 (2^0)开始,每向左移动一位就翻倍。

这才是最适合这个场景的“实战派”解法!

// 解法二:贪心算法 (最优解)
// 时间复杂度: O(N), 空间复杂度: O(1)
class Solution {
    public int longestSubsequence(String s, int k) {
        // 我们最终配置的长度
        int length = 0;
        // 当前配置的总“解码负担”(数值)
        long val = 0; 
        // 当前功能位的“成本”,从最低位(2^0=1)开始
        long power = 1;

        // 从功能列表 s 的末尾开始向前遍历
        for (int i = s.length() - 1; i >= 0; i--) {
            char feature = s.charAt(i);

            if (feature == '0') {
                // 遇到基础功能('0'),直接拿下!因为它增加长度,成本几乎为零。
                length++;
            } else { // feature == '1'
                // 遇到高级功能('1'),得算算账了
                if (power <= k && val + power <= k) {
                    // 预算充足!“购买”这个功能
                    val += power;
                    length++;
                }
            }

            // 不管买不买,我们都要向左移动一位,所以下一位的“成本”要翻倍
            if (power <= k) {
                power *= 2;
            }
        }
        return length;
    }
}

两种解法对比

特性解法一 (DP)解法二 (Greedy)
思路系统化,严谨洞察力,巧妙
时间复杂度O(N^2)O(N) 🚀
空间复杂度O(N)O(1)
编码难度较高,易错简单,直观
适用性普适性强针对性强
评价可靠的备胎最优的选择 👍
提示解读:出题人的“悄悄话”
  • 1 <= s.length <= 1000:字符串不长,暗示 O(N^2) 的DP解法可以通过,但 O(N) 的贪心解法会更受欢迎。
  • 1 <= k <= 10^9:这是最关键的提示!
    • 10^9 约等于 2^30。这意味着,任何有效的配置,其有意义的部分(从第一个’1’开始)不会超过30位。这说明我们能选的’1’数量是极其有限的,更坚定了我们优先选“便宜”的’1’的贪心策略是正确的。
    • 计算 val + power 时,数值可能超过 Integer.MAX_VALUE (约2*10^9)。所以代码里用 long 来存 valpower 是一个非常重要的好习惯,可以避免溢出bug!😉
  • 子序列可以有前导0:这是出题人给的“福利”!它明确告诉我们,‘0’的价值在于增加长度,而不会增加数值负担,这让我们的“无脑选’0’”策略变得名正言顺。
举一反三:贪心思想的更多应用

这种“在预算内最大化收益,优先选成本最低的”的贪心思想,在开发中非常普遍:

  1. 云资源分配:假设你有一笔预算,要去云服务商那里购买虚拟机。不同配置的虚拟机价格不同,能提供的算力也不同。你想在预算内,购买到总算力最高的虚拟机组合。如果问题简化为“价格越低,单位算力越高”,那么贪心策略(先买最便宜的)就是最优解。
  2. 前端性能优化:加载页面资源。你想让页面尽快可交互(加载的资源数最多),但又不能超过首次渲染的带宽限制。你可以给每个资源一个“成本”(大小)和“收益”(对交互的贡献度)。优先加载那些“成本低、收益高”的资源,就是一种贪心策略。
类似题目推荐

想多练习一下这种思维吗?LeetCode上还有一些题目也能让你体会到贪心算法的魅力:

希望今天的分享能对你有所启发!从一个通用的DP解法,到发现问题本质后的精妙贪心解,这个过程本身就是编程的乐趣所在。

下次见!👋

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值