[英]Comparator suitable for TreeSet when there is no distinguishing field
假設我有一個沒有實現Comparable
接口的類
class Dummy {
}
以及該類的一個實例的集合以及該類外部的一些函數,它們允許部分地比較這些實例(下面將使用一個映射):
Collection<Dummy> col = new ArrayList<>();
Map<Dummy, Integer> map = new HashMap<>();
for (int i = 0; i < 12; i++) {
Dummy d = new Dummy();
col.add(d);
map.put(d, i % 4);
}
現在我想使用帶有自定義比較器的TreeSet
類對此集合進行排序:
TreeSet<Dummy> sorted = new TreeSet<>(new Comparator<Dummy>() {
@Override
public int compare(Dummy o1, Dummy o2) {
return map.get(o1) - map.get(o2);
}
});
sorted.addAll(col);
結果顯然不令人滿意(包含的元素少於初始集合)。 這是因為這樣的比較器與equals
不一致 ,即有時對於不相等的元素返回0
。 我的下一次嘗試是將compare
器的compare
方法更改為
@Override
public int compare(Dummy o1, Dummy o2) {
int d = map.get(o1) - map.get(o2);
if (d != 0)
return d;
if (o1.equals(o2))
return 0;
return 1; // is this acceptable?
}
它似乎為這個簡單的演示示例提供了期望的結果,但我仍然有疑問:總是為不等(但無法通過地圖)對象返回1
是正確的嗎? 這樣的關系仍然違反Comparator.compare()
方法的一般聯系,因為sgn(compare(x, y)) == -sgn(compare(y, x))
通常是錯誤的。 我是否真的需要為TreeSet
實現正確的總排序才能正常工作或上述是否足夠? 如果實例沒有可比較的字段,該怎么做?
對於更真實的例子,想象一下,你有一個泛型類的類型參數T
,而不是Dummy
。 T
可能有一些字段並通過它們實現equals()
方法,但您不知道這些字段,但需要根據某些外部函數對此類的實例進行排序。 這是否可以在TreeSet
的幫助下實現?
使用System.identityHashCode()
是一個好主意,但有(不是很小) 碰撞的機會。
除了這種碰撞的可能性之外,還有一個陷阱 。 假設你有3個對象: a
, b
, c
這樣map.get(a) = map.get(b) = map.get(c)
(這里=
不是賦值但數學相等), identityHashCode(a) < identityHashCode(b) < identityHashCode(c)
, a.equals(c)
為真,但a.equals(b)
(因此c.equals(b)
)為假。 按照以下順序將這3個元素添加到TreeSet
之后: a, b, c
您可以進入將所有這些元素添加到集合中的情況,這與Set
接口的規定行為相矛盾 - 它不應包含相等的元素。 怎么處理?
另外,如果熟悉TreeSet
機制的人向我解釋,在TreeSet javadoc中,短語“集合的行為即使其排序與equals不一致 也很明確”中的術語“ 定義良好”會是多么好意思。
除非你有絕對大量的Dummy對象並且真的運氣不好,否則你可以使用System.identityHashCode()
來打破關系:
Comparator.<Dummy>comparingInt(d -> map.get(d))
.thenComparingInt(System::identityHashCode)
您的比較器是不可接受的,因為它違反了合同:如果它們不相等並且在地圖中沒有共享相同的值,則同時具有d1> d2和d2> d1。
這個答案僅涵蓋問題中的第一個例子。 問題的其余部分和各種編輯,我認為更好地回答作為單獨的,有針對性的問題的一部分。
第一個示例設置12個Dummy
實例,創建一個映射,將每個實例映射到[0,3]范圍內的Integer
,然后將12個Dummy
實例添加到TreeSet
。 TreeSet
提供了一個使用Dummy-to-Integer映射的比較器。 結果是TreeSet
只包含四個Dummy
實例。 該示例以以下語句結束:
結果顯然不令人滿意(包含的元素少於初始集合)。 這是因為這樣的比較器與equals不一致,即有時對於不相等的元素返回0。
這最后一句不正確。 結果包含的元素少於插入的元素,因為比較器認為許多實例是重復的,因此它們不會插入到集合中。 equals
方法根本不進入討論。 因此,“與平等一致”的概念與本討論無關。 TreeSet
從不調用equals
。 比較器是唯一決定TreeSet
成員資格的東西。
這似乎是一個令人不滿意的結果,但只是因為我們“知道”有12個不同的Dummy
實例。 但是, TreeSet
並不“知道”它們是不同的。 它只知道如何使用比較器比較Dummy
實例。 當它這樣做時,它發現有幾個是重復的。 也就是說,比較器有時返回0,即使它被我們認為是不同的Dummy
實例調用。 這就是為什么只有四個Dummy
實例最終出現在TreeSet
。
我不完全確定所期望的結果是什么,但似乎結果TreeSet
應該包含由Dummy-to-Integer映射中的值排序的所有12個實例。 我的建議是使用Guava的Ordering.arbitrary()
,它提供了一個比較器來區分不同但不相同的元素,但這樣做的方式滿足了比較器的一般契約。 如果您像這樣創建TreeSet
:
SortedSet<Dummy> sorted = new TreeSet<>(Comparator.<Dummy>comparingInt(map::get)
.thenComparing(Ordering.arbitrary()));
結果將是TreeSet
包含所有12個Dummy
實例,按地圖中的Integer
值排序,以及Dummy
實例映射到任意排序的相同值。
在評論中,您聲明Ordering.arbitrary
文檔“毫不含糊地警告不要在SortedSet
中使用它”。 那不太對勁; 那個醫生說,
因為排序是基於身份的,所以它與Comparator定義的“與Object.equals(Object)”不一致。 從中構建SortedSet或SortedMap時要小心,因為生成的集合將不會完全按照規范運行。
短語“不完全按照規范行事”實際上意味着它會像Comparator
的類doc中所描述的那樣“奇怪地”行為:
當且僅當
c.compare(e1, e2)==0
具有與每個e1的e1.equals(e2)
相同的布爾值時,比較器c對一組元素S施加的排序被稱為與等於一致。和S中的e2當使用能夠強加與equals不一致的排序的比較器來排序有序集(或有序映射)時,應該謹慎行事。 假設帶有顯式比較器c的有序集(或有序映射)與從集合S中繪制的元素(或鍵)一起使用。如果由S對S施加的排序與equals不一致,則排序集(或有序映射)將表現得“奇怪”。 特別是有序集(或有序映射)將違反集合(或映射)的一般契約,其以
equals
方式定義。例如,假設有一個元素a和b添加兩個元素a
(a.equals(b) && c.compare(a, b) != 0)
到帶有比較器c的空TreeSet。 第二個add操作將返回true(並且樹集的大小將增加)因為a和b在樹集的透視圖中不等效,即使這與Set.add方法的規范相反。
您似乎表明這種“奇怪”行為是不可接受的,因為equals
Dummy
元素不應出現在TreeSet
。 但Dummy
類不會覆蓋equals
,所以看起來這里還有一個額外的要求。
在稍后的問題編輯中添加了一些其他問題,但正如我上面提到的,我認為這些問題可以作為單獨的問題更好地處理。
更新2018-12-22
在重新閱讀編輯和評論的問題后,我想我終於找到了你想要的東西。 您希望在任何對象上使用比較器,該對象基於某些int值函數提供主要排序,這可能導致不等對象的重復值(由對象的equals
方法確定)。 因此,需要二次排序,它提供所有不等對象的總排序,但對於equals
對象,它返回零。 這意味着比較器應該與equals一致。
Guava的Ordering.arbitrary
接近於它提供了對任何對象的任意總排序,但它只對相同的對象(即==
)返回零,但對於equals
對象則不返回零。 因此它與equals不一致。
那么,聽起來,你想要一個比較器,它可以在不等對象上提供任意排序。 這是一個創建一個的函數:
static Comparator<Object> arbitraryUnequal() {
Map<Object, Integer> map = new HashMap<>();
return (o1, o2) -> Integer.compare(map.computeIfAbsent(o1, x -> map.size()),
map.computeIfAbsent(o2, x -> map.size()));
}
從本質上講,這會為每個新看到的不等對象分配一個序列號,並將這些數字保存在比較器所持有的地圖中。 它使用地圖的大小作為計數器。 由於永遠不會從此映射中刪除對象,因此大小和序列號總是會增加。
(如果您打算同時使用此比較器,例如,並行排序,則應使用ConcurrentHashMap
替換HashMap
,並且應修改大小技巧以使用在添加新條目時遞增的AtomicInteger
。)
請注意,此比較器中的映射為它所見過的每個不等對象構建條目。 如果將其附加到TreeSet
,則對象將在比較器的映射中保留,即使它們已從TreeSet
刪除。 這是必要的,這樣如果添加或刪除對象,它們將隨着時間的推移保持一致的排序。 Guava的Ordering.arbitrary
使用弱引用來允許在不再使用對象時收集對象。 我們不能這樣做,因為我們需要保留不相同但相等的對象的順序。
你會這樣使用它:
SortedSet<Dummy> sorted = new TreeSet<>(Comparator.<Dummy>comparingInt(map::get)
.thenComparing(arbitraryUnequal()));
您還詢問了以下內容中“明確定義”的含義:
即使集合的順序與equals不一致,集合的行為也是明確定義的
假設您使用與equals不一致的比較器來使用TreeSet
,例如使用上面顯示的Guava的Ordering.arbitrary
的比較器。 TreeSet
仍將按預期工作,與自身一致。 也就是說,它將以總排序維護對象,它不包含比較器返回零的任何兩個對象,並且它的所有方法都將按指定的方式工作。 但是,有可能存在一個contains
返回true的對象(因為它是使用比較器計算的),但是如果使用實際在集合中的對象調用,則equals
為false。
例如, BigDecimal
是Comparable
但它的比較方法與equals不一致:
> BigDecimal z = new BigDecimal("0.0")
> BigDecimal zz = new BigDecimal("0.00")
> z.compareTo(zz)
0
> z.equals(zz)
false
> TreeSet<BigDecimal> ts = new TreeSet<>()
> ts.add(z)
> HashSet<BigDecimal> hs = new HashSet<>(ts)
> hs.equals(ts)
true
> ts.contains(zz)
true
> hs.contains(zz)
false
這就是當規范表明事情可能“奇怪”時,規范意味着什么。 我們有兩套是平等的。 然而,它們報告了contains
相同對象的不同結果,並且TreeSet
報告它包含一個對象,即使該對象與集合中的對象不相等。
這是我最終得到的比較器。 它既可靠又節省內存。
public static <T> Comparator<T> uniqualizer() {
return new Comparator<T>() {
private final Map<T, Integer> extraId = new HashMap<>();
private int id;
@Override
public int compare(T o1, T o2) {
int d = Integer.compare(o1.hashCode(), o2.hashCode());
if (d != 0)
return d;
if (o1.equals(o2))
return 0;
d = extraId.computeIfAbsent(o1, key -> id++)
- extraId.computeIfAbsent(o2, key -> id++);
assert id > 0 : "ID overflow";
assert d != 0 : "Concurrent modification";
return d;
}
};
}
它在給定類T
所有對象上創建總排序,從而允許通過附加到它來區分不能通過給定比較器區分的對象,如下所示:
Comparator<T> partial = ...
Comparator<T> total = partial.thenComparing(uniqualizer());
在問題中給出的例子中, T
是Dummy
和
partial = Comparator.<Dummy>comparingInt(map::get);
請注意,在調用uniqualizer()
時不需要指定類型T
,編譯器會通過類型推斷自動確定它。 您只需確保T
中的hashCode()
與equals()
一致,如hashCode()
的常規協定中所述。 然后uniqualizer()
將為您提供與equals()
)一致的比較器( total
),您可以在任何需要比較T
類對象的代碼中使用它,例如在創建TreeSet
:
TreeSet<T> sorted = new TreeSet<>(total);
或排序列表:
List<T> list = ...
Collections.sort(list, total);
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.