【ONE·基础算法 || 贪心(二)】

总言

  主要内容:编程题举例,学习理解贪心策略解题思想。
  
  


  
  
  
  
  
  

16、最优除法(medium)

  题源:链接

在这里插入图片描述

  
  

16.1、贪心

  1)、思路分析
  一般思路流程:暴力 + 递归(枚举所有情况) → 记忆搜索化(优化)→ 动态规划(转化)
  

  贪心: 但实际上我们可以结合一点数学知识,很容易就能得出最优的解法。

  题目明确指出nums[i] >= 2,这个条件非常重要,因为它决定了除法运算的性质。

在除法运算中:
如果被除数(分子)保持不变,而除数(分母)增大,则商(结果)会减小。
反之,如果除数(分母)减小(通过连续除法),则商(结果)会增大。

在这里插入图片描述

  因此,我们可以按照以下逻辑添加括号,以此最大化表达式的值:

  1)、最大化分子: 由于 nums 数组中的每个元素都是正整数,并且我们希望结果尽可能大,因此应该让数组中的第一个元素(即 nums[0])单独作为分子。这是因为,如果我们将它与其他元素组合在一起作为分子的一部分,由于 nums[i] >= 2,结果肯定会比 nums[0] 本身小。

对 a/b/c/……/x:
a/(b/c/……/x) 的结果,比(a/b/……)/x 的大,因为后者减小了分子。

  2)、最小化分母: 接下来,我们希望将剩余的所有元素都组合在一起作为分母的一部分,以最小化分母的值。这可以通过将它们连续相除来实现(例如 nums[1] / nums[2] / nums[3] / …)。这样做可以使得整个表达式的值最大化,因为分母越小,结果越大。

从除法角度: 
a/(b/c/……/x)(b/c/……/x)越除越小,无限趋近于0,那么结果自然越来越大

从分数角度:对分母做除法,等同于将数放到分子上,nums[i] >= 2,结果自然大。
  a*c*……*x
------------
	  b

  证明(反证法): { a 、 b 、 c 、 d 、 e 、 f } \{a、b、c、d、e、f\} {abcdef},假设存在一种解法,使得
a c d b e f > a c d e f b \frac{acd}{bef} > \frac{acdef}{b} befacd>bacdef
  通分后有:
a c d b e f 、 a c d e 2 f 2 b e f \frac{acd}{bef} 、 \frac{acde^2f^2}{bef} befacdbefacde2f2
  两边比较抵消得:
1 、 e 2 f 2 1、e^2f^2 1e2f2
  由于 n u m s [ i ] > = 2 nums[i]>=2 nums[i]>=2,所以 1 < e 2 f 2 1< e^2f^2 1<e2f2 恒成立,与假设矛盾。
  
  
  3)、添加括号: 由上述分析,我们应该将第一个元素单独作为分子,并将剩余的元素全部用括号括起来并连续相除作为分母。这样做既满足了题目的要求(添加了括号),又确保了表达式的值最大化。

简化括号后,最优的表达式形式应该是:
nums[0] / (nums[1] / nums[2] / ... / nums[n-1])

  需要注意特殊情况:

1)、单独一个数时:a
2)、两个数时:a / b

  
  
  2)、题解

class Solution {
public:
    string optimalDivision(vector<int>& nums) {
        int n = nums.size();
        string ret(to_string(nums[0]));// 题目至少给定一个数
        if(n == 1) return ret;
        if(n == 2)// 两个数的情况:a / b
        {
            ret += '/' +  to_string(nums[1]);
            return ret;
        }

        // 其它情况: a /( b / c / d / …… / z)
        ret += "/(";
        ret += to_string(nums[1]);
        for(int i = 2; i < n; ++i)
        {
            ret += '/' + to_string(nums[i]);
        }
        ret +=')';
        return ret;

    }
};

  
  
  
  
  
  
  
  
  
  
  
  
  

17、 跳跃游戏Ⅱ(medium)

  题源:链接

在这里插入图片描述

  
  

17.1、动态规划

  分析此题,从左到右单向跳跃,很容易想到使用动态规划求解,而且这还是一个线性dp。dp[i]:表示从0位置开始,到达i位置时的最小跳跃次数。

  使用动态规划解题,时间复杂度为 O ( n 2 ) O(n^2) O(n2)

class Solution {
public:
    int jump(vector<int>& nums) {
        int n = nums.size();
        // 1、创建dp表并初始化
        vector<int> dp(n,INT_MAX);
        dp[0] = 0;
        // 2、填值:从左到右
        for(int i = 1; i < n; ++i)
        {
            for(int j = i-1; j >=0; --j)
            {
                if(j + nums[j] >= i)// 能从j位置跳到i位置
                    dp[i] = min(dp[i],dp[j]+1);
            }
        }
        // 3、返回
        return dp[n-1];
    }
};

  
  
  

17.2、贪心(基于层序遍历的思想)

  1)、思路分析
  
在这里插入图片描述

  
  2)、题解
  时间复杂度为: O ( n ) O(n) O(n)

class Solution {
public:
    int jump(vector<int>& nums) {
        int n = nums.size();
        if(n == 1) return 0;// 只有一个数时,无需起跳

        int left = 0, right = 0;// 用于记录起跳区间
        int step = 0;// 用于记录起跳次数
        while(left <= right)
        {
            if(right >= n-1) // 说明已经能跳到n-1这个位置了
                return step;

            // 遍历,找下一段跳跃区间的右端点位置
            int next_right = 0;
            for(int i = left; i <= right; ++i)
                next_right = max(next_right,i+nums[i]);
            // 更新区间数值
            left = right+1;
            right = next_right;
            ++step;
        }
        return -1;// 说明不存在这种跳跃方式
    }
};

  为什么循环条件是while(left <= right)?虽然本题做了说明,保证生成的测试用例可以到达 nums[n - 1],但在18题中,存在跳跃不到最后一个位置的情况:

