算法学习(27):从暴力递归到动态规划(下)

从暴力递归到动态规划

算法学习(16)中的纸牌问题改成动态规划

给定一个整型数组arr,代表数值不同的纸牌排成一条线,纸牌上的数值代表分数。玩家A和玩家B依次拿走每张纸牌,规定玩家A先拿,玩家B后拿,但是每个玩家每次只能拿走最左或最右的纸牌,玩家A和玩家B都绝顶聪明。请返回最后获胜者的分数。


(记忆化搜索很容易就能改出,所以省略了,后面的题都是这样)假设给定的数组是[3,100,4,50],则

  1. 分析可变参数的变化范围,确定dp表的维度和大小。两个可变参数,范围都是0~arr.size()-1,所以维度是2,大小是4x4的二维表。这次是两个递归函数,一个先手一个后手,所以是两张表

  2. 标出要计算的目标位置。

  3. 根据base case填写表中已经知道的数据。F函数的base case是left=right时返回这个位置的数值,S函数的base case是left=right时返回0,又因为left不能大于right,所以两个表格左下角都是×

  4. 根据递归推出表中格子之间的依赖关系。根据递归函数可知,F表格的值等于,它的left下标在arr中对应的值加上它在S表格中相同位置下边格子的值,与它的right下标在arr中对应的值加上它在S表格中相同位置左边格子的值,两者之间较大的值。S表格的值则是等于它在F表格中相同位置下边格子、左边格子的值之间较小的值。

  5. 确定表中数据的依次计算的顺序。顺序有多种,这里采用沿着对角线从左上角到右下角填,F和S表交叉填写,填完一条斜线再填下一条


这一题是一种新的尝试方法:范围尝试,是两个表的形式,采用搭积木的方式决定谁先求谁后求


代码:

int maxScoreDP(vector<int> arr)
{
    int length = arr.size();
    vector<vector<int>> F(length, vector<int>(length));
    vector<vector<int>> S(length, vector<int>(length));
    for (int i = 0; i < length; i++)
    {
        F[i][i] = arr[i];
    }
    int row = 0;
    int col = 1;
    while (col < length)
    {
        int i = row;
        int j = col;
        while (i < length && j < length)
        {
            F[i][j] = max(arr[i] + S[i + 1][j], arr[j] + S[i][j - 1]);
            S[i][j] = min(F[i + 1][j], F[i][j - 1]);
            j++;
            i++;
        }
        col++;
    }
    return max(F[0][length - 1], S[0][length - 1]);
}

象棋问题(三维表)

有一个10*9的棋盘,一个马,它的初始位置是(0,0),给定一个目标位置(a,b)和步数K,马必须走K步到(a,b),返回有多少种走法

暴力递归

比较好理解的思路是反过来,把马看作是从(a,b)到(0,0),每次走八个方向,调用八个方向的递归,越界就返回0,步数走完且到了(0,0)就返回1。

int process(int x, int y, int rest);

int knight(int a, int b, int k)
{
    return process(a, b, k);
}

int process(int x, int y, int rest)  //x,y代表坐标,rest代表还有多少步要走
{
    if (rest == 0)
    {
        return (x == 0 && y == 0) ? 1 : 0;    //步数走完,且到了(0,0)
    }
    if (x > 8 || x < 0 || y > 9 || y < 0)   //越界,前面所作的选择是错的
    {
        return 0;
    }
    return process(x + 1, y + 2, rest - 1)
        + process(x + 2, y + 1, rest - 1)
        + process(x + 2, y - 1, rest - 1)
        + process(x + 1, y - 2, rest - 1)
        + process(x - 1, y - 2, rest - 1)
        + process(x - 2, y - 1, rest - 1)
        + process(x - 2, y + 1, rest - 1) 
        + process(x - 1, y + 2, rest - 1);   //调用八个方向的递归,把它们的方法数加起来并返回
}

动态规划

  1. 分析可变参数的变化范围,确定dp表的维度和大小。三个可变参数,x的范围是08,y的范围是09,rest的范围是0~k。

  2. 标出要计算的目标位置。需要计算的目标位置为最上层的某个位置

  3. 根据base case填写表中已经知道的数据。最下层即第0层除了(0,0)点是1外,其他点都是0

  4. 根据递归推出表中格子之间的依赖关系。根据递归可得,上一层需要自己在下一层对应位置的八个方向上的值的和,越界的方向就为0

  5. 确定表中数据的依次计算的顺序。从下到上填

