本文正在参加「金石计划」
flag:每月至少产出三篇高质量文章~
欢迎关注我的另外几篇文章:
- 【微前端】手把手教你从0到1实现基于Webpack5 模块联邦(Module Federation)的微前端~
- 【微前端】在造一个微前端轮子之前,你需要知道这些~
- 【重学CSS】图文并茂!一次性搞懂 Flex 布局,实现“布局自由”~
- 【重学CSS】图文并茂!一次性搞懂 Grid 布局,实现“布局自由”~
- 【脚手架】从0到1搭建React18+TS4.x+Webpack5项目(一)项目初始化
- 【脚手架】从0到1搭建React18+TS4.x+Webpack5项目(二)基础功能配置
- 【脚手架】从0到1搭建React18+TS4.x+Webpack5项目(三)代码质量和git提交规范
- 【React】万字长文!100+个让你事半功倍的常用 React Hooks 和工具包
- 【React】面试官:如何在页面刷新之后保持状态?看看你知道几种~
- 【2023】前端趋势前瞻,学起来!
高能预警:本文中大部分插图都是
“当代灵魂画手”——也就是我自己😭,用 IPAD 手工画的,因为我觉得学习算法如果不自己具象出大致的思路,挺难形成记忆的。鄙人画功拙劣(书法尚可),可能刚开始有点辣眼睛,凑合着看,后续看能不能找到合适的绘图工具,JYM有推荐的也麻烦告知,蟹蟹啦~
1、什么是数组
数组是存储在连续内存位置的项目的集合,将多个相同类型的项目(有些语言中也可以是不同类型,比如
JavaScript)存储在一起。这使得通过简单地向基值添加偏移量来计算每个元素的位置变得更加容易,即,数组的第一个元素的内存位置(通常由数组的名称表示)。基值是索引 0,两个索引之间的差值是偏移量。每个元素都可以通过它们在数组中的索引来唯一标识。
简单来说数组就是用于储存多个相同类型数据的集合。(当然,js中的数组也可以存储不同类型数据,但是!不建议这样做!)如下图:
1.1 数组的应用
- 数组存储相同数据类型(有些语言中也可以是不同类型,如JavaScript)的数据元素。
- 当数据集的大小已知时,使用数组。
- 用于解决矩阵问题。
- 在计算机中用作搜索表。
- 数据库记录也是由数组实现的。
- 帮助实现排序算法。
- 同一类型的不同变量可以保存在一个名称下。
- 数组可用于 CPU 调度。
- 用于实现其他数据结构,如栈、队列、堆、哈希表等。
1.2 数组的优缺点
数组是一种线性表数据结构,它用一组连续的内存空间,来存储一组具有相同类型的数据(JS里可以是任意类型)。
关键点:连续的存储空间(数组可以进行随机访问)
- 在操作系统中数组的特性为
- 存储在物理空间上是连续的。
- 底层的数组长度是不可变的(js中之所以能随意修改数组的长度,是因为js做了数据优化)
- 数据的变量,指向了数组第一个元素的位置
- 底层数组长度不可变,那底层是如何添加与删除的:假设当前的数组的长度为8,此时需要网数组中添加一个新数据,底层会新建一个16位长度的数组,将之前的长度为8的数组中的数据拷贝过来,然后将新数据添加进该数组。当为删除时,假设此时删除数组索引为5的数据,那么索引为5之后的数据到最后一位数据会往前挪,因为存储在物理空间上是连续的。
优缺点
- 优点:
- 查询性能好,指定查询某一个位置
- 易于创建和使用
- 复杂数据结构的基础构建块
- 缺点:
- 因为空间必须是连续的,所以如果数据比较大,当系统的空间碎片较多的时候,容易存不下。(空间碎片:何为空间碎片,当数组中出现空值,导致数组不是连续的,这个空值为空间碎片,如果系统没有清除掉,那么新数据就存不下,举个例子,一个数据组的长度为10,之中有三个不连续的空值,当存入三个新数据,此时会存不进去,因为此时系统中已经存入数组的长度为10,包括啦空值,系统整理空间碎片会很慢,大概是这,需要操作系统原理)
- 因为数组的长度是固定的,所以数组的内容难以被添加和删除,导致昂贵的插入/删除或重排
1.3 数组的操作
关于 JavaScript 和 Go 中数组(Go中还有切片)的一些方法,为了节省篇幅,这里就不赘述了,可看下面的推荐文章:
- JavaScript 数组相关操作
- Array - JavaScript - MDN Web Docs
- The JavaScript Array Handbook – JS Array Methods - freecodecamp
- Array methods - The Modern JavaScript Tutorial
- Go 数组(切片)相关操作
- 官方网站 —— Go Slices: usage and internals
- Go Slices - W3Schools
- Go Arrays - W3Schools
- Introduction to Slices in Golang - CalliCoder
- Working with Arrays in Golang - CalliCoder
2、线性搜索
2.1 经典线性搜索
- 顺序搜索:按一定的顺序遍历数组并检查每个元素,比较典型的就是线性搜索。
什么是线性搜索?假设该项目以随机顺序存在于数组中,我们必须找到一个项目。那么搜索目标项目的唯一方法就是首先从第一个位置开始并将其与目标进行比较。如果项目在同一位置,我们将返回当前项目的位置。否则,我们将移动到下一个位置。如果我们到达数组的最后一个位置仍然找不到目标,我们返回-1。这称为线性搜索或顺序搜索。如下图:
JavaScript 实现:
function linearSearch(arr, ele) {
for(let i = 0; i < arr.length; i++){
if(arr[i] === ele) {
return i;
}
}
return -1;
}
const arr = [3, 7, 1, 4, 9, 2];
const elementToFind = 4;
console.log(`element found at index: ${linearSearch(arr, elementToFind)}`);
// element found at index: 3
- 时间复杂度: O(N)
- 辅助空间: O(1)
Go 实现:
func linearSearch(arr []int, ele int) int {
for i := 0; i < len(arr); i++ {
if arr[i] == ele {
return i
}
}
return -1
}
arr := []int{3, 7, 1, 4, 9, 2}
ele := 4
fmt.Printf("element found at index: %d", linearSearch(arr, ele))
// element found at index: 3
2.2 哨兵线性搜索
顾名思义,哨兵线性搜索(Sentinel Linear Search)是一种线性搜索,与传统的线性搜索相比,它的比较次数有所减少。
在这个搜索中,数组的最后一个元素被替换成要搜索的元素,然后在数组上进行线性搜索,而不检查当前的索引是否在数组的索引范围内,因为要搜索的元素肯定会在数组中找到,即使它不在原数组中,因为最后一个元素被替换成了它。所以,要检查的索引永远不会超出数组的范围。在最坏的情况下,比较的次数将是(N + 2)(在最坏情况下,因为要对目标元素与数组末尾位置元素进行判断,因此需要再进行两次判断,多出 2 次比较)。
尽管在最坏情况下,两种算法的时间复杂度都是O(n)。只是在哨兵线性搜索中的比较次数要比线性搜索少。
基本思路:哨兵线性搜索的基本思想是在数组的末端增加一个额外的元素(即哨兵值),该元素与搜索键相匹配。通过这样做,我们可以避免在循环中对数组末端的条件进行检查,一旦找到哨兵元素,就提前终止搜索。这样就不需要对数组的末端进行单独检查,从而使算法的平均性能略有提高。
哨兵线性搜索算法的好处:它消除了对数组末端的单独检查,这可以提高算法的平均情况性能。然而,它并没有改善最坏情况下的性能,它仍然是
O(n)(其中n是数组的大小),因为我们可能需要扫描整个数组来找到哨兵值。
JavaScript 实现:
function sentinelLinearSearch(arr, target) {
const n = arr.length
const lastElement = arr[n - 1] // 记录最后一个元素
arr[n - 1] = target // 将目标值替换成最后一个元素,从而省略了边界判断
let i = 0
while (arr[i] !== target) {
i++
}
// 恢复原数组
arr[n - 1] = lastElement
// 区分搜索成功和失败的情况
if (i < n - 1 || arr[n - 1] === target) {
return i
}
return -1
}
// 测试
const arr = [2, 4, 7, 9, 12, 23, 34, 45, 56, 67]
console.log(sentinelLinearSearch(arr, 7)) // output: 2
console.log(sentinelLinearSearch(arr, 10)) // output: -1
- 时间复杂度: O(N)
- 辅助空间: O(1)
Go 实现:
func SentinelLinearSearch(arr []int, target int) int {
n := len(arr)
last := arr[n - 1] // 记录最后一个元素
arr[n - 1] = target // 将目标值替换成最后一个元素,从而省略了边界判断
i := 0
for arr[i] != target {
i++
}
arr[n - 1] = last // 恢复原数组
if i < n - 1 || arr[n - 1] == target { // 区分搜索成功和失败的情况
return i
}
return -1
}
3、二分搜索
区间搜索:专门为在 已排序的数据结构 中搜索元素而设计的,通常区间搜索会比线性搜索更有效,因为它们反复以搜索结构的中心为目标,并将搜索空间分成两半。比较典型的就是二分搜索。
3.1 经典二分搜索
3.1.1 迭代实现
JavaScript实现:
function binarySearch(arr, ele) {
let left = 0;
let right = arr.length - 1;
while(left <= right){
const mid = Math.floor(left + (right - left) / 2);
if(ele === arr[mid]) {
return mid;
} else if(ele < arr[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return -1;
}
const arr = [1, 2, 3, 4, 7, 9];
const elementToFind = 4;
console.log(`element found at index: ${binarySearch(arr, elementToFind)}`);
// element found at index: 3
Go 实现:
func binarySearch(arr []int, ele int) int {
left := 0
right := len(arr) - 1
for left <= right{
mid := left + (right - left) / 2
if ele == arr[mid] {
return mid
} else if ele < arr[mid] {
right = mid - 1
} else {
left = mid + 1
}
}
return -1
}
arr := []int{1, 2, 3, 4, 7, 9}
ele := 4
fmt.Printf("element found at index: %d", binarySearch(arr, ele))
// element found at index: 3
注意: 这里我们计算中值的写法:
const mid = Math.floor(left + (right - left) / 2);
为什么我们要这样计算中间指数?可以简单地将较低和较高的指数相加并除以 2 吗?比如
const mid = (left + right) / 2;
还真不一定行,如果 left + right 的总和大于最大正整型值 ( – 1),就会出现溢出了。
高能预警:这也是各个大厂算法中考试的一个隐藏点,有些面试官可能会因为你没考虑溢出的情况而把你 pass 掉!~
3.1.2 递归实现
大致的实现思路是:
- 在函数执行开始时,会先判断当前搜索范围是否有效(即左侧下标是否大于右侧下标),如果无效,则退出递归,并返回-1,表示未找到目标元素。
- 如果当前搜索范围有效,函数会计算当前搜索范围的中间元素下标,并将其与目标值进行比较。
- 如果当前中间元素正好等于目标值,则直接返回该元素的下标;
- 如果当前中间元素小于目标值,则代表目标值可能在数组的右侧区域中,因此将搜索范围右移一位,递归调用二分搜索的函数进行下一轮搜索;
- 如果当前中间元素大于目标值,则代表目标值可能在数组的左侧区域中,因此将搜索范围左移一位,递归调用二分搜索的函数进行下一轮搜索。
JavaScript 实现:
function binarySearchRecursive(arr, target, left = 0, right = arr.length - 1) {
// 确定搜索范围无效,退出递归,该分支未找到目标元素
if (left > right) {
return -1;
}
// 计算中间元素下标
const mid = Math.floor(left + (right - left) / 2);
if (arr[mid] === target) {
// 当前中间元素正好等于目标值
return mid;
} else if (arr[mid] < target) {
// 中间元素小于目标值,在右侧区域中继续搜索
return binarySearchRecursive(arr, target, mid + 1, right);
} else {
// 中间元素大于目标值,在左侧区域中继搜索
return binarySearchRecursive(arr, target, left, mid - 1);
}
}
二分搜索的递归实现:通过不断缩小搜索范围来确定目标元素的位置。函数接受4个参数:
- 第一个参数为已排序的数组
- 第二个参数为要搜索的目标元素
- 第三和第四个参数表示当前搜索范围在数组中的左右下标。
Go 实现:
func binarySearch(arr []int, low int, high int, target int) int {
// high < low 表示整个数组都被搜索,但是没有找到目标元素
if high < low {
return -1
}
// 用 int((low + high) / 2) 可能会导致最大值 + 最小值溢出
mid := low + ((high - low) >> 1)
if arr[mid] > target {
// 当中间元素的值比target大时,在数组左侧进行搜索
return binarySearch(arr, low, mid - 1, target)
} else if arr[mid] < target {
// 当中间元素的值比target小时,在数组右侧进行搜索
return binarySearch(arr, mid + 1, high, target)
} else {
// 找到了目标元素,返回其索引
return mid
}
}
参数:
low:当前搜索区间的最低点索引high:当前搜索区间的最高点索引target:需要搜索的目标值
函数首先判断是否找到目标元素或者整个数组都已被搜索过。如果未找到,则找出当前搜索区间的中心元素的索引(由于low + high可能非常大,因此使用移位操作代替简单的平均除法)。中心元素的值用来与目标值进行比较。当中心元素比目标值大时,向前半部分继续搜索;当中心元素比目标值小时,向后半部分继续搜索;如果中心元素正好等于目标值,则已找到该元素。每次递归时,都会将搜索区域一分为二并递归搜索目标元素的对应半部分,最终在左侧或右侧子数组中找到目标值。
3.1.3 二分搜索的优缺点和适用场景
二分搜索的优点
- 二分搜索比线性搜索更快,尤其是对于大型数组。随着数组大小的增加,执行线性搜索所需的时间呈线性增加,而执行二分搜索所需的时间则呈对数增加。
- 二分搜索比具有类似时间复杂度的其他搜索算法(例如插值搜索或指数搜索)更有效。
- 二分搜索实现起来相对简单且易于理解,使其成为许多应用程序的不错选择。
- 二分搜索既可以用于排序数组,也可以用于排序链表,是一种灵活的算法。
- 二分搜索非常适合搜索存储在外部存储器(例如硬盘或云端)中的大型数据集。
- 二分搜索可以用作更复杂算法的构建块,例如计算机图形学和机器学习中使用的算法。
二分搜索的缺点
- 我们需要对数组进行排序。如果数组没有排序,我们必须先排序,然后再执行搜索。这为排序步骤增加了额外的
O(nlogn)时间复杂度,这会使非常小的数组的二分搜索效率降低。 - 二分搜索要求被搜索的数组存储在连续的内存位置。如果数组太大而无法放入内存,或者如果数组存储在外部存储器(如硬盘驱动器)或云中,这可能会成为一个问题。
- 二分搜索要求数组的元素是可比较的,这意味着它们必须能够被排序。如果数组的元素不是自然排序的,或者如果排序没有明确定义,这可能是一个问题。
- 对于搜索无法放入内存的非常大的数据集,二分搜索的效率可能低于其他算法(例如哈希表)。
二分搜索的应用
- 机器学习中的搜索:二分搜索可以用作机器学习中使用的更复杂算法的构建块,例如用于训练神经网络的算法或寻找模型的最佳超参数的算法。
- 常用于竞争性编程。
- 可用于在计算机图形中搜索。二分搜索可以用作计算机图形学中使用的更复杂算法的构建块,例如光线跟踪或纹理映射算法。
- 可用于搜索数据库。二分搜索可用于有效地搜索记录数据库,例如客户数据库或产品目录。
何时使用二分搜索
- 在搜索大型数据集时,因为它具有
O(logn)的时间复杂度,这意味着它比线性搜索快得多。 - 当数据集被排序时。
- 当数据存储在连续内存中时。
- 数据没有复杂的结构或关系。
3.1.4 推荐阅读
这里有几个可视化观察二分搜索过程的站点,可供学习:
- Binary Search animation
- Binary Search Animation by Y. Daniel Liang
- Binary and Linear Search Visualization
- Binary Search Tree Visualization
3.2 元二分搜索(单边二分搜索)
元二分搜索(Steven Skiena在《算法设计手册》中也称之为单边二分搜索)是二分搜索的一种改进形式,它以递增的方式构建数组中目标值的索引。与普通二分搜索一样,元二分搜索需要 O() 时间。
元二分搜索,也被称为单边二分搜索,是二分搜索算法的一种变体,用于搜索一个有序的列表或元素数组。这种算法是为了减少在列表中搜索一个给定元素所需的比较次数。
元二分搜索的基本思想是以一个大小为n的初始区间开始,其中包括整个数组。然后,该算法计算一个中间元素,就像二分搜索一样,并将其与目标元素进行比较。如果找到了目标元素,搜索就结束了。如果中间元素大于目标元素,算法将新的区间设置为上一个区间的左半部分,如果中间元素小于目标元素,新的区间被设置为上一个区间的右半部分。然而,与二分搜索不同,元二分搜索不对循环的每一次迭代进行比较。
该算法使用启发式方法来确定下一个区间的大小,它计算中间元素的值和目标元素的值之间的差异,并将该差异除以一个预先确定的常数,通常是2。该算法继续进行,直到找到目标元素或确定它不在列表中。
与二分搜索相比,元二分搜索的优点是在某些情况下可以进行较少的比较,特别是当目标元素接近列表的开头时。缺点是,在其他情况下,特别是当目标元素接近列表的末端时,该算法可能会比二分搜索执行更多的比较。因此,当列表的排序方式与目标元素的分布一致时,元二分搜索是最有效的。
JavaScript 实现:
function metaBinarySearch(arr, key) {
const n = arr.length
// 设置代表数组最大索引的比特数
const lg = parseInt(Math.log(n - 1) / Math.log(2)) + 1
// while ((1 << lg) < n - 1)
// lg += 1;
let pos = 0
for (let i = lg; i >= 0; i--) {
if (arr[pos] === key) return pos
// 递增构建目标值的索引值
const newPos = pos | (1 << i)
// 在单边搜索并不断更新索引位置
if (newPos < n && arr[newPos] <= key) pos = newPos
}
return arr[pos] === key ? pos : -1
}
Go 实现:
func metaBinarySearch(arr []int, key int) int {
n := len(arr)
lg := int(math.Log2(float64(n-1))) + 1
pos := 0
for i := lg; i >= 0; i-- {
if arr[pos] == key {
return pos
}
newPos := pos | (1 << i)
if newPos < n && arr[newPos] <= key {
pos = newPos
}
}
if A[pos] == key {
return pos
} else {
return -1
}
}
时间复杂度: O(),其中 n 是给定数组的大小
辅助空间: O(1),因为我们没有使用任何额外空间
4、三分搜索
考虑一个简单的例子,即双调序列:[1, 2, 3, 3, 3, 4, 4, 5, 5, 7, 6, 5, 5, 5, 4, 2, 2, 2]。
其中数字首先以递增顺序出现然后突然开始减少。现在,如果要求你在 O() 的时间复杂度下找到最大数?你能用二分查找来解决这个问题吗?
实际上,除了二分搜索之外,其实还有三分搜索(Ternary Search)算法。它的知名度没有二分搜索那么高,但是用处也不小。一句话,三分搜索用来确定函数在凹/凸区间上的极值点。什么是凹凸性呢?借用同济版《高等数学(上册)》里的图来说明:
上图展示的是凹凸性的最简单情况。若函数 f(x) 在 区间I(曲线上任意两点x1、x2)上连续,恒有:
- f(( + ) / 2) < (f() + f(x2)) / 2,那么
f(x)在区间I上是向上凹的; - f(( + ) / 2) > (f() + f(x2)) / 2,那么
f(x)在区间I上是向上凸的。
可见,凹凸性是相对的。有很多国外的书籍资料判断凹凸性时,是以原点方向为准,而不是y轴正方向,所以图a也可以是向下凸的,图b也可以是向下凹的。不管怎么说,函数f(x)在区间I上都有单峰(unimodal)性质,亦即有且仅有一个极值。三分搜索法就可以确定这个极值。
铺垫了这么多,三分搜索与二分搜索的本质不同是,在函数f(x)的某个区间[l, r]上取分界点时,不是只取一个中点,而是取两个分别位于1/3处的点,即:
m1 = l + (r - l) / 3,m2 = r - (r - l) / 3
这两个点把区间分成了三段。以凸函数(即有最大值)为例,有三种情况需要考虑:
- 若
f(m1) < f(m2),说明极值点位于[m1, r]区间内,可以不必再考虑[l, m1]区间; - 若
f(m1) > f(m2),说明极值点位于[l, m2]区间内,可以不必再考虑[m2, r]区间。 - 若
f(m1) = f(m2),说明极值点位于[m1, m2]区间内。
这样,每一轮迭代都会把搜索范围限制在原来的2/3,直到最终逼近极值点,即l和r之间的差值接近无穷小。容易推导出三分搜索的时间复杂度为:
T(n) = T(2n / 3) + 1 = O()
通过三分搜索算法,最大限度地提高你的搜索能力,减少时间的复杂性,实现更加高效的搜索。
三分搜索可以用来寻找数组中的一个元素。它类似于二分搜索,我们将数组分为两部分,但在这种算法中,我们将给定的数组分为三部分,并确定其中的关键(搜索的元素)。我们可以通过采取
mid1和mid2将数组分成三部分,其计算方法如下所示。最初,l和r将分别等于0和n-1,其中n是数组的长度。这与二分搜索相同。唯一的区别是,它将时间复杂度降低了一些。它的时间复杂度是 O(),二分搜索的时间复杂度是 O()。一般来说:
- 二分查找用于查找排序列表中的特定元素。
- 三元搜索用于查找函数的最大值或最小值。
注意: 数组需要排序才能对其进行三元搜索。
三元搜索的步骤:
- 首先,我们将键与 mid1 处的元素进行比较。如果发现相等,我们返回 mid1。
- 如果不是,则我们将键与 mid2 处的元素进行比较。如果发现相等,我们返回 mid2。
- 如果不是,那么我们检查键是否小于 mid1 处的元素。如果是,则返回第一部分。
- 如果不是,那么我们检查键是否大于 mid2 处的元素。如果是,则返回第三部分。
- 如果不是,那么我们回到第二(中间)部分。
例子:
JavaScript 实现:
function ternarySearch(l, r, key, ar) {
if (r >= l) {
const mid1 = l + parseInt((r - l) / 3, 10)
const mid2 = r - parseInt((r - l) / 3, 10)
if (ar[mid1] == key) {
return mid1
}
if (ar[mid2] == key) {
return mid2
}
// 由于key不存在于中间,检查它存在于哪个区域,然后在该区域重复搜索操作
if (key < ar[mid1]) {
// key 在 l 和 mid1 中间
return ternarySearch(l, mid1 - 1, key, ar)
}
if (key > ar[mid2]) {
// key 在 mid2 和 r 中间
return ternarySearch(mid2 + 1, r, key, ar)
}
// key 在 mid1 和 mid2 中间
return ternarySearch(mid1 + 1, mid2 - 1, key, ar)
}
return -1
}
- 时间复杂度: O()
- 辅助空间: O()
Go 实现:
func TenarySearch(arr []int, left int, right int, target int) int {
if left > right {
return -1
}
partition := (right - left) / 3
middle1 := left + partition
middle2 := right - partition
if arr[middle1] == target {
return middle1
} else if arr[middle2] == target {
return middle2
} else if target < arr[middle1] {
return TenarySearch(arr, left, middle1-1, target)
} else if target > arr[middle2] {
return TenarySearch(arr, middle2+1, right, target)
} else {
return TenarySearch(arr, middle1+1, middle2-1, target)
}
}
5、跳跃搜索
跳跃搜索算法跟二分搜索算法类似,它也是针对有序序列的搜索,只是它是通过搜索比较少的元素找到目标。当然它需要通过固定的跳跃间隔,这样它相比二分搜索效率提高了很多。
例如,假设我们有一个大小为 n 的数组 array 和要跳跃的步长为 m。 然后我们搜索索引 array[0],array[m],array[2m] ... ..array[km]等等。 一旦我们找到间隔(array[km] < x < array[(k + 1)m]),我们从索引 km 执行线性搜索操作来找到元素 x。
我们考虑以下数组:[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610]。数组的长度为16,跳跃搜索将以下列步骤找到值 55,假设跳跃的步长为 4:
- 步骤1:从索引0跳转到索引4;
- 步骤2:从索引4跳转到索引8;
- 步骤3:从索引8跳转到索引16;
- 步骤4:由于索引16处的元素大于55,因此我们将跳回一步到索引9。
- 步骤5:从索引9执行线性搜索以获得元素55。
关于跳跃的大小数值确定,在最坏的情况下,我们必须进行 n / m 次跳转,如果最后一个检查值大于要搜索的元素,则我们对线性搜索进行 m-1 比较。 因此,最坏情况下的比较总数将为 ((n / m) + m -1)。 当 m = 时,函数 ((n / m) + m -1) 的值将为最小值。 因此,最好的步长是 m = 。
该算法的特点:
- 该算法仅适用于有序数组;
- 要跳转的最佳大小为 O(), 这使得跳跃搜索 O() 的时间复杂度;
- 跳跃搜索的时间复杂度在线性搜索 O() 和二分搜索 O() 之间;
- 二分搜索算法相比跳跃搜索更好,但是跳跃搜索有以下优点:跳跃搜索仅遍历一次,而二分搜索最多需要 O(),考虑要搜索的元素是最小元素或小于最小的),我们选用跳跃搜索;
JavaScript 实现:
function jumpSearch(arr, x) {
const n = arr.length; // 数组长度
let step = Math.floor(Math.sqrt(n)); // 定义步长为平方根向下取整
let prev = 0; // 记录上一步的位置
// 查找 x 在哪个块中
while (arr[Math.min(step, n) - 1] < x) {
// 如果 x 在当前块中最大的数还小,那就要往前跳一个块
prev = step; // 记录上一个块的位置
step += Math.floor(Math.sqrt(n)); // 块的长度增加一个步长
if (prev >= n) {
// 如果上一块已经到了末尾,说明 x 不在数组中
return -1;
}
}
// 线性搜索块中的元素
while (arr[prev] < x) {
// 在当前块中线性查找
prev++; // 指针向后移动
if (prev === Math.min(step, n)) {
// 如果超出了当前块的范围,说明 x 不在数组中
return -1;
}
}
if (arr[prev] === x) {
// 找到了 x 的位置
return prev;
}
return -1; // 没有找到 x
}
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const x = 5;
const index = jumpSearch(arr, x);
console.log(index); // 4
Go 实现:
package main
import (
"fmt"
"math"
)
func jumpSearch(arr []int, x int) int {
n := len(arr) // 数组长度
step := int(math.Floor(math.Sqrt(float64(n)))) // 定义步长为平方根向下取整
prev := 0 // 记录上一步的位置
// 查找 x 在哪个块中
for arr[int(math.Min(float64(step), float64(n))-1)] < x {
// 如果 x 在当前块中最大的数还小,那就要往前跳一个块
prev = step // 记录上一个块的位置
step += int(math.Floor(math.Sqrt(float64(n)))) // 块的长度增加一个步长
if prev >= n {
// 如果上一块已经到了末尾,说明 x 不在数组中
return -1
}
}
// 线性搜索块中的元素
for arr[prev] < x {
// 在当前块中线性查找
prev++ // 指针向后移动
if prev == int(math.Min(float64(step), float64(n))) {
// 如果超出了当前块的范围,说明 x 不在数组中
return -1
}
}
if arr[prev] == x { // 找到了 x 的位置
return prev
}
return -1 // 没有找到 x
}
func main() {
arr := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
x := 5
index := jumpSearch(arr, x)
fmt.Println(index) // 4
}
- 时间复杂度:O()
- 辅助空间:O(1)
6、插值搜索
当我们从字典中搜索 “algorithm” 这个单词的时候,我们肯定不会傻傻地像二分搜索一样首先从中间开始。相反,我们会从首字母为 a 的地方开始搜索,然后根据第二个字母在字母表中的位置,找到相应的位置再继续搜索,这样重复这个过程,直到我们搜索到这个单词。
插值搜索(interpolation search)实际上是二分搜索的改良版。假设有这样一个数组 [0, 10, 20, 30, 40, 50, 60, 70, 80, 90],我们可以发现,每个相邻元素的差均为 10,满足均匀分布。如果要搜索元素 70,我们首先可以计算数组中小于等于 70 的元素占所有元素的比例的期望值 p = (70 - 0) / (90 - 0) = 7 / 9,而数组的长度 n 我们知道等于 10,所以我们期望搜索的索引值就为 ⌊n × p⌋ = 7,对应的元素为 70,恰好就是我们要找的元素。这样,原本用二分法需要搜索 3 次的用插值搜索只用搜索 1 次,大大提高了搜索的效率。
这里,我们用一个公式来表示每次搜索的期望索引值:
其中,l 和 r 分别代表数组的第一个和最后一个索引,key代表待搜索的元素。跟二分搜索一样,如果一次搜索失败,数组的长度就相应地减小,再代入上面的公式继续搜索,直到搜索成功或失败。
插值搜索的平均复杂度为 O(),但证明过程相当的复杂,这里我们就不再讨论了。要是数组不是均匀分布的,插值搜索的复杂度会退化到线性的复杂度 O(n)。举一个极端的例子,假设数组为 [0, 99, 100, 100, 100],我们要搜索元素 99。第一轮搜索我们计算出索引值为 3,第二轮为 2,第三轮为 1,这样我们搜索了三次。推广到含有 n 个元素的数组就需要搜索 n - 2 次,所以复杂度就为 Θ(n)。
因此,插值搜索的高效性只针对均匀分布的数组,而对于分布不均匀的数组,插值搜索便不再适用了。
JavaScript 实现:
function interpolationSearch(arr, low, high, x) {
let pos
// 因为数组是排序的,所以数组中的一个元素必须在角所定义的范围内
if (low <= high && x >= arr[low] && x <= arr[high]) {
// 在保持均匀分布的情况下探查位置。
pos = low + Math.floor(((high - low) / (arr[high] - arr[low])) * (x - arr[low]))
// 发现目标值的情况
if (arr[pos] === x) {
return pos
}
// 如果x比较大,x就在右边的子数组中
if (arr[pos] < x) {
return interpolationSearch(arr, pos + 1, high, x)
}
// 如果x比较小,x就在左边的子阵数组
if (arr[pos] > x) {
return interpolationSearch(arr, low, pos - 1, x)
}
}
return -1
}
Go 实现:
func interpolationSearch(arr []int, x int) int {
low, high := 0, len(arr)-1
for low <= high && x >= arr[low] && x <= arr[high] {
// 根据插值公式计算索引的估计值
pos := low + int(float64(x-arr[low])*(float64(high-low)/float64(arr[high]-arr[low])))
if arr[pos] == x {
return pos
} else if arr[pos] < x {
low = pos + 1
} else {
high = pos - 1
}
}
return -1
}
7、指数搜索
二分搜索,是一种分治算法,主要的思路是不断缩小排序数组中要查询的值可能存在的区间,每一次缩小一半。与二分搜索类似,指数搜索也是一种搜索固定值的分治算法,只不过它的空间范围是以指数形式缩小的。
指数搜索的大概过程如下图所示:
指数搜索同样需要数组是排序好的,如果数组是升序的,就从数组1号位开始,逐次检查元素是否小于等于要搜索的值,如果是,就把序号乘以2,以指数增长,如果不成立,就停止,留下当前的位置,假设是i。那么位置是0的元素怎么办呢?只能是在最开始的时候额外判断一下,如果位置是0的元素就是要找的值,那么就直接返回0,也就是元素的位置即可。
在上图的例子中,我们当然首先判断了一下位置为0的元素的值是否不等于要搜索的值,发现1≠9,那么说明要找的元素不在位置0,要继续寻找。然后我们要依次判断位置1,2,4,8的值是否小于等于要搜索的值,发现位置8元素小于等于9不成立,那么我们就停止判断,留下位置8。所以我们就知道,9肯定在[4, 8)中,注意这里是左闭右开区间,左闭的原因是,左边的位置判断元素≤9是成立的,也就是说左边界还是有可能等于9的,而右边界≤9不成立,所以肯定不可能等于9,故是左闭右开区间。
通过上面的过程,我们就成功地把9存在的区间范围,从整个数组的范围缩小到[4,8)中,在这个小范围内,我们无法继续进行指数搜索了,因为4已经很大了,再乘以2,就是8,都超过范围了,指数搜索失去了意义,但是[4,8)这段还是数组的一部分,数组元素还是有序的,所以我们可以使用二分搜索搜索[4,8)范围内9的位置,最终就可以找到9在位置6。
当然还有一种情况,那就是我们搜索的数是13,那么我们划定的范围就没有了右边界,这个时候数组的长度,就是右边界,也就是[8, 9),当然这里还是取左开右闭区间,因为位置9在数组中实际上是不存在的,但由于是右开区间,故我们可以不用对数组长度进行-1的操作,直接填入即可,这就是我们为什么要判断每个位置≤要查询元素的原因。
时间复杂度:还是上面的图的例子,实际上指数搜索只涉及到了 [0,8] 的范围内,也就是 [0,i] 的范围,而且只比较了0,1,2,4,8五个值,第一个是一定要查询的,后面的个数和数值的位置有关系。 [0,i] 中,也不是每个元素都比较,而是只遍历 O() 个元素,遍历完之后,还需要在[ i/2 , i ]内进行二分搜索,时间复杂度为 O() 。所以整体时间复杂度就是:
O() + O() = O() + O() = 2O() = O()
JavaScript 实现:
function exponentialSearch(arr, val) {
// 如果第一个元素匹配
if (arr[0] === val) {
return 0;
}
// 初始化范围大小为1
let i = 1;
const n = arr.length;
while (i < n && arr[i] <= val) {
i *= 2;
}
// 使用二分搜索在搜索范围内进行搜索
return binarySearch(arr, Math.floor(i / 2), Math.min(i, n - 1), val);
}
function binarySearch(arr, left, right, val) {
while (left <= right) {
const mid = Math.floor(left + (right - left) / 2);
if (arr[mid] === val) {
return mid;
} else if (arr[mid] < val) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
Go 实现:
func exponentialSearch(arr []int, val int) int {
// 如果第一个元素匹配
if arr[0] == val {
return 0
}
// 搜索范围并初始化范围大小为1
i := 1
n := len(arr)
for i < n && arr[i] <= val {
i *= 2
}
// 使用二分搜索法在搜索范围内进行搜索
return binarySearch(arr, int(math.Floor(float64(i/2))), int(math.Min(float64(i), float64(n-1))), val)
}
func binarySearch(arr []int, left int, right int, val int) int {
for left <= right {
mid := int(left + (right - left) / 2)
if arr[mid] == val {
return mid
} else if arr[mid] < val {
left = mid + 1
} else {
right = mid - 1
}
}
return -1
}
8、斐波那契搜索
斐波那契搜索是一种使用分而治之算法搜索已排序数组的方法,该算法借助斐波纳契数来缩小可能的位置。与二分搜索相比,排序数组被分成两个大小相等的部分,其中一个进一步检查,斐波那契搜索将数组分成两个部分,其大小为连续的斐波那契数。平均而言,这导致执行的比较增加了大约4%,但它的优点是只需要加法和减法来计算被访问数组元素的索引,而经典二分搜索需要比特移位,除法或乘法。斐波那契搜索具有O() 的平均和最差情况复杂度。总的来说是二分查找的一个优化。
Fibonacci序列具有一个数字是其前面两个连续数的总和的属性。因此,可以通过重复添加来计算序列。两个连续数字的比率接近黄金比率——1.618,二分搜索通过将搜索区域除以相等的部分 (1:1) 来工作。斐波那契搜索可以将其分成接近 1:1.618 的部分,同时使用更简单的操作。
黄金分割 点是指把一条 线段 分割为两部分,使其中一部分与全长之比等于另一部分与这部分之比。取其前三位数字的近视值是 0.618。由于按此比例设计的造型十分美丽,因此称为 黄金分割,也称为 中外比。这是一个神奇的数字,会带来意想不到的效果,比如《蒙娜丽莎的微笑》。
简单说,两条线的比例为
1:1.618,比如上图的头和身体的比例、鼻子和嘴巴下巴的比例。
对于一个 斐波那契数列:[1, 1, 2, 3, 5, 8, 13, 21, 34, 55],我们会发现斐波那契数列的 两个相邻数的比例,无限接近 黄金分割值 0.618。
简单说:
3/2=1.5 、5/3=1.667、8/5=1.6、13/8=1.625 这样看来,他们的比例值是无限接近的 1.618 的
1/2=0.5 、3/5=0.6、5/8=0.625、8/13=0.6125 这样看,他们的比例值是无限接近 0.618 的
算法的主要思想如下图:
斐波那契数列性质
F[k] = F[k-1] + F[k-2],由以上性质可以得到上图的组合:F[k]-1 = (F[k -1] -1) + (F[k-2] -1) + 1,那么说明:只要顺序表的长度为F[k]-1,则可以将该表分成 长度为F[k-1]-1和F[k-2]-1两段,如上图所示。那么中间值则为:mid = low + F[k-1]-1。
算法剖析:
斐波那契查找是依据斐波那契序列的特点对表进行分割的。假设开始时表中记录的个数(不妨设为n)比某个斐波那契数 () 小 1,即 n = - 1(这也是一个前提条件),然后将给定值 key 和 a[] 进行比较
- 若相等,则查找成功
- 若key < a[],则继续在 a[1] 至 a[ - 1] 的子表中进行查找
- 若key > a[],则继续在 a[ + 1] 至 a[ - 1] 的子表中进行查找。该子表的长度为 - 1
为了更加直观的理解斐波那契查找的过程,我们借助下图进行一个简单的分析,按 ①~③ 的顺序:
-
首先我们生成一个斐波那契数列: = 1, = 1, = 2, = 3, = 5, = 8, = 13;
-
然后我们设,有序表a, 从
a[1]~a[12]的值为1 ~ 12。(为了方便理解,储存该表的数组的a[0]为空),我们假定,需要查找的数为key = 4。 -
因为 n = - 1 ,可以知道此时,
u = 7。将key和 a[-1] (即a[8])进行比较,我们发现key < a[8]。 -
然后在
a[1]~a[7]中进行查找,此时u = 6。将key和 a[-1](即a[5])进行比较,我们发现key < a[5]。 -
然后再
a[1]~a[4]中进行查找,此时u = 5。将key和 a[-1](即a[3])进行比较,我们发现key > a[3]。 -
此时只剩
a[4],查找完毕。
JavaScript 实现:
function fibMonaccianSearch(arr, value) {
// 初始化斐波那契数
let fibMMm2 = 0
let fibMMm1 = 1
let fibM = fibMMm2 + fibMMm1
// 计算最小的大于n的Fibonacci数
while (fibM < arr.length) {
fibMMm2 = fibMMm1
fibMMm1 = fibM
fibM = fibMMm2 + fibMMm1
}
let offset = -1
while (fibM > 1) {
// 检查arr[min(fibM, n)-1]是否为value
const i = Math.min(offset + fibMMm2, arr.length - 1)
if (arr[i] < value) {
fibM = fibMMm1
fibMMm1 = fibMMm2
fibMMm2 = fibM - fibMMm1
offset = i
} else if (arr[i] > value) {
fibM = fibMMm2
fibMMm1 -= fibMMm2
fibMMm2 = fibM - fibMMm1
} else {
return i
}
}
if (fibMMm1 && arr[offset + 1] === value) {
return offset + 1
}
return -1
}
Go 实现:
func fibonacciSearch(arr []int, value int) int {
// 计算大于或等于数组长度的最小斐波那契数。
fibMinus2, fibMinus1 := 0, 1
fibCur := fibMinus2 + fibMinus1
for fibCur < len(arr) {
fibMinus2 = fibMinus1
fibMinus1 = fibCur
fibCur = fibMinus2 + fibMinus1
}
offset := -1
for fibCur > 1 {
i := min(offset+fibMinus2, len(arr)-1)
if arr[i] < value {
fibCur = fibMinus1
fibMinus1 = fibMinus2
fibMinus2 = fibCur - fibMinus1
offset = i
} else if arr[i] > value {
fibCur = fibMinus2
fibMinus1 = fibMinus1 - fibMinus2
fibMinus2 = fibCur - fibMinus1
} else {
return i
}
}
if fibMinus1 != 0 && offset+1 < len(arr) && arr[offset+1] == value {
return offset + 1
}
return -1
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
end~