重温数据结构(基础知识)
为什么突然写数据结构的,因为算法的(重温算法(基础知识))实在写不下了
数组
前缀和与差分
前缀和
一维前缀和
前缀和指一个数组的某下标之前的所有数组元素的和(包含其自身)。前缀和分为一维前缀和,以及二维前缀和。前缀和是一种重要的预处理,能够降低算法的时间复杂度。可以快速地求出某一段的和。
【问题】输入一个长度为n
的整数序列。接下来再输入m
个询问,每个询问输入一对l
, r
。对于每个询问,输出原序列中从第l
个数到第r
个数的和。
我们很容易想出暴力解法,遍历区间求和。这样的时间复杂度为O(n * m)
,如果n
和m
的数据量稍微大一点就有可能超时,而我们如果使用前缀和的方法来做的话就能够将时间复杂度降到O(n + m)
,大大提高了运算效率。
【做法】
首先做一个预处理,定义一个sum[]
数组,sum[i]
代表a
数组中前i
个数的和。
1 | const int N = 1e5 + 10; |
对于每次查询,只需执行sum[r] - sum[l - 1]
,时间复杂度为O(1)
二维前缀和
和一维前缀和一样,我们需要构造一个前缀和数组s[i][j]。利用前缀和的思想:我们需要通过前面已知的前缀和来推出后面未知的前缀和。
从图中我们很容易看出,整个外围蓝色矩形面积s[i][j] = 绿色面积s[i - 1][j] + 紫色面积s[i][j - 1] - 重复加的红色的面积s[i - 1][j - 1] + 小方块的面积a[i][j]
; 因此得出二维前缀和预处理公式:
1 | s[i][j] = s[i - 1][j] + s[i][j - 1] + a[i] [j] - s[i - 1][j - 1] |
子矩阵的和1
2
3
4matrixSum(int x1,int y1,int x2,int y2){}
return s[x2][y2]-s[x2][y1-1]-s[x1-1][y2]+s[x1-1][y1-1];
}
// 求(x1,y1)[含]~(x2,y2)[含]的和。注意这里有-1!
异或前缀和
可以使用「前缀和」这一技巧对按位异或运算的结果进行维护
$pre(i,j)=pre(i−1,j)⊕pre(i,j−1)⊕pre(i−1,j−1)⊕matrix(i,j)$
当我们将$\textit{pre}(i-1, j)$和$\textit{pre}(i, j-1)$进行按位异或运算后,由于对一个数 x 异或两次 y,结果仍然为 x 本身,即:$x \oplus y \oplus y = x$
因此 $pre(i−1,j−1)$对应区域的按位异或结果被抵消,我们需要将其补上,并对位置 $(i,j)$ 的元素进行按位异或运算,这样就得到了$pre(i,j)$。
差分
所谓差分可以理解为前缀和的逆运算,就是将数列中每一项分别与前一项做差。
差分数组:
首先给定一个原数组 a:a[1], a[2], a[3]...... a[n];
然后我们构造一个数组 b : b[1], b[2], b[3]...... b[i];
使得 a[i] = b[1] + b[2] + b[3] + ...... + b[i]
也就是说,a数组是b数组的前缀和数组,反过来我们把b数组叫做a数组的差分数组。
换句话说,每一个a[i]都是b数组中从头开始的一段区间和。
如何构造差分数组?
a[0 ]= 0;
b[1] = a[1] - a[0];
b[2] = a[2] - a[1];
b[3] = a [3] - a[2];
……..
b[n] = a[n] - a[n - 1]
差分数组的性质:
1.对差分数组进行前缀和,就可以的到原数组
原数组 | a1 | a2 | a3 | a4 |
---|---|---|---|---|
差分 | a1 | a2-a1 | a3-a2 | a4-a3 |
差分前缀和 | a1 | a2 | a3 | a4 |
2.要对数组区间[ l, r ]
的数据都加上m时,我们只需要对差分数组进行操作即可
即 L到前一个元素的距离+c,R到后一个元素的距离-c
1 | b[l]+=c |
递推式:b[i][j]=a[i][j]-a[i-1][j]-a[i][j-1]+a[i-1][j-1]
改变——1
2
3
4b[x1][y1] += c ; 让整个a数组中蓝色矩形面积的元素都加上了c。
b[x1,][y2 + 1] -= c ; 让整个a数组中绿色矩形面积的元素再减去c,使其内元素不发生改变。
b[x2 + 1][y1] -= c ; 让整个a数组中紫色矩形面积的元素再减去c,使其内元素不发生改变。
b[x2 + 1][y2 + 1] += c ; 让整个a数组中红色矩形面积的元素再加上c,红色内的相当于被减了两次,再加上一次c,才能使其恢复。
例题:差分矩阵
输入一个n行m列的整数矩阵,再输入q个操作,每个操作包含五个整数x1, y1, x2, y2, c
,其中(x1, y1)和(x2, y2)表示一个子矩阵的左上角坐标和右下角坐标。
每个操作都要将选中的子矩阵中的每个元素的值加上c。
请你将进行完所有操作后的矩阵输出。
- 输入格式
- 第一行包含整数n, m, q。
- 接下来n行,每行包含m个整数,表示整数矩阵。
- 接下来q行,每行包含5个整数x1, y1, x2, y2, c,表示一个操作。
- 输出格式
- 共 n 行,每行 m 个整数,表示所有操作进行完毕后的最终矩阵。
注意是如何构建差分数组和得到新数组的!
- 共 n 行,每行 m 个整数,表示所有操作进行完毕后的最终矩阵。
1 |
|
尺取法
尺取法通常是指对数组保存一对下标(起点,终点),然后根据实际情况交替推进两个端点直到得出答案的方法
在一个不断变化和移动的区间内进行统计,符合尺取法的特点
给定长度为n的数列整数$a_0,a_1,…,a_N$以及整数S。求出总和不小于S的连续子序列的最小长度。如果解不存在,则输出0。
输入
n=10
S=15
a={5,1,3,5,10,7,4,9,2,8}
输出
2(解释:5+10)
尺取法可以表示为下面的过程
即,主要步骤为:
- 初始化时:左右指针都在0
- 只要当Sum<S, 那么右指针一直自增
- Sum>=S时候终止自增,长度存入ans里面(后续比较ans取最小)
- 尺子定型了,Sum中减去最开头那个,然后左指针自增,重复回到第二步骤
1 | void solve() |
线段树与树状数组
树状数组
先输入一个长度为n的数组,然后我们有如下两种操作:
- 输入一个数m,输出数组中下标1~m的前缀和
- 对某个指定下标的数进行值的修改
多次执行上述两种操作;
前缀和的效果就不行了,因为经过1.的时候必须更新前缀和。于n 次操作,时间复杂度就达到了O(n^2)和O(n),这样的方法就显得不适用了。
我们引入树状数组——如图,对于一个长度为n的数组,A数组存放的是数组的初始值,引入一个辅助数组C;
C1 = A1
C2 = C1 + A2 = A1 + A2
C3 = A3
C4 = C2 + C3 + A4 = A1 + A2 + A3 + A4
C5 = A5
C6 = C5 + A6 = A5 + A6
C7 = A7
C8 = C4 + C6 + C7 + A8 = A1 + A2 + A3 + A4 + A5 + A6 + A7 + A8
我们称C[i]
的值为下标为i
的数所管辖的数的和。下标为i
的数所管辖的元素的个数为$2^k$个(k
为i
的二进制的末尾0的个数)
i = 8 = 1000
,末尾3个0,故k = 3
,所管辖的个数为$2^3=8$,C8
是8个数的和;i = 5 = 0101
,末尾没有0,故k = 0
,所管辖的个数为$2^0=1$,C5
是一个数的和;
[补充]如何计算一个非负整数n在二进制下的最低为1及其后面的0构成的数?
所以lowbit(x) = x&(-x)
单点修改,区间查询
我们在单点修改的同时,更新父节点就变得尤为简单,,例如我们对a[1]+k,那么祖先节点t[1],t[2],t[4],t[8]都需要+k更新(因为t[]表示前缀和),此时我们就可以用lowbit操作实现.
1 | int add_dandian(int x,int k) |
修改完成,如何进行区间查询?我们需要查询前7项的区间和sum[7]
sum[7]=t[7]+t[6]+t[4] ,我们进一步发现,6=7-lowbit(7),4=6-lowbit(6),所以我们可以通过不断的-lowbit操作来实现求和
1 | int ask(x){ |
这只能求区间[ 1 , x ] 的区间和,那么如何求 [ L , R ] 的区间和呢? [1,x]的区间和,那么如何求[L,R]的区间和呢?[1,x]的区间和,那么如何求[L,R]的区间和呢?,这时候利用前缀和相减的性质就可以了——$[L,R]=[1,R]−[1,L−1]$
1 | int search(int L,int R) |
区间修改,单点查询
对于这一类操作,我们需要构造出原数组的差分数组b,然后用树状数组维护b数组即可
对于区间修改的话,我们只需要对差分数组进行操作即可,例如对区间[L,R]+k,那么我们只需要更新差分数组add(L,k),add(R+1,-k)
对于单点查询操作,求出b数组的前缀和即可,因为a[x]=差分数组b[1]+b[2]+…+b[x]的前缀和,这是差分数组的性质之一.
1 | ll ask(int pos)//返回区间pos到1的总和 |
线段树
线段-区间-树
- 建树规则
- 用分治法自顶向下建立,每次分治,左右子树各一半。
- 每个节点都表示一个“线段”区间,非叶子节点包含多个元素,叶子节点只包含一个元素。
- 除了最后一层,其他层都是满的。
- l=r,说明这是一个叶子节点。
- l<r,说明他有两个子节点,左儿子[l,m],右儿子[mid+1,r],其中m=(l+r)/2;
1 | // 建立线段树 |
单点修改:
1 | // 修改树 |
区间修改:
我们引进一个新玩意:lazy-tag。tag[i]记录了区间i的修改,这样就不用一个一个的再去修改去区间的内的每个元素了。
当我们进行修改时,先只对这个线段区间上进行整体上的修改,其内部每个元素的值先不修改。只有当查找到的区间不包含在给定的区间时(即l
1 | void down(int l,int r,int o) |
第二个else不太好理解哈,
我希望[1,2]都加1,根据我们的代码:
(1)从整棵树的根节点开始,递归至左子树[1,2],被修改区间[1,2]完全覆盖,于是sum[2]+=1*(2-1+1)=3,tag[o]+=1=1
这样以来,节点2([1,2]区间)的和就成了5,但节点4和5的值还没修改。
lazy-tag正如它的名字一样——懒标记(延时标记),他不会立刻去一次性的把被修改区间的值全修改掉,而是从一个符合条件的节点开始,当需要的时候他才会去修改被标记节点下面的值。
传递函数down()是处理lazy-tag的一个技巧。在进行多次区间修改时,一个节点需要记录多个区间修改,而这些区间修改往往有冲突。例如,做两次区间修改,一次是[4,9],一次是[5,8],它们都会影响5:[4,5]这个节点。第1次修改区间[4,9]覆盖了节点5,用tag[5]做了记录;而第⒉次修改区间[5,8]不能覆盖节点5,需要再向下搜索到节点11:[5,5],从而破坏了tag[5],此时原tag[5]记录的区间统一修改就不得不向它的子节点传递和执行了,传递后tag[5]失去了作用,需要清空。所以,lazy-tag 的主要操作是解决多次区间修改,用down()函数完成。首先检查节点p的tag[p],如果有值,说明前面做区间修改时给p打了tag标记,接下来就把 tag[p]传给左右子树,然后把 tag[p]清零。
- 完全覆盖
- 部分覆盖
- 当mid>=L也就是L与左子树有交集时,我们去递归左子树
TBD 真心看不懂
其他线性结构
栈
单调栈
当题目出现「找到最近一个(左侧/右侧)比其大的元素」的字眼时,自然会想到「单调栈」。大多数情况用于求解出的结果明显的呈现出V或者^形式的问题。其意思是栈内的元素是单调的。根据栈内元素的单调性的不同,可以分为:
- 单调递增栈:栈内元素是单调递增的栈。
- 单调递减栈:栈内元素是单调递减的栈。
完全单调栈
1 | 遍历下面的数组元素维护一个递减栈: |
图解
以单调递增栈为例子——
即一旦找到比栈顶更大的,就弹出来
最终——
例题
【下一个更大元素 I】
nums1 中数字 x 的 下一个更大元素 是指 x 在 nums2 中对应位置 右侧 的 第一个 比 x 大的元素。
给你两个 没有重复元素 的数组 nums1 和 nums2 ,下标从 0 开始计数,其中nums1 是 nums2 的子集。
对于每个 0 <= i < nums1.length ,找出满足 nums1[i] == nums2[j] 的下标 j ,并且在 nums2 确定 nums2[j] 的 下一个更大元素 。如果不存在下一个更大元素,那么本次查询的答案是 -1 。
返回一个长度为 nums1.length 的数组 ans 作为答案,满足 ans[i] 是如上所述的 下一个更大元素 。
示例 1:
输入:nums1 = [4,1,2], nums2 = [1,3,4,2].
输出:[-1,3,-1]
解释:nums1 中每个值的下一个更大元素如下所述:
- 4 ,用加粗斜体标识,nums2 = [1,3,4,2]。不存在下一个更大元素,所以答案是 -1 。
- 1 ,用加粗斜体标识,nums2 = [ 1 ,3,4,2]。下一个更大元素是 3 。
- 2 ,用加粗斜体标识,nums2 = [1,3,4,2]。不存在下一个更大元素,所以答案是 -1 。
示例 2:
输入:nums1 = [2,4], nums2 = [1,2,3,4].
输出:[3,-1]
解释:nums1 中每个值的下一个更大元素如下所述: - 2 ,用加粗斜体标识,nums2 = [1,2,3,4]。下一个更大元素是 3 。
- 4 ,用加粗斜体标识,nums2 = [1,2,3,4]。不存在下一个更大元素,所以答案是 -1 。
1 | class Solution { |
为什么是从后向前遍历:因为求得是对应元素 下一个 得更大元素。下一个靠右,所以需要先遍历右边得
为什么是单调递增栈:因为是求更大得
前缀和+单调栈
普通前缀和
前缀和是一个保证能够递增的数据结构,我们可以与之和单调栈结合
和至少为k的连续子数组
给你一个整数数组 nums 和一个整数 k ,找出 nums 中和至少为 k 的 最短非空子数组 ,并返回该子数组的长度。如果不存在这样的 子数组 ,返回 -1 。
子数组 是数组中 连续 的一部分。
示例 1:
输入:nums = [1], k = 1
输出:1
示例 2:
输入:nums = [1,2], k = 4
输出:-1
示例 3:
输入:nums = [2,-1,2], k = 3
输出:3
1 | class Solution { |
如果遇到与栈的性质相反的,需要弹栈,看图2
所以:单调栈的核心在于——及时移除无用数据,保证队列/栈的有序性。
单调栈求跨度
核心思路:遍历一遍,生成一个单调递减(增)栈,保存从左到右出现的最小(大)值的下标
从右到左,查找比当前单减栈栈顶元素大的第一个数,更新最大跨度
普通跨度
参考力扣-最大宽度坡
1 | class Solution { |
变式前缀和+最长最宽
如果要形成V型或者类似于V的,可以先求出↘的最低端,然后找到↗最大点
1124. 表现良好的最长时间段
给你一份工作时间表 hours,上面记录着某一位员工每天的工作小时数。
我们认为当员工一天中的工作小时数大于 8 小时的时候,那么这一天就是「劳累的一天」。
所谓「表现良好的时间段」,意味在这段时间内,「劳累的天数」是严格 大于「不劳累的天数」。
请你返回「表现良好时间段」的最大长度。注意:时间段不一定从0开始
示例 1:
输入:hours = [9,9,6,0,6,6,9]
输出:3
解释:最长的表现良好时间段是 [9,9,6]。
先把问题转换到我们熟悉的东西上。
「劳累天数大于不劳累天数」等价于「劳累天数减去不劳累天数大于 000」。
那么把劳累的一天视作 nums[i]=1,不劳累的一天视作 nums[i]=−1,则问题变为:
计算 nums的最长子数组,其元素和大于 0。
即:
找到两个下标 i 和 j,满足 j<i且 s[j]<s[i],最大化 i−j 的值。
1 | class Solution { |
字符串
KMP
Everything is a state machine
我们可以以状态机的形式重新审视整个匹配过程
要匹配的过程形成了一种链式结构,每个结点都是当前匹配的状态,如果再当前结点的下一个结点不同,我们走的路径也不同
因此:
KMP 算法最关键的步骤就是构造这个状态转移图。要确定状态转移的行为,得明确两个变量,一个是当前的匹配状态,另一个是遇到的字符;确定了这两个变量后,就可以知道这个情况下应该转移到哪个状态。
1 | dp[j][c] = next |
核心流程是
1 | // 伪代码 |
现在的首要目标就是构造dp,很简单
1 | for 0 <= j < M: # 状态 |
这个 next 状态应该怎么求呢?
- 显然,如果遇到的字符
c
和pat[j]
匹配的话,状态就应该向前推进一个,也就是说next = j + 1
,我们不妨称这种情况为状态推进 - 如果字符
c
和pat[j]
不匹配的话,状态就要回退(或者原地不动),我们不妨称这种情况为状态重启- 所谓影子状态,就是和当前状态具有相同的前缀。比如下面这种情况:
- 当前状态
j = 4
,其影子状态为X = 2
,它们都有相同的前缀 “AB”。因为状态X
和状态j
存在相同的前缀,所以当状态j
准备进行状态重启的时候(遇到的字符c
和pat[j]
不匹配),可以通过X
的状态转移图来获得最近的重启位置。 - 如果状态
j
遇到一个字符 “A”,应该转移到哪里呢?首先只有遇到 “C” 才能推进状态,遇到 “A” 显然只能进行状态重启。状态j
会把这个字符委托给状态X
处理,也就是dp[j]['A'] = dp[X]['A']
:那么j
就可以去问问和自己具有相同前缀的X
,如果X
遇见 “A” 可以进行「状态推进」,那就转移过去,因为这样回退最少。
- 所谓影子状态,就是和当前状态具有相同的前缀。比如下面这种情况:
1 | public KMP(String pat) { |
next数组
例:‘ababa’
- ‘a’的前缀和后缀都为空集,最长相等前后缀长度为0 。
- ‘ab’的前缀为{a},后缀为{b},最长相等前后缀长度为0 。
- ……
- ‘ababa’的前缀:{a,ab,aba,abab},后缀:{a,ba,aba,baba},最长相等前后缀长度为3。
子串跳过最后一个比对成功的next数组,j到达子串末尾匹配成功
特点:主串指针永不回退!
伪代码
代码
1 | vector<int> commpute_next(string pattern){ |
compute_next可以更高效,请参考文章实现
1 | vector<int> commpute_next(string pattern){ |
或者
1 | void getNext(char* s,int len){ |
树
前缀树
并查集
(实际是数组,但借助树的思想)
用于处理一些元素分组的问题。它主要用于处理一些不交集的合并及查询问题 (不交集指的是一系列没有重复元素的集合。)。在计算机科学中,并查集算法常常用于解决连通性问题,如判断图中的两个节点是否属于同一个连通分量,或者判断一个无向图是否是连通图等。解决一些父节点与子节点之间的关系,重要的就是查和并
简单来说,并查集就是用来处理集合的合并和集合的查询。
- 并查集中的「集」指的就是我们初中所学的集合概念,在这里指的是不相交的集合,即一系列没有重复元素的集合。
- 并查集中的「并」指的就是集合的并集操作,将两个集合合并之后就变成一个集合。合并操作如下所示:
{1, 3, 5, 7} ∪ {2, 4, 6, 8} = {1, 2, 3, 4, 5, 6, 7, 8}
「并查集」结构所支持的操作接口:
- 合并
union(x, y)
:将集合x
和集合y
合并成一个集合。 - 查找
find(x)
:查找元素x
属于哪个集合。 - 查找
is_connected(x, y)
:查询元素x
和y
是否在同一个集合中。
【步骤】
(1)初始化。定义一维数组int s[],s[i]是结点i的并查集,开始的时候,还没有处理点与点之间的朋友关系,所以每个点属于独立的集,直接初始化s[i] = i,例如结点1的集s[1] = 1。
(2)合并,例如加入第一个朋友关系(1, 2)。在并查集s中,把结点1合并到结点2,也就是把结点1的集1改成结点2的集2,set[1] = set[2] = 2。此时5人的关系用4个集表示,其中set[2]包括2个结点。
3)继续合并,加入第二个朋友关系(1, 3)。
首先查找结点1的集,是2,set[1] = 2,再继续查找2的集,是set[2] = 2,此时2的集是自己,查找结束。再查找结点3的集是set[3] = 3。由于set[2]不等于set[3],下面把结点2的集2合并到结点3的集3。具体操作是修改set[2] = 3,此时,结点1、2、3都属于一个集:set[1] = 2、set[2] = 3、set[3] = 3。还有两个独立的集set[4] = 4、set[5] = 5。
(5)查找。查找某个结点属于哪个集。这是一个递归的过程,例如找结点1的集,递归步骤是:set[1] = 2、set[2] = 3、set[3] = 4、set[4] = 4
,最后结点的值和它的集相等,就找到了根结点的集。
(6)统计。统计有几个集。只要检查有多少结点的集等于自己,就有几个集。如果s[i] = i,这是一个根结点,是它所在的集的代表;统计根结点的数量,就是集的数量。上面的图示中,只有set[4] = 4、set[5] = 5
,有2个集。
例子——
【题目描述】蓝桥幼儿园的学生天真无邪,朋友的朋友就是自己的朋友。小明是蓝桥幼儿园的老师,这天他决定为学生们举办一个交友活动,活动规则如下:小明用红绳连接两名学生,被连中的两个学生将成为朋友。小明想让所有学生都互相成为朋友,但是蓝桥幼儿园的学生实在太多了,他无法用肉眼判断某两个学生是否为朋友。请你帮忙写程序判断某两个学生是否为朋友。
【输入描述】第1行包含两个正整数N,M,其中N表示蓝桥幼儿园的学生数量,学生的编号分别为1∼N。之后的第2∼M+1 行每行输入三个整数op,x,y。如果op = 1,表示小明用红绳连接了学生x 和学生yy。如果op=2,请你回答小明学生x和学生y是否为朋友。1≤N,M≤2×105,1≤x, y≤N。
【输出描述】对于每个op=2的输入,如果x和y是朋友,则输出一行YES,否则输出一行NO。
1 |
|
代码超时!合并的时候,树变成了一个链表形状,出现了“退化”。下面用路径压缩来解决退化问题。
路径压缩
- 在上面的查询程序
find_set()
中,查询元素i所属的集,需要搜索路径找到根结点,返回的结果是根结点。这条搜索路径可能很长,导致超时。 - 如何优化?如果在返回的时候,顺便把i所属的集改成根结点,那么下次再查的时候,就能在
O(1)
的时间内得到结果。由于find_set()
是一个递归函数,在返回的时候,整个递归路径上的点,它们的集都改成了根结点。如下图所示,查询点1的集时,把路径上的2、3所属的集一起都改成了4,最后所有的结点的集都是4,下次再查询某个点所属的集,只需查一次。
1 | int find_set(int x){ |
刚刚例题不超时代码
1 |
|
找共几类
找一共有几类
1 | unordered_set<int> Sets; |
找每个集合有多少
通过setSum数组,在合并时双方都增加
1 | void init_set() |
你总是忘记init
最小生成树和最大生成树
生成树(spanning tree) :一个连通无向图的生成子图,同时要求是树。也即在图的边集中选择n-1条,将所有顶点连通。
最小生成树
kruskal实现
核心思想:从小到大加入边,是个贪心算法。也接住了并查集的思路
1 |
|
最大生成树
- 方法1:将边权从大到小排序。
- 方法2:将所有边权取反(负数)
LCA 最近公共祖先
基本方法
找两个结点的最近公共祖先
在找a和b的LCA时,先让a和b中深度较大的那一个,向上回溯到与另一个同样深度的位置上。例如下面这个图:
假设要找F和E的LCA,先让F向上回溯到他的父节点D的位置,这样就与E处在同一深度了。然后让他们同时向上回溯(跳到他们的父节点上),直到两者相遇。相遇时所到达的那个点就是他们的LCA。这种方法很容易理解也很好实现,只需要对树进行一次深度搜索,在搜索过程中将每个点的父节点和他的深度保存下来即可进行上述的查询操作了,结合查询函数代码理解一下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18//fa表示每个点的父节点,deep表示每个点的深度
int fa[100],deep[100];
int LCA(int a,int b)
{
//在函数中确保a的深度大于b的深度,方便后面操作。
if(deep[a]<deep[b])
swap(a,b);
//让b不断地跳到他的父节点上,直到与a的深度相同
while(deep[a]>deep[b])
b=deep[b];
//让a和b同时往上跳,直到两者相遇。
while(a!=b)
{
a=deep[a];
b=deep[b];
}
return a;
}
而这种方法最大的问题就是运行太慢了,只能一步一步的往上跳,在很多场景下是不可取的。下面介绍倍增法,这种方法与上面的思路基本一样,但不是一步一步的向前跳
倍增法
关键理论:
- 将2的次幂排成一个序列,即1,2,4,8,16,32……在这个序列中取任意个不相等的数,相加可以得到所有正整数。例如5=1+4;10=2+8;20=4+16,也就是每个正整数都可以拆分成多个2的幂次数。拆分方法也很简单:例如20这个数,先找最接近但不大于20的一个2的幂次数,即16,然后20减去16等于4。再找最接近但不大于4的一个2的幂次数,即4。然后4-4=0,拆分结束,得到了16和4。
- 如果c是a和b的LCA,那么c的所有祖先同样也是a和b的公共祖先,但不是最近的。
倍增法中的ST
在ST算法中,我们维护了一个数组DP[i][j]
,表示的是以下标为i为起点的长度为2^j
的序列的信息。然后用动态规划的思想求出整个数组。刚才在上面说我们求LCA时一次要跳2的幂次方层,这就与DP数组中下标 j 的定义不谋而合了。
所以我们定义倍增法中的DP[i][j]
为:结点 i 的向上 2^j 层的祖先。
DP[4][1]=1;结点4的向上2^1=2层的祖先是结点1。
【递推式】DP[i][j] = DP[ DP[i][j-1] ] [j-1]
:DP[i][j-1]
是结点i往上跳2^(j-1)层的祖先,那我们就在跳到这个结点的基础上,再向上跳2^(j-1)层,这样就相当于从结点i,先跳2^(j-1)层,再跳2^(j-1)层,最后还是到达了2^j层。
1 | //fa表示每个点的父节点 |
查询函数
- 假设a和b的深度相差5,我们需要让b向上跳,步步逼近a所在的深度,直到与a同深度。如何选取这个步长呢?两个原则:
1、选取的步长肯定不能大于二者的深度差,否则b的深度就小于a的了;
2、选择最接近深度差但又不大于深度差的2的幂次数。这使我们每一步都不会超出a,而且步步逼近a。
那么我们要做的就是本着这两个原则,根据二者的深度差来选取合适的步长,步步逼近。根据正整数拆分理论,不管深度差是多少,二者最后一定能处于同一深度。 - 第二次回溯与第一次不同的是:第一次回溯,已经知道了要跳多少层,所以就可以用正整数拆分理论选择步长。而这次回溯是要找LCA,即找一个层数使a和b跳上去之后正好相遇,也就是说我们只能试探着往上跳,步步逼近。
那我们如何选取a和b同时向上跳的步长?这里用到了我们刚才说的第二个关键理论:若c是a和b的LCA,则c的祖先也是a和b的祖先,但不是最近的。所以这里选取步长的原则就是:大胆地、试探性地往上跳。
可能出现两种情况:
1、若跳到了某一层后a和b相遇了,则说明相遇处的结点就是a和b的公共祖先,但不一定是最近的。这个点就告诉我们:LCA可能还在这个点的下方。那我们就不往这个点上跳,因为这个点有可能不是我们要找的LCA。
2、若跳到了某一层后,a和b没有相遇,则说明a和b的LCA在这层之上,那我们完全可以跳到这一层上,这会使我们步步逼近最终的LCA。根据正整数拆分理论,我们最后也一定能找到LCA。
第二次回溯。换个角度讲,假设我们事先知道LCA与a、b差10层,那么我们如果一步跳了10层以上的话,肯定会跳到LCA的祖先上,那我们就减少步长。如果一步跳8层的话,a和b肯定没有相遇,这时我们就可以跳上来。然后LCA与a、b就差两层了。虽然再跳两层就到了,但是程序只知道这是a和b的公共祖先,但不知道这是不是最近公共祖先,而我们只是开了上帝视角知道了而已,所以程度就会放弃2这个步长,还会将步长减小为1并跳上去。当步长减小为1时,这个试探的过程就可以结束了,因为LCA肯定就是此时a和b的父节点。
1 | //查询函数 |
链式前向星(加快图的搜索)
存储一个图通常有两种方式:邻接矩阵和邻接表。如果一个图中的边非常少,那么邻接矩阵就比较稀疏,浪费空间,如果点非常多,则矩阵的内存有可能会爆掉。用向量实现的邻接表在点非常多的时候同样比较慢,在一些题目中会超时。链式前向星是在邻接表基础上的一种优化,其优秀的时空复杂度可以帮助我们在一些边和点都比较多情况下加快对图的遍历。例如DFS、BFS等。我们可以结合DFS的过程来理解链式前向星。
DFS算法的实现过程可以这样理解:
- 以当前点作为起点,在所有与该起点连接着的边中随便找一条,然后跳到这条边的终点上。
- 再将当前跳到的点当做起点,重复1。
- 若跳到某一点后,没有以这个点为起点的边了,就原路返回到之前的起点上,找一条与这条不同的边,再跳到它的终点上。
也就是说,DFS对两件事感兴趣:一是边的终点是什么,二是一个点连接了多少条不同的边。
【数据结构】1
2
3
4
5struct node
{
int to;
int next;
}edge[maxn];
链式前向星以边为单位进行储存。其中,成员to表示这条边的终点,而next就比较重要了,表示跟本条边的起点相同的前一条边,在edge数组中的下标,如果这条边的起点是第一次出现的,则置为0。也就是说,链式前向星的next属性,像链表一样,将图中起点相同的边连在了一起。例如下面这个图:
我们输入边的顺序为:
1 2
2 3
3 4
4 5
1 3
1 5
4 1
那么我们得到的edge数组为:
下标 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
起点 | 1 | 2 | 3 | 4 | 1 | 1 | 4 |
to | 2 | 3 | 4 | 5 | 3 | 5 | 1 |
next | 0 | 0 | 0 | 0 | 1 | 5 | 4 |
以下标为5来讲,建立<1,3>边,而<1,2>边具有相同的起点,其下标为1,所以<1,3>的next为1.1,3>1,2>1,3>
当我们想要得到一条边的终点时,就调用edge[i].to,当我们想得到这个起点连接的其他边时,就可以调用edge[i].next。那么现在的问题就是如何快速求next属性。
解决方法就是再定义一个数组head,head[i]表示最近一次输入的以i为起点的边在edge数组中的下标。
【建立图】1
2
3
4
5
6
7
8
9
10
11
12
13
14struct node
{
int to;
int next;
}edge[maxn];
int head[maxn];
int cnt=1;//表示edge数组的下标,也可以表示已经存入的边数
void add(int from,int t)
{
edge[cnt].to=t;// 终点下标
edge[cnt].next=head[from];// 上一条相同起点边的下标
head[from]=cnt;// 更新head
cnt++;
}
【遍历图】
- DFS
核心for(int i=head[x];i!=0;i=edge[i].next)
在对某一点所连接的所有边的遍历过程中,调用edge[i].next
,就像链表一样,将所有起点相同的边都串在了一起。而且,最先输入的边会最晚遍历到,这是由next的属性所造成的。
1 |
|
- BFS
1 |
|
最终代码
1 |
|
图
最短路径
单源最短路径
即起点固定已经知道了。(终点可以查询)
无边权
即每条边的权相同
1 | // myEgde时一个类似于二维数组的东西,myEgde[now]表示以now为起点的所有终点的集合。 |
迪杰斯特拉(有边权)
1 |
|
SPFA
1 |
|
多源最短路
即起点和终点的目标每次不一样,对同一个图有多次查询。使用Floyd
算法
1 |
|
联通分量与桥、环
Tarjan算法
如果某一个子图的任意两点都是连通(注意:有向图需要双向联通,或者是环) 的则该子图是一个强连通分量。
在一个顶点数为n的图中,Tarjan算法需要维护一个顶点栈和几个数组。
stack S
:表示顶点栈。dfn[i]
:表示第i个顶点的深度搜索次序。(可以理解为遍历到这个点的时间)low[i]
:表示顶点i回溯时所能回溯到的最小dfn。(最早遍历时间)- 从当前节点 x 作为搜索树的根节点出发,能够访问到的所有节点中,时间戳最小的值 —— low[x]
- 什么是“能够访问到的所有节点”?,其需要满足下面的条件之一即可:
- 以 x 为根的搜索树的所有节点
- 通过一条非搜索树上的边,能够到达搜索树的所有节点
- 什么是搜索树:在无向图中,我们以某一个节点 x 出发进行深度优先搜索,每一个节点只访问一次,所有被访问过的节点与边构成一棵树,我们可以称之为“无向连通图的搜索树”。
- 在上面的计算过程中,我们可以认为以序号 2 为根的搜索树的节点有 {2,3,4,5}。上面所说的“通过一条非搜索树上的边”可以理解成动画中的(1,5)这条边,“能够到达搜索树的所有节点”即为节点 1。
visit[i]
:表示顶点i是否在栈中。color[i]
:表示顶点i在哪一个强连通分量,相同的强连通分量染色相同。
桥的判定
在一张无向图中,判断边 e (其对应的两个节点分别为 u 与 v)是否为桥,需要其满足如下条件即可:dfn[u] < low[v]
它代表的是节点 u 被访问的时间,要优先于(小于)以下这些节点被访问的时间 —— low[v]
- 以节点 v 为根的搜索树中的所有节点
- 通过一条非搜索树上的边,能够到达搜索树的所有节点
1 | class Solution { |
视频)
上图黄色的为回溯的部分
理解:为什么需要parent
?
比如现在访问到7->5,那么5就不要深度遍历7了,无向图在这里不能反向。
理解:为什么low[node] = min(low[e], low[node]);
?
比如现在访问到7->5,那么5继续访问到6,发现,6是已经访问过的,那么走low[node] = min(low[node], dfn[e]);
;然后low[5]
就被置为dfn[6]=1
了,继续回溯,(走黄色部分)到7,low[7] = min(low[7], low[5]);
然后就统一了。
在回溯的时候,就已经更新了,所以可以马上判断是否是桥!
经过实战,在判断
桥
时,统一写为min(low[node], low[e]);
也没有问题。
割点
1 | for (int i=1;i<=n;i++) |
为什么这里判断割点,条件稍微有点不一样?
- 首先,
low[to] > dfn[u]
,肯定算割点(因为都是桥了)(u!=fa) low[to]=dfn[u]
:满足这个情况的有两类点- 即最开始的根结点。比如下图,如果从3开始算的话,3本身就是割点!(注意:图示从1开始算)
- 或者一个桥,的另一边,如上一节桥的6号结点(u!=fa)
联通分量的判定
1 | void Tarjan(int u){ |
拓扑排序
拓扑排序思路:
(1)统计所有点的入度。所有入度为0的点入栈,
(2)对栈里面的点进行处理:出栈,邻接表里面点的入度-1,如果-1后为0邻接表相应点入栈。
(3)重复上述,直到栈空,统计过程中遍历到的点,如果len(res) ==n
,则代表所有点都有遍历到
基于DFS的拓扑算法
对于图中的任意一个节点,它在搜索的过程中有三种状态,即:
- 「未搜索」:我们还没有搜索到这个节点;
- 「搜索中」:我们搜索过这个节点,但还没有回溯到该节点,即该节点还没有入栈,还有相邻的节点没有搜索完成);
- 「已完成」:我们搜索过并且回溯过这个节点,即该节点已经入栈,并且所有该节点的相邻节点都出现在栈的更底部的位置,满足拓扑排序的要求。
通过上述的三种状态,我们就可以给出使用深度优先搜索得到拓扑排序的算法流程,在每一轮的搜索搜索开始时,我们任取一个「未搜索」的节点开始进行深度优先搜索。
我们将当前搜索的节点 uu 标记为「搜索中」,遍历该节点的每一个相邻节点 vv:
- 如果 v为「未搜索」,那么我们开始搜索 v,待搜索完成回溯到 u;
- 如果 v 为「搜索中」,那么我们就找到了图中的一个环,因此是不存在拓扑排序的;
- 如果 v 为「已完成」,那么说明 v 已经在栈中了,而 u 还不在栈中,因此 u 无论何时入栈都不会影响到 (u, v)(u,v) 之前的拓扑关系,以及不用进行任何操作。
当 u 的所有相邻节点都为「已完成」时,我们将 u 放入栈中,并将其标记为「已完成」。
在整个深度优先搜索的过程结束后,如果我们没有找到图中的环,那么栈中存储这所有的 n 个节点,从栈顶到栈底的顺序即为一种拓扑排序。
基于BFS
1 | class Solution { |