前提:在最初刷题时,总是无脑的刷,不仅效率低下,而且心态也崩;一些固定模板的算法套路不会用,常常对各种算法只知道其对应的时间复杂度,空间复杂度,但对做题的时候还是不会用其思想,本章将盘点各种算法的概念,以及罗列一些固定的模板和思路🫡
一、算法方法
1、排序算法
目的:将一组数据按照某种顺序(如升序或降序)排列,以下是一些排序算法的逻辑和思想,但是平时使用的话,笔者大部分倾向的是封装好的函数如sort(),其时间复杂度为O(n log n)
1.1 冒泡排序
思路:
- 从左到右依次比较相邻的两个元素,如果顺序不对就交换它们。
- 每一轮将最大的元素“冒泡”到最右边。
- 重复这个过程,直到所有元素有序。
复杂度:
- 平均/最坏情况:O(n²),(即逆序时,等差数列n-1、n-2…2、1,最后取最高项);
- 最好情况(已有序):O(n),(即遍历n个元素的时间)
- 空间复杂度:O(1),(即原地排序(in-place sorting),只需要常数级别的额外空间);
1.2、选择排序
思路:
- 每次从未排序的部分中找到最小的元素;
- 将其放到已排序部分的末尾;(不需要额外数组,直接就原数组操作)
- 当前最小的元素两两替换掉未排序的第一个元素;
- 重复这个过程,直到所有元素有序。(从小到大)
复杂度:
- 时间复杂度:O(n²),即无论最好最差,每次都是遍历一遍,等差数列n-1、n-2…2、1,最后取最高项;
- 空间复杂度:O(1),即原地排序(in-place sorting),只需要常数级别的额外空间;
1.3、插入排序
思路:
- 将待排序的元素插入到已排序部分的适当位置。
- 从第二个元素开始,逐个将元素插入到前面已排序的部分。
复杂度:
- 平均/最坏情况:O(n²),(即每次取出元素插入到已排序的得进行判断,故又得遍历一下已经排序好的元素序列,最坏情况指的是元素的顺序是顺序的);
- 最好情况(已有序):O(n)
- 空间复杂度:O(1),(即原地排序(in-place sorting),只需要常数级别的额外空间);
1.4、希尔排序(插入排序的改进版)
思路:
- 将数据分成若干子序列;(即根据对应的gap值划分原来的数组大小,gap指的是对应数组中索引的间隔)
- 对每个子序列进行插入排序;(即将远离的元素进行排序,排序后对应的数组索引相对应的替换)
- 逐步缩小子序列的间隔;
- 最终对整个序列进行一次插入排序。
复杂度:
- 时间复杂度:O(n log n) ~ O(n²)(取决于间隔序列,优化了交换和移动的次数)
- 空间复杂度:O(1),(即原地排序(in-place sorting),只需要常数级别的额外空间);
1.5、归并排序(分治法的应用)
思路:
- 拆分:选择中间点,将数组分为左右两部分,递归处理。
- 排序:递归调用自身,直到每个子数组只包含一个元素。
- 合并:合并两个已排序的子数组,形成一个新的有序数组。(这里在合并函数中创建两个数组,然后先对两个数组分别赋值,然后再进行比较两个临时数组大小排序更换其在原数组中元素的下标)
- 复制:将合并后的结果复制回原数组。
复杂度:
- 时间复杂度:O(n log n)(即由n*log2n所得)
- 空间复杂度:O(n)(即需要额外的数组进行临时存储)
1.6、快速排序(比归并排序不稳定)
思路:
- 选择一个“基准”元素,将数组分成两部分。(即每次递归后都选择,一般是中间值)
- 比基准小的元素和比基准大的元素。(即调用分区操作对基准的下标进行计算,在计算过程中通过遍历数组比较基准下标对应的值,调换数组中元素的位置,最后返回更新后的基准值)
- 然后递归地对两部分进行排序。(递归传参基准下标)
复杂度:
- 时间复杂度:
- 平均情况:O(n log n) (即平分后递归log2n,然后每层都处理n个)
- 最坏情况(已有序):O(n²)(即每次基准选择的是最小值或者最大值,导致层层递归的话都是对应的n-1)
- 空间复杂度:O(log n)(即递归栈空间)
1.7、堆排序(Heap Sort)
思路:
最大的堆的结构如完全二叉树,每一个结点都比孩子结点大,并且堆的顶部是一个最大的值。
- 将数组构建成一个最大堆;(即构建这个堆排序的时间复杂度为O(1~logn),每次都是从堆顶判断其是否比其孩子节点小,进而进行交换,最坏情况从头交换到底)
- 然后将堆顶元素(最大值)与最后一个元素交换;(即用一个for循环遍历数组中的元素,每次将一个堆顶元素与最后一个元素进行交换)
- 缩小堆的范围,重新调整堆;(即后续的就再次调用构建堆的函数调整剩余的n-1个元素为最大堆的结构)
- 重复这个过程,直到所有元素有序。
复杂度:
- 时间复杂度:O(n log n)(即调整堆结构的平均时间复杂度为logn,然后最后排序的话由于得遍历n个元素,故最终的复杂度为O(n logn))
- 空间复杂度:O(1)(即不需要其他的存储空间,原地数组顺序变换)
1.8、桶排序
虽然桶排序不像归并排序或快速排序那样通过递归形式的分治进行处理,但其依然利用了“分治”思想,划分数据、独立处理、最终合并结果。
思路:
- 将元素分配到若干个“桶”中;(即根据规则【值的范围等】创建多个桶将原来的元素进行划分)
- 对每个桶内的元素进行排序(即使用插入排序等算法);
- 最后将所有桶的元素按顺序合并。
复杂度:
- 时间复杂度:
- 平均情况:O(n + k)
- 首先,我们需要把所有的元素放到不同的桶里,这个过程需要检查每个元素一次,时间复杂度是 O(n)。
- 然后,我们对每个桶里的元素进行排序。假设每个桶内的元素比较少(我们希望平均分配),每个桶内的排序复杂度大概是 O(n/k),因为每个桶大约有 n/k 个元素。由于有 k 个桶,所有桶的排序总复杂度是 O(n)。
- 最后,我们把所有桶里的元素按顺序取出来,合并成一个有序的列表,这个过程也是 O(n)
- 故总的时间复杂度为O(n+n+n),但这里最终取O(n+k),在大部分情况下,如果 k 的数量不是特别大,桶排序的时间复杂度接近 O(n),但如果 k 非常大,可能会影响性能。因此,桶排序的时间复杂度通常写作 O(n + k)
- 最坏情况:O(n²)(即桶内的排序复杂度为O(n²),合并需要n遍,故为n²+n,取最高项)
- 空间复杂度:O(n + k)(即需要额外的k个桶的数量)
1.9、基数排序
思路:
- 从最低位到最高位,依次对每一位进行排序(通常使用计数排序作为子排序);
复杂度:
- 时间复杂度:O(n * d)(d 是最大数字的位数)(即每一轮排序选择的是算法可以是计数排序、桶排序等,时间复杂度为O(n+k),由于需要经过d轮,则时间复杂度为O(n*d))
- 空间复杂度:O(n + k)(即需要额外的k个桶的数量)
2、查找算法
目的:在一组数据中查找特定元素的位置,常见的查找算法包括线性查找和二分查找。与排序算法不同,查找算法的效率和数据结构的组织方式密切相关
2.1、线性查找
思路:
- 从数组中第一个元素出发;
- 依次遍历比较目标值
复杂度:
- 时间复杂度:O(n);
- 空间复杂度:O(1);
2.2、二分查找(折半查找)
思路:
- 它的操作步骤如下:首先,从有序数组的中间位置开始搜索,如果该元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于要查找的元素,则在数组中间元素的左侧继续进行搜索;反之,如果某一特定元素小于要查找的元素,则在数组中间元素的右侧继续进行搜索。重复以上过程,直到找到要查找的元素,或者查找范围为空
public static int binarySearch(int[] array, int target) {
int left = 0, right = array.length - 1;
while (left <= right) {
int mid = (left + right) / 2;
if (array[mid] == target) {
return mid;
} else if (array[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
复杂度:
- 时间复杂度:O(log n)
- 空间复杂度:
- 递归实现:O(log n)
- 迭代实现:O(1)
2.3、插值查找
思路:
- 插值查找是一种二分查找的变体,其核心是根据查询元素与数组中间元素的比较结果来调整查找范围,以加快查询速度。它的核心思想是:当搜索到一个关键字时,将搜索范围缩小一半,这样比较次数就会减少。它的查找公式为:
middle=low+(key-a[low])/(a[high]-a[low])*(high-low)
public static int interpolationSearch(int[] array, int target) {
int low = 0;
int high = array.length - 1;
int mid;
while (low <= high) {
mid = low + (high - low) * (target - array[low]) / (array[high] - array[low]);
if (array[mid] == target) {
return mid;
}
if (array[mid] > target) {
high = mid - 1;
} else {
low = mid + 1;
}
}
return -1;
}
复杂度:
- 时间复杂度:
- 平均时间复杂度:O(log log n) (即均匀分布的数据情况下)
- 最坏时间复杂度:O(n)
- 空间复杂度:O(1) (即原地查找)
2.4、斐波那契查找
由于斐波那契数是一个相对规律的一组数,如 0 1 1 2 3 5 8 13 21 34 55 89 144 233 377…因此使用其进行查找在一定程度上提高了查找速度
思路:
- 先创建一个函数去生成斐波那契数,公式为
F(n) = F(n-1) + F(n-2)(n ≥ 2)
- 对要查找的数值进行斐波那契数比较,当比较到对应的斐波那契数大于要查找的值时,提取对应的斐波那契数的值a[n];
- 对该值进行【0 到 a[n]】范围内的二分查找;
复杂度:
- 时间复杂度:O(log n)
- 空间复杂度:O(1)
二、算法思想
1、分治法
思想:分而治之,将原来问题划分为k个规模,且结构相似的子问题,递归地解决这些这些子问题,然后最后再进行合并其结果。
常见的用法:
- 快速排序:选择基准进行左右划分,不断递增,相对不稳定;
- 归并排序:选择中间的元素进行左右划分,不断递增,相对稳定;
- 矩阵乘法(Stassen算法):使用分治思想,将 n×n 矩阵拆分成 4 个 (n/2)×(n/2) 矩阵,利用 7 次递归 计算,降低时间复杂度至 O(n2.81) , 传统的矩阵乘法为O(n3)
- 故m x m个规模为n x n的矩阵,运用其后时间复杂度为O(m3*n2.81) 【这里的m3,涉及到m行、m列、m求和的遍历】。
2、动态规划
思想:每一次决策依赖于当前的状态,即下一状态的产生取决于当前状态。一个决策序列就是在变化的状态中产生的,这种多阶段最优化问题的求解过程就是动态规则过程
.
动态规划算法是解决一类具有重叠子问题和最优子结构性质的问题的有效方法。核心思想就是穷举求最值。
.
三个要素:状态转移方程、最优子结构和重叠子问题
求解步骤:
- 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义。
# 自顶向下递归的动态规划
def dp(状态1, 状态2, ...):
for 选择 in 所有可能的选择:
# 此时的状态已经因为做了选择而改变
result = 求最值(result, dp(状态1, 状态2, ...))
return result
# 自底向上迭代的动态规划
# 初始化 base case
dp[0][0][...] = base case
# 进行状态转移
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 求最值(选择1,选择2...)
- 重叠子问题:当原问题通过递归或者for循环重复遇见同样的小问题,导致算法低效————带备忘录的递归解法(DP table)
- 最优子结构:给定一个问题,若能找出所有可能的子问题,并且每个子问题的最优解都能帮助我们构造原问题的最优解。
参考:https://labuladong.online/algo/essential-technique/dynamic-programming-framework/
3、贪心算法
- 贪心算法(又称贪婪算法)是指,在对问题求解时,总是做出在当前看来是最好的选择。
- 也就是说,不从整体最优上加以考虑,他所做出的仅是在某种意义上的局部最优解。
- 贪心算法不是对所有问题都能得到整体最优解,但对范围相当广泛的许多问题他能产生整体最优解或者是整体最优解的近似解。
4、回溯法
回溯算法,又称为“试探法”。解决问题时,每进行一步,都是抱着试试看的态度,如果发现当前选择并不是最好的,或者这么走下去肯定达不到目标,立刻做回退操作重新选择。
这种走不通就回退再走的方法就是回溯算法。
5、图算法
5.1 深度优先搜索(DFS)
5.2 广度优先搜索(BFS)
5.3 树直径计算
树的直径(树上任意两节点之间最长的简单路径即为树的「直径」)计算:
- 两次 DFS(BFS)求解直径:(核心:反证法证明)
- ①从任意一点P出发,通过DFS寻找离它最远的点Q。
- ②再次从点Q出发,通过DFS寻找离它最远的W。
- ③直径即为WQ。
dfs模板:
无权:
void dfs(int root){
for(int i = 0; i < edge[st].size() ; i++){
int to = edge[st][i];
if( !vis[ i ] ){
vis[ i ] = 1;
dis = dis [ root ] + 1;
dfs[ to ];
}
}
}
- 每次调用dfs一次后就进行for循环dis,选取最大值Q;
- 然后再从Q进行dfs,最后再在dis选取最大值W,即路径为W的值
有权:
struct Edge {
int to; // 目标节点
int weight; // 边的权重
};
E[u].push_back({v, w}); // 添加边
E[v].push_back({u, w}); // 添加反向边
void dfs(int root){
for (const Edge &edge : E[u]) {
int to = edge.to; // 获取邻接节点
if( !vis[ to ] ){
vis[ to ] = 1;
dis = dis [ root ] + edge.weight;
dfs[ to ];
}
}
}
- Dp 求解每一个节点的深度,并且在每次递归时记录其路径最大值,执行结束后可以得出不同不同节点的深度
dfs模板:
无权:
void dfs(int root, int Uproot){
for ( int v : E[root] ){
if(v == Uproot) continue;
dfs(v , root);
d = max(d , dp[root] + dp[v] + 1);
dp[root] = max(dp[root] , dp[v] +1);
}
}
- 这里的root的代表的是当前的节点,Uproot为root的父节;
- E []的数据定义为 vector<int> E[n] ,为不同节点的二维数组;
- dp[] 的数据定义为dp[n],代表的是每一个节点的深度,默认为0;
- d代表最大的的树的直径;
有权:
struct Edge {
int to; // 目标节点
int weight; // 边的权重
};
vector<Edge> E[N];
void dfs(int u, int fa) {
for (const Edge &edge : E[u]) {
int v = edge.to; // 获取邻接节点
if (v == fa) continue;
dfs(v, u);
// 更新 d 和 dp[u],考虑边的权重
d = max(d, dp[u] + dp[v] + edge.weight);
dp[u] = max(dp[u], dp[v] + edge.weight);
}
}
E[u].push_back({v, w}); // 添加边
E[v].push_back({u, w}); // 添加反向边
鉴于学习精力,笔者后续会尽量完善这遍文章的内容,也感谢读者能够看到这里。
你们的点赞和关注,是笔者最大的动力🌹🌹