int knight(int a, int b, int k)
{
    vector<vector<vector<int>>> dp(9, vector<vector<int>>(10, vector<int>(k + 1)));
    dp[0][0][0] = 1;
    for (int h = 1; h < k + 1; h++)
    {
        for (int i = 0; i < 9; i++)
        {
            for (int j = 0; j < 10; j++)
            {
                dp[i][j][h] += getValue(dp, i + 1, j + 2, h - 1);
                dp[i][j][h] += getValue(dp, i + 2, j + 1, h - 1);
                dp[i][j][h] += getValue(dp, i + 2, j - 1, h - 1);
                dp[i][j][h] += getValue(dp, i + 1, j - 2, h - 1);
                dp[i][j][h] += getValue(dp, i - 1, j - 2, h - 1);
                dp[i][j][h] += getValue(dp, i - 2, j - 1, h - 1);
                dp[i][j][h] += getValue(dp, i - 2, j + 1, h - 1);
                dp[i][j][h] += getValue(dp, i - 1, j + 2, h - 1);

            }
        }
    }
    return dp[a][b][k];
}

bob生存问题(三维表)

给定两个参数N和M,在N*M的地图上有一个人bob,它每次等概率上下左右移动一步,一旦走到地图范围外,bob就会死亡,给定一个参数K,代表bob必须走K步,求bob走完k步之后还活着的概率。

暴力递归

首先算出存活的方法数,在当前位置,出界返回0,rest归零且没出界返回1,上下左右每个位置调用一次递归并且相加,再返回。在主函数中最后把方法数除以所有走的可能性4k再返回即可(与递归函数process返回值res除以4结果一样)。

int process(int N, int M, int i, int j, int rest);

double bobProbability(int N, int M, int i, int j, int K)
{
    if (N <= 0 || M <= 0 || i < 0 || j < 0 || K < 0)
    {
        return 0;
    }
    double ways = process(N, M, i, j, K);
    return ways / pow(4, K);
}

int process(int N, int M, int i, int j, int rest)
{
    if (i < 0 || i > N - 1 || j < 0 || j > M - 1)
    {
        return 0;
    }
    if (rest == 0)
    {
        return 1;
    }
    int res = 0;
    res += process(N, M, i - 1, j, rest - 1);
    res += process(N, M, i + 1, j, rest - 1);
    res += process(N, M, i, j + 1, rest - 1);
    res += process(N, M, i, j - 1, rest - 1);
    return res;
}

动态规划

  1. 分析可变参数的变化范围,确定dp表的维度和大小。三个可变参数,i的取值范围是0N-1,j的取值范围是0M-1,rest的取值范围是0~k。
  2. 标出要计算的目标位置。
  3. 根据base case填写表中已经知道的数据。第0层的值都是1
  4. 根据递归推出表中格子之间的依赖关系。根据递归可得,上一层需要自己在下一层对应位置的上下左右四个方向上的值的和,越界的方向就为0
  5. 确定表中数据的依次计算的顺序。从下到上填
int getValue(vector<vector<vector<double>>>& dp, int N, int M, int i, int j, int K);

double bobProbability(int N, int M, int i, int j, int K)
{
    vector<vector<vector<double>>> dp(N, vector<vector<double>>(M, vector<double>(K + 1)));
    for (int i = 0; i < N; i++)
    {
        for (int j = 0; j < M; j++)
        {
            dp[i][j][0] = 1.0;
        }
    }
    for (int h = 1; h < K + 1; h++)
    {
        for (int i = 0; i < N; i++)
        {
            for (int j = 0; j < M; j++)
            {
                dp[i][j][h] += getValue(dp, N, M, i + 1, j, h - 1);
                dp[i][j][h] += getValue(dp, N, M, i - 1, j, h - 1);
                dp[i][j][h] += getValue(dp, N, M, i, j - 1, h - 1);
                dp[i][j][h] += getValue(dp, N, M, i, j + 1, h - 1);

            }
        }
    }
    return dp[i][j][K] / pow(4, K);
}

