Java数据结构第四讲-树/递归/Hash

本文涉及的产品
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 MongoDB,独享型 2核8GB
推荐场景:
构建全方位客户视图
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
简介: Java数据结构第四讲-树/递归/Hash

9、树部分面试题

9.1、如何遍历一棵二叉树?

二叉树是n个有限元素的集合,由根元素以及左右子数组成。集合可以为空。

  • 概念:结点的度,结点所拥有的子树的个数称为度。
    叶节点,度为o的结点。
    分支节点,即非叶子结点
  • 路径:n1,n2,,,nk的长度为路径
  • 层数:根结点层数为1,其余的结点++双亲
  • 深度:最大层数
  • 满二叉树:所有叶子结点在同一层
  • 完全二叉树:叶子结点只能出现在最下层和次下层。
  • 性质: 非空二叉树第i层最多2{i-1}个结点
    深度为k,最多2{k}-1个结点,最少k个结点
    非空二叉树,度为0的节点数比度为2的节点数多1,n0=n2+1;
    n个结点的完全二叉树的深度为lgn+1
  • 存储:1、基于指针或者应用的二叉链式存储法;2、基于数组的顺序存储法(适合完全二叉树)
    链式存储法:每个结点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针
    顺序存储法:节点X存储在数据中下标为i的位置,下标为2i的位置存储的是左子节点,下标为2i+1的位置存储的是右子节点。
  • 遍历:使用队列来实现对二叉树的层序遍历,思路:根结点放入队列,每次从队列中取出一个结点,打印值。若这个值有子结点,子结点入队尾,直至队列为空。 代码P305(是一种广度优先的遍历算法)
    递归实现:中序遍历,先序遍历,后序遍历(表示的是节点与它的左右子树节点遍历打印的先后顺序) 时间复杂度O(n)
    非递归中序遍历:首先要移动到结点的左子树,完成左子树的遍历后,再将结点出栈进行
void InOrderNonRecursive(TreeNode root){
  if (root == null) {
    return;
  }
  Stack s = new Stack();
  while(true){
    while (root !=null) {
      s.push(root);
      root = root.left;
    }
    if (s.isEmpty()) {
      break;
    }
    root = (TreeNode) s.pop();
    System.out.println(root.val);
    root = root.right;
  }
}

非递归先序遍历:需要使用一个栈来记录当前节点,以便在完成左子树遍历后能返回到右子树中进行遍历;

* 在遍历左子树之前,把当前节点保存在栈中,直至遍历完左子树,将该元素出栈,然后找到右子树进行遍历。

void PreOderNonRecursive(TreeNode root){
  if (root == null) {
    return;
  }
  Stack s= new Stack();//使用栈保存将要遍历的结点
  while(true){
    while (root !=null) {
      System.out.println(root.val);
      s.push(root);
      root = root.left;
    }
    if (s.isEmpty()) {
      break;
    }
    root = (TreeNode) s.pop();
    root = root.right;
  }
}
递归后序遍历:
void PostOrder(TreeNode root){
  if (root!= null) {
    PostOrder(root.left);
    PostOrder(root.right);
    System.out.println(root.val);
  }
}
层序遍历:
void LevelOrder(TreeNode root){
  TreeNode temp;
  Queue q= new ArrayBlockingQueue(0);
  if (root == null) 
    return;
  q.add(root);
  while (!q.isEmpty()) {
    temp = (TreeNode) q.remove();
    System.out.println(temp.val);
    if (temp.left != null) {
      q.add(temp.left);
    }
    if (temp.left != null) {
      q.add(temp.right);
    }
  }
  q.clear();
}

9.2、二叉查找树(二叉搜索树)(Mysql索引的底层)

二叉查找树最大的特点就是,支持动态数据集合的快速插入、删除、查找操作

1、查找操作:我们先去根节点,如果它等于我们要查找的数据,就返回;如果比根节点小,就在左子树中递归查找;如果比根节点值大,就在右子树中递归查找。

public class BinarySearchTree {
  private Node tree;
  public Node find(int data) {
    Node p = tree;
    while (p != null) {
      if (data < p.data) p = p.left;
      else if (data > p.data) p = p.right;
      else return p;
    }
    return null;
  }
  public static class Node {
    private int data;
    private Node left;
    private Node right;
    public Node(int data) {
      this.data = data;
    }
  }
}

2、插入操作 得先比较,从根节点开始

public void insert(int data) {
  if (tree == null) {
    tree = new Node(data);
    return;
  }
  Node p = tree;
  while (p != null) {
    if (data > p.data) {
      if (p.right == null) {
        p.right = new Node(data);
        return;
      }
      p = p.right;
    } else { // data < p.data
      if (p.left == null) {
        p.left = new Node(data);
      return;
      }
      p = p.left;
    }
  }
}

3、二叉查找树的删除操作(1、如果要删除的节点没有子节点,我们只需要直接将父节点中指向要删除节点的指针置为null;2、要删除的节点有一个子节点,我们只需要更新父节点,指向要删除节点的指针

3、要删除的节点有两个子节点,找到这个节点的右子树中的最小节点,替换到要删除的节点上)

