数据结构与算法系列——查找(一)

710 阅读9分钟

我们把世界看错,反说它欺骗了我们

前言

花了几个晚上的时间,复习了下数据结构,看得快忘得应该也快吧,无所谓这是常态,不打紧的,大不了多看几遍就是了。在看到某些复杂的算法实现时,比如最小生成树、最短路径、关键路径的相关算法,我就直接跳过了,不说图本来就比较复杂,更考虑到我是前端一员,应用不到等于白学,只能说目前考虑这些,意义不大,所以我将重点放在了了解概念上。但对于查找和排序,老生常谈的话题,当然非常有必要来手动实现,学习这些算法的目的就是为了丰富我们的编程思想,我目前就先将重点置于这里吧。

了解概念

查找就是根据给定的某个值,在指定数据集中确定一条其关键字等于给定值的记录。

查找按操作方式可分为静态查找动态查找静态查找指的就是在已有的数据中找到我们需要的,而动态查找指的是在查找的过程中还要进行插入或删除操作。

对于静态查找,我们采用线性表结构组织数据,使用顺序查找算法,高效查询考虑折半查找等技术;

对于动态查找,我们采用树结构组织数据,考虑二叉排序树的查找技术;

另外,还可以用哈希表结构来解决一些查找问题。

顺序查找

顺序查找,大家也喜欢叫暴力查找,就是按照顺序依次进行比较,直到查找到自己要找的。

实现也很简单,如下:

// key:关键字,data: 数据集
function sequence_search(key, data){
	for(let i = 0; i < data.length; i++){
		if(data[i] === key)	return true;
	}
	return false;
}

因为原始 for循环的效率最高,所以这里我并没有为了写法上的简便,而使用for...of循环,并且后面的优化也只能用原始 for循环来讲解。

扩展知识:

  1. js中常见循环的效率排名:原始 for> for...of> forEach > map> for...in

    解析:你也不用专门做测试,简单想一下就出来了。首先,for...in需要遍历原型链查找可枚举属性,故最慢;然后mapforEach比较,map返回一个新数组,故效率比forEach低;forEachfor...of比较,forEach需要维护循环的当前值、下标及额外参数,for...of只维护当前值,故for...of胜出;最后for...of原始 for比较,for...of内部调用Symbol.iterator接口产生遍历器,每次循环调用next()方法将遍历器的值产出,自然原始 for较快,也是最快。

  2. 为什么forEachmap无法跳出循环?

    答:forEachmap并不是for循环体,它们采用的是回调形式,在循环结束前会一直调用,故无法跳出。

都是很容易就想明白的道理啦~~

优化

在上面的for循环体中,每次循环都要判断下标i是否越界,这里有一种做法,可以跳过这个判断过程。

看了代码,你就懂了:

// key:关键字,data: 数据集
function sequence_search(key, data){
	data.push(key);
	let i = 0;
	while(data[i] !== key){
		i++;
	}
	return i+1 !== data.length;
}

上面代码,在数据集末尾添加上关键字,从头开始查找,一定能查找到关键字,最后我们只需要判断查找到的下标i加上1是否等于数据集长度即可。等于说明查找失败,不等于即为查找成功,所以我在最后写的是!==

这里的小优化就是避免了查找时每次都要判断下标i是否越界,在数据量较大情况下,效率将会极大提高。当然也不一定非要是在数据集末尾添加关键字,也可以在头部添加,但你要考虑在头部添加,数组(这里指真正意义上的数组)中每个数据元素都要往后移动一位。

扩展知识: js中的数组本质是对象,对象是采用哈希表实现的,所以并不是真正意义上的数组。真正意义上的数组是连续存储空间中数据元素的顺序存储,是线性表顺序存储结构的抽象实现。

顺序查找的时间复杂度为O(n),因为最坏情况下需要比较n次。

有序查找

顾名思义,数据集是有序的,通常从小到大有序。

折半查找

折半查找,又称二分查找。基本思想,如下:

在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录的关键字,则在中间记录的左半区继续查;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找。不断重复上述过程,直到查找成功,或所有查找区域无记录,查找失败为止

对于折半查找,有两种实现方式:

  1. 采用循环形式实现:
function binary_search(key, data){
 let [low, high] = [0, data.length - 1];
 while(low <= high){
   let mid = Number.parseInt(low + (high - low) / 2);
   if(key < data[mid])
     high = mid - 1;
   else if(key > data[mid])
     low = mid + 1;
   else
     return true;
 }
 return false;
}
  1. 采用递归形式实现:
function binary_search(key, data, low = 0, high = data.length - 1){
    if(low > high)	return false;
    let mid = Number.parseInt(low + (high - low) / 2);
    if(key === data[mid])	
     return true;
    else if(key < data[mid])
     return binary_search(key, data, low, mid - 1);
    else 
     return binary_search(key, data, mid + 1, high);
    return false;
}

