代码随想录Leetcode刷题总结

二刷时,想不到思路的题目: 

 347. 前 K 个高频元素 - 力扣(LeetCode)

222.完全二叉树的节点个数

236. 二叉树的最近公共祖先

474.一和零

337.打家劫舍Ⅲ


符号说明:

(~)代表考前必刷;

(!)代表2刷还是有困难的题目;


1《刷题方法论》

1、如何读懂题以及确定解题思路

刷题没思路?可以按照以下方法思考以下:

  1. 暴力解法是如何解的?
  2. 题目都看不懂时应该怎么思考?可以反向思考,如要求最大值,则想想能否求最小值(题目:二叉树迭代法的后序遍历是通过前序遍历修改而来;无重叠区间)
  3. 一定要明确题目的要求,例如,要求次数则不需要考虑具体过程;要求不同的路径而不要求将具体的路径列出来,等待,那思考过程和解题方法都是完全不一样的。
  4. 学会利用简单例子类比复杂例子。
  5. 学会进行分类讨论,出现题目要求的情况有几种可能的情况?

2《C++基础-查漏补缺》

字符串操作

如何将一个只包含正整数的字符串,转化为对应字面的整数

例如,将"213"这个字符串转为整数213

int num = 0;
for (int i = 0; i < s.size(); i++) {
    num = num * 10 + (s[i] - '0');
}

字符串,取字符串的子串(其子串也是相同类型的字符串)

str = str.substr(0, nSlow);// substr(起始位置,截取个数)

如何求一个整数的各个位

while(m) {
            sum += (m%10)*(m%10);//注意不要丢了+号!!!
            m = m/10;
        }

对结果取整操作

若题目要求: 结果需要对 10^9 + 7取模。则将数据类型定义为uint64_t ,如:

vector<vector<uint64_t>> dp(s.size() + 1, vector<uint64_t>(t.size() + 1));

更详细见:https://leetcode.cn/circle/discuss/mDfnkW/ ,结论如下:

MOD = 1_000_000_007

// 加
(a + b) % MOD

// 减
(a - b + MOD) % MOD

// 乘
a * b % MOD

// 多个数相乘,要步步取模,防止溢出
a * b % MOD * c % MOD

// 除(MOD 是质数且 b 不是 MOD 的倍数)
a * qpow(b, MOD - 2, MOD) % MOD

作者:灵茶山艾府
链接:https://leetcode.cn/circle/discuss/mDfnkW/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

3《数组》

二分查找法

二分查找法的适用场景:1、数组为有序数组;2、数组中无重复元素

二分查找法需要注意的点

  1. 【在二分查找的过程中,保持区间的定义这个不变量(区间的定义就是不变量),就是在while寻找中每一次边界的处理都要坚持根据区间的定义来操作,这就是循环不变量规则 】
  2. 具体定义的是什么区间,一定要在初始化right时候就明确,定义左闭右闭区间,初始时right = num.size() - 1;定义的是左闭右开区间,初始时right = nums.size();
  3. 定义的是左闭右闭区间,还要注意:while循环判断条件为: left <= right ;定义的是左闭右开区间,还要注意:while循环判断条件为: left < right;
  4. 二分查找法的时间复杂度为 O(log n)

二分查找法的相关题目有:

704.二分查找法:                                                                                                                              

35.搜索插入位置

34.在排序数组中查找元素的第一个位置和最后一个位置:(该题中的数组为元素可重复的有序数组,因此设计到元素左边界及右边界的查询)


双指针法

数组中的双指针应用场景:

  1. 将通过一个for循环实现两个for循环的工作。
  2. 在数组中 删除/移除 元素。
  3. 需要同时处理 数组两端 的数据,例如:比较大小。

双指针的类型有哪几种:

  1. 快慢双指针:快指针用于遍历集合,慢指针用于保存新集合中的元素。
  2. 双边双指针:同时从数组的两个端点开始遍历,进行相应的处理。

数组的双指针法相关题目有:

27.移除元素:用快慢双指针法

26.删除有序数组中的重复项:用快慢双指针法

283.移动零:本题要求将数组中所有0元素移动到数组的末尾,其实就是数组的双指针操作,将非零的元素往前移动,最后所有位置赋值为0即可。

84.比较含退格的字符串:快慢双指针法,处理退格 # 的时候用快慢双指针处理字符串,注意边界条件的处理!

977.有序数组的平方:双边双指针法,一个for循环里直接比较两端的数值大小,倒序插入新的数组中,注意处理 中间值 和 两个指针处的值相等 这两种情况!


滑动窗口

首先明确:滑动窗口的本质就是双指针! 它的思想:就是不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果

对于一般性的子数组操作,都是通过 2个for循环暴力 实现的,这两个for循环的两个下标(两个指针)之间 其实也是一个滑动的窗口,然鹅,在暴力解法中,其第一个for循环指向滑动窗口的起始位置,一个for循环为滑动窗口的终止位置,用两个for循环 完成了一个不断搜索区间的过程。

通过双指针实现的滑动窗口,能够通过1个for循环实现两个for循环的作用,但!此时for循环中的下标(指针)指向的是滑动窗口的结束位置啦!而滑动窗口的精妙之处在于根据当前子序列和大小的情况,不断调节子序列的起始位置。从而将O(n^2)暴力解法降为O(n)

滑动窗口的应用场景:

  1. 用于数组中,对子数组进行相关操作的题目,例如:得到满足条件的最大/最小子数组,提取子数组等。

滑动窗口类的相关题目有

209.长度最小的子数组:本题有3个点需要考虑清楚:

  • 窗口内是什么?
  • 如何移动窗口的起始位置?
  • 如何移动窗口的结束位置?

窗口就是 满足其和 ≥ s 的长度最小的 连续 子数组。

窗口的起始位置如何移动:如果当前窗口的值大于等于s,窗口就要向前移动(即该缩小了)。

窗口的结束位置如何移动:窗口的结束位置就是遍历数组的指针,也就是for循环里的索引。


4《链表》

总结1

  1. 关于链表,涉及到头节点是否需要特殊处理时,需要考虑的点有:
    1. 设不设虚拟头节点(头指针)?
  2. 关于链表,涉及到遍历时,需要考虑的点有:
    1. 设不设虚拟头节点
    2. 设不设临时指针用于遍历?设的话将临时指针设为dummyhead还是dummyhead的next?(取决于需要处理的节点时cur还是cur->next?)
    3. 需要处理的是哪一个节点?
  3. 关于链表,涉及到插入时,需要考虑的点有:
    1. 节点之间连接的顺序,更改时一定要主要,先处理后面的,再处理前面的

  4. 只是对节点进行查询的,而不需要用到某一个节点的上一个节点,则不需要设置头指针,直接将头节点设置为cur,然后进行遍历即可!  
  5. 若需要增删,则必然用到某个节点的上个节点,则需要设置头指针,目的是使头节点的处理方式与其他节点相同!

具体题目

2、设计链表

题目链接:707. 设计链表 - 力扣(LeetCode)

获取链表某个节点

因为在链表中不是随机存取方式,因此要获取第几个节点的值时一定是用到while循环的!将cur指向dummyhead->next也就是head节点,若index为0(基于0的索引),则下面的while不用执行,就是指向的第0个节点(若基于1的索引,最好设立头指针(虚拟头节点),且将cur指向dummyhead)


5《哈希表》

哈希表的用法(用处)

  • 当我们遇到了要快速判断一个元素是否(重复)出现(某个/另一个)集合里的时候,就要考虑哈希法(其实也并不一定要重复出现,只要是有一定的匹配关系的就可以!)
  • 其实,当我们需要将得到的结果进行去重的时候,也可以使用哈希法中的unordered_set等结果存储,最后再将其转换为vector返回(例如求两个数组的交集中)。

map的应用场景:

  1. 对集合中元素出现的频率进行统计,其中key值为元素值,value值为出现的次数。
  2. 用于对结果去重,由于像set、unordered_set等数据结构内部会自动将重复值去掉,因此用这些数据结构可以去重。

总结

  • 当我们要使用集合来解决哈希问题的时候,优先使用unordered_set,因为它的查询和增删效率是最优的,如果需要集合是有序的,那么就用set,如果要求不仅有序还要有重复数据的话,那么就用multiset。
  • map 是一个key-value 的数据结构,map中,对key是有限制,对value没有限制的,因为key的存储方式使用红黑树实现的。

用法

  • set、unordered_set这些主要是用insert函数进行插入,map因为是键值对的形式,因此用[ ]也可以,用insert也可以!
  • 虽然std::set、std::multiset 的底层实现是红黑树,不是哈希表,std::set、std::multiset 使用红黑树来索引和存储,不过给我们的使用方式,还是哈希法的使用方式,即key和value。所以使用这些数据结构来解决映射问题的方法,我们依然称之为哈希法。

数组作为哈希表

242.有效的字母异位词

字母异位词就是字符串中字母出现的次数相同。即:一个字符串中出现的所有的字符是否在另一个字符串中重复出现了!反过来也要对应找到,因此要用哈希表!

数组就是简单的哈希表,但是数组的大小是受限的!这道题目只包含小写字母,那么使用数组来做哈希最合适不过。

383.赎金信

与上一题相似,要找的就是一个字符串中的所有字符能否在另一个字符串中找到,也就是:一个字符串中的元素是否在另一个字符串中重复出现,因此必要用到哈希表!

这道题中同样要求只有小写字母,那么就给我们浓浓的暗示,用数组!本题和242.有效的字母异位词 (opens new window)很像,242.有效的字母异位词 (opens new window)是求 字符串a 和 字符串b 是否可以相互组成,在383.赎金信 (opens new window)中是求字符串a能否组成字符串b,而不用管字符串b 能不能组成字符串a。

上面两道题目用map确实可以,但使用map的空间消耗要比数组大一些,因为map要维护红黑树或者符号表,而且还要做哈希函数的运算。所以数组更加简单直接有效!因此,能用数组的哈希法就用数组

set作为哈希表

(~)349.两个数组的交集

交集其实就是一个数组中出现的数值在另一个数组中也出现!因此要用哈希表!

但是由于这道题目没有限制数值(元素数值范围跨度比较大)的大小,就无法使用数组来做哈希表了。由于题目中说明了:结果唯一(即结果要求去重),且无序!因此选用unorder_set

主要因为如下两点:

  • 数组的大小是有限的,受到系统栈空间(不是数据结构的栈)的限制。
  • 如果数组空间够大,但哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。

所以此时一样的做映射的话,就可以使用set了。