在这里插入图片描述

  
  
  
  
  
  
  
  
  
  
  

18、跳跃游戏(medium)

  题源:链接

在这里插入图片描述

  
  

18.1、贪心

  1)、思路分析
  思路和17题一样,区别在于返回值。
  

  2)、题解

class Solution {
public:
    bool canJump(vector<int>& nums) {
        int n = nums.size();
        int left = 0, right = 0;// 用于记录跳跃区间
        while(left <= right)
        {
            if(right >= n - 1) return true;// 能跳跃到最后一个位置
            // 找下一次跳跃的区间右端点
            int next_right = 0;
            for(int i = left; i <= right; ++i)
                next_right = max(next_right, i + nums[i]);
            // 更新区间段:
            left = right + 1;
            right = next_right;
        }
        return false;// 说明到不了最后一个位置
    }
};

  
  
  
  
  
  
  
  
  
  
  
  
  
  
  

19、加油站(medium)

  题源:链接

在这里插入图片描述

  
  

19.1、模拟(暴力解法)

  1)、思路分析
  实则此题可以看作是一个模拟题。
  以暴力解法来分析:依次枚举所有的起点,从每个起点开始,模拟⼀遍加油的流程,判断从当前位置出发,是否能够成功完成行驶。
在这里插入图片描述

  如何实现暴力解法?可以使用一个变量step(取值在[0,n-1]之间),记录从i位置出发往后走了几步。由于这是一个环路问题,我们需要利用取模运算(i+step)%n,来确保在遍历过程中能够正确地循环回到起点。通过这种方式,可以对每一个可能的起点进行逐一尝试和验证。
  

  2)、题解
  相对来说,时间复杂度为 O ( n 2 ) O(n^2) O(n2)

class Solution {
public:
    int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
        int n = cost.size();
        // 暴力+模拟:
        for(int i = 0; i < n; ++i)// 依次枚举所有起点
        {
            int remain = 0;// 用于记录油量
            for(int step = 0; step < n; ++step)// 从i位置往后走step步
            {
                int index = (i + step) % n;// 获取i往后走step步所到达的下标位置
                remain = remain + gas[index] - cost[index];
                if(remain < 0) break;// 说明从 i 位置出发无法走完一环
            }
            if(remain >= 0 )return i;// 出内层循环的情况有二,只有满足该条件时,说明走完一回合
        }
        return -1;// 无解
    }
};

  
  
  
  
  
  
  

19.2、贪心优化(找规律)

  优化分析:

在这里插入图片描述

  代码如下:实则改动部分很小。时间复杂度优化到 O ( n ) O(n) O(n)

class Solution {
public:
    int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
        int n = cost.size();
        
        for(int i = 0; i < n; ++i)// 依次枚举所有起点
        {
            int remain = 0;// 用于记录油量
            int step = 0;
            for(; step < n; ++step)// 从i位置往后走step步
            {
                int index = (i + step) % n;// 获取i往后走step步所到达的下标位置
                remain = remain + gas[index] - cost[index];
                if(remain < 0) break;// 说明从 i 位置出发无法走完一环
            }
            if(remain >= 0 )return i;// 出内层循环的情况有二,只有满足该条件时,说明走完一回合
            i = i + step;// 优化
        }
        return -1;// 无解
    }
};

  
  
  
  
  
  
  
  
  
  
  
  

20、单调递增的数字(medium)

  题源:链接

在这里插入图片描述
  
  

20.1、暴力

  1)、思路分析
  先来思考暴力解法,可以从给定的整数 n 开始,从大到小枚举 [n, 0] 区间内的每一个数字。对于每一个枚举到的数字,判断其是否是单调递增的,如此,即可找出首个单调递增的数字。

  这里需要思考的是,如何判断一个数字是单调递增的?
  
  
  2)、题解
  写法一:将数转换为字符串,再判断单调递增

class Solution {
public:
    int monotoneIncreasingDigits(int n) {
    	if (n < 10) return n; // 只有个位数的情况,直接满足
        for(int i = n; i >= 0; --i)
        {
            if(fun(i)) return i;
        }
        return -1;
    }

    bool fun(int n)// 将数转换为字符串,再判断单调递增
    {
        string str = to_string(n);
        for(int i = 0; i+1 < str.size(); ++i)
        {
            if(str[i] > str[i+1]) return false;
        }
        return true;
    }
};

  写法二:直接使用%10、/10的方法。

    bool fun(int n)// 使用 % 10 、/ 10 的方法判断
    {
        int prev = n % 10;
        while(n/=10)
        {
            int cur = n % 10;
            if(cur > prev) return false;
            prev = cur;
        }
        return true;
    }

  
  
  

20.2、贪心

  1)、思路分析
  贪心的解法实则就是根据“数的性质”找规律。

  保持高位单调递增: 如果一个数的高位(从左到右的靠前位置)已经是单调递增的,那么我们没有必要去修改这些位。因为修改它们可能会导致整个数变小,从而可能不再是小于或等于 n 的最大单调递增数字。

  寻找递减位置: 从左到右遍历这个数的每一位,找到第一个出现递减的位置。这个位置意味着其前一位的数字大于后一位的数字,违反了单调递增的规则。
