Personally, I would design this in terms of a standard Set<> interface:
public class StringPair { public StringPair(String a, String b) { a_ = a; b_ = b; hash_ = (a_ + b_).hashCode(); } public boolean equals(StringPair pair) { return (a_.equals(pair.a_) && b_.equals(pair.b_)); } @Override public boolean equals(Object obj) { if (obj instanceof StringPair) { return equals((StringPair) obj); } return false; } @Override public int hashCode() { return hash_; } private String a_; private String b_; private int hash_; } public class StringSetImpl implements StringSet { public StringSetImpl(SetFactory factory) { pair_set_ = factory.createSet<StringPair>(); } // ... private Set<StringPair> pair_set_ = null; }
Then you could leave it up to the user of StringSetImpl to use the preferred Set type. If you are attempting to optimize access, though, it's hard to do better than a HashSet<> (at least with respect to runtime complexity), given that access is O(1), whereas tree-based sets have O(log N) access times.
That contains() usually returns true may make it worth considering a Bloom filter, although this would require that some number of false positives for contains() are allowed (don't know if that is the case).
Edit
To avoid the extra allocation, you can do something like this, which is similar to your two-level approach, except using a set rather than a map for the second level:
public class StringSetImpl implements StringSet { public StringSetImpl() { elements_ = new HashMap<String, Set<String>>(); } public boolean contains(String a, String b) { if (!elements_.containsKey(a)) { return false; } Set<String> set = elements_.get(a); if (set == null) { return false; } return set.contains(b); } public void add(String a, String b) { if (!elements_.containsKey(a) || elements_.get(a) == null) { elements_.put(a, new HashSet<String>()); } elements_.get(a).add(b); } public void remove(String a, String b) { if (!elements_.containsKey(a)) { return; } HashSet<String> set = elements_.get(a); if (set == null) { elements_.remove(a); return a; } set.remove(b); if (set.empty()) { elements_.remove(a); } } private Map<String, Set<String>> elements_ = null; }
Since it's 4:20 AM where I'm located, the above is definitely not my best work (too tired to refresh myself on the treatment of null by these different collections types), but it sketches the approach.