目录
1 概述
1.1 分类
1.2 常见的查找方法
查找方法有很多种,其中常见的有:顺序查找、二分查找、插值查找、斐波那契查找、分块查找、哈希查找和树表查找。
1.3 平均查找长度(ASL)
平均查找长度(Average Search Length,ASL):需和指定key进行比较的关键字的个数的期望值,称为查找算法在查找成功时的平均查找长度。
对于含有n个数据元素的查找表,查找成功的平均查找长度为:ASL = Pi*Ci的和。
Pi:查找表中第i个数据元素的概率。
Ci:找到第i个数据元素时已经比较过的次数。
2 顺序查找
2.1 算法描述
顺序查找是一种简单的查找方法,也称为线性查找。它的基本思想是从表的一端开始,顺序扫描线性表,依次将扫描到的元素与给定值进行比较,直到找到相等的元素或扫描完整个线性表为止。
2.2 代码实现
int SequentialSearch(int[] nums,int target){
for(int i=0;i<nums.Length;i++){
if(nums[i]==target) return i;
}
return -1;
}
2.3 性能分析
假设每个数据元素的概率相等,查找成功时的平均查找长度为:ASL = 1/n*(1+2+3+…+n) = (n+1)/2 。而当查找不成功时,需要n+1次比较,时间复杂度为O(n)。
平均查找长度ASL | 时间复杂度 |
---|---|
( n + 1 ) / 2 (n+1)/2 (n+1)/2 | O ( n ) O(n) O(n) |
3 二分查找
3.1 算法描述
二分查找又称折半查找,是一种有序查找算法。
它的基本思路是:用给定值k先与中间结点的关键字比较,中间结点把线形表分成两个子表,若相等则查找成功;若不相等,再根据k与该中间结点关键字的比较结果确定下一步查找哪个子表,这样递归进行,直到查找到或查找结束发现表中没有这样的结点。
3.2 代码实现
int BinarySearch(int[] nums,int target){
if(nums.Length==0) return -1;
int left = 0;
int right = nums.Length-1;
while(left<=right){
int mid = (left+right)/2;
if(nums[mid]==target) return mid;
else if(nums[mid]<target) left = mid+1;
else right = mid-1;
}
return -1;
}
3.3 性能分析
平均查找长度ASL | 时间复杂度 |
---|---|
l o g 2 ( n + 1 ) log_2{(n+1)} log2(n+1) | O(logn) |
4 插值查找
4.1 算法描述
差值查找法是基于二分法进行改进的算法,与二分法不同的是,差值法选择的不是中间值。而是根据所要查找的数进行自适应选择,从而使mid值更靠近关键字key,这样可以间接减少比较次数。
int mid = left+(right-left)*(target-nums[left])/(nums[right]-nums[left]);
4.2 代码实现
int InterpolationSearch(int[] nums,int target){
if(nums.Length==0) return -1;
int left = 0;
int right = nums.Length-1;
while(left<=right){
if(left==right){
if(nums[left]==target) return left;
else return -1;
}
int mid = left+(right-left)*(target-nums[left])/(nums[right]-nums[left]);
if(nums[mid]==target) return mid;
else if(nums[mid]<target) left = mid+1;
else right = mid-1;
}
return -1;
}
4.3 性能分析
插值查找的平均时间复杂度是Θ(loglogn),如果数组不是均匀分布的,插值查找的复杂度会退化到线性的复杂度。
平均查找长度ASL | 时间复杂度 |
---|---|
略 | O(loglogn) |
5 斐波那契查找
5.1 算法描述
斐波那契数列即1,1,2,3,5,8,13…,从第三个数开始,后面的数都等于前两个数之和,而斐波那契查找就是利用的斐波那契数列来实现查找的。
斐波拉契查找是相对于二分查找和插值查找来区分的。具体思想差不多,主要区别在于插值的选取。
推论部分
F(k) = F(k-1)+F(k-2);
F(k)-1 = (F(k-1)-1)+(F(k-2)-1)+1;
即数组长度=左半部分+右半部分+mid
所以如果待查找数组长度为F(k)-1,mid=left+F(k-1)-1;
判断部分
if(target==nums[mid]) return mid;
else if(target<nums[mid]) return FibnacciSearch(left,mid-1,k-1);
else return FibnacciSearch(mid+1,right,k-2);
5.2 代码实现
int FibonacciSearch(int[] nums,int target){
if(nums.Length==0) return -1;
int left = 0;
int right = nums.Length-1;
int len = nums.Length;
int k = 0;
while(len>(Fib(k)-1)) k++;
int[] temp = new int[Fib(k)-1];
nums.CopyTo(temp,0);
for(int i=len;i<temp.Length;i++) temp[i] = nums[right];
return FibonacciSearch(temp,target,left,right,k-1);
}
int FibonacciSearch(int[] temp,int target,int left,int right,int k){
while(left<=right){
int mid = left+(Fib(k)-1);
if(temp[mid]==target){
if(mid<=right) return mid;
else return right; //说明temp[mid]是扩充部分。
}else if(temp[mid]<target){
left = mid+1;
k-=2; //右半部分
}else{
right = mid-1;
k-=1; //左半部分
}
}
return -1;
}
int Fib(int n){
if(n<=1) return n;
int left = 0;
int right = 1;
for(int i=2;i<=n;i++){
int cur = left+right;
left = right;
right = cur;
}
return right;
}
5.3 性能分析
平均查找长度ASL | 时间复杂度 |
---|---|
略 | O ( l o g n ) O(logn) O(logn) |
5.4 二分查找、插值查找和斐波那契查找的区别
二分查找、插值查找和斐波那契查找都是针对有序数组进行的查找算法,但它们的查找方式有所不同。其中,二分查找是最基本的一种,它的中间点是数组的中间位置,即 mid = (low + high) / 2。插值查找是通过人类的思维对折半查找进行优化,让查找的位置尽量靠近目标位置,而不是从数组的中间开始查找。斐波那契查找是一种根据斐波那契数列来确定黄金分割点的查找方法,它可以在一定程度上提高效率。
二分查找和插值查找适用于静态表,而斐波那契查找适用于动态表。
二分查找和插值查找的时间复杂度都为 O(logn),其中插值查找在数据分布均匀时的时间复杂度为O(loglogn),数据分布不均匀时为O(n)。
而斐波那契查找的时间复杂度和二分查找相同,为 O(logn) ,但它的优势在于它只涉及加法和减法运算,而不用除法,除法会占用更多的时间,因此,斐波那契查找的运行时间理论上比二分查找要小。
6 分块查找
6.1 算法描述
分块查找又称索引顺序查找,它是顺序查找的一种改进方法。
分块查找的思想就是,将n个数据元素"按块有序"划分为m块(m ≤ n)。每一块中的结点不必有序,但块与块之间必须"按块有序";即第1块中任一元素的关键字都必须小于第2块中任一元素的关键字;而第2块中任一元素又都必须小于第3块中的任一元素,……
算法流程:
1.先选取各块中的最大关键字构成一个索引表。
2.查找分两个部分:先对索引表进行二分查找或顺序查找,以确定待查记录在哪一块中;然后,在已确定的块中用顺序法进行查找。
6.2 代码实现
public struct Block{
public int start;
public int max;
}
const int BLOCK_COUNT = 3; //在分块的选择上,一般让分块的大小尽量大,而分块的数量尽量少。
public int Search(int[] nums, int target) {
//0.处理特殊情况
if(nums.Length<3){
for(int i=0;i<nums.Length;i++) if(target==nums[i]) return i;
return -1;
}
//1.初始化
int len = nums.Length;
int blockSize = len/BLOCK_COUNT;
int remain = len%BLOCK_COUNT;
Block[] blocks = new Block[BLOCK_COUNT];
for(int i=0;i<BLOCK_COUNT;i++){
int size = 0;
if(i==0){
blocks[i].start = i*blockSize;
size = blockSize+remain;
}
else{
blocks[i].start = i*blockSize+remain;
size = blockSize;
}
blocks[i].max = FindMax(nums,blocks[i].start,size);
}
return BlockSearch(nums,target,blocks,blockSize,remain);
}
int BlockSearch(int[] nums,int target,Block[] blocks,int blockSize,int remain){
int i=0;
while(i<blocks.Length&&target>blocks[i].max) i++;
if(i==blocks.Length) return -1;
if(i==0) blockSize+=remain;
for(int j=blocks[i].start;j<nums.Length&&j<(blocks[i].start+blockSize);j++){
if(nums[j]==target) return j;
}
return -1;
}
int FindMax(int[] nums,int start,int size){
int max = nums[start];
for(int i=start+1;i<nums.Length&&i<(start+size);i++){
if(nums[i]>max) max = nums[i];
}
return max;
}
7 树表查找
7.1 二叉树查找(BST)
7.1.1 二叉查找树
二叉查找树(又叫二叉搜索树,BinarySearch Tree)或者是一棵空树,或者是具有下列性质的二叉树:
1.若任意节点的左子树不空,则左子树上所有结点的值均小于它的根结点的值。
2.若任意节点的右子树不空,则右子树上所有结点的值均大于它的根结点的值。
3.任意节点的左、右子树也分别为二叉查找树。
二叉查找树的性质:对二叉查找树进行中序遍历,即可得到有序数列。
7.1.2 算法描述
二叉树查找算法是一种基于二叉树的查找算法,它的思想是将一个有序序列构建成一棵二叉树,通过比较查找元素和当前节点的大小关系,不断缩小查找范围,直到找到目标元素或者确定目标元素不存在。二叉树查找算法的优点是可以在有序序列中快速查找目标元素,但是在无序序列中查找目标元素时效率较低。
7.1.3 代码实现
class TreeNode{
public int val;
public int index;
public TreeNode left;
public TreeNode right;
public TreeNode(int val,int index){
this.val = val;
this.index = index;
}
}
public int Search(int[] nums, int target) {
//1.建树
TreeNode root = null;
for(int i=0;i<nums.Length;i++){
root = CreateBST(root,nums[i],i);
}
//2.搜索
return BSTSearch(root,target);
}
TreeNode CreateBST(TreeNode node,int val,int index){
if(node==null) return new TreeNode(val,index);
if(val<node.val) node.left = CreateBST(node.left,val,index);
else node.right = CreateBST(node.right,val,index);
return node;
}
int BSTSearch(TreeNode node,int target){
if(node==null) return -1;
if(target==node.val) return node.index;
else if(target<node.val) return BSTSearch(node.left,target);
else return BSTSearch(node.right,target);
}
7.1.4 性能分析
和二分查找一样,插入和查找的时间复杂度均为O(logn),但是在最坏的情况下仍然会有O(n)的时间复杂度。原因在于插入和删除元素的时候,树没有保持平衡。
基于二叉查找树进行优化,可以得到其他的树表查找算法,如平衡树、红黑树等高效算法。
7.2 平衡查找树之AVL树
PS:一般不用AVL树,这里为了科普所以加上了。
7.2.1 AVL树
AVL树是第一种自平衡的二叉查找树,因为AVL树任意节点的左右子树高度差的绝对值不超过1,所以AVL树又被称为高度平衡树。AVL树本质上是一棵带有平衡条件的二叉搜索树。
AVL树的每个结点都有一个平衡因子,可以是-1、0或1,平衡因子是左子树的高度减去右子树的高度。
AVL树的特点是:
1.从根结点到叶子结点的最长路径不超过最短路径的两倍。
2.任何结点的两个子树的高度差不超过1。
7.2.2 AVL树的自平衡
AVL树的自平衡是通过旋转行为实现的,当AVL树的某个节点的左右子树高度差大于1时,就需要进行旋转操作来保持AVL树的平衡性。旋转行为一般是在插入和删除过程中才发生。
AVL树的旋转操作分为左旋和右旋两种。左旋是指以某个节点为支点,将其右子树向左旋转,使其成为该节点的父节点,而该节点成为其左子树的根节点。右旋则是相反的操作。
7.2.3 性能分析
AVL树的平衡性能非常好,插入,删除和查找操作的时间复杂度都是O(logn)。
7.3 平衡查找树之2-3查找树
7.3.1 2-3查找树
2-3查找树(2-3 Tree)或者是一棵空树,或者是具有下列性质的树:
1.对于2结点,该结点保存一个key及对应value,以及两个指向左右结点的结点,左结点也是一个2-3结点,所有的值都比key要小,右结点也是一个2-3结点,所有的值比key要大。
2.对于3结点,该结点保存两个key及对应value,以及三个指向左中右的结点。左结点也是一个2-3结点,所有的值均比两个key中的最小的key还要小;中间结点也是一个2-3结点,中间结点的key值在两个跟结点key值之间;右结点也是一个2-3结点,结点的所有key值比两个key中的最大的key还要大。
2-3查找树的性质:
1.如果中序遍历2-3查找树,就可以得到排好序的序列。
2.在一个完全平衡的2-3查找树中,根结点到每一个为空结点的距离都相同。(这也是平衡树中“平衡”一词的概念,根结点到叶结点的最长距离对应于查找算法的最坏情况,而平衡树中根结点到叶结点的距离都一样,最坏情况也具有对数复杂度。)
7.3.2 2-3树的自平衡
2-3树的自平衡是通过节点的分裂和合并来实现的。当一个节点中有三个元素时,它会被分裂成两个节点,其中一个节点包含较小的两个元素,另一个节点包含较大的元素。当一个节点中只有一个元素时,它会和它的兄弟节点合并成一个节点,这样就可以保证2-3树的平衡性。
7.3.3 2-3树的查找和插入
偷懒,放个链接:平衡查找树之2-3树
7.3.4 性能分析
2-3查找树的查找效率与高度息息相关:
1.在最坏的情况下,也就是所有的结点都是2-node结点,查找效率为O(logn)。
2.在最好的情况下,所有的结点都是3-node结点,查找效率约等于O(0.631logn)。
7.3.5 AVL树和2-3树的区别
AVL树和2-3树都是平衡树,但是它们的实现方式不同。AVL树是一种带有平衡条件的二叉搜索树,它的每个节点的左右子树高度差不超过1。而2-3树则是一种多路查找树,它的每个节点可以有两个或三个子节点。
AVL树的自平衡是通过旋转操作实现的,而2-3树则是通过节点分裂和合并来保持平衡。
7.4 平衡查找树之红黑树
7.4.1 红黑树
2-3查找树能保证在插入元素之后能保持树的平衡状态,最坏情况下即所有的子节点都是2-node,从而保证了最坏情况下的时间复杂度。但是2-3树实现起来比较复杂,于是就有了一种简单实现2-3树的数据结构,即红黑树(Red-Black Tree)。
红黑树的思想就是对2-3查找树进行编码,尤其是对2-3查找树中的3-nodes结点添加额外的信息。红黑树中将结点之间的链接分为两种不同类型,红色链接,他用来链接两个2-nodes结点来表示一个3-nodes结点。黑色链接用来链接普通的2-3结点。特别的,使用红色链接的两个2-nodes来表示一个3-nodes结点,并且向左倾斜,即一个2-node是另一个2-node的左子结点。这种做法的好处是查找的时候不用做任何修改,和普通的二叉查找树相同。
红黑树是一种具有红色和黑色链接的自平衡二叉查找树,同时满足:
1.红色结点向左倾斜。
2.一个结点不可能有两个红色链接。
3.从根结点到叶子结点的最长路径不超过最短路径的两倍,为了满足这个特点,红黑树要求从根结点到所有叶子结点的路径上,黑色链接的个数相同。这样就可以保证红黑树的平衡性。
下图可以看到红黑树其实是2-3树的另外一种表现形式:如果我们将红色的连线水平绘制,那么他链接的两个2-node节点就是2-3树中的一个3-node节点了。
7.4.2 代码实现
偷懒,放个链接:平衡查找树之红黑树
7.4.3 红黑树的自平衡
红黑树通过对节点进行颜色标记,并通过旋转操作来实现自平衡。当插入或删除一个节点后,如果破坏了红黑树的平衡性质,就需要通过旋转操作来调整节点的位置和颜色,从而保持红黑树的平衡性。
7.4.4 红黑树的应用
1 应用
1…NET中的SortedDictionary,SortedSet等。
2.Java中的java.util.TreeMap,java.util.TreeSet。
3.C++ STL中的:map,multimap,multiset;
2 SortedDictionary
SortedDictionary的底层是红黑树,因此它的查找时间复杂度是O(logn),它相对于Dictionary的优势是,它的键是已排序的。如果需要对字典进行排序,或者是需要对未排序数据执行更快的插入或移除操作,SortedDictionary是更好的选择。
7.4.5 性能分析
红黑树的平均高度大约为logn,最坏情况是除了最左侧路径全部是由3-node节点组成,即红黑相间的路径长度是全黑路径长度的2倍。
红黑树可以看作是2-3查找树的一种实现,它能保证最坏情况下仍然具有对数的时间复杂度。
7.4.6 BST、2-3 Tree、Red-Black Tree性能对比
7.5 B树和B+树
7.5.1 B Tree/B+ Tree
1 B树
在计算机科学中,B树(B-tree)是一种树状数据结构,它能够存储数据、对其进行排序并允许以O(log n)的时间复杂度运行进行查找、顺序读取、插入和删除的数据结构。B树,概括来说是一个节点可以拥有多于2个子节点的二叉查找树。与自平衡二叉查找树不同,B树为系统最优化大块数据的读和写操作。B-tree算法减少定位记录时所经历的中间过程,从而加快存取速度。普遍运用在数据库和文件系统。
2 B+树
B+树是B树的一种变形树,B树的特点是:
1.非叶子结点的结点仅有索引作用,跟记录有关的信息均存放在叶子结点。
2.树的所有叶子结点构成一个有序链表,可以按照关键码排序的次序遍历全部记录。
3 B树和B+树的区别
B树的优点是:由于B树的每个结点都存储了数据,所以查询的时候可能不需要O(logn)的复杂度,甚至最好的情况是O(1)就可以找到数据。
B+树的优点是:
1.由于B+树在内部结点上不好含数据信息,因此在内存页中能够存放更多的key。 数据存放的更加紧密,具有更好的空间局部性。因此访问叶子结点上关联的数据也具有更好的缓存命中率。
2.B+树的叶子结点都是相连的,因此对整棵树的遍历只需要一次线性遍历叶子结点即可。而且由于数据顺序排列并且相连,所以便于区间查找和搜索。而B树则需要进行每一层的递归遍历。相邻的元素可能在内存中不相邻,所以缓存命中性没有B+树好。
7.5.2 应用
B/B+树常用于文件系统和数据库系统中,它通过对每个节点存储个数的扩展,使得对连续的数据能够进行较快的定位和访问,能够有效减少查找时间,提高存储的空间局部性从而减少IO操作。它广泛用于文件系统及数据库中,如:
1.Windows:HPFS文件系统。
2.Mac:HFS,HFS+文件系统。
3.Linux:ResiserFS,XFS,Ext3FS,JFS文件系统。
4.数据库:ORACLE,MYSQL,SQLSERVER等中。
8 哈希查找
前面介绍了很多种查找方法,可以发现红黑树在平均情况下插入、查找以及删除上都达到了O(logn)的时间复杂度,那么有没有查找效率更高的数据结构呢?
答案是哈希表。
8.1 算法描述
哈希查找是一种借助哈希表(散列表)查找目标元素的方法,查找效率最高时对应的时间复杂度为 O(1)。哈希查找算法适用于大多数场景,既支持在有序序列中查找目标元素,也支持在无序序列中查找目标元素。哈希查找算法的思想是通过对元素的关键字值进行某种运算,直接求出元素的地址,即使用关键字到地址的直接转换方法,而不需要反复比较。
8.2 哈希表
哈希表就是一种以 键-值(key-indexed) 存储数据的结构,我们只要输入待查找的值即key,即可查找到其对应的值。
使用哈希查找有两个步骤:
1.使用哈希函数将被查找的键转换为数组的索引。在理想的情况下,不同的键会被转换为不同的索引值,但是在有些情况下我们需要处理多个键被哈希到同一个索引值的情况。所以哈希查找的第二个步骤就是处理冲突
2.处理哈希碰撞冲突。有很多处理哈希碰撞冲突的方法,本文后面会介绍拉链法和线性探测法。
哈希表是一个在时间和空间上做出权衡的经典例子。如果没有内存限制,那么可以直接将键作为数组的索引。那么所有的查找时间复杂度为O(1)。如果没有时间限制,那么我们可以使用无序数组并进行顺序查找,这样只需要很少的内存。哈希表使用了适度的时间和空间来在这两个极端之间找到了平衡。只需要调整哈希函数算法即可在时间和空间上做出取舍。
8.3 哈希函数
哈希查找第一步就是使用哈希函数将键映射成索引。这种映射函数就是哈希函数。如果我们有一个保存0-M数组,那么我们就需要一个能够将任意键转换为该数组范围内的索引(0~M-1)的哈希函数。哈希函数需要易于计算并且能够均匀分布所有键。
对于不同的键类型,我们需要实现不同的哈希函数。
8.3.1 正整数
获取正整数哈希值最常用的方法是使用除留余数法。即对于大小为M的数组,对于任意正整数k,计算k%M。为了尽可能避免哈希冲突,M一般取素数。
8.3.2 字符串
将字符串作为键的时候,我们也可以将他作为一个大的整数来采用除留余数法。也可以将将组成字符串的每一个字符取值然后进行哈希。
以下采取的是Horner计算字符串哈希值的方法,举个例子进行说明:比如要获取”call”的哈希值,字符串c对应的unicode为99,a对应的unicode为97,L对应的unicode为108,那么hash=108 + 31· (108 + 31 · (97 + 31 · (99)))。
int GetHashCode(string str){
char[] s = str.ToCharArray();
int hash = 0;
for(int i=0;i<s.Length;i++){
hash = s[i]+31*hash;
}
return hash;
}
如果字符串较长,对每个字符去哈希值可能会比较耗时,所以可以通过间隔取N个字符来获取哈西值来节省时间,比如,可以 获取每8-9个字符来获取哈希值:
int GetHashCode(string str){
char[] s = str.ToCharArray();
int hash = 0;
int skip = Math.Max(1,s.Length/8);
for(int i=0;i<s.Length;i+=skip){
hash = s[i]+(31*hash);
}
return hash;
}
8.4 避免哈希冲突
8.4.1 拉链法
通过哈希函数,我们可以将键转换为数组的索引(0-M-1),但是对于两个或者多个键具有相同索引值的情况,我们需要有一种方法来处理这种冲突。
一种比较直接的办法就是,将大小为M 的数组的每一个元素指向一个条链表,链表中的每一个节点都存储散列值为该索引的键值对,这就是拉链法。
该方法的基本思想就是选择足够大的M,使得所有的链表都尽可能的短小,以保证查找的效率。对采用拉链法的哈希实现的查找分为两步,首先是根据散列值找到等一应的链表,然后沿着链表顺序找到相应的键。
//注意:基于力扣704题
public class HashTable{
private List<int[]>[] data;
private int size;
public HashTable(int size){
this.size = size;
data = new List<int[]>[size];
for(int i=0;i<size;i++){
data[i] = new List<int[]>();
}
}
private int GetHashCode(int key)=>Math.Abs(key)%size;
//这里key和value相同,所以就不传入value了
public void Put(int key,int index){
int hash = GetHashCode(key);
data[hash].Add(new int[]{
key,index});
}
public int GetIndex(int key){
int hash = GetHashCode(key);
for(int i=0;i<data[hash].Count;i++){
if(data[hash][i][0]==key) return data[hash][i][1];
}
return -1;
}
}
public class Solution {
public int Search(int[] nums, int target) {
HashTable hashTable = new HashTable(10);
for(int i=0;i<nums.Length;i++){
hashTable.Put(nums[i],i);
}
return hashTable.GetIndex(target);
}
}
8.4.2 线性探测法
线性探测法是开放寻址法解决哈希冲突的一种方法,基本原理为,使用大小为M的数组来保存N个键值对,其中M>N,我们需要使用数组中的空位解决碰撞冲突。如下图所示:
开放寻址法中最简单的是线性探测法:当碰撞发生时即一个键的散列值被另外一个键占用时,直接检查散列表中的下一个位置即将索引值加1,这样的线性探测会出现三种结果:
1.命中,该位置的键和被查找的键相同
2.未命中,键为空
3.继续查找,该位置和键被查找的键不同。
//注意:基于力扣704题
public class HashTable{
private int size;
private int[] keys;
private int[] values;
private bool[] types;
public HashTable(int size){
this.size = size;
keys = new int[size];
values = new int[size];
types = new bool[size];
}
private int GetHash(int key)=>Math.Abs(key)%size;
public void Put(int key,int index){
int hash = GetHash(key);
while(types[hash]) hash = (hash+1)%size;
keys[hash] = key;
values[hash] = index;
types[hash] = true;
}
public int GetIndex(int key){
int hash = GetHash(key);
int cur = hash;
while(keys[cur]!=key){
cur = (cur+1)%size;
if(cur==hash) return -1;
}
return values[cur];
}
}
public class Solution {
public int Search(int[] nums, int target) {
HashTable hashTable = new HashTable(nums.Length);
for(int i=0;i<nums.Length;i++){
hashTable.Put(nums[i],i);
}
return hashTable.GetIndex(target);
}
}
线性探查(Linear Probing)方式虽然简单,但是有一些问题,它会导致同类哈希的聚集。在存入的时候存在冲突,在查找的时候冲突依然存在。
8.5 哈希碰撞攻击
哈希表攻击就是通过精心构造哈希函数,使得所有的键经过哈希函数后都映射到同一个或者几个索引上,将哈希表退化为了一个单链表,这样哈希表的各种操作,比如插入,查找都从O(1)退化到了链表的查找操作,这样就会消耗大量的CPU资源,导致系统无法响应,从而达到拒绝服务供给(Denial of Service, Dos)的目的。
8.6 .NET中哈希的实现
任何作为key的值添加到Dictionary中时,首先会获取key的hashcode,然后将其映射到不同的bucket中去:
public Dictionary(int capacity, IEqualityComparer<TKey> comparer) {
if (capacity < 0) ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity);
if (capacity > 0) Initialize(capacity);
this.comparer = comparer ?? EqualityComparer<TKey>.Default;
}
在Dictionary初始化的时候,会如果传入了大小,会初始化bucket 就是调用Initialize方法:
private void Initialize(int capacity) {
int size = HashHelpers.GetPrime(capacity);
buckets = new int[size];
for (int i = 0; i < buckets.Length; i++) buckets[i] = -1;
entries = new Entry[size];
freeList = -1;
}
我们可以看看Dictonary的Add方法,Add方法在内部调用了Insert方法:
private void Insert(TKey key, TValue value, bool add)
{
if( key == null ) {
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
}
if (buckets == null) Initialize(0);
int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
int targetBucket = hashCode % buckets.Length;
#if FEATURE_RANDOMIZED_STRING_HASHING
int collisionCount = 0;
#endif
for (int i = buckets[targetBucket]; i >= 0; i = entries[i].next) {
if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) {
if (add) {
ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_AddingDuplicate);
}
entries[i].value = value;
version++;
return;
}
#if FEATURE_RANDOMIZED_STRING_HASHING
collisionCount++;
#endif
}
int index;
if (freeCount > 0) {
index = freeList;
freeList = entries[index].next;
freeCount--;
}
else {
if (count == entries.Length)
{
Resize();
targetBucket = hashCode % buckets.Length;
}
index = count;
count++;
}
entries[index].hashCode = hashCode;
entries[index].next = buckets[targetBucket];
entries[index].key = key;
entries[index].value = value;
buckets[targetBucket] = index;
version++;
#if FEATURE_RANDOMIZED_STRING_HASHING
if(collisionCount > HashHelpers.HashCollisionThreshold && HashHelpers.IsWellKnownEqualityComparer(comparer))
{
comparer = (IEqualityComparer<TKey>) HashHelpers.GetRandomizedEqualityComparer(comparer);
Resize(entries.Length, true);
}
#endif
}
首先,根据key获取其hashcode,然后将hashcode除以backet的大小取余映射到目标backet中,然后遍历该bucket存储的链表,如果找到和key相同的值,如果不允许后添加的键与存在的键相同替换值(add),则抛出异常,如果允许,则替换之前的值,然后返回。
如果没有找到,则将新添加的值放到新的bucket中,当空余空间不足的时候,会进行扩容操作(Resize),然后重新hash到目标bucket。这里面需要注意的是Resize操作比较消耗资源。
8.7 性能分析
9 练习
练习题目见力扣。
69 x的平方根
1 二分法
public class Solution {
public int MySqrt(int x) {
int left = 0;
int right = x;
int cur = 0; //保存当前的最好答案
while(left<=right){
int mid = (left+right)/2;
//这里用long是考虑了乘积的大数情况
if((long)mid*(long)mid==x) return mid;
else if((long)mid*(long)mid<x){
cur = mid;
left = mid+1;
}else right = mid-1;
}
return cur;
}
}
2 牛顿迭代法*
介绍
牛顿迭代法是一种可以用来快速求解零点的方法。为了叙述方便,在这里,我们用C表示待求出平方根的那个整数,那么我们可以容易得到这个式子: f ( x ) = x 2 − C f(x)=x^2-C f(x)=x2−C。而函数的零点即是我们要求的结果。
牛顿迭代法的本质是借助泰勒级数,从初始值开始快速向零点逼近。我们任取一个 x 0 x_0 x0作为初始值,在每一步迭代中,我们找到函数图像上的点( x i x_i xi, f ( x i ) f(x_i) f(xi)),过点做一条斜率为该点导数 f ′ ( x i ) f'(x_i) f′(xi)的直线,与横轴的交点记为 x i + 1 x_{i+1} xi+1。 x i + 1 x_{i+1} xi+1相较于 x i x_i xi而言距离零点更近。在经过多次迭代后,我们就可以得到一个距离零点非常接近的交点。下图给出了从 x 0 x_0 x0开始迭代两次,得到 x 1 x_1 x1和 x 2 x_2 x2的过程。
公式计算
细节
1.初始值选择:
我们选择 x 0 x_0 x0=C作为初始值,因为这是最稳妥的选择。因为零点是从右向左逼近的,如果选择的初始值小了的话,可能会位于零点左侧而导致迭代不到零点。
2.迭代到何时才算结束:
每一次迭代后,我们都会距离零点更进一步,所以当相邻两次迭代得到的交点非常接近时,我们就可以断定,此时的结果已经足够我们得到答案了。一般来说,可以判断相邻两次迭代的结果的差值是否小于一个极小的非负数 ,一般可以取 1 0 − 6 10^{-6} 10−6或 1 0 − 7 10^{-7} 10−7。
3.如何通过迭代得到的近似零点得出最终的答案?
我们每次迭代的结果 x i x_i xi都会恒大于等于平方根,所以只要选的差值足够小,就可以保证最终结果只是稍大于零点,然后通过int保存去除小数部分。
代码
public class Solution {
public int MySqrt(int x) {
int C = x;
double dif = 1e-7;
double a = x;
double b = (a+C/a)/2;
while((a-b)>=dif){
a = b;
b = (a+C/a)/2;
}
return (int)a;
}
}
300 最长递增子序列
1 动态规划
public class Solution {
public int LengthOfLIS(int[] nums) {
//dp[n]:以数组下标为n的元素结尾的最长递增子序列长度
//dp[n] = Math.Max(dp[n],dp[m]+1); 其中nums[m]<nums[n]
int[] dp = new int[nums.Length];
for(int i=0;i<dp.Length;i++) dp[i] = 1;
int max = 1;
for(int i=1;i<nums.Length;i++){
for(int j=i-1;j>=0;j--){
if(nums[j]<nums[i]){
dp[i] = Math.Max(dp[i],dp[j]+1);
}
}
max = Math.Max(max,dp[i]);
}
return max;
}
}
2 贪心+二分*
动态规划解法的时间复杂度为 O ( n 2 ) O(n^2) O(n2),我们需要想一个方法来降低时间复杂度。具体的解释力扣题解里有很多就不赘述,仅展示代码。
public class Solution {
public int LengthOfLIS(int[] nums) {
int[] tails = new int[nums.Length];
int len = 0;
foreach(int item in nums){
int left = 0;
int right = len;
while(left<right){
int mid = (left+right)/2;
if(tails[mid]<item) left = mid+1;
else right = mid;
}
tails[left] = item;
if(left==len) len++;
}
return len;
}
}
4 寻找两个正序数组的中位数
1 第k小的数*
假设两个数组的长度分别为len1和len2,那么找中位数即找第k个数或第k、k+1个数的平均值,那么问题转换为如何求第k个数。
假设现在我们有两个数组,a=[2,3,4,5],b=[1,6,7],我们的目标是找到第k=4个数。
1.第一趟:我们先各取两个数组的第k/2=2个数进行对比,发现3<6,那么我们可以知道,3以及3前面的数肯定不是数组的第4位数,进行排除,排除完之后,我们要在剩余的数字里找第k-2=2个数。
2.第二趟:一样的,我们各取两个数组的第k/2=1个数进行对比,发现1<4,那么我们可以知道1不是剩余数字里的第2位数,进行排除,排除完之后,我们要在剩余的数字里找第k-1=1位数。
3.第三趟,因为此时我们只需要在剩余数字里找第1个数,所以直接输出两个数组里第一位数的较小数就可以了。
当然,我们要考虑一些边界情况,比如当a=[1],b=[2,3,4],我们容易将a的全部数字进行排除,那么只要在b中继续找就可以了。
public class Solution {
private int[] nums1;
private int[] nums2;
public double FindMedianSortedArrays(int[] nums1, int[] nums2) {
this.nums1 = nums1;
this.nums2 = nums2;
int len = nums1.Length+nums2.Length;
if(len%2!=0) return (double)(FindKNum(len/2+1));
else return (double)(FindKNum(len/2)+FindKNum(len/2+1))/2;
}
//方法:返回第k大的数
private int FindKNum(int k){
int len1 = nums1.Length,len2 = nums2.Length;
int index1 = 0,index2 = 0;
while(true){
//边界情况
if(index1==len1) return nums2[index2+k-1];
if(index2==len2) return nums1[index1+k-1];
if(k==1) return Math.Min(nums1[index1],nums2[index2]);
//普通情况
int half = k/2;
int newIndex1 = Math.Min(index1+half,len1)-1;
int newIndex2 = Math.Min(index2+half,len2)-1;
if(nums1[newIndex1]<=nums2[newIndex2]){
k-=(newIndex1-index1+1);
index1 = newIndex1+1;
}else{
k-=(newIndex2-index2+1);
index2 = newIndex2+1;
}
}
}
}
2 划分数组*
public class Solution {
public double FindMedianSortedArrays(int[] nums1, int[] nums2) {
int m = nums1.Length;
int n = nums2.Length;
if(m>n) return FindMedianSortedArrays(nums2,nums1);
int i=0,j=0; //两个数组的划分指针
int left=0,right=m; //对第一个数组执行二分查找,查找最佳划分点
while(true){
i = (left+right)/2;
j = (m+n+1)/2-i;
if(i!=0&&j!=n&&nums1[i-1]>nums2[j]){
//i左移->i在左边
right = i-1;
}else if(i!=m&&j!=0&&nums2[j-1]>nums1[i]){
//i右移->i在右边
left = i+1;
}else{
//i到合适位置了
int maxLeft = 0;
if(i==0) maxLeft = nums2[j-1];
else if(j==0) maxLeft = nums1[i-1];
else maxLeft = Math.Max(nums1[i-1],nums2[j-1]);
//奇数
if((m+n)%2!=0) return maxLeft;
//偶数
int minRight = 0;
if(i==m) minRight = nums2[j];
else if(j==n) minRight = nums1[i];
else minRight = Math.Min(nums1[i],nums2[j]);
return (double)(maxLeft+minRight)/2;
}
}
return -1;
}
}