90 数字三角形(Triangle)

1 题目

题目:数字三角形(Triangle)
描述:给定一个数字三角形,找到从顶部到底部的最小路径和。每一步可以移动到下面一行的相邻数字上。

lintcode题号——109,难度——medium

样例1:

输入:
triangle = [
     [2],
    [3,4],
   [6,5,7],
  [4,1,8,3]
]
输出:11
解释:从顶到底部的最小路径和为11 ( 2 + 3 + 5 + 1 = 11)。

样例2:

输入:
triangle = [
     [2],
    [3,2],
   [6,5,7],
  [4,4,8,1]
]
输出:12
解释:从顶到底部的最小路径和为12 ( 2 + 2 + 7 + 1 = 12)。

2 解决方案

2.1 思路和图解

2.1.1 遍历法和分治法

  这题按照常规思路,可以使用遍历法或者分治法来做.
  使用遍历法的方式,从顶点往下走,每次向左下或者右下的节点移动,走到最底层时判断路径和是否最小.
  使用分治法的方式,可以将当前节点到最底层的最小路径和进行拆分,转化为对左下节点与右下节点中取较小的路径和的问题。

但是如果按照遍历或者分治来解这一题,提交时对数据量很大的样例会提示代码运行超时。所以需要考虑优化。

二叉树
数字三角形
2
1
3
4
5
6
7
8
9
10
11
12
13
14
15
2
1
3
4
5
6
7
8
9
10

  我们使用遍历和分治的场景一般能够对应一颗二叉树,分析二叉树的图与该题的数字三角形的图,它们很相似,但区别在于二叉树中到达每个节点的路径是唯一的,但数字三角形中的分支节点是被共用的,到达一个节点的路径可以从左肩或者右肩而来,并不唯一。
  换个说法,就是我们使用遍历法来处理数字三角形,在某个节点向下走的时候,它的右下节点可能会被它右边同层的节点遍历第二次,这样就造成了重复访问,且每次访问该节点都需要向下再次遍历该节点的所有子分支来计算该节点的最优解,所以这种重复计算的情况对越底层的节点会出现越多次。这就造成了该题的解法还有优化的空间。

2.1.2 带记忆化搜索的分治法

  为了不进行重复计算,我们会考虑在第一次访问节点后,将计算出的最优值保存起来,下次从不同路径访问该节点时,就不用再遍历该节点的子分支来重新计算一遍最优值了。
  使用带记忆化搜索的分治法的方式,可以避免常规分治法中对节点最优值的重复计算。

数字三角形
step1
step2
step3
step4
step5
step6
2: Val=NULL
1: Val=NULL
3: Val=NULL
4: Val=11
5: Val=NULL
6: Val=NULL
7: Val=7
8: Val=8
9: Val=NULL
10: Val=NULL

上图对数字三角形(假设节点值就等于其节点序号)进行分治法,要计算节点1的最优值,先计算左右子分支,即节点2、3,先计算左子分支,这样一直向下,就经过1->2->4->7->4->8->4,回到节点4之后,就记录下了节点4的当前最优解为11,即从4->7到底层比从4->8到底层的路径和要小,是当前的最优解。依次计算出所有节点的最优解,顶点的最优解即是答案。
关于重复计算的问题,以节点5为例,我们第一次访问节点5(从1->2->5),需要继续向下计算完节点8和9才能得到节点5的最优解,此时对该值进行记录,等第二次访问节点5(从1->3->5),则不需要向下计算子分支,直接取之前保存的最优解即可。这样就避免了重复计算。

  其实带记忆化搜索的分治法已经是动态规划中的一种了,动态规划的解决方案分为两种形式,一种是带记忆化搜索的递归,另一种是多重循环。

比较标准的动态规划一般是用多重循环来实现的,在代码中可以看出差别。而多重循环的动态规划也有两种分类,一种是至底向上的方式,另一种是至顶向下的方式。

2.1.3 至底向上的动态规划

  对动态规划的分析有四个要素,状态、方程、初始化、答案。

动态规划的四要素对应递归的三要素:
动态规划中的状态——递归中的定义
动态规划中的方程——递归中的拆解
动态规划中的初始化——递归中的出口
动态规划多了一个要素,即答案

