【问题标题】:Set vs List when need both unique elements and access by index在需要唯一元素和按索引访问时设置 vs 列表
【发布时间】:2016-12-11 19:18:16
【问题描述】:

我需要保留一个唯一的元素列表,并且我还需要不时从它们中随机选择一个。我有两种简单的方法可以做到这一点。

  1. 让元素出现在一个集合中——这让我拥有了元素的独特性。当需要随机选择一个时,请执行以下操作:

    elementsSeen.toArray()[random.nextInt(elementsSeen.size())]
    
  2. 保留在列表中看到的元素 - 这样不需要转换为数组,因为当我需要随机请求时,有 get() 函数。但是这里我需要在添加时这样做。

    if (elementsSeen.indexOf(element)==-1) {elementsSeen.add(element);}
    

所以我的问题是哪种方式更有效?转换为数组更消耗还是 indexOf 更糟?如果尝试添加元素的次数增加了 10 次、100 次或 1000 次怎么办?

我对如何以最高效的方式将列表的功能(按索引访问)与集合的功能(唯一添加)结合起来很感兴趣。

【问题讨论】:

  • 你为什么不同时使用呢?无需创建额外的对象,因为两个集合可以指向同一个。
  • 您会经常查找它们吗?然后使用列表。你会插入很多吗?然后使用一个集合。我绝对不喜欢选项 1,但我也不完全确定你需要使用它。无论如何,集合通常会以不确定的顺序返回内容,因此您可以获取任何元素。 set.iterator().next(); 或保留迭代器的引用并在需要随机元素时调用 .next()。后者应该是随机的,尽管你不会得到重复。或者int i = random.nextInt(set.size()); for (Object o : set) { if (i-- == 0) return o;) - 内存比数组少。
  • 因此,您根本不需要列表。你只是认为你需要一个列表,因为你想要一个随机选择。
  • 我的问题实际上并不是从集合中获取随机数,而是关于如何有效地结合集合和列表的特征。随机在这里更像是一个比喻,我真正想要的列表方面是按索引访问。

标签: java performance list set


【解决方案1】:

如果使用更多内存不是问题,那么您可以通过在包装器中同时使用 list 和 set 来获得两者的最佳效果:

public class MyContainer<T> {
    private final Set<T> set = new HashSet<>();
    private final List<T> list = new ArrayList<>();

    public void add(T e) {
        if (set.add(e)) {
            list.add(e);
        }
    }

    public T getRandomElement() {
        return list.get(ThreadLocalRandom.current().nextInt(list.size()));
    }
    // other methods as needed ...
}

【讨论】:

  • 如果你只需要这些方法,就只有这些方法。
  • 这个解决方案不一定会增加内存使用量,因为Ts 不会被复制到所有地方
【解决方案2】:

HashSet 和 TreeSet 都扩展了AbstractCollection,其中包括toArray() 的实现,如下所示:

public Object[] toArray() {
    // Estimate size of array; be prepared to see more or fewer elements
    Object[] r = new Object[size()];
    Iterator<E> it = iterator();
    for (int i = 0; i < r.length; i++) {
        if (! it.hasNext()) // fewer elements than expected
            return Arrays.copyOf(r, i);
        r[i] = it.next();
    }
    return it.hasNext() ? finishToArray(r, it) : r;
}

如您所见,它负责为数组分配空间,以及创建一个Iterator 对象进行复制。因此,对于Set,添加是 O(1),但由于元素复制操作,检索随机元素将是 O(N)。

另一方面,List 允许您快速访问支持数组中的特定索引,但不保证唯一性。您必须重新实现 addremove 和相关方法以保证插入时的唯一性。添加唯一元素将是 O(N),但检索将是 O(1)。

因此,这实际上取决于哪个区域是您潜在的高使用点。是否会大量使用添加/删除方法,而很少使用随机访问?或者这将是一个最重要的检索容器,因为在程序的生命周期内几乎没有添加或删除元素?

如果是前者,我建议使用SettoArray()。如果是后者,实现一个唯一的 List 以利用快速检索可能对您有利。显着的缺点是add 包含许多边缘情况,标准 Java 库非常注意以有效的方式处理这些情况。您的实施会达到相同的标准吗?

