算法:动态规划

开始前先用两个小问题开开思路:

寻找多数元素:

链接:题目1

cpp 复制代码
class Solution {
public:
    int majorityElement(vector<int>& nums) {
        int x = 0, votes = 0;
        for (int num : nums){
            if (votes == 0) x = num;
            votes += num == x ? 1 : -1;
        }
        return x;
    }
};

这是一个投票的算法,x代表候选者,votes代表票数,当votes等于0就说明要更换新的候选者了,在候选者遍历的期间,它会根据与当前元素是否相同,进行累加或者减少,最终得到的候选者(没有被归0或者代替的),就是数组中存在最多次数的元素。

寻找公共前缀:

链接:题目2

cpp 复制代码
class Solution {
public:
    string longestCommonPrefix(vector<string>& strs) {
        if (!strs.size()) {
            return "";
        }
        int length = strs[0].size();
        int count = strs.size();
        for (int i = 0; i < length; ++i) {
            char c = strs[0][i];
            for (int j = 1; j < count; ++j) {
                if (i == strs[j].size() || strs[j][i] != c) {
                    return strs[0].substr(0, i);
                }
            }
        }
        return strs[0];
    }
};

动态规划

扩宽一下你的思路,主要是学习他怎么避免重复求解,什么样的问题用动态规划,以及怎么做,提高运行效率的。

动态规划(Dynamic Programming, DP)在查找有很多重叠子问题的情况的最优解时有效。它将问题重新组合成子问题。为了避免多次解决这些子问题,它们的结果都逐渐被计算并被保存,从简单的问题直到整个问题都被解决。

因此,动态规划保存递归时的结果,因而不会在解决同样的问题时花费时间 · · · · · · 动态规划只能应用于有最优子结构的问题。最优子结构的意思是局部最优解能决定全局最优解(对有些问题这个要求并不能完全满足,故有时需要引入一定的近似)。简单地说,问题能够分解成子问题来解决。"

通俗一点来讲,动态规划和其它遍历算法(如深/广度优先搜索)都是将原问题拆成多个子问
题然后求解,他们之间最本质的区别是,动态规划保存子问题的解, 避免重复计算。解决动态规
划问题的关键是找到状态转移方程,这样我们可以通过计算和储存子问题的解来求解最终问题。

动态规划的核心思想

  1. 1.把「原问题」分解为「若干个重叠的子问题」,每个子问题的求解过程都构成一个 「阶段」 。在完成一个阶段的计算之后,动态规划方法才会执行下一个阶段的计算。
  2. 2.在求解子问题的过程中,按照「自顶向下的记忆化搜索方法」或者「自底向上的递推方法」求解出「子问题的解」,把结果存储在表格中,当需要再次求解此子问题时,直接从表格中查询该子问题的解,从而避免了大量的重复计算。

这看起来很像是分治算法,但动态规划与分治算法的不同点在于: 有重叠和无重叠

1.适用于动态规划求解的问题,在分解之后得到的子问题往往是相互联系的,会出现若干个重叠子问题。

2.使用动态规划方法会将这些重叠子问题的解保存到表格里,供随后的计算查询使用,从而避免大量的重复计算。

在本节中,我们从一个经典例题入手,先给出它的暴力回溯解法,观察其中包含的重叠子问题,再逐步导出更高效的动态规划解法。

给定一个共有n 阶的楼梯,你每步可以上1阶或者2阶,请问有多少种方案可以爬到楼顶。

本题的目标是求解方案数量,我们可以考虑通过回溯来穷举所有可能性 。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上1阶或2阶,每当到达楼梯顶部时就将方案数量加1 ,当越过楼梯顶部时就将其剪枝。

cpp 复制代码
#include <stdio.h>  
#include <stdlib.h>  
  
void backtrack(int *choices, int state, int n, int *res) {  
    if (state == n) {  
        (*res)++;  
        return;  
    }  
  
    for (int i = 0; i < 2; i++) {  
        int choice = choices[i];  
        if (state + choice > n) {  
            break;  
        }  
        backtrack(choices, state + choice, n, res);  
    }  
}  
  
int climbingStairsBacktrack(int n) {  
    int choices[2] = {1, 2};  
    int state = 0;  
    int res = 0; // 直接使用整数变量来记录方案数量  
  
    backtrack(choices, state, n, &res);  
  
    return res;  
}  
  