至底向上的动态规划
2
1
3
4
5
6
7
8
9
10
状态: State[n]——表示节点n到最底层的路径和的最小值
方程: State[n] = Value[n] + min(State[n的左子节点], State[n的右子节点])
初始化: State[底层节点] = Value[底层节点]
答案: State[顶点节点]

  首先解释状态,状态指的是在一个大问题中的某个阶段的当前小规模问题的解,可以看成递归中的定义。在示例中,我们将状态定义为当前节点的最优解(从当前节点到最底层的最小路径和),上图中的节点5的状态即表示从节点5到最底层的最小路径和,记做State[n]
  再看方程,方程指的是如何将大状态用小状态来表示,可以看成递归中的拆解。示例中,State[节点5] = Value[节点5] + min(State[节点8], State[节点9]),节点5的最优解可以拆解为节点8和节点9的最优解中较小的一个加上节点5自身的值。
  还需要确定初始化状态,初始化状态表示动态规划的起点,对应的是递归中的出口,有了初始化状态才能以此为基础进行至底向上的求解,是动态规划求解问题的极限小的状态。示例中,要求解某个节点的最优解,必须得不断向下拆解,直到最底层的元素,所以这里的极限小的状态就是最底层元素的最优解,它们的最优解即为自身节点值,State[底层节点] = Value[底层节点]
  最后需要确定要得到的最终答案,答案指的是动态规划求解问题的极限大的状态。示例中,我们要求解的是从顶点的最优解,所以答案 = State[顶点节点]

状态:存储当前小规模问题的结果。
方程:通过小状态来表示大状态的式子。
初始化:最极限的小状态。
答案:要得到的最大状态。

  把四要素都理清了之后,写出的代码就很清晰了。参看3.4节。

2.1.4 至顶向下的动态规划

  对至顶向下的动态规划而言,其形式是一样的,只是对应的四要素的定义有所不同。
  首先状态,在示例中,将状态同样定义为当前节点的最优解,但是是从顶点到当前节点的最小路径和(注意与至底向上的定义区分开),下图中的节点5的状态即表示从顶点1到节点5的最小路径和,记做State[n]
  再看方程,示例中,State[节点5] = Value[节点5] + min(State[节点2], State[节点3]),节点5的最优解可以拆解为节点2和节点3的最优解中较小的一个加上节点5自身的值。
  确定初始化状态,示例中,要求解某个节点的最优解,必须从左、右父节点向下计算,所以这里的极限小的状态就是三角形两条腰上节点的最优解,它们的最优解需要从顶点累加而来,两腰上的节点都只有唯一的父节点,State[三角形两腰上的节点] = Value[节点自身] + State[唯一父节点]
  最后需要确定要得到的最终答案,示例中,至顶向下计算,每个底层节点都会得到一个最优解,我们需要再次进行比较,得到最终答案,所以答案 = min(State[所有底层节点])

至底向上的动态规划
2
1
3
4
5
6
7
8
9
10
状态: State[n]——表示从顶点1到节点n的路径和的最小值
方程: State[n] = Value[n] + min(State[n的左父节点], State[n的右父节点])
初始化: State[三角形两腰上的节点] = Value[节点自身] + State[唯一父节点]
答案: min(State[所有底层节点])

  至顶向下与至底向上只是对状态的定义的不同,都需要把四要素理清,对应的代码参看3.5节。

2.3 时间复杂度

2.3.1 以树高h为基准计算

  如果使用树高h为基准,可以更直观地对比遍历、分治与动态规划:

对于高为h的数字三角形,节点数的数量级是h的平方。
节点数 = 1 + 2 + 3 + …… + (h + 1)
      = h * (1 + (h + 1)) / 2
      = (h^2 + 2) / 2

对于高为h的二叉树,节点数的数量级为2的h次方。
节点数 = 1 + 2 + 4 + …… + 2^(h - 1)
      = 1 * (1 - 2^h) / (1 - 2)
      = 2^h - 1

  使用遍历法和分治法,每次访问一个节点后,有左右两条路径要走,节点数为h^2,时间复杂度为O(2^(h^2))

重复访问节点导致的耗时为指数级增长,

  使用动态规划的方式,每个节点只计算一次,后续可以直接使用计算值,没有对子节点的重复访问和计算,节点数为h^2,时间复杂度为O(h^2)。

2.3.2 以节点数n为基准计算

  使用遍历法和分治法,每次访问一个节点后,有左右两条路径要走,节点数为n,时间复杂度为O(2^n)
  使用动态规划的方式,每个节点只计算一次,后续可以直接使用计算值,没有对子节点的重复访问和计算,节点数为n,时间复杂度为O(n)。

