Java:哈希映射中的复合键

37

我想把一组对象存储在哈希映射表中,其中键(key)应该是由两个字符串值组成的复合键(composite key)。有没有方法可以实现这一点?

我可以简单地将这两个字符串连接起来,但我相信有更好的方法来实现这一点。


脱离上下文,这只是另一个例子,C#似乎比Java更实用的编程语言。 - shashwat
9个回答

57

你可以创建一个自定义对象,其中包含这两个字符串:

class StringKey {
    private String str1;
    private String str2;
}

问题是,您需要确定两个这样的对象的相等测试和哈希码。

相等性可以匹配两个字符串,并且哈希码可以是连接成员的哈希码(这是有争议的):

class StringKey {
    private String str1;
    private String str2;

    @Override
    public boolean equals(Object obj) {
        if(obj != null && obj instanceof StringKey) {
            StringKey s = (StringKey)obj;
            return str1.equals(s.str1) && str2.equals(s.str2);
        }
        return false;
    }

    @Override
    public int hashCode() {
        return (str1 + str2).hashCode();
    }
}

2
由于ABC、D和AB、CD的哈希码相同,是否会出现任何问题?还是equals方法的不同解决了这个问题? - Scott McIntyre
1
@smackfu:这取决于情况。只有当你有许多这样的字符串对时,才会成为问题,因为它们将哈希到表中的同一槽位,使查找效率降低。 - Tudor
1
@Zak:唯一的区别就是我使用了两个字符串连接,而他在它们之间使用了波浪号。他的版本应该更好地分散成对字符串,否则它们会在连接时产生相同的结果。无论如何,我只是举了一个生成哈希码的例子,我并没有旨在使其高效。 - Tudor
@Tudor,使用instanceOf运算符时不需要obj!= null,干杯! - bgs
1
我也更喜欢将变量定义为“final”。这样可以确保字符串在不可变的情况下使用。 - unknownerror
显示剩余2条评论

18

您无需重复发明轮子。只需使用GuavaHashBasedTable<R,C,V>实现Table<R,C,V>接口,满足您的需求。以下是一个示例:

Table<String, String, Integer> table = HashBasedTable.create();

table.put("key-1", "lock-1", 50);
table.put("lock-1", "key-1", 100);

System.out.println(table.get("key-1", "lock-1")); //prints 50
System.out.println(table.get("lock-1", "key-1")); //prints 100

table.put("key-1", "lock-1", 150); //replaces 50 with 150

11
public int hashCode() {
    return (str1 + str2).hashCode();
}
这似乎是一种可怕的生成hashCode的方式:每次计算hashCode时创建一个新的字符串实例是糟糕的!(即使只生成字符串实例一次并缓存结果也是不好的做法。)
这里有很多建议: 如何为字符串列表计算良好的哈希码?
public int hashCode() {
    final int prime = 31;
    int result = 1;
    for ( String s : strings ) {
        result = result * prime + s.hashCode();
    }
    return result;
}

对于一对字符串,变成:

return string1.hashCode() * 31 + string2.hashCode();

这是一个非常基础的实现。链接中有很多建议,可以提供更好的优化策略。


1
"哈哈哈,发现得好!每次计算哈希码时都会创建一个新的字符串实例。" - mike rodent
如果哈希码是通过XOR计算而不是*+计算,是否会有任何区别,因为String hashCode在哈希码中使用了大多数位? - SOFe
XOR和其他位运算符比乘法和加法更快。在示例中,差异是否显着取决于String.hashCode()的实现方式。此外,应特别注意确保新实现具有良好的哈希函数属性。 - Thomas Bitonti

8
为什么不创建一个(比如)Pair对象,其中包含两个字符串作为成员,然后将其用作键?例如:
public class Pair {
   private final String str1;
   private final String str2;

   // this object should be immutable to reliably perform subsequent lookups
}

不要忘记 equals()hashCode()。更多关于 HashMaps 和 keys 的信息,包括不可变性要求的背景,请参阅这篇博客文章this blog entry。如果您的 key 不是不可变的,则可以更改其组件,并且随后的查找将无法找到它(这就是为什么不可变对象如 String 是键的好选择)。您是正确的,串联字符串并不理想。对于某些情况,它可能有效,但通常是不可靠和脆弱的解决方案(例如,AB/CA/BC 是不同的键吗?)。

如果我们有太多的条目(约77,500个),那么我们可能会遇到哈希碰撞的情况吗? - lolo
碰撞会与哈希函数的范围、被哈希的条目数量、条目值的分布、哈希函数的行为良好程度以及哈希映射的可用空间成比例发生。如果不知道这些细节,就无法确定77,500个条目是否会有许多或很少(或没有)碰撞。请注意,即使使用非常好的哈希函数,碰撞也可能会发生。对于典型的哈希映射实现,重要的不是是否发生任何碰撞,而是相对于总条目数有多少碰撞。 - Thomas Bitonti

5
我有一个类似的案例。我的做法是使用波浪线(~)将两个字符串连接起来。
因此,当客户端调用服务函数从映射中获取对象时,代码如下:
MyObject getMyObject(String key1, String key2) {
    String cacheKey = key1 + "~" + key2;
    return map.get(cachekey);
}

这很简单,但它有效。


1
是的。OP明确规定了“两个字符串”。另一种复合键可能需要更复杂的东西。但在我的看法中,这是用例的正确答案。然而,“连接”字符需要更多的工作:例如,OP没有说这些字符串“仅限字母数字”,因此它们可能潜在地包含波浪符号。否则,来自Unicode较野生平面的非常奇特的字符可能会起到作用: 或者♄也许可以。 - mike rodent

