当前位置:首页 > 技术 > 正文内容

动态规划专题:零钱兑换、完全平方数与单词拆分求解

访客 技术 2026年10月29日 1

动态规划(Dynamic Programming, DP)是一种通过将复杂问题分解为子问题,并存储子问题的解以避免重复计算的算法范式。本文将深入探讨几个经典的动态规划问题,包括零钱兑换、完全平方数以及单词拆分,并对多重背包问题进行补充说明。

LeetCode 322. 零钱兑换

问题描述: 假设你拥有不同面额的硬币,由整数数组 coins 表示。给定一个总金额 amount,任务是计算凑成该总金额所需的最少硬币数量。如果无法凑成目标金额,则返回 -1。每种硬币的数量是无限的。

动态规划思路:

  • 状态定义: 设 dp[k] 表示凑成金额 k 所需的最少硬币数量。
  • 初始状态: dp[0] = 0,因为凑成金额 0 不需要任何硬币。对于所有其他金额 k > 0,初始化 dp[k] 为一个极大值(例如 amount + 1),表示这些金额目前还无法凑成或所需硬币数大于可能的最大值。
  • 状态转移方程: 对于每一个金额 k,我们可以尝试使用每种硬币 coin。如果当前硬币 coin 小于或等于 k,并且 dp[k - coin] 不是极大值(即 k - coin 可以被凑成),那么 dp[k] 就可以更新为 min(dp[k], dp[k - coin] + 1)。这表示凑成金额 k 可以通过凑成 k - coin 后再额外加上一个面额为 coin 的硬币来实现。
  • 遍历顺序: 外层循环遍历硬币面额,内层循环遍历目标金额。这种顺序确保了每个硬币可以在凑成总金额时被重复使用(符合完全背包的特性)。

class Solution {
public:
    int coinChange(std::vector<int>& denominations, int targetAmount) {
        // minCoinsRequired[k] 存储凑成金额 k 所需的最少硬币数量
        // 初始化为 targetAmount + 1,表示一个无法达到的值,比任何可能的硬币数都大
        // 因为最多 targetAmount 个硬币(全部是 1 元)
        std::vector<int> minCoinsRequired(targetAmount + 1, targetAmount + 1);

        // 凑成金额 0 不需要硬币
        minCoinsRequired[0] = 0;

        // 遍历每种硬币面额
        for (int coin : denominations) {
            // 遍历所有可能的金额,从当前硬币面额开始
            // 确保当前金额可以减去当前硬币面额
            for (int currentAmount = coin; currentAmount <= targetAmount; ++currentAmount) {
                // 如果 (currentAmount - coin) 可以被凑成 (即 minCoinsRequired[currentAmount - coin] 不是初始的极大值)
                if (minCoinsRequired[currentAmount - coin] != targetAmount + 1) {
                    // 更新当前金额所需的最少硬币数
                    minCoinsRequired[currentAmount] = std::min(minCoinsRequired[currentAmount], minCoinsRequired[currentAmount - coin] + 1);
                }
            }
        }

        // 如果 minCoinsRequired[targetAmount] 仍然是初始的极大值,说明无法凑成目标金额
        return (minCoinsRequired[targetAmount] == targetAmount + 1) ? -1 : minCoinsRequired[targetAmount];
    }
};
    

LeetCode 279. 完全平方数

问题描述: 给定一个正整数 n,求最少需要多少个完全平方数(例如 1, 4, 9, 16, ...)才能使其和为 n。一个完全平方数是一个整数的平方,即 x*x。

动态规划思路: 此问题与"零钱兑换"问题结构高度相似。我们可以将其视为一个特殊的"零钱兑换"问题,其中"硬币"的面额是所有小于或等于 n 的完全平方数。

  • 状态定义: 设 dp[k] 表示凑成整数 k 所需的最少完全平方数数量。
  • 初始状态: dp[0] = 0。对于所有其他 k > 0,初始化 dp[k] 为一个极大值(例如 n + 1)。
  • 状态转移方程: 对于每个整数 k,我们尝试用每个完全平方数 square 来更新 dp[k]。如果 square <= k 且 dp[k - square] 不是极大值,则 dp[k] = min(dp[k], dp[k - square] + 1)。
  • 遍历顺序: 可以先生成所有完全平方数,然后外层遍历这些平方数,内层遍历目标和。

