在Java ArrayList中删除对象 - 时间消耗

11

我正在尝试从一个大小为7,140,000的ArrayList中删除140,000个对象。我认为这应该只需要几秒钟(如果需要的话),但实际上Java每删除1000个对象需要花费好几秒钟。以下是我的代码:

     for (int i = list.size(); i > P; i--)
     {
         int size = list.size();

         int index = (int) (Math.random() * size);

         list.remove(index);
     }

注意:P是我之前设置为7,000,000的常数。

这个循环的目标是随机删除列表中的对象,直到其大小为7,000,000。

Java花费这么长时间是因为我从7百万个对象开始吗? 我以前从ArrayList中删除时从未注意到这个效率问题。 如果有帮助的话,我使用DrJava Beta IDE。


剩余项目的顺序是否重要? - Piro
1
如果唯一的标准是使列表大小为7,000,000,为什么需要随机进行并支付移动数组的成本?为什么不能只删除从7,140,000到7,000,000的元素? - Nishit
2个回答

7
每次从ArrayList中删除一个元素时,它都必须将所有索引大于被删除元素的元素向下移动一个插槽。比如说你要删除一个包含7M个元素的列表中的第一个元素,那么你就需要移动6,999,999个元素。
如果你在循环中这样做,时间复杂度将会是O(n^2),其中n为列表的大小。对于一个包含7M个元素的列表来说,速度会非常慢。
相反,如果你事先知道要删除哪些元素,你可以在单次遍历中将所有元素向下移动:
int dst = 0;
for (int src = 0; src < list.size(); ++src) {
  if (!toRemove(src)) {
    list.set(dst++, list.get(src));
  }
}
list.subList(dst, list.size()).clear();

toRemove(src) 是一个函数,用于判断是否需要移除第 src 个元素。

例如,您可以构造一个只设置了除 P 元素之外所有元素的 BitSet

BitSet toRemove = new BitSet(list.size());
for (int i = list.size(); i > P; i--) {
  int rand;
  do {
    rand = Math.random() * list.size();
  } while (toRemove.get(rand));
  toRemove.set(rand, true);
}

如果您只是从一个700万元素列表中删除零元素,则仍然需要将所有6999999个元素向右移动;但是任何其他的删除都不需要再进行额外的移动。这个算法的时间复杂度为O(n),其中n是列表的大小。


编辑:您可以按照以下方式从列表中选择P个元素(其中P <= list.size()):

int dst = 0;
Random rand = new Random();
for (int src = 0; dst < P; ++src) {
  if (rand.nextInt(list.size() - src) < (P-dst)) {
    list.set(dst++, list.get(src));
  }
}
list.subList(dst, list.size()).clear();

这种策略将以等概率 (*) 从列表中选择元素,并适用于任何 P 的值;它还保持了原始顺序。


如果你想从一个有 N 个项目的列表中随机选出 K 个项目而不重复,有 choose(N, K) = N! / (K! * (N-K)!) 种方法可以实现。如果你想要以相等的概率选择所有元素,则应该选择这些 c(n,k) 种不同的配置之一。

当还剩下 k 个项目可供选择时,你可以:

  • 选择第一个项目;然后从剩余的 n-1 个项目中选择 k-1 个项目;或者
  • 不选择第一个项目;然后从剩余的 n-1 个项目中选择 k 个项目。

为了确保整体上选择这 K 个元素的概率相等,你需要根据从 n-1 个元素中选择的组合数来选择其中一种方案。

                                   #(combinations after taking first item) 
P(take first item) = ------------------------------------------------------------------
                     #(combinations after taking) + #(combinations after not taking)

                   = C(n-1,k-1) / (C(n-1, k-1) + C(n-1, k))

                   = ... working omitted ...

                   = k / n

因此,当您需要从n中取k项时,您应该在时间的k/n中首先取第一项。
需要指出的两种有趣情况是:
当k == n时,k/n = 1,因此您始终会取该元素。直观地看,如果您需要从n个项目中选出n个项目,则必须全部取出。
当k == 0时,k/n = 0,因此您永远不会取该元素。 直观地看,如果您已经选择了所有K个项目,则无需再选择任何项目。
要实现这一点,您可以在范围[0..n)内简单生成一个均匀分布的随机数r,并在r < k时从列表中“取”元素。
根据上面的实现,k = P - dst,并且n = list.size() - src。

1
感谢您的回答。我刚刚做了一些自己的研究,与您回答中的想法相符。我没有意识到每次移动所有元素需要多少时间,但这很有道理。我的解决方案是随机将140,000个元素设置为null,然后将所有“非null”元素放入临时ArrayList中。然后我将列表设置为临时ArrayList。我认为这与您的解决方案相同,并且按预期快速运行。 - Inertial Ignorance
@oleg.cherednik 是的,我最终做了你提到的事情。它运行得很好。 - Inertial Ignorance
“BitSet”解决方案似乎存在随机效率问题,因为它可能会访问之前设置为true的相同位。这样做有更高的算法性能不良的几率,如果您要从输入中删除更多项目(因为您正在将越来越多的项目设置为true,超出整个列表)。例如,如果您想删除99%的项目,则会出现这样一种情况,即近乎99%的循环将无法获取要设置的位。我更喜欢并建议使用更确定性的解决方案。 - android developer
@Android开发者,没错。我想这只对我有效,因为我正在删除140,000 / 7,140,000个对象:不到2%。 - Inertial Ignorance
@Andy Turner 我知道,我的意思是你提到的同一概念。 实现/解决方案则非常不同。 - Inertial Ignorance
显示剩余9条评论

6
一个ArrayList由一个数组支持,所以修改需要将项目实际上移开,有时甚至需要创建一个全新的数组。
一些可能的解决方案:
1.考虑使用LinkedList或skip-list实现。请注意,这里要删除一个项仍然需要O(N)(或者在skip-list中是O(logN)),因为它必须找到它。但是,您可以根据您已经删除的项目数量来遍历项目。
2.您可以随机从输入中取出项目放入一个新的ArrayList,直到得到所需数量的项目。但是,您必须知道添加了哪些项目,所以以线性方式遍历,并让随机选择器有一个根据您已经移动的项目数量而定的步数机会。
3.最简单的解决方案:对整个输入数组进行洗牌,然后选择前M个项目。
以下是解决方案#3的可能代码:
public static List<String> pickNRandom(List<String> lst, int m) {
    Collections.shuffle(lst);
    return lst.subList(0, n);
}

这里的缺点是它破坏了项目的顺序。您可以通过将列表作为输入创建副本来克服此问题,但这将占用更多内存(暂时)...


这是我找到的一个类似于解决方案#3的示例答案:https://dev59.com/Lmsy5IYBdhLWcg3wsQJj#8378788 - android developer
这里还有其他解决方案,类似于我写的解决方案#1,#2:https://en.wikipedia.org/wiki/Reservoir_sampling - android developer
有道理。在列表上运行一个循环并在每次迭代中交换两个随机对象应该非常快。在我的情况下,顺序根本不重要,所以这个解决方案是理想的。 - Inertial Ignorance
@InertialIgnorance,你可以尝试我展示的这两行代码。我想知道什么是最好的解决方案,既不会破坏项目的顺序,又在内存和时间上高效。我从未实现我写的其他解决方案,因为它们需要更多的代码,并需要考虑如何使随机性分布良好。 - android developer
@androiddeveloper请查看我对答案的修改。它不需要很多代码:为了均匀地从列表中抽取一个元素所需的概率,计算起来非常简单。 - Andy Turner
显示剩余3条评论

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