5

我发现许多人使用嵌套映射。也就是说,将Key1 -> Key2 -> Value(我使用计算机科学/即 Haskell 的柯里化符号来表示(Key1 x Key2) -> Value 映射,它有两个参数并产生一个值),您首先提供第一个键——这会返回一个(部分)映射 Key2 -> Value ,然后在下一步中展开。

例如,

Map<File, Map<Integer, String>> table = new HashMap(); // maps (File, Int) -> Distance

add(k1, k2, value) {
  table2 = table1.get(k1);
  if (table2 == null) table2 = table1.add(k1, new HashMap())
  table2.add(k2, value)
}

get(k1, k2) {
  table2 = table1.get(k1);
  return table2.get(k2)
}

我不确定它是否比普通的复合键构造更好,您可以对此发表评论。

2

阅读关于意大利面条/仙人掌堆栈的文章,我想到了一种变体,可以用于此目的,包括将键映射为任意顺序的可能性,以便 map.lookup("a","b") 和 map.lookup("b","a") 返回相同的元素。它还适用于任意数量的键,而不仅仅是两个。

我将其用作数据流编程实验的堆栈,但这是一个快速而脏乱的版本,可以用作多键映射(应该改进:应该使用集合而不是数组来避免查找重复出现的键)。

public class MultiKeyMap <K,E> {
    class Mapping {
        E element;
        int numKeys;
        public Mapping(E element,int numKeys){
            this.element = element;
            this.numKeys = numKeys;
        }
    }
    class KeySlot{
        Mapping parent;
        public KeySlot(Mapping mapping) {
            parent = mapping;
        }
    }
    class KeySlotList extends LinkedList<KeySlot>{}
    class MultiMap extends HashMap<K,KeySlotList>{}
    class MappingTrackMap extends HashMap<Mapping,Integer>{}

    MultiMap map = new MultiMap();

    public void put(E element, K ...keys){
        Mapping mapping = new Mapping(element,keys.length);
        for(int i=0;i<keys.length;i++){
            KeySlot k = new KeySlot(mapping);
            KeySlotList l = map.get(keys[i]);
            if(l==null){
                l = new KeySlotList();
                map.put(keys[i], l);
            }
            l.add(k);
        }
    }
    public E lookup(K ...keys){
        MappingTrackMap tmp  = new MappingTrackMap();
        for(K key:keys){
            KeySlotList l = map.get(key);
            if(l==null)return null;
            for(KeySlot keySlot:l){
                Mapping parent = keySlot.parent;
                Integer count = tmp.get(parent);
                if(parent.numKeys!=keys.length)continue;
                if(count == null){
                    count = parent.numKeys-1;
                }else{
                    count--;
                }
                if(count == 0){
                    return parent.element;
                }else{
                    tmp.put(parent, count);
                }               
            }
        }
        return null;
    }
    public static void main(String[] args) {
        MultiKeyMap<String,String> m = new MultiKeyMap<String,String>();
        m.put("brazil", "yellow", "green");
        m.put("canada", "red", "white");
        m.put("USA", "red" ,"white" ,"blue");
        m.put("argentina", "white","blue");

        System.out.println(m.lookup("red","white"));  // canada
        System.out.println(m.lookup("white","red"));  // canada
        System.out.println(m.lookup("white","red","blue")); // USA
    }
}

1
public static String fakeMapKey(final String... arrayKey) {
    String[] keys = arrayKey;

    if (keys == null || keys.length == 0)
        return null;

    if (keys.length == 1)
        return keys[0];

    String key = "";
    for (int i = 0; i < keys.length; i++)
        key += "{" + i + "}" + (i == keys.length - 1 ? "" : "{" + keys.length + "}");

    keys = Arrays.copyOf(keys, keys.length + 1);

    keys[keys.length - 1] = FAKE_KEY_SEPARATOR;

    return  MessageFormat.format(key, (Object[]) keys);}
public static string FAKE_KEY_SEPARATOR = "~";

输入: fakeMapKey("keyPart1","keyPart2","keyPart3");
输出:keyPart1~keyPart2~keyPart3

0

我想提到两个选项,我认为其他答案中没有涉及。它们是否适合您的目的,您必须自己决定。

Map<String, Map<String, YourObject>>

您可以使用映射的映射,将字符串1用作外部映射中的键,将字符串2用作每个内部映射中的键。

我不认为这是一个非常好的语法解决方案,但它很简单,我在一些地方看到过它的使用。它也应该在时间和内存上高效,虽然在99%的情况下这不应该是主要原因。我不喜欢它的原因是我们失去了关于键类型的明确信息:它仅从代码中推断出有效键是两个字符串,阅读时不够清晰。

Map<YourObject, YourObject>

这是一个特殊情况。我已经遇到过这种情况不止一次,所以它并不比那更特殊。如果您的对象包含用作键的两个字符串,并且根据这两个字符串定义对象相等性是有意义的,则按照规定定义equalshashCode,并将对象用作键和值。

在这种情况下,人们希望使用Set而不是Map,但Java的HashSet没有提供基于相等对象从集合中检索对象的任何方法。因此,我们确实需要使用Map。

一个缺点是您需要创建一个新对象才能进行查找。这也适用于其他答案中的许多解决方案。

链接

Jerónimo López: HashMaps中的复合键关于映射效率的讨论。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接