LeetCode周赛297,一小时AK你也可以

1,918 阅读2分钟

大家好,日拱一卒,我是梁唐。

今天是周一,我们照惯例来看看昨天的LeetCode周赛。这次周赛是地平线赞助的,如果没记错,这已经不是这个公司第一次赞助了。前5名可以获得直接进入面试的机会,前200名可以获得内推。

这次比赛的题目总体难度不算很大,没有涉及一些比较复杂或冷门的算法,相对来说比较基础,以思维题为主,适合新人练手。

好了,废话不多说,让我们来看题吧。

计算应缴税款总额

给你一个下标从 0 开始的二维整数数组 brackets ,其中 brackets[i] = [upperi, percenti] ,表示第 i 个税级的上限是 upperi ,征收的税率为 percenti 。税级按上限 从低到高排序(在满足 0 < i < brackets.length 的前提下,upperi-1 < upperi)。

税款计算方式如下:

  • 不超过 upper0 的收入按税率 percent0 缴纳
  • 接着 upper1 - upper0 的部分按税率 percent1 缴纳
  • 然后 upper2 - upper1 的部分按税率 percent2 缴纳
  • 以此类推

给你一个整数 income 表示你的总收入。返回你需要缴纳的税款总额。与标准答案误差不超 10-5 的结果将被视作正确答案。

题解

数据范围很小,最多只有100个区间,并且最大收入不会超过1000,基本上属于理解了题意之后,随便操作的问题。

虽然题目说了有精度要求,但实际上由于我们计算税率的时候,最多只有两位小数,所以也不用担心精度的问题。

class Solution {
public:
    double calculateTax(vector<vector<int>>& bk, int income) {
        double ret = 0;
        
        int n = bk.size();
        
        int last = 0;
        for (int i = 0; i < n; i++) {
            if (income == 0) break;
            int level = min(income, bk[i][0] - last);
            ret += level * bk[i][1] / 100.0;
            income -= level;
            last = bk[i][0];
        }
        return ret;
    }
};

网格中的最小路径代价

给你一个下标从 0 开始的整数矩阵 grid ,矩阵大小为 m x n ,由从 0 到 m * n - 1 的不同整数组成。你可以在此矩阵中,从一个单元格移动到 下一行 的任何其他单元格。如果你位于单元格 (x, y) ,且满足 x < m - 1 ,你可以移动到 (x + 1, 0), (x + 1, 1), ..., (x + 1, n - 1) 中的任何一个单元格。注意: 在最后一行中的单元格不能触发移动。

每次可能的移动都需要付出对应的代价,代价用一个下标从 0 开始的二维数组 moveCost 表示,该数组大小为 (m * n) x n ,其中 moveCost[i][j] 是从值为 i 的单元格移动到下一行第 j 列单元格的代价。从 grid 最后一行的单元格移动的代价可以忽略。

grid 一条路径的代价是:所有路径经过的单元格的 值之和 加上 所有移动的 代价之和 。从 第一行 任意单元格出发,返回到达 最后一行 任意单元格的最小路径代价*。*

题解

由于相邻两层的点都能随意连通,简单计算一下就会发现可能存在的路径数量非常庞大,所以直接枚举所有路径是肯定行不通的。

一般情况下这种时候有两种思路,一种是将问题抽象成图论,使用图论的一些算法例如最短路、网络流来解决。另外一种就是动态规划,不去细究路径中的细节,仅仅关注状态和状态之间的转移关系。

对于这题来说,dijkstra最短路和动态规划应该都是可行的。不过从代码复杂度上来看,显然动态规划更加方便一些。

我们用dp[i][j]存储从第0行至坐标(i, j)的最短路径和,从(i, j)出发,我们可以到达所有的(i+1, k)点。对应的状态为dp[i+1][k],这个策略对应的状态是dp[i][j] + moveCost[grid[i][j]][k] + grid[i+1][k]。我们对于所有的策略,维护最小的状态即可。

代码如下:

class Solution {
public:
    int minPathCost(vector<vector<int>>& grid, vector<vector<int>>& moveCost) {
        int n = grid.size();
        int m = grid[0].size();
        
        vector<vector<int>> dp(n+1, vector<int>(m, 0x3f3f3f3f));
        
        for (int i = 0; i < m; i++) dp[0][i] = grid[0][i];
        
        for (int i = 0; i < n-1; i++) {
            for (int j = 0; j < m; j++) {
                for (int k = 0; k < m; k++) {
                    dp[i+1][k] = min(dp[i+1][k], dp[i][j] + moveCost[grid[i][j]][k] + grid[i+1][k]);
                }
            }
        }
        
        int ret = 0x3f3f3f3f;
        
        for (int i = 0; i < m; i++) ret = min(ret, dp[n-1][i]);
        return ret;
    }
};

公平分发饼干

给你一个整数数组 cookies ,其中 cookies[i] 表示在第 i 个零食包中的饼干数量。另给你一个整数 k 表示等待分发零食包的孩子数量,所有 零食包都需要分发。在同一个零食包中的所有饼干都必须分发给同一个孩子,不能分开。

