Using Linq to sum up to a number (and skip the rest)
If we have a class that contains a number like this:
class Person
{
public string Name {get; set;}
public int Amount {get; set;}
}
and then a collection of people:
IList<Person> people;
That contains, let's say 10 people of random names and amounts is there a Linq expression that will return me a subcollection of Person objects whose sum fulfills a condition?
For example I want the first x people whose sum of Amount is under 1000. I can do that traditionally by
var subgroup = new List<Person>();
people.OrderByDescending(x => x.Amount);
var count = 0;
foreach (var person in people)
{
count += person.Amount;
if (count < requestedAmount)
{
subgroup.Add(person);
}
else
{
break;
}
}
But i've been wondering if there's an elegant Linq way of doing something like this using Sum and then some other function like Take?
UPDATE
This is fantastic:
var count = 0;
var subgroup = people
.OrderByDescending(x => x.Amount)
.TakeWhile(x => (count += x.Amount) < requestedAmount)
.ToList();
But I am wondering if I can somehow change it further in order to grab the next person in the people list and add the remainder into the sum so that the total amount equals requested amount.
You can use TakeWhile
:
int s = 0;
var subgroup = people.OrderBy(x => x.Amount)
.TakeWhile(x => (s += x.Amount) < 1000)
.ToList();
Note: You mention in your post first x people. One could interpret this as the ones having the smallest amount that adds up until 1000
is reached. So, I used OrderBy
. But you can substitute this with OrderByDescending
if you want to start fetching from the person having the highest amount.
Edit:
To make it select one more item from the list you can use:
.TakeWhile(x => {
bool bExceeds = s > 1000;
s += x.Amount;
return !bExceeds;
})
The TakeWhile
here examines the s
value from the previous iteration, so it will take one more, just to be sure 1000
has been exceeded.
I don't like these approaches of mutating state inside linq queries.
EDIT: I did not state that the my previous code was untested and was somewhat pseudo-y. I also missed the point that Aggregate actually eats the entire thing at once - as correctly pointed out it didn't work. The idea was right though, but we need an alternative to Aggreage.
It's a shame that LINQ don't have a running aggregate. I suggest the code from user2088029 in this post: How to compute a running sum of a series of ints in a Linq query?.
And then use this (which is tested and is what I intended):
var y = people.Scanl(new { item = (Person) null, Amount = 0 },
(sofar, next) => new {
item = next,
Amount = sofar.Amount + next.Amount
}
);
Stolen code here for longevity:
public static IEnumerable<TResult> Scanl<T, TResult>(
this IEnumerable<T> source,
TResult first,
Func<TResult, T, TResult> combine)
{
using (IEnumerator<T> data = source.GetEnumerator())
{
yield return first;
while (data.MoveNext())
{
first = combine(first, data.Current);
yield return first;
}
}
}
Previous, wrong code:
I have another suggestion; begin with a list
people
[{"a", 100},
{"b", 200},
... ]
Calculate the running totals:
people.Aggregate((sofar, next) => new {item = next, total = sofar.total + next.value})
[{item: {"a", 100}, total: 100},
{item: {"b", 200}, total: 300},
... ]
Then use TakeWhile and Select to return to just items;
people
.Aggregate((sofar, next) => new {item = next, total = sofar.total + next.value})
.TakeWhile(x=>x.total<1000)
.Select(x=>x.Item)