【微软算法面试高频题】戳气球

447 阅读2分钟

微软和谷歌的几个大佬组织了一个面试刷题群,可以加管理员VX:sxxzs3998(备注掘金),进群参与讨论和直播

1. 题目

有 n 个气球,编号为 0 到 n - 1,每个气球上都标有一个数字,这些数字存在数组 nums 中。

现在要求你戳破所有的气球。戳破第 i 个气球,你可以获得 nums[i - 1] * nums[i] * nums[i + 1] 枚硬币。 这里的 i - 1 和 i + 1 代表和 i 相邻的两个气球的序号。如果 i - 1或 i + 1 超出了数组的边界,那么就当它是一个数字为 1 的气球。

求所能获得硬币的最大数量。

示例 1:
输入:nums = [3,1,5,8]
输出:167
解释:
nums = [3,1,5,8] --> [3,5,8] --> [3,8] --> [8] --> []
coins =  3*1*5    +   3*5*8   +  1*3*8  + 1*8*1 = 167

示例 2:
输入:nums = [1,5]
输出:10

2. 解析

涉及求最值的问题,首先想到的就是穷举所有可能的结果,然后对比得出最值。

穷举主要有两种算法,就是回溯算法和动态规划。

2.1 回溯算法

如果采用回溯算法,那其实就是穷举戳气球的顺序,不同的戳气球顺序可能得到不同的分数,然后将最高的那个分数找出来。伪码如下:

int res = Integer.MIN_VALUE;
/* 输入一组气球,返回戳破它们获得的最大分数 */
int maxCoins(int[] nums) {
    backtrack(nums, 0); 
    return res;
}
/* 回溯算法的伪码解法 */
void backtrack(int[] nums, int socre) {
    if (nums 为空) {
        res = max(res, score);
        return;
    }
    for (int i = 0; i < nums.length; i++) {
        int point = nums[i-1] * nums[i] * nums[i+1];
        int temp = nums[i];
        // 做选择
        在 nums 中删除元素 nums[i]
        // 递归回溯
        backtrack(nums, score + point);
        // 撤销选择
        将 temp 还原到 nums[i]
    }
}

但显然,算法的效率并不高,时间复杂度是阶乘级别的。

2.2 动态规划

此处问题有一点很关键在于:每戳破一个气球 nums[i],得到的分数和该气球相邻的气球 nums[i-1] 和 nums[i+1] 是有相关性的。

首先,要想清楚如何定义这个子问题,也就是如何定义dp数组。对于题目中的nums[-1] = nums[n] = 1,我们先将边界加进去,

int maxCoins(int[] nums) {
    int n = nums.length;
    // 两端加入两个虚拟气球
    int[] points = new int[n + 2];
    points[0] = points[n + 1] = 1;
    for (int i = 1; i <= n; i++) {
        points[i] = nums[i - 1];
    }
    // ...
}

现在的气球索引变成了从1到n,points[0]和points[n+1]可以认为是两个虚拟气球。那么问题转化为,在一排气球points当中,戳破0到n+1之间的所有气球。能够得到多少分?

那么,dp数组的含义可以定义为:dp[i][j] = x 表示,戳破气球 i 和气球 j 之间(开区间,不包括 i 和 j)的所有气球,可以获得的最高分数为 x。

最终需要得到的结果是dp[0][n+1]的值,basecase是dp[i][j] = 0,0 <= i <= n+1, j <= i+1,再次情况下,开区间(i, j)根本没有气球可以戳。

接下来要确定状态转移方程,我们假定最后一个戳破的气球为k,那么dp[i][j]的值就应该为 dp[i][j] = dp[i][k] + dp[k][j] + points[i]*points[k]*points[j] 如果要最后戳破气球k,那得先把开区间(i, k)气球都戳破(得到的分数为dp[i][k]),再把开区间(k, j)的气球都戳破(得到的分数为dp[k][j]);剩下的气球k,相邻的就是气球i和气球j,这时候戳破k的话得到的分数就是points[i]*points[k]*points[j]。

image.png

所以最后的状态转移方程:

// 最后戳破的气球是哪个?
for (int k = i + 1; k < j; k++) {
    // 择优做选择,使得 dp[i][j] 最大
    dp[i][j] = Math.max(
        dp[i][j], 
        dp[i][k] + dp[k][j] + points[i]*points[j]*points[k]
    );
}

然后再穷举状态i和j,如下

for (int i = ...; ; )
    for (int j = ...; ; )
        for (int k = i + 1; k < j; k++) {
            dp[i][j] = Math.max(
                dp[i][j], 
                dp[i][k] + dp[k][j] + points[i]*points[j]*points[k]
            );
return dp[0][n+1];

代码层面的图示推导如下:

首先,basecase在DP table上画出来如下:

image.png

在此过程中,遍历的方向是这样的:(由于我们规定i<j)

image.png

那么,完整的代码如下:

int maxCoins(int[] nums) {
    int n = nums.length;
    // 添加两侧的虚拟气球
    int[] points = new int[n + 2];
    points[0] = points[n + 1] = 1;
    for (int i = 1; i <= n; i++) {
        points[i] = nums[i - 1];
    }
    // base case 已经都被初始化为 0
    int[][] dp = new int[n + 2][n + 2];
    // 开始状态转移
    // i 应该从下往上
    for (int i = n; i >= 0; i--) {
        // j 应该从左往右
        for (int j = i + 1; j < n + 2; j++) {
            // 最后戳破的气球是哪个?
            for (int k = i + 1; k < j; k++) {
                // 择优做选择
                dp[i][j] = Math.max(
                    dp[i][j], 
                    dp[i][k] + dp[k][j] + points[i]*points[j]*points[k]
                );
            }
        }
    }
    return dp[0][n + 1];
}

微软和谷歌的几个大佬组织了一个面试刷题群,可以加管理员VX:sxxzs3998(备注掘金),进群参与讨论和直播