int main() {  
    int n = 3; // 举例:5阶楼梯  
    int result = climbingStairsBacktrack(n);  
    printf(" %d 阶楼梯有 %d种方法\n", n, result);  
    return 0;  
}

暴力搜索

这是一个很暴力的求法,直接去不断调用可能的结果求解。

回溯算法通常并不显式地对问题进行拆解,而是将问题看作一系列决策步骤,通过试探和剪枝,搜索所有可能的解。我们可以尝试从问题分解的角度分析这道题。设爬到第i 阶共有dp[i] 种方案,那么dp[i]就是原问题,其子问题包括:

dp[i-1],dp[i-2],...,dp[2],dp[1]

由于每轮只能上1阶或2阶,因此当我们站在第i 阶楼梯上时,上一轮只可能站在第 i-1 阶或第 i-2 阶上。换句话说,我们只能从第 i-1阶或第 i-2阶前往第 i 阶。

由此便可得出一个重要推论:爬到第 i-1 阶的方案数加上爬到第 i-2 阶的方案数就等于爬到第 i 阶的方案数。公式如下:

dp[i] = dp[i-1] +dp[i-2]

这意味着在爬楼梯问题中,各个子问题之间存在递推关系,原问题的解可以由子问题的解构建得来。下图展示了该递推关系。(像下面的代码它就存储了每次运算后的结果,方便后面的调用,实现了时间复杂度的优化,由于它是从顶到低的因此是记忆化搜索)

cpp 复制代码
#include <stdio.h>  
#include <stdlib.h>  
  
// 假设最大楼梯数不会超过某个值 MAX_STAIRS  
#define MAX_STAIRS 100  
int memo[MAX_STAIRS + 1] = {0}; // 初始化记忆化数组为0  
  
int dfs(int i) {  
    // 已知 dp[1] 和 dp[2] ,返回之  
    if (i == 1) return 1;  
    if (i == 2) return 2;  
    // 如果已经计算过,则直接返回结果  
    if (memo[i] != 0) return memo[i];  
    // dp[i] = dp[i-1] + dp[i-2]  
    int count = dfs(i - 1) + dfs(i - 2);  
    // 将结果存入记忆化数组  
    memo[i] = count;  
    return count;  
}  
  
/* 爬楼梯:搜索(带记忆化) */  
int climbingStairsDFS(int n) {  
    return dfs(n);  
}  
  
int main() {  
    int n = 5; // 举例:计算到达第10阶楼梯的方法数  
    int result = climbingStairsDFS(n);  
    printf(" %d 阶楼梯有 %d种方法\n", n, result);  
    return 0;  
}

记忆化搜索

为了提升算法效率,我们希望所有的重叠子问题都只被计算一次。为此,我们声明一个数组 mem 来记录每个子问题的解,并在搜索过程中将重叠子问题剪枝。

  1. 当首次计算dp[i] 时,我们将其记录至 mem0[i] ,以便之后使用。

  2. 当再次需要计算dp[i] 时,我们便可直接从 mem0[i] 中获取结果,从而避免重复计算该子问题。

当然上面的代码是一个全局的存储,你也可以用局部的存储方式,不过那要开辟空间释放资源,比全局更加高效点。

经过记忆化处理后,所有重叠子问题都只需被计算一次,时间复杂度被优化至 O(n) ,这是一个巨大的飞跃。

动态规划搜索

下面展示动态规划,它是从低到顶的,而且不需要递归。直接暴力存储的分问题求解。

记忆化搜索是一种"从顶至底"的方法 :我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点)。之后,通过回溯将子问题的解逐层收集,构建出原问题的解。

与之相反,动态规划是一种"从底至顶"的方法:从最小子问题的解开始,迭代地构建更大子问题的解,直至得到原问题的解。

由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无须使用递归。在以下代码中,我们初始化一个数组 dp 来存储子问题的解,它起到了记忆化搜索中数组 mem 相同的记录作用。

cpp 复制代码
#include <stdio.h>  
#include <stdlib.h>  
  