关于set,C++ 给提供了如下三种可用的数据结构:(详情请看关于哈希表,你该了解这些! (opens new window)

  • std::set
  • std::multiset
  • std::unordered_set

std::set和std::multiset底层实现都是红黑树,std::unordered_set的底层实现是哈希, 使用unordered_set 读写效率是最高的,本题并不需要对数据进行排序,而且还不要让数据重复,所以选择unordered_set。

另外应该注意:set用的是insert函数进行插入的

202.快乐数

快乐数是重复 将一个正整数的每个位置上的数字的平方和替换为它自身,然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。

在这个重复替换的过程中,如何判断这个数进入了无限循环呢?也就是说:进入无限循环以后,某个数会重复出现,因此,要判断是否会出现无限循环,也就是要判断在这个替换的过程中某个数 是否会重复出现,因此必要用到哈希表

同时这道题考察了如何求出一个整数的每个位上的值!这属于C++基础-查漏补缺中的内容!

map作为哈希表

1.两数之和

题目要求:在给定一个整数数组 nums 中找到 相加等于目标值 target的那 两个 整数,并返回它们的数组下标。

本题其实就是:查询在一个数组中是否出现了另一个与当前遍历的元素匹配的元素,如果出现了,则返回其下标。因此,这就涉及到了哈希表!

遍历,查询哈希表中是否出现过target-nums[i],若未出现,则将当前数值存放到哈希表中,由于返回的是下标值,因此将target-nums[i]作为key值,下标值作为value值,最后返回即可!

(~)454.四数相加Ⅱ

题目给定4个长度均为n的整数数组,在每个数组中各选一个元素,使得四数相加等于0。

本题的暴力解法为n^4,并非最优的。

这道题目是四个独立的数组,只要找到A[i] + B[j] + C[k] + D[l] = 0就可以,不用考虑有重复的四个元素相加等于0的情况。

本题为什么会想到哈希表法呢?首先是因为A[i] + B[j] + C[k] + D[l] = 0具有一定的匹配关系,并且不需要考虑去重的问题,要在一个数组B中找到与另一个数组A有关的元素,就涉及到了哈希表。本题与有效的字母异位词或者 两个数组的交集是跟相似的,都是要寻找两个不同的字符串/数组直接具有匹配关系的元素/个数

本题解题步骤:

  1. 首先定义 一个unordered_map,key放a和b两数之和,value 放a和b两数之和出现的次数。
  2. 遍历大A和大B数组,统计两个数组元素之和,和出现的次数,放到map中。
  3. 定义int变量count,用来统计 a+b+c+d = 0 出现的次数。
  4. 在遍历大C和大D数组,找到如果 0-(c+d) 在map中出现过的话,就用count把map中key对应的value,也就是出现次数统计出来。
  5. 最后返回统计值 count 就可以了

(!)15.三数之和

给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有满足条件且不重复的三元组。注意,题目要求返回的是元素值。

本题是可以参考 两数之和 的题目,但是由于要对结果进行去重,三数之和返回的三元组是不唯一的,且有可能重复,而两数之和返回的结果唯一,且不可能重复,因此,与两数之和还是有较大差别的!采用哈希表法不容易去重,因此本题改用哈希表+双指针法。

  • 首先将数组排序,然后有一层for循环,i从下标0的地方开始,同时定一个下标left 定义在i+1的位置上,定义下标right 在数组结尾的位置上。
  • 依然还是在数组中找到 abc 使得a + b +c =0,我们这里相当于 a = nums[i],b = nums[left],c = nums[right]。
  • 接下来如何移动left 和right呢, 如果nums[i] + nums[left] + nums[right] > 0 就说明 此时三数之和大了,因为数组是排序后了,所以right下标就应该向左移动,这样才能让三数之和小一些。
  • 如果 nums[i] + nums[left] + nums[right] < 0 说明 此时 三数之和小了,left 就向右移动,才能让三数之和大一些,直到left与right相遇为止。

本题的去重逻辑是关键!一定要弄清楚,多加体会!

思考

两数之和 就不能使用双指针法,因为1.两数之和 (opens new window)要求返回的是索引下标, 而双指针法一定要排序,一旦排序之后原数组的索引就被改变了。

如果1.两数之和 (opens new window)要求返回的是数值的话,就可以使用双指针法了。

18.四数之和

给定一个包含 n 个整数的数组 nums 和一个目标值 target,判断 nums 中是否存在四个元素 a,b,c 和 d ,使得 a + b + c + d 的值与 target 相等?找出所有满足条件且不重复的四元组。注意,本题依然是返回数组的元素值而非下标。

四数之和,和15.三数之和 (opens new window)是一个思路,都是使用双指针法, 基本解法就是在15.三数之和 (opens new window)的基础上再套一层for循环。

但是有一些细节需要注意,例如: 不要判断nums[k] > target 就返回了,三数之和 可以通过 nums[i] > 0 就返回了,因为 0 已经是确定的数了,四数之和这道题目 target是任意值。比如:数组是[-4, -3, -2, -1]target-10,不能因为-4 > -10而跳过。但是我们依旧可以去做剪枝,逻辑变成nums[i] > target && (nums[i] >=0 || target >= 0)就可以了。

15.三数之和 (opens new window)的双指针解法是一层for循环num[i]为确定值,然后循环内有left和right下标作为双指针,找到nums[i] + nums[left] + nums[right] == 0。

四数之和的双指针解法是两层for循环nums[k] + nums[i]为确定值,依然是循环内有left和right下标作为双指针,找出nums[k] + nums[i] + nums[left] + nums[right] == target的情况,三数之和的时间复杂度是O(n^2),四数之和的时间复杂度是O(n^3) 。

注意,四数之和因为有2层for循环,因此分别有2级剪枝和2级去重操作;而三数之和只有1级剪枝和1级去重操作


《栈与队列》

栈和队列的适用场景:

  1. 栈用于先进后出,例如:实现二叉树的迭代法遍历
  2. 队列用于先进先出,例如:二叉树的层序遍历、图论中的BFS
  3. 实现函数递归调用就需要栈,递归的实现就是:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。

适用栈和队列尤其需要注意的点

在栈中,当我们进行了一个弹出操作pop()或者取栈顶元素top()的时候,我们接下去的操作一定要想着 防止操作空栈 !或者在进行循环之前判断条件里一定要有防止操作空栈的步骤!

栈和队列的底层理解

225.用队列实现栈:栈可以通过 队列实现,通过队列的转圈输入弹出实现栈的 先入后出一个队列在模拟栈弹出元素的时候只要将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部,此时在去弹出元素就是栈的顺序了。

232.用栈实现队列:队列可以通过两个栈实现,一个输入栈专用于输入数据;一个输出栈专用于输出数据。

对称匹配问题——栈

20.有效的括号:这道题的题目意思是判断成对出现的括号能否以正确的顺序和正确的类型闭合。

那么这些括号排列的方式只有3种:左括号多了;右括号多了;左右括号数量相等但不匹配;做题过程种的技巧:遇到左括号存对应的右括号,方便比较。这种匹配问题适合用栈解决。

1047.删除字符串中的所有相邻重复项:本题要求将字符串中相邻的重复元素删除,直到没有相邻的重复元素。

其实,这题也是匹配问题:1、本题可以用一个栈,将s中的每一项从后向前遍历加入栈中,遇到当前遍历元素与栈顶的元素相同则弹出后,遍历下一个元素,最后将栈中元素一一弹出到新的字符串中,返回;2、也可以用一个string作为容器模拟栈,可以简化后序的转化。

150.逆波兰表达式求值:逆波兰表达式就是表达式后缀法,每一个子表达式要得出一个结果,然后拿这个结果再进行运算,其本质也就是字符串匹配消除问题,即:遇到了数字,将其加入栈中,遇见运算符就取出数进行运算,再加入栈中。

单调队列问题

单调队列的实现结构如下: 

class MyQueue {
public:
    void pop(int value) {
    }
    void push(int value) {
    }
    int front() {
        return que.front();
    }
};

维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。C++中没有直接支持单调队列,需要我们自己来实现一个单调队列。

尤其需要注意的点:优先级队列是对加入队列中的元素进行排序,而单调队列并不是对加入队列的元素进行排序,而是:保证队列里的元素都是从大到小/从小到大单调排序的!)

设计单调队列的时候,pop,和push操作要保持如下规则:

  1. pop(value):如果窗口移除的元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
  2. push(value):如果push的元素value大于入口元素的数值,那么就将队列入口的元素弹出,直到push元素的数值小于等于队列入口元素的数值为止

一般使用 deque 实现单调队列!

239.滑动窗口最大值:本题使用单调队列的经典题,题目要求返回 每个固定了k个元素的不断向前滑动的窗口 中的最大值。注意 首先要将前k个元素提前加入到单调队列中。

C++中

可以使用multiset作为单调队列

多重集合(multiset) 用以有序地存储元素的容器。允许存在相等的元素。

在遍历原数组的时候,只需要把窗口的头元素加入到multiset中,然后把窗口的尾元素删除即可。因为multiset是有序的,并且提供了*rbegin(),可以直接获取窗口最大值。

class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        multiset<int> st;
        vector<int> ans;
        for (int i = 0; i < nums.size(); i++) {
            if (i >= k) st.erase(st.find(nums[i - k]));
            st.insert(nums[i]);
            if (i >= k - 1) ans.push_back(*st.rbegin());
        }
        return ans;
    }
};

优先级队列——前K个大数 问题

347.前k个高频元素:这道题求的是一个数组中出现频率前k高的元素。其实一看题目,很不好想,但是遇到不会的题目时就要学会一步一步分解题目,至少是可以提供一些思路的。

首先,求一个数组中元素出现的频率问题——可以用哈希表map进行统计。其次,对value项进行快排(时间复杂度为O(nlogn)),然后去前k个对应的key值。

-----------------------------------------------------------------------------

通过求前 K 个高频元素,引出另一种队列就是优先级队列

什么是优先级队列呢?

其实就是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。

而且优先级队列内部元素是自动依照元素的权值排列。那么它是如何有序排列的呢?

缺省情况下priority_queue利用max-heap(大顶堆)完成对元素的排序,这个大顶堆是以vector为表现形式的complete binary tree(完全二叉树)。

什么是堆呢?

堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。

所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。

本题就要使用优先级队列来对部分频率进行排序。 注意这里是对部分数据进行排序而不需要对所有数据排序!


《单调栈》

单调栈的题目一定要记住在while循环之后将当前的i的值push到stack中!!!!!!!!

《二叉树》

二叉树的应用场景:

  1. 完全二叉树:大顶堆、小顶堆、优先级队列
  2. 二叉搜索树:map、multimap、set、multiset

做二叉树类题目需要注意的点

  1. 需要用到栈、队列和数组等数据结构对二叉树的节点进行存储的时候,一定要注意定义的数据结构类型为 Treenode*   !
  2. 二叉树类型的题目所有都是基于遍历顺序进行的,尤其是递归算法!拿到一道二叉树的题目,应该遵循以下的思考顺序
  3. 1、选择遍历顺序(因为涉及到二叉树节点的读取和处理,所以一定离不开遍历)

    2、题目要求我们做什么(处理内容),那么这就往往是“”的时候要做的事情

  4. 二叉树顺序存储时的子节点与父节点的 下标计算:记不住可以不记,直接举一个小例子就可以找到规律。(此外还有:满二叉树的节点个数计算)

二叉树的遍历方式

二叉树的遍历方式从大类上分为:深度优先遍历和广度优先遍历

①深度优先遍历又分为:前序遍历(递归法和迭代法)、中序遍历(递归法和迭代法)、后序遍历(递归法和迭代法) 这3种;

对于递归法,有递归三步曲

  1. 确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数, 并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型。

  2. 确定终止条件: 写完了递归算法, 运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。

  3. 确定单层递归的逻辑: 确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。

