一、框架思维
1.数据结构存储方式
数据结构底层存储方式有两种:数组和链表(即顺序存储和链式存储)。
2.树的遍历框架
//二叉树遍历框架
class TreeNode{ int val; TreeNode left,right; } public void traverse(TreeNode root){ //前序遍历 traverse(root.left); //中序遍历 traverse(root.right); //后序遍历 }
//n叉树比遍历框架 class TreeNode{ int val; TreeNode[] children; } void traverse(TreeNode root){ for(TreeNode child:root.children){ traverse(child); } }
注:涉及递归的问题,基本都是树的问题。
二、动态规划解题框架
(一)概述
1.动态规划通常是求最值的问题。核心问题是穷举。
2.重叠子问题:使用备忘录或DPtable优化穷举。
3.最优子结构:通过子问题的最值得到原始问题的最值。
4.状态转移方程(分段函数):状态、选择、dp的定义。(思考最简单情况、问题的状态有什么、对每个状态可以进行什么操作得到什么新的状态、如何定义dp数组或函数来表现“状态”和“选择”。)
框架如下:
//初始化base case dp[0][0][···]=base case //进行状态转移 for 状态1 in 状态1的所有取值: for 状态2 in 状态2的所有取值: for ··· dp[状态1][状态2][···]=求最值(选择1,选择2,···)
(二) 具体方法:
1.暴力递归
递归算法的时间复杂度:子问题个数乘以解决单个子问题需要的时间。
2.带备忘录的递归解法
一般用一个数组充当备忘录,也可以使用哈希表(字典),将子问题的答案记录在备忘录内,需要时直接取出来,就不用再耗时计算了。
3.dp数组的迭代解法
用一个独立的数组表示备忘录。如果当前状态之和前几个状态有关,可以用多个变量表示dpTable——状态压缩。
三、回溯算法解题套路框架(DFS深度优先搜索)
回溯方法即穷举,解决回溯问题就是决策树遍历的问题。具体包括:
1.路径:已经做出的选择。
2.选择列表:当前可以做的选择。
3.结束条件:到达决策树底层无法再做选择的条件。
回溯算法是动态规划的暴力求解阶段。
回溯算法是一个多叉树遍历的问题,关键是前序遍历和后序遍历位置的操作。写Backtrack函数时,需要维护走过的“路径”和当前可以做的“选择列表”,当触发“结束条件”时,将“路径”计入结果集。
回溯算法的况下如下:
result=[] def backtrack(路径,选择列表); if 满足结束条件 result.add(路径); return; for 选择 in 选择列表: 做选择 backtrack(路径,选择列表) 撤销选择
for循环中的递归在条用之前做选择,在调用递归之后撤销选择。
维护节点的选择列表和路径的方法:在递归之前做出选择,在递归之后撤销刚才的选择。
四、BFS广度优先搜索算法框架
核心思想:把问题想象成图,从一个点开始向四周扩散。每次将一个节点周围的所有节点加入队列。
BFS的特点:BFS找到的路径是最短的,但空间复杂度比DFS大得多。
应用场景:在一个图中,找到从起点到终点的最短距离。
算法框架如下所示:
//计算从起点到终点的最短距离 int BFS(Node start,Node target){ Queue<Node> q; //核心数据结构 Set<Node> visited; //避免走回头路 q.offer(start); //将起点加入队列 visited.add(start); int step=0; //记录扩散的步数 while(q not empty){ int sz=q.size(); // 将队列中的所有节点向四周扩散 for(int i=0;i<sz;i++){ Node cur=q.pool(); // 这里判断是否到达终点 if(cur is target) return step; // 将cur的相邻接点加入到队列,cur.adj()指cur相邻的节点 for(Node X:cur.adj()){ if(x not in visited){ q.offer(x); visited.add(x); //visited是防止走回头路,一般的二叉树没有子节点到父节点的指针,不需要visited } } } // 在这里更新步数 step++; } }
BFS和DFS的关系
1.寻找最短路径时,广度优先相当于面,深度优先相当于线,深度优先也可以找到最短路径。
2.DFS空间复杂度小,时间复杂度大。BFS空间复杂度大,时间复杂度小。
五、双指针框架
(一)快慢指针
1.定义:初始化两个指针指向链表头部节点head,fast指针在前,slow指针在后。
2.应用场景:
①判断链表中是否有环
用双指针,如果链表无环,快的会遇到null,如果链表有环,快的最终会超过慢的一圈和慢的相遇。
boolean hasCycle(ListNode head){ ListNode fast,slow; //初始化快、慢指针指向头节点 fast=slow=head; while(fast!=null&&fast.next!=null){ //快指针每次前进两步 fast=fast.next.next; //慢指针每次前进一步 slow=slow.next; //如果有环,快慢指针必然相遇 if(fast==slow) return true; } }
②已知链表有环,返回环的起始位置。
方法:把快慢指针中的其中任意一个重新指向head,然后两个指针同速前进,再次相遇的位置就是环的起点。
原因:假设首次相遇是,slow走的长度是k,那么fast就走了2k(fast比slow多走了一圈,所以环的周长也是k)。假设此时的位置距离环的起点距离为m,那么环的起始位置可以用k-m表示。此时首次相遇的位置距离环的起点距离同样是k-m。因此重置一个指针,再次相遇即可找到环的起点。
具体方法如下:
ListNode detectCycle(ListNode head) { ListNode fast, slow; //初始化快、慢指针指向头节点 fast = slow = head; while (fast != null && fast.next != null) { //快指针每次前进两步 fast = fast.next.next; //慢指针每次前进一步 slow = slow.next; //如果有环,快慢指针必然相遇 if (fast == slow) break; } //以上代码类似hasCycle函数,接下来先把指针重新指向head slow = head; while (slow != fast) { // 两个指针同速前进 fast = fast.next; slow = slow.next; } return slow; }
③寻找无环单链表的中点。
暴力方法:先遍历一遍链表,算出长度N。再走n/2步,这样就到了链表的中点。
更优雅的方法:快慢两个指针,快指针到终点时候慢指针的位置就是中点的位置。
while(fast!=null&&fast.next!=null){ fast=fast.next.next; slow=slow.next; } return slow; //slow就在中间位置了
链表中点的一个重要作用:用链表进行归并排序。递归的把数组分成两部分,然后对两部分分别排序,最后合并两个有序数组。
④寻找单链表中倒数第K个元素
让快指针先走k步,然后快慢指针同速前进。当快指针到达末尾null时,慢指针的位置就是倒数第K个节点。
框架如下:
ListNode slow, fast; slow=fast=head; while(k-->0) fast=fast.next; while(fast!=null){ slow=slow.next; fast=fast.next; } return slow;
(二)左右指针
1.定义:左右指针用在数组问题中,实际是两个索引值,通常定义
left=0;
right=len(nums)-1;
2.应用场景:
①二分搜索
②两数之和
③反转数组
④滑动窗口算法
以上内容下文详细介绍。滑动窗口算法是快慢指针在数组上的应用,解决字符串匹配问题,下文单独介绍。
六、二分搜索算法
二分法注意问题:
确定好搜索区间,定位闭区。
while条件带等号。
if条件相等了就返回。
mid要加减1,因为是闭区间。
while结束就返回-1。
最后用if条件确保索引不出边界。
尽量用else if把条件写清楚了,不用else
1.寻找一个数
计算mid时防止溢出。使用left+(right-left)/2与(left+right)/2的计算结果是一样的,但前者更不容易出现整数溢出的问题。
寻找一个整数的方法如下:
int binarySearch(int[] nums,int target){ int left=0,right=nums.lentth-1; while(left<=right){ int mid=left+(right-left)/2; if(nums[mid]==target){ return mid; }else if(nums[mid]<target){ left=mid+1; }else if(nums[mid]>target){ right=mid-1; } } return -1; }
2.寻找边界:数组中连续出现的多个相同的数值,找左边界或右边界。
方法与查找某个数基本一致,不同的是,当min和目标相等时不出结果,找哪个边界就往哪个边收。再加一步检查边界。
①左边界
//当nums[mid]=target时,右边界往左收 right=mid-1; //检查出界情况(原return -1的位置) if(left>=nums.length||nums[left]!=target){ return -1; }
return left;
②右边界
//当nums[mid]=target时 left=mid+1 //检查边界 if(right<0||nums[right]!=target){ return -1; } return right;
七、滑动窗口
维护一个窗口不断滑动,然后更新答案。通常用于解决子字符串的问题。
基本框架如下:①额和②表示需要更新的数据。
void slidingWindow(string s, string t) { unordered_map<char, int> need, window; for (char c : t) need[c]++; int left = 0, right = 0; int valid = 0; while (right < s.size()) { //c是将移入窗口的字符 char c = s[roght]; //右移窗口 right++; //将窗口内的一系列数据更新 // ①... prindf("window:[%d,%d]\n", left, right); //判断左窗口是否需要收缩 while (window needs shrink){ //d是将移出窗口的字符 char d = s[left]; //左移窗口 left++; //进行窗口内数据的一系列更新 // ②... } } }
常见的几个问题:
①最小覆盖子串
需要考虑以下四个问题:当right扩大窗口,加入字符时,应该更新哪些数据;
什么条件下窗口停止扩大,开始移动left缩小窗口。
移动left缩小窗口时,应该更新哪些数据。
最终结果是在扩大窗口还是缩小窗口时候更新。
对框架的修改通常为
void minWindow(string s, string t) { unordered_map<char, int> need, window; for (char c : t) need[c]++; int left = 0, right = 0; int valid = 0; while (right < s.size()) { //c是将移入窗口的字符 char c = s[roght]; //右移窗口 right++; //将窗口内的一系列数据更新 if (need.count(c)) { window[c]++; if (window[c] = need[c]) { valid++; } } prindf("window:[%d,%d]\n", left, right); //判断左窗口是否需要收缩 while (window needs shrink){ //d是将移出窗口的字符 char d = s[left]; //左移窗口 left++; //进行窗口内数据的一系列更新 if (need.count(d)) { if (window[d] == need[d]) walid__; window[d]--; } } } return len == INT_MAX ? "" : s.substr(start, len); }
②字符串排列
方法和①几乎一致,注意返回值的不同即可。
③找所有字母异位词
同样,差异在于返回值的不同。
④最长无重复子串
去掉need和valid,更新窗口数据也只需要window即可。