分发的 不公平程度 定义为单个孩子在分发过程中能够获得饼干的最大总数。

返回所有分发的最小不公平程度。

题解

本题的数据范围非常小,最多只有8个孩子和8份饼干。那么我们可以直接枚举所有的分配情况,找到其中不公平程度最小的即可。

虽然思路很简单,但是要实现还是有一点麻烦,需要用到搜索的回溯法。我们使用一个数组sums用来存储每个孩子拿到的饼干总和。当所有饼干分配完成之后,sums当中的最大值就是不公平程度。

代码如下:

class Solution {
public:
    
    int distributeCookies(vector<int>& cks, int k) {
        int ret = 0x3f3f3f3f;
        int n = cks.size();
        vector<int> sums(n, 0);
        function<void(int)> func;
        func = [&](int c) {
            if (c == n) {
                int maxi = sums[0];
                for (int i = 1; i < n; i++) maxi = max(sums[i], maxi);
                ret = min(ret, maxi);
                return ;
            }
            for (int i = 0; i < k; i++) {
                sums[i] += cks[c];
                func(c+1);
                // 回溯
                sums[i] -= cks[c];
            }
        };
        func(0);
        return ret;
    }
};

公司命名

给你一个字符串数组 ideas 表示在公司命名过程中使用的名字列表。公司命名流程如下:

  1. 从 ideas 中选择 2 个 不同 名字,称为 ideaA 和 ideaB 。
  2. 交换 ideaA 和 ideaB 的首字母。
  3. 如果得到的两个新名字 不在 ideas 中,那么 ideaA ideaB(串联 ideaA 和 ideaB ,中间用一个空格分隔)是一个有效的公司名字。
  4. 否则,不是一个有效的名字。

返回 不同 且有效的公司名字的数目。

题解

首先观察数据范围,显然1e4的量级下,我们直接枚举两两字符串是不行的,一定会超时。如果不枚举所有的字符串pair,应该怎么办呢?

我们稍作分析可以发现,对于某个idea来说,如果它和另外一个idea构成冲突,本质上是它和对应的首字母冲突。比如说coffee和toffee,对于coffee来说,所有t开头的idea都不能构成答案。

进一步可以想到,我们可以找到所有和coffee冲突的首字母,排除掉这些字母对应的所有idea。然而这又带来了新的问题,因为和coffee冲突的除了t开头的idea以外,还有那些和字母c开头冲突的idea,这部分也要排除掉。

所以我们也需要维护所有和c字母冲突的idea,但是进一步分析又会发现,和c冲突的idea以及和coffee冲突的idea这两个集合之间是可能有交集的。我们需要保证这些交集的idea只会被去除一次……

显而易见,这样一来问题会变得非常复杂,我们要考虑若干集合的交并情况。对于这个问题又该如何解决呢?

我们可以枚举首字母,比如首字母c和首字母t。确定了首字母之后,首先这两个首字母对应的idea都确定了,并且冲突关系也都确定了。我们可以很容易确定c字母开头的idea有多少和字母t冲突,反之,我们也可以知道首字母t的idea当中又有多少和c字母冲突。

两边的数量减去冲突的数量一乘,就是这两个首字母组合对答案的贡献。由于英文当中只有26个字母,两两字母的组合非常有限,我们完全可以进行枚举。

class Solution:
    def distinctNames(self, ideas: List[str]) -> int:
        n = len(ideas)
        chars = 'abcdefghijklmnopqrstuvwxyz'
        ret = 0
        
        ideas = set(ideas)
        
        from collections import defaultdict
        
        # 首字母之间的冲突关系,如字母c和t冲突,记作forbid[c][t]++
        forbid = {c : defaultdict(int) for c in chars}
        # 首字母对应的idea数量
        cnts = defaultdict(int)
        
        for wd in ideas:
            c = wd[0]
            cnts[c] += 1
            for nc in chars:
                if c == nc:
                    continue
                nword = nc + wd[1:]
                # 如果更换首字母之后依然在idea中能找到,说明存在冲突
                if nword in ideas:
                    forbid[c][nc] += 1
        
        for c in chars:
            for nc in chars:
                if c == nc:
                    continue
                lef = cnts[c]
                rig = cnts[nc]
                if lef == 0 or rig == 0:
                    continue
                # 去掉冲突的数量
                lef -= forbid[c][nc]
                rig -= forbid[nc][c]
                ret += lef * rig
            
        return ret

这个代码和算法本身都不难,但是要把题目中的含义以及思路理清楚并不容易,这才是最锻炼人的。

我个人也比较喜欢这样的问题,它对于算法的考察不是硬性的。不是你不知道某个算法就一定解不出来的题目,而是本身就没有特定的解决方案,就是需要依靠你的思考和分析从问题当中自己找到答案。

尤其是灵光乍现,找到解法的那一刻,真的是成就感爆棚,非常畅快。

希望各位都能体会到算法和思考的魅力,找到自己的畅快时刻。