// 爬楼梯:动态规划  
int climbingStairsDP(int n) {  
    if (n == 1 || n == 2)  
        return n;  
    // 初始化 dp 表,用于存储子问题的解  
    int *dp = (int *)malloc((n + 1) * sizeof(int));  
    if (dp == NULL) {  
        // 处理内存分配失败的情况  
        perror("Memory allocation failed");  
        exit(EXIT_FAILURE);  
    }  
    // 初始状态:预设最小子问题的解  
    dp[1] = 1;  
    dp[2] = 2;  
    // 状态转移:从较小子问题逐步求解较大子问题  
    for (int i = 3; i <= n; i++) {  
        dp[i] = dp[i - 1] + dp[i - 2];  
    }  
    int result = dp[n];  
    // 释放内存  
    free(dp);  
    return result;  
}  
  
int main() {  
    int n = 5; // 举例:计算到达第10阶楼梯的方法数  
    int result = climbingStairsDP(n);  
    printf("到达 %d 阶楼梯有 %d 种方法\n", n, result);  
    return 0;  
}

为什么可以采用:dp[i] = dp[i - 1] + dp[i - 2]; 因为本题是走一步或者两步的情况,对于dp[5]=dp[3]+dp[4]。也就是说对于5层阶梯的情况,可以分为两个子问题,到第3层走2步,到第4层走1步,因为这一步对于走到第几层来说,再继续走一次方法的个数是不变的。

就好比你从学校到家楼下有4种方式,那你上楼到家是不悔影响从学校到家楼下的方式数量的,因此从学校到家里面也是4种方式。

与回溯算法一样,动态规划也使用"状态"概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数i。

根据以上内容,我们可以总结出动态规划的常用术语。

  1. 将数组 dp 称为「dp表」,dp[i]表示状态i 对应子问题的解。
  2. 将最小子问题对应的状态(即第1 和2 阶楼梯)称为「初始状态」。
  3. 将递推公式 dp[i] = dp[i-1]+dp[i-2]称为「状态转移方程」。
    1. 细心的你可能发现,由于dp[i]只与dp[i-1] 和dp[i-2] 有关,因此我们无须使用一个数组 dp 来存储所有子问题的解,而只需两个变量滚动前进即可。(是的,它又又又优化了,厉不厉害!)

优化

cpp 复制代码
#include <stdio.h>  
  
// 爬楼梯:空间优化后的动态规划  
int climbingStairsDPComp(int n) {  
    if (n == 1 || n == 2)  
        return n;  
    int a = 1, b = 2;  
    for (int i = 3; i <= n; i++) {  
        int tmp = b;  
        b = a + b;  
        a = tmp;  
    }  
    return b;  
}  
  
int main() {  
    int n = 10; // 举例:计算到达第10阶楼梯的方法数  
    int result = climbingStairsDPComp(n);  
    printf("到达 %d 阶楼梯有 %d 种方法\n", n, result);  
    return 0;  
}

观察以上代码,由于省去了数组 dp 占用的空间,因此空间复杂度从 O(n)降低至 O(1) 。

在动态规划问题中,当前状态往往仅与前面有限个状态有关,这时我们可以只保留必要的状态,通过"降维"来节省内存空间。这种空间优化技巧被称为"滚动变量"或"滚动数组"。

动态规划的特征

首先,能够使用动态规划方法解决的问题必须满足以下三个特征:(什么问题用动态规划)

最优子结构性质

重叠子问题性质

无后效性

最优子结构性质

最优子结构:指的是一个问题的最优解包含其子问题的最优解。

举个例子,如下图所示,原问题 S={a1,a2,a3,a4},在 a1步我们选出一个当前最优解之后,问题就转换为求解子问题 S子问题={a2,a3,a4}。如果原问题 S的最优解可以由「第 a1 步得到的局部最优解」和「 S子问题的最优解」构成,则说明该问题满足最优子结构性质。

也就是说,如果原问题的最优解包含子问题的最优解,则说明该问题满足最优子结构性质。

重叠子问题性质

重叠子问题性质:指的是在求解子问题的过程中,有大量的子问题是重复的,一个子问题在下一阶段的决策中可能会被多次用到。如果有大量重复的子问题,那么只需要对其求解一次,然后用表格将结果存储下来,以后使用时可以直接查询,不需要再次求解。

无后效性

无后效性:指的是子问题的解(状态值)只与之前阶段有关,而与后面阶段无关。当前阶段的若干状态值一旦确定,就不再改变,不会再受到后续阶段决策的影响。

