如何快速从列表中删除项目

82

我正在寻找一种快速从 C# 的 List<T> 中删除项目的方法。文档中指出,List.Remove()List.RemoveAt() 操作都是 O(n)

这严重影响了我的应用程序。

我编写了几种不同的删除方法,并将它们全部测试在一个包含 500,000 个项目的 List<String> 上。以下是测试用例...


概览

我编写了一个方法,它生成一个字符串列表,其中每个数字都以字符串形式表示("1"、"2"、"3",等等)。然后我尝试着从列表中删除每五个项目。以下是用于生成列表的方法:

private List<String> GetList(int size)
{
    List<String> myList = new List<String>();
    for (int i = 0; i < size; i++)
        myList.Add(i.ToString());
    return myList;
}

测试1:RemoveAt()

这是我用来测试RemoveAt()方法的测试。

private void RemoveTest1(ref List<String> list)
{
     for (int i = 0; i < list.Count; i++)
         if (i % 5 == 0)
             list.RemoveAt(i);
}

测试2:Remove()

这是我用来测试Remove()方法的测试。

private void RemoveTest2(ref List<String> list)
{
     List<int> itemsToRemove = new List<int>();
     for (int i = 0; i < list.Count; i++)
        if (i % 5 == 0)
             list.Remove(list[i]);
}

测试3:将其设置为null,排序,然后删除范围

在这个测试中,我循环遍历了列表一次,并将要删除的项设置为null。然后,我对列表进行了排序(以便null位于顶部),并删除了所有设置为null的顶部项。 注意:这重新排序了我的列表,因此我可能需要将其放回正确的顺序。

private void RemoveTest3(ref List<String> list)
{
    int numToRemove = 0;
    for (int i = 0; i < list.Count; i++)
    {
        if (i % 5 == 0)
        {
            list[i] = null;
            numToRemove++;
        }
    }
    list.Sort();
    list.RemoveRange(0, numToRemove);
    // Now they're out of order...
}

测试4:创建一个新列表,并将所有“好”的值添加到新列表中

在这个测试中,我创建了一个新的列表,并将所有保留项添加到了新的列表中。然后,我将所有这些项目放入了原始列表中。

private void RemoveTest4(ref List<String> list)
{
   List<String> newList = new List<String>();
   for (int i = 0; i < list.Count; i++)
   {
      if (i % 5 == 0)
         continue;
      else
         newList.Add(list[i]);
   }

   list.RemoveRange(0, list.Count);
   list.AddRange(newList);
}

测试5:将其设为 null,然后使用FindAll()

在此测试中,我将所有要删除的项目设置为null,然后使用FindAll()功能查找所有不是null的项目。

private void RemoveTest5(ref List<String> list)
{
    for (int i = 0; i < list.Count; i++)
       if (i % 5 == 0)
           list[i] = null;
    list = list.FindAll(x => x != null);
}

测试6:将其设置为null,然后使用RemoveAll()方法

在此测试中,我将所有要删除的项设置为null,然后使用RemoveAll()方法删除所有不为null的项。

private void RemoveTest6(ref List<String> list)
{
    for (int i = 0; i < list.Count; i++)
        if (i % 5 == 0)
            list[i] = null;
    list.RemoveAll(x => x == null);
}
客户端应用程序和输出。
int numItems = 500000;
Stopwatch watch = new Stopwatch();

// List 1...
watch.Start();
List<String> list1 = GetList(numItems);
watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());

watch.Reset(); watch.Start();
RemoveTest1(ref list1);
watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());
Console.WriteLine();

// List 2...
watch.Start();
List<String> list2 = GetList(numItems);
watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());

watch.Reset(); watch.Start();
RemoveTest2(ref list2);
watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());
Console.WriteLine();

// List 3...
watch.Reset(); watch.Start();
List<String> list3 = GetList(numItems);
watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());

watch.Reset(); watch.Start();
RemoveTest3(ref list3);
watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());
Console.WriteLine();