对于迭代法,有统一格式的迭代写法,有非统一格式的迭代法。非统一格式的:前序遍历迭代法通过栈模拟实现;后序遍历可以通过前序遍历的方法稍加调整得到;但是中序遍历又其特殊处理方法,主要介绍统一风格的中序迭代法:前序遍历中,因为要访问的元素和要处理的元素顺序是一致的,都是中间节点;而中序遍历是左中右,也就是需要先处理最左边叶子节点,再处理中间根节点,然而实际上,无论什么遍历顺序,都需要先访问根节点,这就造成了中序遍历迭代法的访问顺序和处理顺序是不一样的!

统一风格的迭代法相对难理解一些,不用!

589.N叉树的前序遍历:这题与一般的层序遍历方式是一样的思路,只是在出来子节点时有一些不同,N叉树中一般由一个Node*类型的数组children保存子节点,因此,N叉树节点个数为:node->children.size();N叉树中某个节点的子节点为:node->children[i]  。

590.N叉树的后序遍历:同理。

一定要掌握前中后序至少一种迭代的写法,并不因为某种场景的题目一定要用迭代,而是现场面试的时候,面试官看你顺畅的写出了递归,一般会进一步考察能不能写出相应的迭代。

----------------------------------------

②广度优先遍历就是层序遍历。

层序遍历就是图论中的广度优先搜索,要想完全的访问每一层的数据,仅依赖树本身的结构(或者图本身的结构)是无法做到的,必须使用队列 保存我们遍历过的每一层的元素,这样在访问某一层的时候,能找到上一层的节点啦!

102.二叉树的层序遍历:基础,使用队列模拟。

107.二叉树的层序遍历Ⅱ:题目要求自底向上的层序遍历。在层序遍历的基础上,得到正序输出的层序遍历结果,然后使用reverse()函数进行翻转即可!

199.二叉树的右视图:题目要求输出二叉树每一层的最右边的节点值。用层序遍历,当遍历到每一层中的最后一个节点时保存节点值(通过每一层的size() 进行控制)。

637.二叉树的层平均值:题目要求返回每一层节点的平均值。在首先统计每一层的节点个数 与 节点值的和,除以 每一层的节点个数即可。

429.N叉树的层序遍历:题目要求N叉树的层序遍历结果。其实与二叉树的相似,只是因结构不完全一样导致处理上有些差别。要注意N叉树的定义!

                int childSz = cur->children.size();
                for (int i = 0; i < childSz; i++) {
                    que.push(cur->children[i]);
                }

515.在每个树行中找最大值:要求二叉树每一层中的最大值,其实与199.二叉树的右视图是一样的思路!另外注意:int型变量的最小值为 INT_MIN ,其包含在头文件 <climits>中!

116.填充每个节点的下一个右侧节点:这道题用的是层序遍历,但是与之前的题目不同,这道题中处理的并不是节点的值,而是节点的地址

这道题二刷时,ac的还行,因此记录自己的题解如下:

class Solution {
public:
    Node* connect(Node* root) {
        //这道题用的是层序遍历,但是与之前的题目不同,这道题中处理的并不是节点的值,而是节点的地址!
        queue<Node*> que;
        Node* cur = root;
        if (root != nullptr) que.push(root);
        while (!que.empty()) {
            int size = que.size();
            vector<Node*> vec(size + 1, NULL);
            for (int i = 0; i < size; i++) {
                Node* cur = que.front();
                que.pop();
                vec[i] = cur;
                if (cur->left) que.push(cur->left);
                if (cur->right) que.push(cur->right);
            }
            for (int j = size; j > 0; j--) {
                vec[j - 1]->next = vec[j];
            }//这里其实就是 双指针的思路
        }
        return root;
    }
};

117.填充每个节点的下一个右侧节点Ⅱ:这道题与上一道题的区别在于题目给定的二叉树的结构不同,但是用 我上一题的题解,完全也可以无修改的ac这道题!

104.二叉树的最大深度:这题用层序遍历去做,只需要记录进入第一层while循环的次数,即可得到解!或者:只需要输出:vector<vector<int>> res 的这个数组的大小,即为二叉树的最大深度!

111.二叉树的最小深度:找最小深度其实就是 在层序遍历的过程中,找到第一个 叶子节点 所在的层数!

二叉树的修改与构造

  1. 这道题应该是要用递归方法来做才好了,那么选择什么递归遍历方式呢?前序可以、后序可以但是中序不太行,因为会出现重复翻转的情况
  2. 翻转函数我们可以直接调用swap()函数进行实现
  3. 递归3部曲

226.翻转二叉树:题目要求将二叉树的每个左右节点进行翻转,最终实现一种镜像的效果。那么这道题就必须遍历每一个节点,然后将每个节点的左右节点进行翻转即可。前序、后序和层序遍历均可做!

二叉树的属性

对称二叉树

  1. 这道题需要用两个指针分别从根节点的左、右两个方向去判断是否对称,一旦有遇到不对称的即为 非对称二叉树
  2. 可以用层序遍历,将每一层的节点收集到一个vector数组中并用双指针法判断其是否对称;可以用递归的方法,相当于判断根节点的左右两个子树是否是可以相互翻转的。
  3. 对称二叉树的处理内容就是判断,判断根节点的左右两个子树时候是可以相互翻转的,也就是 判断某个节点能否与另一个节点对称;
  4. 对于递归的方法而言,由于判断的逻辑是:必须先判断根节点的左右两个子树是否可以翻转,才能判断出以根节点为跟的树是否是对称的,那么就有点自下而上的意思了,因此只能后序遍历

二叉树的最大深度

方法一:层序遍历,很好做,只要统计二叉树遍历的层数即可得到最大深度。

方法二:深度优先即递归法,通常,求高度用后序遍历;求深度用的是前序遍历。

1、首先可采用层序遍历,而且思路很简单

2、其次要 明确二叉树的深度,是指每个节点到根节点的节点数量。二叉树的最大深度就是最低一层的叶子节点到跟节点的节点数量。最大深度的本质其实就是:从所有的叶子节点出发,往根节点的方向搜索,其中经过节点数量最大的就是最大深度!因此用递归也可以,由于是从叶子节点向根节点层层返回高度!所以说只能用后序遍历!

class Solution {
public:
    int maxDepth(TreeNode* root) {
        //首先可采用层序遍历,而且思路很简单
        //首先明确二叉树的深度,是指每个节点到根节点的节点数量
        //二叉树的最大深度就是最低一层的叶子节点到跟节点的节点数量
        //最大深度的本质其实就是:从所有的叶子节点出发,往根节点的方向搜索
        //其中节点数量最大的就是最大深度!
        //用递归也可以,由于是从叶子节点向根节点层层返回高度!因此只能用后序
        //递归方法中每层返回到就是一个数值,表示从叶子节点到当前节点已经经过的节点数量!
        //因此递归返回值类型为int型,参数为根节点
        if (root == nullptr) return 0;
        int leftDepth = maxDepth(root->left);
        int rightDepth = maxDepth(root->right);
        int res = max(leftDepth, rightDepth) + 1;
        return res;
    }
};

二叉树的最小深度

方法一:层序遍历,很好做,遇到左右孩子均为空的情况就返回层数即可。

方法二:深度优先递归法,求二叉树的最小深度和求二叉树的最大深度的差别主要在于处理左右孩子不为空的逻辑。

此处总结以下递归的思想:

以上面两题为例,对根节点而言,求最小深度就是要求最小的高度,要 求根节点的最大/最小高度,就要求左右子树的最大/最小高度,得到左右子树的最大/最小高度以后 + 1;然后要 求左子树的根节点的最大/最小高度,······,最终递归到了叶子节点:要得到叶子节点的高度就返回以叶子节点为根节点的树的最大/最小高度,因此终止条件就是遇到空节点时将返回0,然后在上一层+1;想明白这个处理逻辑以后,按照单层递归的逻辑去思考写好代码,其实程序就是会按着一层一层的去遍历。

完全二叉树的节点个数

方法一:递归法——后序遍历;层序遍历,这是普通的二叉树求节点的方法!

方法二: 要注意到,完全二叉树中是包含了满二叉树的,(在完全二叉树中如何判断子树是满二叉树呢?方法为:(在完全二叉树中,向左递归的深度 = 向右递归的深度))。那么用后序遍历的方法求完全二叉树的节点数量,首先应该判断一个子树是否是满二叉树,并计算该满二叉树的深度,若是,直接用公式计算该子树的节点数量,若不是则继续递归。

110.平衡二叉树

深度优先遍历递归法,处理的内容就是,分别计算节点的左右子树的高度,判断左右子树的高度差绝对值超不超过1(求高度用的是后序遍历),若不超过1,则返回该节点的最大高度;若超过1,那就不用再进行判断了,因为整颗树就已经是非平衡的二叉树,直接返回false就可以了!

二叉树的所有路径

1、很明显,采用前序遍历!

2、本题涉及到了回溯,要注意:回溯和递归是一一对应的,有一个递归,就要有一个回溯,在 递归函数中 往往已经对某个节点进行了处理,因此,在递归函数中 再嵌套调用递归函数后,相当于已经处理了下一个节点,所以在调用完递归函数后,进行回溯!

左叶子之和

  1. 用后序遍历,很好理解!
  2. 在递归中判断当前节点是不是左叶子是无法判断的,必须要通过节点的父节点来判断其左孩子是不是左叶子。因此假如能在递归的过程中判定叶子节点与父节点之间的关系就能做这道题!

方法一:在递归参数中加入一个类型为bool的 isFromleft 作为判断值!然后就是正常的递归了(这种方法很好理解)

class Solution {
private:
    //递归参数为树的节点,返回值类型为void
    void leftLeaf(TreeNode* node, int& count, bool& isFromleft) {
        if (node->left == nullptr && node->right == nullptr) {
            if (isFromleft == true) count += node->val;
            return;
        }
        //单层递归的逻辑
        if (node->left) {
            isFromleft = true;
            leftLeaf(node->left, count, isFromleft);
        }
        if (node->right) {
            isFromleft = false;
            leftLeaf(node->right, count, isFromleft);
        }
        return;
    }
public:
    int sumOfLeftLeaves(TreeNode* root) {
        //因为要求的是左叶子,因此需要用到left = left->left这种
        int count = 0;
        bool isFromleft = true;
        if (root == nullptr || (root->left == nullptr && root->right == nullptr)) return count;
        
        leftLeaf(root, count, isFromleft);
        return count;
    }
};

方法二:后序法递归 向上返回以当前节点为根节点的左叶子之和!(较难想全)

class Solution {
public:
    int sumOfLeftLeaves(TreeNode* root) {
        if (root == NULL) return 0;
        if (root->left == NULL && root->right== NULL) return 0;

        int leftValue = sumOfLeftLeaves(root->left);    // 左
        if (root->left && !root->left->left && !root->left->right) { // 左子树就是一个左叶子的情况
            leftValue = root->left->val;
        }
        int rightValue = sumOfLeftLeaves(root->right);  // 右

        int sum = leftValue + rightValue;               // 中
        return sum;
    }
};

513.找树的左下角的值