在这里插入图片描述

  修改递减位置及其后续: 一旦找到了递减的位置,我们需要从这个位置开始向前推,找到与该数值相同的首个位置,①将该位置的数减1,②将这个位置之后的所有数字都修改为 9。这样做是为了在保持数尽可能大的同时,确保整个数是单调递增的。
在这里插入图片描述

  
  
  2)、题解

class Solution {
public:
    int monotoneIncreasingDigits(int n) {
        if (n < 10) return n; // 只有个位数的情况,直接满足

        string str = to_string(n); // 把数转换为字符串,方便找位数
        int len = str.size();

        // 从左往右,找第一个递减的位置
        int i = 0;
        while (i + 1 < len && str[i] <= str[i + 1]) ++i;

        // 来到此处,情况有二:    
        if (i + 1 >= len) return n; // a、特殊情况:找到字符尾部(1234全递增的情况)
        while (i - 1 >= 0 && str[i] == str[i - 1]) --i; // b、找到首个递减位置:从这个位置往前推,找到相同元素的最左区域
        
        // 来到此处,说明找到了正确位置,开始修改值
        --str[i];
        for (int j = i + 1; j < len; ++j)
            str[j] = '9';

        // 返回
        return stoi(str);
    }
};

  
  
  
  
  
  
  
  
  
  
  
  
  

21、坏了的计算器(medium)

  题源:链接

在这里插入图片描述

  
  

21.1、贪心

  1)、思路分析
  在决定是执行双倍操作还是递减操作时,我们需要基于后续的结果进行反向推导,这使得问题变得复杂。我们不能仅仅因为双倍操作带来的数值变化比递减操作大,就优先选择双倍操作,然后再选择递减操作。

  这里,设startValue = begintarget = end
  在解题时会发现,正向思考比较困难(虽然也能解题)。这是因为,在考虑当前begin是选择 ×2 操作,还是选择 -1操作时,往往需要基于后续的结果进行反向推导。也就是说,我们不能仅仅因为乘法的操作带来的数值变化比减法的操作大,就直接贪心地优选选择乘除操作,再选择加减操作。
  由于这是数学运算,×2、-1操作恰好对应÷2、+1操作。因此正难则反,本题可以逆向来考虑,且这道题采用逆向思维更优。
  

  为什么逆向思考更优?
  1)、逻辑简化:
  ①、正向思考中,当 begin<end 时,要实现操作数最小,则需要将 begin 逼近 end 的某个分数值(1/2值、1/4值、1/8值、…),再进行×2操作。这里,难点就在于要判断要逼近的是1/2值还是1/4值还是其他值,逻辑复杂。
  ②、逆向思考中:当 end>begin 时,end只管÷2,到了end<begin时,再+1逼近。
  ③、说白了就是,正向思维采用的是先小跨度的-1操作,再大跨度的2操作;逆向思维采用的是先大跨度的/2操作,再小跨度的-1操作。然而事实上往往是先大后小的解决问题思维在实现起来会比较简单。

  2)、操作优化:
  ①、正向思考时,对于×2、-1,两种操作奇数偶数都可选择。
  ②、而逆向思考时,对于÷2、+1,虽然偶数仍旧可以选择+1、÷2操作,但奇数只能选择 +1 操作。为什么?因为题目背景是计算器,给定的begin、end是整数,对一个奇数÷2,会得到一个小数,无法从end转换到begin值(注意,这要与C++中的除法后结果取整区分开。本题计算器考虑的是实际中的数学运算)。
  ③、因此,相比于正向思考,这种逆向思考使得操作更加明确和唯一。
  
  
  基于上述,逆向分析此题的贪心策略。要从end 到达 begin
  1)、当end <= begin 的时候,只能执行 +1 操作;(所需的操作步数可以通过 begin - end 快速得出)
  2)、当end > begin 的时候,需要根据 end 的奇偶性来决定操作:
  a、如果 end 是奇数,我们只能执行+1 操作,将其变为偶数。
  b、如果 end 是偶数,我们优先执行÷2 操作,以尽快减小 end 的值
  这样,每次操作都是唯一且确定的,从而保证了操作数的最小化。

  
  
  
  2)、题解

class Solution {
public:
    int brokenCalc(int begin, int end) {
        int step = 0; // 记录操作步数

        // 正难则反: end -> begin
        while (end > begin) {
            if (end % 2)
                end += 1; // 奇数
            else
                end /= 2;
            ++step;
        }
        return step + begin - end;// end <= begin 的情况
    }
};

  
  
  

  
  
  
  
  
  
  
  
  
  
  

22、合并区间(medium)

  题源:链接

在这里插入图片描述

  
  

22.1、贪心

  1)、思路分析
  区间类型的问题,是经典的贪心类题。关于此类题的一般解题思路是: ①对给定区间排序;②根据排序后的结果,总结规律,找解题策略,或先蒙一个解题策略,再总结规律。
  
  1)、如何对区间进行排序? 一般有两种方式。
  ①基于区间左端点排序:保证排序后的区间集,左端点从小到大(不关心右端点)
  ②基于区间右端点排序:保证排序后的区间集,右端点从小到大(不关心左端点)
  通常情况下,大多数区间问题这两种排序方式都能解题,只不过是解题策略需要依照排序方式进行灵活变动。

