算法小结
所有的套路都只是提供一种思想,列出来的是典型的格式,切不可每道题都生搬硬套
提高语言表达能力:不管这个想法是否正确,把自己的想法用代码快速表示出来的能力
总结出每一种套路的特性,每种套路分别需要知道哪些信息、条件才能使用这种套路,从题目中抽取这些信息
不要一开始就想着用什么套路可以解题,要从问题出发按照自己的思路去寻找切入点(实在想不出来再试试往哪个套路上靠,看看哪个套路可以解),然后看看自己想的这种方法对应哪种套路,如果都对不上,那就要想一想是否还有其他方法(但也不能太生硬的使用套路,套路只是提供了一种典型的思路),慎用自己的解法,因为用自己的野路子很可能行不通
当我不知道遍历一个序列何时才能找到我想要的那个元素时,可以用while()来作为结束遍历的条件,也可以用for(){if(){break;}},如果是对序列中的每一个元素都做相同的处理时,一般用for来遍历所有元素,也可以用while(n<len)来设置结束条件
一般习惯用前者
先考虑一般情况的处理再考虑特殊情况的处理
找出问题的一般规律,再套用合适的算法
先实现一般场景流程,再补充特殊场景流程
先实现灵感中的关键步骤,再补充其余步骤
不要一开始就妄想写出完整的代码,先实现主体框架,再补充细节
字符串与数字间的相互转换?
常见题型:
贪心算法?
动态规划?
递归?
最短路径?
排序
回溯算法
二分查找及其变种
二分查找必须是在一个有序序列中才能使用吗?
1019 单调栈?
序列问题:贪心,栈,滑动窗,动态规划,双指针,并查集,前缀和
处理链表的中间元素:
1. 快慢指针(2095. 删除链表的中间节点)
环形链表入环点: 142. 环形链表 II
求两个链表的相交节点,要求空间效率为O(1):把2个链表拼接起来,用2个指针p1,p2分别遍历2个链表,总有一次处理他们会相遇(p1==p2)
反转链表:一般要用到栈,或者迭代 (用栈和迭代两种方式反转链表:92. 反转链表 II 143. 重排链表)
具有随机指针的链表:把旧链表的每个节点和新链表的每个节点关联起来
求两个链表的交点:把2个链表对方的部分和自己拼起来 面试题 02.07. 链表相交
1 class Solution { 2 public: 3 ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) { 4 ListNode *p1=headA, *p2=headB; 5 if(p1==NULL || p2==NULL) return NULL; 6 while(p1 != p2){ 7 if(p1==NULL){ 8 p1 = headB; 9 } 10 else{ 11 p1 = p1->next; 12 } 13 if(p2==NULL){ 14 p2=headA; 15 } 16 else{ 17 p2=p2->next; 18 } 19 } 20 return p1; 21 } 22 };
处理链表问题最重要的是不要把这些指针弄乱,在用一个tmp变量存储其中一个指针的当前值后,就可以立马接着更新这个指针,这样依次更新,就不容易乱,例如92. 反转链表 II这道题下面的写法:
1 class Solution { 2 public: 3 ListNode* reverseBetween(ListNode* head, int left, int right) { 4 if(left==right)return head; 5 int i=1; 6 ListNode *cur = head; 7 ListNode *l = nullptr; 8 ListNode *r = nullptr; 9 while(cur != nullptr && i!=left){ 10 l = cur; 11 cur = cur->next; 12 ++i; 13 } 14 ListNode *pre = l; 15 ListNode *oldcur = cur; 16 while(cur != nullptr && i<=right){ 17 r = cur->next; 18 cur->next = pre; 19 pre = cur; 20 cur = r; 21 ++i; 22 } 23 oldcur->next = cur; 24 if(l!=nullptr){ 25 l->next = pre; 26 return head; 27 } 28 return pre; 29 30 } 31 };
判断是否要用动态规划:序列中后出现的元素会影响前面的子序列的解
贪心算法:
所求问题的整体最优解可以通过一系列局部最优的选择,换句话说,当考虑做何种选择的时候,我们果。这是贪心算法可行的第一个基本要素。贪心算法以迭代的方式作出相继的贪心选择,只考虑对当前这一步迭代中的问题做出最佳的选择,每作一次贪心选择就将所求问题简化为规模更小的子问题,并且保证以同样的处理方式处理每一步迭代最终得到的解就是最优解,即问题的关键是,每次迭代中所作的处理,可以将问题变为规模更小的相同的问题(也就是可以反复做同样的处理),并且这个处理到最后可以得到正确答案。对于一个具体问题,要确定它是否具有贪心选择性质,必须证明每一步所作的贪心选择最终导致问题的整体最优解。
应用场景:每次都进行同样的操作可以得到当前问题域的最优解(1792. 最大平均通过率)
1 Greedy(C) //C是问题的输入集合即候选集合 2 { 3 S={ }; //初始解集合为空集 4 while (not solution(S)) //集合S没有构成问题的一个解 5 { 6 x=select(C); //在候选集合C中做贪心选择 7 if feasible(S, x){ //判断集合S中加入x后的解是否可行 8 S=S+{x}; 9 C=C-{x}; 10 } 11 } 12 return S; 13 }
经典求覆盖整个区间所需要的最少子区间数问题: 1326. 灌溉花园的最少水龙头数目 1024. 视频拼接
大数相加/相乘/相除(直接算会溢出的那种,一般需要用到与字符串相转换,或者模拟人手算过程)(989)
双指针问题(986)
并查集/图(990,1722)
dfs()
bfs(994)
哈希(997, 1002)
模拟(999)
矩阵/数组(999)
栈
序列处理:一般使用单调栈、回溯、贪心、滑动窗
1. 使用单调栈时,不是说非得用栈来实现,而是要使用单调栈思想,栈功能可以用其他容器来实现(1081(2种方法都提交了))
处理一个序列时,有些可以边遍历边处理,有些是一定要知道序列全貌后才能进行下一步处理,有2种方法:
1) 先把序列遍历一遍,知道序列全貌后再进行下一步逐个处理;(1054,1090)
2) 也有些是可以一边遍历一边处理:但是每遍历一个元素就要往前或往后观察这个元素是否满足,相当于变相地观察了全貌(1081)
2. 处理以空格为分隔符的字符串序列时可以用stringstream(1078)
3. 处理以某个字符为分隔符来分割字符串可以用 istringstream + getline (71)
/* 问题:返回 s 字典序最小的子序列,该子序列包含 s 的所有不同字符,且只包含一次。*/ class Solution { public: string smallestSubsequence(string s) { vector<bool> visited(26); // 判断字母是否已经在栈中 unordered_map<char, int> num; // 记录字母出现的次数 for (char ch : s) { num[ch]++; } string stk; // string就可以模拟栈了(stk.back()为栈顶) //理想的stk是一个大栈顶,stk就是最后想要的子序列,思想就是尽量把小字符放前面:如果stk中的字符比当前的ch大,且后面还有机会能再次遇到的话,就先不要这个字符,而把ch先装进stk里 //否则就不能把这个字符从栈里面剔除(因为后面遇不到了) for (char ch : s) { if (!visited[ch - 'a']) { // 若字母没有出现在栈中 // 当 栈顶元素 > 当前字母的值,且栈顶字母次数不为0,进行出栈操作 while (!stk.empty() && stk.back() > ch && num[stk.back()] > 0) { visited[stk.back() - 'a'] = false; // 出栈后,字母不在栈中了 stk.pop_back(); // 出栈 } visited[ch - 'a'] = true; // 入栈,字母在栈中 stk.push_back(ch); } num[ch]--; // 每次访问后, 字母次数 - 1 } return stk; } };
栈的特性:遍历一个序列的元素,后入栈的元素被放到栈顶,下一个元素访问栈时首先访问的也是栈顶,也就是离它最近的元素,所以我们访问栈顶元素的顺序,也就是离当前元素从近到远的访问顺序,所以求离当前元素最近/最远的且能满足某种要求的元素(常见的词汇还有"第一个满足XXX的元素")时,可以考虑使用单调栈
栈不是要按大小把序列的所有元素都存进去,而是要在遍历序列的过程中把某些不满足的元素从栈中pop出来,从而找到满足条件的离当前所遍历的元素最近的那个元素,再把当前元素push入栈,这样来在遍历的过程中不断更新栈
(496. 下一个更大元素 I)比如求一个序列中大于A(需要满足的条件:B>A)的下一个(也就是离A最近)元素B,那么就可以从右向左遍历序列,右边的先入栈,则下一个被遍历的元素访问栈时一定是先接触到离它最近的元素,而按题目要求直到在栈内遇到比当前被遍历元素大的元素,就是我们想要的那个元素(在此之前的元素全都被pop出来,也就是:新来的元素一定要入栈,把栈中不满足单调性的元素全都pop掉),不必担心被pop掉的元素会不会导致丢解,因为后入栈的元素肯定比先前入栈的元素离当前元素更近(根据要求我们也可以反过来遍历,这样栈顶的元素就比栈底元素更远离当前元素),且要求是大于A,而我们pop出去的都是比当前元素B'小的元素,所以如果被POP出去的元素比A大的话,那B'肯定也比A大
关键点:序列的遍历顺序(从左往右还是从右往左);栈内元素需要满足的单调性条件(不一定是大于/小于,还可能是使得某个条件为true),把不满足的元素pop出来,再把当前元素push进去
要学会利用好具有单调性的序列 :
我们对序列排序后,可以使用二分法,滑动窗,双指针(尤其是头尾双指针,二分法其实就是一种头尾双指针),还有利于解决不允许元素重复的问题(15. 三数之和 1574. 删除最短的子数组使剩余数组有序)
1 /*求和为target的2个数*/ 2 // 注意这种移动方式是不会丢解的!有人有疑问为什么大于target的时候r左移而不是l左移(同理小于target的时候), 3 // 那是因为 1.如果l左移,有可能陷入死循环;2.试着实例化移动一下,你会发现l左移的情况已经被遍历过,或者已经被排除。所以不会丢解 4 class Solution { 5 public: 6 vector<int> twoSum(vector<int>& numbers, int target) { 7 int len = numbers.size(); 8 vector<int> ans; 9 int l=0, r=len-1; 10 11 while(l<r){ 12 if(numbers[l]+numbers[r]==target){ 13 ans = {l+1, r+1}; 14 break; 15 } 16 else if(numbers[l]+numbers[r] > target){ 17 --r; 18 } 19 else if(numbers[l]+numbers[r] < target){ 20 ++l; 21 } 22 else{ 23 --r;++l; 24 } 25 } 26 return ans; 27 28 } 29 };
前缀和问题(同理有后缀和)
在一个序列{a0,a1,a2,...,an-1}中,如果要对第p到第q项加上某一值m(0<=p<q<=n-1),
对第x到第y项加上某一值k(0<=x<y<=n-1),...(p,q,x,y为下标)
则可设前缀和数组{b0,b1,b2,...,bn-1}(初始化为0)第p,q项:
1 /* [p,q]区间加m,[x,y]区间加k */ 2 b[p] += m; 3 if(q != n-1) //注意这种情况 4 b[q+1] += -m; 5 b[x] += k; 6 if(y != n-1) 7 b[y+1] += -k; 8 ...
然后就可以通过累加的方式一次性处理前缀和数组,并将其加到目标序列中:
1 for(int i=1; i<n; ++i){ 2 b[i] += b[i-1]; 3 } 4 for(int i=0; i<n; ++i){ 5 a[i] += b[i]; 6 }
经典 前缀和+ 滑动窗 问题: 2488. 统计中位数为 K 的子数组
经典 前缀和+哈希表 : 1590. 使数组和能被 P 整除 面试题 17.05. 字母与数字
经典 前缀和+单调栈: 1124. 表现良好的最长时间段
(x-y) mod p ==0 等价于 x mod p == y mod p (mod p即对p取余)
为了避免对x为负数的判断,可以写成 (x mod p + p) mod p
在模运算中,经常会用到两个等式
(a+b)%m = (a%m+b%m)%m
(a*b)%m = ((a%m)*(b%m))%m
滑动窗(注意下面内部嵌套的while也可以用其他的代替):
模板:
1 class Solution { 2 public: 3 int totalFruit(vector<int>& arr, int k) { 4 int ans=0; 5 int len = arr.size(); 6 int left=0, right=0; 7 int cur=0; //很多时候也可以不定义这个cur,可以用right-left(+1)来更新ans 8 map<int, int> fru; 9 while(right<len){ 10 fru[arr[right]]++; 11 cur++; 12 if(fru.size() > k){ // 也可以把if去掉,直接while循环,然后在外层while里每次循环都更新ans(直接用right-left+1来更新(+1表示[left,right]这个闭区间是满足要求的最大区间),然后再更新++right) 13 ans = max(ans, cur-1); 14 while(fru.size() > k){ 15 if(--fru[arr[left]]==0){ 16 fru.erase(arr[left]); 17 } 18 --cur; 19 ++left; 20 } 21 } 22 ++right; 23 } 24 return max(ans, cur); // 用right-left+1来更新的话这里就可以直接返回ans 25 } 26 };
例1:给定一个二进制数组 nums
和一个整数 k
,如果可以翻转最多 k
个 0
,则返回 数组中连续 1
的最大个数 。
1 class Solution { 2 public: 3 int longestOnes(vector<int>& nums, int k) { 4 int left=0, right = 0, len = nums.size(), znum=0, cur = 0, fi = 0; 5 if(k==0){ 6 int cur = 0; 7 for(auto iter = nums.begin(); iter != nums.end(); ++iter){ 8 if(*iter != 0)cur++; 9 else { 10 fi = fi>cur?fi:cur; 11 cur = 0; 12 } 13 } 14 return fi>cur?fi:cur; 15 } 16 17 while(right < len){ 18 if(nums[right] == 0)znum++; 19 cur++; // 挪动右指针的同时滑动窗的一些参数也要跟着变化 20 // 这里这个满足条件设置的不是很好,znum固定为k和 21 nums[right+1]==0,没有考虑k可能为0,right到达尽头的情况(所以才要补充right == len -1 和 前面k为0的情况) 22 23 while(znum==k && (right == len -1 || nums[right+1]==0)) { // 设置滑动窗的满足条件很关键!(挪动右指针,直到满足条件时挪动左指针,直到再次不满足条件) 24 fi = fi>cur?fi:cur; //每挪动一次左指针都要更新一次结果 25 if(nums[left]==0)znum--; 26 cur--; // 注意在挪动左指针的同时滑动窗的一些参数也要跟着变化 (注意看这里滑动窗的参数: znum 和 cur , 在挪动右值针时也同样是这些参数在变化,且方向正好相反) 27 left++; // 在内部while 中挪动左指针 28 } 29 30 right++; // 把当前指向的元素处理完后,最后才挪动指针到下一个元素 31 } 32 33 return fi>cur?fi:cur; 34 } 35 36 };
直接套用模板:
1 class Solution { 2 public: 3 int longestOnes(vector<int>& nums, int k) { 4 int ans=0; 5 int len = nums.size(); 6 int left=0,right=0; 7 while(right<len){ 8 if(nums[right]==0){ 9 --k; 10 } 11 while(k<0){ 12 if(nums[left]==0){ 13 ++k; 14 } 15 ++left; 16 } 17 ans = max(ans, right-left+1); 18 ++right; 19 } 20 return ans; 21 } 22 };
注意,如果满足内层while循环条件时候的[left,right]区间才是符合问题答案的子序列,则ans应该放在内层while里更新。
例如(LeetCode 1234. 替换子串得到平衡字符串):
1 /* 有一个只含有 'Q', 'W', 'E', 'R' 四种字符,且长度为 n 的字符串。 2 假如在该字符串中,这四个字符都恰好出现 n/4 次,那么它就是一个「平衡字符串」。 3 给你一个这样的字符串 s,请通过「替换一个子串」的方式,使原字符串 s 变成一个「平衡字符串」。 4 你可以用和「待替换子串」长度相同的 任何 其他字符串来完成替换。 5 请返回待替换子串的最小可能长度。 6 如果原字符串自身就是一个平衡字符串,则返回 0。 */ 7 8 class Solution { 9 public: 10 int balancedString(string s) { 11 int n = s.size(); 12 int ans = n; 13 map<char, int> cnt; 14 for(auto ch : s){ 15 ++cnt[ch]; 16 } 17 if(cnt['Q']==n/4 && cnt['W']==n/4 && cnt['E']==n/4 && cnt['R']==n/4){ 18 return 0; 19 } 20 for(auto &[k,v] : cnt){ 21 if(cnt[k]>n/4) cnt[k] = v-n/4; 22 else cnt[k] = 0; 23 } 24 int left=0,right=0; 25 map<char, int> cur; 26 while(right<n){ 27 cur[s[right]]++; 28 while(cur['Q']>=cnt['Q'] && cur['W']>=cnt['W'] && cur['E']>=cnt['E'] && cur['R']>=cnt['R']){ 29 ans = min(ans, right-left+1); 30 cur[s[left]]--; 31 ++left; 32 } 33 34 ++right; 35 } 36 return ans; 37 } 38 };
注意ans 的更新方式!例如 713. 乘积小于 K 的子数组
1 class Solution { 2 public: 3 int numSubarrayProductLessThanK(vector<int>& nums, int k) { 4 int len = nums.size(); 5 int ans =0; 6 int l=0, r=0; 7 int tmp=1; 8 while(r<len){ 9 tmp *= nums[r]; 10 while(tmp >= k && l<=r){ 11 tmp /= nums[l]; 12 ++l; 13 } 14 ans += r-l+1; // 注意不是简单的ans++,r往前一步,nums[r]与前面的元素组合成r-l+1个满足条件的子数组 15 ++r; 16 } 17 return ans; 18 } 19 };
注意判断一个题目是否可以使用滑动窗,借用 1124. 表现良好的最长时间段下的评论:
1. 我之前看过一个大佬写过什么时候可以用滑动窗口,原话我记不清了,反正意思是,当你窗口右边界确定时,可以明确知道左边界是否应该移动,那么就可以使用滑动窗口。而这道题对于[9,9,6,6,6,9,9]这种例子来说的话,你到下标为3的位置就应该移动左边界了,但是明显这道题答案是整个数组长度,说明此时不该移动左边界,即还没遍历到的元素对当前窗口的左边界存在影响。
2. 你说的滑动窗口是指双指针。不能做的原因是这题不具备二段性,或者说单调性,滑动窗口能做的题二分也能做,明显这题二分是不能做的,比如[9,6,9] (所以用滑动窗要求序列是“有序”的?)
例如 209. 长度最小的子数组 ,cur在右指针不断往右移动的过程中累加,一定是单调递增的,所以可以用滑动窗,而“表现良好的最长时间段”这道题在累加右值针过程中指标是有可能出现负值的
遍历顺序容器时
下标? 需要求出容器的长度,确保不会访问越界
迭代器?只有用迭代器遍历时可以增删元素(因为增删后容器长度发生变化容易导致越界,而只有iter!=c.end()这个是不以c的长度作为结束条件的,erase入参为迭代器),但是要慎用(容易用乱)
范围for循环? 遍历整个容器,可以修改元素,但不能增删元素
无论哪种方式,都不能在遍历容器的时候对容器增删元素!否则会导致遍历发生错乱!
小技巧:
如果实在想不到什么方法,可以看一下题目给的问题规模,如果不是很大的话就用最笨的方法,在保证不会超时或者不会超出给定的内存大小的情况下,一个一个遍历,或者用一块可能性最大的内存来保存结果
慎用迭代器!如果要增加/删除序列中的元素,不要采用遍历整个序列的方式,容易把迭代器用乱!可以在循环外创建一个副本,然后再增删这个副本,循环结束后用这个副本取代原来的序列。
或者用这样的方法(不推荐):
if(XXX){
iter = s.insert(iter);
//or iter = s.erase(iter)-1;
}
不是万不得已不要用反向迭代器,因为像insert,erase这样的方法不支持反向迭代器(556)
位运算:
n = n>>k 可以用来表示n/(2^k)
n = n<<k 可以表示n*(2^k)
(或者直接使用c++自提供的函数__gcd(a,b))
gcd(a,b)可用于求最a,b的最大公约数:
int gcd(int a, int b) { if (a % b == 0) return b; return gcd(b, a%b); }
求一组数的最大公约数:
1 int num =arr[0]; 2 for(int i=0; i<len; ++i){ 3 num = gcd(arr[i], num); 4 } 5 return num;
回文特性:
正序和倒序一样
所给的序列中,数量为奇数的字符的数量有n个,就意味着这个序列的字符组成的回文串数量不可能少于n个
回文串中数量为奇数的字符至多有1个
经典回文处理:头尾双指针
回文串左右同等缩进得到的子串也是回文串(对称位置的元素相等),或者说如果下标为i,j的子串为回文串,那么如果下标为i-1,j+1的元素相同,下标为i-1,j+1的子串也是回文串
可以用栈的思想处理回文(尤其是带括号的字符串计算)
sort还能这么用:
sort(mp.rbegin(), mp.rend()); //从大到小排序
二分查找:
1 int binarySearch(int[] nums, int target) { 2 int left = 0; 3 int right = nums.length - 1; // 注意 4 while(left <= right) { // 注意 5 int mid = (right + left) / 2; 6 if(nums[mid] == target) 7 return mid; 8 else if (nums[mid] < target) 9 left = mid + 1; // 注意 10 else if (nums[mid] > target) 11 right = mid - 1; // 注意 12 } 13 return -1; 14 }
当循环退出时,除非找到了target,否则一定有left==right+1(因为循环条件是left<=right),且left或right都有可能越界(left==nums.length 或right==-1)
这里right 初始化为nums.length - 1,所以while中的条件是left <= right(即left可以等于right),每次循环搜索的区间是[left, right],所以right更新是right = mid - 1。
但是:
1 int binarySearch(int[] nums, int target) { 2 int left = 0; 3 int right = nums.length; // 注意 4 while(left < right) { // 注意 5 int mid = (right + left) / 2; 6 if(nums[mid] == target) 7 return mid; 8 else if (nums[mid] < target) 9 left = mid + 1; // 注意 10 else if (nums[mid] > target) 11 right = mid - 1; // 注意 12 } 13 return -1; 14 }
如果right 初始化为nums.length,所以while中的条件是left < right(即left不能等于right),每次循环搜索的区间是[left, right),所以right更新是right = mid。(如果是 mid - 1的话,则下一次循环搜索的区间是[left, mid-1),就会错过mid-1这个数)
综上:
- right初始化为nums.length对应while(left < right)对应right更新方式为right = mid,循环结束时一定有left==right(中途没有break的话)且有可能越界(left=right=nums.length)
- right初始化为nums.length-1对应while(left <= right)对应right更新方式为right = mid-1,循环结束时一定有right+1==left(中途没有break的话),且有可能越界(left=nums.length或right=-1)
二分查找可分为三大类:
1. 找指定的target
初始化right=nums.length,退出循环后先判断是否越界(即left是否等于nums.length),
再判断nums[left]是否等于target(判断left就行了,因为left==right),不等的话说明序列中没有target
2. 找左边界 (34. 在排序数组中查找元素的第一个和最后一个位置)
初始化right=nums.length,退出循环后一定有l==r,先判断是否越界(即left是否等于nums.length),
如果没越界的话再判断nums[left]是否等于target(当循环结束时如果不越界,nums[left]一定是大于等于target的最小者,即nums[left]一定是左边界),不等的话说明序列中没有target
1 while(left < right){ 2 int mid = left +(right-left)/2; 3 if(letters[mid] < target) 4 left = mid+1; 5 else 6 right = mid; //可以这样记:因为要找左边界,所以当letters[mid]==target时也要往左边挪,即令right = mid 7 }
其实第1类问题查找指定的target也可以这么写,退出循环后只需要先判断nums[left]是否越界,如果不越界则判断判断nums[left]是否等于target,如果不等则说明没有target,且target位于nums[left-1]和nums[left]之间
例题:
给定一个排序的整数数组 nums 和一个整数目标值 target ,请在数组中找到 target ,并返回其下标。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。(LeetCode 剑指 Offer II 068. 查找插入位置)
1 class Solution { 2 public: 3 int searchInsert(vector<int>& nums, int target) { 4 int len = nums.size(); 5 int l=0, r=len; 6 while(l<r){ 7 int m = l + (r-l)/2; 8 if(nums[m] < target){ 9 l = m+1; 10 } 11 else{ 12 r = m; 13 } 14 } 15 return l==len? len : l; 16 } 17 };
3. 找右边界
初始化right=nums.length,退出循环后一定有l==r,先判断是否越界(即left是否等于nums.length),
如果没越界的话再判断nums[left-1]是否等于target(注意先判断left是否越界)(当循环结束时如果不越界,nums[left]一定是大于target(注意没有等于)的最小者),不等的话说明序列中没有target
(其实如果left越界的话也可以判断nums[left-1]是否等于target,因为left越界顶多等于nums.length)
1 while(left < right){ 2 int mid = left +(right-left)/2; 3 if(letters[mid] > target) 4 right = mid; 5 else 6 left = mid + 1; //可以这样记:因为要找右边界,所以当letters[mid]==target时也要往右边挪,即令left = mid + 1 7 }
//注意一定不能left和right的更新方式都等于mid,否则可能陷入死循环
寻找左/右边界时也可以先判断左端点/右端点是否大于/小于target,如果是的话就不用进入循环了
二分查找经典应用:当我们以单步+1的方式逐渐去寻找我们想要的数行不通时(太慢),可以考虑能否通过二分查找来快速查找(要先确定好取值范围)(1011)
如果二分查找只是解题的其中一步,想节省时间,那么可以调用:
(用于顺序容器(必须先排好序))
vector, 数组索引的意义:arr[pos],表示pos这个索引的数前面有pos个数,例如当我们获得string中某个字符的索引pos后,我们可以用string::substr(0, pos)来截取这个字符前的子字符串。
大小写转换:
方法一:
小写字母比大写字母大32,所以:
小写字母 = 大写字母+32
方法二:
大小写转换函数:
toupper (入参可以是int也可以是char)
tolower
下面的函数入参必须是char:
判断是否是字母、小写、大写、数字:
isalpha()
islower()
isupper()
isdigit()
判断是否是字母或数字(a~z||A~Z||0~9):
isalnum()
记得multimap中是不能用key来做索引的,其他该有的性质和map一样
multiset暂时没发现有什么禁忌
求最值的min_element, max_element
迭代器遍历,反向迭代器
都能用
unordered_map不能用pair来做key,除非重定义 operator==()
iota(s.begin(), s.end(), T)
这个函数前两个参数是一对迭代器,第三个参数是这个迭代器范围的初始值,随后的值会调用T类型的operator++()来自增后依次填入,有点像Python的列表生成式(但是只能自增1),例如1:
std::vector<double> data(9); double initial {-4}; std::iota (std::begin (data) , std::end (data) , initial); std::copy(std::begin(data), std::end(data),std::ostream_iterator<double>{std::cout<< std::fixed << std::setprecision(1), " "}); std::cout << std::endl; // -4.0 -3.0 -2.0 -1.0 0.0 1.0 2.0 3.0 4.0
例如2:
string text {"This is text"}; std::iota(std::begin(text), std::end(text), 'K'); std::cout << text << std::endl; // Outputs: KLMNOPQRSTUV
例如3:
std::vector<string> words (8); std::iota(std::begin(words), std::end(words), "mysterious"); std::copy(std::begin(words), std::end(words),std::ostream_iterator<string>{std::cout, " "}); std::cout << std::endl; // mysterious ysterious sterious terious erious rious ious ous //这里因为入参是一个字符串常亮,相当于char *p,自增p相当于指向下一个字符,但如果是一个string的话就不像,因为string没有定义operator++()
处理异位字符串:老老实实用map统计字符串中各个字符的数量,然后比较map是否相等(map也支持==运算符)!不要想着用set/multiset/unordered_set或者把序列排序后再比较!
unordered_set/unordered_map里元素顺序是不固定的,增/删元素都有可能导致排序发生变化!(iter=st.begin(); iter != st.end(); ++iter)这种方式只能遍历元素!
例如:
1 int main(){ 2 string s = "zxcvvbnm"; 3 unordered_set<char> st(s.begin(), s.end()); 4 for(auto &item : st){ 5 cout << item << " "; // 你会发现st中字符的顺序和s不一样!所以你如果想通过st.erase(st.begin())来删掉z的话会出错! 6 } 7 cout << endl; 8 }
蓄水池抽样算法:解决这样一个问题:
当一个数据流很大(比如一串长度不知道多少的序列,假设长度为n),且不知道各个不同的元素在序列中占比多少,要想随即从序列中抽取m个不同的元素,且被抽中的概率等于每个元素在序列中的占比.
https://www.jianshu.com/p/7a9ea6ece2af
判断质数的方法(0,1都不是质数):
1 bool isPrime(int x) { 2 if (x < 2) { 3 return false; 4 } 5 for (int i = 2; i * i <= x; ++i) { 6 if (x % i == 0) { 7 return false; 8 } 9 } 10 return true; 11 }
统计一个数a在二进制下的”1”的个数:
__builtin_popcount(a)
当问题解的可能情况较多,且问题域较小时,有可能是让你暴力解出所有的可能性
回溯(n叉树的遍历):
1. 列出所有组合情况(或者说所有子集/子序列)(面试题 08.04. 幂集(经典滴酱油法/滴流法), (剑指 Offer 38. 字符串的排列) ,回溯与dfs的区别:回溯算法(转))
1 class Solution { 2 public: 3 vector<vector<int>> subsets(vector<int>& nums) { 4 int len = nums.size(); 5 vector<vector<int>> res(1,vector<int>({})); 6 for(auto &item : nums){ 7 vector<vector<int>> tmp = res; 8 for(auto &vt : tmp){ // 遍历当前集合中的所有序列,在每个序列尾部加上新的元素,得到新的序列再加入到现有集合中 9 vt.push_back(item); 10 } 11 res.insert(res.end(), tmp.begin(), tmp.end()); 12 } 13 return res; 14 } 15 };
2. 列出所有排列情况(剑指 Offer 38. 字符串的排列)
3. 列出所有排列组合情况(即全排列:子序列的所有排列组合情况也列出来,本质还是第2类题)
4. 列出所有组合情况中满足某个条件的情况,每个元素可以无限重复(剑指 Offer II 081. 允许重复选择元素的组合)
5. 列出所有子串(也可以使用双指针嵌套for循环暴力解决) (注意下面与滴流法的区别,其实这个方法也不快,暴力是n^2,这个是nlogn)
1 class Solution { 2 public: 3 vector<vector<int>> subsets(vector<int>& nums) { 4 int len = nums.size(); 5 vector<vector<int>> res; 6 vector<vector<int>> tmp; 7 for(auto &item : nums){ 8 for(auto &vt : tmp){ // 遍历当前集合中的所有序列,在每个序列尾部加上新的元素,得到新的序列再加入到现有集合中 9 vt.push_back(item); 10 } 11 tmp.push_back({item}); // 这里不push_back(),像滴流法那样初始化res有一个空数组也可以 12 res.insert(res.end(), tmp.begin(), tmp.end()); 13 } 14 return res; 15 } 16 };
eg:(898. 子数组按位或操作)
1 class Solution { 2 public: 3 int subarrayBitwiseORs(vector<int>& arr) { 4 unordered_set<int> st; 5 int len = arr.size(); 6 unordered_set<int> tmp; 7 for(int i=0; i<len; ++i){ 8 unordered_set<int> ctmp; 9 for(auto &item : tmp){ 10 ctmp.insert(item | arr[i]); // 注意不能直接item=item|arr[i],因为遍历关联容器元素具有底层const,所以这里要用新的容器ctmp 11 } 12 ctmp.insert(arr[i]); 13 tmp = ctmp; 14 st.insert(tmp.begin(), tmp.end()); 15 } 16 return st.size(); 17 } 18 };
取3个数的最大者:
max(a, max(b,c))
一. 动态规划的关键一步是找出问题域为i时的状态dp[i]所表示的含义
常见线性模型DP方程形式:
1. dp[i]表示以nums[i]为结尾的最优子串的最优值(一般用于求最长/最大子序列)
2. 每次只能从序列的最左或最右端选择,则dp[i][j]表示左边取i个,右边取j个时的最优值(一般需要设dp大小为(len+1)*(len+1))
3. 找出序列中符合条件的最长子串,一般可以设dp[i][j]表示序列中下标i到下标j的子串的目标值(也可能是布尔值)
结合以上形式推导dp方程时,别忘了dp方程有可能是:
dp[i]=F(dp[i+n]或dp[i-n])(eg:leetcode比特位计数,旋转函数)
还有可能是类似:
dp[i]=F(dp[i-1],dp[i-2],...,dp[0]) 或 best{F(dp[j])}(其中0<=j<i),也有可能是先把dp[i+n]求出来再求dp[i] (LeetCode完全平方数,最大整除子集,300. 最长递增子序列, 313. 超级丑数)
有时候需要注意推导dp[i]的遍历顺序,例如LeetCode 最长回文子串,设dp[i][j]为从下表i到下标j的子串是否为回文串,是回文串的条件为s[i]==s[j]且dp[i+1][j-1]为true,那么我们知道dp[i][j]是依赖于dp[i+1][j-1]的,所以遍历的时候需要先把i,j差值小的dp求出来
结合以上,有时候还有可能需要维护多个dp(LeetCode 乘积最大子数组)
判断是否可以用动态规划:最优子结构性质是动态规划问题的必要条件,且一定是让你求最值的最优子结构问题
有些动态规划问题有多个状态维度,有时需要降维:将其中一个状态变得有序后,再处理另一个状态,无论另一个维度怎么选,前一个维度都是满足题意“有序”的。
eg: 354. 俄罗斯套娃信封问题 1626. 无矛盾的最佳球队 (另一个需降维的非动态规划问题: 406. 根据身高重建队列)
1. 列出正确的「状态转移方程」
判断算法问题是否具备「最优子结构」:对于需要穷举出所有情况的问题,子问题之间存在重叠计算的部分,那么就要用备忘录将子问题(子结构)答案记录下来,子问题之间相互独立(例如凑零钱问题中,每选择一个面值,剩下额度还需要凑多少个硬币这个子问题就是一个子结构,是独立的,即前面的选择不干扰后面的子问题)
2. 接下来的解决方法要么是:
递归一般是自顶向下问题(或穷举问题)(记忆化搜索)
或者是:
使用dp table一般是自底向上(使用一维或二维vector记录)
3. 状态转移方程:dp[i][j]
- 确定「状态」:状态就是dp[i][j]中变量i和j,或者递归函数中的入参,他们表示什么
- 确定「选择」:导致「状态」产生变化的行为
- 确定dp 函数/数组的含义(这一步最关键),有时还要注意遍历方向 eg: 174. 地下城游戏 (反向) (正向)
- 列出状态方程
如果状态方程不好列(即不好从当前状态推出下一个状态),说明dp函数的定义可能不正确,dp定义正确的话一般边界条件和状态转移方程都可以比较容易的推出
有时候需要维护两个DP方程,这两个DP相互维护 152. 乘积最大子数组
(求最长递增子序列问题还有一种二分查找法,详见:二分查找解法 例题: 354. 俄罗斯套娃信封问题 )
解决两个字符串的动态规划问题,一般都是用两个指针 i, j
分别指向两个字符串的最后,然后一步步往前移动,递归地缩小问题的规模,直到遇到边界条件后再从内往外递归地求出dp[len1 - 1][len2 - 1]。
dp递归:dp(i, j)的返回值:有可能是s1[...i], s2[...j]的答案(72. 编辑距离 1143. 最长公共子序列 1092. 最短公共超序列),也有可能是s1[i...], s2[j...](115. 不同的子序列)
1 /* 求最长公共子序列模板 */ 2 class Solution { 3 public: 4 int longestCommonSubsequence(string text1, string text2) { 5 int len1=text1.size(); 6 int len2=text2.size(); 7 // dp[i][j]表示text1取前i个,test取前j个字符的最长公共子序列的长度 8 vector<vector<int>> dp(len1+1, vector<int>(len2+1)); 9 for(int i=1; i<=len1; ++i){ 10 for(int j=1; j<=len2; ++j){ 11 // 当这两个字串的最后一个字符相等时,说明dp[i][j]比dp[i-1][j-1]多了一个公共字符 12 if(text1[i-1]==text2[j-1]){ 13 dp[i][j] = dp[i-1][j-1] + 1; 14 } 15 //当最后一个字符不等时,则dp[i][j]要么等于dp[i-1][j], 要么等于dp[i][j-1],并且是其中较大者 16 else{ 17 dp[i][j] = max(dp[i-1][j], dp[i][j-1]); 18 } 19 } 20 } 21 return dp[len1][len2]; 22 } 23 };
表示后半段序列(s1[i-1, ...]和s2[j-1, ...])已经处理好所需要的操作数(X),当前正在处理第i和j个元素
表示子串[...,i]和[...,j]所需要的操作数,在dp函数体内,依然要用正向思维来思考,如何从较小的i,j推出当前(较大的)i,j的答案(即dp(i, j)的返回值,此时函数体内一般有一个备忘录数组memo[i][j]来记住已经算出来的答案
- 函数体内依然是 memo[i][j] = func(dp(较小的i, j,注意不一定是i-1,j-1,也有可能是i-n,j-n,例如“不同的子序列”这道题)) 的形式,即我们在列方程/函数体的时候是正向的:当前状态是如何从上一个状态转移得到的,不同的转移方式可能对应不同的func,
- 然而在理解的时候是这样去理解:如何将问题域缩小!即如何从当前的i,j缩小到更小的i,j. 因为dp(较小的i,j)在到达边界条件前是一个未知数,所以应该假设后半段序列(s1[i-1, ...]和s2[j-1, ...])已经“处理过”了,当前正在处理i,j
- 注意边界条件不一定是i和j等于某个具体值时才返回,也可能是小于某个值时就返回!
使用某个符号将元素分割开,可以这样做:
for (int i = 0; i < arr.size() - 1; i++) { res.append(to_string(arr[i]) + ","); } res.append(to_string(arr.back()));
这样就不用怕末尾再带个逗号了
二叉树:
二叉树:
2种不同顺序遍历二叉树:
1. 把二叉树上的点摊平到一条水平轴上,则对二叉树而言,在递归函数中遍历顺序:
如果是左中右(中序遍历),则处理顺序就相当于依次对水平轴上的点从左到右进行处理。同理如果是先对右节点进行递归调用(右中左),则是从右往左的处理顺序。(其实就是DFS)
如果中序遍历二叉树节点值是严格升序,则这棵树是平衡二叉树(也就是每个节点可以对应到数轴上,每个子树都是左子树的值<根节点值<右子树的值)
如果是中左右(前序遍历),
如果是左右中(后序遍历),
此外,如果是用BFS来遍历,则相当于从上往下逐层遍历直到最底层的叶子结点
例如:
1 void traverse(treeNode * node){
2
3 /****A****/
4
5 traverse(node->left);
6
7 /****B****/
8
9 traverse(node->right);
10
11 /****C****/
12
13 }
选择在A,B,C三个不同的区域处理当前节点,得到的整棵树的节点处理顺序是不同的!!
一般我们把处理当前节点流程放在B,这样就表示整棵树的节点平铺到水平轴上时,以从左到右的顺序处理节点!(先right后left的话就表示从右到左)
2. 利用BFS算法,将队列头部节点的左节点先放入队列,右节点后放入,则逐个处理队列头(处理完后将其弹出)即逐层以从左到右的顺序处理,反之从右到左(2312完全二叉树)
3. 二叉搜索树查找某个元素target时,如果树中没有这个target,则越往下查找(越靠近叶子节点),则值越接近target(当然,如果树不是满的(某些非叶子节点为null),则有可能出现最接近target的元素是非叶子节点),理论上每往下一个节点挪动,就排除掉一半的候选节点
4. 插入新的数一定是放在最底层(叶子节点的子节点)
删除指定节点:教科书式的做法(《算法》P261):
1 class Solution { 2 public: 3 TreeNode* deleteNode(TreeNode* root, int key) { 4 if(root==nullptr)return root; 5 if(root->val > key) 6 root->left = deleteNode(root->left, key); 7 else if(root->val < key) 8 root->right = deleteNode(root->right, key); 9 else { //关键就在这里,代码思路:将val==key的这个target节点的右子树中的最小值节点(也就是最接近key的节点)替换到target的位置 10 //auto tmp = root; 11 if(root->left==nullptr)return root->right; 12 if(root->right==nullptr)return root->left; 13 auto min_node = getmin(root->right); 14 // 注意!下面一定要先处理min_node->right再处理min_node->left, 否则报错:Error - Found cycle in the TreeNode 15 min_node->right = delmin(root->right); 16 min_node->left = root->left; 17 return min_node; 18 } 19 return root; 20 } 21 TreeNode* getmin(TreeNode* node){ 22 if(node->left==nullptr)return node; 23 return getmin(node->left); 24 } 25 TreeNode* delmin(TreeNode* node){ 26 if(node->left==nullptr)return node->right; 27 node->left = delmin(node->left); // 如果递归函数有返回值时,那么注意在调用递归函数的代码里要有变量接收递归函数的返回值 28 return node; 29 } 30 };
在二叉树里,当要将某个值设为某个节点的子节点时,一般通过递归函数返回值的形式将这个新增的节点返回去给父节点,而不是用prev指针,或者在递归函数参数里把父节点传进来之类的费劲巴拉的操作(654. 最大二叉树,450. 删除二叉搜索树中的节点, 669. 修剪二叉搜索树),一般是这种形式:
1 traverse(treenode* node){ 2 if(node==nullptr)return node; 3 if(XXX){ 4 //当前节点不满足要求,则跳过这个节点,将他的子树的叶子节点(因为叶子节点的值最接近当前节点)作为它的父节点的子节点 5 //右区间满足要求时,取当前节点的右子树的最小节点 6 auto newnode = getmin(node->right); 7 8 //左区间满足要求时,取当前节点的左子树的最大节点 9 //or: auto newnode = getmax(node->left); 10 ... 11 12 return newnode; 13 } 14 node->left = traverse(node->left); 15 node->right = traverse(node->right); 16 17 return node; // 当前节点依然满足要求时,返回这个节点(即这个节点的父节点还是不变) 18 }
保留某个区间教科书式做法:
1 class Solution {//把小于low,大于high的节点都删掉 2 public: 3 TreeNode* trimBST(TreeNode* root, int low, int high) { 4 root = traverser(root, high); 5 root = traversel(root, low); 6 return root; 7 } 8 TreeNode * traverser(TreeNode *node, int r){ 9 if(node==nullptr)return node; 10 if(node->val < r){ 11 node->right = traverser(node->right, r); 12 } 13 else if(node->val == r){ 14 node->right=nullptr; 15 } 16 else { 17 /* 18 auto max_node = getmax(node->left); // 为什么不像删除指定节点那样做?因为删除指定节点后,指定节点的左右子树还需要有别的节点来承接,以顶替指定节点(因为它的父节点不可能同时接收它的两个子树),而这里因为是一个范围,如果节点值已经大于r了,那么它的右子树肯定也都大于r,整个右子树都不要了,所以直接返回左子树就行(在删除指定节点中的红色字体部分其实也是同样的处理,如果其中一个子树是null的话直接返回另一个就行) 19 if(min_node==nullptr)return min_node; 20 max_node->left = traverse(node->left); 21 return max_node; 22 */ 23 return traverser(node->left, r); 24 } 25 26 return node; 27 } 28 29 TreeNode * traversel(TreeNode *node, int l){ 30 if(node==nullptr)return node; 31 if(node->val > l){ 32 node->left = traversel(node->left, l); 33 } 34 else if(node->val == l){ 35 node->left=nullptr; 36 } 37 else { 38 return traversel(node->right, l); 39 } 40 41 return node; 42 } 43 }; 44 45
注意下面的式子要加括号:
if(A && (num&1)==0)) //判断奇偶性, (num&1)不加括号的话num&1永远为true(表示这个式子计算没发生错误) int a=1,b=2; // 这样做: int c = 5 + b==1?a:b; // c等于2!! //应该这样: int c = 5 + (b==1?a:b);
向上取整的方法:
hours += pile % speed == 0 ? pile / speed : (pile / speed)+1;
变为:
hours += (pile-1) / speed + 1;
或者:
(pile + speed - 1) / speed;
如果要确定大量元素中每一个是否在一个序列里,最好先把把这个序列存到关系容器里(map或set),再在关系容器中查找,这样访问关系容器比直接访问序列容器会快很多。(lc817. 链表组件)
left <= right
深度优先搜索一般是解决是否存在一条路径的问题,dfs是一条路走到黑(直到不能再往前为止),然后他会回退一步再往别的方向上探索
对网格点周围的格子进行dfs时,可以用一个数组move来表示移动方向,而不用把每个方向都写死到代码里(LeetCode 529. 扫雷游戏)
1 class Solution { 2 int move[3] = {0,1,-1}; 3 vector<vector<char>> board; 4 int rows; 5 int cols; 6 public: 7 vector<vector<char>> updateBoard(vector<vector<char>>& _board, vector<int>& click) { 8 swap(board, _board); 9 rows = board.size(); 10 cols = board[0].size(); 11 12 if(board[click[0]][click[1]]=='M'){ 13 board[click[0]][click[1]]='X'; 14 return board; 15 } 16 traverse(click[0],click[1]); 17 return board; 18 } 19 void traverse(int row, int col){ 20 int adjm = 0; 21 if(row>=0 &&row<rows&&col>=0&&col<cols&& board[row][col]=='E'){ 22 for(int i=0; i<3; ++i){ // 遍历周围的8个格子 23 for(int j=0; j<3; ++j){ 24 if(i==0&&j==0)continue; 25 if(row+move[i]>=0 &&row+move[i]<rows&&col+move[j]>=0&&col+move[j]<cols){ 26 if(board[row+move[i]][col+move[j]]=='M'){ 27 ++adjm; 28 } 29 } 30 } 31 } 32 if(adjm==0){ 33 board[row][col] = 'B'; 34 for(int i=0; i<3; ++i){ 35 for(int j=0; j<3; ++j){ 36 if(i==0&&j==0)continue; 37 traverse(row+move[i], col+move[j]); 38 } 39 } 40 }else{ 41 board[row][col] = to_string(adjm)[0]; 42 } 43 44 } 45 } 46 };
记忆化搜索:对dfs进行剪枝从而优化dfs时间复杂度的一种方法。用一个容器将各个子问题域的结果储存起来,这个结果可以用来求解更大的问题域(其实有点像动态规划),从而减少重复的计算。典型的空间换时间方法
典型应用场景是可能经过不同路径转移到相同状态的dfs问题。 (剑指 Offer II 103. 最少的硬币数目 464. 我能赢吗 638. 大礼包)(https://blog.csdn.net/weixin_38889219/article/details/116275418)
模板:
1 map<string, int> memo //也可以是别的容器 2 3 solve(given_problem, vector selectable){ 4 return traverse(given_problem, selectable); 5 } 6 7 traverse( cur_problem, vector &selectable){ 8 if(!memo.count(cur_problem)){ // 如果当前子问题没有被解决过 9 int res; 10 for(int i=0; i<len; ++i){ 11 if(!can_select(selectable[i]))continue; //有时需要先把不能选择的项过滤掉 12 res = best_chose(res, res_if_select(selectable[i])+traverse(cur_problem-selectable[i], selectable)); // 遍历所有可能的方案,取最优 13 } 14 memo[cur_problem] = res; // 将计算得到的当前子问题的结果保存到记忆数组里 15 } 16 return memo[cur_problem]; 17 }
广度优先搜索一般是解决求最短路径代价的问题,因为bfs每走一步都会把从当前位置走下一步的所有可走路径加到队列中,然后step++,当其中一个点到达目的地后就可以求得最小的step
bfs 2种写法:
1 queue<int> que; 2 map<int, bool> marked; 3 int step=0; 4 while(!que.empty()){ 5 queue<int> tmp; 6 while(!que.empty()){ 7 if(XX)return step; 8 auto next = nextpoint(q.front()); 9 if(!marked[next]){ 10 tmp.push(next); 11 marked[next] = true; 12 .... 13 } 14 .... 15 que.pop(); 16 } 17 swap(tmp, que); 18 ++step; 19 } 20 return -1;
其实没必要嵌套while循环,只需要维护一个dist表来保存每个网格点的step值,最后返回dist[n-1][n-1]就行, 也不用维护marked,直接判断dist[i]是否更新过就行:
用BFS队列处理二叉树时也是这样,while循环里只处理队列头节点,一次循环只pop()一次,while循环体内更新的是这个头结点的子节点的step(例如对二叉树而言这个step可以是节点深度,那么子节点的step=父节点step+1),而不是头结点的step!(所以进入循环前记得初始化第一个push进队列的节点的step)
1 queue<int> que; 2 vector<vector<int>> dist(n, vector<int>(n, -1)); 3 4 while(!que.empty()){ 5 tmp = q.front(); 6 if(dist[nextpoint(tmp).x][nextpoint(tmp).y]==-1 && XXX) //nextpoit即tmp的子节点,dist等于-1说明这个点没走过 7 que.push(nextpoint(tmp)); 8 dist[nextpoint(tmp).x][nextpoint(tmp).y] = func(dist[tmp.x][tmp.y]); //在处理tmp这个点时就把tmp下一层的点nextpoint(tmp)的dist处理了,而不是等处理nextpoint(tmp)时才处理他们的dist,同理tmp的dist在他的上层就处理了 9 .... 10 que.pop(); 11 } 12 13 return dist[n-1][n-1];
经典题:LeetCode 1129. 颜色交替的最短路径 1210. 穿过迷宫的最少移动次数 1091. 二进制矩阵中的最短路径
置换环问题(LeetCode2471. 逐层排序二叉树所需的最少操作数目 1. 两数之和):
假设有一个元素随机排列的数组a,在一次操作中选出任意两个元素交换他们的位置,多次操作后数组变为单调递增或递减数组,求最少操作次数n。
方法:找到置换环,环与环之间是数字是不需要发生交换的,只会在环内发生交换。例如在数组 [2,0,1,4,3][2,0,1,4,3] 中,[2,0,1][2,0,1] 和 [4,3][4,3] 分别是两个置换环,
怎么找到环呢?从第一个数开始,把这个数字当成下标去访问数组,不断循环直到回到这个数本身。
我们只需要计算每个环内需要多少次交换。对于每个环,交换次数为环的大小减一。
字典树:
trie[N][i]: 点trie[p][i]表示父节点编号为p的节点之下字母为'a'+i这个节点的节点编号。所以p表示节点编号,trie[p][i]也表示节点编号,i表示节点编号为trie[p][i]的节点是哪个字母
1 const int N = 1000050; // 最大存储节点数 2 int trie[N][26]; 3 int cnt[N]; 4 int id; 5 6 void insert(string s) 7 { 8 int p = 0; 9 for (int i = 0; i < s.size(); i++) 10 { 11 int x = s[i] - 'a'; 12 if (trie[p][x] == 0) trie[p][x] = ++id; 13 p = trie[p][x]; 14 } 15 cnt[p]++; 16 } 17 18 int find(string s) 19 { 20 int p = 0; 21 for (int i = 0; i < s.size(); i++) 22 { 23 int x = s[i] - 'a'; 24 if (trie[p][x] == 0)return 0; 25 p = trie[p][x]; 26 } 27 return cnt[p]; 28 }
单调栈:
中位数:在一组大小为n数据arr中,中位数指的是这组数据中小于和大于这个数的元素各有一半,这个数就是中位数,注意中位数不一定是唯一的!满足条件的都可以是中位数,
例如arr = {1,2,3,4,5}中位数是3,arr = {1,2,5,6}中位数可以是3或4
对于一组整数数据,若要选取一个数使得每一个数到这个数的距离之和最小,那么这个数一定是中位数!
证明:对于两个数[a, b],目标位置肯定在ab之间(包括a,b),而且只要在ab之间,移动距离固定b-a,可以随便选。一组数,对于头尾来讲,只要目标位置在头尾之间,头和尾的移动距离就是最小且固定的,把头尾去掉,同样考虑剩下的元素,不断重复,最终目标位置就是在最中间两个元素之间,所有移动距离和最小。
对于类似例题:LeetCode 1551. 使数组中所有元素相等的最小操作数 462. 最小操作次数使数组元素相等 II (另一个可以用俄罗斯方块游戏(每次上升1行并打掉1格)来模拟:453. 最小操作次数使数组元素相等)
取中位数:
对于大小为奇数的有序数组,可取中位数为arr[n/2]
对于大小为偶数的有序数组,可取中位数为 arr[n/2] 或 arr[n/2-1]
(所以无论长度奇偶性如何一律取arr[n/2]即可)
注意priority_queue不能传入lambda表达式作为比较方法(但是sort方法可以),除了用less<int>, greater<int>这样的方法,还可以自定义数据结构,在结构体里定义好operator<就行(1792. 最大平均通过率):
1 class Solution { 2 public: 3 struct Ratio { 4 int pass, total; 5 bool operator < (const Ratio& oth) const { 6 return (long long) (oth.total + 1) * oth.total * (total - pass) < (long long) (total + 1) * total * (oth.total - oth.pass); 7 } 8 }; 9 10 double maxAverageRatio(vector<vector<int>>& classes, int extraStudents) { 11 priority_queue<Ratio> q; 12 for (auto &c : classes) { 13 q.push({c[0], c[1]}); 14 } 15 16 for (int i = 0; i < extraStudents; i++) { 17 auto [pass, total] = q.top(); 18 q.pop(); 19 q.push({pass + 1, total + 1}); 20 } 21 22 double res = 0; 23 for (int i = 0; i < classes.size(); i++) { 24 auto [pass, total] = q.top(); 25 q.pop(); 26 res += 1.0 * pass / total; 27 } 28 return res / classes.size(); 29 } 30 };
区间DP: 1000. 合并石头的最低成本
模拟二进制数相加:arr1 + arr2 典型:1017. 负二进制转换 1073. 负二进制数相加
注意先把这两个序列凑成等长之后在计算(在短的序列前面补0),这样后面就不用判断谁长谁短了
设置一个进位标记 carry, x = arr1[i] + arr2[i] + carry;
如果 x结果为 0或1,则ans[i] = x,并且将 carry 置 0;
如果 x结果为 2或3,则ans[i] = x-2,并且将carry 置 1。
挺有意思的一题: 1240. 铺瓷砖
看官方题解(直接看提交记录)有很多亮点