【讨论】:

  • 既然没有替代品,只有扩展,他能不能不只是继承自 List 并在 super 调用之前预先附加唯一性检查?
  • @IsaacvanBakel 违反了List 合同,变得一团糟。如果您要朝这个方向发展,请不要扩展 List,而只需创建一个具有 List 的类并在将元素添加到内部 List 之前进行唯一性检查。
  • @LouisWasserman 我发现用我自己的实现修饰一个内部列表更容易,然后尝试重新实现整个类
  • 我不是建议重新实现该类,我建议的是一个具有 List 并且只公开您的用例实际需要的操作的类。
【解决方案3】:

编写一些测试代码并为您的用例输入一些实际值。如果性能对您来说是一个真正的问题,那么这两种方法都不会太复杂以至于不值得付出努力。

我根据您所描述的确切两种方法快速尝试了该方法,由于indexOf 方法的速度较慢,如果您添加的内容远远多于检索的内容,那么 Set 实现似乎会更快。但我真的建议您自己进行测试 - 您是唯一知道细节可能是什么的人。

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Random;
import java.util.Set;

public class SetVsListTest<E> {
    private static Random random = new Random();
    private Set<E> elementSet;
    private List<E> elementList;

    public SetVsListTest() {
        elementSet = new HashSet<>();
        elementList = new ArrayList<>();
    }

    private void listAdd(E element) {
        if (elementList.indexOf(element) == -1) {
            elementList.add(element);
        }
    }

    private void setAdd(E element) {
        elementSet.add(element);
    }

    private E listGetRandom() {
        return elementList.get(random.nextInt(elementList.size()));
    }

    @SuppressWarnings("unchecked")
    private E setGetRandom() {
        return (E) elementSet.toArray()[random.nextInt(elementSet.size())];
    }

    public static void main(String[] args) {
        SetVsListTest<Integer> test;
        List<Integer> testData = new ArrayList<>();
        int testDataSize = 100_000;
        int[] addToRetrieveRatios = new int[] { 10, 100, 1000, 10000 };

        for (int i = 0; i < testDataSize; i++) {
            /*
             * Add 1/5 of the total possible number of elements so that we will
             * have (on average) 5 duplicates of each number. Adjust this to
             * whatever is most realistic
             */
            testData.add(random.nextInt(testDataSize / 5));
        }

        for (int addToRetrieveRatio : addToRetrieveRatios) {
            /*
             * Test the list method
             */
            test = new SetVsListTest<>();
            long t1 = System.nanoTime();
            for(int i=0;i<testDataSize; i++) {
                // Use == 1 here because we don't want to get from an empty collection
                if(i%addToRetrieveRatio == 1) {
                    test.listGetRandom();
                } else {
                    test.listAdd(testData.get(i));
                }
            }
            long t2 = System.nanoTime();
            System.out.println(((t2-t1)/1000000L)+" ms for list method with add/retrieve ratio "+addToRetrieveRatio);

            /*
             * Test the set method
             */
            test = new SetVsListTest<>();
            t1 = System.nanoTime();
            for(int i=0;i<testDataSize; i++) {
                // Use == 1 here because we don't want to get from an empty collection
                if(i%addToRetrieveRatio == 1) {
                    test.setGetRandom();
                } else {
                    test.setAdd(testData.get(i));
                }
            }
            t2 = System.nanoTime();
            System.out.println(((t2-t1)/1000000L)+" ms for set method with add/retrieve ratio "+addToRetrieveRatio);
        }
    }
}

我机器上的输出是:

819 ms for list method with add/retrieve ratio 10
1204 ms for set method with add/retrieve ratio 10
1547 ms for list method with add/retrieve ratio 100
133 ms for set method with add/retrieve ratio 100
1571 ms for list method with add/retrieve ratio 1000
23 ms for set method with add/retrieve ratio 1000
1542 ms for list method with add/retrieve ratio 10000
5 ms for set method with add/retrieve ratio 10000

