4 minute read

5/29

T435 无重叠区间

1、这一题需要再次细致研究证明

2、和前面两题不同,它每次考虑的是当前较大的端点值,而非二维排序后的最值

3、两种解法,分别聚焦保留区间与舍弃区间:

代码一

class Solution {
public:
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        // 按区间右端点从小到大排序,优先保留结束最早的区间。
        // 这样可以给后面的区间留下更多空间,是本题的贪心策略。
        sort(intervals.begin(), intervals.end(), [](const vector<int>& a, const vector<int>& b) {
            return a[1] < b[1];
        });

        // keep 表示当前最多能保留下来的不重叠区间数量。
        int keep = 1;
        // lastEnd 表示上一个被保留区间的右端点。
        int lastEnd = intervals[0][1];

        for (int i = 1; i < intervals.size(); ++i) {
            // 当前区间的左端点 >= 上一个保留区间的右端点,说明不重叠。
            // 注意:[1,2] 和 [2,3] 只在一点接触,也算不重叠。
            if (intervals[i][0] >= lastEnd) {
                ++keep;
                lastEnd = intervals[i][1];
            }
        }

        // 总区间数 - 最多能保留的区间数 = 最少需要删除的区间数。
        return intervals.size() - keep;
    }
};

代码二

class Solution {
public:
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        // 按左端点从小到大排序,方便从左到右判断相邻区间是否重叠。
        sort(intervals.begin(), intervals.end(), [](const vector<int>& a, const vector<int>& b) {
            return a[0] < b[0];
        });

        // remove 表示已经删掉的区间数量。
        int remove = 0;
        // lastEnd 表示当前保留下来的区间中,最后一个区间的右端点。
        int lastEnd = intervals[0][1];

        for (int i = 1; i < intervals.size(); ++i) {
            // 当前区间左端点 < lastEnd,说明和前面保留的区间发生重叠。
            if (intervals[i][0] < lastEnd) {
                // 两个区间重叠时,必须删掉一个。
                // 为了尽量不影响后面的区间,删掉右端点更大的那个。
                ++remove;
                lastEnd = min(lastEnd, intervals[i][1]);
            } else {
                // 不重叠时,当前区间可以保留,更新最后保留区间的右端点。
                lastEnd = intervals[i][1];
            }
        }

        return remove;
    }
};

T452 用最少数量的箭引爆气球

1、这一题正好印证了上一题的结论:想不到思路的时候,先排序。本题的难度在于直觉性的排序会让人觉得有很多反例,犹豫是否是正确的。具体面试时可以先写,AC了再来思考正确性证明。

2、核心逻辑:按照右端点从小到达排序,每次先选最小的射箭,干掉一系列符合要求的左端点,遇到不符合时再更新右端点。注意:假设有这样一个排序后的 [1,6] [2,8] [7,12] [3,15] [10,16]

假如一开始射箭6,左端点7时射出第二支箭,12。但后面又出现【3,15】。这个3其实是符合6的,所以不需要12,但毕竟有个7需要12,我们现在只需要让【3,15】也能被12干掉。之前的要求是3<=6,现在是3<=12,放宽松了,故不必担心此等情况。

3、代码如下:

class Solution {
public:

    int findMinArrowShots(vector<vector<int>>& points) {

        if (points.empty())
            return 0;

        // 按右端点升序排序
        sort(
            points.begin(),
            points.end(),
            [](const vector<int>& a, const vector<int>& b)
            {
                return a[1] < b[1];
            }
        );

        int ans = 1;

        // 第一支箭射在第一个区间右端点
        long long pos = points[0][1];

        for (int i = 1; i < points.size(); i++)
        {
            // 当前气球无法被之前那支箭射爆
            if (points[i][0] > pos)
            {
                ans++;

                // 新箭射在当前区间右端点
                pos = points[i][1];
            }
        }

        return ans;
    }
};

4、非常关键的一种正确性证明法:证明这样做不会比最优解差

算法

按右端点从小到大排序。

每次选择当前还没被射爆的第一个气球 [l, r], 把箭射在它的右端点 r。