题目要求在树的最后一行找到最左边的值。即首先要是最后一行,然后是最左边的值:等价于求最大深度处的最左侧叶子节点。 本题用层序遍历十分简单。但如果使用递归法,有两个关键点需要考虑:

  1. 如何判断是最后一行呢?其实就是深度最大的叶子节点一定是最后一行
  2. 那么如何找最左边的呢?可以使用前序遍历(当然中序,后序都可以,因为本题没有 中间节点的处理逻辑,只要左优先就行),保证优先左边搜索,然后记录深度最大的叶子节点,此时就是树的最后一行最左边的值。

在记录最后一行的第一个叶子节点时:只要当前的深度是已经遍历过的深度的最大值,那么就记录下来,在最后一行时,由于深度已经是最大了,因此不会再有新的值覆盖,因此此时记录的叶子节点即为最后一行的第一个叶子节点!

112.路径总和

题目要求的是 是否存在 树的节点 之和为目标值的路径。

本题目有几个点比较关键:

1、首先是计数器如何统计这一条路径的和呢

不要去累加然后判断是否等于目标和,那么代码比较麻烦,可以用递减,让计数器count初始为目标和,然后每次减去遍历路径节点上的数值。

2、其次是如何判断路径符合条件?

当遍历到叶子节点且计数器的值变为0的时候,路径符合条件,此时,应该向上一直返回true,并停止继续遍历。

总结:一般情况下:如果需要搜索整棵二叉树,那么递归函数就不要返回值,如果要搜索其中一条符合条件的路径,递归函数就需要返回值,因为遇到符合条件的路径了就要及时返回。

113.路径总和Ⅱ:要求的是所有的 路径,也就是要记录路径,并且需要遍历所有的节点。

class Solution {
    private:
    vector<vector<int>> result;
    vector<int> path;
    void traversal(TreeNode* node, int count) {
        if (node->left == nullptr && node->right == nullptr) {
            if (count == 0) {
                result.push_back(path);
            }
            return;
        }

        if (node->left) {
            path.push_back(node->left->val);
            count -= node->left->val;
            traversal(node->left, count);
            count += node->left->val;
            path.pop_back();
        }
        if (node->right) {
            path.push_back(node->right->val);
            count -= node->right->val;
            traversal(node->right, count);
            count += node->right->val;
            path.pop_back();
        }
        return;
    }
public:
    vector<vector<int>> pathSum(TreeNode* root, int targetSum) {
        //递归法:
        //1、函数的参数和返回值:参数1,树都节点,参数2记录路径节点值的数组path,参数3,记录路径的数组
        //返回值,void
        //2、终止条件
        //3、单层递归的逻辑
        result.clear();
        path.clear();
        if (root == nullptr) return result;
        path.push_back(root->val);
        traversal(root, targetSum - root->val);
        return result;
    }
};

 二叉树的修改和构建

构造二叉树有三个注意的点:

  • 分割时候,坚持区间不变量原则,左闭右开,或者左闭又闭。
  • 分割的时候,注意后序 或者 前序已经有一个节点作为中间节点了,不能继续使用了。
  • 如何使用切割后的后序数组来切合中序数组?利用中序数组大小一定是和后序数组的大小相同这一特点来进行切割。

106.从中序与后序遍历序列构造一颗二叉树

如何根据两个顺序构造一颗唯一的二叉树?

  • 第一步:如果数组大小为零的话,说明是空节点了。

  • 第二步:如果不为空,那么取后序数组最后一个元素作为节点元素。

  • 第三步:找到后序数组最后一个元素在中序数组的位置,作为切割点

  • 第四步:切割中序数组,切成中序左数组和中序右数组 (顺序别搞反了,一定是先切中序数组)

  • 第五步:切割后序数组,切成后序左数组和后序右数组

  • 第六步:递归处理左区间和右区间

654.最大二叉树

构造树一般采用的是前序遍历,因为先构造中间节点,然后递归构造左子树和右子树!

617.合并二叉树

题目要求 按位置 合并两个二叉树。

分析:这个题目如果用层序遍历,那么需要用一个值去代替每一个空值,也就是位置不好定,做起来肯定麻烦!因此,优先用递归的前序遍历!遍历的方法按照递归3部曲进行即可。

本题对于递归思想进行一下总结和理解:

  • 因为树是一种递归结构,你可以理。解为:树是由一颗颗树构成的!构成树 的树称为子树。因此,在我们合并两个子树的过程中,我们的思路应该是这样的:
    1. 首先合并两颗树的两个根节点
    2. 其次合并两颗树的两个左节点(而合并两个左节点的过程,其实就是合并两个左子树的两个根节点的过程,因此就是将两个左子树进行合并,合并函数就是过程1中的函数)。
    3. 然后合并两颗树的两个右节点(而合并两个右节点的过程,其实就是合并两个 右子树的两个根节点 的过程,因此就是将两个右子树进行合并,合并函数就是过程1中的函数)。
  • 因此,就形成了一种递归思想,即:处理树的子树/子节点(左子树/右子树、左节点/右节点)的过程其实是和处理 树/根节点的过程、函数(方法)是一样一样的!
  • 最后根据上述的思想去考虑递归函数所需要传入的参数、返回值等,就清晰得多啦!

二叉搜索树的属性

700.二叉搜索树中的搜索

二叉搜索树是一个有序树:

  • 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
  • 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
  • 它的左、右子树也分别为二叉搜索树

二叉搜索树的一个重要特性:二叉搜索树用中序遍历方法得到的是一个升序序列!因此二叉搜索树就相当于将一个单调递增的数组存放在二叉树中。因此,看到二叉搜索树就要联想到中序遍历!!

本题可以用递归法进行遍历,方法按照递归3部曲,不难!

想重点记录的是如何用迭代法对二叉树进行搜索

对于二叉搜索树可就不一样了,因为二叉搜索树的特殊性,也就是节点的有序性,可以不使用辅助栈或者队列就可以写出迭代法。

对于一般二叉树,递归过程中还有回溯的过程,例如走一个左方向的分支走到头了,那么要调头,在走右分支。

对于二叉搜索树,不需要回溯的过程,因为节点的有序性就帮我们确定了搜索的方向。

class Solution {
public:
    TreeNode* searchBST(TreeNode* root, int val) {
        while (root != NULL) {
            if (root->val > val) root = root->left;
            else if (root->val < val) root = root->right;
            else return root;
        }
        return NULL;
    }
};

98.验证二叉搜索树

要知道中序遍历下,输出的二叉搜索树节点的数值是有序序列。

有了这个特性,验证二叉搜索树,就相当于变成了判断一个序列是不是递增的了。

可以把二叉树转变为数组来判断,是最直观的!

这道题目比较容易陷入两个陷阱:

  • 陷阱1

不能单纯的比较左节点小于中间节点,右节点大于中间节点就完事了

写出了类似这样的代码:

if (root->val > root->left->val && root->val < root->right->val) {
    return true;
} else {
    return false;
}

我们要比较的是 左子树的 所有节点小于中间节点,右子树所有节点大于中间节点。所以以上代码的判断逻辑是错误的。

后面就是正常的递归3部曲:

注意递归函数要有bool类型的返回值, 我们在二叉树:递归函数究竟什么时候需要返回值,什么时候不要返回值? (opens new window)中讲了,只有寻找某一条边(或者一个节点)的时候,递归函数会有bool类型的返回值

其实本题是同样的道理,我们在寻找一个不符合条件的节点(某一特性),如果没有找到这个节点就遍历了整个树,如果找到不符合的节点了,立刻返回。

总结:验证二叉搜索树这道题用的是双指针的思路!

530.二叉搜索树中的最小绝对值差

因为二叉树的中序遍历是有序递增序列,因此,本题可以直接进行中序遍历,然后用双指针法比较相邻两个节点直接的差值,并更新差值的最小值,最后得到结果!

二叉搜索树 == 有序数组,遇到在二叉搜索树上求什么最值啊,差值之类的,就把它想成在一个有序数组上求最值,求差值,这样就简单多了。

501.二叉搜索树中的众数

//涉及到二叉搜索树,要联想到中序遍历,并且联想到双指针思路!

//涉及到频率,要联想到哈希法记录数值重复出现的次数。

//涉及到二叉搜索树中重复出现的数,一定是相邻的!

//关键步骤:

        if (pre == nullptr) count = 1;
        else if (pre->val == node->val) count++;
        else if (pre->val != node->val) count = 1;
        pre = node;
        if (count == maxCount) res.push_back(node->val);
        else if (count > maxCount) {
            maxCount = count; 
            res.clear();
            res.push_back(node->val);
        }

236.二叉树的最近公共祖先

二叉树的公共祖先涉及到 回溯 的过程(也就是自底向上查询/返回的过程),而回溯一定要用到 后序遍历了!因为后序遍历(左右中)就是天然的回溯过程,可以根据左右子树的返回值,来处理中节点的逻辑。

本题:判断逻辑是 如果递归遍历遇到q,就将q返回,遇到p 就将p返回,那么如果 左右子树的返回值都不为空,说明此时的中节点,一定是q 和p 的最近祖先。

本题归纳如下三点

  1. 求最小公共祖先,需要从底向上遍历,那么二叉树,只能通过后序遍历(即:回溯)实现从底向上的遍历方式。

  2. 在回溯的过程中,必然要遍历整棵二叉树,即使已经找到结果了,依然要把其他节点遍历完,因为要使用递归函数的返回值(也就是代码中的left和right)做逻辑判断。

  3. 要理解如果返回值left为空,right不为空为什么要返回right,为什么可以用返回right传给上一层结果。

可以说这里每一步,都是有难度的,都需要对二叉树,递归和回溯有一定的理解。

235.二叉搜索树的最近公共祖先

要记住 二叉搜索树自带搜索方向 这个特性!要找 二叉搜索树的最近公共祖先,那么相当于就是二叉搜索树的搜索,要想到“二叉搜索树的搜索”的迭代法其实是利用了二叉树的特性的,那么,本题也是可以用迭代法简单解决的!

701.二叉搜索树中的插入删除操作

450.删除二叉搜索树中的节点

本题有以下五种情况:

  • 第一种情况:没找到删除的节点,遍历到空节点直接返回了
  • 找到删除的节点
    • 第二种情况:左右孩子都为空(叶子节点),直接删除节点, 返回NULL为根节点
    • 第三种情况:删除节点的左孩子为空,右孩子不为空,删除节点,右孩子补位,返回右孩子为根节点
    • 第四种情况:删除节点的右孩子为空,左孩子不为空,删除节点,左孩子补位,返回左孩子为根节点
    • 第五种情况:左右孩子节点都不为空,则将删除节点的左子树头结点(左孩子)放到删除节点的右子树的最左面节点的左孩子上,返回删除节点右孩子为新的根节点。

注意若要删除一个节点的左/右孩子,那么就要先将这个节点保存,否则会出错!

因为二叉搜索树添加节点只需要在叶子上添加就可以的,不涉及到结构的调整,而删除节点操作涉及到结构的调整

这里我们依然使用递归函数的返回值来完成把节点从二叉树中移除的操作。

这里最关键的逻辑就是第五种情况(删除一个左右孩子都不为空的节点),这种情况一定要想清楚

而且就算想清楚了,对应的代码也未必可以写出来,所以这道题目既考察思维逻辑,也考察代码能力

注意:二叉树的插入/删除操作都是再终止条件中体现的!

669.修剪二叉搜索树

108.将有序数组转换为二叉搜索树

