本笔记记录王争专栏数据结构与算法之美的学习记录,以便自己复习回顾,代码部分均已经过验证,可直接使用
1. 二分查找(Binary Search)
针对有序数据集合的查找算法:二分查找算法,也叫折半查找算法。
思考题:假设有1000万个整数数据,每个数据占8个字节,如何设计数据结构和算法,快速判断某个整数是否出现在这1000万个数据中。希望该功能不要占太多内存,最多不超过100MB
实际开发场景:假设1000条订单数据,已经按照订单金额从小到大排序,每个订单金额都不同,且最小单位是元。想知道是否存在金额等于19元的订单,如果存在,返回订单数据,不存在,返回null。
简化模型,假设只有10个订单,金额分别:8,11,19,24,27,33,45,55,67,98
利用二分思想,每次都与区间的中间数据比对大小,缩小查找区间的范围。low和high表示待查找区间的下标,mid表示待查找区间的中间元素下标
二分查找针对的是一个有序的数据集合,查找思想有些类似分治思想,每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为0.
public class BinarySearch {
// 数组a,数组长度n,查找值value
public int bsearch(int[] a,int n,int value){
int low = 0;
int high = n-1;
while(low <= high){
// 防止low+high和溢出,也为了提升性能
int mid = low + ((high-low)>>1);
if(a[mid]==value){
return mid;
}else if(a[mid]<value){
low = mid+1;
}else{
high = mid -1;
}
}
return -1;
}
public static void main(String[] args) {
int[] a = {1,3,5,7,9,2,6,4};
int count = new BinarySearch().bsearch(a, a.length, 5);
System.out.println(count);
}
}
low、high、mid都是数组下标,其中low和high表示当前查找的区间范围,初始low=1,high=n-1。mid表示[low,high]的中间位置。通过对比a[mid]与value的大小,来更新接下来要查找的区间范围,直到找到或者区间缩小为0,就退出。
容易出错的3个地方:
- 循环退出条件。 是low<=high,而不是low<high
- mid的取值 low + ((high-low)>>1)
- low和high的更新 low=mid+1 high=mid-1。
二分查找应用场景的局限性
- 二分查找依赖顺序表结构,也就是数组
- 二分查找针对有序数据
- 数据量太小不适合二分查找
- 数据量太大也不适合二分查找,因为底层依赖数组,需要连续内存
二分查找的应用场景
能用二分查找解决的,绝大部分更倾向于散列表或二叉查找树。二分查找实际更适合用在“近似”查找问题。
2. 4种变形
1. 查找第一个值等于给定值的元素
如果有序数据集合中存在重复的数据,希望找到第一个值等于给定值的数据。
public int bsearch01(int[] a,int n,int value){
int low =0;
int high = n-1;
while(low<=high){
int mid = low +((high-low)>>1);
if(a[mid]>value){
high = mid-1;
}else if(a[mid]<value){
low = mid+1;
}else{
// mid=0说明该元素已经是数组的第一个元素,肯定是要找的;如果mid不是0,但是前一个元素不等于value,说明也是第一个等于给定值的元素
if((mid==0)||(a[mid-1]!=value)){
return mid;
}else{
// 前一个元素值也等于value,更新high
high = mid-1;
}
}
}
return -1;
}
2. 查找最后一个值等于给定值的元素
public int bsearch02(int[] a,int n,int value){
int low =0;
int high = n-1;
while(low<=high){
int mid = low +((high-low)>>1);
if(a[mid]>value){
high = mid-1;
}else if(a[mid]<value){
low = mid+1;
}else{
//a[mid]已经是最后一个元素;后一个元素值不等于value
if((mid==n-1) || (a[mid+1]!=value)){
return mid;
}else{
//不是最后一个值等于给定值的元素
low = mid+1;
}
}
}
return -1;
}
3. 查找第一个大于等于给定值的元素
// 查找第一个大于等于给定值的元素
public int bsearch03(int[] a,int n,int value){
int low =0;
int high = n-1;
while(low<=high){
int mid = low +((high-low)>>1);
if(a[mid]>=value){
// 同之前,第一个元素;前一个元素小于给定值
if((mid==0)||(a[mid-1]<value)) return mid;
// 前一个元素也满足,high左移
else high = mid -1;
}
}
return -1;
}
4. 查找最后一个小于等于给定值的元素
// 查找最后一个小于等于给定值的元素
public int bsearch04(int[] a,int n,int value){
int low =0;
int high = n-1;
while(low<=high){
int mid = low +((high-low)>>1);
// 如果大于等于给定值,左移high指针
if(a[mid]>value){
high = mid -1;
}else{
if((mid==n-1)||(a[mid+1]>value)) return mid;
else low = mid +1;
}
}
return -1;
}
3. 解答问题
如何快速定位出一个IP地址的归属地?
如果ip区间和归属地的对应关系不经常更新,可以先预处理这12万条数据,让其按照起始ip从小到大排序。如何排序?IP地址转换为32位的整型数。将起始地址,按照对应的整型的大小关系,从小到大排序。
然后,该问题转换为“在有序数组中,查找最后一个小于等于某个给定值的元素”
要查找某个ip归属地,先通过二分查找,找到最后一个起始ip小于等于这个ip的ip区间,再检查这个ip是否在这个ip区间内,如果在,取出对应归属地显示;不在,返回未找到。
4. 散列表
1. 散列的思想
在Word中输入错误的英文单词会标红,单词拼写检查功能,是如何实现的呢?
散列表,也叫hash表,用的是数组支持按照下标随机访问数据的特性,其实就是数组的一种扩展,由数组演化而来。
以马拉松参赛选手编号为例,编号我们叫key或者关键字,编号转化为数组下标的映射方法叫散列函数(哈希函数),而散列函数计算得到的值叫散列值(或hash值)
散列表用的就是数组支持按照下标随机访问的时候,时间复杂度为O(1)的特性。通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。当按照键值查询元素的时候,用同样的散列函数,将键值转化数组下标,从对应的数组下标的位置取数据。
2. 散列函数
定义为hash(key),其中key表示元素的键值,hash(key)的值表示经过散列函数计算得到的散列值。
如何构造散列函数?三点基本要求:
- 散列函数计算得到的散列值是一个非负整数
- 如果key1=key2,那hash(key1)==hash(key2)
- 如果key1!=key2,那hash(key1)!=hash(key2)
对于第三点,在真实的情况下,很难找到一个不同的key对应的散列值都不一样的散列函数,即便是MD5、SHA、CRC等hash算法,也无法完全避免散列冲突。
3. 散列冲突
主要有两种解决方案,开放寻址法(open addressing)和链表法(chaining)
1. 开放寻址法
核心思想:如果出现散列冲突,就重新探测一个空闲位置,将其插入。如何重新探测?简单的有:线性探测(linear probing)
往散列表插入数据时,如果某个数据经过散列函数散列后,存储位置已经被占用,就从当前位置开始,依次往后查找,看是否有空闲位置
线性探测的问题:当散列表插入数据越多,散列冲突发生的可能性越大,空闲位置越少,线性探测的时间越久。极端情况可能要探测整个散列表,最坏时间复杂度为O(n)
另外两种比较经典的探测方法:二次探测(Quadratic probing)和双重散列(Double hashing)
不管用哪种探测方法,当散列表中空闲位置不多时,散列冲突的概率会大大提高。一般会尽可能保证散列表中有一定比例的空闲槽位,用装载因子(load factor)表示空位的比例。
2. 链表法
更加常见的解决散列冲突的方法。散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素都放到相同槽对应的链表中。
5. 为什么散列表和链表经常一起用
以案例来说明
1. LRU缓存淘汰算法
借助散列表,把LRU缓存淘汰算法的时间复杂度降低为O(1)
先思考如何通过链表实现LRU缓存淘汰算法的
需要维护一个按照访问时间从大到小有序排列的链表结构。因为缓存大小有限,当缓存空间不够,需要淘汰一个数据的时候,直接从链表头部的节点删除。
当要缓存某个数据的时候,先在链表中查找这个数据。没找到,直接将数据放到链表的尾部;找到,移动到链表的尾部。因为查找数据要遍历链表,单纯链表实现时间复杂度为O(n)
总结一下,一个缓存(cache)系统主要包含下边几个操作:
- 往缓存中添加一个数据;
- 从缓存中删除一个数据;
- 在缓存中查找一个数据。
都会涉及到查找操作。如果将散列表和链表组合使用,可将这三个操作的时间复杂度降低为O(1)
使用双向链表存储数据,链表中每个节点处理存储数据(data)、前驱指针(prev)、后继指针(next)之外,还新增一个特殊字段hnext,将节点串在散列表的拉链中。
首先,如何查找一个数据。通过散列表,很快在缓存中找到一个数据。当找到数据后,还需要将它移动到双向链表的尾部。
其次,如何删除一个数据。需要找到数据所在节点,然后将节点删除。借助散列表,O(1)时间复杂度内找到要删除的节点。
最后,如何添加一个数据。添加数据稍微麻烦,先看是否已在缓存中,如果已在其中,需要将其移动到双向链表的尾部;不在,看缓存满不满。如果满了,将双向链表的头部的节点删除,再将数据放到链表的尾部;如果不满,直接放到链表的尾部。
其中涉及到的查找的操作,都可以用散列表完成。如删除头结点、链表尾部插入数据,都可以O(1)时间复杂度完成。这样就通过散列表和双向链表的组合使用,实现高效、支持LRU缓存淘汰算法的缓存系统原型。
class LRUCache extends LinkedHashMap<Integer,Integer>{
private static int MAX_ENTRIES;
public LRUCache(int capacity) {
super(capacity,(float)0.75f,true);
this.MAX_ENTRIES=capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size()>MAX_ENTRIES;
}
public int get(int key) {
return super.get(key)==null?-1:super.get(key);
}
public void put(int key, int value) {
super.put(key,value);
}
public static void main(String[] args) {
LRUCache cache = new LRUCache( 2 /* 缓存容量 */ );
cache.put(1, 1);
cache.put(2, 2);
System.out.println(cache.get(1)); // 返回 1
cache.put(3, 3); // 该操作会使得密钥 2 作废
System.out.println(cache.get(2)); // 返回 -1 (未找到)
cache.put(4, 4); // 该操作会使得密钥 1 作废
System.out.println(cache.get(1)); // 返回 -1 (未找到)
System.out.println(cache.get(3)); // 返回 3
System.out.println(cache.get(4)); // 返回 4
}
}
2. redis有序集合
在有序集合中,每个成员对象有两个重要的属性,key和score。我们不仅会通过score来查找数据,还会通过key来查找数据
举个例子:用户积分排行榜有这样一个功能:我们可以通过用户的id来查找积分信息,也可以通过积分区间来查找用户id或者姓名信息。这里包含id、姓名和积分的用户信息,就是成员对象,用户id就是key,积分就是score
如果细化redis有序集合的操作,就是下边这样:
- 添加一个成员对象
- 按照键值key删除一个成员对象
- 按照键值key查找一个成员对象
- 按照分值区间查找数据,比如查找积分在[100,356]之间的成员对象
- 按照分值从小到大排序成员变量
如果仅仅按照分值将成员对象组织成跳表的结构,那按照键值key来删除、查询成员对象很慢,解决方法和LRU缓存淘汰算法类似。可以再按照键值key构建一个散列表,这样按照key来删除、查找一个成员对象的时间复杂度就变成O(1)。同时,借助跳表结构,其他操作也非常高效
还有一类是查找成员对象的排名Rank或者根据排名区间查找成员对象,这个功能无法单纯用该组合高效实现。
6. hash算法
1. 概念
将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是hash算法,通过原始数据映射之后得到的二进制值串就是hash值。
优秀的hash算法需要满足的条件
- 从hash值不能反向推导出原始数据(所以hash算法也叫单向hash算法)
- 对输入数据非常敏感,原始数据只修改一个bit,最后得到的hash值也不相同
- hash冲突的概率要很小,对于不同的原始数据,hash值相同的概率非常小
- hash算法的执行效率要尽量高效,针对较长的文本,也能快速算出hash值
hash算法的应用,常见有七个:安全加密、唯一标识、数据校验、hash函数、负载均衡、数据分片、分布式存储
1. 安全加密
最常用加密的hash算法是MD5(MD5 Message-Digest Algorithm, MD5消息摘要算法)和SHA(Secure Hash Algorithm, 安全散列算法)
此外,还有对称加密DES(Data Encryption Standard,数据加密标准)、AES(Advanced Encryption Standard,高级加密标准)
为什么会hash冲突?
基于组合数学中的鸽巢原理,如果有10个鸽巢,11只鸽子,肯定有1个鸽巢中鸽子数量大于1.
同样,hash算法产生的hash值长度是固定且有限的,而要hash的数据是无穷的,一般情况,hash值越长,hash冲突的概率越低。
没有绝对安全的加密,需要权衡破解难度和计算时间,来选择加密算法
2. 唯一标识
海量的图库中搜索一张图是否存在,不能单纯用图片的元信息(如图片名称)对比,因为可能存在同名内容不同,或者不同名内容相同的情况,如何搜索?
最笨的方法,用图片的二进制码串与图库中所有图片的二进制码串一一比对,非常耗时。
我们可以给每一个图片取一个唯一标识,或者说信息摘要。如从开头取100个字节,中间取100个字节,最后再取100个字节,然后将这300个字节放一起,通过hash算法(如MD5)得到一个hash字符串,作为图片的唯一标识,判断是否在图库中。
如果想继续提高效率,可以把每个图片的唯一标识和响应的图片文件在图库中的路径信息,都存储在散列表。当要查看图片是否在图库中时,先通过hash算法对图片取唯一标识,在散列表中找是否存在该唯一标识,如果存在,再通过散列表中存储的文件路径,获取这个已经存在的图片,跟要插入的图片做全量的比对,看是否完全一样,一样,说明已存在。
3. 数据校验
迅雷、电驴这样的BT下载软件,如何下载?原理:基于P2P协议,从多个机器上并行下载一个2GB的电影,该电影文件被分隔为多个文件块(如100块,每块20MB),所有文件块都下载完成,再组装为完整的电影文件。
网络传输不安全,下载的文件块可能被宿主机器恶意修改过,或者下载过程出错,文件块不完整,如何校验文件块的安全、正确、完整呢?
具体协议很复杂,其中一种思路是:通过hash算法,对100个文件块分别取hash值,保存在种子文件中,文件块下载完成后,通过相同的hash算法,对下载好的文件块逐一求hash值,跟种子文件中保存的hash值比对,由于hash算法对数据的敏感,如果不同,说明该文件块不完整或被篡改了,重新从其他宿主机器下载该文件块。
4. hash函数
hash函数也是hash算法的应用。hash函数中用到的hash算法,更关注散列后的值能否平均分布,也就是,一组数据是否能均匀的散列在各个槽中。此外,如果hash函数太慢,影响散列表的性能,所以一般比较简单,追求效率。
5. 负载均衡
负载均衡算法有很多,如轮询、随机、加权轮询等,如何实现一个会话粘滞(session sticky)的负载均衡算法?也就是,在同一个客户端上,一次会话中所有请求都路由到同一个服务器上。
通过hash算法,对客户端IP地址或者会话ID计算hash值,将取得的hash值与服务器列表的大小取模运算,得到的就是应该路由到的服务器编号。这样,同一个IP过来的所有请求,都路由到同一个后端服务器上。
6. 数据分片
两个例子
1. 如何统计“搜索关键词”出现的次数
假设1T的日志文件,记录用户的搜索关键词,想要快速统计每个关键词被搜索的次数,如何做?
这个问题有两个难点:1是搜索日志太大了,没办法放到一台机器的内存中,2是只用一台机器耗时太长
先对数据进行分片,再采用多台机器处理,提高处理速度。具体思路:用n台机器并行处理。从日志文件中,依次读取每个关键词,且通过hash函数计算hash值,再跟n取模,最终得到的值,就是应该分配到的机器编号。
这样,相同hash值的搜索关键词就被分配到同一个机器上。每个机器分别计算关键词出现次数,最终合并得到最终结果。
2. 如何快速判断图片是否在图库中
假设图库有1亿张图片,在单台机器上构建散列表显然行不通,远远超出单台机器的内存上限。
同样对数据进行分片,多机处理。准备n台机器,让每台机器只维护某一部分图片对应的散列表。每次从图库中读取一个图片,计算唯一标识,再与机器个数n求余取模,得到值就是要分配的机器编号,再将该图片的唯一标识和图片路径发往对应的机器构建列表。
要判断一个图片是否在图库,用相同的hash算法,计算唯一标识,与机器个数n求余取模,假设得到的值是k,去编号k的机器构建的散列表中查找。
估算大致需要多少台机器。散列表中每个数据单元包含两个信息,hash值和图片文件的路径。假设通过MD5计算hash值,长度128bit,也就是16字节,文件路径长度上限为256字节,假设平均长度128字节。用链表法解决冲突,需要存储指针,占用8字节,散列表每个数据单元占用152字节(估算,可能不准确)
假设一台机器内存大小2GB,散列表装载因子0.75,一台机器可以给约1000万(2GB*0.75/152)张图片构建散列表,一亿张图片大约需要十几台机器,估算能更好的评估解决方案的可行性。
借助分片的思路,突破单机内存、CPU等资源的限制。
7. 分布式存储
为了提高数据的读写能力,一般采用分布式方式来存储数据,如分布式缓存。海量数据,一个缓存机器肯定不够,需要将数据分布在多肽机器。
如何决定将哪个数据放到哪个机器上?借助数据分片思想,对数据取hash值,再对机器个数取模,最终值就是应该存储的缓存机器编号。
如果数据增多,10台机器不够,扩容,变成11台机器怎么办?
所有数据都要重新计算hash值,搬移到正确机器上,缓存中的数据一下子都失效了,可能会发生雪崩效应。
需要一致性哈希算法,假设有k个机器,数据的hash值范围是[0,MAX],将整个范围划分为m个小区间(m远大于k),每个机器负责m/k个小区间,当有新机器加入时,将某几个小区间的数据,从原来的机器中搬到新的机器中。这样不用全部重新hash、搬移数据,也保持了各个机器数据数量的均衡。