public void delete(int data) {
  Node p = tree; // p指向要删除的节点,初始化指向根节点
  Node pp = null; // pp记录的是p的父节点
  while(p != null && p.data != data) {
    pp = p;
    if (data > p.data) p = p.right;
    else p = p.left;
  }
  if (p == null) return; // 没有找到
  //要删除的节点有两个子节点
  if (p.left != null && p.right != null) {//查找右子树中最小节点
    Node minP = p.right;
    Node minPP = p; // minPP表示minP的父节点
    while (minP.left != null) {
      minPP = minP;
      minP = minP.left;
    }
    p.data = minP.data; // 将minP的数据替换到p中
    p = minP; // 下面就变成了删除minP了
    pp = minPP;
  }
  //删除节点是叶子节点或者仅有一个子节点
  Node child; // p的子节点
  if (p.left != null) child = p.left;
  else if (p.right != null) child = p.right;
  else child = null;
  if (pp == null) tree = child; // 删除的是根节点
  else if (pp.left == p) pp.left = child;
  else pp.right = child;
}

二叉查找树的执行效率:若是根节点的左右子树季度不平衡,已经退化到了链表,查找的时间复杂度为O(n);平衡二叉查找树的时间复杂度O(lgn)


9.3、红黑树的应用场景(TreeMap 红黑树:一种近似平衡的二叉查找树:二叉树中任意一个节点的左右子树的高度相差不能大于1。包括完全二叉树、满二叉树) 红黑树一定得掌握
  • 红黑树的由来
    红黑树是于1972年发明的,当时称为对称二叉B树,1978年得到优化,正式命名为红黑树。它的主要特征是在每个节点上增加一个属性来表示节点的颜色,可以是红色,也可以是黑色。红黑树和 AVL 树类似,都是在进行插入和删除元素时,通过特定的旋转来保持自身平衡的, 从而获得较高的查找性能。与AVL树相比,红黑树并不追求所有递归子树的高度差不超过1, 而是保证从根节点到叶子节点的最长路径不超过最短路径的2 倍,所以它的最坏运行时间也是 O(logn)。红黑树通过重新着色和左右旋转,更加高效地完成了插入和删除操作后的自平衡调整。当然 , 红黑树在本质上还是二叉查找树,它额外引入了 5 个约束条件,如下
  • 红黑树的特点
    1、每个节点要么是红色,要么是黑色;
    2、根节点必须是黑色;
    3、红色节点不能连续(红色节点的孩子和父亲都不能是红色);
    4、对于每个节点,从该点至叶子的任何路径,都含有相同个数的黑色节点;
    5、确保节点的左右子树的高度差,不会超过二者中较低那个的一倍;
  • 应用场景:搜索,插入删除次数多(为了解决普通二叉查找树在数据更新的过程中,复杂度退化的问题而产生的)
    1、map和set都是用红黑树实现的
    2、linux进程调度Completely Fair Scheduler,用红黑树管理进程控制块
    3、epoll在内核中的实现,用红黑树管理事件块
    4、nginx中,用红黑树管理timer
    5、Java的TreeMap实现
    AVL树适合用于插入删除次数比较少,但查找多的情况
  • 关于动态数据结构:链表/栈/队列/哈希表(链表适合遍历的场景,插入和删除操作方便;栈和队列可以算一种特殊的链表,分别使用先进后出和先进先出的场景;哈希表适合插入和删除比较少,查找比较多的场景;红黑树对数据要求有序,对数据增删改查都有一定要求的时候)
  • 散列表/跳表/红黑树性能对比:
    1、散列表:插入删除查找都是O(1),是最常用的,缺点是不能顺序遍历以及扩容缩容的性能损耗。适用于不需要顺序遍历、数据更新不那么频繁的;
    2、跳表:插入删除查找都是O(lgn),能顺序遍历,缺点是空间复杂度O(n),适用于不那么在意内存空间的,其顺序遍历和区间查找非常方便;
    3、红黑树:插入删除查找都是O(lgn),中序遍历即是顺序遍历,稳定。缺点是难以实现,去查找不方便。

9.4、数据结构 堆
  • 满足条件:1、堆是一个完全二叉树;2、堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值
    堆都支持哪些操作以及如何存储一个堆(通过数组来存储)
  • 缺点:对于一组已经有序的数据来说,经过建堆后,数据反而变得更无序了
  • 堆排序的过程:1、建立初始堆(把数组中的元素的序列看成是一颗完全二叉树,对该二叉树进行调整,使之成为堆) 根节点的索引是1
    2、堆排序(把根元素与最右子节点交换,然后再次构建堆,再与倒数第二集结点交换,然后再构建堆) 生成由小到大排列的数组
    时间复杂度:假设有n个数据,需要进行n-1次建堆,每次建堆本身耗时lgn,则其时间效率为O(nlgn) 空间复杂度O(1)
    建堆操作:
