树:
二叉树这种数据结构通常可以用两种方式来处理:递归和层级遍历。
1.递归。在当前节点,分别考虑左子树和右子树如何处理。
由递归,又衍生出DFS。
由DFS,又衍生出回溯。
2.层级遍历。
遍历整棵树,可以用DFS或者BFS。DFS又可以分为前序遍历、中序遍历、后序遍历。
题目变种可能是,在遍历完后,对结果处理。
在遍历过程中,记录路径(回溯)。
树的序列化:
前序中序后序处理二叉树
典型题目:
449-二叉搜索树的序列化与反序列化。注意其中一个细节是index的初始值是high+1
297-普通二叉树的序列化与反序列化。如果二叉树的遍历考虑了null,则不管什么二叉树都可以还原。
652
二叉搜索树:
二叉搜索树的中序遍历是升序序列。
典型题目:530
以树转图
这种类型的题目,通常起点不是root,需要将树转化为图,然后再BFS或者DFS。
典型题目:
863-二叉树中所有距离为 K 的结点
栈
栈在pop时,要注意大小是不断变化的,因此最好用while(!stack.isEmpty())来遍历。如果一定要用for循环来遍历,遍历前先取size,队列同理。
单调栈
单调增栈每次插入新数据时,会一次性将比自己小的元素全部排出。
在存储时,可以存储元素的下标,也可以存储元素本身。
常规的代码为:
for (int i = 0; i < T.length; i++) {
while (!stack.isEmpty() && T[i] > T[stack.peek()]) {
int tmp = stack.pop();
result[tmp] = i - tmp;
}
stack.push(i);
}
2.更进一步的,单调栈的应用场景
求右边第一个较小的元素,建立一个从左到右的单调递增栈,while num
求右边第一个较大的元素,建立一个从左到右的单调递减栈,while num>stack.peek()
求左边第一个较小的元素,建立一个从右往左的单调递增栈,while num
求左边第一个较大的元素,建立一个从右往左的单调递减栈,while num>stack.peek()
3.单调栈也可解决去掉N个元素后最小的问题
316、402用单调递增栈来解决,316剩下来的是最小字典序,402剩下来的是最小数。
4.解决左右边界的问题
84-柱状图中最大的矩形
前缀和:
应用常用:求区间和:前缀和常常用在一段连续区间的求和,可以用前缀和相减的方式来获得区间和。
2.前缀和常常能降低时间复杂度,降成O(n)常规前缀和
构建一个前缀和的数组,然后拿着数组再做一遍计算。
2. 前缀和+哈希表
前缀和经常和哈希表一起配套使用。可以减少时间复杂度。
可以只遍历一遍数组,在计算出sum后,判断sum-k
for (int num : nums) {
sum += num;
if (map.containsKey(sum - k)) {
count += map.get(sum - k);
}
map.put(sum, map.getOrDefault(sum, 0) + 1);
}
3.例题:325、560
525-连续数组,这题目是560的变种,题目里出现的0和1转换成-1和1,就转换成了和为0的的情况。
4.map的构造
1)一种是,用于统计次数,不在于中间具体数值。map初始为map(0,1)
2) 一种是 ,可用于计算区间长度。map初始为map(0,-1)。例如第325题。
差分:
差分类似于公交车算法,上车下车。用于多区间有重叠。注意点就是在哪个站下车,直接在当前站减或者在下一个站减。
典型题目:
1109 航班预定统计。先扫描第一遍,得出增减量。对于[i,j,k]来说,在i站上车,在i站+k;在j站下车,在j+1站-k。
得出增减量之后,遍历数组求前缀和,即得出每个点的值。
1094拼车。
253 会议室二。
循环数组
用取模来搞定循环数组,就不用创建2遍了。
例子:
503题
int n = nums.length;
Stack stack = new Stack<>();
int[] result = new int[n];
Arrays.fill(result, -1);
for (int i = 0; i < 2 * n; i++) {
int index = i % n;
while (!stack.isEmpty() && nums[index] > nums[stack.peek()]) {
result[stack.pop()] = nums[index];
}
stack.push(index);
}
滑动窗口:
子串问题,要想到能用滑动窗口。
滑动窗口的要点:
1.窗口的选择,窗口内的条件是什么?
2.窗口的大小是不是恒定的?
3.right和left分别在什么时机前进?怎么前进?有的时候是简单++,有的时候要结合其他数据结构,例如map、单调队列、单调栈等处理。不一定每次都等长前进,有的时候是跳着前进,直接赋值,这里一定要想清楚。
4.滑的时候,用for循环或者while都可以,for循环一般用于固定长度的步进,while一般用于不固定长度的步进。
5.在不固定窗口时,right 指针不停向右移动,left 指针仅仅在窗口满足条件之后才会移动,起到窗口收缩的效果。
参考资料:
例题:
1456
while (right < s.length()) {
char c = s.charAt(right);
if (isVowel(c)) {
count++;
}
if (right - left + 1 >= k) {
result = Integer.max(result, count);
if (isVowel(s.charAt(left))) {
count--;
}
left++;
}
right++;
}
经典代码:
while (right < s2.length()) {
s2Sum[s2.charAt(right) - 'a']++;
if (right >= s1.length() - 1) {
if (isEqual(s1Sum, s2Sum)) {
return true;
}
s2Sum[s2.charAt(left) - 'a']--;
left++;
}
right++;
}
1297. 子串的最大出现次数
while (left <= right && right < s.length()) {
char c = s.charAt(right);
map[c - 'a']++;
while ((right - left + 1) >= minSize && (right - left + 1) <= maxSize) {
if (cal(map) <= maxLetters) {
String sub = s.substring(left, right + 1);
result.put(sub, result.getOrDefault(sub, 0) + 1);
}
char l = s.charAt(left);
map[l - 'a']--;
left++;
}
right++;
}
区间类问题:
区间的问题几个思路供参考:1、滑动窗口(双指针) 2、前缀和的差 3、线段树(树状数组)。4、排序 5、差分(公交车算法)
滑动窗口和前缀和的使用区别:
我们可以看到,在题目里出现“连续的子数组”,就要想到用滑动窗口或者前缀和,那么,这两者的区别是什么呢?如果区间中的数有负数,则不适合用滑动窗口,滑动窗口需要单调递增,不回头。
在滑的时候,如果边界会回头,则不适合用滑动窗口,需要用前缀和。
字符串:
字母异位词:判断2个字符串是否在重新排列后相等。
思路一:分别变成char数组排序,然后再遍历一遍,时间复杂度较高。
思路二:统计字符出现的频次,每个字符的频次都一样,则相等。可以用一个26长度的char数组来替代哈希表实现,占用的内存更低。
例题LeetCode-567:力扣
这2道题的主要代码
while (right < s.length()) {
sArray[s.charAt(right) - 'a']++;
if (right >= p.length() - 1) {
if (isEqual(sArray, pArray)) {
// do something }
sArray[s.charAt(left) - 'a']--;
left++;
}
right++;
}
大小写字母转换
1.可以用Character.tolowercase
2.大小写字母间相差32
统计字符出现的次数
如果仅仅是统计字符出现的次数,建议用一个26长度的int数组来取代map。优先是:
1)运算更快
2)map如果value为0,key可能没删掉,还要单独remove key,如果不这样,计算map的size可能会有问题。
表达式
表达式是一种特殊类型的字符串处理问题,常见类型有计算器和解方程,综合运用到字符串和栈的处理方式。
一般是遍历,然后将运算符和其他字母入栈,遇到特殊运算符时做一些处理。
计算器
计算器一般有几种思路,一种是labuladong的思路,即拆解为+1-2+3的思路,数都入栈,操作符不入栈,最终把栈里的取出来相加就是结果。遇到乘除法,就先取出来计算,然后再入栈。遇到括号就递归。
另外一种用双栈,操作符一个栈,数一个栈。
还有一种用一个栈,但是要注意左右括号的处理。
还有一种是利用栈来实现中缀转后缀。
排序:
常见写法
Collections.sort(list, new Comparator() {
@Override
public int compare(String o1, String o2) {
if (o1.length() == o2.length()) {
return o1.compareTo(o2);
}
return o2.length() - o1.length();
}
});
判断字典序,哪些是默认O1-O2是升序,O2-O1是降序
compareTo >0 是降序,compareTo <0 是升序
TOP K问题
可以使用大小为K的优先队列,Java中有现成的Priorityqueue类,可以重写其中的compare方法来实现各种排序。例如直接比较,或者结合map和比较键值。
其中升序优先队列,在出队(queue.poll)时删除的是最小值,这样保留下来的都是较大值。
降序优先队列,在出队(queue.poll)时删除的是最大值,这样保留下来的都是较小值。
举个例子,[1,2,3,4,5]的队列,K为2,
如果用升序优先队列,最后保留的是[4,5],如果再poll,出队的就是4
如果用降序优先队列,最后保留的是[2,1],如果再poll,出队的就是2
BFS:
一、树上的BFS
常用于树的层序遍历
二、图上的BFS
1.两种模板。一种是只要找到就行,队列不需要取size;一种是求最短路径,有层次的概念,需要取队列的size。
2.单源起点和多源起点。
单向BFS和双向BFS,双向BFS和单向BFS一样,也是O(n),但是如果双向BFS从节点多的一端开始,可以加速。
3.有的时候可以改变二维矩阵的值,有的时候不可以改变值,对于不能改变的,要建立visited数组。
4.方向有变种,例如四个方向,八个方向,马走日等。
单源起点典型题目:
岛屿类问题:200
class Solution {
public int numIslands(char[][] grid) {
int m = grid.length;
if (m == 0) {
return 0;
}
int count = 0;
int n = grid[0].length;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == '1') {
count++;
bfs(grid, i, j);
}
}
}
return count;
}
private void bfs(char[][] grid, int i, int j) {
Queue queue = new LinkedList<>();
queue.add(new int[] {i, j});
int[][] directions = {{0, 1}, {1, 0}, {-1, 0}, {0, -1}};
while (!queue.isEmpty()) {
int[] position = queue.poll();
int r = position[0];
int c = position[1];
for (int[] direction : directions) {
int x = r + direction[0];
int y = c + direction[1];
if (x >= 0 && y >= 0 && x < grid.length && y < grid[0].length && grid[x][y] == '1') {
grid[x][y] = '2';
queue.add(new int[] {x, y});
}
}
}
}
}
单词接龙类问题:单源、图
127、433、126
多源BFS:
典型题目:542
class Solution {
public int[][] updateMatrix(int[][] matrix) {
int m = matrix.length;
int n = matrix[0].length;
int[][] result = new int[m][n];
int[][] directions = new int[][] {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
boolean[][] visited = new boolean[m][n];
Queue queue = new LinkedList<>();
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (matrix[i][j] == 0) {
result[i][j] = 0;
visited[i][j] = true;
queue.add(new int[] {i, j});
}
}
}
while (!queue.isEmpty()) {
int[] position = queue.poll();
int r = position[0];
int c = position[1];
for (int[] dir : directions) {
int x = r + dir[0];
int y = c + dir[1];
if (x >= 0 && y >= 0 && x < m && y < n && !visited[x][y]) {
result[x][y] = result[r][c] + 1;
visited[x][y] = true;
queue.add(new int[] {x, y});
}
}
}
return result;
}
}
DFS:
DFS在递归的时候,经常有两种构造递归函数的办法,一种是直接带返回值;另一种是不带返回值(void),通常第二种用全局变量,常用于计数等。
DFS实际就是一种枚举,借助于递归来实现。
一、树上的DFS
1.基本类型
典型题目:
2.树上的DFS还有一个变种就是回溯,回溯是一种隐式的多叉决策树,通常还要剪枝。
二、图上的DFS
典型题目:200、531、130、733
三、总结
DFS比较重要的:
1.递归函数的含义。
首先要想明白递归函数的用途。想不清楚时给递归删除写下备注。
入参是什么,要带哪些参数进去?
返回值是什么?返回值是void还是指定类型?
2.递归的终止条件
如果返回值是void,考虑终止条件可能是边界条件,对于树来说,如果从根节点出发,是到了叶子节点;对于图来说,是超出了边界。
如果返回值是指定类型,终止条件可能是满足一个条件后的值就返回。
3.当前节点的处理
在二叉树中,就是当前node的处理;在图中或者小岛问题中,就是当前节点的处理。
4.下一节点的处理
在二叉树中,就是左子树和右子树的处理。在图中,根据题意,可能要处理4个方向,可能处理一个方向。
四、注意点
1.在写图中的DFS时,方向数组可以提供为全局变量,因为递归会反复调用,可以不放在dfs函数里。
2. 图上的BFS或者DFS都要注意visited的使用,普通图用hashset来存储visited,二维数组图用一个boolean数组来记录visited
五、别人的总结:
1.岛屿类问题的通用解法、DFS 遍历框架
2.
回溯
典型题目:排列、组合、子集
回溯重要的是画出决策树!
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
回溯要点:选择,约束(可以做剪枝),结束条件
回溯的剪枝是个难点:491中同层用set过滤
贪心算法:
1.数组区间类贪心。看有没有重叠,常常用数组的end来排序,然后遍历。
区间调度:435 先按照end排序,然后算不重叠的区间。最后拿n-不重叠的区间
646
二分
常规的二分就不说了,无非就是寻找区间内的某个值,寻找左边界,寻找右边界。数组有旋转数组,山脉数组。
二分的本质就是在满足单调性的前提下,分而治之解决问题。
这里提下二分的一种变种,前提是看出单调性,单调性调参。
主要题目有:875、1482、LCP12
可以查看下面题解中最后提到的变种二分题目。
int溢出
int是32位的,long是64位的,涉及到整数的乘法,防止溢出可以用long
取余
如果有负数,不能直接取余,需要(sum%K+K)%K ,防止出现负数的情况。
取模
有的时候,题目里面会说结果是个很大的整数,需要对1e9+7取余后返回。这里要注意,取余之前,先用long来装这个“很大的整数”,为什么是long呢?因为int是32位,换成十进制整数是一个2开头的10位整数,而long是64位的。既然是很大的整数,就有可能超过int的范围,所以先用long来装,再取余。
因为int是然后取余后返回int,例如(int)(count %MOD),这里括号要准确,要不然结果不对。
去重
1.可以用hashset去重,使用时先判断set.contains,然后再set.add
2.可以用stream.distinct()去重
3.可以直接把list放入set中去重。
判断是否是回文数
判断是否是奇偶数
1.可以通过对2取余来计算,取余后为0是偶数,取余后为1是奇数。
2.可以通过num&1来计算,如果是0是偶数,为1是奇数。位运算比取余运算快很多。
返回值:
求最小值时返回值不要是0或者-1,容易出bug。可以设置为Integer.MAX_VALUE;
逆向思维:
或者说是求补思维,当正向比较复杂时,反向想,会简化很多,原始问题可以通过一种很巧妙的方式进行转换。
例如:
1423. 可获得的最大点数 ---当正向首尾取时比较复杂,可以反过来想,想一下。维护一个length-k长度的滑动窗口,如果窗口内和最小,则剩下来的和就最大。
417. 太平洋大西洋水流问题 --- 从中间往边界扩散不好处于,反过来想,从边界往中间扩展。
DOUBLE运算
在计算返回值为一个double类型时,不要用int/int 直接用double/int,这样比较精准。
Map常见的坑key相同时,后面的值会覆盖前面的value。所以要考虑是否要用map.putIfAbsent
get经常会有空指针,所以建议用map.getOrDefault
value为空时,需要remove key,否则map的size会有问题。
降低时间复杂度的方法:空间换时间
比如map的引入,dp数组的引入,都是空间换时间。
2.引入二分
3.list.contains改为set.contains
设计类题目注意点:
设计类题目一般都不难,主要有这么两个注意点:
1)读懂题意,选取合适的数据结构。选择合适的数据结构,设计类题目就成功了一半。
2)考虑各种边界条件,尤其是入参的校验。和平常业务开发联系还是比较紧密的。
边界条件通常有:
1)数据结构为空时的处理
2)数组越界
!!!数据结构删除注意!!!
例如map嵌套的数据结构,map> 根据key值获取一个list后,list删除某个元素,此时并不需要重新put map,map已经是删除后的结果。
同时,如果list为空,map里需要移除。
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Solution {
public static void main(String[] args) {
Map> map = new HashMap<>();
List list1 = new ArrayList<>();
list1.add(6);
list1.add(7);
list1.add(8);
List list2 = new ArrayList<>();
list2.add(3);
list2.add(4);
list2.add(5);
map.put(1, list1);
map.put(2, list2);
List list = map.get(1);
list.remove(2);
if (list.isEmpty()) {
map.remove(1);
}
System.out.println(map);
}
}
编程习惯:
1.不要用Ctrl+D来复制代码,例如递归左右子树以及DFS或者BFS多个方向时,容易改漏出现莫名其妙的bug,还不容易排查。
复制一个就修改检查一个,不要全部复制完再修改检查。
2. 设计大于调试。在拿到题目后,花5-10分钟想清楚,甚至用注释写下来,也比后面边写边想、或者写错了debug要好,因为调试耗时较长。
3.做好入参检查。一些corner case没过就是因为异常入参没做检查。
调试技巧:
1.在关键地方加sout日志比直接debug直观,并且快。
2.二叉树可以逐个节点构造。
薄弱点:Java8语法,stream流
并查集++
字典树+
图、邻接表、邻接矩阵。树转图。++
树(递归) 从数组还原为树
拓扑排序+
单调栈
前缀和
字符串栈++
动态规划
贪心算法++
二分的变形,非常规题。++
DFS ++
回溯+
设计类题目++
区间类问题。区间迭代。