2.4 空间复杂度

  使用遍历法和分治法,只使用常数级的额外空间,空间复杂度为O(1)。
  使用动态规划——带记忆化搜索的分治法,使用了容量为节点数n的容器用于记录当前节点最优值,空间复杂度为O(n)。
  使用多重循环的动态规划,使用了容量为节点数n的容器用于记录当前节点最优值,空间复杂度为O(n)。

3 源码

3.1 遍历法

细节:

  1. 递归的三要素:定义、拆解、出口。
  2. 遍历法走遍所有的节点,并在最底层节点计算出路径和。

该题使用遍历法在Lintcode上Submit的时候并不能获得完全Accept,因为时间复杂度高,在数据量大的时候会Time Limit Exceeded。这里提供代码只做为参考。

C++版本:

/**
* @param triangle: a list of lists of integers
* @return: An integer, minimum path sum
*/
int minimumTotal(vector<vector<int>> &triangle) {
    // write your code here
    int result = INT_MAX;
    if (triangle.empty() || triangle.front().empty())
    {
        return 0;
    }

    traverse(triangle, 0, 0, 0, result);

    return result;
}

// 递归的定义:获取第row行、第col列的值,并与当前的最小值打擂台
void traverse(vector<vector<int>> & triangle, int row, int col, int sum, int & result)
{
    if (row == triangle.size()) // 递归的出口
    {
        return;
    }

    sum += triangle.at(row).at(col);
    if (row == triangle.size() - 1 && sum < result) // 在最后一行计算出来的路径和,与当前最小值比较
    {
        result = sum;
    }

    // 递归的拆解
    traverse(triangle, row + 1, col, sum, result); // 向左便利
    traverse(triangle, row + 1, col + 1, sum, result); // 向右便利
}

3.2 分治法

细节:

  1. 递归的三要素:定义、拆解、出口。
  2. 分治法将子问题分给左右分支去处理,与遍历法最大的区别是,分治法有返回值。

该题使用传统的分治法在Lintcode上Submit的时候并不能获得完全Accept,因为时间复杂度高,在数据量大的时候会Time Limit Exceeded。为了顺利获得Accept,需要对分治法进行改进,添加记忆化搜索,下一小节贴上代码。

C++版本:

/**
 * @param triangle: a list of lists of integers
 * @return: An integer, minimum path sum
 */
int minimumTotal(vector<vector<int>> &triangle) {
    // write your code here
    int result = INT_MAX;
    if (triangle.empty() || triangle.front().empty())
    {
        return 0;
    }

    result = divideAndConquer(triangle, 0, 0);

    return result;
}

// 递归的定义:计算以第row行、第col列的节点为起点,向下的所有路径和之中最小的值,并返回
int divideAndConquer(vector<vector<int>> & triangle, int row, int col)
{
    if (row == triangle.size()) // 递归的出口
    {
        return 0;
    }

    int val = triangle.at(row).at(col);

    // 递归的拆解
    int leftResult = divideAndConquer(triangle, row + 1, col); // 计算左支节点向下的最小值
    int rightResult = divideAndConquer(triangle, row + 1, col + 1); // 计算右支节点向下的最小值

    return val + min(leftResult, rightResult);
}

3.3 动态规划——带记忆化搜索的分治法

细节:

  1. 与传统的分治法相比,添加了一个容器用于记忆化搜索。
  2. 每到一个节点,计算出结果之后,保存到容器中进行记录,下一次再访问该节点的时候,直接从容器中取值即可,避免了重复计算。

带记忆化搜索的分治法已经是动态规划的一种了,去除了传统分治法中的重复计算过程。

C++版本:

/**
* @param triangle: a list of lists of integers
* @return: An integer, minimum path sum
*/
int minimumTotal(vector<vector<int>> &triangle) {
    // write your code here
    int result = INT_MAX;
    if (triangle.empty() || triangle.front().empty())
    {
        return 0;
    }

    // 使用一个与triangle容量相同的容器来记录各个节点的最优值
    vector<vector<int>> sum;
    for (auto it : triangle)
    {
        sum.push_back(vector<int>(it.size(), INT_MAX));
    }

    result = divideAndConquer(triangle, sum, 0, 0);

    return result;
}