private static void buildHeap(int[] a, int n) {
  for (int i = n/2; i >= 1; --i) {
    heapify(a, n, i);
  }
}
private static void heapify(int[] a, int n, int i) {
  while (true) {
    int maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}
排序操作: //n表示数据的个数,数组a中的数据从下标1到n的位置。
public static void sort(int[] a, int n) {
  buildHeap(a, n);
  int k = n;
  while (k > 1) {
    swap(a, 1, k);
    --k;
    heapify(a, k, 1);
  }
}

为什么快速排序要比堆排序性能好?

1、堆排序数据访问的方式没有快速排序友好(开拍是顺序访问;堆排序是跳着访问,对cpu缓存不友好)

2、同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序

应用:1、优先级队列;2、topK;3、流里面的中位数;


9.5、AC自动机:如何用多模式串匹配实现敏感词过滤功能?(使用Trie树)

字符串匹配算法:单模式串匹配算法(BF算法、RK算法、BM算法、KMP算法),多模式串匹配算法(Trie树 最长前缀匹配)

AC自动机算法包含两个部分,第一部分是将多个模式串构建成AC自动机,第二部分是在AC自动机中匹配主串。第一部分又分为两个小的步骤,一个是将模式串构建成Trie树,另一个是在Trie树上构建失败指针

适用场景:

  • 单模式串匹配:
    BF(直接匹配算法 简单场景,主串和模式串都不太长, O(mn) 效率最低)
    KP(字符集范围不要太大且模式串不要太长,否则hash值可能冲突,O(n))
    naive-BM(模式串最好不要太长(因为预处理较重),比如IDE编辑器里的查找场景;预处理O(m
    m),匹配O(n),实现较复杂,需要较多额外空间)
    KMP(适合所有场景,整体实现起来也比BM简单,O(n+m),仅需一个next数组的O(n)额外空间;但统计意义下似乎BM更快)
    还有一种比BM/KMP更快,且实现+理解起来都更容易的Sunday算法
  • 多模式串匹配:
    naive-Trie(适合多模式串公共前缀较多的匹配(O(n*k)) 或者 根据公共前缀进行查找(O(k))的场景,比如搜索框的自动补全提示 root不存储字符)
    AC自动机(适合大量文本中多模式串的精确匹配查找, 查找的复杂度可以到O(n))**
    定义:AC自动机实际上就是在Trie树之上,加了类似KMP的next数组,只不过此处的next数组是构建在树上
public class AcNode {
  public char data; 
  public AcNode[] children = new AcNode[26]; //字符集只包含a~z这26个字符
  public boolean isEndingChar = false; //结尾字符为true
  public int length = -1; //当isEndingChar=true时,记录模式串长度
  public AcNode fail; //失败指针  相当于KMP中失效函数next数组
  public AcNode(char data) {
    this.data = data;
  }
}
9.6、B树和B+树的区别(为什么 MongoDB 索引选择B树,而 Mysql 选择B+树)?

一个是数据的保存位置,

  • B树的数据每个节点既保存索引,又保存数据;B+树的数据只会落在叶子节点

一个是相邻节点的指向

  • 增加了相邻节点的指向指针

上述两种特性造成的现象为:

1、B+树查询时间复杂度固定是logn,B树查询复杂度最好是 O(1)。

2、B+树相邻接点的指针可以大大增加区间访问性,可使用在范围查询等,而B树每个节点 key 和 data 在一起,无法区间查找。

3、B+树更适合外部存储,也就是磁盘存储。由于父级节点无 data 域,每个节点能索引的范围更大更精确

4、注意这个区别相当重要,B树每个节点即保存数据又保存索引,所以磁盘IO的次数很少,B+树只有叶子节点保存,磁盘IO多,但是区间访问比较好。

MongoDB

  • MongoDB 是文档型的数据库,是一种 nosql,它使用类 Json 格式保存数据。比如之前我们的表可能有用户表、订单表、购物车表等等,还要建立他们之间的外键关联关系。但是类Json就不一样了。
  • MongoDB使用B树,所有节点都有Data域,只要找到指定索引就可以进行访问,无疑单次查询平均快于Mysql。

Mysql

  • Mysql作为一个关系型数据库,数据的关联性是非常强的,区间访问是常见的一种情况,B+树由于数据全部存储在叶子节点,并且通过指针串在一起,这样就很容易的进行区间遍历甚至全部遍历。

10、递归方法

递归的使用场景

1、查询类目树

使用递归时应该注意的问题?

1、警惕堆栈溢出:(如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险) 解决方案:递归调用超过一定的深度后,就停止队规,返回错误(由于递归深度无法事先知道,这种方案不实用)

  • 在递归调用类目树时遇到过,由于程序不当,造成了树一直被递归调用,从而导致堆栈溢出

2、递归代码的重复计算问题:某一个子问题被重复计算了多次。解决方案:通过一个数据结构(散列表)保存已经求结果的f(k),先看子问题是否被求解过,若是,直接从散列表中取值返回。

public int f(int n){
  if(n==1) return 1;
  if(n==2) return 2;
  //hasSolvedList可以理解为一个Map,key是n,value是f(n)
  hasSolvedList.containsKey(n){
    return hasSolvedList.get(n);
  }
  int ret =f(n-1)+f(n-2);
  hasSolvedList.put(n,ret);
  return ret;
}//王争这道题没写好,他的本意是记忆化递归

3、空间复杂度,比较大,为O(n)

3、递归代码改写为非递归代码:f(x) =f(x-1)+1 ->

int f(int n){
    int ret = 1;
    for (int i = 2; i <= n; ++i) {
      ret = ret + 1;
    }
    return ret;
  }
  f(n) = f(n-1)+f(n-2) ->
  int f(int n){
    if (n == 1) return 1;
    if (n == 2) return 2;
    int ret = 0;int pre = 2;int prepre = 1;
    for (int i = 3; i <= n; ++i) {
      ret = pre + prepre;
      prepre = pre;
      pre = ret;
    }
    return ret;
  }

4、如何调试递归?调试递归:1.打印日志发现,递归值。2.结合条件断点进行调试

编程题2:如何找到“最终推荐人”?在数据库表中,我们可以记录两行数据,其中actor_id表示用户id,referrer_id表示推荐人id

long findRootReferrerId(long actorId) {
  Long referrerId = select referrer_id from [table] where actor_id = actorId;
  if (referrerId == null) return actorId;
  return findRootReferrerId(referrerId);
}

可能出现的问题:1、递归很深会出现堆栈溢出的问题 2、若数据库中存在脏数据,可能会出现无限循环的问题 (如何来检测环的存在呢?)

  • 检测环可以构造一个set集合或者散列表(下面都叫散列表)。每次获取到上层推荐人就去散列表里先查,没有查到的话就加入,如果存在则表示存在环了。当然,每一次查询都是一个自己的散列表,不能共用。
  • 检测环的第二种方法:双指针法(从起点开始分别以2x,1x速度出发两个指针,当遇到null停止,相遇点为null时说明没有环,如果相遇点不为null,说明有环)
    编程1;实现斐波那契数列求值f(n)=f(n-1)+f(n-2) or 假如这里有n个台阶,每次你可以跨1个台阶或者2个台阶,请问走这n个台阶有多少种走法?
    递推公式:f(n)=f(n-1)+f(n-2) 递归终止条件:f(1)=1,f(2)=2
    最终的递归代码是这样的:
int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  return f(n-1) + f(n-2);
}