// List 4...
watch.Reset(); watch.Start();
List<String> list4 = GetList(numItems);
watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());

watch.Reset(); watch.Start();
RemoveTest4(ref list4);
watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());
Console.WriteLine();

// List 5...
watch.Reset(); watch.Start();
List<String> list5 = GetList(numItems);
watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());

watch.Reset(); watch.Start();
RemoveTest5(ref list5);
watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());
Console.WriteLine();

// List 6...
watch.Reset(); watch.Start();
List<String> list6 = GetList(numItems);
watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());

watch.Reset(); watch.Start();
RemoveTest6(ref list6);
watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());
Console.WriteLine();

结果

00:00:00.1433089   // Create list
00:00:32.8031420   // RemoveAt()

00:00:32.9612512   // Forgot to reset stopwatch :(
00:04:40.3633045   // Remove()

00:00:00.2405003   // Create list
00:00:01.1054731   // Null, Sort(), RemoveRange()

00:00:00.1796988   // Create list
00:00:00.0166984   // Add good values to new list

00:00:00.2115022   // Create list
00:00:00.0194616   // FindAll()

00:00:00.3064646   // Create list
00:00:00.0167236   // RemoveAll()

笔记和评论

  • 前两个测试实际上并没有从列表中删除每个第五个项,因为在每次删除后都重新排序了列表。事实上,在 500,000 项中,只有 83,334 项被删除了(应该是 100,000 项)。我对此无可厚非 - 显然,Remove()/RemoveAt() 方法并不是一个好主意。

  • 尽管我试图从列表中删除第五个项目,但在 现实中 不会存在这样的模式。要删除的条目将是随机的。

  • 虽然我在这个例子中使用了一个List<String>,但情况并不总是如此。它可以是一个List<Anything>

  • 从一开始就不把项目放入列表不是一个选项。

  • 其他方法(3-6)在比较中表现得更好,但我有点担心——在 3、5 和 6 中,我被迫将一个值设置为null,然后根据这个标记删除所有项。我不喜欢这种方法,因为我可以想象到一种情况,在列表中的某个项目可能是null,而它会被意外地删除。

我的问题是:快速从List<T>中删除多个项目的最佳方法是什么?我尝试过的大多数方法看起来非常丑陋,潜在危险。是List数据结构不正确吗?

现在,我倾向于创建一个新的列表,并将好的项添加到新的列表中,但似乎应该有更好的方法。


4
你是否必须使用 List<T>?除非你需要随机访问,否则 LinkedList<T> 可能更为合适。 - Jon Skeet
如果是测试4,您可以直接将新列表分配给列表。您无需进行删除和添加操作。 - Will Calderwood
11个回答

38

在涉及到删除操作时,列表(List)并不是一种高效的数据结构。您最好使用双向链表(LinkedList),因为删除只需要更新相邻条目的引用即可。


谢谢。我会研究一下 LinkedList。它的主要缺点是什么? - user807566
5
链表在找到所需位置后插入和删除速度很快。但是为了定位一个元素,必须遍历整个链表(从两端都可以)。但由于数据不需要重新安置,因此插入或删除比使用列表仍然快得多。与列表一样,链表也能保持顺序。 - Steve Morgan
1
有几种方法可以使链表的索引(更)可行。我认为这主要是因为涉及到更多的麻烦。 - Lodewijk
3
另一个缺点是,链表对于现代处理器缓存不友好。 - StefanLundmark
根据Steve Morgan的评论,这意味着LinkedList没有实现IList接口。此外,由于您必须遍历集合以进行索引,因此在绑定到ItemsSource时可能会遇到性能问题。 - Slate
使用列表在项目数量较少时更有效率,因为它具有高局部性参考。而使用链表编写简短易读的代码则有些繁琐。 - M.kazem Akhgary

