文章目录
倍增,二分,记忆化二分(1)——ST稀疏表
一,引言
1.1二分插入排序
首先,先来复习一下归并排序,其原理非常简单。就是从一组无序的数组中,依次抽取其中的数,将他们按顺序在一个新的空间数组中进行排列。大致分为两步:
1.抽取
2.插入到合适的位置。
其中抽取的过程是无法进行优化的 O ( n ) O(n) O(n),我们对插入的过程进行优化,非常简单,考虑到新数组的有序性,只要将原来的依次比较找到插入位置,转化为与中间位置比较,然后递归寻找插入位置即可 O ( l o g 2 n ) O(log_2n) O(log2n)。
这样时间复杂度就是 n l o g n nlogn nlogn了。
def binary_insertion_sort(arr):
for i in range(1, len(arr)):
key = arr[i]
left, right = 0, i - 1
# 使用二分查找找到插入位置
while left <= right:
mid = (left + right) // 2
if key < arr[mid]:
right = mid - 1
else:
left = mid + 1
# 插入元素到正确位置
for j in range(i, left, -1):
arr[j] = arr[j - 1]
arr[left] = key
# 示例用法
arr = [12, 11, 13, 5, 6]
binary_insertion_sort(arr)
print("排序后的数组:", arr)
1.2倍增思想( 二 分 − 1 二分^{-1} 二分−1)
倍增(Binary Exponentiation),也称为快速幂算法,是一种用于高效计算幂次方的算法。它的核心思想是利用分治的方法来降低计算幂的复杂度。倍增算法通常用于计算 a^n,其中 a 是一个数字,n 是一个非负整数。
传统的方法是将 a 乘以自身 n-1 次,但这会导致 O(n) 的时间复杂度,因为需要进行 n-1 次乘法运算。倍增算法可以将这个复杂度降低到 O(log n)。
倍增的基本思路是将指数 n 分解成二进制表示,然后利用这个二进制表示的每一位来表示 a 的不同幂次。例如,如果 n = 13,它的二进制表示是 1101,那么 a^13 可以表示为 a(20) * a(22) * a(23)。通过迭代计算,可以在 O(log n) 的时间内计算出结果。
def binary_exponentiation(a, n):
result = 1
while n > 0:
if n % 2 == 1: # 如果 n 的最低位为 1
result *= a
a *= a # 将底数平方
n //= 2 # 将指数右移一位
return result
这样看起来,倍增就是从1开始,通过迭代的方式迅速的扩增。如果说二分是不断地/2 ,那么倍增就是不断地*2 ,简称 二 分 − 1 二分^{-1} 二分−1。
二,二分思想
2.1
从上面两个例子,我们可以看出,二分的运用将原本的 O ( n ) O(n) O(n)的时间复杂度变为了 O ( l o g n ) O(log n) O(logn),这大大减少了运行时间。下面我们来具体了解一下二分思想.
2.2 介绍
二分查找是一种基于二分思想的搜索算法,它用于在有序数据集中查找特定元素的位置。这种思想的核心是将数据集划分为两部分,然后根据所需的元素与中间元素的大小关系来确定目标元素可能在哪一部分。这个过程不断重复,直到找到目标元素或确定它不存在。
以下是二分查找的一般步骤:
-
初始化:首先,确定查找的范围,通常是整个数组。设置左指针为数组的起始位置,右指针为数组的结束位置。
-
查找中间元素:计算中间元素的索引,通常为
(左指针 + 右指针) / 2
。 -
比较中间元素:将中间元素与目标元素进行比较。
- 如果中间元素等于目标元素,找到了目标,返回中间元素的索引。
- 如果中间元素大于目标元素,说明目标元素位于左半部分,将右指针移至中间元素的前一个位置。
- 如果中间元素小于目标元素,说明目标元素位于右半部分,将左指针移至中间元素的后一个位置。
-
重复:重复步骤2和步骤3,直到左指针大于等于右指针。如果左指针超过右指针,说明目标元素不存在于数组中。
-
结束:返回找到的目标元素的索引,或者返回不存在的标志。
2.3 应用
二分查找是一种高效的搜索算法,因为它在每次比较后可以将搜索范围减半。它的时间复杂度为O(log n),其中 n 是数据集的大小。这使得它特别适用于大型有序数据集的查找操作,例如在数据库索引、排序数组、和搜索树中。
二分思想也不仅仅用于查找,它在算法和数据结构中有许多其他应用,例如二分插入排序、分治算法、以及一些数学问题的解决方法。这个思想的核心概念是将问题拆分成更小的部分,并在每一步中减少搜索或处理的范围,从而提高算法的效率。
三,相关的数据结构
3.1 汇总
一个好的算法通常需要一个优秀的数据结构与其相匹配,为了充分利用二分的思想,人类发明了许多有趣的数据结构。那么,接下来由我来为大家做一个简要的介绍:
-
ST
-
树状数组
-
线段树
这是目前阶段我在学习的两个,当然肯定不止这些。
3.2 ST稀疏表
3.2.1 ST官方解读
ST稀疏表(Sparse Table) 是一种用于高效处理区间查询问题的数据结构。它通常用于解决具有静态数据集的区间查询问题,例如查找一个数组或序列中某一范围内的最小值、最大值、和、平均值等问题。
ST稀疏表的主要思想是预处理数组的数据,以便快速回答查询问题,同时保持较低的查询时间复杂度。其基本思想是将数据集分成不重叠的、连续的区间块,然后在每个块内预计算出一些信息。这些信息可以是区间内的最小值、最大值、和、平均值等,具体取决于问题的性质。
以下是ST稀疏表的基本步骤:
-
数据预处理:将原始数据分成不重叠的块,通常是2的幂大小(例如,2^0, 2^1, 2^2, …),并计算每个块内的预计算信息。这可以使用动态规划的方式完成。
-
构建稀疏表:构建一个二维表格,其中第i行表示以第i个元素开始的块的信息。每一列的内容是两个相邻块的信息的组合,通常是区间内的最小值、最大值、和、平均值等。
-
查询:对于区间查询,例如查找区间[l, r]内的最小值,可以利用构建好的稀疏表格,在O(1)时间内回答查询。查询的答案是区间内每个块的信息的组合。
ST稀疏表的优点是能够在预处理时以O(n log n)的时间复杂度构建表,然后在O(1)的时间内回答区间查询,这对于需要大量的区间查询操作的静态数据集非常高效。然而,它通常需要额外的空间来存储稀疏表,这可能在存储方面具有一定的开销。
ST稀疏表通常用于解决一些范围查询问题,如最小公共祖先问题、RMQ问题(区间最小值查询问题)等。这些问题需要在静态数据集上高效地回答多次区间查询。
3.2.2 原理分析
-
ST算法应用于区间特征值的快速查询(如最大值,最小值,和,平均值等)。
-
ST算法采用了倍增的思想,将每一个区间的特征值 a i j a_{ij} aij进行记忆化存储。对于一个任意的区间[x,y],先将其转化为已经储存的 a i j a_{ij} aij,然后计算得出结果。
-
在ST稀疏表中,对于一个2的整数次幂的区间,我们将它定义为 a i j a_{ij} aij ,即 [ i , i + 2 j ] [i,i+2^j] [i,i+2j]。然后寻找到一个可以迭代的公式如 a n = f ( a n − 1 ) a_n=f(a_{n-1}) an=f(an−1)的方式进行迭代,同时将 a i j a_{ij} aij储存下来。
-
如对区间[1,17]这一组数据进行转化,我们可以将一下数据进行储存:
[1,3] [2,4] [3,5],...........[15,17] [1,5] [2,6] [3,7],...........[13,17] [1,9] [2,10] [3,11],.........[9,17] [1,16] [2,17]
-
对于指定区间[x,y]我们可以将其转化为 [ x [x [x, x + 2 k ] x+2^k] x+2k]+ [ y − 2 k , y ] [y-2^k,y] [y−2k,y],然后对这两个区间进行操作
3.2.3 构造算法
那么我们如何快速的构造出ST稀疏表呢?每一个区间都可以表示为: [ i , i + 2 j ] [i,i+2^j] [i,i+2j],然后结合倍增的思想对于 [ i , i + 2 j ] [i,i+2^j] [i,i+2j]的计算可以转化为 [ i , i + 2 j − 1 ] [i,i+2^{j-1}] [i,i+2j−1] [ i + 2 j − 1 , i + 2 j ] [i+2^{j-1},i+2^j] [i+2j−1,i+2j] ,通过这两个已知的区间值计算得到 [ i , i + 2 j ] [i,i+2^j] [i,i+2j]。
代码如下:
void ST_init() {
for (int i = 1; i <= n; ++i) f[i][0] = a[i];
int t = log(n) / log(2) + 1;
for (int j = 1; j < t; ++j)
for (int i = 1; i <= n - (1 << j) + 1; ++i)
f[i][j] = max(f[i][j - 1], f[i + (1 << (j - 1))][j - 1]);
}
3.2.3 例题引入——LCA(最近公共祖先)
对于LCA的问题,下面是详细的问题描述,方便之后的阅读:
LCA (Lowest Common Ancestor) 是指一棵树中两个节点的最低共同祖先。在树结构中,每个节点都有一个父节点,除了根节点外。LCA 是指两个节点最深的共同祖先,即沿着树的路径向上移动,直到两个节点的路径合并为止。
LCA 的应用十分广泛,尤其在计算机科学中。在树结构中,常见的应用包括:
-
二叉搜索树 (BST):在 BST 中,LCA 可以用于确定两个节点的最低共同祖先,从而帮助找到它们之间的最短路径。
-
图论和网络:在网络中,节点可以被视为路由器或交换机,LCA 可以用于确定两个节点之间的最低公共祖先,以优化网络路由。
-
树算法:在树形数据结构中,LCA 可以用于解决关于节点之间关系的问题,例如树的直径、最近公共祖先等。
有几种常见的方法可以求解最低公共祖先(LCA):
- 暴力法(Brute Force):
- 对于每一对节点,可以从两个节点开始,沿着它们的父节点一直向上遍历,直到找到它们的共同祖先。这种方法的时间复杂度较高,为O(h),其中h是树的高度。
- Tarjan’s 算法:
- Tarjan’s 算法是一种基于深度优先搜索(DFS)的算法,通过维护一个并查集来找到 LCA。该算法的时间复杂度为O(V + E),其中V是节点数,E是边数。
- 倍增法(Binary Lifting):
- 基于倍增法的 LCA 算法。首先,通过DFS计算每个节点到根节点的距离,然后通过存储每个节点的2的幂次祖先(ST稀疏表),可以在O(log h)的时间内找到任意两个节点的 LCA。这种方法的时间复杂度为O(N log N)。
- RMQ/树链剖分等等
这里我们主要讲解倍增法,即ST稀疏表的算法来求解LCA,对于图的数据结构,我们采用链式前向星(前向星_百度百科 (baidu.com))。
LCA的ST算法设计:
第一步:图的存储(链式前向星)
//头插法加入边 void add(int u, int v) { to[tot] = v, ne[tot] = head[u], head[u] = tot++; }
tot: ~~~~~~~~~~ 边的编号,每增加一个边,tot++。
to: ~~~~~~~~~~ 表示这条有向边所指向的节点序号
head[u]: ~~ 表示节点u的邻接点v所在的边的序号tot
ne[tot] : ~~~ 表示当前边的下一个边(采用头插法进行)
可以通过这个循环来访问u的邻接点:for (int u = head[x]; ~u; u = ne[i]) { int v = to[i]; v就是u的邻接点 }
第二步:BFS+构造ST稀疏表
-
思路:
-
我们需要找到树上两个节点所对应的最近公共祖宗节点,那么抽象的思路是,从两个节点开始,一步一步向上找,直到找到第一个公共祖宗节点,那么这个节点就是最近的。如果我们一步一步向上找,那么时间复杂度将是 O ( n ) O(n) O(n)。为了降低时间复杂度,我们可以将一步一步向上找的过程改为上跳 2 k 2^k 2k那么时间将会大大减少。
上跳 2 k 2^k 2k就用到了ST稀疏表。
我们定义f[y][k]为y节点上跳 2 k 2^k 2k所对应的节点
迭代方程:
fa[y][k] = fa[fa[y][k - 1]][k - 1]
从y上跳 2 k − 1 2^{k-1} 2k−1后再上跳 2 k − 1 2^{k-1} 2k−1等价于直接上跳 2 k 2^k 2k。
void bfs() {
memset(depth, 0x3f, sizeof depth);
//depth数组储存节点的深度
depth[0] = 0, depth[root] = 1;
//这里我们用数组表示队列,hh为队头,tt为队尾,q为队列数组
int hh = 0, tt = 0;
q[0] = root;
//当队列不为空时
while (hh <= tt) {
//入队
int x = q[hh++];
//遍历其邻接点
for (int i = head[x]; ~i; i = ne[i]) {
int y = to[i];
if (depth[y] > depth[x] + 1) {
depth[y] = depth[x] + 1;
q[++tt] = y;
fa[y][0] = x;
//构造ST稀疏表
for (int k = 1; k <= 15; k++)
fa[y][k] = fa[fa[y][k - 1]][k - 1];
}
}
}
}
第三步:LCA查询
首先将两节点上升到同一深度,然后进行上跳寻找LCA。假设他们距离LCA还有10步(二进制为1010),那么实际上只要上跳四次,步数分别为 2 3 , 0 , 2 1 , 0 2^3,0,2^1,0 23,0,21,0即可找到最近公共祖宗节点。
int lca(int x, int y) {
//保证y的深度小于x
if (depth[x] < depth[y]) swap(x, y);
//x向上走到和y同一深度
for (int k = t; k >= 0; k--) {
if (depth[fa[x][k]] >= depth[y]) x = fa[x][k];
}
if (x == y) return x;
//y和x一起向上走,
for (int k = t; k >= 0; k--) {
//如果不同那么向上走
if (fa[x][k] != fa[y][k])
x = fa[x][k], y = fa[y][k];
//如果相同,则代表着fa为他们的公共祖宗,但不一定是最近
//通过不断减少k的值,使其逐渐逼近最近公共祖宗
}
return fa[x][0];
}
完整代码(main函数补充)
int main() {
memset(head, -1, sizeof head);
//2的15次方,代表最多上跳15步
t = 15;
scanf("%d", &n);
for (int i = 0; i < n; i++) {
int a, b;
//构造图
scanf("%d%d", &a, &b);
if (b == -1) root = a;
else add(a, b), add(b, a);
}
bfs();
scanf("%d", &m);
while (m--)
{
//查询
int a, b;
scanf("%d%d", &a, &b);
int p = lca(a, b);
if (p == a) puts("1");
else if (p == b) puts("2");
else puts("0");
}
return 0;
}
四,其他的一些有趣的运用
1.快速幂取模
快速幂取模 是一种用于计算大整数的幂的算法,并且在每个步骤中都取模以避免溢出。这在密码学、模运算和一些数论问题中非常有用。算法的基本思想是通过重复平方法来降低计算复杂度。
以下是快速幂取模的基本算法:
def fast_power_mod(base, exponent, modulus):
result = 1 # 初始结果为1
base = base % modulus # 取模以确保base在[0, modulus)范围内
while exponent > 0:
# 如果指数exponent的二进制表示的最低位为1,乘以当前的base
if exponent % 2 == 1:
result = (result * base) % modulus
base = (base * base) % modulus # 计算base的平方,以准备下一步
exponent //= 2 # 取指数的下一位
return result
这个算法通过将指数 exponent 分解为其二进制表示,然后根据每一位是否为1来决定是否乘以 base。每一次迭代中,base 都会平方,而指数 exponent 则右移一位。这样,算法的时间复杂度为 O(log n),而不是传统的 O(n),这使得它特别适用于大整数的幂运算。