编程题2:汉诺塔(思想:将原柱最上面的n-1个圆盘移动到辅助柱;将第n个圆盘从原柱移到目的柱;将辅助柱的n-1个圆盘移动到目的柱)

void TowersOfHanoi(int n,char frompeg,char topeg,char auxpeg){
  /*如果仅有一个圆盘,直接移动,然后返回*/
  if(n==1){
    syso("Move disk 1 from peg"+frompeg+"to peg"+topeg);
    return;
  }
  /*利用c作为辅助,将A柱最上面的n-1个圆盘移动到B柱*/
  TowersOfHanoi(n-1, frompeg, topeg,auxpeg);
  /*将余下的圆盘从A柱移动C柱*/
  syso("Move disk 1 from peg"+frompeg+"to peg"+topeg);
  /*利用A柱作为辅助,将B柱上的n-1个圆盘移到C柱*/
  TowersOfHanoi(n-1, auxpeg,topeg,frompeg);
}

编程3;实现求阶乘n!

int Fact(int n){
  //基本情形:当参数为0或1时,返回1
  if (n==1) {
    return 1;
  }else if (n == 0) {
    return 1;
  }else {
    return n*Fact(n-1);
  }
}

编程4;实现一组数据集合的全排列

public class 实现一组数据集合的全排列 {
      public void printAllSort(int[] arr) {
        if (arr == null || arr.length == 0) {
          return;
        }
        if (arr.length == 1) {
          System.out.println(arr[0]);
        }
        List<List<Integer>> result = _printAllSort(arr);
        int count=0;
        for (List list : result) {
          count++;
          System.out.println(list+" 第"+count+"种");
        }
      }
      private List<List<Integer>> _printAllSort(int[] tmpArr) {
        // 结束条件
        List<List<Integer>> result = new ArrayList<>();
        if (tmpArr.length == 2) {
          List<Integer> subList = new ArrayList<>();
          List<Integer> subList2 = new ArrayList<>();
          subList.add(tmpArr[0]);
          subList.add(tmpArr[1]);
          subList2.add(tmpArr[1]);
          subList2.add(tmpArr[0]);
          result.add(subList);
          result.add(subList2);
          return result;
        }
        // 当前层处理
        for (int i = 0; i < tmpArr.length; i++) {
          // 顺序拿出一个参数,其余交给下一层处理
          int tmp = tmpArr[i];
          int[] arr = new int[tmpArr.length - 1];
          int offset = 0;
          for (int j = 0; j < tmpArr.length; j++) {
            if (i != j) {
              arr[offset] = tmpArr[j];
              offset++;
            }
          }
          List<List<Integer>> nextLevelResult = _printAllSort(arr);
          // 处理下一层结果(当前值加到结果的前面、后面)
          for (List<Integer> nextList : nextLevelResult) {
            List<Integer> appendList = new ArrayList<>();
            appendList.add(tmp);
            appendList.addAll(nextList);
            result.add(appendList);
          }
        }
        return result;
      }
    }//[1, 2, 3, 4] 第1种  [1, 2, 4, 3] 第2种  [1, 3, 2, 4] 第3种  [1, 3, 4, 2] 第4种  [1, 4, 2, 3] 第5种  [1, 4, 3, 2] 第6种

