赞
踩
不定期补充、修正、更新;欢迎大家讨论和指正
本文以数据结构(C语言版)第三版 李云清 杨庆红编著为主要参考资料,用Java来实现
数据结构与算法Java(一)——线性表
数据结构与算法Java(二)——字符串、矩阵压缩、递归
数据结构与算法Java(四)——检索算法
数据结构与算法Java(五)——图
数据结构与算法Java(六)——排序算法
检索也称为查找,就是从一个数据元素集合中找出某个特定的数据元素或者找出满足某类特征的数据元素的过程,检索运算的使用频率很高,几乎在任何一个软件系统中都会涉及。本文主要涉及线性表、数表、散列表的检索。
要进行检索,必须知道待检索对象的特征,一般的,假定被查找的对象是由n个结点组成的表(查找表),每个结点由若干个数据项组成,并假设每个结点都有一个能唯一标识该结点的关键字。此时,一种最常见的操作是给定一个值Key,在表中找出关键字等于给定值Key的结点,若找到,则检索成功,返回该结点的信息或该结点在表中的位置;否则检索失败,返回提示信息。
如果检索检索过程中不会改变查找表的内容,称为静态查找表;另一种检索通常伴随着数据元素的添加、删除或移动,称为动态查找表,如商品销售系统的售货过程经常在检索到某种商品的同时需要删除相应的商品。
衡量检索算法效率的标准是平均查找长度(Average Search Length, ASL),也就是为确定某一个结点在数据集合中的位置,给定值与集合中结点关键字所需进行的比较次数,
其中n为查找表中元素个数,Pi为查找第i个元素的概率,通常假设每个元素查找概率相同,Pi=1/n,Ci是找到第i个元素的比较次数。一个算法的ASL越大,说明时间性能差,反之,时间性能好。
顺序检索是最简单的查找方法,它的思路是从表的一端开始,顺序扫描线性表,依次将扫描到的结点关键字和给定值Key相比较,若当前扫描的结点关键字与Key相等,则检索成功;若扫描结束仍未找到关键字等于Key的结点,检索失败。
线性表以顺序存储,这里以前面学习线性表自己构建的SequenceList为例,其检索算法是这样的
public int find(int data){
for (int i = 0; i < this.size; i++){
if(list[i]==data)
return i;
}
System.out.println("can`t find the data");
return -1;
}
这里提一下“哨兵”的骚操作,如果以上的算法用while递减来实现,则
public int find1(int data){
int n = size-1;
while (n>=0 && list[n]!=data)
n--;
return n;
}
当找不到元素时,则返回的是-1,这种方法每次循环都要判断n>=0,一种改进的方式就是线性表最前面设置一个与待查找关键字等值的数据元素,作为监视查找是否完成的“哨兵”,也就是说list[0]一开始就空着,不作为线性表的有效信息,而是作为辅助空间,这样即使线性表中没有要查找的元素,到list[0]也必然会成功匹配,但我们人为的知道当返回位置是0是就相当于查找失败。
public int find1(int data){
list[0] = data;
int n = size-1;
while (list[n]!=data)
n--;
return n;
}
哨兵这种奇淫技巧在很多时候很好用,所以在设计线性表时,可以将list[0]空着,有效起始位置从1开始。
回到顺序检索,当用链式存储,以之前构造的LinkedList为例,其检索算法为
public Node findByData(T data){
Node point = head.next;
while (point!=null && point.data!=data)
point = point.next;
return point;
}
假设顺序表中每个记录的查找概率相同,,顺序检索算法查找成功的ASL为
在查找失败时,ASL为n。
有时,表中各个记录的查找概论并不相等,若能事先知道每个节点的检索概率,并按照检索概论升序排序(若从前向后检索时按检索概率降序排列)线性表中的结点,则ASL取极小值。但一般情况下,结点的检索概率往往无法预先测定,为了提高擦护照效率,可以在可以在每个结点中附设一个访问频度域,并使得顺序表中的记录始终保持按访问品读升序排序,检索概率大的结点在查找过程中不断往后移动,以便在以后的查找中减少比较次数,由于涉及结点的移动,这种动态查找表适合用链式存储。
二分法检索(折半查找)要求线性表(顺序存储)结点先按照关键字按序排列好,然后再进行检索。
其检索过程为:设L[left, right]为查找区间,首先同线性表的中间结点mid=[(low + hight)/2]比较,若比较相等,则查找成功结束二分检索。若待查找的元素比中间结点小(升序排序时),则说明查找的元素应该在前半部分,这时查找区间变为L[left, mid-1],继续进行二分查找;相反的待查找元素比中间结点大,查找区间应变为L[mid+1, right]。不断二分直到找到结点或确定表中没有这样的结点。
下面以在该数列查找值为15的元素为例
2 3 4 5 15 18 19 26 27 36 38 44 46 47 50
public static int find(int data){ int left = 0, right = size-1; int mid; while (left<=right){ mid = (left+right)/2; if(list[mid]==data){ return mid; }else if(list[mid]>data){ right = mid - 1; }else{ left = mid + 1; } } return -1; }
二分检索每经过依次比较就将检索范围缩小一半,因此比较次数应为log2n这个量级,
无论检索成功还是失败,二分检索都比顺序检索要快得多,但是它要求线性表必须先按照关键码进行排序,而排序的最佳时间复杂度为nlog2n。另外线性表的二分检索仅适用于顺序存储结构,而对于动态查找表,顺序存储的插入、删除等运算都不方便,因此二分检索一般用于一经建立就很少需要改动而又经常需要查找的静态查找表。
分块检索也称为索引顺序查找,它是顺序检索和二分检索的一种结合,其基本思想在是:首先把线性表分为若干块,在每一块中,结点的存放不一定有序,但块与块之间必须是分块有序,即假定按结点的关键码值递增有序,则第一块中结点的关键码都小于第二块中任意结点的关键码值,第二块中的结点的关键码值都小于第三块中任意结点的关键码值,以此类推。
为实现分块检索,还需建立一个索引表,将每一块最大的关键码值按块的顺序存放在一个索引顺序表中。随后查找时,首先在索引表中采用二分检索或顺序检索确定待检索对象可能所在块的起始位置,然后再所在块中顺序检索待查找的数据元素,便可得到检索对象了。
上图是比较理想的例子,因为整个数据表基本有序,并且后一个分块中最小值比前一个分块的最大值都要大,这就导致分块检索不具有普适性(因为分块检索的实现思路就是这样的)。
现在假设下标7和8位置的值改为5和100,这时第二索引表的最大关键字应该为100,即索引表为22 100 86。所以要对索引表进行排序,但以上索引表的结构是不够的,要额外添加结束地址。
另外还有一个问题,假设现在要检索5这个元素,按照分块检索的规则搜索的应该是22这个块,但5此时在100的块内,所以会导致搜索不到。
现在先解决第一个问题
索引表类结构
public class IndexTable{ private int max; private int start; private int end; public IndexTable(int max, int start, int end) { this.max = max; this.start = start; this.end = end; } public IndexTable(){} //Get/Set方法 }
构造索引表
public IndexTable[] getIndexTable(int[] list, int n){ IndexTable[] table = new IndexTable[n]; int num = list.length/n;//每个分区元素的个数 int start=0, end=num-1, max; for (int i=0; i<n-1; i++){//由于元素的个数不一定能刚好整除分块个数,所以最后一个分块需要额外处理 max = list[start]; for (int j=start; j<=end; j++){ if(list[j]>max)//找到该块中的最大值 max = list[j]; } table[i] = new IndexTable(max,start,end); start = end+1; end = start + num-1; } max = list[start]; for (int i=start; i<list.length; i++){//处理最后一个分块 if(list[i]>max) max = list[i]; } table[n-1] = new IndexTable(max,start,list.length-1 ); IndexTable tmp; for (int i=1; i<n; i++){//根据索引表中的最大关键字进行排序,这里用的是直接插入排序。 for (int j=i-1; j>=0; j--){ if(table[j].max>table[j+1].max){ tmp = table[j]; table[j] = table[j+1]; table[j+1] = tmp; } } } return table; } }
分块检索
public static int blockFind(int[] list, int data, int n){ IndexTable indexTable = new IndexTable(); IndexTable[] table = indexTable.getIndexTable(list, n); for (int i=0; i<table.length; i++){ if(data<table[i].getMax()){ for (int j=table[i].getStart(); j<=table[i].getEnd();j++){ if(list[j] == data) return j; } break; } } return -1; }
回到开始遇到的两个问题,将7,8位的值改为5和100
第一个问题,将索引表排好序就可以解决
第二问题,数据表中是存在5的,但是检索不到
对于第二问题是没办法直接解决的,因为分块检索的要求就是分块与分块间就要求有序了,因此只能考虑如何将普通数据表事先分好块了(这样的话第一个问题也同时解决了,应该一开始就想到要这样)。
如何将普通数据表变为符合分块检索的形式,在排序算法中有一种桶排序算法(按数据范围等分几个区间(桶),然后将数据放到相应的桶里,再在每个桶中对数据排好序,不明白可以看桶排序这篇文章),我们可以利用类似的思路实现。
分块中结点类结构
将原数据表构造成符合分块检索的顺序,最重要的是保存元素在原数据表所在的位置
static class Node{
private int data;
private int index;//在原数据表的下标
private Node next;
public Node(int data, int index) {
this.data = data;
this.index = index;
}
public Node(){};
}
分块
public static Node[] block(int[] list,int n){//n为分块的数量 int max = list[0], min =list[0]; for (int i=0; i<list.length; i++){//找到数据表中最大最小值 if(list[i]>max) max = list[i]; if(list[i]<min) min = list[i]; } int blockSize = (max-min)/n;//每个分块的区间大小 Node[] blocks = new Node[n];//构建桶 for (int i=0; i<n; i++){//初始化,元素以链表的形式存放在各个桶中 blocks[i] = new Node(); } int start, end; for (int i=0; i<list.length; i++){//将原数据表的所有元素分配到各个桶中 start = 0; end = blockSize-1; for (int j=0; j<n; j++){//为元素找到相应的桶 if(list[i]>=start && list[i]<=end){ Node newNode = new Node(list[i],i); newNode.next = blocks[j].next; blocks[j].next = newNode;//头插法 break; }else{ start = end+1; end = start + blockSize - 1;//如果该桶不符合,找下一个桶 if(j==n-1)//解释下这一步,该例子中,min=5,max=100,每个桶的大小为(100-5)/3= 31,因为除不尽,三个桶的总共取值 end = max;//也就0~93,取不到100,所以最后一个桶的范围应该涵盖到后面没取到的元素,即最后一个桶的结束为原数据表的最大值 } } } return blocks; }
构建索引表
public IndexTable[] getIndexTable(Node[] block){ int n = block.length; IndexTable[] table = new IndexTable[n]; int max=0; Node point; for (int i=0; i<n; i++){ point = block[i].next; while (point!=null){//寻找每个分块中的最大值(其实用桶的结束位置来充当分块中的最大值就行) if(point.data>max){ max = point.data; } point = point.next; } table[i] = new IndexTable(max); } return table; } }
分块检索
public static int blockFind1(int[] list, int data, int n){ Node[] block = block(list, n); IndexTable indexTable = new IndexTable(); IndexTable[] table = indexTable.getIndexTable(block); Node point; for (int i=0; i<table.length; i++){ if(data<table[i].getMax()){ point = block[i].next; while (point!=null){ if(point.data == data) return point.index;//返回该元素在原数据表的所在的下标 point = point.next; } } } return -1; }
在线性表的三种检索方式中,二分检索拥有最高的查找效率,但只适用于顺序存储结构且要求数据有序,这给查找表中数据的增添、删除操作带来了不便。为了改善这些问题可以利用二叉排序树,其不仅又二分检索的效率,同时又百年与在查找表中进行数据的添加和删除操作。
二叉排序树,也叫二叉查找树、二叉搜索树,如果它不是空树,则应满足以下特征
随后对该树进行中序遍历就可得到按结点值递增排序的结点序列,如上图中序遍历的结果就为1 3 4 6 7 8 10 13 14
类结构
public class BinarySearchTree { private Node root = null; private int nodeNumber = 0; private int index = 0; static class Node{ private int data; private Node left = null; private Node right = null; public Node(int data) { this.data = data; } } }
操作集
二叉排序树的检索算法十分简单,其思路可以描述为
public Node bstSearch(int data){
Node point = root;
while (point!=null && point.data!=data){
if(point.data>data)
point = point.left;
else
point = point.right;
}
if(point==null)
System.out.printf("can`t find the node");
return point;
}
这里创建树时,用-1表示空树
对于二叉排序树的插入操作,也是十分容易的。稍微想想可以直到新结点的插入过程实际上与结点的查找过程时密切相关的,结点的插入位置就是检索失败的最终叶结点位置,因此具体实现和检索过程差不多。
public void addNode(Node newNode){ if(root==null) root = newNode; else { Node point = root; Node father = point;//记录最终插入的位置的父结点 while (point!=null ){ father = point; if(point.data == newNode.data)//树中已存在值相等的结点,不用插入了 return; else if(point.data > newNode.data) point = point.left; else point = point.right; } point = newNode; if(father.data> point.data)//与其父结点进行比较,决定插入左边还是右边 father.left = point; else father.right = point; } }
既然实现了插入操作,以后创建树就可以不用以前序遍历的方式来创建了。
删除操作就复杂很多,这里有几种情况
删除结点是叶子结点,直接删除即可
删除结点只存在一个左子树或右子树,删除后将其子树链接到其父结点下即可,当然删除结点在其父结点下处在哪一侧,其子树也链接到哪一侧
删除结点同时存在左子树和右子树,这里就要考虑是左子树还是右子树提升地位去链接到删除结点的父结点了。如果是左子树接替,那么右子树就要链接到左子树右边了;相同的,如果是右子树接替,那么左子树就要链接到右子树左边了。
一个思路比较好且容易实现的方法是找到左子树中的最大值结点或右子树中的最小值结点,然后将该结点的值覆盖被删除结点的值,随后从树中删除掉该结点。
这里可以参考力扣的题目
LeetCode:450. 删除二叉搜索树中的节点
public Node deleteNode(Node root,int data){ if(root==null) return null; if(root.data==data){//要删除的就是该结点 if(root.left==null&&root.right==null)//情况1,该结点的左右结点都为空 return null; if(root.left==null)//情况2,该结点左右结点有一边为空 return root.right; else if(root.right==null) return root.left; else {//情况3,该结点左右结点都不为kong Node point = root.left;//这里找左子结点的最大结点,也可以找右子结点的最小结点 while (point.right!=null){ point = point.right; } root.data = point.data; root.left = deleteNode(root.left, point.data); } } if(root.data>data){ root.left = deleteNode(root.left,data); }else { root.right = deleteNode(root.right,data); } return root; }
已下图为例,删除8(根结点)、3(左右子树均存在)、1(叶子节点)、10(只存在一个子树)结点来验证各种情况的正确性
二叉排序树上实现查找、插入、删除等基本操作的平均时间虽然为O(log2n),但当二叉排序树只存在左子树或右子树的极端情况下,就退化成相当于单链表,这些操作的时间复杂度又相应地增加到O(n)。这种情况二叉排序树就没存在的意义了,比如利用二叉排序树的插入操作,此时插入的序列本身有序的或大致有序,这样就会导致结点插入的都是左边或右边,如下图。
为了避免这种情况发生,人们研究了许多动态平衡地方法,包括如何建立一颗“好”的二叉排序树;如何保证往树中插入或删除结点时保持树的“平衡”。这里介绍两种特殊的二叉树:丰满数和平衡树。
一颗二叉树中,若定义σk是结点k到树根的路径长度,若ki和kj是二叉树中子结点个数少于2的任意两个结点,且满足
|σki - σkj| ≤1
即,任意两个结点(其子结点少于2)的高度之差的绝对值要小于等于1,则这颗二叉树为一颗丰满树。由于树的最下一层均为叶节点,所以在丰满书种,子结点少于2的结点只出现在树的最低两层之中。下图为一颗丰满树和非丰满树
容易发现丰满树的倒数第二层必然是一颗满二叉树,最底层的叶节点可以任意摆放(如果全部紧挨着左边就是完全二叉树了)
丰满树的主要应用是从一个排好序的序列平分法来构造二叉排序树(很鸡肋,都排好序直接用二分法检索就行了)
public Node createFullTree(int[] list, int left, int right){
int mid;
Node root;
if(left<=right){
mid = (left+right)/2;
root = new Node(list[mid]);
root.left = createFullTree(list, left, mid-1);
root.right = createFullTree(list,mid+1, right);
return root;
}else
return null;
}
对于具有n个结点的丰满二叉排序树,如果树中所有结点都有相同的使用概率,那么其ASL≈log2n,但对动态的二叉排序树进行插入和删除等操作后,丰满很容易变为非丰满二叉排序树,并且将非丰满二叉排序树改造成丰满二叉排序树非常困难, 因此实际应用中经常使用一种称为“平衡树”的特殊二叉排序树(确实,网上的文章都没几篇)。
AVL树其得名于它的发明者G. M. Adelson-Velsky和E. M. Landis(两毛子),他们在1962年的论文《An algorithm for the organization of information》中发表了它。由于AVL树最常用,所以大多数文献中的平衡二叉树就是AVL树,严格来说AVL树只是平衡二叉树的一种实现方法,除此之外还有红黑树、替罪羊树、Treap、伸展树等实现方法。
如果AVL树不是空树,它应该具有以下性质:它的左子树和右子树都是平衡二叉树,且左子树和右子树的高度之差的绝对值不超过1,为了方便描述,将二叉树中某个结点的左子树高度和右子树高度之差称为该结点的平衡度(平衡因子),因此AVL树种任意结点的平衡因子的绝对值小于等于1(因此平衡因子只可能为-1,0,1)。AVL树可以动态地使一颗二叉排序树保持平衡,从而使它具有较高地检索效率。
由丰满树和平衡树的定义可知,丰满树一定是AVL树,但AVL树不一定是丰满树,如下图即是丰满树又是AVL树。
下图为AVL树,但不是丰满树
现在讨论AVL树的核心,如何动态地保持二叉排序树平衡。上面提到,G. M. Adelson-Velsky和E. M. Landis在那篇论文中提到如何将一个新结点插入到一棵平衡二叉排序树种,后称为 Adelson方法。
Adelson方法由三个依次执行的过程——插入、调整平衡、和改组所组成。这些方法原理并不难,但是书面表达十分繁琐,墙裂建议先看以下视频:可视化数据结构-AVL树
以下为改组的具体操作
LL型平衡旋转(单右旋)
由于在A的左子结点的左子树,即B插入新结点而使A的平衡度由1增至2,致使以A为根的子树失去平衡。此时应进行依次顺时针(右旋)旋转,“提升”A的左子结点(即B)作为新子树的根结点,A下降为B的右子结点,同时将B原来的右子节点BR调整为A的左子树。如下图。(很多地方把这叫做右旋操作,刚开始学看到LL误会是左旋操作,为啥叫LL型网上也找不到解释,个人猜测 是新结点插入的位置为左子结点的左子树)
RR型平衡旋转(单左旋)
由于在A的右子结点的右子树,即B插入新结点而使A的平衡度由-1增至-2,致使以A为根的子树失去平衡。此时应进行依次逆时针(左旋)旋转,“提升”A的右子结点(即B)作为新子树的根结点,A下降为B的左子结点,同时将B原来的左子节点BL调整为A的右子树。如下图。
LR型平衡旋转(先左后右)
由于在A的左子节点的右子树上插入新结点,使A的平衡度由1变成2,致使以A为根的子树失去平衡,此时应进行两次旋转操作(先逆时针,后顺时针,下图写错了),先以B为根的子树做左旋操作,再以A为根的子树做右旋操作。即“提升”C(即A的左子结点的右子节点)为新子树的根结点;A下降为C的右子节点;B变为C的左子节点;C原来的左子树CL调整为B现在的右子树;C原来的右子结点CR调整为A现在的左子树。
RL型平衡旋转(先右后左)
由于在A的右子节点的左子树上插入新结点,使A的平衡度由-1变成-2,致使以A为根的子树失去平衡,此时应进行两次旋转操作(先顺时针,后逆时针),先以B为根的子树做右旋操作,再以A为根的子树做左旋操作。即“提升”C(即A的右子结点的左子节点)为新子树的根结点;A下降为C的左子节点;B变为C的右子节点;C原来的左子树CL调整为A现在的右子树;C原来的右子结点CR调整为B现在的左子树。
至于什么情况做什么旋转,可以观察平衡度。假设现在有一A结点,
当A平衡度为2,说明新结点插入位置为A的左子树:再继续看A的左子结点的平衡度,若为1,表示插入位置为A的左子节点的左子树,做LL旋转。若为-1,表示插入位置为A的左子节点的右子树,做LR旋转。
当A平衡度为-2,说明新结点插入位置为A的右子树:再继续看A的右子结点的平衡度,若为-1,表示插入位置为A的右子节点的右子树,做RR旋转。若为1,表示插入位置为A的右子节点的左子树,做RL旋转。
类结构
public class AVL { private static Node root; static class Node{ private int data; private int bf = 0;//该结点的平衡度(平衡因子,balance factor) private Node left; private Node right; public Node(){} public Node(int data) { this.data = data; } }
辅助方法
public static int height(Node node){//求node结点的高度
if(node!=null)
return height(node.left)>= height(node.right)? height(node.left)+1: height(node.right)+1;
return 0;
}
public static int getBF(Node node){//求该结点的平衡度
return height(node.left) - height(node.right);
}
当结点的平衡度为2时,知道要做LL旋转或LR旋转,具体要做哪个操作要视其左子结点的平衡度而定,为了代码简洁一些,这两个操作放在一个方法内。
另外在LR旋转修改平衡度时,以下图为例,根据新结点插入的位置,A、B旋转后的平衡度也不同,这里有三种情况
可能从图中不能直观感受,所以要自己动笔画画了
L类改组
public Node LChange(Node node){ Node l = node.left;//该结点的左子结点 if(l.bf==1){//LL旋转 node.left = l.right;//改组 l.right = node; node.bf = 0;//修改平衡度 l.bf = 0; return l; }else {//LR旋转 Node lr = l.right;//node结点的左子节点的右子结点 l.right = lr.left;//改组 lr.left = l; node.left = lr.right; lr.right = node; if(lr.bf==1){//修改平衡度 node.bf = -1; l.bf = 0; }else if (lr.bf==-1){ node.bf = 0; l.bf = 1; }else if(lr.bf==0){ node.bf = 0; l.bf = 0; } lr.bf = 0; return lr; } }
R类改组,和L类改组操作同理
public Node RChange(Node node){ Node r = node.right;//该结点的右子结点 if(r.bf == -1){//RR旋转 node.right = r.left;//改组 r.left = node; node.bf = 0;//修改平衡度 r.bf = 0; return r; }else {//RL旋转 Node rl = r.left;//该结点的右子结点的左子结点 r.left = rl.right;//改组 rl.right = r; node.right = rl.left; rl.left = node; if(rl.bf == -1){//修改平衡度 node.bf = 1; r.bf = 0; }else if(rl.bf == 1){ node.bf = 0; r.bf = -1; }else if(rl.bf == 0){ node.bf = 0; r.bf = 0; } rl.bf = 0; return rl; } }
现在可以进行添加操作了
public Node addNode(Node node, int data){ if(node==null){ node = new Node(data); }else { if(node.data>data){//插入左边 node.left = addNode(node.left,data); node.bf = getBF(node);//更新该结点的平衡度 switch (node.bf){ case 2: node = LChange(node); break; case -2: node = RChange(node); break; default: break;//未失衡的情况 } }else if(node.data<data){//插入右边 node.right = addNode(node.right,data); node.bf = getBF(node);//更新该结点平衡度 switch (node.bf){ case 2: node = LChange(node); break; case -2: node = RChange(node); break; default: break; } } } return node; }
这里给出四组试验序列,每组序列只实现一种旋转功能,大家自行下去调试感受下
RR旋转:{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
LL旋转:{10, 9, 8, 7, 6, 5, 4, 3, 2, 1}
LR旋转:{10, 7, 9, 2, 5, 6, 3, 1, 4}
RL旋转:{1, 4, 2, 9, 6, 5, 8, 10, 7}
为了验证功能的正确性,这里不可能一步步调试。根据AVL树的定义,任意一个结点的平衡度的绝对值小于等于1,所以可以在遍历时顺便判断,并且中序遍历的结果为递增的,就可以认为功能实现成功。
public void inorder(Node node){
if(node!=null){
if(node.bf==2 || node.bf==-2)//如果在遍历过程中有任一结点不平衡,说明AVL树构建失败
System.out.println("unbalance!!!!!");
inorder(node.left);
System.out.print(node.data + " ");
inorder(node.right);
}
}
功能实现成功
经过调试,可以确定生成的AVL树结构如下(79写成78了,就不改了)
对于删除操作,在普通的二叉排序树就看到,因为删除结点所处位置的不同,实现比添加操作复杂些。而在AVL树中删除结点不仅要考虑删除结点的位置,还需要考虑删除后整棵树的平衡问题。
红黑树(Red Black Tree) 是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。
红黑树是在1972年由Rudolf Bayer发明的,当时被称为平衡二叉B树(symmetric binary B-trees)。后来,在1978年被 Leo J. Guibas 和 Robert Sedgewick 修改为如今的“红黑树”。
红黑树是一种特化的AVL树(平衡二叉树),都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能。
它虽然是复杂的,但它的最坏情况运行时间也是非常良好的,并且在实践中是高效的: 它可以在O(log n)时间内做查找,插入和删除,这里的n是树中元素的数目。–摘自百度百科
红黑树应包含以下性质
前面所讨论的查找算法都是在内存中进行,它们适用于较小的文件,而对于较大的、存放在外存储器的文件就不合适了。
1970年,R.Bayer和E.McCreight在《Organization and Maintenance of Large Ordered Indices》首次提出了一种称为B树的多路平衡查找树(Balance Tree,也成为“B-树”,“-”是英文连接符),它适合在磁盘等直接存取设备上组织动态的索引表,动态索引结构在文件创建、初始装入记录时生成,在系统运行过程中插入或删除记录时,索引本身也可以发生改变,以保持较好的检索性能。
一棵m阶(m≤3)的非空B-树,应满足以下性质:
如下图为3阶的B树,
在前面的线性表、树等的数据结构中,相应的检索算法时通过若干次的比较来寻找指定的记录,比较的次数与数据的规模直接相关。虽然二分检索和二叉排序树的检索算法具有较高的查询性能,但对于查询速度要求高、数据量大且又难以维护数据有序性的问题也无能为力。这里介绍一种新的存储结构——散列存储,它既是一种存储结构,又是一种常见的检索方法(就是熟悉的Hash(哈希)表)。
散列存储的基本思想时以关键码的值为自变量,通过一定的函数关系(散列函数,也叫Hash(哈希)函数),计算得到相应的函数值,以这个值映射到结点的存储地址,将结点存入计算得到的存储单元里;检索时再根据要检索的关键码用同样的函数计算地址,然后到相应的单元里去取要找的结点。用散列法存储的线性表称为“散列表”,显然散列表的检索时间与散列表中元素的个数无关,理想情况下存储和检索的复杂度能在O(1)。
散列存储经常会出现两个不同关键字,其求得哈希值后经过处理后存放的地址一样,这种现象称为冲突或碰撞,如上图的b和c。碰撞的多个关键字称为同义词(相对于哈希函数而言)。碰撞的发生不仅与散列函数有关,还与散列表的“负载因子”密切相关。负载因子α反应了散列表的装填称度,其定义为:
α = 散列表中结点的个数/基本区域能容纳的结点数
比如散列表的容量为5,尽管每个单元都均匀地分配了1个元素,当元素个数超过5时,冲突就不可避免。
综上所述,对于哈希表,主要研究两个问题
构造哈希函数的方法很多,但总的原则是尽可能地将关键字序列均匀地映射到地址地址集合空间中去,同时尽可能降低冲突发生地概率。这里列出几种常见的哈希函数。
除余法
该方法时最为简单常用的方法,它是以一个略小于哈希地址集合中地址个数的质数来除(除和除以要分清)关键字,取其余数作为哈希地址,即:
H(key) = key%p
例如一序列{5, 21, 65, 22, 69},哈希表大小为7,取得被除数为7,H(x)=x%7,就可以得到下面的哈希表
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
21 | 22 | 65 | 5 | 69 |
这种方法的关键显然是被除数p的选取,如果选取不当容易产生同义词。若选取的是关键字的基数的幂次,则就等于是选择关键字的最后若干位数字作为地址,而与高位无关,这样就容易产生低位相同的同义词了,所以一般选择与哈希表大小相近的质数。
平方取中法
取关键字平方后的中间几位为哈希地址,所取得位数和哈希地址位数相同,这种方法也是常见得方法, 因为通常在选定哈希函数时不一定能知道关键字得全部情况,难于取其中哪几位比较合适,而一个数平方后的中间几位数和数的每一位都相关,由此使随机分布的关键字得到的哈希地址也是随机的。 例如关键字序列为 {421,423,436} ,对各个关键字进行平方后的结果为 {177241,178929,190096} ,则可以取中间的两位 {72,89,00} 作为其哈希地址。
折叠法
将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为哈希地址。关键字位数很多且关键字中每一位上数字分布大致均匀时,可以采用折叠法得到哈希地址。
例如,在图书馆中图书都是以一个 10 位的十进制数字为关键字进行编号的,若对其查找表建立哈希表时,就可以使用折叠法。
若某书的编号为:0-442-20586-4,分割方式如图中所示,在对其进行折叠时有两种方式:一种是移位折叠,另一种是间界折叠:
移位折叠是将分割后的每一小部分,按照其最低位进行对齐,然后相加,如图 (a);间界折叠是从一端向另一端沿分割线来回折叠,如图 (b)。
数字分析法
对于关键字的位数比哈希表的地址码位数多的情况,可以采取对关键字的各位进行分析,丢掉分布不均匀的位留下分布均匀的位作为哈希地址。例如下标中列举的一部分关键字,每个关键字都由 8 位十进制数组成:
通过分析关键字的构成,很明显可以看到关键字的第 1 位和第 2 位都是固定不变的,而第 3 位不是数字 3 就是 4,最后一位只可能取 2、7 和 5,只有中间的 4 位其取值近似随机,所以为了避免冲突,可以从 4 位中任意选取 2 位作为其哈希地址。
直接地址法
直接取关键字或关键字的某个线性函数值作为哈希地址,即:
H(key) = key 或H(key) = a*key + b
在使用时,为了使哈希地址与存储空间吻合,可以调整b。例如,一人口调查表中,表中每个记录包括年龄、人数、民族等情况,若取年龄作为关键字,则可利用直接地址确定各记录的哈希存储地址。直接地址法简单,并且对于不同的关键字不会产生冲突,但在实际问题中,关键字集合的元素往往是离散的,用该方法产生的哈希表会造成空间的大量浪费。
在实际应用中,无论如何构造哈希函数,冲突是必不可免的。这里介绍几种冲突处理方法:开放定址法、再哈希法、拉链法、差值法。
开放定址法
开放定址法的基本做法是在发生冲突时,按照某种方法继续探测基本表中的其他存储单元,直到找到一个开放的地址(即空位置)为止,显然这种方法需要用某种标记区分空单元和非空单元。
开放定址法的一般形式可表示为:
Hi(k) = ( H(k) + di ) MOD m (i = 1,2,...,k(k≤m-1) )
其中H(k)为关键字k的直接哈希地址,m为哈希表大小,di为每次再探测时的地址增量。
当发生冲突时,则采用上述方法探测空位置,一旦找到空位置,就把该记录存入到刚探测到的空位上,插入过程结束;如果用完整个探测地址序列还没找到空位,说明哈希表已满,插入失败。
值得注意的是,再线性探测法中,当发生冲突时,往下一批数据位置寻找可用空间存储,这种方法解决冲突容易发生“聚集”现象,假如现在表中i,i+1,i+2位置上已存储有关键字,下一次哈希地址为i,i+1,i+2和i+3都企图存储到i+3这个位置上,这种几个哈希地址不同的关键字争夺同一个后继哈希地址的现象就是聚集。它使得再处理同义词的冲突过程中又添加了非同义词的冲突 ,显然这种现象对查找不利。
在哈希表中查找关键字为k的记录的过程很简单,根据k值求出哈希地址,若该地址记录为空,则检索失败;若地址记录不为空,将k与该地址记录的关键字相比较,若两者相等,则检索成功;否则按照哈希表建立时采用的解决冲突办法,继续在“下一个哈希地址”中寻找,若在某个地址中有关键字相等的情况,则检索成功;若探测完整个哈希地址序列都未找到,则检索失败(这种情况下检索时间复杂度又和顺序检索的效率差不多了)。
再哈希法
该方法是当待存入散列表的某个元素k再原哈希函数H(k)的映射下与其他数据发生冲突时,采用另一个哈希函数Hi(k) ( i = 1,2,…,n)计算k的存储地址,Hi均是不同的哈希函数,直到冲突不再发生为止。这种方法不易发生碰撞,但增加了计算的时间。
拉链法(⭐)
上面两种方法,当元素个数大于哈希表大小时,即必然发生冲突时,就会导致存储失败,这对于大量数据,但哈希表有限的情况下是十分不利的。而拉链法解决冲突的做法是,将所有关键字为同义词的结点链接在同一个单链表中。
例如有一组关键字为{19,14,23,01,68,20,84,27,55,11,10,79},其哈希函数为:H(key)=key MOD 13,使用链地址法所构建的哈希表如图 3 所示:
与开放定址法相比,拉链法不会有聚集现象,且能够存储的元素可以比哈希表大小多得多;缺点则是指针需要额外的空间。
另外,当数据量过大时,亦或者哈希表中某个地址存储的记录过多,此时以链表的检索方式效率是不算高的,这时可以将链表转换为二叉排序树、AVL数等提高检索效率,后面要讲的HashMap(HashTable、HashSet)就是这么实现的。
类结构
public class Hash { private Node[] hashTable = new Node[TABLE_SIZE]; private static final int TABLE_SIZE = 10; static class Node{//哈希表结点记录了关键字的值和在原序列下的位置 private int data; private int index; private Node next; public Node(){} public Node(int data,int index) { this.data = data; this.index = index; } @Override public String toString() { return "Node{" + "data=" + data + ", index=" + index + '}'; } }
除余法获得哈希地址
public int getHashValue(int key){
int p=0;
for (int i=4; i<=TABLE_SIZE; i++){
if(i%2==0 || i%3==0){
}else
p = i;
}
return key%p;
}
构建哈希表
public Node[] getHashTable(int[] list){ for (int i=0; i<TABLE_SIZE; i++){ hashTable[i] = new Node(); } int index; Node point; for (int i=0; i<list.length; i++){ index = getHashValue(list[i]); point = hashTable[index];//头插法 Node newNode = new Node(list[i],i); newNode.next = point.next; point.next = newNode; } return hashTable; }
检索操作
public Node find(int key, int[] list){
Node[] hashTable = getHashTable(list);
int index = getHashValue(key);
Node point = hashTable[index].next;
while (point!=null && point.data!=key){
point = point.next;
}
return point;
}
差值法
这种方法在发生冲突时,处理原则时以现在的数据地址加上一个固定的差值,当数据地址超出数据大小时,则让数据地址采用循环的方式处理。另外,还可以建立一个公共溢出栈的方法来解决冲突。即m个哈希地址用数组T[0,m-1]表示,该表为基本表,每个地址存放一个关键字,另外设立一个数组v[0…n]为溢出表。若关键字和基本表中关键字为同义词,不管它由哈希函数得到的哈希地址是什么,一旦发生冲突都填入溢出表。
四种方法,个人还是觉得拉链法实用些。直观上看负载系数α越小,发生冲突的可能性越小,查找时所用的平均比较次数也就越少;反之越大,即表越慢,发生冲突的可能性就越,查找的平均比较次数越多,因此哈希表查找成功的平均查找长度Sn和不成功的平均长度Un均与α有关。
哈希表检索发的效率分析比较复杂,这里直接从课本给出结论
HashMap是Java集合一个十分重要的类,它存储的内容是键值对(key-value)映射,底层用散列表实现,根据键的 HashCode 值存储数据,具有很快的访问速度。HashSet、HashTable的实现也大同小异,这里只了解HashMap即可。
查看HashMap源码,先看看用于初始化的几个属性
哈希表默认初始容量,<<为二进制移位操作,1<<4后为10000,也就是16
最大容量
默认负载因子
树化阈值,前面提到过,当哈希表的某个存储空间(桶)的聚集的元素超过一定个数时,再用单链表存储的顺序检索效率会降低,这时可以转换为二叉排序树、AVL树等提高效率。HashMap是超过8个就转换为红黑树。
还原链表阈值,由于HashMap有扩容操作,扩容后数据会重新计算哈希值然后进行分配,这时某个桶的元素个数可能会减小,因为分配到其他桶去了,所以就需要还原为链表,毕竟红黑树的插入删除操作还是比较麻烦的。
最小树形化容量阈值,前面讲的是某个空间内的转换操作,这里说的是整个哈希表。
当哈希表中的容量大于该值时,才允许树形化链表,否则,若桶内元素太多时,则直接扩容,而不是树形化
为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
也就是说,某个桶大于树化阈值时并不是直接转换为树,而是优先对整个哈希表进行扩容
现在来看HashMap如何计算哈希地址。
hashCode()方法是Object的一个方法,计算出一个哈希码,随后与自己的高16位作为异或操作(^异或操作,<<<无符号移位)
至于为什么这么做可以看看这篇文章:HashMap中hash(Object key)原理,为什么(hashcode >>> 16)。
求出哈希地址就可以将元素插入哈希表中了,HashMap插入map的方法为putMapEntries(),前面部分不是我们关心的内容(自行了解JDK8 HashMap源码 putMapEntries解析),我们关心后面的插入操作,其又调用了 putVal()方法
public V put(K key, V value) { //调用putVal()方法 //传入的参数:经过哈希扰动和hashcode,key,val,onlyIfAbsent,evict return putVal(hash(key), key, value, false, true); } //这里得到onlyIfAbsent表示如果hash冲突时,新值是否替换旧值,false表示替换 //evict我们用不到,暂时不分析 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { //这里首先定义了几个临时变量 //table表示当前的散列表 //p表示当前散列表的元素 //n表示散列表数组的长度 //i表示寻址的结果 Node<K,V>[] tab; Node<K,V> p; int n, i; //如果table表未初始化,或者数组长度为0,就会进行扩容操作,并返回扩容后的数组长度 //resize()扩容方法会开专题讲解 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //通过取模运算(前文详细写道),来获得寻址结果 //也就是传入的key-value键值对在数组中的存储位置 if ((p = tab[i = (n - 1) & hash]) == null) //如果为null,说明此处还没有存储元素,将key-value包装成Node设置在i处 tab[i] = newNode(hash, key, value, null); //else说明寻址结果i已经存储的有元素了,哈希冲突了 else { //两个临时变量,node和key Node<K,V> e; K k; //表示你要插入的key和原位置的key完全相同,这里将p赋值给e,便于后文的替换操作 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //此时说明p已经树化,调用红黑树的方法添加到指定位置 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else {//走到这里说明,hash寻址冲突了,并且和寻址结果i处的key不同,也不是树,说明此时要在链表上操作了 //找到链表的尾节点,插入新节点 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //说明此时链表长度超过了树化阈值,进行树化 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //此时在链表找尾节点时,发现了和新插入节点完全一致的key,所以记录,跳出,后文替换 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } //替换操作,如果e!=null,说明e记录了冲突的节点 if (e != null) { // existing mapping for key //记录老值,用于返回 V oldValue = e.value; //开头传入的参数flase,说明冲突时会进行替换操作 if (!onlyIfAbsent || oldValue == null) e.value = value; //具体实现在LinkedHashMap,此处不在详解 afterNodeAccess(e); return oldValue; } } //对散列表操作进行记录,用于fast-fail机制 ++modCount; //如果插入后元素超过了扩容阈值,就会进行扩容操作 if (++size > threshold) resize(); //此处与HashMap关系不大,不在分解 afterNodeInsertion(evict); return null; }
以下为pulVal()的执行图
原文链接:【集合-HashMap】源码级理解HashMap之putVal()方法,一行行手撕源码
简单来说HashMap的插入操作的流程如下
根据key计算出哈希地址,找到在哈希表的位置,如果该位置上没有元素,则直接插入成功。
如果该位置已经有元素(表示有一个或多个元素以链表或树的形式存储),这时插入的key要用其哈希码(注意是哈希码)与这些元素一一比较,如果没有相同的,则插入成功。
若有相同的,则将存放的value值更新为插入的value值。
HashMap是一个深入学习数据结构的好实例,也是面试时经常问的问题,建议好好看看。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。