数据结构与算法笔记3

本笔记记录王争专栏数据结构与算法之美的学习记录,以便自己复习回顾,代码部分均已经过验证,可直接使用

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个地方:

  1. 循环退出条件。 是low<=high,而不是low<high
  2. mid的取值 low + ((high-low)>>1)
  3. low和high的更新 low=mid+1 high=mid-1。

二分查找应用场景的局限性

  1. 二分查找依赖顺序表结构,也就是数组
  2. 二分查找针对有序数据
  3. 数据量太小不适合二分查找
  4. 数据量太大也不适合二分查找,因为底层依赖数组,需要连续内存

二分查找的应用场景

能用二分查找解决的,绝大部分更倾向于散列表或二叉查找树。二分查找实际更适合用在“近似”查找问题。

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)的值表示经过散列函数计算得到的散列值。

如何构造散列函数?三点基本要求:

  1. 散列函数计算得到的散列值是一个非负整数
  2. 如果key1=key2,那hash(key1)==hash(key2)
  3. 如果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、搬移数据,也保持了各个机器数据数量的均衡。

猜你喜欢

转载自blog.csdn.net/wjl31802/article/details/93190246