编程5;爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

//方法1:暴力法 使用递归
public int climbStairs(int n) {
  return climb_Stairs(0, n);
}
public int climb_Stairs(int i, int n) {
  if (i > n) {
    return 0;
  }
  if (i == n) {
    return 1;
  }
  return climb_Stairs(i + 1, n) + climb_Stairs(i + 2, n);
}//时间复杂度:O(2^n)

方法2:记忆化递归 每一步的结果存储在 memomemo 数组之中,每当函数再次被调用,我们就直接从 memomemo 数组返回结果

public class 记忆化递归 {
  public int climbStairs(int n) {
    int memo[] = new int[n + 1];
    return climb_Stairs(0, n, memo);
  }
  public int climb_Stairs(int i, int n, int memo[]) {
    if (i > n) {
      return 0;
    }
    if (i == n) {
      return 1;
    }
    if (memo[i] > 0) {
      return memo[i];
    }
    memo[i] = climb_Stairs(i + 1, n, memo) + climb_Stairs(i + 2, n, memo);
    return memo[i];
  }
}

方法3:动态规划 第i阶可以由以下两种方法得到:在第(i-1)阶后向上爬一阶。在第(i-2)阶后向上爬 2 阶。状态转移公式:dp[i]=dp[i?1]+dp[i?2]

public class 动态规划求解爬楼梯 {
  public int climbStairs(int n) {
    if (n == 1) {
      return 1;
    }
    int[] dp = new int[n + 1];
    dp[1] = 1;
    dp[2] = 2;
    for (int i = 3; i <= n; i++) {
      dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
  }
}

11、散列表相关知识点(HashMap/LinkedHashMap)

11.1、什么是hash算法,他们用于什么?

hash算法是一个hash函数,他使用任意长度的字符串,并将其减少为唯一的固定长度字符串。他用于密码有效性、消息和数据完整性以及许多其他加密系统。

  • 加密算法原理:加密是将明文转换成“密文”的过程。要转换文本,算法使用一系列被称为“键”的位来进行计算。密钥越大,创建密文的潜在模式越多。大多数加密算法使用长度约为64到128位的固定输入块,而有些则使用流方法。
  • 常用的加密算法:3-way blowfish cast cmea gost des/triple des idea loki crc MD5
  • 哈希算法的应用:安全加密(MD5/SHA)、数据校验、唯一标识、散列函数,负载均衡、数据分片、分布式存储 **
  • 安全加密:第一是很难根据哈希值反向推导出原始数据,第二是散列冲突的概率很小。
  • 唯一标识:图片的唯一id(我们可以把每个图片的唯一标识,和相应的图片文件在图库中的路径信息,都存储在散列表中)
  • 数据校验:我们通过哈希算法,对100个文件块分别取哈希值,并且保存在种子文件中。哈希算法特点,对数据很敏感。只要文件块的内容有一丁点儿的改变,最后计算出的哈希值就会完全不同。所以,当文件块下载完成之后,我们可以通过相同的哈希算法,对下载好的文件块逐一求哈希值,然后跟种子文件中保存的哈希值比对。如果不同,说明这个文件块不完整或者被篡改了,需要再重新从其他宿主机器上下载这个文件块。
  • 散列函数:对于冲突的要求低很多(即便出现个别散列冲突,只要不是过于严重,我们都可以通过开放寻址法或者链表法解决),更看重的是散列的平均性和哈希算法的执行效率。
  • 在分布式系统中的应用:
  • 负载均衡:(利用哈希算法替代映射表)通过哈希算法,对客户端IP地址或者会话ID计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到的值就是应该被路由到的服务器编号。

数据分片:(通过哈希算法对处理的海浪数据进行分片,多机分布式处理,突破单机资源限制)

1、如何统计“搜索关键词”出现的次数?(难点:搜索日志很大,没办法放到一台机器的内存中;第二:如果只用一台机器处理数据,时间耗费很长)

我们可以先对数据进行分片,然后采用多台机器处理的方法,来提高处理速度(n台机器,从搜索记录的日志文件中,依次独处每个搜索关键词,并且通过哈希函数计算hash值,然后再跟n取模

最终得到的值,就是应该被分配到的机器编号)(MapReduce的基本设计思想)

2、如何快速判断图片是否在图库中?我们同样可以对数据进行分片,然后采用多机处理。我们准备n台机器,让每台机器只维护某一部分图片对应的散列表。我们每次从图库中读取一个图片,计算唯

一标识,然后与机器个数n求余取模,得到的值就对应要分配的机器编号,然后将这个图片的唯一标识和图片路径发往对应的机器构建散列表。

当我们要判断一个图片是否在图库中的时候,我们通过同样的哈希算法,计算这个图片的唯一标识,然后与机器个数n求余取模。假设得到的值是k,那就去编号k的机器构建的散列表中查找。

分布式存储:(利用一致性哈希算法,解决缓存等分布式系统的扩容/缩容导致数据大量搬移的问题)

假设我们有k个机器,数据的哈希值的范围是[0, MAX]。我们将整个范围划分成m个小区间(m远大于k),每个机器负责m/k个小区间。当有新机器加入的时候,我们就将某几个小区间的数据,从原来的机器中搬移到新的机器中。这样,既不用全部重新哈希、搬移数据,也保持了各个机器上数据数量的均衡。

散列表:散列表用的就是数组支持按照下标随机访问的时候,时间复杂度是O(1)的特性。我们通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。

当我们按照键值查询元素时,我们用同样的散列函数,将键值转化数组下标。

散列函数:1. 散列函数计算得到的散列值是一个非负整数;2. 如果key1 = key2,那hash(key1) == hash(key2);3. 如果key1 ≠ key2,那hash(key1) ≠ hash(key2)(这一点即使是MD5/CRC算法也无法完全避免散列冲突)

