Update 12 August 2018: I've updates the project to Core 2.1 and changed the Add method to treat OutOfMemoryExceptions in case of memory fragmentation.

I have been looking for a job lately and so I've run into these obnoxious interviews when someone asks you to find the optimal algorithm for some task. And as much as I hate those questions in an interview, they got me thinking about all kinds of normal situations where the implementation is not optimal. I believe it will be hard to find something less optimal than the List<T> implementation in .Net.

Reference


For reference, look at the implementation code in its entirety.

Story


I will be discussing some of the glaring issues with the code, but before that, let's talk about the "business case". You have a C-like programming language, armed with all the default types for such, like bool, int, array, etc. and you need a dynamically sized container, one where you can add, insert and remove elements. The first idea is to use an array, then resize it accordingly. Only you can't know how large the array will need to be and you can't just allocate memory and then resize that allocation, as other variables have already occupied the next blocks of memory. The solution might be to allocate an initial array, then - when its size is no longer sufficient - create a larger one, copy what you need and make the changes in it.

A comment in the source tells us what the developers meant to do: The list is initially empty and has a capacity of zero. Upon adding the first element to the list the capacity is increased to 16, and then increased in multiples of two as required. So, an empty list is just a wrapper for an empty array. A list with one element occupies the size of a 16 element array, but if you add up to 17 elements, the array will double and continue to double.

Implementation


Let's check the algorithmic complexity for the add operation. For the first 16 elements you just add elements in the internal array, n operations for n elements. Once you get to 17, the complexity increases, as you need to copy all previous 16 values first. Now it's 16+16+1, which continues up to 33, where you have 16+16+16+32+1. At 65 it's 16+16+16+32+32+64+1. So while we are adding element after element the operational complexity is on average twice as much as using an array. Meanwhile, the space occupied is half more than you actually need.

Insertion is similarly tricky, but even more inefficient. Basically, when you insert a value or a range, the first operation is EnsureCapacity, which may copy the entire array in a new array. Only afterward the insert algorithm is run and it again copies the part of the array found after the index for the insert.

Removal works in the opposite direction with the caveat that it never decreases the size of the array. If you added 10 million records in your list, then deleted them, your list now contains an internal array that is 10 million elements in size. There is a method called TrimExcess that tries to solve this, but you must call it manually. At least RemoveAll is using an O(n) algorithm instead of calling Remove repeatedly, which would have been a disaster.

The piece of code that sets the internal dimension of the list is actually in the setter of the Capacity property, and it dumbly creates an array and copies the values from the current one to the new one.

A lot of the other operations are implemented by calling the static methods on the Array class: Array.IndexOf, Array.Sort, Array.BinarySearch, Array.Reverse, etc.

The last issue that List has is that, as an array wrapper, it needs contiguous memory space. There will be times where your code will fail not because there is not enough free memory, but because it is fragmented and the runtime cannot find a free block that is large enough for your data.

Better solutions


Before I start spouting all kinds of stupid things, I will direct you to the venerable C5 collection library, which is very well designed, documented and tested. It contains all kinds of containers to optimize whichever scenario you might have been thinking about.

Let's think solutions now. The major problem of this implementation is the need of a continuous array. Why not solve it by adding more arrays instead of replacing the one with another twice as large? When the capacity is exceeded, we create a new array of similar size, then link it to our list. And since we want to have index access, not linked list access, we need to add this array into an array of arrays.

What would that mean? Memory doesn't need to be contiguous. Adding is twice as fast. Inserting is fast, also, as you only need to insert a new array between existing arrays and move around the data in a single inner array. Accessing by index is a bit more complicated, but not by much. Removal is just as simple, with the added bonus that some inner arrays might become empty and be removed directly.

This is by far not "the best" option, but just one level of optimization that tries to fix the biggest single problem with the current implementation. As one of my friends used to say: first collect data about your program, see where the bottlenecks are, then proceed to fix them in decreasing order of importance. The source can be found on GitHub.

Notes


A tester program of sorts is showing the Count, Capacity and time for random operations for a normal list and the FragmentList<T>. The next line shows the individual lengths of the inner arrays. Note that I cheated by using Lists instead of arrays. It only makes sense, as the simplistic operations of List<T> now have a limited negative impact on individual fragments. Take note of AutoDefragmentThreshold, which is 0.8 by default. It replaces all the internal lists with a single contiguous one when there are more than 80% of internal lists that are smaller than a tenth of the total count. To disable the feature you need to set the value to more than 1, not 0. I also implemented all the public methods of List<T>, although you might only need to implement IList<T> and the *Range methods.

Well, enjoy! Hope it helps.

Comments

Siderite

Thanks for the heads up. I&#39;ve reread the post and I realize that, since I set off to solve the problem with contiguous blocks of memory, having the same problem when adding items is not so great :) At the time I was merely trying to write the least amount of code that would &quot;fix&quot; List, but now I realize that if I rewrite everything as it should have been written it will become more useful. The simplest fix is the one above. When I get home (I am on holiday now) I will work on this some more.

Siderite

Siderite

I looked at the implementation again and indeed it might not help you without some modifications. You said your use case was adding a lot of items, in which case my implementation just adds stuff to the last list in the bucket. Add is basically the same as in a normal List. To fix it, you should check the length of the last bucket and if it is too large, add another: public void Add(T item) { ensureAtLeastOneArray(); var arr = _bucket[_bucket.Count - 1]; if (arr.Count&gt;tooLargeABucket) { //define tooLargeABucket as the maximum accepted length of a bucket arr = new List&lt;t&gt;(); _bucket.Add(arr); } arr.Add(item); _count++; }

Siderite

andrea l

Ok, my use case is a little different. My main problem is the &quot;Out of Memory&quot; Exception. I have a list of objects where I continuosly add items: at one point I have this exception, because internally it tries to allocate a contiguos block of memory that is twice the actual capacity of the list: if it can&#39;t do (because of fragmented memory) .NET throws this kind of exception. Perhaps I need to change a little your code to overcome better this problem (changing Add and Capacity methods).

andrea l

Siderite

That could work, too, but when you specify or set the capacity it kind of means you don&#39;t want to change the size of the collection often or ever. That is why I preferred to recreate the collection, which in this case would work as fast as a normal unfragmented list until removals or inserts would change its shape. And if you check the TrimExcess method, which is called to defragment the collection, you will see that it sets the Capacity as a way to do that.

Siderite

andrea l

Discussion and code are interesting, but I will modify Capacity method in this form. otherwise you will get the same problems as in the standard List&lt;t&gt; implementation. public int Capacity { get { return _bucket.Sum(l=&gt;l.Capacity); } set { if (value &lt; _count) { throw new ArgumentOutOfRangeException(); } // When setting capacity you have the same problem as with List&lt;t&gt; //var lst = new List&lt;t&gt;(value); //lst.AddRange(this); //_bucket.Clear(); var lst = new List&lt;t&gt;(value-_count); _bucket.Add(lst); } }

andrea l

Post a comment