24
如果顺序不重要,那么有一个简单的O(1) List.Remove方法。
public static class ListExt
{
    // O(1) 
    public static void RemoveBySwap<T>(this List<T> list, int index)
    {
        list[index] = list[list.Count - 1];
        list.RemoveAt(list.Count - 1);
    }

    // O(n)
    public static void RemoveBySwap<T>(this List<T> list, T item)
    {
        int index = list.IndexOf(item);
        RemoveBySwap(list, index);
    }

    // O(n)
    public static void RemoveBySwap<T>(this List<T> list, Predicate<T> predicate)
    {
        int index = list.FindIndex(predicate);
        RemoveBySwap(list, index);
    }
}

这个解决方案适合内存遍历,因此即使需要先找到索引,速度也非常快。

注意事项:

  • 查找项目的索引必须是O(n),因为列表必须是未排序的。
  • 链表在遍历时速度较慢,特别是对于具有长生命周期的大型集合。

1
在我的测试中,您的方法比普通的list.remove方法快10%。 - Arsen Zahray
如果你正在使用100个条目,并且按照OP的要求删除每5个条目,那么你需要删除20个条目,但是第85、90、95和100个条目在被删除之前会被移动到列表中较早的位置,因此会被跳过。这可能就是为什么它更快的原因? - Zac Faragher
Zac,我提到的这种方法仅适用于顺序不重要且您只想要一袋东西的情况。如果您想根据位置特定地删除元素,则不应使用此方法。 - Yosef O

20

如果您乐意创建一个新的列表,就不必将项目设置为null。例如:

// This overload of Where provides the index as well as the value. Unless
// you need the index, use the simpler overload which just provides the value.
List<string> newList = oldList.Where((value, index) => index % 5 != 0)
                              .ToList();

然而,你可能需要考虑其他数据结构,例如LinkedList<T>HashSet<T>。这取决于你从数据结构中需要哪些功能。


14

我认为使用 HashSet, LinkedList, 或者 Dictionary 会更好。


4
或者你可以这样做:
List<int> listA;
List<int> listB;

...

List<int> resultingList = listA.Except(listB);

4

您可以始终从列表末尾删除项目。当在最后一个元素上执行时,列表删除的时间复杂度为O(1),因为它只是将计数减少。这不涉及下一个元素的移位。(这也是通常情况下列表删除时间复杂度为O(n)的原因)

for (int i = list.Count - 1; i >= 0; --i)
  list.RemoveAt(i);

这需要进行预排序,将要删除的项目放在列表末尾。List.Sort使用的是Array.Sort,最好情况下是O(nlogn),最坏情况下是O(n^2) - BaltoStar

3

好的,尝试这样使用RemoveAll

static void Main(string[] args)
{
    Stopwatch watch = new Stopwatch();
    watch.Start();
    List<Int32> test = GetList(500000);
    watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());
    watch.Reset(); watch.Start();
    test.RemoveAll( t=> t % 5 == 0);
    List<String> test2 = test.ConvertAll(delegate(int i) { return i.ToString(); });
    watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());

    Console.WriteLine((500000 - test.Count).ToString());
    Console.ReadLine();

}

static private List<Int32> GetList(int size)
{
    List<Int32> test = new List<Int32>();
    for (int i = 0; i < 500000; i++)
        test.Add(i);
    return test;
}

这段代码只循环两次,然后移除恰好100,000个项目。

我的输出结果:

00:00:00.0099495 
00:00:00.1945987 
1000000

已更新以尝试使用 HashSet

