当equals()使用相似度度量时,重写hashCode()以保持一致性

7
假设我有一个车辆类(Car),其中包含颜色和型号两个字段。我需要将这些车辆存储在一个集合中,并确保没有重复的车辆(即不会出现两辆相同的车)。在下面的示例中,我使用了HashMap。
根据Java文档,如果我们有两个Car对象car1和car2,使得`car1.equals(car2) == true`,那么必须也满足`car1.hashCode() == car2.hashCode()`。因此,在这个例子中,如果我只想通过车辆的颜色进行比较,我会在equals()和hashCode()方法中仅仅使用颜色字段,就像代码中所做的一样,它可以正常工作。
public class Car {
String color;
String model;

@Override
public int hashCode() {
    final int prime = 31;
    int result = 1;
    result = prime * result + ((color == null) ? 0 : color.hashCode());
    return result;
}

@Override
public boolean equals(Object obj) {
    if (this == obj)
        return true;
    if (obj == null)
        return false;
    if (getClass() != obj.getClass())
        return false;
    Car other = (Car) obj;
    if (color == null) {
        if (other.color != null)
            return false;
    } else if (!color.equals(other.color))
        return false;
    return true;
}

public Car(String color, String model) {
    super();
    this.color = color;
    this.model = model;
}

@Override
public String toString() {
    return color + "\t" + model;
}

public static void main(String[] args) {
    Map<Car, Car> cars = new HashMap<Car, Car>();
    Car a = new Car("red", "audi");
    Car b = new Car("red", "bmw");
    Car c = new Car("blue", "audi");
    cars.put(a, a);
    cars.put(b, b);
    cars.put(c, c);
    for(Car car : cars.keySet()) {
        System.out.println(cars.get(car));
    }

}

}

输出结果如下:

  • 红色 宝马
  • 蓝色 奥迪

正如预期的那样。现在,我正在尝试其他比较两辆车的方法。我提供了一个函数来衡量两辆车之间的相似度。为了论证,假设我有一个方法double similarity(Car car1, Car car2),它返回一个[0,1]区间内的双精度值。如果它们的相似性函数返回大于0.5的值,则认为两辆车相等。然后,我重写了equals方法:

@Override
public boolean equals(Object obj) {
    Car other = (Car) obj;
    return similarity(this, other) > 0.5;
}

现在,我不知道如何重写hashCode()方法以确保始终遵守hashCode - equals约定,例如2个相等的对象始终具有相等的hashCode。

我一直在考虑使用TreeMap而不是HashMap,只是为了避免重写hashCode,因为我不知道如何正确地实现它。但是,我不需要任何排序,所以我认为在这个问题中使用TreeMap不合适,并且我认为它在复杂度方面会更昂贵。

如果您能建议我一种重写hashCode方法的方式或者一个更适合我的问题的不同结构的替代方案,那将非常有帮助。

提前感谢您!


1
什么定义了 similarity() > 0.5?一旦我们知道了这个,那么我们就可以构建一个新的 hashCode() - jlewkovich
@J 这实际上是一个简化版本,因为我工作的真实项目更加复杂。对于这个问题来说可能没有意义,但从技术角度来看,让我们假设相似性函数定义了颜色之间的字符串相似性。例如,如果两辆汽车的颜色是“蓝色”和“浅蓝色”,那么它将返回大于0.5的某个值,但如果颜色是“蓝色”和“红色”,则会返回0。 - giliev
由于您的“equals”方法将违反“equals”的一般契约,因此本问题实质上是重复的https://dev59.com/M3VD5IYBdhLWcg3wTJrF。 - Raedwald
6个回答

4
尽管sprinter已经解决了您的策略中的一些问题,但是您的方法存在更多基于合同的问题。根据Javadoc,
“ [equals]”是可传递的:对于任何非空引用值x、y和z,如果x.equals(y)返回true并且y.equals(z)返回true,则x.equals(z)应该返回true。”
然而,x可以类似于y,y可以类似于z,但x与z相距太远,无法获得类似性,因此您的equals方法不起作用。

尽管equals的合约方面很重要,但在这种情况下,最好让OP避免像帖子中描述的那样黑掉equals和hashcode方法。这不是一个好的设计。 - user3248346

4

您不应该这样篡改equalshashcode方法。 Collection数据结构依赖于这些方法,以非标准方式使用它们会导致意外的行为。

我建议您创建一个Comparator实现,用于比较两个汽车,或者实现Comparable接口,在其中可以使用您的similarity方法。


谢谢您的建议!其他答案中提到的equals方法的传递性仍然是一个问题,但我认为我的解决方案不会受到太大影响,所以我想我会尝试一下这个基于Comparator的解决方案。毕竟它不会影响我的代码的任何其他部分。 - giliev
如果您不确定如何实现equals和hashcode方法,大多数IDE都可以为您的类自动生成这些方法。Eclipse和Intellij都可以为您自动生成它们。 - user3248346
当你处理可扩展的类时,传递性是一个问题。也就是说,当你处理继承层次结构时。如果不是这种情况,那么通常的equals()和hashcode()的实现对你来说就足够了。阅读此文章获取更多信息:http://www.artima.com/lejava/articles/equality.html - user3248346