在这里插入图片描述

  
  本题中,我们以左端点进行排序(C++中,可以使用sort函数排序,默认就是左端点排序,比较方便)
  

  2)、排序后,如何合并区间?
  说明①:实际上,排序后的区间具有以下性质:能够合并的区间,在给定数组中,都是连续的存放的。(也就意味着,当从左到右进行区间合并时,首次遇到不能合并的区间,此后所有区间都不能与当前区间进行合并)

在这里插入图片描述

  说明②:合并区间的实质是求各个区间的并集。本题中,题目明确告知此意图,在某些问题中,题目可能不会直接说明要求。因此解题的关键在于,能否分析出题目要求(让我们求并集,或者求交集,等等)。
  

  具体的合并操作如下:
  以左端点排序进行区间合并,设选中区间为[left,right],待合并区间为[begin,end]。观察可以发现:
  ①、若两个区间能合并,则有right <= begin(是否取=看题目条件,本题示例2中,[1,4],[4,5]被视为重叠区间)。合并后,区间左端点不变, 右端点取max(right,end)
  ②、若两个区间不能合并,根据上述说明的连续性质,此后的区间均不能与当前区间合并,因此我们找全了一个完整的重叠区间,将其加入结果集,,并继续找下一个重叠区间。
在这里插入图片描述

  
  
  
  2)、题解

class Solution {
public:
    vector<vector<int>> merge(vector<vector<int>>& intervals) {
        // 1、先排序:这里以左端点进行排序
        sort(intervals.begin(),intervals.end());

        vector<vector<int>> ret;// 记录返回值
        // 2、合并区间
        int left = intervals[0][0], right = intervals[0][1];
        for(int i = 1; i < intervals.size(); ++i)
        {
            int begin = intervals[i][0], end = intervals[i][1];
            if(right >= begin)// 有重叠部分,需要合并
                right = max(right,end);// 合并区间左端点不变,找并集的右端点
            else// 没重叠部分,找全一个重叠区间
            {
                ret.push_back({left,right});// 加入结果集中
                left = begin;// 更新
                right = end;
            }
        }
        ret.push_back({left,right});// 最后一个区间没加入结果集中
        // 3、返回
        return ret;

    }
};

  
  
  
  
  
  
  
  
  
  
  

23、无重叠区间(medium)

  题源:链接

在这里插入图片描述

  
  

23.1、贪心

  1)、思路分析
  可以发现,此题和上一题同属于区间类题。解题思路具有一定的共通性,区别在于:
  ①、本题中,只在一点上接触的区间是不重叠的。
  ②、题目要找的是“使剩余区间互不重叠时,需要移除区间的最小数量”
  

  如何理解这里的“移除区间的最小数量”?(这里我们举例理解)

在这里插入图片描述
  
  如何操作,才能使我们“移除最少的区间,保留更多的区间”?
  当两个区间重叠时,为了保留更多的区间,我们应该移除区间范围较大的那个。
  区间范围的大小可以通过比较右端点来确定,因为右端点更大的区间通常会覆盖更多的空间,从而增加与其他区间重叠的可能性
  因此,在发生重叠进行区间移除时,我们都应该优先选择保留右端点较小的区间,以便为后续区间留下更多的空间。(这就是贪心的地方。)

在这里插入图片描述

  
  
  
  
  2)、题解

class Solution {
public:
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        // 1、排序:以左端点为基准
        sort(intervals.begin(),intervals.end());

        // 2、移除区间
        int count = 0;// 记录删除次数
        int right = intervals[0][1];// 本题实际只需要右端点即可
        for(int i = 1; i < intervals.size(); ++i)
        {
            int begin = intervals[i][0],end = intervals[i][1];
            if(right > begin)// 有重叠部分
            {
                right = min(right,end);// 取小删大(贪心点)
                ++count;// 删除次数增加
            }
            else// 无重叠,进行更新
                right = end;
        }
        // 3、返回
        return count;
    }
};

  
  
  
  
  
  
  
  
  
  
  
  

24、用最少数量的箭引爆气球(medium)

  题源:链接

在这里插入图片描述

  
  

24.1、贪心

  1)、思路分析
  先理解题目意思(图源力扣评论区):分析题目可知,虽然故事背景是射气球,但此题本质上就是区间类问题。每个气球由一个区间表示,即 [xstart, xend],表示气球的直径在 xstartxend 之间。
  (像上述22、23那样直接表明题意的,可称之为母题,其中的解题思想经验可以被借鉴学习)

在这里插入图片描述
  分析出区间问题后,一般按照两步走即可。
  

  题目要求使用“最少的弓箭数量”,从贪心的角度来讲,这表明,对于一支箭,都应该尽可能地引爆更多的气球。为了做到这一点,我们需要找到那些两两之间互相重叠的气球区间,并在这些重叠的部分射箭。(注意理解这里“互相重叠”的含义)

在这里插入图片描述
  
  
  如何求出互相重叠的区间?(求交集)。
  1)、对气球区间按照左端点进行排序。
  2)、排序后,从左到右遍历这些区间,并尝试合并相邻的重叠区间。合并两个区间的规则是:
  ①、新的左端点是两个区间左端点的最大值(由于我们已经按左端点排序,所以新的左端点总是当前区间的左端点。也就是说,左端点不会影响我们的合并结果,可以忽略)
  ②、新的右端点是两个区间右端点的最小值。
  
在这里插入图片描述

  那么,我们只用使用一个变量,记录射箭数量即可。
  

  2)、题解