static void Main(string[] args)
    {
        Stopwatch watch = new Stopwatch();
        do
        {
            // Test with list
            watch.Reset(); watch.Start();
            List<Int32> test = GetList(500000);
            watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());
            watch.Reset(); watch.Start();
            List<String> myList = RemoveTest(test);
            watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());
            Console.WriteLine((500000 - test.Count).ToString());
            Console.WriteLine();

            // Test with HashSet
            watch.Reset(); watch.Start();
            HashSet<String> test2 = GetStringList(500000);
            watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());
            watch.Reset(); watch.Start();
            HashSet<String> myList2 = RemoveTest(test2);
            watch.Stop(); Console.WriteLine(watch.Elapsed.ToString());
            Console.WriteLine((500000 - test.Count).ToString());
            Console.WriteLine();
        } while (Console.ReadKey().Key != ConsoleKey.Escape);

    }

    static private List<Int32> GetList(int size)
    {
        List<Int32> test = new List<Int32>();
        for (int i = 0; i < 500000; i++)
            test.Add(i);
        return test;
    }

    static private HashSet<String> GetStringList(int size)
    {
        HashSet<String> test = new HashSet<String>();
        for (int i = 0; i < 500000; i++)
            test.Add(i.ToString());
        return test;
    }

    static private List<String> RemoveTest(List<Int32> list)
    {
        list.RemoveAll(t => t % 5 == 0);
        return list.ConvertAll(delegate(int i) { return i.ToString(); });
    }

    static private HashSet<String> RemoveTest(HashSet<String> list)
    {
        list.RemoveWhere(t => Convert.ToInt32(t) % 5 == 0);
        return list;
    }

这个给了我:
00:00:00.0131586
00:00:00.1454723
100000

00:00:00.3459420
00:00:00.2122574
100000

这是一个很好的方法。与其他答案不同,它不会在内存中创建新列表。而且,它比逐个删除项目要快得多。 - Carra

3

我发现在处理大型列表时,这种方法通常更快。删除操作和在字典中查找要删除的正确项的速度远远超过了创建字典的时间。不过需要注意两点:原始列表必须具有唯一值,并且完成后无法保证顺序。

List<long> hundredThousandItemsInOrignalList;
List<long> fiftyThousandItemsToRemove;

// populate lists...

Dictionary<long, long> originalItems = hundredThousandItemsInOrignalList.ToDictionary(i => i);

foreach (long i in fiftyThousandItemsToRemove)
{
    originalItems.Remove(i);
}

List<long> newList = originalItems.Select(i => i.Key).ToList();

你可以将50k列表作为字典,然后在100k上迭代一次并检查50k中的内容。这样可以避免进行50k个字典化操作。但这仍然是一个非常丑陋的解决方案。 - Lodewijk
支持NeilPearson。我们有一段使用了两个列表和RemoveAll的代码。仅通过将主列表更改为使用.ToDictionary(x=>x),并将removeall更改为使用字典包含,我们的代码从几分钟变成了不到一秒钟。 - Choco Smith
这个可以使用 HashSet 而不是字典,因为我们真正关心的只是键。 - NeilPearson
“我认为一旦完成,顺序不能保证。”在字典中,顺序从未得到保证。但如果需要,可以使用OrderedDictionary。 - Zac Faragher

2

当n(数据量)变得非常大时,列表比链表快。这是因为使用链表时所谓的缓存未命中发生得比使用列表更频繁。内存查找非常昂贵。由于列表是作为数组实现的,CPU可以一次性加载一堆数据,因为它知道所需的数据存储在相邻位置。然而,链表不会给CPU任何提示,下一个需要的数据是什么,这迫使CPU进行更多的内存查找。顺便说一下,术语“内存”指的是RAM。

有关更多详细信息,请参见:https://jackmott.github.io/programming/2016/08/20/when-bigo-foolsya.html


1
其他答案(以及问题本身)提供了使用内置的.NET Framework类来处理这种“slug”(缓慢的bug)的各种方法。但是,如果您愿意切换到第三方库,则可以通过更改数据结构并仅更改列表类型而不更改代码来获得更好的性能。Loyc Core库包括两种类型,其工作方式与List<T>相同,但可以更快地删除项目:DList是一个简单的数据结构,可在从随机位置删除项目时使您的速度提高2倍;AList是一种复杂的数据结构,可在列表非常长时(但列表很短时可能会更慢)大幅提高速度。

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