class Solution {
public:
    int numSquares(int targetNum) {
        // 生成所有小于等于 targetNum 的完全平方数
        std::vector<int> perfectSquares;
        for (int i = 1; i * i <= targetNum; ++i) {
            perfectSquares.push_back(i * i);
        }

        // minSquareCounts[k] 存储凑成整数 k 所需的最少完全平方数数量
        // 初始化为 targetNum + 1,表示一个无法达到的值
        std::vector<int> minSquareCounts(targetNum + 1, targetNum + 1);

        // 凑成整数 0 不需要完全平方数
        minSquareCounts[0] = 0;

        // 遍历每个完全平方数作为"物品"
        for (int squareVal : perfectSquares) {
            // 遍历所有可能的和,从当前完全平方数开始
            for (int currentSum = squareVal; currentSum <= targetNum; ++currentSum) {
                // 如果 (currentSum - squareVal) 可以被凑成
                if (minSquareCounts[currentSum - squareVal] != targetNum + 1) {
                    // 更新当前和所需的最少完全平方数
                    minSquareCounts[currentSum] = std::min(minSquareCounts[currentSum], minSquareCounts[currentSum - squareVal] + 1);
                }
            }
        }

        // 根据题意,任何正整数 n 总是可以由 n 个 1 组成 (1 也是完全平方数),
        // 因此 minSquareCounts[targetNum] 不会是初始的极大值,直接返回即可。
        return minSquareCounts[targetNum];
    }
};
    

LeetCode 139. 单词拆分

问题描述: 给你一个非空字符串 s 和一个包含若干单词的字典 wordDict。判断 s 是否可以被拆分为一个或多个在字典中出现的单词。字典中的单词可以重复使用。

动态规划思路: 此问题可以看作是判断一个字符串能否通过"选择"字典中的单词来构建。

  • 状态定义: 设 dp[i] 是一个布尔值,表示字符串 s 的前 i 个字符(即子串 s[0...i-1])是否可以被字典中的单词拆分。
  • 初始状态: dp[0] = true。空字符串(长度为0的字符串)被认为是始终可以拆分的,这是递归的基础。
  • 状态转移方程: 为了确定 dp[i] 的值,我们需要向前看。如果存在某个 j (0 <= j < i),使得 dp[j] 为 true(表示 s[0...j-1] 可以被拆分),并且子串 s[j...i-1] 存在于字典中,那么 dp[i] 就为 true。这意味着 s[0...i-1] 可以被拆分为 s[0...j-1] 和 s[j...i-1] 两部分,且这两部分都符合条件。
  • 遍历顺序: 外层循环遍历字符串的结束位置 i (从 1 到 s.size()),内层循环遍历所有可能的分割点 j (从 0 到 i-1)。

class Solution {
public:
    bool wordBreak(std::string inputString, std::vector& dictionary) {
        // 将字典中的单词放入哈希集合,以便 O(1) 时间复杂度查找
        std::unordered_set dictSet(dictionary.begin(), dictionary.end());

        // canSegment[i] 表示 inputString 的前 i 个字符能否被拆分
        std::vector<bool> canSegment(inputString.size() + 1, false);

        // 空字符串可以被拆分 (作为递归的基准)
        canSegment[0] = true;

        // 遍历字符串的每个可能的结束位置 (从长度 1 到 inputString.size())
        for (int endIndex = 1; endIndex <= inputString.size(); ++endIndex) {
            // 遍历所有可能的分割点 (从 0 到 endIndex - 1)
            for (int startIndex = 0; startIndex < endIndex; ++startIndex) {
                // 如果 inputString 的前 startIndex 个字符可以被拆分 (canSegment[startIndex] 为 true)
                // 并且从 startIndex 到 endIndex-1 的子串在字典中
                if (canSegment[startIndex] && dictSet.count(inputString.substr(startIndex, endIndex - startIndex))) {
                    canSegment[endIndex] = true; // 那么 inputString 的前 endIndex 个字符也可以被拆分
                    break; // 找到一个有效拆分即可,无需继续遍历 startIndex
                }
            }
        }
        return canSegment[inputString.size()];
    }
};
    

