0

Similar to this Java question I want to sort a List<T> where a known set of values should come first (if present), after which we use the default order - e.g. EqualityComparer<T>.Default. Efficient code is desirable, but I'm more interested in a clever LINQ expression.

Sample data

Known:    e c
Input:    c b e d a
Output:   e c a b d

Known:    e c
Input:    b a c e c
Output:   e c c a b

Known:    e c
Input:    b d f a
Output:   a b d f

Wanted API

Some minor tweaks might be necessary. Note the keySelector, allowing us to choose a property as sorting target.

public static class EnumerableExtensions
{
    public static IEnumerable<TSource> Sort<TSource, TKey>(
        this IEnumerable<TSource> input,
        Func<TSource, TKey> keySelector, TKey[] knownValues)
    {
        // TODO: Clever LINQ implementation here!
        yield break;
    }
}

Sample program

class Program
{
    public class Foo
    {
        public Foo(string name) => Name = name;
        public string Name { get; }
        public override string ToString() => Name;
    }

    public static void Main()
    {
        string[] knownValues = { "e", "c" }; // We can assume that these values are unique!
        var (a, b, c, d, e) = (new Foo("a"), new Foo("b"), new Foo("c"), new Foo("d"), new Foo("e"));
        
        var input = new List<Foo> { c, b, e, d, a };
        var expected = new List<Foo> { e, c, a, b, d };
        var actual = input.Sort(t => t.Name, knownValues).ToList();
        
        Console.WriteLine(expected.SequenceEqual(actual) ? "SUCCESS" : "FAIL");
        Console.WriteLine("Expected: " + string.Join(", ", expected));
        Console.WriteLine("Actual:   " + string.Join(", ", actual));
    }
}
l33t
  • 18,692
  • 16
  • 103
  • 180
  • `var known = input.Where(ts => knownValues.Any(k => k.Equals(keySelector(ts)))).OrderBy(ts => Array.IndexOf(knownValues, keySelector(ts))); return known.Union(input.Except(known).OrderBy(ts => keySelector(ts)));` – Jimi Dec 02 '20 at 17:08
  • What is the expected output for `Known: e c e` and `Input: a b c d e e`? Is it `e c e a b d` or `e e c a b d`, or something else? – Theodor Zoulias Dec 03 '20 at 00:14
  • I did specify that we can assume `known` values are unique. Thus, `Known: e c` and `Input: a b c d e e` should result in the _latter_; `Output: e e c a b d` – l33t Dec 03 '20 at 00:25

3 Answers3

2

This should do the trick :

var actual = input
    .OrderByDescending(o => knownValues.Contains(o.Name))
    .ThenBy(o => knownValues.FindIndex(p => o.Name == p))
    .ThenBy(o => o.Name)
    .ToList();

OrderByDescending will split the list in two parts. The known values and the unknown values. The first ThenBy will order the known values in the same order than the knownValues array and the last ThenBy will order the unknown values.

RenanL
  • 29
  • 2
  • Ok, so if we change `knownValues` to a `List` and then use the `keySelector`, it will indeed work: `input.OrderByDescending(o => knownValues.Contains(keySelector(o))).ThenBy(o => knownValues.IndexOf(keySelector(o))).ThenBy(keySelector)` So three sort operations in total. Can we improve it? – l33t Dec 02 '20 at 15:56
  • 1
    How about this? `input.OrderBy(o => (uint)knownValues.IndexOf(keySelector(o))).ThenBy(keySelector)` – l33t Dec 02 '20 at 16:06
  • @NetMage Just trying to be creative. Casting to `uint` should be equivalent to `PastIndexOf` of yours. No? – l33t Dec 02 '20 at 21:35
  • @l33t Yes, I think you are right... very good! – NetMage Dec 02 '20 at 21:50
2

Unfortunately .Net doesn't have an IndexOf that returns one past the upper bound when the element isn't found, but you can write one:

public static class ArrayExt {
    public static int PastIndexOf<T>(this T[] array, T val) {
        var index = array.IndexOf(val);
        return index == -1 ? array.Length : index;
    }
}

NOTE: This feels unfortunate to me, since the framework has special code to handle the -1 return and now we are adding special code on top of it to undo that, but writing our own implementation would probably be slower then Array.IndexOf which has special optimizations.

With this extension method, your method is simple:

public static IEnumerable<TSource> Sort<TSource, TKey>(this IEnumerable<TSource> input, Func<TSource, TKey> keySelector, TKey[] knownValues)
    => input.OrderBy(i => knownValues.PastIndexOf(keySelector(i))).ThenBy(i => keySelector(i));

Alternatively, you can use a generic extension method to catch the concept of testing and replacing a value:

public static class ObjectExt {
    public static T ElseIf<T>(this T a, Func<T,bool> testFn, T b) => testFn(a) ? b : a;
}

And then write your method using this:

public static IEnumerable<TSource> Sort<TSource, TKey>(this IEnumerable<TSource> input, Func<TSource, TKey> keySelector, TKey[] knownValues)
    => input.OrderBy(i => knownValues.IndexOf(keySelector(i)).ElseIf(n => n == -1, knownValues.Length+1)).ThenBy(i => keySelector(i));

NOTE: Of course, these extension methods makeup for C#'s lack of a easy declaration expression where you could just use ?:. These are generalizations of ?? that feel like could use a more succinct expression, but I don't know what it would be.

PS: You can have a declaration expression, it just hard to read:

public static IEnumerable<TSource> Sort<TSource, TKey>(this IEnumerable<TSource> input, Func<TSource, TKey> keySelector, TKey[] knownValues)
    => input.OrderBy(i => (knownValues.IndexOf(keySelector(i)) is var v) && v >= 0 ? v : knownValues.Length+1).ThenBy(i => keySelector(i));

PPS: Or using C# 9 extended pattern matching:

public static IEnumerable<TSource> Sort<TSource, TKey>(this IEnumerable<TSource> input, Func<TSource, TKey> keySelector, TKey[] knownValues)
    => input.OrderBy(i => knownValues.IndexOf(keySelector(i)) is var v and >= 0 ? v : knownValues.Length+1).ThenBy(i => keySelector(i));

PPPS: As pointed out by @l33t, a uint cast of the return value of IndexOf will produce the proper ordering:

public static IEnumerable<TSource> Sort<TSource, TKey>(this IEnumerable<TSource> input, Func<TSource, TKey> keySelector, TKey[] knownValues)
    => input.OrderBy(i => (uint)knownValues.IndexOf(keySelector(i))).ThenBy(i => keySelector(i));
    // cast IndexOf return value to uint to move not found to the end
NetMage
  • 26,163
  • 3
  • 34
  • 55
  • I think the idea of using a "past index" is the right solution. Perhaps add a "PPPS" with a `(uint)` cast variant? :) – l33t Dec 02 '20 at 22:10
  • @l33t I added it. – NetMage Dec 02 '20 at 23:26
  • Nice answer, but the formatting of the code makes it difficult to read because of the horizontal scrolling needed. I guess it should look fine in a some ultrawide screen with 3840 horizontal pixels, but I don't have any to confirm it. – Theodor Zoulias Dec 03 '20 at 00:21
  • 1
    @TheodorZoulias my 2560 x 1600 screens says that it wouldn't help - stackoverflow sites aren't responsive to horizontal growth, as zoom will show you :( You can only see 90 characters horizontally, and that is far too few for C# in my book. – NetMage Dec 03 '20 at 19:18
1

Yes. The easiest method is probably to use .ThenBy

Example assumes the items are comparable and equatable for simplicity, and that input and knownvalues have the same type.

input.OrderBy(i => knownValues.Contains(i) )
      .ThenBy(i => i)
JonasH
  • 28,608
  • 2
  • 10
  • 23