一、查找算法
常见的查找算法
· 线性查找(lined search)
从数组的第一个元素开始,逐一比对,直到找到目标元素或遍历完数组。
· 二分查找(binary search)/折半查找
是一种基于分治策略的高效搜索算法,利用数据的有序性,每轮缩小一半搜索范围,直到找到目标元素或搜索区间为空为止。
(1)基于递推/迭代实现的二分查找
对一个已经排好序的数组,从中间大小元素开始,如果中间元素正好是要查找的元素,则查找成功;如果要查找的元素大于或小于中间元素,则在数组的大于或小于中间元素的那一半中查找,并且重复以上过程,最终就能查询到目标元素。
(2)基于分治实现的二分查找
- 计算搜索区间[i,j]的中点m,根据它排除一半搜索区间;
- 递归求解规模减小一半的子问题,可能为f(i,m−1)或f(m+1,j);
- 循环第1步和第2步,直至找到target 或区间为空时返回。
-
-
(3)二分查找优点与局限性
-
优点:时间空间效率高
局限:仅限于有序数据、仅适用于数组(因为需要跳跃式/非连续的访问元素)
-
· 插值查找
-
是一种改进的二分查找,适用于元素均匀分布的有序数组,通过预测目标元素的位置进行查找。
-
(1)二分查找插入点
-
二分查找不仅可以用于搜索目标元素,还可以搜索目标元素的插入位置。
-
①无重复元素的情况
当数组中原本不包含待插入元素target时,则目标元素的插入位置就是二分查找结束后的left值
以从小到大排序的序列为例
-
-
②有重复元素的情况:以向一个从大到小排序的序列中插入target为例
-
-
当 nums[m] == target 时,我们并没有将 i 或 j 置于 m,而是将 j 移到 m - 1。这意味着我们在查找区间 [i, m-1] 中继续搜索,确保我们能找到所有可能小于 target 的元素。
-
(2)二分查找边界
-
①在一个有序数组中,当存在多个重复元素时,查找这些重复元素的最左一个/左边界,若
数组中不包含此元素,返回-1
-
-
· 哈希查找
-
在算法题中我们通过将线性查找替换为哈希查找来降低算法的时间复杂度。哈希查找是一种利用哈希表来提高数据查找效率的技术。哈希表通过将数据映射到哈希表的不同位置来加速查找过程。
-
-
几种查找的对比
-
线性搜索
‧ 通用性较好,无须任何数据预处理操作。假如我们仅需查询一次数据,那么其他三种方法的数据预处理的时间比线性搜索的时间还要更长。
‧ 适用于体量较小的数据,此情况下时间复杂度对效率影响较小。
‧ 适用于数据更新频率较高的场景,因为该方法不需要对数据进行任何额外维护。
-
-
‧ 适用于数组和链表
二分查找
‧ 适用于大数据量的情况,效率表现稳定
二分查找依赖数据的有序性,通过循环逐步缩减一半搜索区间来进行查找。它要求输入数据有序,且仅适用于数组或基于数组实现的数据结构。
哈希查找
‧ 适合对查询性能要求很高的场景,平均时间复杂度为O(1)。
‧ 不适合需要有序数据或范围查找的场景,因为哈希表无法维护数据的有序性。
‧ 对哈希函数和哈希冲突处理策略的依赖性较高,具有较大的性能劣化风险。
‧ 不适合数据量过大的情况,因为哈希表需要额外空间来最大程度地减少冲突,
从而提供良好的查询性能。
树查找
‧ 适用于海量数据,因为树节点在内存中是分散存储的。
‧ 适合需要维护有序数据或范围查找的场景。
‧ 在持续增删节点的过程中,二叉搜索树可能产生倾斜,时间复杂度劣化至O(n)。
‧ 若使用AVL树或红黑树,则各项操作可在O(logN)效率下稳定运行,但维护树
平衡的操作会增加额外的开销。
-
-
-
-
-
-
- 递归求解规模减小一半的子问题,可能为f(i,m−1)或f(m+1,j);
二、排序算法
排序算法 |
平均时间复杂度 |
最坏时间复杂度 |
最好时间复杂度 |
空间复杂度 |
稳定性 |
冒泡排序 |
O(n²) |
O(n²) |
O(n)带flag的bubble |
O(1) |
稳定 |
选择排序 |
O(n²) |
O(n²) |
O(n²) |
O(1) |
不稳定 |
插入排序 |
O(n²) |
O(n²) |
O(n) |
O(1) |
稳定 |
快速排序 |
O(nlogn) |
O(n²) |
O(nlogn) |
O(logn) |
不稳定 |
归并排序 |
O(nlogn) |
O(nlogn) |
O(nlogn) |
O(n) |
稳定 |
桶排序(k为桶数) |
O(n+k) |
O(n²) |
O(n+k) |
O(n+k) |
稳定 |
计数排序(m为范围) |
O(n+m) |
O(n+m) |
O(n+m) |
O(n+m) |
稳定 |
基数排序(k为位数) |
O(nk) |
O(nk) |
O(nk) |
O(n+k) |
稳定 |
常见的排序算法
· 冒泡排序(Bubble Sort)
适用于数据量小、部分有序、要求稳定性的排序场景。对于从小到大排列来说,从左到右,相邻两个元素进行比较,将相邻的两个元素中较大的放在右面,较小的放在左面,遍历一轮后最大的元素就从右面冒出来了,重复的遍历后,就实现了从小到大的排列
算法流程:设数组的长度为n
1. 首先,对 n个元素执行“冒泡”,将数组的最大元素交换至正确位置。
2. 接下来,对剩余 n− 1 个元素执行“冒泡”,将第二大元素交换至正确位置。
3. 以此类推,经过 n − 1 轮“冒泡”后,前 n− 1 大的元素都被交换至正确位置。
4. 仅剩的一个元素必定是最小元素,无须排序,因此数组排序完成。
优化的冒泡排序:
如果某轮”冒泡”中没有执行任何交换操作,说民数组已经完成排序,可直接返回结果,因此增加一个标志位flag来检测这种情况。经过这种优化,冒泡排序的最差时间复杂度和平均时间复杂度仍为O(n2);但当输入数组完全有序时,可达到最佳时间复杂度O(n)。
void bubbleSortFlag(std::vector<int>& arr) {
int len = arr.size();
bool swapped;
for (int i = 0; i < len - 1; i++) {
//每轮先设置flag为false
swapped = false;
for (int j = 0; j < len - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
std::swap(arr[j], arr[j + 1]);
swapped = true;
}
}
if (!swapped) {
break;
}
}
}
· 选择排序(Selection Sort)
由于选择排序的时间复杂度较高,通常只适用于小规模数据集或对性能要求不高的场景。
对于从小到大排序来说,首先找到数组中最小的元素,将它与数组的第一个元素交换位置。然后,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。以此类推,直到整个数组排序完毕。
· 插入排序(Insertion Sort)
适用于数据量小、部分有序、要求稳定性的排序场景。
通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。在未排序的区间选择一个基准元 素(一般是紧邻已排序区间的第一个元素),将该元素与其左侧已排序区间(一般最初的已排序区间就是第一个元素,一个元素也可以看成是已排序的)的元素逐一比较大小,并将该元素插入到正确的位置。
算法流程:
注意:在实际情况中,插入排序的使用频率显著高于冒泡排序和选择排序,主要有以下原因。
‧ 冒泡排序基于元素交换实现,需要借助一个临时变量,共涉及3个单元操作;插入排序基于元素赋值实现,仅需1个单元操作。因此,冒泡排序的计算开销通常比插入排序更高。
‧ 选择排序在任何情况下的时间复杂度都为O(n2)。如果给定一组部分有序的数据,插入排序通常比选择排序效率更高。
‧ 选择排序不稳定,无法应用于多级排序。
· 快速排序(Quick Sort)
适用于大规模数据集,且对排序的时间要求较高的场景,是一种基于分治策略的排序算法,运行高效、应用广泛。
以从小到大为例,任选一个数据作为基准元素,将数组中大于基准元素的都放在右面,将小于基准元素的都放在左面,分别对左右两部分重复以上操作,就可以实现从小到大排序
通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列
基本步骤:
①选择基准元素(Pivot):
从待排序的数组中选择一个元素作为“基准元素”。选择基准元素的方法可以有多种,例如选择第一个元素、最后一个元素、中间元素,或者随机选择。
②分区(Partition):
通过一趟排序将数组分成两部分,左边部分的所有元素都小于等于基准元素,右边部分的所有元素都大于等于基准元素。
实现分区的方式通常是从数组两端同时向中间扫描,找到一个在左边但比基准元素大的元素,和一个在右边但比基准元素小的元素,然后交换它们的位置,直到两个扫描指针相遇。
③递归排序(Recursive Sort):
对分区后的左右子数组递归地进行快速排序。
④结束条件(Base Case):
当子数组的大小为1或0时,不再需要进行排序。
注意:如果以最左端元素为基准元素时,必须先从右往左查找。如果以最右端元素为基准元素时,必须先从左往右查找。
快速排序的优化?
快速排序在某些输入下的时间效率可能降低。举一个极端例子,假设输入数组是完全倒序的,由于我们选择最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为n−1、右子数组长度为0。如此递归下去,每轮哨兵划分后都有一个子数组的长度为0,分治策略失效,快速排序退化为“冒泡排序”的近似形式。为了尽量避免这种情况发生,我们可以优化哨兵划分中的基准数的选取策略。
我们可以在数组中选取三个候选元素(通常为数组的首、尾、中点元素),并将这三个候选元素的中位数作为基准数。
// 选取三个数中的中位数下标(median-of-three 方法)
int medianThree(std::vector<int>& nums, int left, int mid, int right) {
int l = nums[left], m = nums[mid], r = nums[right];
if ((l <= m && m <= r) || (r <= m && m <= l))
return mid; // m 在 l 和 r 之间,是中位数
if ((m <= l && l <= r) || (r <= l && l <= m))
return left; // l 在 m 和 r 之间,是中位数
return right; // 否则 r 是中位数
}
// 基于 median-of-three 的 partition 函数
int partition(std::vector<int>& nums, int left, int right) {
int mid = (left + right) / 2;
int med = medianThree(nums, left, mid, right);
// 将中位数交换到最右端,作为基准值(pivot)
std::swap(nums[med], nums[right]);
int pivot = nums[right];
int i = left - 1; // 小于 pivot 区间的末尾
for (int j = left; j < right; ++j) {
if (nums[j] <= pivot) {
++i;
std::swap(nums[i], nums[j]);
}
}
// 将 pivot 放到中间正确位置
std::swap(nums[i + 1], nums[right]);
return i + 1;
}
快速排序为什么快?
尽管快速排序的平均时间复杂度与归并排序和堆排序相同。但通常快速排序的效率更高,主要有以下原因。
- 出现最差情况概率低
- 缓存使用效率高。在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像堆排序这类算法需要跳跃式访问元素,从而缺乏这一特性
- 复杂度的常数系数小
· 归并排序(Merge Sort)
适应于数据量大,对稳定性要求高的场景
是一种基于分治策略的排序算法。它通过递归地将数组分成两半,分别对每一半进行排序,然后合并这两个已经排序的子数组,最终得到一个有序的数组。
步骤:
- 分解(Divide): 将数组从中间分成两个子数组,直到每个子数组的长度为1。
- 排序(Conquer): 当子数组的长度为1时,开始合并两个子数组,使之成为一个有序的子数组。
- 合并(Combine): 合并两个有序的子数组,生成一个新的有序数组。
· 桶排序(Bucket Sort)
适用于数据量大的场景,本身比较稳定,但稳定性也取决于桶内排序所用的排序算法。前面在几种排序算法都属于“基于比较的排序算法”,他们通过比较元素间的大小来实现排序。此类算法的时间复杂度不会超过O(nlogn)。而桶排序是一种非比较算法。通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到各个桶中;然后在每个桶内部分别执行排序;最终按照桶的顺序将所有数据合并。
步骤:
- 初始化n个桶,将n个元素分配到n个桶中。
- 对每个桶分别执行排序(这里采用编程语言的内置排序函数)。
- 按照桶从小到大的顺序合并结果。
示例:以对元素范围是[0,1)内的浮点数数组进行排序为例
注意:桶排序的时间复杂度理论上可以达到O(n),关键在于将元素均匀分配到各个桶中,避免各个桶内元素的数量相差过大。为了实现平均分配我们可以先设置一个大致的分界线,将数据粗略分到三个桶中。分配完毕后再将元素数量较多的桶继续划分为3个桶,直到所有桶中的元素数量大致相等。这种方法的本质是创建一颗递归树,目标是让叶节点的值尽可能平均。
桶排序的最差时间复杂度为什么是O(n2)?
最差的情况,当所有元素被分到同一个桶中。如果我们采取一个O(n2)的排序对桶内元素进行排序,则时间复杂度退化为O(n2)。
· 计数排序(Count Sort)
适用于数据量n大但数据范围m较小的情况且数据分布均匀而且对稳定性有要求的场景,并且只适应于非负整数的排序,通过统计元素数量来实现排序,通常应用于非负整数数组。
注意:上述示例的m不能过大,m在这里看做是数组中的最大值,但实际上应将其看为数组的取值范围区间m=max-min,如果m过大会占用过多的空间,当n << m时,计数排序使用O(m)时间可能比O(nlogn)还要慢。
· 基数排序
相较于计数排序更适合数值范围大的场景,但前提是数据必须可以表示为固定位数的格式,且位数不能过大。适应于整数、字符串排序;不适应于浮点数、位数较多的整数的排序。计数排序适用于数据量大但数据范围较小的情况,如果数据范围过大,则计数排序不再适用,此时引入基数排序。基数排序的核心思想与基数排序一致,也是通过统计个数来实现排序。在此基础上,基数排序利用数字各位之间的递进关系,依次对数据的每一位进行排序,从而得到最终的排序结果。
步骤:
以8位学号数据的排序为例,假设数字的最低位是第1位,最高位是第8位
- 1. 初始化位数k=1。
- 2. 对学号的第k位执行“计数排序”。完成后,数据会根据第k位从小到大排序。
- 3. 将k增加1,然后返回步骤2继续迭代,直到所有位都排序完成后结束。
对于一个d进制的数字x,其第k位xk为:
注意:当计数排序稳定时,基数排序也稳定,当计数排序不稳定时,基数排序无法保证得到正确的排序结果。
三、分治算法
分治全称分而治之,是一种非常重要且常见的算法策略。分治通常基于递归实现,包括分和治两个步骤。
分(划分阶段):递归的将问题分解为两个或多个子问题,直至到达最小子问题时终止
治(合并阶段):从已知解的最小子问题开始,从底至顶地将子问题地解进行合并,从而构建出原问题的解。
判断是否是分治算法问题的依据包括:问题是否能分解、子问题是否独立、子问题能否合并。
其中:归并排序/二分查找等都是分治策略的典型应用。
分治算法的应用:汉诺塔问题
分治策略:
将原问题f(n)划分为两个子问题f(n−1)和一个子问题f(1),并按照以下顺序解决这三个子问题。
- 1. 将n−1个圆盘借助C从A移至B。
- 2. 将剩余1个圆盘从A直接移至C。
- 3. 将n−1个圆盘借助A从B移至C。
对于两个子问题f(n−1)可以通过相同的方式进行递归划分,直到达到最小子问题f(1)。而f(1)的解是已知的,只需一次移动操作即可
void move(std::vector<int>& src, std::vector<int>& tar) {
//从src顶部拿出一个圆盘
int pan = src.back();
src.pop_back();
//将圆盘放入tar顶部
tar.push_back(pan);
}
void dfs(int i, std::vector<int>& src, std::vector<int>& buf, std::vector<int>& tar) {
//若src只剩一个,将其移动到tar
if (i == 1) {
move(src, tar);
return;
}
//子问题f(i-1):将src顶部i-1个圆盘借助tar移动到buf
dfs(i - 1, src, tar, buf);
//子问题f(1):将src剩余一个圆盘移到tar
move(src, tar);
//子问题f(i-1):将buf顶部i-1个圆盘借助src移动到tar
dfs(i - 1, buf, src, tar);
}
void solveHanota(std::vector<int>& A, std::vector<int>& B, std::vector<int>& C) {
int n = A.size();
//将A顶部n个圆盘借助B移动到C
dfs(n, A, B, C);
}
汉诺塔问题形成一颗高度为n的递归树,每个节点代表一个子问题,对应一个开启的dfs()函数
四、回溯算法
是一种通过穷举来解决问题的方法,核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解将其记录,直到找到解或者尝试了所有可能的选择都无法找到为止。回溯算法通常采用深度优先搜索来遍历空间
尝试与回退:之所以会称为回溯算法是因为该算法在搜索解空间时会采用”尝试”与”回退的策略”。当算法在搜索过程总遇到某个状态无法继续前进或无法得到满足条件的解时,他会撤销上一步的选择,退回到之前的状态,并尝试其他可能的选择。
示例:在二叉树中搜索所有值为7的节点,请返回根节点到这些节点的路径?
// 假设以下变量为全局变量或在调用中传入
std::vector<int> path; // 当前路径
std::vector<std::vector<int>> res; // 保存满足条件的路径集合
void preOrder(TreeNode* root) {
if (root == nullptr) {
return;
}
// 尝试:将当前节点加入路径
path.push_back(root->val);
// 如果满足条件(例如值为 7),记录路径
if (root->val == 7) {
res.push_back(path);
}
// 继续递归左子树和右子树
preOrder(root->left);
preOrder(root->right);
// 回溯:移除当前节点
path.pop_back();
}
剪枝:复杂的回溯问题通常包含一个或多个约束条件,约束条件通常可用于剪枝。剪枝可避免许多无意义的尝试,从而提高了搜索效率。
示例:在二叉树中搜索所有值为7的节点,请返回根节点到这些节点的路径,并要求路径中不包含值为3的节点。为了满足上述约束条件,我们需要添加剪枝操作;在搜索过程中若遇到值为3的节点,则提前返回,不再继续搜索。
// 假设全局变量或外部传入
std::vector<int> path; // 当前路径
std::vector<std::vector<int>> res; // 满足条件的所有路径
void preOrder(TreeNode* root) {
// 剪枝:遇到空节点或值为3的节点,直接返回
if (root == nullptr || root->val == 3) {
return;
}
// 尝试:将当前节点值加入路径
path.push_back(root->val);
// 满足条件时记录当前路径(这里示例为值为7时记录)
if (root->val == 7) {
res.push_back(path);
}
// 递归遍历左右子树
preOrder(root->left);
preOrder(root->right);
// 回溯:移除当前节点值
path.pop_back();
}
回溯常见术语
回溯算法的优点与局限性?
能够找到所有可能的解决方案,并且在合理的剪枝操作下,具有很高的效率。然而在大规模或复杂问题中,回溯算法的运行效率可能难以接受,无论是时间效率还是空间效率都较高。