class Solution {
public:
    int findMinArrowShots(vector<vector<int>>& points) {
        // 1、排序
        sort(points.begin(),points.end());

        // 2、求交集,找无重叠区间的数量
        int count = 0;
        int left = points[0][0], right = points[0][1];
        for(int i = 1; i < points.size(); ++i)
        {
            int begin = points[i][0], end = points[i][1];
            if(begin <= right)// 存在重叠区域
            {
                right = min(right,end);// 仅需更新右端点
            }
            else// 不重叠,记作一次射箭,更新
            {
                ++count;
                right = end;// 仅需更新右端点
            }
        }

        // 3、返回
        return count+1;// 注意出循环时少记一次(还需再射出一支箭)
    }
};

  
  
  
  
  
  
  
  
  
  
  
  

25、整数替换(medium)

  题源:链接

在这里插入图片描述

  
  

25.1、递归+记忆搜索化

  1)、思路分析
  ①、直接根据题意模拟(递归):本题的核心在于寻找将任意正整数n转换为1所需的最小替换次数。这个转换过程可以通过两种基本操作来实现:

如果n是偶数,则将其除以2;
如果n是奇数,则可以选择将其加1或减1

  由于这个过程具有递归性质,即每次操作后得到的数(无论是偶数减半还是奇数加减一)都可能需要进一步的操作才能变为1,因此自然而然地引出了递归的解题思路。
  
  ②、引入记忆搜索化: 直接的递归实现可能会面临重复计算的问题。例如,在处理n=8时,我们需要知道将4变为1的最小次数;而在处理n=4时,我们同样需要这个信息。这种重复的子问题使得直接的递归效率不高,因为相同的计算会被重复多次。
  为了解决这个问题,可以使用记忆化搜索优化递归,利用一个额外的数据结构(通常是哈希表或数组)来存储已经计算过的子问题的结果。当递归过程中再次遇到相同的子问题时,可以直接从存储的结果中读取,而无需重新计算。

  
  2)、题解
  PS:在本题中,不加记忆搜索化,直接使用递归解题,也能通过(因为偶数/2操作,一次减半数据)

class Solution {
public:
    unordered_map<long long,long long> hash;// 记忆搜索化的备忘录:key表示数n,value表示操作次数
    int integerReplacement(long long n) {
        if(hash.count(n)) return hash[n];// 备忘录中有值,直接返回
    
        // 备忘录中无值,模拟:
        
        if(n == 1)// 递归结束条件
        {
            hash[1] = 0;
            return 0;
        }

        if(n % 2)// 奇数的情况
            hash[n] = min(integerReplacement(n - 1),integerReplacement(n + 1)) + 1;// 此处n+1会溢出
        else // 偶数的情况
            hash[n] = integerReplacement(n / 2) + 1;

        return hash[n];
    }
};

  
  
  
  

25.2、贪心

  1)、思路分析
  分析题目可知:①对于偶数,只有一种操作(/2);②对于奇数,有两种操作(+1-1)。我们的任何选择,应该让这个数尽可能快的变成1,因此对奇数如何操作才能使决策最优,就是贪心需要解决的问题。
在这里插入图片描述
  这里,我们需要深入到数的二进制位分析,二进制表示的数有如下几个特点:

1、偶数:在二进制表示中,最后一位为0;
2、奇数:在二进制表示中,最后一位为1;
3/2 操作:在二进制表示中,统一右移一位

  我们需要重点分析奇数的情况,因为偶数只能进行一种操作(/2),而奇数有两种选择(+1-1)。
  对一个奇数,其二进制表示,结尾可以是 0bxxx01 ,或者 0bxxx11 。

  当 n == 1 时:不需要进行任何操作,返回 0
  当 n == 3 时:最优操作是先将 3 减 1 变成 2,再除以 2 变成 1,共需 2 次操作。
  当 n > 1n % 4 == 1 时:此时 n 的二进制表示以 ...01 结尾。最优策略是选择-1,这样可以去掉末尾的 1,使得接下来的除法操作能更快地使 n 减小到 1。
  当 n > 3n % 4 == 3 时:此时 n 的二进制表示以 ...11 结尾。最优策略是选择+1,这样可以将末尾的连续 1 转换成 0(或者通过进位影响前面的位),从而更快地通过除法操作使 n 减小到 1。

在这里插入图片描述

  
  
  2)、题解
  要思考出这种贪心策略,需要对数的性质与运用融会贯通(从上述题解可知,其解题思路是深入到二进制位进行分析的)

class Solution {
public:
    int integerReplacement(long long  n) {
        long long count = 0; // 统计操作次数
        while (n > 1) // 分类讨论
        {
            // 判断奇偶数
            if (n % 2) // 奇数:分情况选择最优决策
            {
                if(n  == 3) n -= 1;// 注意这里,要先把这种情况挑出来,不然 n % 4 == 3 会进入 n += 1的决策 
                else if(n % 4 == 1)// 说明是"01"的情况
                    n -= 1;    
                else // 说明是"11"的情况
                    n += 1;
            } 
            else // 偶数:只能执行/操作
                n /= 2;
            ++count;// 完成一次操作,次数+1
        }
        return count;
    }
};

  在判断奇数的操作时,还可以更简化一些,直接执行两步操作:

class Solution {
public:
    int integerReplacement(int n) {
        int count = 0; // 统计操作次数
        while (n > 1)        // 分类讨论
        {
            // 判断奇偶数
            if (n % 2) // 奇数:分情况选择最优决策
            {
                if (n == 3) // 注意这里,要先把这种情况挑出来:执行-1、/2 操作
                    n = 1;  // 直接改值
                else if (n % 4 == 1) // 说明是"01"的情况:执行-1、/2 操作
                    n /= 2;          // 本应该是 n = (n - 1) / 2,但因为/运算向零取值,故可直接写为 n = n / 2
                else                 // 说明是"11"的情况:执行+1、/2 操作
                    n = n / 2 + 1;   // 本该是 n = (n +1)/2,因为+1数据会溢出,由于是奇数,这里这样写也行
                
                count +=2; // 上述操作均走了两步,统一把count放在条件判断外处理(也可以放到每个条件中)
            } 
            else     // 偶数:只能执行/操作
            {
                n /= 2;
                ++count; // 完成一次操作,次数+1
            }
        }
        return count;
    }
};

  说明一下C++中,表达式 n = (n + 1) / 2n = n / 2 + 1 ,这两个表达式在 n 为偶数时,显然结果不同。但在 n 为奇数时,它们的结果会相同,因为整数除法的特性导致 (n + 1) / 2 总是产生 (n / 2) + 0.5 的整数部分(即 n / 2 的结果),然后 n / 2 + 1 直接在 n / 2 的结果上加 1。

假设 n = 5(奇数):
n = (5 + 1) / 2 结果为 3(因为 (5 + 1) = 66 / 2 = 3)。
n = 5 / 2 + 1 结果也为 3(因为 5 / 2 = 22 + 1 = 3)。

假设 n = 4(偶数):
n = (4 + 1) / 2 结果为 2(因为 (4 + 1) = 55 / 2 = 2)。
n = 4 / 2 + 1 结果为 3(因为 4 / 2 = 22 + 1 = 3)。

  
  
  
  
  
  
  
  
  
  
  
  

26、俄罗斯套娃信封问题(hard)

  题源:链接

在这里插入图片描述

  
  

26.1、动态规划(常规解法/通用解法)

  1)、前请说明
  说明1: 本题在排序之后,问题就转换为了“最长递增子序列”的模型。此处“动态规划”和“贪心+二分”的解题思路,就是建立在那道题的基础上的,建议先温故学习一下当时的解题方法。
  
  说明2: 动态规划的解法时间复杂度为 O ( n 2 ) O(n^2) O(n2),在本题中会超时,但这种解法具有普遍性,在其它类似题型中可以使用(面试题 08.13. 堆箱子)。
  
  
  2)、思路分析
  本题中,由于数组元素乱序,且能否套娃由信封的宽度和高度两个条件共同决定,直接求解,找信封会比较困难。因此,我们先对数组元素按照左端点(信封宽度)进行排序,这样一来,在判断第 i 个信封能否装下前面[0,i-1]中的信封时,我们只需关注高度是否满足递增的条件,因为宽度已经保证了是递增的。
在这里插入图片描述

  由此,可用动态规划解题(思路和最长递增子序列那题一致)

  1、确定状态表示: dp[i],表示以 i 位置的信封为结尾的所有套娃序列中,最长的套娃序列的长度(最大套娃信封数量)。

  2、推导状态转移方程:

当envelopes[i][0] > envelopes[j][0] && envelopes[i][1] > envelopes[j][1]时,有:
	dp[i] = max(dp[j] + 1),其中,0 <= j < i 

  3、初始化: dp[i] = 1,表示单独一个信封时,数量为1。
  4、填表顺序: 从左到右。
  5、返回值: 整个dp表中的最⼤值。
  
  
  3)、题解

class Solution {
public:
    int maxEnvelopes(vector<vector<int>>& envelopes) {
        
        // 0、预处理:排序
        sort(envelopes.begin(),envelopes.end());

        // 1、创建dp表并初始化
        int n = envelopes.size();
        vector<int> dp(n,1);

        // 2、填表
        int ret = 1;// 返回值
        for(int i = 1; i < n; ++i)
        {
            for(int j = 0; j < i; ++j)
            {
                if(envelopes[i][0] > envelopes[j][0] && envelopes[i][1] > envelopes[j][1])
                    dp[i] = max(dp[i],dp[j] + 1);
            }
            ret = max(ret,dp[i]);
        }

        // 3、返回
        return ret;
    }
};

  
  
  
  
  
  
  

26.2、贪心+二分查找

  1)、思路分析
  “最长递增子序列”题可以将动态规划改为用“贪心+二分”解题,同理,本题也一样(建议先回顾当时解题的思路)。
  
  相比于动态规划中直接使用“左端点”进行排序,在贪心解法中,需要重写排序

  原因说明:
  1)、若只以左端点(信封的宽度)排序,那么当面对宽度相同但高度不同的信封时,我们就无法做出有效的选择
  比如,排序后得[2,3]、[4,1]、[7,8]、[10,12]、[11,3],数组的左端点是严格单调递增的。此时完全可以忽略左端点,对右端点仿照“最长递增子序列”中“贪心+二分”的思路进行解题。题目就转化成了:在 {3、1、8、12、3} 中,挑一个最长递增子序列。

  2)、但本题中,左右端点均可能出现相同元素值,这时候,若仅仅只以“左端点”排序,是行不通的。
  比如,排序后得 [2,3]、[2,4]、[2,6]、[2,7]、[2,9]。忽视左端点,在右端点序列{3、4、6、7、9}中,挑一个最长递增子序列,这会得到错误的结果,因为此时左端点是不满足套娃信封的条件的。

  3)、因此,我们不能只以左端点排序,而需要重新考虑排序。那么可以如何排序呢?