也就是说,一旦某一个子问题的求解结果确定以后,就不会再被修改。

举个例子,下图是一个有向无环带权图,我们在求解从 A 点到 F 点的最短路径问题时,假设当前已知从 A点到 D点的最短路径(2+7=9)。那么无论之后的路径如何选择,都不会影响之前从 A 点到 D 点的最短路径长度。这就是「无后效性」。

而如果一个问题具有「后效性」,则可能需要先将其转化或者逆向求解来消除后效性,然后才可以使用动态规划算法。

动态规划的例子

你以为完了吗,NONONO!咋们继续来举几个例子看看什么样的题可以做动态规划。

现在我们看一个费布那切数列(费布那切数列有重叠的子问题,可以用动态规划),对于传统的函数递归来实现费布那切数列是这样的:

因此直接递归暴力求解,把整个展开就是O(2^n)的时间复杂度。

因此我们发现它具有重叠,无效性,最优解(一个问题的解包含另外一个问题),因此可以用动态规划。

那么我们在看一个事例:

假设这是一个时间表,上面有8个任务,同一时间不能做两个任务,每个任务上面有一个值(代表你可以获得的钱),现在你要安排怎么才可以赚到更多的钱。

对于这个题我们发现它有重复的子问题,因此可以用动态规划去寻找最优解。那么我们可以通过一个选不选的思路去求解。

这里假设一个数组OPT它代表我们选择哪一个任务:
如OPT(8),选择第8个任务。那么对于它来说选了它,就不可以选择7,6的任务,因此OPT(8)可以等于做就是OPT(5)+4(即做8之前可以做第5个任务);如果不选那么OPT(8) = OPT(7)。

对于第i个任务,我们做了它之后,最多去做它的前一个任务我们用prev()+当前值来表示。不选的话就是当前任务不做的前一个任务即OPT(i-1)(与它重叠了),也就是它的最优解由下面两种情况构成。

那么我们得到每个任务它前一个可以做到任务列出来就是:

那么现在开始计算出每个子问题的最优解,把它们构成最后的大问题的解吧:

对于前1个(OPT(1)),任务1的值为5,前面没有任务,因此赚的钱只能是5,选择任务1.OPT(1)=5;

对于前2个(OPT(2)),任务2的值是1,我们可以选做还是不做,做就只能赚1,不做就是OPT(1),可以赚5.因此选任务1。因此OPT(2)=5;

对于前3个(OPT(3)),任务3的值是8,我们可以选做还是不做,做可以赚8,不做就是OPT(2)为5,因此,选择任务3.因此OPT(3)=8;

那么对于前4个(OPT(4)),任务4的值是4,我们可以选择做还是不做,做可以赚4+OPT(1)即等于9,不做为OPT(3),即8.因此OPT(4)=9;

......

那么对于前8g(OPT(8)),任务8的值是4,我们可以选择做还是不做,做可以赚4+OPT(5)即等于13,不做为OPT(7)=10。因此OPT(8)=13;

所以前8个任务的最优解是13.

因此现在你应该知道动态规划是什么样的了吧,当满足重叠,选择最优解,无效性的问题时,可以选择动态规划。(不知道看看前面),那么期待你吧代码写出来,无论什么方式。

相关推荐
ROBIN__dyc1 分钟前
数组
算法
手握风云-38 分钟前
零基础Java第十六期:抽象类接口(二)
数据结构·算法
笨小古1 小时前
路径规划——RRT-Connect算法
算法·路径规划·导航
<但凡.2 小时前
编程之路,从0开始:知识补充篇
c语言·数据结构·算法
f狐0狸x2 小时前
【数据结构副本篇】顺序表 链表OJ
c语言·数据结构·算法·链表
paopaokaka_luck2 小时前
基于Spring Boot+Vue的多媒体素材管理系统的设计与实现
java·数据库·vue.js·spring boot·后端·算法
视觉小萌新3 小时前
VScode+opencv——关于opencv多张图片拼接成一张图片的算法
vscode·opencv·算法
2的n次方_3 小时前
二维费用背包问题
java·算法·动态规划
simple_ssn3 小时前
【C语言刷力扣】1502.判断能否形成等差数列
c语言·算法·leetcode
寂静山林3 小时前
UVa 11855 Buzzwords
算法