記事は最初に公開アカウント(Moon with Feiyu)で公開され、次に個人のWebサイトxiaoflyfish.cn/に同期されました。
WeChat検索:月面のトビウオ、友達を作り、インタビュー交換グループに参加
良いと思います。気に入って、見て、転送してサポートしてください、ありがとうございます!
序文
非常に長いドキュメントがあるとします。ドキュメント内の各単語がドキュメントに表示される回数を数えたい場合は、どうすればよいですか。
簡単だ!
文字列型をKey、Int型をValueとしてHashMapを構築できます。
- ドキュメント内の各単語をトラバースし、キーがキーと値のペアにあるアイテムを見つけて
word
、関連する値に対して自動インクリメント操作を実行します。word
- key =の
word
アイテムがHashMapに存在しない場合(word,1)
、新しい追加を示すアイテムを挿入します。 - このように、キーと値のペアの各セットは、特定の単語に対応する番号を表します。ドキュメント全体をトラバースすると、各単語の番号を取得できます。
単純な実装では、コード例は次のとおりです。
import java.util.HashMap;
import java.util.Map;
public class Test {
public static void main(String[] args) {
Map map = new HashMap<>();
String doc = "yue ban fei yu";
String[] words = doc.split(" ");
for (String s : words) {
if (!map.containsKey(s)) {
map.put(s, 1);
} else {
map.put(s, map.get(s) + 1);
}
}
System.out.println(map);
}
}
复制代码
HashMapはどのようにして対応する単語数を効率的にカウントしますか?以下、段階的に学習していきます!
まず、特定の単語の数だけを数えるかどうかを見てみましょう。
変数を開き、すべての単語もトラバースし、ターゲット単語と同じ単語に遭遇した場合にのみ、この変数に対して自動インクリメント操作を実行する必要があります。
- トラバーサルが完了すると、単語の数を取得できます。
- 考えられるすべての単語を一覧表示し、各単語の変数を使用してその出現回数をカウントし、すべての単語をトラバースして、現在の単語をどの変数に累積するかを決定できます。
import java.util.HashMap;
import java.util.Map;
public class Main {
public static void main(String[] args) {
int[] cnt = new int[20000];
String doc = "a b c d";
String[] words = doc.split(" ");
int a = 0;
int b = 0;
int c = 0;
int d = 0;
for (String s : words) {
if (s == "a") a++;
if (s == "b") b++;
if (s == "c") c++;
if (s == "d") d++;
}
}
}
复制代码
注:このようなコードには、明らかに2つの大きな問題があります。
- 単語とカウンターの間のマッピング関係は、if-elseの束を介して死ぬまで書き込まれ、保守は非常に貧弱です。
- 考えられるすべての単語を知っている必要があり、新しい単語が見つかった場合、それに対処する方法はありません。
最適化1
配列を開いてカウンターを維持することができます。
具体的な方法は、各単語に番号を割り当て、その番号の添え字に対応する配列要素をカウンターとして直接使用することです。
2つのアレイを構築できます。
- 最初の配列はすべての単語を格納するために使用されます。配列の添え字は単語番号です。これを辞書配列と呼びます。
- 第二个数组用于存放每个单词对应的计数器,我们称之为计数数组。
每遇到一个新的单词,都遍历一遍字典数组,如果没有出现过,我们就将当前单词插入到字典数组结尾。
这样做,整体的时间复杂度较高,还是不行。
优化2
优化方式:
- 一种是我们维护一个有序的数据结构,让比较和插入的过程更加高效,而不是需要遍历每一个元素判断逐一判断。
- 另一种思路就是我们是否能寻找到一种直接基于字符串快速计算出编号的方式,并将这个编号映射到一个可以在O(1)时间内基于下标访问的数组中。
以单词为例,英文单词的每个字母只可能是 a-z。
我们用0表示a、1表示b,以此类推,用25表示z,然后将一个单词看成一个26进制的数字即可。
import java.util.HashMap;
import java.util.Map;
public class Main {
public static void main(String[] args) {
int[] cnt = new int[20000];
String doc = "a b c d";
String[] words = doc.split(" ");
for (String s : words) {
int tmp = 0;
for (char c: s.toCharArray()) {
tmp *= 26;
tmp += (c - 'a');
}
cnt[tmp]++;
}
String target = "a";
int hash = 0;
for (char c: target.toCharArray()) {
hash *= 26;
hash += c - 'a';
}
System.out.println(cnt[hash]);
}
}
复制代码
这样我们统计N个单词出现数量的时候,整体只需要O(N)的复杂度,相比于原来的需要遍历字典的做法就明显高效的多。
这其实就是散列的思想了。
优化3
使用散列!
散列函数的本质,就是将一个更大且可能不连续空间(比如所有的单词),映射到一个空间有限的数组里,从而借用数组基于下标O(1)快速随机访问数组元素的能力。
但设计一个合理的散列函数是一个非常难的事情。
- 比如对26进制的哈希值再进行一次对大质数取mod的运算,只有这样才能用比较有限的计数数组空间去表示整个哈希表。
取了mod之后,我们很快就会发现,现在可能出现一种情况,把两个不同的单词用26进制表示并取模之后,得到的值很可能是一样的。
这个问题被称之为哈希碰撞。
如何实现
最后我们考虑一下散列函数到底需要怎么设计。
以JDK(JDK14)的HashMap为例:
- 主要实现在
java.util
下的HashMap
中,这是一个最简单的不考虑并发的、基于散列的Map实现。
找到其中用于计算哈希值的hash方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
复制代码
可以发现就是对key.hashCode()
进行了一次特别的位运算。
hashcode方法
在Java中每个对象生成时都会产生一个对应的hashcode。
- 当然数据类型不同,hashcode的计算方式是不一样的,但一定会保证的是两个一样的对象,对应的hashcode也是一样的;
所以在比较两个对象是否相等时,我们可以先比较hashcode是否一致,如果不一致,就不需要继续调用equals,大大降低了比较对象相等的代价。
我们就一起来看看JDK中对String类型的hashcode是怎么计算的,我们进入 java.lang
包查看String类型的实现:
public int hashCode() {
// The hash or hashIsZero fields are subject to a benign data race,
// making it crucial to ensure that any observable result of the
// calculation in this method stays correct under any possible read of
// these fields. Necessary restrictions to allow this to be correct
// without explicit memory fences or similar concurrency primitives is
// that we can ever only write to one of these two fields for a given
// String instance, and that the computation is idempotent and derived
// from immutable state
int h = hash;
if (h == 0 && !hashIsZero) {
h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
if (h == 0) {
hashIsZero = true;
} else {
hash = h;
}
}
return h;
}
复制代码
Latin和UTF16是两种字符串的编码格式,实现思路其实差不多,我们来看看StringUTF16
中hashcode的实现:
public static int hashCode(byte[] value) {
int h = 0;
int length = value.length >> 1;
for (int i = 0; i < length; i++) {
h = 31 * h + getChar(value, i);
}
return h;
}
复制代码
其实就是对字符串逐位按照下面的方式进行计算,和展开成26进制的想法本质上是相似的。
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
复制代码
为什么选择了31?
首先在各种哈希计算中,我们比较倾向使用奇素数进行乘法运算,而不是用偶数。
因为用偶数,尤其是2的幂次,进行乘法,相当于直接对原来的数据进行移位运算;这样溢出的时候,部分位的信息就完全丢失了,可能增加哈希冲突的概率。
为什么选择了31这个奇怪的数,这是因为计算机在进行移位运算要比普通乘法运算快得多,而31*i
可以直接转化为(i << 5)- i
,这是一个性能比较好的乘法计算方式,现代的编译器都可以推理并自动完成相关的优化。
具体可以参考《Effective Java》中的相关章节。
h>>>16
我们现在来看 ^ h >>> 16
又是一个什么样的作用呢?
它的意思是就是将h右移16位并进行异或操作,为什么要这么做呢?
因为那个hash值计算出来这么大,那怎么把它连续地映射到一个小一点的连续数组空间呢?
所以需要取模,我们需要将hash值对数组的大小进行一次取模。
我们需要对2的幂次大小的数组进行一次取模计算。
ただし、2の累乗を法とすることは、数値の下位ビットを直接インターセプトすることと同じです。配列に要素が少ない場合は、数値の下位ビットの情報のみを使用して情報を破棄することと同じです。競合を増やす可能性のある上位ビットの確率。
したがって、JDKコードでは、^ h >>> 16
このようなビット演算が導入されています。これは、実際には上位16ビット情報を下位16ビットに重ね合わせて、モジュロを取るときに上位情報を使用できるようにするものです。
ハッシュ衝突に対処する方法は?
JDKではオープンチェーン方式が使用されています。
ハッシュテーブルの組み込み配列の各スロットにはリンクリストが格納され、リンクリストノードの値には、格納する必要のあるキーと値のペアが格納されます。
ハッシュの衝突が発生した場合、つまり2つの異なるキーが配列内の同じスロットにマップされた場合、スロットに対応するリンクリストの最後に要素を直接配置します。
結論は
手書きのデータ構造の単語数を数える正しい方法は次のとおりです。
全文の長さに応じて、単語の数を概算し、そのサイズの数倍の配列を開いてから、各単語を配列の添え字にマップする適切なハッシュ関数を設計し、この配列を使用します統計を数えるために。
もちろん、実際のエンジニアリングでは、シナリオごとにこのようなハッシュテーブルの実装を個別に作成することはなく、複雑な拡張シナリオを自分で処理する必要もありません。