A、左端点不同的时候:按照“左端点从小到大”排序;
B、左端点相同的时候:按照“右端点从大到小”排序。

  按照这个规则,上述[2,3]、[2,4]、[2,6]、[2,7]、[2,9],排序后得 [2,9]、[2,7]、[2,6]、[2,4]、[2,3]。这样一来,按照“贪心+二分”思路,在右端点序列{9、7、6、4、3}挑一个最长递增子序列,选出的最长子序列长度就是正确的。
  
  余下的,就是“最长递增子序列”中,“贪心+二分”的解题思想,这里不再赘叙。
  
  
  2)、题解

class Solution {
public:
    int maxEnvelopes(vector<vector<int>>& envelopes) {
        
        // 0、预处理:重写排序
        sort(envelopes.begin(),envelopes.end(),[&](vector<int>& v1, vector<int>& v2)
        {
            // 当左端点不同时,按照左端点从小到大顺序
            // 当左端点相同时,按照右端点从大到小排序
            return v1[0] != v2[0] ? v1[0] < v2[0] : v1[1] > v2[1];
        });

        // 2、贪心 + 二分
        vector<int> ret;// 辅助数组:用于记录右端点,即高度
        ret.push_back(envelopes[0][1]);// 先将首个元素的右端点存入
        for(int i = 1; i < envelopes.size(); ++i)// 遍历信封,找合适的高度(右端点),挑选出最长序列
        {
            int cur_h = envelopes[i][1];// 当前信封的高度(待判断元素)
            if(cur_h > ret.back()) 
                ret.push_back(cur_h);
            else // 二分查找,找合适位置
            {   
                int left = 0, right = ret.size();
                while(left < right)
                {
                    int mid = left + (right - left )/ 2;
                    if(cur_h > ret[mid]) left = mid + 1;
                    else right = mid;
                }
                ret[right] = cur_h;// 找到位置,放值
            }
        }

        // 3、返回
        return ret.size();
    }
};

  
  
  
  
  
  
  
  
  
  

  

27、可被三整除的最大和(medium)

  题源:链接

在这里插入图片描述

  
  

27.1、贪心

  1)、思路分析
  正难则反: 直接考虑一个一个数累加判断比较麻烦。我们可以先求出数组中所有元素的总和,然后根据总和的余数情况,贪心地删除一些数来使剩余的和能被三整除。
  
  分类讨论:
  设累加和为sum,用x标记数组中%3 == 1的元素,用y标记数组中%3 == 2的元素。根据 sum 的余数进行分类:

  1、如果 sum % 3 == 0,则所有元素的和已经满足能被三整除的条件,此时无需删除任何数,直接返回 sum。

  2、如果 sum % 3 == 1,这意味着我们需要减少 sum 的值,使其变为能被三整除的数。此时有两种选择:
  ①、删除一个余数为 1 的数(1 % 3 = 1,贪心的最优选择是,删除 x 中最小的数,记为 x1)。
  ②、删除两个余数为 2 的数(2+2 = 4,4 % 3 = 1,贪心的最优选择是,删除 y 中最小和次小的数,记为 y1y2)。
  选择这两种情况中的最大值,即 max(sum - x1, sum - y1 - y2)

  3、如果 sum % 3 == 2,同样需要减少 sum 的值。此时有两种选择:
  ①、删除一个余数为 2 的数( 2 % 3 = 2,贪心的最优选择是,删除 y 中最小的数,记为 y1)。
  ②、删除两个余数为 1 的数(1+1 = 2,2 % 3 = 2,贪心的最优选择是,删除 x 中最小和次小的数,记为 x1x2)。
  选择这两种情况中的最大值,即 max(sum - y1, sum - x1 - x2)
  
  可以看到,上述两者情况均需要找%3 == 1%3 == 2的最小值和次小值,那么,如何求一堆数中的最小值以及次小值?(同理,如何求一堆数中的最大值和次大值?)
  方法一: 在C++中,可以使用sort排序后求解。这种方式,时间复杂度是 O ( n l o g n ) O(nlogn) O(nlogn)
  方法二: 设最小数为x1, 次小数为x2,遍历数组元素求解。这种方式,时间复杂度为 O ( n ) O(n) O(n)
  ①若 x < x1 ,则有 x2 = x1, x1 = x (注意这里赋值顺序不能颠倒)。
  ②若 x1 <= x <= x2 (等号可取可不取),则有 x2 = x
在这里插入图片描述

  

  2)、题解

class Solution {
public:
    int maxSumDivThree(vector<int>& nums) {
        int sum = 0;// 统计元素和
        const int INF = 0x3f3f3f3f;// 定义无穷大:因为下述要做运算,直接使用INT_MAX,容易溢出
        int x1 = INF, x2 = INF;// 记录 % 3 == 1 的数中的最小值和次小值
        int y1 = INF, y2 = INF;// 记录 % 3 == 2 的数中的最小值和次小值
        // 遍历一遍数组:
        for(auto n : nums)
        {
            sum += n;
            if(n % 3 == 1)
            {
                if(n < x1)
                    x2 = x1, x1 = n;
                else if(n < x2)
                    x2 = n;
            }
            if(n % 3 == 2)
            {
                if(n < y1)
                    y2 = y1,y1 = n;
                else if(n < y2)
                    y2 = n;
            }
        }

        // 分情况讨论:
        if(sum % 3 == 0) 
            return sum;
        else if(sum % 3 == 1)
            return max(sum - x1, sum - y1 - y2);
        else if(sum % 3 == 2)
            return max(sum - y1, sum - x1 - x2);

        return 0;
    }
};

  
  
  
  
  
  
  
  
  
  
  
  
  
  