做这道题目之前大家可以了解一下这几道:

上述题目同本题一样都是构造二叉树类型的题目。

本质就是寻找分割点,分割点作为当前节点,然后递归左区间和右区间

分割点就是数组中间位置的节点。

递归三部曲:

  • 确定递归函数返回值及其参数

删除二叉树节点,增加二叉树节点,都是用递归函数的返回值来完成

本题要构造二叉树,依然用递归函数的返回值来构造中节点的左右孩子。在构造二叉树的时候尽量不要重新定义左右区间数组,而是用下标来操作原数组

  • 确定单层递归的逻辑

首先取数组中间元素的位置,不难写出int mid = (left + right) / 2;这么写其实有一个问题,就是数值越界,例如left和right都是最大int,这么操作就越界了,在二分法 (opens new window)中尤其需要注意!

所以可以这么写:int mid = left + ((right - left) / 2);

root的左孩子接住下一层左区间的构造节点,右孩子接住下一层右区间构造的节点。

538.把二叉搜索树转换为累加树

《贪心算法》

贪心算法的整体思路就是通过局部最优推出全局最优。用直白的话来说就是指:想要完成整个任务,那就得一个一个来完成。

例如:分发饼干中,想要让尽可能多的孩子吃饱,那么就得让一个一个孩纸吃饱。如此产生的解题思路就是,优先用大尺寸的饼干先喂饱胃口大的孩子,一个一个饼干喂。


《动态规划算法》

怎么确定是动态规划的题目呢?

  • 需要用到前面或者后面的状态去确定当前状态的值。也就是说:如果某一问题有很多重叠子问题,使用动态规划是最有效的;所以动态规划中的每一个状态一定是由上一个状态推导出来的,这一点区别于贪心,贪心没有状态推导,而是从局部直接选最优的!
  • 要善于举例子,从第一层、第二层开始思考,而不是宏观的将其数学式子列出来(动态规划就是利用子问题推导出总的问题的)。

动态规划解题5步曲:

  1. 确定dp数组以及下标的含义(一般根据题目要求而定义)
  2. 确定递推公式
  3. dp数组如何初始化(需要考虑:递推公式 以及 dp数组的下标和含义)
  4. 确定遍历顺序
  5. 举例推导dp数组

动态规划解题思路总结

动态规划基础

509.斐波那契数列

题中说了,当前斐波那契值由前两个数相加而得。很明显的当前状态由前一(或者前二)状态推出,上动规5部曲

dp数组及下标的含义、递推公式、dp数组初始化、遍历顺序等在题中很明显,不再赘述。

需要格外注意的一点是

如该题中,初始化dp数组的时候,初始化了两个元素:

        if (n == 0) return 0;//当n == 0的时候一定要剪枝

        vector<int> dp(n + 1, 0);

        dp[0] = 0;

        dp[1] = 1;

假如上面的dp数组大小为1(实际上不从1开始,但是编译的过程会出现后面的问题),若写为:dp(n, 0),而初始化时初始化了2个元素,必然会越界!因此,在剪枝的基础上,初始化时还不得不防止数组越界

70.爬楼梯

每次可以爬 1 或 2 个台阶,求到达n阶楼顶有多少种方法。

到达第n阶楼梯可以由:到达第n-1阶楼梯 与 到达第n-2阶楼梯,两种方法相加!因此当前状态实际上是由前面两种状态推导而来,要用动态规划的方法!

动规5部曲:比较简单,略。

需要注意的点为

初始化时:

        if (n == 1) return n; //若n==1,则dp的大小为2,那么后面初始化时dp[2]必然越界!

        vector<int> dp(n + 1);//为了防止数组越界,数组定义时 +1

        //dp[0] = 0;//不用考虑dp[0]的情况,因为n >= 1

        dp[1] = 1;

        dp[2] = 2;

746.使用最小花费爬楼梯

每次可以爬 1 或 2 个台阶,第 i 个台阶向上爬需要的成本为cost[i]。

与上题的区别在于本题的初始化和递推公式上面。

直接思维可能不太好想到,但是我们用反向思维:假设当前前楼顶 cost.size()这个位置,那么如何最小呢?依赖于前两阶台阶,哪一阶最小,就是到达该级台阶的最小花费,也就是:dp[j] = min(dp[j - 1], dp[j - 2])。

62.不同路径

题目要求 左上角的机器人机器人每次只能向下或者向右移动一步,移动到右下角的终点有几种不同的方法呢?

机器人的走法是固定的,即:机器人不是从终点的上方格子移动到终点,就是从终点的下方格子移动到终点的,因此,机器人到达终点的方法数量=左方移动到终点的方法数+上方移动到终点的方法数。很明显,存在状态转移,因此用动态规划解决

动规5部曲

  1. dp[i][j]为:机器人从起点移动到第i行第j列有dp[i][j]种方法。
  2. 递推公式:dp[i][j] = dp[i-1][j] + dp[i][j - 1]
  3. dp数组初始化:dp[i][0] = 1, dp[0][j] = 1
  4. 向右下角递推
  5. 打印dp数组

63.不同路径Ⅱ

本题相比于上一题多了 障碍物,因此分析的逻辑还是一样的,不同之处在于:1、初始化;2、递归过程中。

1、初始化时,如果(i, 0) 这条边有了障碍之后,障碍之后(包括障碍)都是走不到的位置了,所以障碍之后的dp[i][0]应该还是初始值0。

vector<vector<int>> dp(m, vector<int>(n, 0));
for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1;
for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1;

2、递推公式中,当遇到障碍物时,不应再赋值。

for (int i = 1; i < m; i++) {
    for (int j = 1; j < n; j++) {
        if (obstacleGrid[i][j] == 1) continue;
        dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
    }
}

343.整数拆分

给定一个正整数 n ,将其拆分为 k 个 正整数 的和( k >= 2 ),并使这些整数的乘积最大化。返回最大乘积。

96.不同的二叉搜索树

给定一个整数 n,求以 1 ... n 为节点组成的二叉搜索树有多少种?

背包问题

背包问题的应用场景:

  1. 题目中隐含着选择为手段去完成目标的!(选择(目标和、)、选出、找出等字眼,还有(找出)最多、最小。。。)
  2. 一般题目问什么或者怎么问的,我们就怎么设dp数组的含义!

01背包和完全背包两类问题的区别仅在于:每个物品的数量有多少个。

背包问题的理论基础重中之重是01背包,一定要理解透!完全背包也可以转化为01背包。

背包问题注意事项汇总

二维数组中,对于非0下标的初始化问题,需要具体问题具体分析:

  • 假设非0下标的值是最开始就是由0下标的值推导而来(并且不涉及与自身的比较),则这些下标初始化为多少都可以,因为最终都会被覆盖!(涉及一维dp数组的初始化)

1、0-1背包问题

纯01背包问题描述:

有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

其暴力解法为:

每一件物品其实只有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是O(2^n),这里的n表示物品数量。

从上可知,暴力的解法是指数级别的时间复杂度,因此需要动态规划的解法来进行优化!

1.1、二维dp数组01背包
  • 确定dp数组以及下标的含义

dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,所能容纳的价值总和最大是dp[i][j]。(要时刻记着这个dp数组的含义,下面的一些步骤都围绕这dp数组的含义进行的

  • 确定递推公式

再回顾一下dp[i][j]的含义:从下标为 [0-i] 的物品里任意取,放进容量为 j 的背包,价值总和最大是多少。那么可以有两个方向推出来dp[i][j],

  • 不放物品i:由dp[i - 1][j]推出,即背包容量为 j,里面不放物品 i 的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当物品 i 的重量大于背包 j 的重量时,物品 i 无法放进背包中,所以背包内的价值依然和前面相同。)
  • 放物品i:由dp[i - 1][j - weight[i]] 推出,dp[i - 1][j - weight[i]] 为背包容量为 j - weight[i] 的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品 i 得到的最大价值。

所以递推公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

  • dp数组如何初始化

关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱

首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。如下图:

再看其他情况:

状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);可以看出 i 是由 i-1 推导出来,那么 i 为 0 的时候就一定要初始化。

dp[0][j],即:i 为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。

那么很明显当 j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。

当j >= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。

for (int j = 0 ; j < weight[0]; j++) {  
    dp[0][j] = 0;
}
// 当然上面这一步,如果把dp数组预先初始化为0了,这一步就可以省略,
// 但很多同学应该没有想清楚这一点。

// 正序遍历
for (int j = weight[0]; j <= bagweight; j++) {
    dp[0][j] = value[0];
}

dp[0][j] 和 dp[i][0] 都已经初始化了,那么其他下标应该初始化多少呢?

其实从递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出dp[i][j] 是由左上方数值推导出来了,那么 其他下标初始为什么数值都可以,因为都会被覆盖。

初始-1,初始-2,初始100,都可以!

但只不过一开始就统一把dp数组统一初始为0,更方便一些。

最终初始化部分代码如下:

// 初始化 dp
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
for (int j = weight[0]; j <= bagweight; j++) {
    dp[0][j] = value[0];
}
  • 确定遍历顺序

在二维dp数组01背包中,有两个遍历的维度:物品与背包重量。

那么,先遍历 物品还是先遍历背包重量呢?

其实都可以!! 但是先遍历物品更好理解,代码如下

// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
    for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
        if (j < weight[i]) dp[i][j] = dp[i - 1][j];
        else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

    }
}

先遍历背包,再遍历物品,也是可以的!(注意我这里使用的二维dp数组)

// weight数组的大小 就是物品个数
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
    for(int i = 1; i < weight.size(); i++) { // 遍历物品
        if (j < weight[i]) dp[i][j] = dp[i - 1][j];
        else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
    }
}

注意:以上两种方法的代码中物品的编号都是从1开始的,因为,第一行已经初始化了!

为什么二维dp数组的两个for循环(也就是遍历顺序)是可以颠倒的?

因为:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 递归公式中可以看出dp[i][j]是靠dp[i-1][j] 和 dp[i - 1][j - weight[i]] 推导出来的。

dp[i-1][j] 和 dp[i - 1][j - weight[i]] 都在 dp[i][j] 的左上角方向(包括正上方向),那么先遍历物品,再遍历背包的过程如图所示:

再来看看先遍历背包,再遍历物品,如图:

大家可以看出,虽然两个for循环遍历的次序不同,但是dp[i][j]所需要的数据就是左上角和正上方的值,无论推导到哪一个值,它的需要的左上角和正上方的状态已经推导完成了,根本不影响dp[i][j]公式的推导!

  • 举例推导dp数组

建议大家此时自己在纸上推导一遍,看看dp数组里每一个数值是不是这样的。

做动态规划的题目,最好的过程就是自己在纸上举一个例子把对应的dp数组的数值推导一下,然后在动手写代码!

最后二维dp数组01背包问题的代码如下:

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

