目录
【二分查找】
二分查找的特殊情况
【哈希算法】
应用一:安全加密
应用二:唯一标识
应用三:数据校验
应用四:散列函数
应用五:负载均衡
应用六:数据分片
应用七:分布式存储(一致性哈希算法)
每日一练:搜索旋转排序数组
【二分查找】
二分查找是一种针对有序数据集合的查找算法,查找数据的时候每次都与区间的中间数据比对大小,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0,因此也叫折半查找算法。如下图所示(left、right、mid 分别表示待查找区间的左、右、中间下标):
可以很明显的看出来,二分查找的时间复杂度是O(logn),随着数据量的增大,查找的效率会很高效。在 42 亿个数据中用二分查找,最多只需要比较 32 次,因为2^32等于42亿多。二分查找最容易理解的写法就是递归代码,如下所示:
// 二分查找:递归实现
func BinarySearch1(a []int, v int) int {
n := len(a)
if n == 0 {
return -1
}
return BinarySearchRecursive(a, v, 0, n-1)
}
func BinarySearchRecursive(a []int, v int, low, high int) int {
if low > high {
return -1
}
mid := (low + high) / 2
if a[mid] == v {
return mid
} else if a[mid] > v {
return BinarySearchRecursive(a, v, low, mid-1)
} else {
return BinarySearchRecursive(a, v, mid+1, high)
}
}
func main() {
arr := []int{1, 3, 5, 6, 8}
fmt.Println(BinarySearch1(arr, 6)) // 3
}
二分查找也可以用循环来实现:
// 二分查找:循环实现
func BinarySearch2(a []int, v int) int {
n := len(a)
if n == 0 {
return -1
}
low := 0
high := n - 1
for low <= high { //注意:循环退出条件low<=high,而不是low<high
mid := (low + high) / 2
if a[mid] == v {
return mid
} else if a[mid] > v {
high = mid - 1
} else {
low = mid + 1
}
}
return -1
}
func main() {
arr := []int{1, 3, 5, 6, 8}
fmt.Println(BinarySearch2(arr, 6)) // 3
}
并不是所有情况下都可以用二分查找,它的应用场景是有很大局限性的,分析如下:
- 二分查找需要使用数组结构,链表就不可以,因为二分查找需要按照下标随机访问元素,而链表随机访问的时间复杂度是 O(n)。
- 二分查找需要数组必须是有序的,如果是个无序的数组,需要先排序,排序的最低时间复杂度是O(nlogn)。关于排序参考:数据结构与算法07:高效的排序算法
- 二分查找不适合太小或太大的数据,数据量太小可以直接循环遍历;由于数组的内存空间要求必须连续,因此数据量太大的话使用二分查找会比较吃内存。当然这里的太小和太大没有一个固定数值,根据自己的业务情况来灵活判定。
- 二分查找只能用在插入和删除操作不频繁的场景,比如一次排序多次查找。针对动态变化的数据集合,二分查找不再适用。
如果一个无序的数组没有频繁地插入和删除操作,那么可以进行一次排序,多次二分查找,这样排序的成本可被均摊;如果有频繁的插入和删除操作,要么每次插入和删除之后保证数据仍然有序,要么在每次二分查找之前都先进行排序,这种情况下维护有序的成本较高。
二分查找的特殊情况
上面示例中是比较简单的二分查找,不存在重复元素,实际上二分查找会有很多个特殊情况,比如当数组中存在重复的元素,然后需要查找 (第一个值/最后一个值) (等于/大于等于/小于等于)给定值的元素,实现起来就会不一样。比如还是用上面的二分查找的方法,在一个存在重复元素的数组中查找:
func main() {
myArr := []int{1, 3, 4, 5, 6, 8, 8, 8, 11, 21}
fmt.Println(BinarySearch1(myArr, 8)) //7
fmt.Println(BinarySearch2(myArr, 8)) //7
}
如果想查找第一个出现的数字8,上面的方法就不适用了。
(1)查找第一个等值的元素
改动思路:当中间元素刚好等于被查找的元素时,需要确认这个元素是不是第一个出现。逻辑改动如下:
- 如果mid等于0,说明前面没有元素了,那这个元素肯定是第一个出现;
- 如果mid-1位置的元素已经不等于要查找的元素了,那么当前mid这个位置就是第一个;
- 如果当前元素的前一个元素也等于被查找的元素,说明当前元素不是第一个出现,说明要查找的元素应该在[low,mid-1]区间,需要更新 high = mid - 1;
改动后的代码如下:
// 二分查找: 查找第一个等值的元素
func BinarySearch3(a []int, v int) int {
n := len(a)
if n == 0 {
return -1
}
low := 0
high := n - 1
for low <= high {
mid := (low + high) / 2
if a[mid] > v {
high = mid - 1
} else if a[mid] < v {
low = mid + 1
} else {
//重点需要改造这里
if mid == 0 || a[mid-1] != v {
return mid
} else {
high = mid - 1
}
}
}
return -1
}
func main() {
myArr := []int{1, 3, 4, 5, 6, 8, 8, 8, 11, 21}
// 查找第一个出现8的元素的位置
fmt.Println(BinarySearch3(myArr, 8)) //5
}
(2)查找最后一个等值的元素
这个情况和上面的类似,只不过在出现等值元素的时候判断条件稍微改动一下,改动的代码:
if mid == n-1 || a[mid+1] != v {
return mid
} else {
low = mid + 1
}
//...
// 查找最后一个出现8的元素的位置
fmt.Println(BinarySearch4(myArr, 8)) //7
(3)查找第一个大于指定值的元素
if mid != n-1 && a[mid+1] > v {
return mid + 1
} else {
low = mid + 1
}
//...
// 查找第一个大于8的元素:11的位置
fmt.Println(BinarySearch5(myArr, 8)) //8
(4)查找最后一个小于指定值的元素
if mid == 0 || a[mid-1] < v {
return mid - 1
} else {
high = mid - 1
}
//...
// 查找最后一个小于11的元素:最后一个8的位置
fmt.Println(BinarySearch6(myArr, 11)) //7
【问】现在有 1000 万个整数数据,每个数据占 8 个字节,需要快速判断某个整数是否出现在这 1000 万数据中,而且每次查找最多耗费100MB内存空间,应该怎么实现?
【答】将这1000万个整数数据存储在数组中,内存占用差不多是 80MB,符合内存的限制。然后对这 1000 万数据从小到大排序,再利用二分查找算法就可以找到想要的数据了。
源代码:search/BinarySearch.go · 浮尘/go-algo-demo - Gitee.com
【哈希算法】
哈希算法是把任意长度的原始数据通过散列算法转换成一个新的固定长度的数据输出,这个输出值就是哈希值。 转换后的哈希值可以用于检验一段数据或者一个文件的完整性,这里利用了哈希函数里的一个特性:在同一个哈希函数中输入两个相同的原始数据,它们总会得到相同的哈希值;而当这个数据文件里面的任何一点内容被修改之后,通过哈希函数所产生的哈希值也就不一样了,因此就可以判定这个数据文件是被修改过的文件。
- 从哈希值不能反向推导出原始数据,所以哈希算法也叫单向哈希算法;
对输入数据非常敏感,哪怕原始数据只修改了一个 Bit,最后得到的哈希值也大不相同;
散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小;
哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值。
哈希算法常见的应用:安全加密、唯一标识、数据校验、散列函数、负载均衡、数据分片、分布式存储。
应用一:安全加密
常见的用于加密的哈希函数算法有 MD5、SHA、DES、AES;越复杂越难破解的加密算法,需要的计算时间也越长,比如 SHA-256 比 SHA-1 要更复杂更安全,相应的计算时间就会比较长。
Git 就是采用了SHA-1算法对每一个文件对象都进行了一次哈希值运算,所以每一个提交的文件都会有自己的一个哈希值。在Git里面要找到一个文件对象其实是通过哈希值来寻找的。
MD5的哈希值是固定的 128 位二进制串,最多能表示 2^128 个数据,必然会存在哈希值相同的情况。尽管如此,如果想通过毫无规律的穷举方法来破解MD5的原数据也是很难的。一般在使用MD5的时候最好加上一个其它的字符串(salt)来改变生成后的MD5,因为很多常用密码的MD5值还是很容易被字典攻击的。
字典攻击就是数据库信息被“脱库”,黑客拿到了加密之后的密文,可以通过“猜”的方式来破解密码,把字典中的常用密码(比如000000、123456)用MD5计算哈希值,然后跟脱库后的密文比对,如果相同基本上就可以找到对应的明文密码。(注意,这里说是的是“基本上可以认为”,因为哈希算法存在散列冲突,也有密文一样但明文不一样的情况)。针对字典攻击,可以引入一个盐(salt),跟密码组合在一起,增加密码的复杂度。
应用二:唯一标识
在一个系统中,用户上传的图片很有可能存在重复,因此可以对重复的图片不再重复上传,从而节省存储空间。那么如果要在海量的图库中查找一个新增的图片是否存在,不能简单的根据图片的名称来比对,其实就算同一张图片的名称修改了,它的二进制数据时不会变的,因此可以通过校验图片的二进制数据来判断图片是否存在。
可以从图片的二进制数据开头取 100 个字节,从中间取 100 个字节,从最后再取 100 个字节,然后将这 300 个字节放到一块,通过哈希算法(比如 MD5)得到一个哈希字符串,用它作为图片的唯一标识,通过这个唯一标识来判定图片是否在图库中,这样就可以减少很多工作量。如果还想继续提高效率,可以把每个图片的二进制数据全量的哈希算法,然后和相应的图片路径都存储在数据库中,当要查看某个图片是不是在图库中的时候,先通过哈希算法对这个图片取唯一标识,然后在数据库中查找是否存在这个唯一标识。
想一想,百度网盘的“极速秒传”是怎么实现的?就是这个原理,相同的一个文件,即使很大,比如一个2GB的电影文件,别人之前已经传过了,那么你再上传也可以实现秒传。比如下面这样,它在前面几秒钟内“正在读取中...” 实际上是在用这个文件的唯一标识和数据库中已有的文件标识进行比对,比对后找出来了,直接把服务器上的那个文件链接指向你的网盘对应的文件夹里面就可以了。
应用三:数据校验
如果在网上下载一个很大的资源文件,一般都会标注文件的MD5或者SHA1,原因是这些源文件有可能是被分割成了很多块存储在服务器上的,等把所有的小块都下载完成后再组装成一个完整的文件。为了防止网络传输过程中所有小块的完整性,可以通过哈希算法对所有的文件小块分别取哈希值并且保存在种子文件中,当文件块下载完成之后再通过相同的哈希算法对下载好的文件块逐一求哈希值,然后跟种子文件中保存的哈希值比对。
比如这个网页下载GhostWin7的系统镜像,就标注了下载后的MD5和SHA1,
应用四:散列函数
在之前 数据结构与算法05:跳表和散列表 中说过散列表这种数据结构,是使用一个散列函数把一个较大的数据映射到一个较小的散列表中,这里的散列表也叫做“Hash表”,使用的就是哈希的原理。散列函数是设计一个散列表的关键,相对哈希算法的其他应用,散列函数即使出现个别散列冲突,也可以通过开放寻址法或者链表法解决。散列函数更加关注散列后的值是否能均匀的分布。
应用五:负载均衡
载均衡的算法有轮询、随机、加权轮询等,如果要保证在同一个客户端上每一次会话中的所有请求都路由到同一个服务器上,有两种办法:
- 维护一张映射关系表,存储客户端会话ID与服务器编号的映射关系。客户端发出的每次请求都要先在映射表中查找应该路由到的服务器编号,然后再请求编号对应的服务器。但是存在的问题是太多的客户端会导致映射表很大,浪费内存空间;客户端下线和上线、服务器扩容和缩容 都会导致映射失效,因此维护这样一个映射表的成本很大;
- 借助哈希算法,对客户端会话ID计算出哈希值与服务器列表的大小取模运算,最终得到的值就是应该被路由到的服务器编号。 这样就可以把同一个客户端过来的所有请求都路由到同一个后端服务器上。
应用六:数据分片
假如有 1TB 的日志文件,里面记录了用户的搜索关键词,现在想要快速统计出每个关键词被搜索的次数,没办法放到一台机器的内存中,怎么解决?
可以先对这些数据分片,然后采用多台机器并行处理,从这 1TB 的日志文件中依次读出每个搜索关键词,然后通过一个哈希函数计算出哈希值,然后再跟 n 取模得到一个值,就是应该被分配到的机器编号,同一个关键词的哈希值相同,就会分配到同一个机器上。接下来每个机器会分别计算关键词出现的次数,最后合并起来就是最终的结果。其实这个思路和上面二分查找的“分而治之”的思想类似,只不过具体解决方式是采用了哈希函数。
针对这种海量数据的处理问题,都可以采用多机分布式处理,借助数据分片的思路可以突破单机内存和CPU 等资源的限制。
应用七:分布式存储(一致性哈希算法)
现在的互联网时代都是海量数据存储,单台机器肯定承受不了这样的数据量级,一般都会采用分布式存储技术将数据分布在多台机器上。比如数据库的分库分表,就可以使用数据分片的思想,通过哈希算法对数据取哈希值,然后对机器个数取模,这个最终值就是应该存储的缓存机器编号。但是存在的问题是,如果将来机器数量扩容了,原来取模的结果就不一样了,所有的历史数据都要重新计算哈希值然后重新搬移到正确的机器上。
比如原来根据10取模, 数据1保存到节点1上, 数据2保存到节点2上;后来扩容后改为根据11取模, 因为分子发生了变化,所以取模的值都变化。
针对这种情况就需要使用一致性哈希算法,关于一致性哈希算法我在之前MySQL分库分表相关的文章中已经说过了,可以点击查看:MySQL分区分库分表和分布式集群_浮尘笔记的博客
每日一练:搜索旋转排序数组
力扣33. 搜索旋转排序数组
整数数组 nums 按升序排列,数组中的值 互不相同,nums在预先未知的某个下标 k 上进行了 旋转。例如, [0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2] 。
给你 旋转后 的数组 nums 和一个整数 target ,如果 nums 中存在这个目标值 target ,则返回它的下标,否则返回 -1 。你必须设计一个时间复杂度为 O(log n) 的算法解决此问题。
示例 1:输入:nums = [4,5,6,7,0,1,2], target = 0,输出:4
示例 2:输入:nums = [4,5,6,7,0,1,2], target = 3,输出:-1
思路分析:题目要求必须使用时间复杂度为 O(log n) 的算法,那么首先应该想到二分查找。设定两个指针left和right,分别指向数组的第一个和最后一个元素,然后用和中间的元素 mid 比较大小。时间复杂度 O (logn),空间复杂度 O (1)。
// https://gitee.com/rxbook/go-algo-demo/blob/master/leetcode/SearchRevolveSortedArray.go
func search(nums []int, target int) int {
left := 0
right := len(nums) - 1
//搜索区间 [left,right]
for left <= right {
mid := (left + right) / 2 //获得区间[left,right]的中间位置
if nums[mid] == target { //如果刚好命中,直接返回
return mid
}
if nums[mid] <= nums[right] { //mid和right在同一边
if nums[mid] < target && nums[right] >= target { //target在mid的右边
left = mid + 1
} else { //target在mid的左边
right = mid - 1
}
} else { //mid和right不在同一边
if nums[left] <= target && target < nums[mid] { //target在mid的左边
right = mid - 1
} else { //target在mid的右边
left = mid + 1
}
}
}
return -1
}
func main() {
fmt.Println(search([]int{4, 5, 6, 7, 0, 1, 2}, 0)) //4
fmt.Println(search([]int{4, 5, 6, 7, 0, 1, 2}, 3)) //-1
}