多重背包问题

问题描述: 假设有一个容量为 C 的背包。现有 N 种物品,每种物品 i 有其重量 w[i]、价值 v[i],并且最多有 k[i] 个可用。目标是在不超过背包容量的前提下,最大化所能获得的总价值。

动态规划思路:

多重背包问题是 0/1 背包问题的变种,每种物品不再是只能选一次,而是可以选多次,但有数量上限。有两种常见的解法:

方法一:转化为 0/1 背包问题

最直接的方法是将每种数量为 k[i] 的物品 i 展开成 k[i] 个完全独立的物品,然后像 0/1 背包问题那样处理。例如,如果物品 A 有 3 个可用,就创建 3 个独立的物品 A1, A2, A3,它们都具有物品 A 的重量和价值。 然而,更高效的方法是使用二进制拆分(Binary Splitting),将物品 i 的 k[i] 个数量拆分为若干个物品,它们的数量是 1, 2, 4, ..., 2p, 以及剩余的部分,这样可以有效减少物品总数。下面示例代码展示的是一种简单的展开方式,即将多个同种物品直接添加到物品列表中:


#include <iostream>
#include <vector>
#include <algorithm> // for std::max

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int capacity, numOriginalItems;
    std::cin >> capacity >> numOriginalItems;

    std::vector<int> originalWeights(numOriginalItems);
    std::vector<int> originalValues(numOriginalItems);
    std::vector<int> originalCounts(numOriginalItems);

    for (int i = 0; i < numOriginalItems; ++i) std::cin >> originalWeights[i];
    for (int i = 0; i < numOriginalItems; ++i) std::cin >> originalValues[i];
    for (int i = 0; i < numOriginalItems; ++i) std::cin >> originalCounts[i];

    // 将多重背包问题展开为 0/1 背包问题
    std::vector<int> expandedWeights;
    std::vector<int> expandedValues;

    for (int i = 0; i < numOriginalItems; ++i) {
        int currentCount = originalCounts[i];
        while (currentCount > 0) {
            expandedWeights.push_back(originalWeights[i]);
            expandedValues.push_back(originalValues[i]);
            currentCount--;
        }
    }

    // maxValues[j] 表示背包容量为 j 时可以获得的最大价值
    std::vector<int> maxValues(capacity + 1, 0);

    // 0/1 背包的核心逻辑
    for (int itemIdx = 0; itemIdx < expandedWeights.size(); ++itemIdx) {
        int currentWeight = expandedWeights[itemIdx];
        int currentValue = expandedValues[itemIdx];
        // 倒序遍历容量,确保每件展开的"物品"只被选择一次
        for (int currentCapacity = capacity; currentCapacity >= currentWeight; --currentCapacity) {
            maxValues[currentCapacity] = std::max(maxValues[currentCapacity], maxValues[currentCapacity - currentWeight] + currentValue);
        }
    }

    std::cout << maxValues[capacity] << std::endl;

    return 0;
}
    

方法二:嵌套循环遍历

另一种直接的方法是在 0/1 背包的框架内,为每种物品额外增加一个循环来遍历该物品可以选择的数量。对于每种物品 i,我们可以选择 0 到 k[i] 个。