3
这里有几点需要注意。
首先,这是对equals的不同寻常用法。通常equals被解释为这是两个相同对象的实例;一个可以替换另一个而没有影响。
第二点是a.equals(b)意味着a.hashCode() == b.hashCode()但反之则不一定成立。事实上,所有对象返回相同的哈希码是完全合法的(虽然毫无意义)。因此,在您的情况下,只要所有足够相似的汽车返回相同的哈希码,各种集合就会正常运作。
我认为更可能的是,您应该有一个单独的类来表示您的“相似”概念。然后,您可以测试相似性的相等或将其映射到汽车列表。这可能比为汽车重载equals更好地表示概念。

3

hashCode() 只是一个equals()的“捷径”。确保您正在努力实现的方案对于equals很重要。考虑汽车abc,其中similarity(a, b) == 0.3并且similarity(b, c) == 0.3

但是如果similarity(a, c) == 0.6呢?那么您将处于这样一种情况: a.equals(b)b.equals(c),但神秘地a.equals(c)为false。

这违反了Object.equals()的一般契约。当发生这种情况时,标准库的某些部分,如HashMapTreeMap,将突然开始表现出非常奇怪的行为。

如果你有兴趣插入不同的排序方案,最好使用实现你的方案的不同Comparator<Car>。虽然在Comparator API1中也存在相同的限制,但它让你表示小于和大于,听起来这正是你真正需要的,而且不能通过Object.equals()完成。

[1] 如果compare(a,b) == compare(b,c) == 0,那么compare(a,c)也必须为0


1
有趣。如果我有'a类似于b'、'b类似于c'和'a不类似于c',那么根据插入的顺序,最终可能会得到a和c在集合中(如果按照a、b、c的顺序插入),或者只有b如果我首先插入b,然后是a和c。我明白你的意思。然而,除非避免hashCode()覆盖,否则Comparator对此问题帮助不大。毕竟,我应该对我的问题进行一些测试,看看这个问题是否会影响我的解决方案。谢谢! - giliev

2
如其他人所述,你后面实现的.equals()违反了它的约定。你不能以这种方式实现它。如果你停下来想一想,这是有道理的,因为你实现的.equals()并不是用于当两个对象实际相等时返回true,而是当它们足够相似时返回true。但是足够相似并不等同于相等,无论在Java还是其他任何地方。

查看.equals() javadocs,你会发现任何实现它的对象都必须遵守其约定:

equals方法对非空对象引用实现了等价关系:
- 它是自反的:对于任何非空引用值x,x.equals(x)应该返回true。 - 它是对称的:对于任何非空引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)应该返回true。 - 它是传递的:对于任何非空引用值x、y和z,如果x.equals(y)返回true并且y.equals(z)返回true,则x.equals(z)应该返回true。 - 它是一致的:对于任何非空引用值x和y,只要没有修改在对象上进行equals比较的信息,多次调用x.equals(y)将一致地返回true或一致地返回false。 - 对于任何非空引用值x,x.equals(null)应该返回false。
你的equals()方法没有遵守这个契约。
根据你实现的double similarity(Car car1, Car car2),它可能不对称。显然它不是传递的(在先前的答案中有很好的解释)。它可能不一致:考虑一个略微不同于您在评论中提供的示例:'cobalt' would be equal to 'blue' while 'red' would be different to 'blue'。如果您使用了某些外部来源来计算相似性,例如字典,并且某天找不到'cobalt'作为条目,则可能会返回接近0.0的相似度,因此汽车将不相等。但是,第二天您意识到'cobalt'是一种特殊的'blue',因此将其添加到字典中,这次当您比较相同的两辆车时,相似度非常高(或接近1.0),因此它们是相等的。这将是一种不一致性。我不知道您的相似性函数如何工作,但如果它依赖于与您正在比较的两个对象中包含的数据不同的任何内容,则可能违反.equals()一致性约束。
关于使用 TreeMap<Car, Whatever>,我不认为它会有任何帮助。从 TreeMap javadocs 中可以看到:

... Map 接口是通过 equals 操作定义的,但是一个排序的 map 使用其 compareTo(或 compare)方法执行所有键比较,因此通过该方法判断相等的两个键在排序 map 的角度来看是相等的。

换句话说,在一个 TreeMap<Car, Whatever> map 中,map.containsKey(car1) 仅在 car1.compareTo(car2) 对于某个属于 mapcar2 返回恰好为 0 时才返回 true。然而,如果比较结果不是 0,尽管按照你的相似性函数,car1car2 非常相似,map.containsKey(car1) 也可能返回 false。这是因为 .compareTo() 只适用于排序,而不适用于相似性。