// 递归的定义:计算以第row行、第col列的节点为起点,向下的所有路径和之中最小的值,并返回
int divideAndConquer(vector<vector<int>> & triangle, vector<vector<int>> & sum, int row, int col)
{
    if (row == triangle.size()) // 递归的出口
    {
        return 0;
    }

    if (sum.at(row).at(col) != INT_MAX)
    {
        return sum.at(row).at(col); // 如果当前位置已经有记录值,则不用重复计算,直接返回记录值
    }

    int val = triangle.at(row).at(col);

    // 递归的拆解
    int leftResult = divideAndConquer(triangle, sum, row + 1, col); // 计算左支节点向下的最小值
    int rightResult = divideAndConquer(triangle, sum, row + 1, col + 1); // 计算右支节点向下的最小值

    // 记录当前节点的最优值
    sum.at(row).at(col) = val + min(leftResult, rightResult);

    return sum.at(row).at(col);
}

3.4 动态规划——至底向上

细节:

  1. 动态规划的四要素:状态、方程、初始化、答案。
  2. 状态:用dp[i][j]表示以第i行、第j列的节点为起点向下的路径和的最小值。
  3. 方程:dp[i][j] = val + min(dp[i+1][j], dp[i+1][j+1]),当前节点最优解 = 当前节点值 + 左右分支中较小的值。
  4. 初始化:dp[maxRow][n] = triangle[maxRow][n],最后一行的路径和的值即为节点自身的值。
  5. 答案:要计算的结果是dp[0][0]

C++版本:

/**
 * @param triangle: a list of lists of integers
 * @return: An integer, minimum path sum
 */
int minimumTotal(vector<vector<int>> &triangle) {
    // write your code here
    if (triangle.empty() || triangle.front().empty())
    {
        return 0;
    }

    // 状态:dp[i][j]表示以第i行、第j列的节点为起点向下的路径和的最小值
    vector<vector<int>> dp;
    for (auto it : triangle)
    {
        dp.push_back(vector<int>(it.size(), 0));
    }

    // 初始化:最后一行的路径和的值即为节点自身的值
    dp.back() = triangle.back();
    
    for (int i = dp.size() - 2; i >= 0; i--)
    {
        for (int j = 0; j < dp.at(i).size(); j++)
        {
            // 方程式:当前节点最优解 = 当前节点值 + 左右分支中较小的值
            dp[i][j] = triangle[i][j] + min(dp[i + 1][j], dp[i + 1][j + 1]);
        }
    }

    return dp[0][0]; // 答案:要计算的是从顶点向下的最优解
}

3.5 动态规划——至顶向下

细节:

  1. 动态规划的四要素:状态、方程、初始化、答案。
  2. 状态:用dp[i][j]表示从顶点向下到第i行、第j列的节点所有路径中的路径和的最小值。
  3. 方程:dp[i][j] = val + min(dp[i-1][j-1], dp[i-1][j]),当前节点最优解 = 当前节点值 + 左右肩上游节点中较小的值。
  4. 初始化:三角形两条斜边上的值可以通过从顶点进行累加计算出来。
  5. 答案:要得到的结果是最底层值中最小的值,即min(dp[所有底层节点])

初始化操作一般都是为了把不能通过方程得到的非常规点的值先计算出来,该解法中三角形的斜边上的点都缺失左肩或者右肩上的上游节点,所以先把斜边上的点计算出来有利于后续循环的顺利进行。

C++版本:

/**
* @param triangle: a list of lists of integers
* @return: An integer, minimum path sum
*/
int minimumTotal(vector<vector<int>> &triangle) {
    // write your code here
    if (triangle.empty() || triangle.front().empty())
    {
        return 0;
    }

    // 状态:dp[i][j]表示从顶点向下到第i行、第j列的节点所有路径中的路径和的最小值
    vector<vector<int>> dp;
    for (auto it : triangle)
    {
        dp.push_back(vector<int>(it.size(), 0));
    }

    // 初始化:三角形两条斜边上的值
    dp[0][0] = triangle[0][0];
    for (int i = 1; i < triangle.size(); i++)
    {
        dp[i][0] = triangle[i][0] + dp[i - 1][0];
        dp[i][i] = triangle[i][i] + dp[i - 1][i - 1];
    }

    for (int i = 2; i < dp.size(); i++)
    {
        for (int j = 1; j < dp.at(i).size() -1; j++)
        {
            // 方程:当前节点最优解 = 当前节点值 + 左右肩上游节点中较小的值
            dp[i][j] = triangle[i][j] + min(dp[i - 1][j - 1], dp[i - 1][j]);
        }
    }

    // 答案:要得到的结果是最底层值中最小的值
    int result = INT_MAX;
    for (auto it : dp.back())
    {
        if (it < result)
        {
            result = it;
        }
    }

    return result;
}
  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值