#include <iostream>
#include <vector>
#include <algorithm> // for std::max

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int capacity, numItems;
    std::cin >> capacity >> numItems;

    std::vector<int> itemWeights(numItems);
    std::vector<int> itemValues(numItems);
    std::vector<int> itemCounts(numItems);

    for (int i = 0; i < numItems; ++i) std::cin >> itemWeights[i];
    for (int i = 0; i < numItems; ++i) std::cin >> itemValues[i];
    for (int i = 0; i < numItems; ++i) std::cin >> itemCounts[i];

    // maxValues[j] 表示背包容量为 j 时可以获得的最大价值
    std::vector<int> maxValues(capacity + 1, 0);

    // 遍历每种物品
    for (int i = 0; i < numItems; ++i) {
        int currentWeight = itemWeights[i];
        int currentValue = itemValues[i];
        int currentCount = itemCounts[i];

        // 倒序遍历背包容量,确保物品 i 的每次选择都基于上一个物品或当前物品的上一个状态
        for (int currentCapacity = capacity; currentCapacity >= currentWeight; --currentCapacity) {
            // 额外循环遍历当前物品可以选择的数量
            for (int k = 1; k <= currentCount; ++k) {
                // 如果选择 k 个当前物品后,背包容量仍足够
                if (currentCapacity - k * currentWeight >= 0) {
                    maxValues[currentCapacity] = std::max(maxValues[currentCapacity], maxValues[currentCapacity - k * currentWeight] + k * currentValue);
                } else {
                    // 如果再多选一个就会超重,则无需继续增加数量
                    break;
                }
            }
        }
    }

    std::cout << maxValues[capacity] << std::endl;

    return 0;
}
    

相关文章

Linux crontab 详解

1) crontab 是什么cron 是 Linux 的定时任务守护进程;crontab 是用来编辑/查看“按时间周期执行命令”的表(cron table)。常见两类:用户 crontab:每个用户一份(crontab -e 编辑)系统级 crontab / cron.d:可指定执行用户(/etc/crontab、/etc/cron.d/*)2) crontab 时间...

富文本里可以允许的 HTML 属性

一、所有标签默认允许的安全属性(极少)class        (可选)id           (通常建议禁用)title️ 注意:id 容易被滥用做锚点注入,很多系统直接禁用class 允许的话最好只允许固定前缀(如 editor-*)二、a 标签允许属性<a href="" t...

Dom\HTML_NO_DEFAULT_NS 的副作用:自动加闭合标签

在使用Dom\HTMLDocument时,Dom\HTML_NO_DEFAULT_NS 将禁止在解析过程中设置元素的命名空间, 此设置是为了与DOMDocument向后兼容而存在的。当使用它时,已知的一个副作用就是:自动加闭合标签例如 </img> 为什么会这样?当你使用:Dom\HTML_NO_DEFAULT_NS文档会变成 无命名空间模式,此时内部更接近 XML...

Laravel 事件和监听器创建

在 Laravel 中,使用 Artisan 命令创建 Events(事件) 和 Listeners(监听器) 是非常高效的。你可以通过以下几种方式来实现:1. 手动创建单个 Event如果你只想创建一个事件类,可以使用 make:event 命令:Bashphp artisan make:event UserRegistered执行后,文件将生成在 app/Even...

自定义域名解析神器 dnsmasq

什么是 dnsmasq?dnsmasq 是一个轻量级、功能强大的网络服务工具,专为小型和中等规模网络设计。它是一个综合的网络基础设施解决方案[1]。dnsmasq 能做什么?功能说明应用场景DNS 转发与缓存将 DNS 查询转发到上游服务器(ISP、Google DNS 等),并在本地缓存结果加快 DNS 查询速度,减少外部 DNS 流量本地 DNS解析本地网络设备的主机名,无需编辑&n...

linux screen 用法详情 (nohup 的替代方案)

一、screen 是什么?能干嘛?screen 是一个终端复用器,可以:在一个 SSH 会话中开多个“虚拟终端”SSH 断线后,程序仍然在后台运行随时重新连接到原来的会话特别适合:nohup 的替代方案跑脚本 / 爬虫 / 训练模型运维、远程开发二、安装 screen# CentOS / Rocky / Almayum install -y screen# Debian / Ubuntuapt i...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。