文章目录
应做未做
28.实现 strStr()
31.长度最小的子数组
32.用 Rand7() 实现 Rand10()
面试题 17.13. 恢复空格
312.戳气球
287.寻找重复数 => 虽然暴力法通过了,但是题解中富有技巧的解法值得学习!!
336.回文对 => 字典树+马拉车算法待学习!!
494.目标和 => 根据数据特点使用动态规划
538.把二叉搜索树转换为累加树 => Mirros算法
406.根据身高重建队列 => 贪心法
459.重复的子字符串 => 只用了枚举求解,需进一步使用KMP
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 9月开学后
983.最低票价 => 倒着动态规划
581.最短无序连续子数组 => 值得多次体会
股票买卖系列
685.冗余连接 II => 并查集
968.监控二叉树 => 二叉树+动态规划,很难想到
887.鸡蛋掉落 => 自顶向下动态规划+二分法(二分法待研究)
698. 划分为k个相等的子集
460. LFU缓存 => 自己写的不能完全通过
312. 戳气球
486. 预测赢家
91. 解码方法 => 动态规划,极易出错; 且暂不明二维动态规划的思路错在哪里??
127. 单词接龙 => 自己写的DFS时间超限,应该用BFS??
10. 正则表达式匹配=> 已尝试过几次,均未能完全正确!!
301. 删除无效的括号 => 通过左右括号剪枝,不容易想到
295. 数据流的中位数
144. 二叉树的前序遍历
=> morris算法,这么久了都没学过!!!
87. 扰乱字符串 => 区间DP这一大类
140. 单词拆分 II
72. 编辑距离 => 下面的笔记思路不对!!!
327. 区间和的个数 => 树状数组
421. 数组中两个数的最大异或值 =>近两天较懈怠
122. 买卖股票的最佳时机 II => 待复习股票买卖问题大类
399. 除法求值
803. 打砖块 => 逆序并查集(或者DFS,但是自己写的DFS超时,需参考其他答案)
480. 滑动窗口中位数 => 自己写的思路基本没问题,但是未完全通过,需要重做 --02.03
1178. 猜字谜 => trie树+子集 --02.27
153. 寻找旋转排序数组中的最小值 => 待使用二分法
154. 寻找旋转排序数组中的最小值 II
=> 待使用二分法
未弄懂
- 378.有序矩阵中第K小的元素 => 官方二分查找的答案中如何保证最终返回的结果是正确的??
- 478.在圆内随机生成点 => 拒绝抽样的统一思路??
经典题+易错题
- 218.天际线问题 => 回看自己最初的扫描线算法,边界条件很多!! + 一题多解法
- 148.排序链表=> 二路归并(注意数组的话无法实现空间O(1),自顶向下也无法实现空间O(1),此题只能自底向上)
- 350.两个数组的交集 II => 想到hash很容易,但是很难考虑到时空开销更小的方法
- 206.反转链表 => 简单但是不容易理清楚,难以写出简洁的答案!!(迭代和递归两种解法,迭代循环外只需定义两个指针)
- 160.相交链表 => 官方解法三很巧妙!
- 448.找到所有数组中消失的数字 => 不难,但是需要很熟练,能快速完成!!
- 873.最长的斐波那契子序列的长度 => 不易想到的动态规划
- 581.最短无序连续子数组
- 213.打家劫舍 II => 拆开
- 501.二叉搜索树中的众数 => 注意不需要额外空间的miros算法
- LCP 19. 秋叶收藏集 => 动态规划,比赛题
54. 螺旋矩阵 => 注意只有一行/一列的特殊情况;注意有多少个圈的计算
59. 螺旋矩阵 II => 参考最早的解法
一、长见识的方法
- 前缀和+状态压缩=>1371.每个元音包含偶数次的最长子字符串
- 中心扩展法 => 5.最长回文子串
- 判断两个区间是否可以合并 => 57.插入区间
- 顺时针扫描矩阵=> 29. 顺时针打印矩阵
- Boyer-Moore 投票算法 => 169. 多数元素
- 识别排序数组中两个交换元素 => 99. 恢复二叉搜索树
- Morris 中序遍历 => 99. 恢复二叉搜索树(相比普通的递归中序遍历的优点在于其空间复杂度为O(1))
- 判断回文数时只反转一半数字 => 9. 回文数
- 单调栈
739.每日温度; 42.接雨水; 1014.最佳观光组合;84. 柱状图中最大的矩形 - 双指针法 => 15.三数之和
- 双重二分查找 => 1300. 转变数组后最接近目标值的数组和
- 空间冗余以减少特殊判断
718.最长重复子数组; 174.地下城游戏 - 最小堆完成k路归并排序 => 378. 有序矩阵中第K小的元素
- 扫描线算法+set => 218. 天际线问题
- 时间O(nlogn)空间O(1)的链表排序方法 => 148. 排序链表(只能自底向上)
- 不使用循环颠倒二进制位 => 190.颠倒二进制位
- 倒着dp => 174.地下城游戏
- 滚动数组优化空间 =>97.交错字符串
- 数组中运用前缀和 =>724. 寻找数组的中心索引
二、杂七杂八积累
-
注意对输入值特殊情况的排除:0、负数、空指针等
-
递归有很多重复计算,考虑使用动态规划(如斐波那契数列)
-
时间O(n)的排序=>通常针对数字范围小,使用hash
-
部分排序的数组也可能使用二分查找=>剑指 “旋转数组的最小数字”
-
n&(n-1):相当于将n的最右边的1变成0(布赖恩·克尼根算法)
-
通过x&1代替%判断x的奇偶性,从而提高计算效率!
-
如果指定了要删除的链表节点p,则O(1)的解法:交换p和后继位置,删除后继
-
当一个指针一次遍历链表无法解决问题,尝试使用两个相隔固定距离的指针(链表中倒数第k个节点)
-
查找一个未排序数组的中位数,不一定需要对整个数组排序,也可以使用partition用于寻找中位数,O(n)
-
大数问题 => 数字转换成字符串
-
一个数字与自己异或,结果必为0:x^x=0,剑指"数组中只出现一次的数字"
-
九大排序算法?不稳定的排序算法
-
lowbit函数
目的:用来求一个数二进制表示中最低一位
代码:int lowbit(int x){ return x & (-x); //x+(-x)==0; 因此x与-x按位与,最低位必定为1,其他位为0 } //或者 int lowbit(int x){ return x-(x & (x-1)); // x&(x-1)相当于将x最低位的1变成0,所以... }
-
k路归并排序可使用最小堆进行优化,不用操心某路已经遍历完的情况!=> 参考:最小堆实现k路归并
-
求两个数的中间值(l+r)/2 => l+(r-l)/2 以防止溢出
-
数字A不断增长,可能超过基本类型的表示范围,如何判断增长过程中每一步是否能被b整除? => 每一步计算A%b的余数,用余数参与下一轮计算…1018. 可被 5 整除的二进制前缀
三、面试常考题目索引
java刷题常用
-
使用堆
使用优先队列PriorityQueue
,默认是小根堆;自定义排序需要指定比较器PriorityQueue<Integer> pq=new PriorityQueue<Integer>(new Comparator<..>{ ....}); //常用方法 peek()//返回队首元素 poll()//返回队首元素,队首元素出队列 add()//添加元素 offer()//添加元素 size()//返回队列元素个数 isEmpty()//判断队列是否为空,为空返回true,不空返回false
-
队列
Queue只是接口,不是类。需要使用Queue<T> q=new LinkedList<T>();
-
排序
Arrays.sort(num); 或者 Arrays.sort(num,idx1,idx2); …
树
数组
摩尔投票算法
- 基本思想
Boyer-Moore 投票算法的基本思想是:在每一轮投票过程中,从数组中删除两个不同的元素,直到投票过程无法继续
,此时数组为空或者数组中剩下的元素都相等。
如果数组为空,则数组中不存在主要元素;
如果数组中剩下的元素都相等,则数组中剩下的元素可能为主要元素。
=>即拿主要元素和剩下的元素不断取抵消
,则剩下的一定是主要元素
面试题 17.10. 主要元素
堆
数据流的中位数
剑指 Offer 41. 数据流中的中位数
=> 进阶480. 滑动窗口中位数(延迟删除)
排序
四、基础知识总结
4.x 数组
前缀和数组
-
问题描述
如何快速得到数组中某个连续的子数组中元素的和/区间和? -
方法:前缀和数组
使用前缀和数组的方法,数组prefixSum[i]代表nums[0,1,…i]的元素总和,从而要快速求子数组num[i,i+1,…j]的元素和只需计算prefixSum[j]-prefixSum[i-1]class PrefixSum { // 前缀和数组 private int[] prefix; /* 输入一个数组,构造前缀和 */ public PrefixSum(int[] nums) { prefix = new int[nums.length + 1]; // 计算 nums 的累加和 for (int i = 1; i < prefix.length; i++) { prefix[i] = prefix[i - 1] + nums[i - 1]; } } /* 查询闭区间 [i, j] 的累加和 */ public int query(int i, int j) { return prefix[j + 1] - prefix[i]; } }
注:前缀和问题涉及很多方法/数据结构,后续先关的还有差分数组、树状数组、线段树等,他们有各自的特点和适用场景,详见下文…
-
题目
560. 和为K的子数组
=> 前缀和数组+hash优化(大方向简单,细节容易出错)
前缀和+hash经典题目
- 题目
560. 和为K的子数组
=> 前缀和数组+hash优化(大方向简单,细节容易出错)
525. 连续数组
523. 连续的子数组和
差分数组
-
问题描述/适用场景
如何快速地对数组某个区间的所有元素同时增/减一个相同的数? =>
直接逐个计算的话,时间O(k),k是区间大小,能否做到O(1)? -
方法:差分数组
构造差分数组diff,diff[i]=nums[i]-nums[i-1],如果想对区间nums[i,i+1,…j]中的元素同时加val,只需修改diff[i]+=val和diff[j+1]-=val即可:class Difference { // 差分数组 private int[] diff; public Difference(int[] nums) { assert nums.length > 0; diff = new int[nums.length]; // 构造差分数组 diff[0] = nums[0]; for (int i = 1; i < nums.length; i++) { diff[i] = nums[i] - nums[i - 1]; } } /* 给闭区间 [i,j] 增加 val(可以是负数)*/ public void increment(int i, int j, int val) { diff[i] += val; if (j + 1 < diff.length) { diff[j + 1] -= val; } } public int[] result() { int[] res = new int[diff.length]; // 根据差分数组构造结果数组 res[0] = diff[0]; for (int i = 1; i < diff.length; i++) { res[i] = res[i - 1] + diff[i]; } return res; } }
-
题目
1109. 航班预订统计 -
补充
单点更新,范围查询,就用线段树;范围更新,单点查询,就用差分数组
。
树状数组
见 树 部分
线段树
4.x 链表
链表反转
不难但是易错,多练习
为了养成固定的做题套路,凡是链表反转问题,都设两个指针,一个prev,一个curr,反转时的核心操作是将curr指向prev
链表反转的其他常见思路:递归、栈、最好手动设置一个头结点
25. K 个一组翻转链表
206. 反转链表(作为模板背下来)
92. 反转链表 II
删除排序链表中的重复元素
同样不难,但是细节很恼人 => 使用类似于前后指针的方法
83. 删除排序链表中的重复元素
82. 删除排序链表中的重复元素 II
其他链表相关问题
143. 重排链表 => 不容易看出可以通过链表逆序+合并的方法,能够实现O(1)的空间
4.1字符串
字符串匹配—KMP
- 概述
KMP算法利用比较过的信息,主串上的指针 i i i 不需要回溯,只需将子串向右滑动一个合适的位置和主串开始比较(这个合适的位置仅和子串本身的结构有关,与主串无关) - 例题
459. 重复的子字符串
回文子串
-
中心扩展法
思路:从回文中心向两边扩展,寻找最大回文子串(在长度为 N 的字符串中,可能的回文串中心位置有 2N-1 个:字母,或两个字母中间的空格)
题目:647. 回文子串
5. 最长回文子串 (也可动态规划) -
马拉车(Manacher)算法
相较于中心扩展法,时间复杂度更低,只需O(N)
思路参考:[译+改]最长回文子串(Longest Palindromic Substring) Part II
题目:647. 回文子串
注:面试应该不会要求这个算法,但是值得了解!! -
回文串相关题目
5. 最长回文子串 => 动态规划、中心扩展法
1312. 让字符串成为回文串的最少插入次数 => 动态规划(与516题几乎一样)
516. 最长回文子序列=>还是动态规划(注意这题子序列不一定是连续的,区别于第5题)
131. 分割回文串
132. 分割回文串 II
马拉车(Manacher)算法
编辑距离
-
概述
问题描述:对于两个字符串A、B,可以对其中一个字符串进行三种操作:插入、删除、替换 => 求使得两个字符串相同(只能修改一个字符串,另一个不变)的最少操作次数
方法:动态规划 -
问题分析
分析可知,既可以A=>B,又可以B=>A,但是:- A删除一个字符等价于B插入一个字符
A(doge)、B(dog)既可以删除A末尾的e,也可以在B末尾添加e - B删除一个字符等价于A插入一个字符
原理同上 - A替换一个字符等价于B替换一个字符
A(bat)、B(cat)既可以将A中的b替换为c,也可以将B中的c替换为b
所以,本质上A=>B和B=>A没有区别,若只考虑A=>B(即所有操作都在A上进行),则对A一共可以有三种操作:1.在A中插入一个字符; 2.从A中删除一个字符; 3.替换A的一个字符
- A删除一个字符等价于B插入一个字符
-
动态规划求解思路
对于上述分析的情况,从动态规划的角度出发,只考虑每个操作在末尾进行:- 1.在A中插入一个字符
如果已知A(horse)与B(ro)的编辑距离为a,则horse到ros的编辑距离不会超过a+1 => 假设对A进行了a次操作后,A(horse)变成了B(ro);对于horse到ros,只需对A再附加一次操作,在A(ro)末尾插入字符s - 2.从A中删除一个字符
如果已知A(hors)与B(ros)的编辑距离为b,则horse到ros的编辑距离不会超过b+1=>假设对A进行了b此操作后,A(hors)变成了B(ros);对于horse到ros,直接删除horse末尾的e即可(而hors到ros的转换已知需要b此操作) - 3.替换A的一个字符
如果已知A(hors)与B(ro)的编辑距离为c,则horse与ros的编辑距离不会超过c+1 => 假设对A进行了c此操作后,A(hors)变成了B(ro);对于horse到ros,horse末尾的e替换为s即可(而hors到ro的转换已知需要c次操作)
- 1.在A中插入一个字符
-
状态转移方程
由上述分析,horse=>ros的最小编辑距离取min(a+1,b+1,c+1)即可。假设动态规划数组中dp[i][j]代表A的前i个字符A[0,1,…i-1]与B的前j个字符B[0,1,…j-1]之间的编辑距离,整理可得状态转移方程为:
d p [ i ] [ j ] = m i n ( d p [ i ] [ j − 1 ] + 1 , d p [ i − 1 ] [ j ] + 1 , d p [ i − 1 ] [ j − 1 ] + ( A [ i ] ! = B [ j ] ) ) dp[i][j]=min(dp[i][j-1]+1,dp[i-1][j]+1,dp[i-1][j-1]+(A[i]!=B[j])) dp[i][j]=min(dp[i][j−1]+1,dp[i−1][j]+1,dp[i−1][j−1]+(A[i]!=B[j]))
注意:判断A[i-1]!=B[j-1]是因为如果两者相等时不需要进行替换操作
题目:72. 编辑距离 -
动态规划的空间优化
既然每个 dp[i][j] 只和它附近的三个状态有关,空间复杂度是可以压缩成 O(min(M, N))(M,N 是两个字符串的长度)
4.x 栈
表达式求值
-
概述
常见思路:中缀表达式转换成后缀表达式,然后再对后缀表达式求值
或者:将上述两个步骤结合在一起,同时进行(即运算符中每弹出一个符号,操作数栈中就弹出两个数进行运算; 栈顶优先级大于当前优先级时弹出!) -
中缀表达式转换为后缀表达式
思路:从左到右扫描中缀表达式 =>
1.若读取的是操作数,直接放入后缀表达式中
2.若读取的是运算符号:
a.若运算符栈为空,直接将其入栈;
b.若运算符栈不空,只有当前运算符的优先级高于栈顶运算符的优先级时,当前运算符方可入栈;否则不断出栈直到栈顶优先级低于当前运算符
c.对于括号的特殊处理:若栈顶运算符是“(”,当前运算符直接入栈;若当前运算符是“(”,直接入栈 ; 若当前运算符是")",不断出栈直到将栈顶的第一个左括号出栈为止;
注:上述的出栈结果如果是括号则忽略,不是括号则添加到后缀表达式;
由上述可知,先出栈的先计算 -
后缀表达式求值
思路:从左到右扫描后缀表达式tokens => 如果读取的是一个操作数,则将其放入操作数栈; 若读取的是一个运算符,则连续出栈两个操作数,进行对应的运算,再将结果放入操作数栈…最终栈内只有一个元素,就是运算结果// 后缀表达式求值 //["4", "13", "5", "/", "+"] => (4 + (13 / 5)) = 6 public int evalRPN(String[] tokens) { int len=tokens.length; Stack<Integer> stackNum=new Stack<Integer>(); for(int i=0;i<len;i++){ if(tokens[i].equals("+")){ Integer op2=stackNum.pop(); Integer op1=stackNum.pop(); Integer res=op1+op2; stackNum.push(res); } else if(tokens[i].equals("-")){ Integer op2=stackNum.pop(); Integer op1=stackNum.pop(); Integer res=op1-op2; stackNum.push(res); } else if(tokens[i].equals("*")){ Integer op2=stackNum.pop(); Integer op1=stackNum.pop(); Integer res=op1*op2; stackNum.push(res); } else if(tokens[i].equals("/")){ Integer op2=stackNum.pop(); Integer op1=stackNum.pop(); Integer res=op1/op2; stackNum.push(res); } else{ // 数字,直接入栈 Integer num=Integer.valueOf(tokens[i]); stackNum.push(num); } } return stackNum.peek(); }
-
直接两个栈求解表达式求值
一个操作数栈,一个符号栈;
遇到操作数时直接入栈
遇到符号时:
1.如果是左括号,直接入栈;
2.如果是右括号,出栈符号并计算,直到遇到第一个左括号;
3.如果是运算符,若当前运算符优先级低于栈顶运算符,则先弹出栈顶运算符计算… -
相关题目
150. 逆波兰表达式求值
224. 基本计算器
227. 基本计算器 II => 可先求后缀表达式,也可直接计算
单调栈
- 概述
1.单调栈实际上就是栈,只是利用了一些巧妙的逻辑,使得每次新元素入栈后,栈内的元素都保持有序(单调递增或单调递减)
2.单调栈用途不太广泛,只处理一种典型的问题,叫做 Next Greater Element,比如,输入[2,1,2,4,3],对应输出[4,2,4,-1,-1]
解决思路:比如上图中,需要找每个数的greater => 可以使用单调递减的栈,遇到比栈顶更小的值,直接入栈;遇到比栈顶更大的值,不断出栈,直到栈顶大于当前数组值(而这些出栈值的greater就是当前数组值)
更多参考:特殊数据结构:单调栈 - 相关题目
496. 下一个更大元素 I
503. 下一个更大元素 II => 理解这里倒着遍历比顺着遍历的优势!! => 顺着遍历也行,只是栈中就应该存储下标而不是数值!
556. 下一个更大元素 III => 也是单调栈(极易错,见提交中的solve1)
1081. 不同字符的最小子序列 => 不易想到,同316题
85. 最大矩形 =>解法3
84. 柱状图中最大的矩形 => 85题目也可以沿用这一思想
栈实现队列,队列实现栈
-
两个栈实现队列
一个栈(inS)暂存所有入队元素,从另一个栈(outS)出队,outS中没有元素时再将inS的所有元素出栈,并入栈到outS
232. 用栈实现队列 -
两个队列实现栈(一个队列也行)
入栈时将元素放到空队列q1,然后将q2中元素出队,并入队到q1,直到q2为空
=> 那么下一个元素入栈时,则该放入q2…依次重复…
225. 用队列实现栈
栈相关题目
1047. 删除字符串中的所有相邻重复项
331. 验证二叉树的前序序列化
4.x 队列
双端队列/动态窗口
- 概述
双端队列最常用的地方就是实现一个长度动态变化的窗口或者连续区间,而动态窗口这种数据结构在很多题目里都有运用。可以通过双向链表实现双端队列
单调队列
-
概述
类似于单调栈。以单增队列为例,从队列头到队列尾的元素是严格递增
添加元素e到队尾时,需要先检查e是否大于队尾元素 => 如果是则直接加入队尾,否则不断从队尾删除大于等于e的元素
由于从队尾删除元素不方便,所以经常使用双端队列实现单调队列 -
与单调栈的对比
参考:单调队列和单调栈详解
简言之:
1.本质上两者都是一个单调的顺序序列,都是从尾部添加元素
2.但是队列可以从队列头弹出元素,可以方便地根据入队的时间顺序(访问的顺序)删除元素
3.这样导致了单调队列和单调栈维护的区间不同。当访问到第i个元素时,单调栈维护的区间为[0, i),而单调队列维护的区间为(lastpop, i)
-
题目
239. 滑动窗口最大值
4.x 堆
topK(三种方法)
- 堆求解top-k问题
首先设置一个大小为K的堆(如果求最大top K,那么用最小堆,如果求最小top K,那么用最大堆),然后扫描数组。并将数组的每个元素与堆的根比较,符合条件的就插入堆中,同时调整堆使之符合堆的特性,扫描完成后,堆中保留的元素就是最终的结果 => O(nlogk)
题目:
973. 最接近原点的 K 个点
692. 前K个高频单词 - 全部排序
O(nlogn) - partition+二分
借鉴快排思路:O(n)
数据流中位数
使用一个大根堆,一个小根堆,大根堆中的最大数<=小根堆汇总的最小数;
每次加入数据时,先插入大根堆,然后进行调整以满足两个条件:
1.大根堆中元素个数与小根堆个数相等或者恰好多一个;
2.同时满足大根堆中的最大数<=小根堆汇总的最小数
例题
剑指 Offer 41. 数据流中的中位数 (同时使用两个堆)
480. 滑动窗口中位数(数据流中位数的进阶版=> 延迟删除)参考:两种做法:multiset,两个堆
295. 数据流的中位数(同剑指offer41题)
K路归并
问题: 设计一个时间复杂度为O(NlogK)的算法,它能够将K个有序链表合并为一个有序链表,这里的N为所有输入链表包含的总的元素个数
该问题为经典的利用堆完成K路归并的问题:
当K个序列满足一定的条件(如单调不减或单调不增)时,利用堆实现K路归并使其归并为一个满足相同条件的序列,具体做法如下:
1)假设存在K个序列,从每一个序列中取出一个元素放于堆中;
2)从堆中取出顶端元素,并在该元素的序列中取出下一个元素插入堆中。
3)重复操作1)与2),直到完成归并。
例题:23. 合并K个升序链表
4.2树
并查集
-
基础理解
常用于解决连通性问题
-
三个操作
1.init(s) => 将集合s中的每一个元素都初始化为只有一个单元素的子集合
2.find(s,idx) => 查找对应下标的元素所在的集合,返回该集合对应的根节点下标
3.union(root1, root2) => 将互不相交的集合root1和root2合并 -
代码(常规)
#define SIZE 100 int S[SIZE]; //双亲指针数组(下标对应元素,值对应父节点编号),根节点的父节点编号为负数 //初始化,每个元素自成一个集合; 集合的根节点就是该节点 void init(int S[]){ for(int i=0;i<size;i++) S[i]=-1; } //查找,返回某个编号的节点所在集合的根节点的编号 int find(int S[], int idx){ while(S[idx]>=0) //找到根节点时退出 idx=S[idx]; return idx; } //合并两个不相交的集合 // 若输入的是元素而不是集合,则必须先找到集合的根节点!!!! void unite(int S[],int root1, int root2){ //S[root1]+=S[root2]; =>若全部初始化为-1,此步骤可以用根节点的父节点值(负数)的绝对值表示集合元素个数 S[root2]=root1; //将集合2合并到集合1下面 //S[find(idx2)]=find(idx1); //如果输入的是要合并的两个元素 }
-
代码(路径压缩优化)
class UnionFind { int[] parents; public UnionFind(int totalNodes) { parents = new int[totalNodes]; // 每个结点初始化为一个集合,且父节点为他本身(方便路径压缩进行优化) // <=> 对比常规代码此处的区别!! for (int i = 0; i < totalNodes; i++) { parents[i] = i; } } //合并两个节点,合并连通区域是通过find来操作的, 即看这两个节点是不是在一个连通区域内. void union(int node1, int node2) { int root1 = find(node1); int root2 = find(node2); if (root1 != root2) { parents[root2] = root1; } } // 查找,注意使用了路径压缩,查找过程中修改树结构 int find(int node) { while (parents[node] != node) { // 当前节点的父节点 指向父节点的父节点. // 保证一个连通区域最终的parents只有一个. parents[node] = parents[parents[node]]; node = parents[node]; } return node; } }
-
代码(按秩合并)
可能有一个误解,以为路径压缩优化后,并查集始终都是一个菊花图(只有两层的树的俗称)。但其实,由于路径压缩只在查询时进行,也只压缩一条路径,所以并查集最终的结构仍然可能是比较复杂的
按秩合并
:用一个数组rank[]记录每个根节点对应的树的深度(如果不是根节点,其rank相当于以它作为根节点的子树的深度)。一开始,把所有元素的rank(秩)设为1。合并时比较两个根节点,把rank较小者往较大者上合并
下面的代码是按秩合并(没有和路径压缩一起使用)inline void init(int n){ for (int i = 1; i <= n; ++i) { fa[i] = i; rank[i] = 1; } } int find(int x){ return x == fa[x] ? x : (fa[x] = find(fa[x])); } inline void merge(int i, int j){ int x = find(i), y = find(j); //先找到两个根节点 if (rank[x] <= rank[y]) fa[x] = y; else fa[y] = x; if (rank[x] == rank[y] && x != y) rank[y]++; //如果深度相同且根节点不同,则新的根节点的深度+1 }
详情参考:算法学习笔记(1) : 并查集
-
分析
1.并查集的时间复杂度比较麻烦(不过肯定小于O(logn),是阿克曼反函数),可参考网络资料
2.路径压缩
方法的并查集作为模板背下来!!!
3.理解秩的含义;
4.路径压缩和按秩合并如果一起使用,时间复杂度接近 O(1) ,但是很可能会破坏rank的准确性。 -
题目
990. 等式方程的可满足性
547. 省份数量
1202. 交换字符串中的元素
803. 打砖块 => 逆向思维使用并查集(不过这里也可使用DFS)
1319. 连通网络的操作次数
959. 由斜杠划分区域 =>思路巧妙,必看!
1579. 保证图可完全遍历 => 类似Kruskal
1631. 最小体力消耗路径 => 并查集的思路很灵活;且一题多解,尝试!!!
778. 水位上升的泳池中游泳 => 与1631非常相似
二叉树的遍历(Morris算法未完成!很有用)
-
先序、中序、后序
比较简单,略 -
层序遍历
对于层序遍历,若要按层输出所有值,一种思路是采用两个队列交替使用但是先得很笨(之前就这样做过); 更简单的思路是每个大循环中,直接获取队列的元素个数,这就是上一层元素的个数,据此解题…参考:637. 二叉树的层平均值
题目:117. 填充每个节点的下一个右侧节点指针 II =>巧妙地利用next指针进行空间复杂度O(1)的层序遍历 -
Morris 算法
题目:538. 把二叉搜索树转换为累加树 -
先序非递归
与中序非递归框架基本相同,题目:144. 二叉树的前序遍历 -
中序非递归(需常练)
这个写法条件判断不好记,不推荐 => 参考下面的Java写法void InOrder(BiTree T){ InitStack(S); BiTree p=T; //p是遍历指针 while(p || !isEmpty(S)){ if(p){ while(p){ // 向左走完!! S.push(p); p=p->left; } } else{ Pop(S,p); visit(p); p=p->right; //向右走!!! } } }
关键是这个遍历指针p,p不为空则持续向左,p为空向右
更好理解的写法
例题:173. 二叉搜索树迭代器 94. 二叉树的中序遍历// 中序非递归 public List<Integer> inorderTraversal(TreeNode root) { if(root==null) return new ArrayList<Integer>(); List<Integer> arr=new ArrayList<Integer>(); Stack<TreeNode> stack=new Stack<TreeNode>(); TreeNode node=root; // 1.先将根节点开始所有左子树入栈 stack.push(node); node=node.left; while(node!=null){ stack.push(node); node=node.left; } // 2.开始出栈 while(!stack.empty()){ node=stack.pop(); //出栈结点 arr.add(node.val); if(node.right!=null){ stack.push(node.right); // 出栈结点的右子节点入栈 node=node.right.left; while(node!=null){ // 出栈结点的右子节点的所有左子节点入栈 stack.push(node); node=node.left; } } } return arr; }
中序遍历对比 <=> 二叉树的线索化
-
后序非递归(有难度)
大体的迭代方式其实和中序迭代相似,只是由于后遍历双亲节点,需要记录已经访问过的节点(这里使用prev),而中序非递归则没有这个苛刻的要求class Solution { // 后序遍历的迭代解法 public List<Integer> postorderTraversal(TreeNode root) { List<Integer> ans=new ArrayList<Integer>(); Stack<TreeNode> s=new Stack<TreeNode>(); TreeNode currNode=root; TreeNode prev=null; //代表前一个已经访问的点 // currNode不为null说明还有节点未入栈; s不为空说明栈中节点没有访问完 while(currNode!=null || !s.isEmpty()){ while(currNode!=null){ // 所有左节点入栈 s.push(currNode); currNode=currNode.left; } currNode=s.pop(); // 弹出当前栈顶节点 // 栈顶节点没有右孩子,则直接访问栈顶节点 // 或者栈顶节点的右孩子已经访问过了,则直接访问栈顶节点 => prev很巧妙, if(currNode.right==null || currNode.right==prev){ ans.add(currNode.val); prev=currNode; //标记已经访问的节点 currNode=null; } //否则(栈顶节点有有孩子且尚未访问),则刚弹出的栈顶节点入栈,之后接着入栈右孩子所有左节点 else{ s.push(currNode); currNode=currNode.right; } } return ans; } }
- 二叉树遍历经典题目
236. 二叉树的最近公共祖先
235. 二叉搜索树的最近公共祖先
116. 填充每个节点的下一个右侧节点指针(不易想到)
二叉搜索树/二叉排序树
- 概述
仅仅只是数字有序的二叉树,考察点主要还是二叉树
对二叉搜索树进行中序遍历即得有序序列 - 二叉搜索树的插入
详见:701. 二叉搜索树中的插入操作,虽然自己写了,看官方答案更简洁
B树(待完善)
B+树(待完善)
- 为什么索引使用B+树而不是B树
参考:MySQL用B+树(而不是B树)做索引的原因
LSM树(待完善)
字典树/前缀树/Trie
-
基础知识
1.trie树结点示意图
可见,trie树的层数就是单词长度+1(最后一层不存储指针)
2.trie树常见的操作 => 插入、查找(键、键前缀)
3.注意:trie树的叶子结点不对应任何数据,且isEnd标志为true -
代码
/*结点定义*/ class TrieNode { private TrieNode[] links; //每个结点包含R个指向下层结点的指针 private final int R = 26; private boolean isEnd; //标志当前结点是否为一个键的末尾(一个长单词中途可能也会遇到isEnd==true的情况) public TrieNode() { links = new TrieNode[R]; } public boolean containsKey(char ch) { return links[ch -'a'] != null; } public TrieNode get(char ch) { return links[ch -'a']; } public void put(char ch, TrieNode node) { links[ch -'a'] = node; } public void setEnd() { isEnd = true; } public boolean isEnd() { return isEnd; } } /*字典树*/ class Trie { private TrieNode root; public Trie() { root = new TrieNode(); } //插入 public void insert(String word) { TrieNode node = root; for (int i = 0; i < word.length(); i++) { char currentChar = word.charAt(i); if (!node.containsKey(currentChar)) { node.put(currentChar, new TrieNode()); } node = node.get(currentChar); } node.setEnd(); } //用于查找整个键或者键前缀 private TrieNode searchPrefix(String word) { TrieNode node = root; for (int i = 0; i < word.length(); i++) { char curLetter = word.charAt(i); if (node.containsKey(curLetter)) { node = node.get(curLetter); } else { return null; } } return node; } // 查找整个键 public boolean search(String word) { TrieNode node = searchPrefix(word); return node != null && node.isEnd(); } //查找键前缀 public boolean startsWith(String prefix) { TrieNode node = searchPrefix(prefix); return node != null; } }
分析:时间复杂度O(m),m代表键长; 空间O(1)
与平衡树、哈希表相比而言的优点…
详情参考:字典树
例题:211. 添加与搜索单词 - 数据结构设计
336. 回文对
01字典树(todo)
radix tree/基数树(TODO)
是trie树的变种
树状数组/二叉索引树
- 基本原理
主要用于解决区间和、前缀和等问题
(上图只是为了方便理解,空白结点在树状数组中并不存在,更恰当的图参考:树状数组详解)
原数组为A[1]…A[8]; 对应的树状数组为C[1]…C[8];原数组前缀和为SUM[1]…SUM[8],注意下标都是从1开始!!! 观察可知:
C[1]=A[1] | SUM[1]=C[1] |
C[2]=A[1]+A[2] | SUM[2]=C[2] |
C[3]=A[3] | SUM[3]=C[3]+C[2] |
C[4]=A[1]+A[2]+A[3]+A[4] | SUM[4]=C[4] |
C[5]=A[5] | SUM[5]=C[5]+C[4] |
C[6]=A[5] +A[6] | SUM[6]=C[6]+C[4] |
C[7]=A[7] | SUM[7]=C[7]+C[6]+C[4] |
C[8]=A[1]+A[2]...A[8] | SUM[8]=C[8] |
=>规律:
1.
C
[
i
]
=
A
[
i
−
2
k
+
1
]
+
A
[
i
−
2
k
+
2
]
.
.
.
+
A
[
i
]
C[i]=A[i-2^k+1]+A[i-2^k+2]...+A[i]
C[i]=A[i−2k+1]+A[i−2k+2]...+A[i],k满足
l
o
w
b
i
t
(
i
)
=
2
k
lowbit(i)=2^k
lowbit(i)=2k
2.
S
U
M
[
i
]
=
C
[
i
]
+
C
[
i
−
l
o
w
b
i
t
(
i
)
]
+
C
[
(
i
−
l
o
w
b
i
t
(
i
)
)
−
l
o
w
b
i
t
(
i
−
l
o
w
b
i
t
(
i
)
)
]
.
.
.
每个累加项都是向下递归所得
SUM[i]=C[i]+C[i-lowbit(i)]+C[(i-lowbit(i))-lowbit(i-lowbit(i))]...每个累加项都是向下递归所得
SUM[i]=C[i]+C[i−lowbit(i)]+C[(i−lowbit(i))−lowbit(i−lowbit(i))]...每个累加项都是向下递归所得
3.设节点编号为
i
i
i,那么该节点维护的值是
[
i
−
l
o
w
b
i
t
(
i
)
+
1
,
i
]
[i-lowbit(i)+1,i]
[i−lowbit(i)+1,i]这个区间的和(即规律1) => 简单的线段树
-
代码
树状数组最基本的操作有两个:单点更新和区间查询// 计算lowbit int lowbit(int x){ return x & (-x); //或者 return x -(x&(x-1)); } //单点更新,原数组A[idx]加上 void update(int idx, int incr){ A[idx]+=incr; // 实际上这是不断往上查找父节点的过程!! for(int i=idx; i<=n; i+=lowbit(i)) C[i]+=incr; } //区间查询,求A[1]...A[idx]的和 int sum(int idx){ int res=0; for(int i=idx;i>0;i-=lowbit(i)) res+=C[i]; return res; }
通过lowbit即可实现查找、更新的原理比较难以理解,参考知乎体会体会
修改和查询的复杂度都是O(logN)
例题:493. 翻转对
315. 计算右侧小于当前元素的个数(注意离散化的过程)
线段树
平衡二叉树之AVL树(代码未完成)
- 概念基础
AVL树既是一棵平衡树(高度差不超过1); 又是一棵二叉搜索树(有序)
根据插入节点的位置相对于最小不平衡子树的位置,调整为平衡树的情况可分为四类:LL(左孩子的左子树插入)、RR(右孩子的右子树插入)、LR(左孩子的右子树插入)、RL(右孩子的左子树插入) => 具体调整方案文字叙述比较枯燥,实际上看图就能明白
以下多数内容参考自:数据结构之——平衡二叉树(内容详解) - LL型
调整方案 => 右旋:
示例:
代码:
```cpp
在这里插入代码片
```
-
RR型
解决方案 => 左旋:
示例:
代码:在这里插入代码片
-
LR型
解决方案 => 先对最小不平衡子树的左子树上的节点右旋,再对最小不平衡子树进行左旋
示例:
代码:在这里插入代码片
-
RL型
解决方案 => 先对最小不平衡子树的右子树上的节点进行左旋,然后对最小不平衡子树进行右旋
示例:
代码:在这里插入代码片
堆
-
构造堆
堆被模型化为一颗完全二叉树(编号从1开始
)。n个结点的完全二叉树,最后一个结点是第 ⌊ \lfloor ⌊ n/2 ⌋ \rfloor ⌋个结点的孩子。
对第 ⌊ \lfloor ⌊ n/2 ⌋ \rfloor ⌋个结点为根的子树筛选,使该子树成为堆;之后依次向前对 ⌊ \lfloor ⌊ n/2 ⌋ \rfloor ⌋-1 ~1为根的子树进行筛选,看该结点的值是否大于其左右子结点的值,如果不大于,则将左右子结点中较大值与之交换。交换后可能会破坏下一级堆
,于是继续采用上述方法构造下一级堆,直到以该结点为根的子树构成堆为止(向下调整)。
代码:void createHeap(ElemType A[],int len){ // 即,对n/2开始的每一个根节点,都进行向下调整,最终自然能构成堆 for(k=len/2;k>=1;k--){ adjustDown(A,k,len); //向下调整,将k为根的子树调整为堆 } }
注:
建堆的时间复杂度是O(n)
,容易被误认为是O(nlogn),证明比较麻烦,这里略去 -
向下调整
向下调整是将以某结点为根的子树调整为堆
,它是从上到下的递归,算法如下(大根堆为例): => 这里也可以写为递归!!// 注意下标从1开始,k是某子树的根节点 void adjustDown(ElemType A[], int k, int len){ for(i=2*k;i<=len;i*=2){ if(A[i+1]>A[i]) i++; //选取子树中较大的一个 if(A[k]>=A[i]) break; //满足堆的条件,不需要调整 swap(A[k],A[i]); //将较大值交换到根节点上 k=i; //继续递归地向下调整 } } // 将nums[k-1]为根的子树调整为大根堆 => 递归写法示例,Java版 void adjustDown(int[] nums,int k,int len){ if(k*2 > len) return; // 退出,因为没有子树 int lchild=k*2-1; // 减1是为了符合nums,如果是完全二叉树则不用减1 int rchild=lchild+1; int bigger=nums[lchild]; // 默认只有左子树 int bigger_idx=lchild; if(rchild < len){ // 如果左右子树都有 bigger=Math.max(nums[lchild],nums[rchild]); bigger_idx=nums[lchild]>nums[rchild]?lchild:rchild; } if(nums[k-1]>=bigger) return; // 符合大根堆,不用调整 else{ // 子节点大于根,需要交换 int tmp=nums[k-1]; nums[k-1]=nums[bigger_idx]; nums[bigger_idx]=tmp; adjustDown(nums,bigger_idx+1,len); // 递归向下调整,将nums[bigger_idx]为根的子树调整为.. } }
向下调整的时间复杂度是
O(h)
-
向上调整
插入一个元素后,需要从新插入的元素开始向上调整(也可以写成递归):// k为在末尾添加的元素的位置,其实就是堆的长度 void adjustUp(ElemType A[],int k){ for(p=k/2;p>=1;p/=2){ if(A[p]>=A[k]) break; // 插入的节点值不大于双亲结点,不用上调 swap(A[k],A[p]); // 交换,继续向上调整 k=p; } }
注:按理说,插入新结点后,也可以按照构造堆的方式使得整个完全二叉树称为堆,但是这样相率相对较低:O(n) vs O(h)
-
堆排序
算法如下:先输出堆顶元素。然后将堆底元素放入堆顶,从而导致堆顶被破坏,于是从新的堆顶开始向下调整…… =>大根堆用于升序排序,小根堆用于降序排序
void heapSort(ElemType A[],int len){ createHeap(A,len); // 建堆O(n) // 堆排序,n-1次向下调整 for(i=len;i>=1;i--){ // i是当前堆的长度,A[i]是堆底,A[1]是堆顶 swap(A[i],A[1]); // 将堆顶元素放到最终有序位置;同时将堆底元素放入堆顶 adjustDown(A,1,i-1); //从根结点开始,向下调整新的堆(注意长度!) } }
时间复杂度
O(nlogn)
-
堆的删除
同堆排序一样,略…… -
堆的插入
先将新的结点放到堆的末端,然后向上调整 =>
若结点值大于双亲结点,则将双亲与其交换,并继续向上比较……
这一过程就是堆的向上调整 -
分析
建堆时间:O(n),比较麻烦,暂时不用证明
堆排序时间:建堆时间O(n),之后进行n-1时间为O(logn)的向下调整,故综合为O(nlogn)
稳定性:不稳定,比如{1,2
,2} => 建堆为{2
,1,2} => 排序为{1,2,2
}。(大根堆,升序)
平衡二叉树之红黑树
- 定义
注意定义中最下面的空节点被视为叶结点,且为黑色;红黑树示例如下:
- 旋转
左旋
右旋
代码
- 插入
为什么要把插入节点标记为红色?
将插入节点标记为红色,只会违背红黑树定义中的规则2、3,而这两条规则是比较好修复的
代码
举例:red black trees 红黑树 超棒讲解
4.3图
图的遍历
本质上就是在树的先根遍历、层序遍历的基础上增加visited数组!
-
DFS
代码:bool visited[MAX_VERTEX_NUM]; void DFSTraverse(Graph G){ for(v=0; v<G.vexnum; v++) //初始化visited数组 visited[v]=false; for(v=0; v<G.vexnum; v++) //这个循环保证能遍历完非连通图!!! if(!visited[v]) DFS(G,v); } void DFS(Graph G, int v){ visit(v); visited[v]=true; //设置已访问 for(w=FirstNeighbor(G,v); w>=0; w=NextBeighbor(G,v,w)) if(!visited[w]) DFS(G,w); //w是v尚未访问的邻接点 }
分析:
1.空间复杂度:O(n) => 递归,需要借助递归工作栈
2.时间复杂度:邻接表:O(|V|+|E|) <=>邻接矩阵O(|V^2|)
3.对于同一个图,由于邻接表可能不同,因此DFS遍历序列可能不同(矩阵则同)
4.回溯法本质上就是DFS算法(但是回溯有一个选择/撤销选择的步骤) -
BFS
代码: 借助队列,略…
分析:
1.空间复杂度:O(n) => 需要使用队列
2.时间复杂度:邻接表:O(|V|+|E|) <=>邻接矩阵O(|V^2|)
3.对于同一个图,由于邻接表可能不同,因此BFS遍历序列可能不同(矩阵则同)
4.BFS可用于求解非带权图的单源最短路径 -
再谈BFS(深入理解)
1.本质就是让你在一幅「图」中找到从起点 start 到终点 target 的最近距离(同上文第四点)
2.visited 的主要作用是防止走回头路,大部分时候都是必须的,但是像一般的二叉树结构,没有子节点到父节点的指针,不会走回头路就不需要 visited
例题:
111. 二叉树的最小深度 -
双向BFS(非必须)
时间复杂度数量级没有变,但是相对更快,因为遍历的节点更少
参考:BFS 算法解题套路框架
拓扑排序
- 思路:每次选取DAG中没有前驱的节点即可。这个过程其实就是在遍历图,因此可分为DFS解法和BFS解法,下面以DFS为例(BFS只需将栈改为队列即可)
- 代码(DFS)=> 此为非递归写法,也可写为递归
若是邻接表;则时间O(|V|+|E|) ; 若是邻接矩阵,则时间O(|V|^2)TopologicalSort(Graph G){ InitStack(S); for(injt i=0;i<G.vexnum;i++) if(indegree[i]==0) //所有入度为0的顶点入栈 Push(S,i); int cnt=0; //记录当前已经输出的顶点数 while(!IsEmpty(S)){ Pop(S,i); print(i); //输出顶点i cnt++; for(p=G.vertices[i].firstarc;p=p->nextarc){ v=p->adjvec; if(!(--indegree[v])) //去掉顶点i后,与i相邻的入度为0的顶点入栈 Push(S,v); } } if(cnt<G.vexnum) return false; //有回路 return true; }
空间O(|V|)
注意不管怎么样都需要先建图(比如邻接表),从而对于每个节点i,能够找到依赖它的所有节点
为什么此处遍历图没有使用visited[]数组? => 因为这是有向无环图(DAG),不可能回到已经遍历过的节点
例题:210. 课程表 II => 拓扑排序
双重拓扑排序:1203. 项目管理
- 补充:DFS+染色
参考:Python实现拓扑排序(借助深度搜索,用颜色表示搜索状态)其实就是DFS的递归思路,只是通过染色来判断结点状态
单源最短路径Dijkstra
假设图已经构建好(邻接矩阵),算法如下:
vector<int> dijkstra(vector<vector<int>> graph, int start){
int inf=0x3f3f3f3f; // graph中不可达的距离也设置为inf
int n=graph.size();
vector<int> dist(n,inf); // 源点到每个点的路径
for(int i=0;i<n;i++){
dist[i]=graph[start][i];
}
vector<bool> used(n,false); // 是否已经求得最短路径
used[start]=true;
for(int i=0;i<n-1;i++){ // 源点到n-1个点的最短路径,故n-1轮
int minIdx=-1; int minDist=inf;
for(int j=0;j<n;j++){ // 未加入最短路径的点中,选出到源点最近的一个点
if(!used[j] && dist[j]<minDist){
minIdx=j; minDist=dist[j];
}
}
if(minIdx==-1) break; // 说明剩余的点都不可达
used[minIdx]=true;
for(int j=0;j<n;j++){
if(dist[j] > dist[minIdx]+graph[minIdx][j]){
dist[j]=dist[minIdx]+graph[minIdx][j];
}
}
}
return dist;
}
要点总结:
1.一个数组dist[]存储源点到所有点的路径;
2.一个数组used[]标志改点是否已经加入最短路径;
3.算法逻辑:先从未加入最短路径的点中选出一个距源点最近的点,然后根据这个选出的点更新距离
4.这里选点每个都是遍历,其实当数据量很大时,可以用堆优化,参考:743. 网络延迟时间
多源最短路径Floyd(待完成)
最小生成树(Prim、Kruskal/并查集实现)
-
Prim算法
思路:首先以任一结点作为最小生成树的初始结点,然后以迭代的方式找出最小生成树中各结点权重最小的边,并加到最小生成树中。(加入之后如果产生回路了就要跳过这条边,选择下一个结点)当所有的结点都加入到最小生成树中后,就找出了这个连通图的最小生成树 => 可看做每次主要是在选点
,即U=U∪(v);void Prim(G,T){ T=NULL; // 初始化MST为空 U={w}; // 添加任一顶点 while((V-U)!=NULL){ 设(u,v)是满足u属于U,v属于V,且权值最小的边; T=T∪(u,v); // 边归入树 (本质上还是将结点加入树) U=U∪(v); // 结点加入树 } }
复杂度:O(n^2),在两个点集U、V中分别遍历点进行组合故n方,适合稠密图
-
Kruskal算法
思路:Kruskal算法在找最小生成树结点之前,需要对权重从小到大进行排序。将排序好的权重边依次加入到最小生成树中(如果加入时产生回路就跳过这条边,加入下一条边),当所有的结点都加入到最小生成树中后,就找到了这个连通图的最小生成树 => 可看做每次主要是在选边
,即T=T∪(u,v);void Kruskal(V,T){ T=V; // 初始化T,仅含顶点 numS=n; // 连通分量数 while(numS>1){ 从E中取出权值最小的边(u,v); if(v和u属于T中不同的连通分量){ T=T∪(u,v); // 将边加入生成树中 nums--; // 连通分量数减一 } } }
复杂度:O(eloge),主要是边排序
-
Prim与Kruakal的区别
从策略上来说,Prim算法是直接查找,多次寻找邻边的权重最小值,而Kruskal是需要先对权重排序后查找的
所以说,Kruskal在算法效率上是比Prim快的,因为Kruskal只需一次对权重的排序就能找到最小生成树,而Prim算法需要多次对邻边排序才能找到 -
并查集实现Kruskal
思路
:1.计算所有边两两之间的距离; 2.然后根据距离对边排序;3.合并边,直到有n-1条边被合并且能构成连通图(边的两个顶点不在同一集合/连通分量才能合并);
参考题目(下面的题目都是直接给出边,故适合Kruskal):
1584. 连接所有点的最小费用
1489. 找到最小生成树里的关键边和伪关键边
二分图
- 概念
如果我们能将一个图的节点集合分割成两个独立的子集A和B,并使图中的每一条边的两个节点一个来自A集合,一个来自B集合,我们就将这个图称为二分图。 - 二分图的判断
染色法:如果给定的无向图连通,那么我们就可以任选一个节点开始,给它染成红色。随后我们对整个图进行遍历,将该节点直接相连的所有节点染成绿色,表示这些节点不能与起始节点属于同一个集合。我们再将这些绿色节点直接相连的所有节点染成红色,以此类推,直到无向图中的每个节点均被染色。 => 因此关键就是图的遍历问题,空间复杂度O(|V|),时间复杂度根据图的存储结构为O(|V|+|E|)或者O(|V|^2)
并查集:我们知道如果是二分图的话,那么图中每个顶点的所有邻接点都应该属于同一集合,且不与顶点处于同一集合。因此我们可以使用并查集来解决这个问题,我们遍历图中每个顶点,将当前顶点的所有邻接点进行合并,并判断这些邻接点中是否存在某一邻接点已经和当前顶点处于同一个集合中了,若是,则说明不是二分图。
相关题目:785. 判断二分图 - 二分图匹配
判断图中是否有环
三种思路:拓扑排序、DFS、并查集
参考:判断图中是否有环的三种方法
4.x 哈希表
概述
对于一个无序的数组 => 一般情况下,我们会首先把数组排序再考虑双指针技巧。TwoSum 启发我们,HashMap 或者 HashSet 也可以帮助我们处理无序数组相关的简单问题。
比如:1. 两数之和
哈希表的时间复杂度:不考虑hash冲突的话,插入、删除的时间复杂度O(1);即使有hash冲突,平均时间复杂度也是O(1)
hash应用之等概率随机取数(重要)
380. 常数时间插入、删除和获取随机元素 => 注意此题需要等概率且O(1)随机获取,所以不能直接使用hashSet存储元素; 而是用数组存元素,hashMap存下标
381. O(1) 时间插入、删除和获取随机元素 - 允许重复
hash应用之LRU、LFU
经典问题–LRU:146. LRU缓存机制 => 哈希表+双向链表
经典问题–LFU:460. LFU缓存=>多个哈希表+双向链表
hash经典练习题目
4.x 特殊数据结构及设计
LFU
- 题目概述
- 解法分析
LFU缓存 => 参考其中解法2
算法就像搭乐高:带你手撸 LFU 算法
上面两个参考解法,前者使用了两个hash,后者使用三个hash。问题的关键不是hash的个数,而是需要明白当缓存已满且最小频率对应多个数据时,淘汰掉的是最久未使用的节点,这个要求使得必须使用能满足时序关系的双向链表,从而查到最低频率后可O(1)删除最久未使用的节点(双向链表这一点和LRU是相通的)。
所以,问题的核心就是必须有一个:频率 —> 与该频率对应的双向链表 的hash映射。在此基础上,根据自己的设计思路,按需添加更多的hash表即可。