  • 应用:判断单词是否拼写错误(使用Trie树更好);redis的字典是使用链式法来解决散列冲突的,并且使用了渐进式rehash方式进行hash表的弹性扩容

Q:区块链使用的是哪种哈希算法?是为了什么问题而使用的呢?

A:区块链是一块块区块组成的,每个区块分为两部分:区块头和区块体(区块头保存着自己区块体和上一个区块头 的哈希值),因为这种链式关系和哈希值的唯一性,只要区块链上任意一个区块被修改过,后面所有区块保存的哈希值就不对了。区块链使用的是SHA256哈希算法,计算哈希值非常耗时,如果要篡改一个区块,就必须重新计算该区块后面所有的区块的哈希值,短时间内几乎不可能做到。


11.2、hash函数是怎么实现的?

1、散列算法 hash(key)&(capitity-1) //在插入或查找时,计算Key被映射到桶的位置。 当capacity为2的整数倍是该公式才成立。相当于对key的hash值对表厂取模,基于hashmap是2的幂次方特性,这种位运算速度更快。

2、hash的高16bit和低16bit做了一个异或

3、(n-1)&hash 得到下标

4、使用&代替取模,实现了均匀的散列,但效率要高很多,与运算比取模的效率高,由于计算机组成原理

代码:

int hash(Object key){
  int h = key.hashCode();
  return (h ^ (h >>> 16)) & (capitity -1); //capicity表示散列表的大小
} 
public int hashCode(){
  int var1 = this.hash;
  if(var1 == 0 && this.value.length > 0){
    char[] var2 = this.value;
    for(int var3 = 0; var3 < this.value.length; ++var3) {
      var1 = 31 * var1 + var2[var3];
    }
    this.hash = var1;
  }
  return var1;
}

11.3、hash冲突解决方案:(开放定址法/链表法)

使用链地址法,先找到下标i,KEY值找Entry对象,新值存放在数组中,旧值在新值的链表上,将存放在数组中的Entry设置为新值的next