28、 距离相等的条形码(medium)

  题源:链接

在这里插入图片描述

  
  

28.1、贪心+模拟

  1)、思路分析
  根据题目,要使任意两个相邻的数不能相等,关键在于将相同的数间隔放置。因此,贪心策略为:
  a、每次处理一批相同的数。
  b、摆放的时候,每次隔一个格子。
  c、优先处理出现次数最多的那个数,剩下的数的处理顺序无所谓。

在这里插入图片描述  
  
  
  以下为该策略的证明:

在这里插入图片描述

  
  

  2)、题解
  时间复杂度整体为 O ( n ) O(n) O(n)

class Solution {
public:
    vector<int> rearrangeBarcodes(vector<int>& barcodes) {
        
        unordered_map<int,int> hash;// 用于记录数及其出现次数
        int max_value = 0;// 用于标记出现最多的那个数
        int max_count = 0;// 用于标记出现最多的那个数的次数

        // 遍历数组一遍,统计
        for(auto x : barcodes)
        {
            hash[x]++;
            if(hash[x] > max_count)// 有更大频次的数出现,更新标记值
            {
                max_count = hash[x];
                max_value = x;
            }
        }

        // 填数
        int n = barcodes.size();
        vector<int> ret(n,0);
        int pos = 0;// 用于标记待填入的下标位置
        
        // a、先填偶数位(注意:题目有解,保证了 max_count < (n+1)/2 )
        for(int i = 0; i < max_count; ++i)// 一共放max_count次
        {
            ret[pos] = max_value;
            pos += 2;// 下一个位置
        }
        hash.erase(max_value);// 删除摆放过的这个最大数

        // b、填剩余数
        for(auto& [value,count] : hash)
        {
            for(int i = 0; i < count; ++i)
            {
                if(pos >= n)// 偶数位摆放完,将pos重置到1,从奇数位开始填数
                    pos = 1;
                ret[pos] = value;
                pos += 2;
            }
        }

        // 返回
        return ret;
    }
};

  
  
  
  
  
  
  
  
  
  
  
  
  

29、 重构字符串(medium)

  题源:链接

在这里插入图片描述

29.1、贪心

  1)、思路分析
  分析题目可知,此题与上一道题思路一致。只是区别在于,本题不保证有解,因此,我们统计出“出现次数最多的字符”的数量时,需要判断一下是否满足 count < (n+1)/2的条件,若不满足,就是无解的情况,直接返回空串即可。
  
  2)、题解
  使用unordered_map作为哈希表:

class Solution {
public:
    string reorganizeString(string s) {

        // 1、遍历字符串,统计
        unordered_map<char, int> hash; // key:字符, value:该字符出现的次数
        char max_ch = ' '; // 标记出现次数最多的那个字符
        int max_count = 0;// 标记出现次数最多的那个字符的出现频次
       
        for(auto ch : s)
        {
            if(max_count < ++hash[ch])// 说明出现了更大频次的字符,需要更新标记位
            {
                max_count = hash[ch];
                max_ch = ch;
            }
        }

        // 2、判断是否有解
        int n = s.size();
        if(max_count > (n + 1) / 2) return "";// 无解

        // 3、摆放字符:隔一个位置,放置一个字符
        string ret(n,' ');// 返回值
        int pos = 0;// 标记放置字符的下标

        // 3.1、放置出现次数最多的字符
        for(int i = 0; i < max_count; ++i)
        {
            ret[pos] = max_ch;
            pos += 2;
        }
        hash.erase(max_ch);

        // 3.2、放置剩余字符
        for(auto& [ch, count] : hash)
        {
            for(int i = 0; i < count; ++i)
            {
                if(pos >= n) pos = 1;
                ret[pos] = ch;
                pos += 2;
            }
        }

        // 4、返回
        return ret;
    }
};

  
  
  
  
  使用数组作为哈希表的写法:只做了很小的改动。

class Solution {
public:
    string reorganizeString(string s) {

        // 1、遍历字符串,统计
        int hash[26]; // s 只包含小写字母,直接使用数组作为哈希表
        char max_ch = ' '; // 标记出现次数最多的那个字符
        int max_count = 0;// 标记出现次数最多的那个字符的出现频次
       
        for(auto ch : s)
        {
            if(max_count < ++hash[ch - 'a'])// 说明出现了更大频次的字符,需要更新标记位
            {
                max_count = hash[ch - 'a'];
                max_ch = ch;
            }
        }

        // 2、判断是否有解
        int n = s.size();
        if(max_count > (n + 1) / 2) return "";// 无解

        // 3、摆放字符:隔一个位置,放置一个字符
        string ret(n,' ');// 返回值
        int pos = 0;// 标记放置字符的下标

        // 3.1、放置出现次数最多的字符
        for(int i = 0; i < max_count; ++i)
        {
            ret[pos] = max_ch;
            pos += 2;
        }
        hash[max_ch - 'a'] = 0;

        // 3.2、放置剩余字符
        for(int j = 0; j < 26; ++j)// 遍历26个字符
        {
            for(int i = 0; i < hash[j]; ++i)// 若当前字符存在数,则放置
            {
                if(pos >= n) pos = 1;
                ret[pos] = j + 'a';
                pos += 2;
            }
        }

        // 4、返回
        return ret;
    }
};

  
  
  
  
  
  

Fin、共勉。

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值