int main() {
    int n, bagweight;//分别代表:物品的件数,和背包的总容量
    cin >> n >> bagweight;
    vector<int> weight(n, 0);//每件物品所占的重量
    vector<int> value(n, 0);//没件物品的价值
    for (int i = 0; i < n; ++i) {
        cin >> weight[i];//手动输入第一行数据
    }
    for (int i = 0; i < n; ++i) {
        cin >> value[i];//手动输入第二行数据
    }
    vector<vector<int>> dp(n, vector<int>(bagweight + 1, 0));//定义dp数组并初始化
    for (int j = weight[0]; j <= bagweight; j++) {
        dp[0][j] = value[0];
    }//初始化
    for (int i = 1; i < n; ++i) {  //先遍历的物品
        for (int j = 0; j <= bagweight; ++j) { //后遍历背包
            if (j >= weight[i]) {   //放得下物品i
                dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
            } else {    //放不下物品i
                dp[i][j] = dp[i - 1][j];
            }
        }
    }

    cout << dp[n - 1][bagweight] << endl;

    return 0;
}
1.2、一维dp数组01背包(滚动数组)

背包最大重量为4。

物品为:

重量价值
物品0115
物品1320
物品2430

问背包能背的物品最大价值是多少?

二维dp数组中:dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是dp[i][j]

采用滚动数组,进行动规5部曲如下:

  • 确定dp数组的定义

dp[j]表示:容量为j的背包,所背的物品价值最大可以为dp[j]。

  • 一维dp数组的递推公式

dp[j]为 容量为 j 的背包所背的最大价值,那么如何推导 dp[j] 呢?

dp[j]可以通过dp[j - weight[i]]推导出来,dp[j - weight[i]] 表示容量为 j - weight[i] 的背包所背的最大价值。

dp[j]有两个选择,一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j],即不放物品 i ;一个是取dp[j - weight[i]] + value[i],即放物品i 。最后在这两种情况中取最大的,毕竟是求最大价值,

dp[j - weight[i]] + value[i] 表示容量为 [j - 物品i重量] 的背包所背的最大价值 + 物品 i 的价值。(也就是容量为 j 的背包,放入物品 i 了之后的价值即:dp[j])

因此,递推公式为

dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

可以看出相对于二维dp数组的写法,就是把dp[i][j]中i的维度去掉了。

  • 一维dp数组如何初始化

关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱

dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。

那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢?

看一下递推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。

这样才能让dp数组在递推的过程中取的最大的价值,而不是被初始值覆盖了。(因为一维dp数组在递推的过程中需要与自身进行比较,因此初始化的时候很有讲究!)

如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。

  • 一维dp数组遍历顺序
for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    }
}

注意到:二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小

1、为什么遍历背包的时候只能倒序遍历呢?

如图:

当外层循环为 i = 0 时,内层循环如何更新的呢?
内层循环需要 取 当前位置的旧值(上一层一维dp数组的对应位置的值)与当前位置的左边的某个位置的值+value[i] 中最大的值,要注意,此时:当前位置的左边某个位置的值 == dp[j - weight[i] ] !!其实就是二维dp数组中的dp[i - 1][j - weight[i]] !!而这个值就是滚动数组上一层计算出来的!!因此,当内层循环倒序遍历时,我们拿到的 dp[j - weight[i] ] 这个值其实是滚动数组中的上一层的,相当于二维dp数组的 i - 1层的dp[i - 1][j - weight[i]],这是合理的!!但是,当内层循环顺序遍历时,此时拿到的 dp[j - weight[i] ] 这个值已经是滚动数组中最新的当前层的值了,也就相当于二维dp数组中左边的值:dp[i][j - weight[i]] !!我们真正需要的dp[i - 1][j - weight[i]]值已经被覆盖了!这显然就是错的!!因此必须倒序遍历内层循环!

也就是说,一维dp数组的遍历 本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。 

那为什么二维dp数组遍历的时候不用倒序呢?

因为对于二维dp,dp[i][j]都是通过上一层即dp[i - 1][j]计算而来,本层的dp[i][j]并不会被覆盖!

再来看看两个嵌套for循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?

不可以!

因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。

也就是说,dp[j]中的j就是对背包容量的遍历,若在外层遍历背包,则每次遍历物品时,背包的容量都是固定的,不符合实际。(观察上面递推公式代码即可)

  • 举例推导dp数组

416.分割等和子集

题目描述:给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

该题的题目描述中其实对动态规划有一定的启发,即该数组只包含正整数,因此当我们使用一维dp数组进行初始化时能比较明确的初始化为0。

首先,本题要求集合里能否出现总和为 sum / 2 的子集。那么来一一对应一下本题,看看背包问题如何来解决。

只有确定了如下四点,才能把01背包问题套到本题上来。

  • 背包的体积为sum / 2
  • 背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值
  • 背包如果正好装满,说明找到了总和为 sum / 2 的子集。
  • 背包中每一个元素是不可重复放入。

动规5部曲:

  • 确定dp数组以及下标的含义

01背包中,dp[j] 表示: 容量为j的背包,所背的物品价值最大可以为dp[j]。

本题中每一个元素的数值既是重量,也是价值。

套到本题,dp[j]表示 背包总容量(所能装的总重量)是j,放进物品后,背的最大重量为dp[j]

那么如果背包容量为target, dp[target]就是装满 背包之后的重量,所以 当 dp[target] == target 的时候,背包就装满了。

  • 确定递推公式

01背包的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

本题,相当于背包里放入数值,那么物品i的重量是nums[i],其价值也是nums[i]

所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);

  • dp数组如何初始化

在01背包,一维dp如何初始化,已经讲过,从dp[j]的定义来看,首先dp[0]一定是0。

如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷

这样才能让dp数组在递推的过程中取得最大的价值,而不是被初始值覆盖了

  • 确定遍历顺序

动态规划:关于01背包问题,你该了解这些!(滚动数组) (opens new window)中就已经说明:如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历!

代码如下:

// 开始 01背包
for(int i = 0; i < nums.size(); i++) {
    for(int j = target; j >= nums[i]; j--) { // 每一个元素一定是不可重复放入,所以从大到小遍历
        dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
    }
}
  • 举例推导dp数组

dp[j]的数值一定是小于等于j的。

如果dp[j] == j 说明,集合中的子集总和正好可以凑成总和j,理解这一点很重要。

1049.最后一块石头的重量Ⅱ

题目描述:用整数数组 stones (其中 stones[i] 表示第 i 块石头的重量)表示一堆石头的重量,要求两两相撞并粉碎,问最后石头的重量为多少?

按照题意,上一题分割等和子集相当于是求背包是否正好装满,而本题是求背包最多能装多少。那么本题就是要将整堆石头尽量分成 两堆重量一样的,即使不一样,也要相似。因此,与分割等和子集很相似了。

区别在于返回值,本题中,若能分成两堆完全一样的石头,则返回0;若分不成完全一样的,则返回最后一个石头的重量。

本题中,石头的重量是 stones[i],石头的价值也是 stones[i] ,可以 “最多可以装的价值为 dp[j]” == “最多可以背的重量为dp[j]”

if (sum - dp[target] != dp[target]) return sum - dp[target] - dp[target];
        return 0;
494.目标和

题目描述:给定一个非负整数数组,a1, a2, ..., an, 和一个目标数,S。现在你有两个符号 + 和 -。对于数组中的任意一个整数,你都可以从 + 或 - 中选择一个符号添加在前面。要求返回可以使最终数组和为目标数 S 的所有添加符号的方法数。

本题数组中,看到“选择”等字眼,应该要想到可能是背包问题每个数只有两种状态:被选 和 不被选,由此应该想到回溯或者01背包了。那,这里的背包是由什么表示呢?首先,与上一题相同,本题还是将数组分为两个集合,一个为正数集合,另一个为负数集合。因此有:x - neg = target;x + neg = sum;由此得:x = (target + sum)/2;

此时问题就转化为,装满容量为x的背包,有几种方法。这里的x,就是bagSize,也就是我们后面要求的背包容量。由于 (target + sum) / 2 向下取整,当target + sum为奇数时其实是无解的(因为正负的绝对值已经不相等了!)

if ((target + sum) % 2 == 1) return 0; // 此时没有方案

同时如果 target 的绝对值已经大于sum,那么也是没有方案的。

if (abs(target) > sum) return 0; // 此时没有方案
  • 确定dp数组以及下标的含义

dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法

其实也可以使用二维dp数组来求解本题,dp[i][j]:使用 下标为[0, i]的nums[i]能够凑满j(包括j)这么大容量的包,有dp[i][j]种方法。

  • 确定递推公式

只要搞到nums[i],凑成dp[j]就有dp[j - nums[i]] 种方法。

例如:dp[j],j 为5,

  • 已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 容量为5的背包。
  • 已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 容量为5的背包。
  • 已经有一个3(nums[i]) 的话,有 dp[2]中方法 凑成 容量为5的背包
  • 已经有一个4(nums[i]) 的话,有 dp[1]中方法 凑成 容量为5的背包
  • 已经有一个5 (nums[i])的话,有 dp[0]中方法 凑成 容量为5的背包

那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。

所以求组合类问题的公式,都是类似这种:

dp[j] += dp[j - nums[i]]

这个公式在后面在讲解背包解决排列组合问题(求装满背包有几种方法)的时候还会用到!

  • dp数组如何初始化

初始化一般根据dp数组的定义和递推公式而得

从递推公式可以看出,在初始化的时候dp[0] 一定要初始化为1,因为dp[0]是在公式中一切递推结果的起源,如果dp[0]是0的话,递推结果将都是0。

  • 确定遍历顺序
  • 举例推导dp数组
474.一和零

题目描述:给你一个二进制字符串数组 strs 和两个整数 m 和 n 。请你找出并返回 strs 的最大子集的大小,该子集中 最多 有 m 个 0 和 n 个 1 。

本题目中出现了 找出 和 最大 两个字眼,因此就是要用背包问题去解决这道题。那么如何转化为背包问题呢?

本题中strs 数组里的元素就是物品,每个物品都是一个!首先,目标(子集)中最多只能有m个0和n个1,而 m 和 n 相当于是一个背包的大小,这是个两个维度的背包

意思就相当于:背包的最大容量只能装m个0和n个1;然后,题目要求的是在背包容量的基础上最多能装多少个物品?

那应该如何定义dp数组呢?实际上该背包有两个维度组成,那么设dp数组为:从 [0, k] 区间中任选字符串,装满 容量为 i 个0和 j 个1 的背包,此时背包中的字符串数量为dp[k][i][j]。

动态规划5部曲:

  • dp数组的下标及含义

dp数组表示:从 [0, k] 区间中任选字符串,装满 容量为 i 个0和 j 个1 的背包,此时背包中的字符串数量为dp[k][i][j]。

  • 递推公式

dp数组来源于两种情况:

  1. 取strs[k]这个字符串: dp[k][i][j] = dp[k - 1][i - x][j - y] + 1  。
  2. 不取strs[k]这个字符串:dp[k][i][j] = dp[k - 1][i][j] 。

因此 dp[k][i][j] = max(dp[k - 1][i - x][j - y] + 1, dp[k - 1][i][j]) 

  • dp数组如何初始化

dp[0][0][0] = 0        //dp数组表示数量,因此容量为0是,只能选0个字符串

当k为0,而 i 和 j 均不为0时,dp = 0;

当k不为0,而 i 为0,j 不为0时,

当k不为0,而 i 不为 0,j 为0时,。。。太复杂了, 因此进行压缩!

由于dp数组在遍历的过程中涉及到于自身的比较,且dp永远是非负数,因此非0下标初始化为0即可。

  • 遍历顺序

