2023 跟我一起学算法:数据结构和算法- Hash 数据结构
Hash 数据结构
什么是哈希?
Hash
是一种使用Hash
函数将键和值映射到散列表中的技术或过程。这样做是为了更快地访问元素。映射的效率取决于所使用的哈希函数的效率。
让哈希函数 H(x) 将值x 映射到数组中的索引x%10处。 例如,如果值列表是 [11,12,13,14,15],它将分别存储在数组或哈希表中的位置 {1,2,3,4,5} 处。
需要Hash数据结构
互联网上的数据每天都在成倍增加,有效存储这些数据始终是一个难题。在日常编程中,这些数据量可能不是那么大,但仍然需要轻松高效地存储、访问和处理。用于此目的的一种非常常见的数据结构是数组数据结构。
现在问题来了,如果数组已经存在,还需要一个新的数据结构吗!答案就在"效率"二字。虽然存储在数组中需要 O(1) 时间,但搜索至少需要 O(log n) 时间。这个时间看起来很小,但是对于大型数据集来说,它可能会导致很多问题,进而使数组数据结构效率低下。
所以现在我们正在寻找一种可以在恒定时间内(即 O(1) 时间)存储数据并在其中进行搜索的数据结构。这就是哈希数据结构发挥作用的方式。随着哈希数据结构的引入,现在可以轻松地在恒定时间内存储数据并在恒定时间内检索数据。
Hash的组成部分
哈希主要包含三个组成部分:
- 键: 键可以是任何字符串或整数,作为哈希函数的输入,该技术确定数据结构中项目存储的索引或位置。
- 哈希函数: 接收输入键并返回称为哈希表的数组中元素的索引。该索引称为哈希索引。
- 哈希表: 哈希表是一种使用称为哈希函数的特殊函数将键映射到值的数据结构。哈希以关联方式将数据存储在数组中,其中每个数据值都有自己的唯一索引。
Hash 是如何工作的?
假设我们有一组字符串 {"ab", "cd", "efg"} 并且我们希望将其存储在表中。
我们这里的主要目标是在 O(1) 时间内快速搜索或更新表中存储的值,并且我们不关心表中字符串的顺序。因此给定的一组字符串可以充当键,而字符串本身将充当字符串的值,但是如何存储与键对应的值呢?
-
步骤1:我们知道哈希函数(这是一些数学公式)用于计算哈希值,该哈希值充当存储该值的数据结构的索引。
-
第 2 步:
让我们分配
- "a"=1,
- "b"=2,.. 等等,适用于所有字母字符。
-
步骤3: 因此,字符串中所有字符相加得到的数值为:
- "ab" = 1 + 2 = 3,
- "CD" = 3 + 4 = 7 ,
- "efg" = 5 + 6 + 7 = 18
-
步骤 4 : 现在,假设我们有一个大小为 7 的表来存储这些字符串。这里使用的哈希函数是key mod Table size中的字符之和 。我们可以通过
sum(string) mod 7
来计算字符串在数组中的位置。 -
第5步:
所以我们将存储
- 3 mod 7 = 3 中的"ab",
- 7 mod 7 = 0 中的"cd",以及
- 18 mod 7 = 4 中的"efg"。
将键映射到数组的索引
上述技术使我们能够使用简单的哈希函数计算给定字符串的位置,并快速找到存储在该位置的值。因此,散列的想法似乎是在表中存储数据(键,值)对的好方法。
什么是哈希函数?
哈希函数创建键和值之间的映射,这是通过使用称为哈希函数的数学公式来完成的。散列函数的结果称为散列值或散列。哈希值是原始字符串的表示,但通常小于原始字符串。
例如:将数组视为 Map,其中键是索引,值是该索引处的值。因此,对于数组 A,如果我们有索引i,它将被视为键,那么我们只需查看 A[i] 处的值即可找到该值。
哈希函数的类型:
有许多使用数字或字母数字键的哈希函数。不同的哈希函数,我们重点讨论一下 4 种:
划分方法
这是生成哈希值最简单、最容易的方法。哈希函数将值 k 除以 M,然后使用获得的余数。
公式:
h(K) = k 对 M
这里, k 是键值, M是哈希表的大小。
M最好是素数,因为这样可以确保密钥分布更均匀。散列函数取决于除法的余数。
例子:
k = 12345 M = 95 h(12345) = 12345 模 95 = 90
k = 1276 M = 11 h(1276) = 1276 模 11 = 0
优点:
- 该方法对于任何 M 值都非常有效。
- 除法非常快,因为它只需要一次除法运算。
缺点:
- 此方法会导致性能较差,因为连续的键映射到哈希表中的连续的哈希值。
- 有时应格外小心M值的选择。
中间平方法
中间平方法是一种非常好的哈希方法。它涉及计算哈希值的两个步骤 -
- 对键 k 的值求平方,即 k2
- 提取中间r位作为哈希值。
公式:
h(K) = h(k*k)
这里k 是关键值。
r的值可以根据表的大小来确定。
例子:
假设哈希表有 100 个内存位置。所以 r = 2,因为需要两个数字才能将密钥映射到内存位置。
k = 60 k * k = 60 x 60 = 3600 h(60) = 60
得到的哈希值为60
优点:
- 该方法的性能良好,因为键值的大部分或全部数字都对结果有贡献。这是因为密钥中的所有数字都有助于生成平方结果的中间数字。
- 结果不受原始键值的高位或低位数字的分布支配。
缺点:
- 密钥的大小是该方法的限制之一,因为如果密钥的很大,那么它的平方将增加一倍的位数。
- 还有一个缺点就是会有碰撞,但是我们可以尽量减少碰撞。
折叠方法
该方法包括两个步骤:
- 将键值k 分为多个部分,即k1, k2, k3,....,kn,其中每个部分具有相同的位数,除了最后一部分可以比其他部分具有更少的位数。
- 添加各个部分。如果有的话,忽略最后一次进位来获得哈希值。
公式:
k = k1, k2, k3, k4, ....., kn s = k1+ k2 + k3 + k4 +....+ kn h(K)= s
这里,s是通过将密钥k的部分相加得到的
例子:
k = 12345 k1 = 12, k2 = 34, k3 = 5 s = k1 + k2 + k3 = 12 + 34 + 5 = 51 h(K) = 51
注意: 每个部分的位数根据哈希表的大小而变化。例如,假设哈希表的大小为 100,则每个部分必须有两位数字,除了最后一部分可以有更少的数字。
乘法
该方法包括以下步骤:
- 选择一个常数值 A,使得 0 < A < 1。
- 将键值乘以 A。
- 提取 kA 的小数部分。
- 将上述步骤的结果乘以哈希表的大小,即 M。
- 得到的哈希值是通过对步骤 4 中得到的结果进行取整而得到的。
公式:
h(K) = 下限 (M (kA mod 1))
这里,M 是哈希表的大小。k 是关键值。A是一个常数值。
例子:
k = 12345 A = 0.357840 M = 100
h(12345) = 下限[ 100 (12345*0.357840 mod 1)] = 下限[ 100 (4417.5348 mod 1) ] = 下限[ 100 (0.5348) ] = 下限[ 53.48 ] = 53
优点:
乘法的优点是它可以处理 0 到 1 之间的任何值,尽管有些值往往比其他值提供更好的结果。
缺点:
乘法方法一般适用于表大小为2的幂的情况,那么使用乘法哈希通过键计算索引的整个过程非常快。
练练手
找到1到N-1之间唯一的重复元素
给定一个大小为N的数组,其中按随机顺序填充从 1 到 N-1 的数字。该数组只有一个重复元素。任务是找到重复的元素。
例子:
输入: a[] = {1, 3, 2, 3, 4}
输出: 3 解释:数字 3 是唯一的重复元素。
输入: a[] = {1, 5, 1, 2, 3, 4}
输出: 1
要解决问题,请遵循以下想法:
使用两个嵌套循环。外循环遍历所有元素,内循环检查外循环选取的元素是否出现在其他地方。
下面是上述方法的实现:
解法一
go
package main
import (
"sort"
"testing"
"github.com/stretchr/testify/assert"
)
// 解法一: 暴力解法
func findRepeating1[T comparable](arr []T) T {
var count = len(arr)
var result T
for i := 0; i < count; i++ {
for j := i + 1; j < count; j++ {
if arr[i] == arr[j] {
return arr[i]
}
}
}
return result
}
func Test_findRepeating1(t *testing.T) {
// var arr = []int{1, 3, 2, 3, 4}
// var except = 3
// assert.Equal(t, findRepeating1[int](arr), except)
var arr = []int{1, 5, 1, 2, 3, 4}
var except = 1
assert.Equal(t, findRepeating1[int](arr), except)
}
复杂度
- 时间复杂度: O(N 2 )
- 辅助空间: O(1)
解法二:
使用排序查找唯一重复的元素:
对给定的输入数组进行排序。遍历数组,如果第 i 个元素的值不等于 i+1,则当前元素是重复的,因为元素的值在 1 到 N-1 之间,并且除了一个元素外,每个元素只出现一次。
按照以下步骤解决问题:
对给定数组进行排序。
遍历数组并将数组元素与其索引进行比较
如果arr[i] = arr[i+1],则表示arr[i]是重复的,所以只需返回arr[i]。
否则,数组不包含从 1 到 n-1 的重复项,在这种情况下,返回 -1
下面是上述方法的实现:
go
func findRepeating2[T int](arr []int) int {
sort.Ints(arr)
var result int
for i := 0; i < len(arr); i++ {
if arr[i]+1 < len(arr) && arr[i] == arr[i+1] {
result = arr[i]
break
}
}
return result
}
func Test_findRepeating2(t *testing.T) {
// var arr = []int{1, 3, 2, 3, 4}
// var except = 3
// assert.Equal(t, findRepeating2(arr), except)
var arr = []int{1, 5, 1, 2, 3, 4}
var except = 1
assert.Equal(t, findRepeating2(arr), except)
}
复杂度分析
- 时间复杂度: O(N)
- 辅助空间: O(1)
解法三:
使用频率数组查找唯一重复的元素:
使用数组来存储数组中元素出现的频率。如果元素的频率大于1,则返回它。
go
func findRepeating3[T int](arr []int) int {
sort.Ints(arr)
var result int
var m = make(map[T]struct{})
for i := 0; i < len(arr); i++ {
if _, ok := m[T(arr[i])]; ok {
result = (arr[i])
break
}
m[T(arr[i])] = struct{}{}
}
return result
}
func Test_findRepeating3(t *testing.T) {
var arr = []int{1, 3, 2, 3, 4}
var except = 3
assert.Equal(t, findRepeating3(arr), except)
// var arr = []int{1, 5, 1, 2, 3, 4}
// var except = 1
// assert.Equal(t, findRepeating3(arr), except)
}
复杂度分析
- 时间复杂度:O(N)
- 辅助空间:O(N)