然后所有满足 start <= r 的气球都会被这支箭射爆。 继续处理下一个没被射爆的气球。

证明:为什么这是最少支箭? 设排序后,当前还没被射爆的第一个气球是:

[l, r]

因为它还没被射爆,所以我们无论如何都必须再射一支箭来射爆它。 也就是说:

最优解中也必须有一支箭落在 [l, r] 内

假设最优解中这支箭射在:

x

其中:

l <= x <= r

现在我们把这支箭从 x 移到 r。 要证明这样不会变差。

很简单:对于【l r】[]的区间,都射不到。对于【l [r】]的区间,r会比x射到更多可能的,也就是优于或至少一样,对于假定最优解X。对于【l [] r】或[【l ] r】这种右边界小于r的,chu 初看x会更优,实则由于r已经是所有右边界里的最小值,这两种情况根本不会出现。综上,按照算法每次循环选值,必然不会差于想要干掉当前气球(而这是必须做到的)假定最优解。证毕。

T406 根据身高重建队列

1、这一题非常巧妙,一开始我只想到两个特性: //对于y为同一值的人,按照身高顺序由小到大排列。也就是每一组y里,不管身高连不连续,在整个视角下都应该非递减 //对于同样的身高,y值越大的放在越右侧

2、然而,这不足以解题。学习题解后,这里面涉及一个非常有趣的证明:假如我们先按照身高大到小、k值小到大的顺序排序,再按照这个序列依次执行插入,则队列已有的所有元素一定大于等于每次新插入的元素,也就是新插入元素时只需插入到位置坐标为K的地方即可满足要求。每轮循环类推,最终解亦满足。

3、代码如下:

class Solution {
public:

    // 排序规则:
    // 1. 身高高的优先(降序)
    // 2. 身高相同时,k小的优先(升序)
    static bool cmp(vector<int>& a, vector<int>& b)
    {
        // 身高相同
        if (a[0] == b[0])
            return a[1] < b[1];

        // 身高不同,高的排前面
        return a[0] > b[0];
    }

    vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {

        // 先排序
        sort(
            people.begin(),
            people.end(),
            cmp
        );

        // 最终队列
        vector<vector<int>> ans;

        // 依次处理排序后的每个人
        for (auto& p : people)
        {
            // p[0] = 身高
            // p[1] = 前面需要有多少个身高 >= 自己的人

            // 由于已经按身高降序排列,
            // 当前队列中的所有人身高都 >= 当前人
            //
            // 因此直接插入到下标 p[1] 的位置即可:
            // 前面恰好有 p[1] 个人,
            // 且这些人一定满足身高 >= 当前人
            ans.insert(
                ans.begin() + p[1],
                p
            );
        }

        return ans;
    }
};

4、注意这里insert会挪动整个数组,因此题干给的总人数只有2000,配合高复杂度计算。

5、从这一题可以学到,当我们没有思路时,找一些特性排序可以提供灵感,先直觉再证明。

5/27

T860 柠檬水找零

1、这一题看起来很简单,思路如下: //从左到右遍历数组,维护三个数值:5 10 20当前已有钞票数 //对于每一个新顾客,更新这三个数值。特别的,如果是10,观察5的数值是否大于等于1,如果是20,观察10+5或5+5+5是否存在。更新三个数值

2、实际上,这是存在坑的,即需使用贪心逻辑的地方:在遇到20账单时必须优先找10+5,再寻找5+5+5,因为5的灵活性更大,能用次一点的满足就把更好的留到后面。

局部最优:遇到账单20,优先消耗美元10,完成本次找零。全局最优:完成全部账单的找零。

3、最终代码如下:

class Solution {
public:
    bool lemonadeChange(vector<int>& bills) {

        // 当前拥有的 5、10、20 美元数量
        int five = 0;
        int ten = 0;
        int twenty = 0;

        // 按顾客顺序处理
        for (int bill : bills) {

            // 顾客给5元,不需要找零
            if (bill == 5) {
                five++;
            }

            // 顾客给10元,需要找5元
            else if (bill == 10) {

                // 没有5元无法找零
                if (five < 1)
                    return false;

                five--;
                ten++;
            }

            // 顾客给20元,需要找15元
            else {

                // 优先使用 10 + 5
                if (ten >= 1 && five >= 1) {
                    ten--;
                    five--;
                }

                // 否则使用 5 + 5 + 5
                else if (five >= 3) {
                    five -= 3;
                }

                // 都无法满足
                else {
                    return false;
                }

                twenty++;
            }
        }

        return true;
    }
};

5/26

T135 分发糖果

1、这一题虽为困难标签,但思路不难,我的初始思路可以AC: //统计连续上升/下降相邻峰的个数,若为连续的同方向的峰,每次糖果数都要在前一个基础上加一,直到方向改变或值相同,此时都赋值新糖果数为1 //注意,若从上升/连续改为下降峰,检测到下降峰结束后,将最小分数的孩子糖果数赋值为1,左侧同一个下降峰上的糖果数加上1和原先那个孩子糖果数的差值

2、本题代码如下:

class Solution {
public:
    int candy(vector<int>& ratings) {

        int n = ratings.size();

        // 每个孩子至少一个糖果
        vector<int> candy(n,1);

        int i=1;

        while(i<n){

            // 平坡
            if(ratings[i]==ratings[i-1]){

                candy[i]=1;

                i++;

                continue;
            }

            // 上升峰
            while(
                i<n &&
                ratings[i]>ratings[i-1]
            ){

                candy[i]=candy[i-1]+1;

                i++;
            }

            // 记录下降峰起点
            int peak=i-1;

            // 下降峰长度
            int down=0;

            while(
                i<n &&
                ratings[i]<ratings[i-1]
            ){

                down++;

                i++;
            }

            // 存在下降峰
            if(down>0){

                // 最低点赋值1
                candy[peak+down]=1;

                // 从右往左补
                for(
                    int j=peak+down-1;
                    j>=peak;
                    j--
                ){

                    candy[j]=max(
                        candy[j],
                        candy[j+1]+1
                    );
                }
            }
        }

        int sum=0;

        for(int x:candy)
            sum+=x;

        return sum;
    }
};

3、有两个重要的坑:一个是 candy[j]=max( candy[j], candy[j+1]+1 ); 这里要防止下降峰从有至左赋值时把上升峰的那一个也干掉了,得取最大值。例如: [1,3,4,5,2]

再就是对于下降峰,不能直接一个个减,有可能会减到负数,我的处理方式是最低点赋值糖果数1,往左侧++,最后再处理方才那个坑。

T134 加油站

1、这一题我一开始认为不必按照贪心做,因为核心是节省重复计算浪费的时间,直接用前缀和思想即可。

2、我的初始思路如下: //用gas减去cost对应的值,存储为新数组 //从第一个加油站开始,计算后续g-c的每一节点的累加和 //保持一个累加和最小值,若为负数,pass该加油点 //继续第二个加油站,把最小累加和减去1到2加油站之间的g-c差值,对比上一个循环最后累加和+ 1到2之间的g-c差值,取两者最小值更新为累加和最小值 //观察是否为负数,若是,pass,若不是,返回该编号 //直到遍历结束,若都无,返回-1

这个思路的错误点:移动起始位置后,原先的累加和最小值可能不存在了(正好是开头两个加油站时),此时我们并未记录第二小值,因此算法无法继续;若每次都重新计算累加和最小值,则和暴力解法无区别,会超时。

3、可以观察到,该问题和单调队列要解决的滑动窗口问题很像,尝试采用该方法优化时间复杂度,AC:

class Solution {
public:
    int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {

        int n = gas.size();

        vector<int> diff(2*n);

        // 环展开两倍
        for(int i=0;i<n;i++){

            diff[i]=gas[i]-cost[i];

            diff[i+n]=diff[i];
        }

        // 前缀和
        vector<long long> pre(2*n+1,0);

        for(int i=1;i<=2*n;i++)
            pre[i]=pre[i-1]+diff[i-1];

        deque<int> q;

        // 初始化第一个窗口
        for(int i=1;i<=n;i++){

            while(!q.empty() &&
                  pre[q.back()]>=pre[i])
                q.pop_back();

            q.push_back(i);
        }

        // 枚举起点
        for(int start=0;start<n;start++){

            long long base=pre[start];

            // 当前窗口最小值
            long long minsum=
                pre[q.front()]-base;

            if(minsum>=0)
                return start;

            // 删除过期
            while(!q.empty() &&
                  q.front()==start+1)
                q.pop_front();

            int nxt=start+n+1;

            if(nxt<=2*n){

                while(!q.empty() &&
                      pre[q.back()]>=pre[nxt])
                    q.pop_back();

                q.push_back(nxt);
            }
        }

        return -1;
    }
};

4、然而,这样做的缺点很明显:时间效率仍然很低,最优解依旧应当考虑贪心算法。贪心解法的核心和累加和很像,具体代码如下:

class Solution {
public:
    int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {

        int n = gas.size();

        int sum = 0;

        // 总累加和
        int minsum = INT_MAX;

        // 最小前缀和对应位置
        int minpos = -1;

        for(int i=0;i<n;i++){

            sum += gas[i]-cost[i];

            if(sum < minsum){

                minsum = sum;

                minpos = i;
            }
        }

        // 总油量不足
        if(sum < 0)
            return -1;

        // 最小前缀和后一个点
        return (minpos+1)%n;
    }
};

5、贪心算法核心是利用了一个定理:假设总g大于总c,无论如何分布,都一定存在可以跑完一圈的起点,且寻找该解的方式就是利用最小前缀和。该定理证明过程如下:

设: diff[i] = gas[i] - cost[i]

并设前缀和:

S[0] = 0 S[1] = diff[0] S[2] = diff[0] + diff[1] … S[n] = diff[0] + … + diff[n-1]

如果:

S[n] = sum(gas) - sum(cost) >= 0

说明总油量不亏。 现在找到前缀和最小的位置:

S[k] 是所有 S[0], S[1], …, S[n] 中的最小值

那么选择:

start = k

作为起点。 为什么可行? 从 k 出发,到后面任意一个位置 j,车上的油量等于:

S[j] - S[k]

因为 S[k] 是最小前缀和,所以:

S[j] - S[k] >= 0

因此从 k 出发走到后面不会断油。

如果走到数组末尾后继续绕回前面,到某个位置 j < k,此时油量等于:

S[n] - S[k] + S[j]

由于:

S[n] >= 0

且:

S[j] >= S[k]

所以:

S[n] - S[k] + S[j] >= 0

因此绕回前面也不会断油。 所以只要:

sum(gas) >= sum(cost)

就一定存在一个可行起点。这个起点就是前缀和最低点的后一个位置。

T1005 K次取反后最大化的数组和

1、本题没什么难度,直接逻辑分析可得排序+奇偶处理解法。

2、但是,有个坑容易掉入:若减去负数元素数量后k有剩余且为奇数,这时不能直接反转最小原始数组非负数,而是该同时考虑新数组由负数转为正数中的最小值,因为这个绝对值可能更小,且同为正数。以及,对于偶数组,由于最终结果为无影响,不必模拟流程,直接跳过处理即可。

3、代码如下:

class Solution {
public:
    int largestSumAfterKNegations(vector<int>& nums, int k) {

        // 从小到大排序
        sort(nums.begin(), nums.end());

        // 优先翻转负数
        for (int i = 0; i < nums.size() && k > 0; i++) {

            if (nums[i] < 0) {
                nums[i] = -nums[i];
                k--;
            }
            else break; // 后面全非负,直接退出
        }

        // 重新排序,找最小值
        sort(nums.begin(), nums.end());

        // 若剩余次数为奇数
        // 只能再翻转一次最小值
        if (k % 2 == 1)
            nums[0] = -nums[0];

        // 求和
        int sum = 0;

        for (int x : nums)
            sum += x;

        return sum;
    }
};