从前向后,且先遍历物品(本题中即字符串)或者先遍历背包都可以。

以下是自己的代码,但还未ac

class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
        vector<vector<vector<int>>> dp(strs.size() + 1, vector<vector<int>>(m + 1, vector<int>(n + 1, 0)));
        for (int k = 1; k < strs.size(); k++) {     //遍历物品
            int x = 0;//用于记录strs[k]中0的数量
            int y = 0;//用于记录strs[k]中1的数量
            for (char str : strs[k]) {
                if (str == '0') x++;
                if (str == '1') y++;
            }
            for (int i = 0; i <= m; i++) {  //后面两个for循环属于是遍历背包了!
                for (int j = 0; j <= n; j++) {
                    if (i >= x && j >= y) {
                        dp[k][i][j] = max(dp[k - 1][i - x][j - y] + 1, dp[k - 1][i][j]);
                    }
                    dp[k][i][j] = dp[k - 1][i][j];
                }
            }
        }
        return dp[strs.size()-1][m][n];
    }
};

这道题难,还未想明白——2024.5.8

要彻底的理解一维滚动数组的含义,可以结合 爬楼梯完全背包版本(进阶版) 题目的PDF讲解!

代码随想录 (programmercarl.com)

0-1背包总结

此时我们讲解了0-1背包的多种应用,

所以在代码随想录中所列举的题目,都是 0-1背包不同维度上的应用,大家可以细心体会!

2、完全背包

纯完全背包问题描述:

有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。

完全背包和01背包问题唯一不同的地方就是,每种物品有无限件(有些题目会着重强调这一点,因此是一个突破点)

在实际应用中,01背包和完全背包的解法 唯一不同就是体现在遍历顺序上,所以本文就不去做动规五部曲了,我们直接针对遍历顺序经行分析!

首先回顾01背包一维滚动数组的核心代码:

for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

也就是说:01背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次

完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:

// 先遍历物品,再遍历背包
for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = weight[i]; j <= bagWeight; j++) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    }
}

其实还有一个很重要的问题,为什么遍历物品在外层循环,遍历背包容量在内层循环?

在01背包中二维dp数组的两个for遍历的先后循序是可以颠倒的,一维dp数组的两个for循环先后顺序一定是先遍历物品,再遍历背包容量

在纯完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的!

因为dp[j] 是根据 下标 j 之前(j - 1, j - 2, ...)所对应的dp[j]计算出来的。 只要保证下标 j 之前的dp[j]都是经过计算的就可以了。

也就是说:在完全背包中,两个for循环的先后顺序,都不影响计算dp[j]所需要的值(这个值就是下标 j(j - 1, j - 2, ...)之前所对应的dp[j])。

先遍历背包再遍历物品,代码如下:

// 先遍历背包,再遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
    cout << endl;
}
518.零钱兑换Ⅱ

题目描述:给定不同面额的硬币 coins[i] 和一个总金额 amount 。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。

注意:本题中,不同面额的硬币表示不同的重量,同时也表示价值,总金额表示背包的容量,因为物品有无限个,因此是完全背包问题。但本题和纯完全背包不一样,纯完全背包是凑成背包最大价值是多少,而本题是要求凑成总金额的物品组合个数!

注意:组合不强调元素之间的顺序,排列强调元素之间的顺序

动规5步曲来分析如下:

  • 确定dp数组以及下标的含义

dp[j]:凑成总金额 j 的货币组合数为dp[j]

  • 确定递推公式

dp[j] 就是所有的dp[j - coins[i]](考虑coins[i]的情况)相加。

所以递推公式:dp[j] += dp[j - coins[i]];

这个递推公式大家应该不陌生了, 我在讲解01背包题目的时候在这篇494. 目标和  中就讲解了,求装满背包有几种方法,公式都是:dp[j] += dp[j - nums[i]]

  • dp数组如何初始化

首先dp[0]一定要为1,dp[0] = 1 是递推公式的基础。如果dp[0] = 0 的话,后面所有推导出来的值都是0了。下标非0的dp[j]初始化为0,这样累计加dp[j - coins[i]]的时候才不会影响真正的dp[j]。

  • 确定遍历顺序

本题中我们是外层for循环遍历物品(钱币),内层for遍历背包(金钱总额),还是外层for遍历背包(金钱总额),内层for循环遍历物品(钱币)呢?

实际上,对于纯完全背包问题来说,两种遍历顺序都是可以的

但本题就不行了!

因为纯完全背包求的是装满背包的最大价值是多少,和凑成总和的元素有没有顺序没关系,即:有顺序也行,没有顺序也行!

而本题要求凑成总和的组合数,元素之间明确要求没有顺序。

所以纯完全背包是能凑成总和就行,不用管怎么凑的。

本题是求凑出来的方案个数,且每个方案个数是为组合数。

        1、对于外层for循环遍历物品(钱币),内层for遍历背包(金钱总额)的情况,代码如下:

for (int i = 0; i < coins.size(); i++) { // 遍历物品
    for (int j = coins[i]; j <= amount; j++) { // 遍历背包容量
        dp[j] += dp[j - coins[i]];
    }
}

假设:coins[0] = 1,coins[1] = 5。

那么就是先把1加入计算,然后再把5加入计算,得到的方法数量只有{1, 5}这种情况。而不会出现{5, 1}的情况。

所以这种遍历顺序(先遍历物品,再遍历背包)中dp[j]里计算的是组合数!

        2、对于外层for循环遍历背包(金钱总额),内层for循环遍历物品(钱币),代码如下:

for (int j = 0; j <= amount; j++) { // 遍历背包容量
    for (int i = 0; i < coins.size(); i++) { // 遍历物品
        if (j - coins[i] >= 0) dp[j] += dp[j - coins[i]];
    }
}

背包容量的每一个值,都是经过 1 和 5 的计算,包含了{1, 5} 和 {5, 1}两种情况。

此时这种遍历顺序(先遍历背包,后遍历物品)中dp[j]里算出来的就是排列数!

  • 举例推导dp数组
总结

在求装满背包有几种方案的时候,认清遍历顺序是非常关键的。

如果求组合数就是外层for循环遍历物品,内层for遍历背包

如果求排列数就是外层for遍历背包,内层for循环遍历物品

377.组合总和Ⅳ

题目描述:给你一个由 不同 整数组成的数组 nums ,和一个目标整数 target 。请你从 nums 中找出并返回总和为 target 的元素组合的个数。

题目中出现了 找出、总和等两个字眼,判断为背包类问题

从示例中可知本题其实求的是排列数!(完全背包求排列数一定是 先遍历背包,后遍历物品!)

大家在公众号里学习回溯算法专题的时候,一定做过这两道题目回溯算法:39.组合总和回溯算法:40.组合总和II会感觉这两题和本题很像!

但其本质是本题求的是排列总和,而且仅仅是求排列总和的个数,并不是把所有的排列都列出来。

如果本题要把排列都列出来的话,只能使用回溯算法爆搜

动规5部曲分析如下:

  • 确定dp数组以及下标的含义

dp[i]: 凑成目标正整数为 i 的排列个数为dp[i]。

  • 确定递推公式

dp[i](考虑nums[j])可以由 dp[i - nums[j]](不考虑nums[j]) 推导出来。因为只要得到nums[j],排列个数dp[i - nums[j]],就是dp[i]的一部分。

动态规划:494.目标和和 动态规划:518.零钱兑换II中我们已经讲过了,求装满背包有几种方法,递推公式一般都是dp[i] += dp[i - nums[j]];

本题也一样。

  • dp数组如何初始化

因为递推公式dp[i] += dp[i - nums[j]]的缘故,dp[0]要初始化为1,这样递归其他dp[i]的时候才会有数值基础。

  • 确定遍历顺序

个数可以不限使用,说明这是一个完全背包

得到的集合是排列,说明需要考虑元素之间的顺序

注意:

如果求组合数就是外层for循环遍历物品,内层for遍历背包

如果求排列数就是外层for遍历背包,内层for循环遍历物品

本题遍历顺序最终遍历顺序:target(背包)放在外循环,将nums(物品)放在内循环,内循环从前到后遍历

  • 举例来推导dp数组

注意:C++测试用例有两个数相加超过int的数据,所以需要在if里加上dp[i] < INT_MAX - dp[i - num]。

57.爬楼梯(进阶版)

题目描述:假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬至多m (1 <= m < n)个台阶。你有多少种不同的方法可以爬到楼顶呢?

之前做的 爬楼梯 是只能至多爬两个台阶。这次改为:一步一个台阶,两个台阶,三个台阶,.......,直到 m个台阶。问有多少种不同的方法可以爬到楼顶呢?其实,本题的题意相当于:从1到m任意取值,其和为n,且每个数可以取无数个,这其实是一个完全背包问题

动规五部曲分析如下:

  • 确定dp数组以及下标的含义

dp[i]:爬到有i个台阶的楼顶,有dp[i]种方法

  • 确定递推公式

动态规划:494.目标和、 动态规划:518.零钱兑换II动态规划:377. 组合总和 Ⅳ中我们都讲过了,求装满背包有几种方法,递推公式一般都是dp[i] += dp[i - nums[j]];

本题呢,dp[i]有几种来源,dp[i - 1],dp[i - 2],dp[i - 3] 等等,即:dp[i - j]

那么递推公式为:dp[i] += dp[i - j]

  • dp数组如何初始化

既然递归公式是 dp[i] += dp[i - j],那么dp[0] 一定为1,dp[0]是递归中一切数值的基础所在,如果dp[0]是0的话,其他数值都是0了。非0下标的dp[i]初始化为0,因为dp[i]是靠dp[i-j]累计上来的,dp[i]本身为0这样才不会影响结果

  • 确定遍历顺序

这是背包里求排列问题,即:1、2 步 和 2、1 步都是上三个台阶,但是这两种方法不一样!所以需将target放在外循环,将nums放在内循环。每一步可以走多次,这是完全背包,内循环需要从前向后遍历。

  • 举例来推导dp数组
#include <iostream>
#include <vector>
using namespace std;
int main() {
    int n, m;
    while (cin >> n >> m) {
        vector<int> dp(n + 1, 0);
        dp[0] = 1;
        for (int i = 1; i <= n; i++) { // 遍历背包
            for (int j = 1; j <= m; j++) { // 遍历物品
                if (i - j >= 0) dp[i] += dp[i - j];
            }
        }
        cout << dp[n] << endl;
    }
}
322.零钱兑换

题目描述:给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

你可以认为每种硬币的数量是无限的。

题目中明确说硬币的数量是无限的,这是背包问题无疑!而且是完全背包!

动规5部曲分析如下:

  • 确定dp数组以及下标的含义

dp[j]:凑足总额为j所需钱币的最少个数为dp[j]

  • 确定递推公式

凑足总额为 j - coins[i] 的最少个数为dp[j - coins[i]],那么只需要加上一个钱币coins[i] 即dp[j - coins[i]] + 1就是dp[j](考虑coins[i])

所以dp[j] 要取所有 dp[j - coins[i]] + 1 中最小的。

递推公式:dp[j] = min(dp[j - coins[i]] + 1, dp[j]);

  • dp数组如何初始化

首先凑足总金额为0所需钱币的个数一定是0,那么dp[0] = 0;