因此,关键点在于你不能仅使用一个 Map 来适应你的用例,因为它是错误的结构。实际上,你不能仅使用任何依赖于 .hashCode().equals() 的 Java 结构,因为你永远无法找到与你的键匹配的对象。


现在,如果您想通过您的similarity()函数找到与给定汽车最相似的汽车,我建议您使用Guava的HashBasedTable结构来构建一个表格,其中包含您集合中每辆汽车之间的相似系数(或者您喜欢的任何其他花哨的名称)。 这种方法需要Car按照通常的方式实现.hashCode().equals()(即不仅仅通过颜色进行检查,当然也不调用您的similarity()函数)。例如,您可以通过新的车牌号码Car属性进行检查。
思路是拥有一个表格,其中存储了每辆汽车之间的相似性,其对角线干净,因为我们已经知道一辆汽车与自身相似(实际上,它与自身相等)。例如,对于以下汽车:
Car a = new Car("red", "audi", "plate1");
Car b = new Car("red", "bmw", "plate2");
Car c = new Car("light red", "audi", "plate3");

这张表格会是这个样子:
      a       b       c

a   ----    0.60    0.95

b   0.60    ----    0.45

c   0.95    0.45    ----

对于相似度值,我假设同品牌和同颜色系列的汽车比相同颜色但不同品牌的汽车更相似,而不同品牌且不同颜色的汽车则更不相似。
你可能已经注意到这个表是 对称的 。如果需要空间优化,我们只需存储一半的单元格。然而,根据文档,HashBasedTable 被优化为通过行键访问,所以让我们保持简单,并将进一步的优化作为练习。
找到与给定汽车最相似的汽车的算法可以概述如下:
  1. 检索给定汽车的行
  2. 返回在返回的行中与给定汽车最相似的汽车,即行中相似系数最高的汽车
这里有一些展示一般思路的代码:
public class SimilarityTest {

    Table<Car, Car, Double> table;

    void initialize(Car... cars) {
        int size = cars.length - 1; // implicit null check
        this.table = HashBasedTable.create(size, size);
        for (Car rowCar : cars) {
            for (Car columnCar : cars) {
                if (!rowCar.equals(columnCar)) { // add only different cars
                    double similarity = this.similarity(rowCar, columnCar);
                    this.table.put(rowCar, columnCar, similarity);
                }
            }
        }
    }

    double similarity(Car car1, Car car2) {
        // Place your similarity calculation here
    }

    Car mostSimilar(Car car) {
        Map<Car, Double> row = this.table.row(car);
        Map.Entry mostSimilar = Maps.immutableEntry(car, Double.MIN_VALUE);
        for (Map.Entry<Car, Double> entry : row.entrySet()) {
            double mostSimilarCoefficient = mostSimilar.getValue();
            double currentCoefficient = entry.getValue();
            if (currentCoefficient > mostSimilarCoefficient) {
                mostSimilar = entry;
            }
        }
        return mostSimilar.getKey();
    }

    public static void main(String... args) {
        SimilarityTest test = new SimilarityTest();

        Car a = new Car("red", "audi", "plate1");
        Car b = new Car("red", "bmw", "plate2");
        Car c = new Car("light red", "audi", "plate3");

        test.initialize(a, b, c);

        Car mostSimilarToA = test.mostSimilar(a);
        System.out.println(mostSimilarToA); // should be c

        Car mostSimilarToB = test.mostSimilar(b);
        System.out.println(mostSimilarToB); // should be a

        Car mostSimilarToC = test.mostSimilar(c);
        System.out.println(mostSimilarToC); // should be a
    }
}

关于复杂度...初始化表格需要O(n2),而寻找最相似的汽车只需要O(n)。我相信这可以改善,例如为什么要将已知不相似的汽车放入表格中呢?(我们只能将相似系数高于给定阈值的汽车放入表格中),或者我们可以停止搜索,当找到一个相似系数高于另一个给定阈值的汽车时等等。

0
根据我对你的相似性()方法的理解,我认为最好保持你的hashCode()函数大致相同,但是不要使用color.hashCode(),而是创建一个帮助方法来生成“相似的颜色”,并使用该hashCode:
public int getSimilarColor(String color) {
    if(color == "blue" || color == "light blue" || color == "dark blue" /* add more blue colors*/) {
        return "blue";
    } else if(color == "red" || color == "light red" || color == "dark red" /* add more red colors*/) {
        return "red";
    }
    /*
    else if(yellow...)
    else if(etc...)
    */
    else {
        return color;
    }
}

然后在您的hashCode方法中使用它:

@Override
public int hashCode() {
    final int prime = 31;
    int result = 1;
    result = prime * result + ((color == null) ? 0 : getSimilarColor(color).hashCode());
    return result;
}

这个辅助方法在 similarity() 中也可能很有用。如果你不想在方法中硬编码相似的颜色,可以使用其他方式来生成它们,比如模式匹配。


谢谢您的建议!然而,我的列表不会是有限的,所以我应该尝试找到一些更通用的方法来测试相等性。 - giliev

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