  • 开放定址法
    散列表的冲突处理?散列表的冲突处理主要分为闭散列法和开散列法;常用:线性探测法、链地址法 20181230补
    1、闭散列法(开放寻址法) 不开辟额外的存储空间 (当数据量比较小、装载因子小的时候,适合采用开放寻址法)

特点

1、不开辟额外的存储空间,还是在原先hash表的空间范围之内

2、当插入元素发生了散列冲突,就逐个查找下一个空的散列地址供插入,直到查找失败

方法

1、线性探测法:将散列表看作是一个循环向量,若初始地址是f(key)=d,则依照顺序d、d+1、d+2…的顺序取查找,即f(key)=(f(key)+1)mod N;(ThreadLocalMap使用的线性探测法)

2、二次探测法:基本思路和线性探测法一致,只是搜索的步长和方向更加的多样,会交替以两个方向,步长为搜索次数的平方来查找 **

3、 双重散列法:通常双重散列法是开放地址中最好的方法,其通过提供hash()和rehash()两个函数,前者产生冲突的时候,定制化后者rehash()重新寻址

2、开散列法(链地址法) 寻找额外的存储空间(基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略)

1、特点

1、一般通过将冲突的元素组织在链表中,采用链表遍历的方式查找

2、解决方法直观,实现起来简单,尤其在删除元素的时候此处只是简单的链表操作

3、开散列法可以存储超过散列表容量个数的元素

2、方法

1、链地址法:相同散列值的记录放到同一个链表中,他们在同一个Bucket中(java中LinkedHashMap采用此方法)

优化方案:将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树。

2、公共溢出法:将所有的冲突都放到一个公共的溢出表中去,适用于冲突情况很小的时候


11.4、面试题
  • 1、假设我们有10万条URL访问日志,如何按照访问次数给URL排序遍历10万条数据,以URL为key,访问次数为value,存入散列表,同时记录访问次数的最大值K,时间复杂度O(N),如果K不是很大,可以使用桶排序,时间复杂度O(N)。如果k非常大(10万),就使用快速排序,复杂度O(NlgN)
  • demo如下所示:
  • 2、有两个字符串数组,每个数组大约有10万条字符串,如何快速找出两个数组中相同的字符串?
    以第一个字符串数组构建散列表,key为字符串,value为出现次数。再遍历第二个字符串数组,以字符串为key在散列表中查找,如果value大于零,说明存在相同字符串。时间复杂度O(N)。
  • 3、如何避免低效扩容?
    当装载因子已经到达阈值,需要先进行扩容,再插入数据,这种操作很低效。解决方案:将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。
    当有新数据要插入时,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。
  • 5、如何通过哈希算法生成短网址?(王争的第56讲 http://t.cn是短网址服务的域名)
    MurmurHash算法。现在它已经广泛应用到Redis、MemCache、Cassandra、HBase、Lucene等众多著名的软件中。
  • 6、编程实现一个基于链表法解决冲突问题的散列表
public class HashTable {
  private int tSize;
  private int count;
}
public class HashTableNode {
  private int blockCount;
  private ListNode startNode;//维护的线性表
}
public class 基于散列表解决冲突的散列表 {
  public final static int LOADFACTOR = 20;
  public static HashTable createHashTable(int size) {
    HashTable h = new HashTable();
    //count默认设置为0;
    h.settSize(size / LOADFACTOR);
    for (int i = 0; i < h.gettSize(); i++) {
      h.getTable()[i].setStartNode(null);
    }
    return h;
  }
  public static int hashSearch(HashTable h, int data) {
    ListNode temp;
    temp = h.getTable()[Hash(data, h.gettSize())].getStartNode();
    while (temp != null) {
      if (temp.getVal() ==data) {
         return 1; 
      }
      temp = temp.getNext();
    }
    return 0;
  }
  //散列函数
  public static int Hash(int data, int gettSize) {
    int h = data; /* data.hashCode(); */
    return (h ^ (h >>> 16)) & (gettSize - 1); // capicity表示散列表的大小
  }
}

7、编程实现一个LRU缓存淘汰算法 (使用散列表+链表组合实现缓存淘汰算法)LinkedHashMap(思路牛逼)(双向链表+散列表)(使用双向链表支持按照插入的顺序遍历数据,支持按照访问顺序遍历数据)

一个缓存(cache)系统主要包含下面这几个操作:往缓存中添加一个数据;从缓存中删除一个数据;在缓存中查找一个数据。

①使用双向链表存储数据,链表中每个节点存储数据(data)、前驱指针(prev)、后继指针(next)和hnext指针(解决散列冲突的链表指针)。

②散列表通过链表法解决散列冲突,所以每个节点都会在两条链中。一条链是双向链表,另一条链是散列表中的拉链。前驱和后继指针是为了将节点串在双向链表中,hnext指针是为了将节点串在散列表的拉链中。(牛逼)

  • 往缓存中查找一个数据:在散列表中查找数据的时间复杂度为O(1),找到后,将其移动到双向链表的尾部
  • 删除数据:在O(1)时间复杂度里找到要删除的结点,双向链表可以通过前驱指针O(1)时间复杂度获取前驱结点
  • 添加一个数据:先看这个数据是否已经在缓存中。如果已经在其中,需要将其移动到双向链表的尾部;如果不在其中,还要看缓存有没有满。如果满了,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;如果没有满,就直接将数据放到链表的尾部。
public class LRU缓存淘汰算法{
  private ListNode head; //最近最少使用,类似列队的头,出队
  private ListNode tail; //最近最多使用,类似队列的尾,入队
  private Map<Integer, ListNode> cache;
  private int capacity;
  public LRU缓存淘汰算法(int capacity){
    this.cache = new HashMap<>();
    this.capacity = capacity;
  }
  public int get(int key) {
    ListNode node = cache.get(key);
    if (node == null) {
      return -1;
    } else {
      moveNode(node);//把该数据移动到链表尾部
      return node.value;
    }
  }
  public void put(int key, int value) {
    ListNode node = cache.get(key);
    if(node != null){
      node.value = value;
      moveNode(node);//把该数据移动到链表尾部
    } else{//缓存满了,移除链表的头结点
      removeHead();
      addNode(new ListNode(key, value));
    }
    cache.put(key, node);
  }
  private void removeHead() {
    if (cache.size() == capacity) {
      ListNode tempNode = head;
      cache.remove(head.key);
      head = head.next;
      tempNode.next = null;
      if (head != null)
        head.prev = null;
    }
  }
  private void addNode(ListNode node) {
    if (head == null)
      head = tail = node;
    else
      addNodeToTail(node);
  }
  private void addNodeToTail(ListNode node) {
    node.prev = tail;
    tail.next = node;
    tail = node;
  }
  //移动数据到链表尾部 分类讨论:第一种要移动的结点是头结点;第二种直接为尾节点,无需处理;第三种为链表中的结点
  private void moveNode(ListNode node){
    if (head == node && node != tail){
      head = node.next;
      head.prev = null;
      node.next = null;
      addNodeToTail(node);
    } else if (tail == node){
    } else {
      node.prev.next = node.next;
      node.next.prev = node.prev;
      node.next = null;
      addNodeToTail(node);
    }
  }
}

java中有现成的工具可以使用

  • LinkedHashMap维护了插入的先后顺序【FIFO】,适合LRU算法做缓存(最近最少使用)(在项目中被用到过)
LinkedHashMap<Long, String> unchecked = new LinkedHashMap<Long, String >(20, .75F, true) {
     @Override
     protected boolean removeEldestEntry(Map.Entry<Long, String> eldest){
         //什么时候执行LRU操作
         return this.size() > 20;
     }
 };


相关文章
|
1月前
|
存储 人工智能 算法
数据结构与算法细节篇之最短路径问题:Dijkstra和Floyd算法详细描述,java语言实现。
这篇文章详细介绍了Dijkstra和Floyd算法,这两种算法分别用于解决单源和多源最短路径问题,并且提供了Java语言的实现代码。
74 3
数据结构与算法细节篇之最短路径问题:Dijkstra和Floyd算法详细描述,java语言实现。
|
1月前
|
存储 Java
Java中的HashMap和TreeMap,通过具体示例展示了它们在处理复杂数据结构问题时的应用。
【10月更文挑战第19天】本文详细介绍了Java中的HashMap和TreeMap,通过具体示例展示了它们在处理复杂数据结构问题时的应用。HashMap以其高效的插入、查找和删除操作著称,而TreeMap则擅长于保持元素的自然排序或自定义排序,两者各具优势,适用于不同的开发场景。
44 1
|
1月前
|
存储 Java
告别混乱!用Java Map优雅管理你的数据结构
【10月更文挑战第17天】在软件开发中,随着项目复杂度增加,数据结构的组织和管理至关重要。Java中的Map接口提供了一种优雅的解决方案,帮助我们高效、清晰地管理数据。本文通过在线购物平台的案例,展示了Map在商品管理、用户管理和订单管理中的具体应用,有效提升了代码质量和维护性。
82 2
|
1月前
|
存储 Java 开发者
Java Map实战:用HashMap和TreeMap轻松解决复杂数据结构问题!
【10月更文挑战第17天】本文深入探讨了Java中HashMap和TreeMap两种Map类型的特性和应用场景。HashMap基于哈希表实现,支持高效的数据操作且允许键值为null;TreeMap基于红黑树实现,支持自然排序或自定义排序,确保元素有序。文章通过具体示例展示了两者的实战应用,帮助开发者根据实际需求选择合适的数据结构,提高开发效率。
61 2
|
21天前
|
存储 搜索推荐 算法
【数据结构】树型结构详解 + 堆的实现(c语言)(附源码)
本文介绍了树和二叉树的基本概念及结构,重点讲解了堆这一重要的数据结构。堆是一种特殊的完全二叉树,常用于实现优先队列和高效的排序算法(如堆排序)。文章详细描述了堆的性质、存储方式及其实现方法,包括插入、删除和取堆顶数据等操作的具体实现。通过这些内容,读者可以全面了解堆的原理和应用。
60 16
|
16天前
|
缓存 算法 Java
本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制
在现代软件开发中,性能优化至关重要。本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制。通过调整垃圾回收器参数、优化堆大小与布局、使用对象池和缓存技术,开发者可显著提升应用性能和稳定性。
36 6
|
22天前
|
存储 Java 索引
Java中的数据结构:ArrayList和LinkedList的比较
【10月更文挑战第28天】在Java编程世界中,数据结构是构建复杂程序的基石。本文将深入探讨两种常用的数据结构:ArrayList和LinkedList,通过直观的比喻和实例分析,揭示它们各自的优势与局限,帮助你在面对不同的编程挑战时做出明智的选择。
|
30天前
|
存储 算法 Java
Java 中常用的数据结构
【10月更文挑战第20天】这些数据结构在 Java 编程中都有着广泛的应用,掌握它们的特点和用法对于提高编程能力和解决实际问题非常重要。
30 6
|
1月前
|
存储 Java 开发者
Java中的Map接口提供了一种优雅的方式来管理数据结构,使代码更加清晰、高效
【10月更文挑战第19天】在软件开发中,随着项目复杂度的增加,数据结构的组织和管理变得至关重要。Java中的Map接口提供了一种优雅的方式来管理数据结构,使代码更加清晰、高效。本文通过在线购物平台的案例,展示了Map在商品管理、用户管理和订单管理中的具体应用,帮助开发者告别混乱,提升代码质量。
29 1
|
1月前
|
存储 算法 Java
Java常用的数据结构
【10月更文挑战第3天】 在 Java 中,常用的数据结构包括数组、链表、栈、队列、树、图、哈希表和集合。每种数据结构都有其特点和适用场景,如数组适用于快速访问,链表适合频繁插入和删除,栈用于实现后进先出,队列用于先进先出,树和图用于复杂关系的表示和查找,哈希表提供高效的查找性能,集合用于存储不重复的元素。合理选择和组合使用这些数据结构,可以显著提升程序的性能和效率。
下一篇
无影云桌面