其他下标对应的数值呢?

考虑到递推公式的特性,dp[j]必须初始化为一个最大的数,否则就会在min(dp[j - coins[i]] + 1, dp[j])比较的过程中被初始值覆盖。

所以下标非0的元素都是应该是最大值。

代码如下:

vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
  • 确定遍历顺序

本题求钱币最小个数,那么钱币有顺序和没有顺序都可以,都不影响钱币的最小个数

所以本题并不强调集合是组合还是排列。

如果求组合数就是外层for循环遍历物品,内层for遍历背包

如果求排列数就是外层for遍历背包,内层for循环遍历物品

在动态规划专题我们讲过了求组合数是动态规划:518.零钱兑换II,求排列数是动态规划:377. 组合总和 Ⅳ

所以本题的两个for循环的关系是:外层for循环遍历物品,内层for遍历背包 或者 外层for遍历背包,内层for循环遍历物品都是可以的!

那么我采用coins放在外循环,target在内循环的方式。

本题钱币数量可以无限使用,那么是完全背包。所以遍历的内循环是正序

综上所述,遍历顺序为:coins(物品)放在外循环,target(背包)在内循环。且内循环正序。

  • 举例推导dp数组
279.完全平方数

给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。

完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,149 和 16 都是完全平方数,而 3 和 11 不是。

由题意可得:要想相加得到 n , 那这些完全平方数一定是比n 小的!题意转为:从小于等于n的完全平方数中任选,使得其和等于n,选取的完全平方数个数为无限个。

以下为本人写的可ac的代码:

class Solution {
public:
    int numSquares(int n) {
        //要想相加得到 n , 那这些完全平方数一定是比n 小的!
        //题意转为,从小于等于n的完全平方数中任选,使得其和等于n,选取的完全平方数个数为无限个
        //如何求完全平方数呢?
        vector<int> squareNum(n,0);
        for (int i = 1; i <= n; i++) {
            if (i * i <= n) squareNum.push_back(i * i);
        }
        vector<int> dp(n + 1, n + 1);
        dp[0] = 0;
        for (int i = 0; i < squareNum.size(); i++) {
            for (int j = squareNum[i]; j <= n; j++) {
                if (squareNum[i] == 0) break;
                dp[j] = min(dp[j - squareNum[i]] + 1, dp[j]);
            }
        }
        return dp[n];
    }
};

动规5部曲分析如下:

  • 确定dp数组(dp table)以及下标的含义

dp[j]:和为j的完全平方数的最少数量为dp[j]

  • 确定递推公式

dp[j] 可以由dp[j - i * i]推出, dp[j - i * i] + 1 便可以凑成dp[j]。

此时我们要选择最小的dp[j],所以递推公式:dp[j] = min(dp[j - i * i] + 1, dp[j]);

  • dp数组如何初始化

dp[0]表示 和为0的完全平方数的最小数量,那么dp[0]一定是0。有同学问题,那0 * 0 也算是一种啊,为啥dp[0] 就是 0呢?

看题目描述,找到若干个完全平方数(比如 1, 4, 9, 16, ...),题目描述中可没说要从0开始,dp[0]=0完全是为了递推公式。

非0下标的dp[j]应该是多少呢?

从递归公式dp[j] = min(dp[j - i * i] + 1, dp[j]);中可以看出每次dp[j]都要选最小的,所以非0下标的dp[j]一定要初始为最大值,这样dp[j]在递推的时候才不会被初始值覆盖

  • 确定遍历顺序

我们知道这是完全背包,

如果求组合数就是外层for循环遍历物品,内层for遍历背包。

如果求排列数就是外层for遍历背包,内层for循环遍历物品。

动态规划:322. 零钱兑换中我们就深入探讨了这个问题,本题也是一样的,是求最小数!

所以本题外层for遍历背包,内层for遍历物品,还是外层for遍历物品,内层for遍历背包,都是可以的!

  • 举例推导dp数组

以上动规五部曲分析完毕,C++代码如下: 

// 版本一
class Solution {
public:
    int numSquares(int n) {
        vector<int> dp(n + 1, INT_MAX);
        dp[0] = 0;
        for (int i = 1; i * i <= n; i++) { // 遍历物品
            for (int j = i * i; j <= n; j++) { // 遍历背包
                dp[j] = min(dp[j - i * i] + 1, dp[j]);
            }
        }
        return dp[n];
    }
};
139.单词拆分

题目描述:给你一个字符串 s 和一个字符串列表 wordDict 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s 则返回 true

注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

题目中明确单词可以重复使用,且题意其实就是:从wordDict中找出一个或者多个单词,拼接出s。因此本题就是比较明显的完全背包类的题目。

动态规划总结

3、打家劫舍

198.打家劫舍

题目描述:你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

分析:当前房屋偷与不偷,取决于 前一个房屋和前两个房屋是否被偷了。

所以这里就更感觉到,当前状态和前面状态会有一种依赖关系,那么这种依赖关系都是动规的递推公式

以上是大概思路,打家劫舍是dp解决的经典问题,接下来我们来动规五部曲分析如下:

  • 确定dp数组(dp table)以及下标的含义

dp[i]:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i]

  • 确定递推公式

决定dp[i]的因素就是第i房间偷还是不偷。

如果偷第i房间,那么dp[i] = dp[i - 2] + nums[i] ,即:第i-1房一定是不考虑的,找出 下标i-2(包括i-2)以内的房屋,最多可以偷窃的金额为dp[i-2] 加上第i房间偷到的钱。

如果不偷第i房间,那么dp[i] = dp[i - 1],即考 虑i-1房,(注意这里是考虑,并不是一定要偷i-1房,这是很多同学容易混淆的点

然后dp[i]取最大值,即dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);

  • dp数组如何初始化

从递推公式dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);可以看出,递推公式的基础就是dp[0] 和 dp[1]

从dp[i]的定义上来讲,dp[0] 一定是 nums[0],dp[1]就是nums[0]和nums[1]的最大值即:dp[1] = max(nums[0], nums[1]);

代码如下:

vector<int> dp(nums.size());
dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]);
  • 确定遍历顺序

dp[i] 是根据dp[i - 2] 和 dp[i - 1] 推导出来的,那么一定是从前到后遍历!

代码如下:

for (int i = 2; i < nums.size(); i++) {
    dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
}
  • 举例推导dp数组
213.打家劫舍Ⅱ

题目描述:你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。

给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,能够偷窃到的最高金额。

本题与上题相同,都是用动态规划解决

对于一个数组,成环的话主要有如下三种情况:

  • 情况一:考虑不包含首尾元素

213.打家劫舍II

  • 情况二:考虑包含首元素,不包含尾元素

213.打家劫舍II1

  • 情况三:考虑包含尾元素,不包含首元素

213.打家劫舍II2

注意我这里用的是"考虑",例如情况三,虽然是考虑包含尾元素,但不一定要选尾部元素! 对于情况三,取nums[1] 和 nums[3]就是最大的。

而情况二 和 情况三 都包含了情况一了,所以只考虑情况二和情况三就可以了

分析到这里,本题其实比较简单了。 剩下的和198.打家劫舍就是一样的了。

337.打家劫舍Ⅲ

题目描述:小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root 。

除了 root 之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。

给定二叉树的 root 。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额 。

本题和前两题一样,都是用动态规划做的题目。但如果对树的遍历不够熟悉的话,那本题就有难度了。

对于树的话,首先就要想到遍历方式,前中后序(深度优先搜索)还是层序遍历(广度优先搜索)。本题一定是要后序遍历,因为通过递归函数的返回值来做下一步计算

与198.打家劫舍,213.打家劫舍II一样,关键是要讨论当前节点抢还是不抢。

如果抢了当前节点,两个孩子就不能动,如果没抢当前节点,就可以考虑抢左右孩子(注意这里说的是“考虑”)。

这道题目算是树形dp的入门题目,因为是在树上进行状态转移,我们在讲解二叉树的时候说过递归三部曲,那么下面我以递归三部曲为框架,其中融合动规五部曲的内容来进行讲解

  • 确定递归函数的参数和返回值

这里我们要求一个节点 偷与不偷的两个状态所得到的金钱,那么返回值就是一个长度为2的数组。

参数为当前节点,代码如下:

vector<int> robTree(TreeNode* cur) {

其实这里的返回数组就是dp数组。

所以dp数组(dp table)以及下标的含义:下标为0记录不偷该节点所得到的的最大金钱,下标为1记录偷该节点所得到的的最大金钱

所以本题dp数组就是一个长度为2的数组!

那么有同学可能疑惑,长度为2的数组怎么标记树中每个节点的状态呢?

别忘了在递归的过程中,系统栈会保存每一层递归的参数

  • 确定终止条件

在遍历的过程中,如果遇到空节点的话,很明显,无论偷还是不偷都是0,所以就返回

if (cur == NULL) return vector<int>{0, 0};

这也相当于dp数组的初始化

  • 确定遍历顺序

首先明确的是使用后序遍历。 因为要通过递归函数的返回值来做下一步计算。

通过递归左节点,得到左节点偷与不偷的金钱。

通过递归右节点,得到右节点偷与不偷的金钱。

代码如下:

// 下标0:不偷,下标1:偷
vector<int> left = robTree(cur->left); // 左
vector<int> right = robTree(cur->right); // 右
// 中

  • 确定单层递归的逻辑

如果是偷当前节点,那么左右孩子就不能偷,val1 = cur->val + left[0] + right[0]; (如果对下标含义不理解就再回顾一下dp数组的含义)。如果不偷当前节点,那么左右孩子就可以偷,至于到底偷不偷一定是选一个最大的,所以:val2 = max(left[0], left[1]) + max(right[0], right[1]);

最后当前节点的状态就是{val2, val1}; 即:{不偷当前节点得到的最大金钱,偷当前节点得到的最大金钱}。代码如下:

vector<int> left = robTree(cur->left); // 左
vector<int> right = robTree(cur->right); // 右

// 偷cur
int val1 = cur->val + left[0] + right[0];
// 不偷cur
int val2 = max(left[0], left[1]) + max(right[0], right[1]);
return {val2, val1};
  • 举例推导dp数组

《图论》

要将 和边界相连的陆地 与 不和边界相连的陆地分开处理:

飞地的数量

130.被围绕的面积:


图论中的:单词接龙之后的题目未刷


C++基础

数组

定义一个n*n的二维数组

vector<vector<int>> res(n, vector<int>(n, 0)); 
// 使用vector定义一个二维数组

对数组求和

定义多个同类型的变量

int startx = 0, starty = 0;//注意中间是,号 不是分号;

将int型变量转换为string | 将string类型变量转换为int

int a = 0;
string sPath;
sPath += to_string(0);
//通过to_string将int型的a转换为string类型数据

return stoi(strNum);    //利用stoi()将strNum这个字符串转换为int型变量

stoll();                //可以转换为 long long型数据
stol();                 //转换为 long型数据

截取字符串片段

string s="sfsa";
string a=s.substr(0,3); //a == "sfs"
// substr的用法:第一个参数为起始位置,第二个参数为截取的个数!

左移和右移

左移:指数运算,例如:2 <<  2 就相当于 2的2次方

右移:除运算,例如:2 >> 2 就相当于 2 / 2等于1

  • 14
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值