折半查找时间复杂度为 O(log2n),想想完全二叉树的深度: log2n+1。折半查找适用于数据分布比较均匀的情况,如[1,2,3,4,...]

插值查找

对于折半查找,我们考虑的是一半即 12\frac 12,如果我们一开始就知道所查找的数据偏小,自然就会从较小部分查起,比如查字典。插值查找就是将折半查找的 12\frac 12 改进为 keya[low]a[high]a[low]\frac {key - a[low]}{a[high] - a[low]},理解起来就是关键字与最小值的部分占最大值与最小值之间的比例。

代码实现如下:

function insert_search(key, data){
	let [low, high] = [0, data.length - 1];
	while(low <= high){
		let mid = Number.parseInt(low + (high - low) * (key - data[low]) / (data[high] - data[low]);
		if(key < data[mid])
			high = mid - 1;
		else if(key > data[mid])
			low = mid + 1;
		else
			return true;
	}
	return false;
}

插值查找时间复杂度同样为 O(log2n),适用于数据分布极端不均匀的情况,如[1,2,100,101...]

斐波那契查找

斐波那契查找,它是利用了黄金分割原理来实现的。斐波那契数列:1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89…….(从第三个数开始,后边每一个数都是前两个数的和)。然后我们会发现,随着斐波那契数列的递增,前后两个数的比值会越来越接近 0.618,利用这个特性,我们就可以将黄金比例运用到查找技术中,具体细节如下:

斐波那契搜索就是在二分查找的基础上根据斐波那契数列进行分割的。在斐波那契数列找一个等于或略大于查找表中元素个数的数F[n],将原查找表扩展为长度为F[n] (如果要补充元素,则补充重复最后一个元素,直到满足F[n]个元素),完成后进行斐波那契分割,即F[n]个元素分割为前半部分F[n-1]个元素,后半部分F[n-2]个元素,找出要查找的元素在那一部分并递归,直到找到。

斐波那契查找,我们首先需要生成一个最末位数等于或略大于查找表中元素个数的斐波那契数列,我们写一个:

// size 为要查找的数据集的个数
function Fib(max_size){
    let arr  = [0,1];
    for(let i=2; i < max_size; i++)
        arr[i] = arr[i-1] + arr[i-2];
    return arr;
}

然后再根据定义来实现主体部分:

function fibonacci_search(key, data){
    const FibList = Fib(data.length);
    if(data.length < FibList[FibList.length-1]){ // 补全有序数组
        let num = FibList[FibList.length-1] - data.length;
        let arr = Array(num).fill(data[data.length-1]);
        data = data.concat(arr);
    }
    let [low, high, k] = [0, data.length - 1, FibList.length - 1];
	while(low <= high){
		let mid = low + FibList[k-1];
		if(key < data[mid]){
            high = mid - 1;
        	k -= 1;
        }else if(key > data[mid]){
            low = mid + 1;
        	k -= 2;
        }else
			return true;
	}
	return false;
}

斐波那契查找时间复杂度也为 O(log2n)。

上面三种有序表的查找算法本质上是分割点的选择不同,各有优劣,应根据实际情况综合考虑做出选择。

线性索引查找

索引就是把一个关键字与它对应的记录相关联的过程。线性索引就是将索引项集合组织为线性结构,也称为索引表。

稠密索引

稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项。

所下图所示:

对于稠密索引这个索引表而言,索引项一定是按照关键码有序的排列。索引项有序也就意味着,我们要查找关键字时,可以用折半,插值及斐波那契等有序查找算法,大大提高了效率。缺点是如果数据集非常大,比如上亿,那也就意味着索引也得同样的数据集长度规模。对于内存有限的计算机来说,可能就需要反复去访问磁盘,查找性能大大下降。

分块索引

分块索引即将数据集分块,以此来减少索引项的个数,然后只需对每一个块建立一个索引就好了。类似图书馆分类藏书。

数据集分的块需满足两个条件:

  1. 块内无序(当然也可以有序,因为需要额外付出时间与空间的代价,所以通常不要求有序)
  2. 块间有序(这很重要,块间有序了才能在查找时提高效率)

所下图所示:

在分块索引表中查找,可以分为两步:

  1. 在分块索引表中查找要查的关键字所在块。由于分块索引表是块间有序的,因此很容易利用折半、插值等算法得到结果。

  2. 根据块首指针找到相应的块,并在块中顺序查找关键码。因为块中可以是无序的,所以采用顺序查找。

倒排索引

倒排索引是最基础的搜索技术。倒排索引源于实际应用中需要根据属性的值来查找记录。这种索引表中的每一项都包括一个属性值和具有该属性值的各记录的地址。由于不是由记录来确定属性值,而是由属性值来确定记录的位置,因而称为倒排索引。

这里不做过多介绍。

👉👉👉 下一篇 《数据结构与算法系列——查找(二)》