int getValue(vector<vector<vector<double>>>& dp, int N, int M, int i, int j, int K)
{
    if (i < 0 || i > N - 1 || j < 0 || j > M - 1)
    {
        return 0;
    }
    return dp[i][j][K];
}

零钱兑换(斜率优化)

给你一个整数数组coins,表示不同面额的硬币;以及一个整数aim,表示总金额。
计算并返回可以凑成总金额的方法数。如果没有任何一种硬币组合能组成总金额,返回-1 。
你可以认为每种硬币的数量是无限的。

暴力递归

经典的从左到右试,当前index位置的硬币拿0~aim/coins[index]枚,依次调用递归,最后全部相加再返回。base case是越界并且剩余钱数为0。

int minCoins(vector<int> coins, int aim)
{
    if (coins.size() > 0 && aim >= 0)
    {
        return process(coins, 0, aim);
    }
    return 0;
}

int process(vector<int> coins, int index, int rest)
{
    if (index == coins.size())
    {
        return rest == 0 ? 1 : 0;
    }
    int ways = 0;
    for (int nums = 0; nums * coins[index] <= rest; nums++)
    {
        ways += process(coins, index + 1, rest - coins[index] * nums);
    }
    return ways;
}

动态规划

  1. 分析可变参数的变化范围,确定dp表的维度和大小。可变参数为两个,index的取值范围为0 ~ coins.size(),rest的取值范围为0 ~ aim。假设coins为[3,5,1,2],aim=10,则

  2. 标出要计算的目标位置。

  3. 根据base case填写表中已经知道的数据。

  4. 根据递归推出表中格子之间的依赖关系。根据递归,当前index位置的值等于下一行rest-arr[index]*(0,1,2,3.......)位置(不越界)的值的和。

  5. 确定表中数据的依次计算的顺序。从下到上,从左到右。


代码:

int minCoins(vector<int> coins, int aim)
{
    int size = coins.size();
    vector<vector<int>> dp(size + 1, vector<int>(aim + 1));
    dp[size][0] = 1;
    for(int i = size - 1; i >= 0; i--)
    {
        for (int j = 0; j <= aim; j++)
        {
            for (int nums = 0; nums * coins[i] <= j; nums++)
            {
                dp[i][j] += dp[i + 1][j - coins[i] * nums];
            }
        }
    }
    return dp[0][aim];
}

斜率优化

在上面的动态规划中,三层for循环的最内层存在枚举行为

for (int nums = 0; nums * coins[i] <= j; nums++)
{
    dp[i][j] += dp[i + 1][j - coins[i] * nums];
}

如果coins[i]=1,那么就会把下一行所有格子的数都加起来,那么外层的时间复杂度O(N* aim),枚举的时间复杂度O(aim),他们造成的结果是整个算法的时间复杂度为O(N*aim2)。那么真的有必要枚举吗?这一题中其实是不需要的,如下图

我们要求?位置的值,注意看X位置,即?位置向左偏移一个coin[i]的dp[i][j - coins[i]]位置,由于X位置与?位置处于同一行,所以每次的偏移量一样,所以?位置的值就等于X位置的值加上?位置下面格子的值。由于是从左到右计算的,所以X位置的值已经事先知道,这样就可以把枚举行为变为直接拿值的行为,把时间复杂度从O(aim)降为O(1)。


优化后代码

int minCoins(vector<int> coins, int aim)
{
    int size = coins.size();
    vector<vector<int>> dp(size + 1, vector<int>(aim + 1));
    dp[size][0] = 1;
    for (int i = size - 1; i >= 0; i--)
    {
        for (int j = 0; j <= aim; j++)
        {
            dp[i][j] = j - coins[i] < 0 ? dp[i + 1][j] : dp[i + 1][j] + dp[i][j - coins[i]];
        }
    }
    return dp[0][aim];
}

从暴力递归到动态规划总结

  1. 优化流程:暴力递归(左右尝试、范围尝试)->记忆化搜索->严格表结构的dp->更加精致的严格表结构dp
  2. 尝试方法优劣的评估标准
    (1)每个可变参数的维度越低越好。最好是int类型的变量,即0维变量,如果是一个数组,则列起来太多太复杂。
    (2)可变参数的个数越少越好。多一个参数,dp表就多一个维度,所以越少越好。
posted @ 2022-08-06 13:36  小肉包i  阅读(26)  评论(0)    收藏  举报