【讨论】:

    【解决方案4】:

    您可以扩展 HashSet 并跟踪对它的更改,维护所有条目的当前数组。

    在这里,我保留了数组的副本,并在每次集合更改时对其进行调整。要获得更强大(但成本更高)的解决方案,您可以在 pick 方法中使用 toArray

    class PickableSet<T> extends HashSet<T> {
        private T[] asArray = (T[]) this.toArray();
    
        private void dirty() {
            asArray = (T[]) this.toArray();
        }
    
        public T pick(int which) {
            return asArray[which];
        }
    
        @Override
        public boolean add(T t) {
            boolean added = super.add(t);
            dirty();
            return added;
        }
    
        @Override
        public boolean remove(Object o) {
            boolean removed = super.remove(o);
            dirty();
            return removed;
        }
    }
    

    请注意,如果被 Iterator 删除,这将无法识别对集合的更改 - 您需要以其他方式处理。

    【讨论】:

    • 您在这里所做的是在每次添加时转换为数组。在我看来,这比在请求随机数时转换为数组要糟糕得多。即使假设检索与添加一样频繁,这对仅使用常规 HashSet 并在检索时进行转换没有任何改进,实际上它只需要更多内存。我错过了什么吗?
    • @Sunny - 目前它支持罕见的添加但常见的pickdirty 方法可以增强为只设置一个dirty 标志,然后将其签入pick,这将有利于常见的add 和罕见的pick。这里的主要目的是指出通过子类化向当前集合添加功能是多么容易。
    • 我明白了,如果使用标志完成,那将是第一个版本,在检索时调用 toArray(),并增加了内存使用量以始终将列表保存在内存中,我会说当您每次需要使用它时都重新创建它时,保留它是没有意义的。我明白你的意思,但是一切看起来都更好。
    【解决方案5】:

    所以我的问题是哪种方式更有效?

    这是一个很难回答的问题,具体取决于一个人的更多操作、插入或随机选择?

    我们需要查看每个操作的Big O。在this case(最好的情况下):

    • 设置:插入 O(1)
    • 设置:toArray O(n)(我假设)
    • 数组:访问 O(1)

    • 列表:包含 O(n)
    • 列表:插入 O(1)
    • 列表:访问 O(1)

    所以:

    • 设置:插入:O(1),访问 O(n)
    • 列表:插入:O(n),访问 O(1)

    因此,在最好的情况下,如果您插入的数量超过您选择的数量,那么 Set 获胜,如果相反,则 List 获胜。

    现在是邪恶的答案 - 选择一个(最能代表问题的那个(所以设置 IMO)),将其包装好并运行它。如果它太慢,那么稍后再处理,当你处理它时,看看问题空间。您的数据是否经常更改?不,缓存数组。

    【讨论】:

      【解决方案6】:

      这取决于你更看重什么。

      List Java 中的实现通常使用数组或链表。这意味着插入和搜索索引很快,但搜索特定元素需要循环考虑列表并比较每个元素,直到找到该元素。

      Java 中的Set 实现主要使用数组、hashCode 方法和equals 方法。因此,当您要插入时,集合更费力,但在查找元素时胜过列表。由于集合不保证结构中元素的顺序,因此您将无法通过索引获取元素。您可以使用有序集,但这会由于排序而导致插入延迟。

      如果您要直接使用索引,那么您可能必须使用List,因为将元素放入Set.toArray() 的顺序会随着您向Set 添加元素而改变。

      希望这会有所帮助:)

      【讨论】:

      • @Seelenvirtuose 我知道ListSet 都是接口,我对它们的实现进行了很多比较。是的,插入HashSet 可以在 O(1) 中完成,但并不总是因为需要调整数组以适应完整结构的方式。最后在Setimplementations中无法完成搜索索引(即数组[1])
      • 很遗憾,SO 上有这么多人似乎真的很喜欢在他们的批评末尾添加一个无用的额外句子,例如 “这是一个非常糟糕的答案”。 @Seelenvirtuose
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2022-10-04
      • 1970-01-01
      • 1970-01-01
      • 2017-04-23
      • 2013-09-04
      • 1970-01-01
      相关资源
      最近更新 更多