Как узнать все перестановки чисел, сумма которых равна 100

Я пытаюсь найти эффективный способ создать метод, который берет словарь, содержащий несколько списков последовательных целых чисел (каждый список должен начинаться выше 0 или выше и заканчиваться на 100 или ниже, но точные числа могут отличаться) и возвращает список словарей, содержащих все перестановки, где сумма всех чисел равна 100.

Например, для 4 категорий: 10 + 20 + 10 + 60 = 100

Каждый словарь в результирующем списке должен храниться с одним целочисленным значением для каждого ключа.

Вот код, который я придумал, чтобы проиллюстрировать свой вопрос:

using System;
using System.Collections.Generic;
using System.Linq;

namespace recursiveTest
{
    class Program
    {
        static void Main(string[] args)
        {
            Dictionary<string, List<int>> data = new Dictionary<string, List<int>>();
            data.Add("A", Enumerable.Range(0, 100).ToList());
            data.Add("B", Enumerable.Range(0, 100).ToList());
            data.Add("C", Enumerable.Range(0, 100).ToList());
            data.Add("D", Enumerable.Range(0, 100).ToList());
            // I would like to add a few entries more...

            List<Dictionary<string, int>> permutations = new List<Dictionary<string, int>>();

            foreach (var a in data["A"])
            {
                foreach (var b in data["B"])
                {
                    foreach (var c in data["C"])
                    {
                        foreach (var d in data["D"])
                        {
                            if (a + b + c + d == 100)
                            {
                                var current = new Dictionary<string, int>()
                                {
                                    ["A"] = a,
                                    ["B"] = b,
                                    ["C"] = c,
                                    ["D"] = d,
                                };
                                permutations.Add(current);
                            }
                        }
                    }
                }
            }

            Console.WriteLine($"Found (foreach): {permutations.Count()}");
            Console.ReadKey();
        }
    }
}

Альтернатива с использованием LINQ:

            List<Dictionary<string, int>> permutations2 = (from a in data["A"]
                                                           from b in data["B"]
                                                           from c in data["C"]
                                                           from d in data["D"]
                                                           where a + b + c + d == 100
                                                           let current = new Dictionary<string, int>()
                                                           {
                                                               ["A"] = a,
                                                               ["B"] = b,
                                                               ["C"] = c,
                                                               ["D"] = d,
                                                           }
                                                           select current).ToList();

            Console.WriteLine($"Found (LINQ): {permutations2.Count()}");
            Console.ReadKey();

Это не было очень сложной задачей до того, как количество категорий (словарных ключей) и чисел начало расти... Поскольку количество словарных ключей (категорий) может варьироваться, это кажется потенциальным кандидатом для рекурсии, но я не не в состоянии заставить его работать. Эти две версии имеют несколько очевидных недостатков:

  1. Как только количество элементов и/или категорий увеличивается, производительность внезапно падает.
  2. Код в форме стрелки кажется рецептом катастрофы.
  3. Он пытается пройти все возможные комбинации, хотя на самом деле полезны лишь некоторые (те, которые в сумме дают 100).

Как лучше всего достичь желаемого результата с коротким и читаемым кодом и хорошей производительностью?

Есть ли способ отфильтровать ненужные циклы при попытке найти эти 100 значений суммы?


EDIT: Для пояснения, моя идея состоит в том, чтобы иметь возможность определить метод с такой сигнатурой:

private static List<Dictionary<string, int>> GetValidPermutations(Dictionary<string, List<int>> data)

Затем назовите это так:

List<Dictionary<string, int>> permutations = GetValidPermutations(data);

person Victor Domingos    schedule 18.04.2020    source источник
comment
Итак, вы хотите взять ровно одно число из каждого списка для суммы, это правильно?   -  person Rufus L    schedule 18.04.2020
comment
Верно. Он принимает список для каждой категории, поэтому входными данными является словарь строковых ключей (категорий) и значений List‹int›. Метод должен возвращать список словарей, каждый из этих словарей должен иметь одинаковый набор ключей (категорий) и одно целое значение для каждого из них. Сумма значений для каждого словаря в этом выходном списке всегда должна быть 100.   -  person Victor Domingos    schedule 19.04.2020


Ответы (1)


Чтобы повысить производительность, необходимо сократить количество ненужных итераций:

static List<Dictionary<string, int>> GetValidPermutations(int target, Dictionary<string, List<int>> data)
{
    return GetValidPermutations(target, data, 0, new int[data.Count])
            .Select(perm => CreateDictionary(data.Keys, perm))
            .ToList();
}

static Dictionary<string, int> CreateDictionary(IEnumerable<string> keys, IEnumerable<int> values)
{
    return keys.Zip(values, KeyValuePair.Create)
               .ToDictionary(pair => pair.Key, pair => pair.Value);
}

static IEnumerable<int[]> GetValidPermutations(int target, Dictionary<string, List<int>> data, int level, int[] sequence)
{
    if (level < sequence.Length)
    {
        var currentList = data.ElementAt(level).Value;
        var subsequentLists = data.Skip(level + 1).Select(x => x.Value);
        int start = Math.Max(currentList[0], target - subsequentLists.Sum(x => x.Last()));
        int end = Math.Min(currentList.Last(), target - subsequentLists.Sum(x => x[0]));
        for (sequence[level] = start; sequence[level] <= end; sequence[level]++)
        {
            int subTarget = target - sequence[level];
            foreach (var perm in GetValidPermutations(subTarget, data, level + 1, sequence))
            {
                yield return perm;
            }
        }
    }
    else
    {
        var perm = sequence.Append(target);
        System.Diagnostics.Debug.Assert(perm.Sum() == 100);
        yield return perm.ToArray();
    }
}

start и end выше тщательно рассчитаны, чтобы включить только необходимые итерации. Другие значения пропускаются, поскольку они не могут образовывать перестановку.

Затем вы можете вызвать метод следующим образом:

var p4 = GetValidPermutations(100, data);
Console.WriteLine($"Found (Recursion): {p4.Count()}");

Во-первых, может быть трудно понять версию рекурсии, есть эквивалент цикла for, вы можете видеть, что некоторые участки кода повторяются:

const int TARGET = 100;
var permutations3 = new List<Dictionary<string, int>>();

int aStart = Math.Max(data["A"][0], TARGET - data["B"].Last() - data["C"].Last() - data["D"].Last());
int aEnd = Math.Min(data["A"].Last(), TARGET - data["B"][0] - data["C"][0] - data["D"][0]);
for (int a = aStart; a <= aEnd; a++)
{
    int bStart = Math.Max(data["B"][0], TARGET - a - data["C"].Last() - data["D"].Last());
    int bEnd = Math.Min(data["B"].Last(), TARGET - a - data["C"][0] - data["D"][0]);
    for (int b = bStart; b <= bEnd; b++)
    {
        int cStart = Math.Max(data["C"][0], TARGET - a - b - data["D"].Last());
        int cEnd = Math.Min(data["C"].Last(), TARGET - a - b - data["D"][0]);
        for (int c = cStart; c <= cEnd; c++)
        {
            var perm = new Dictionary<string, int>
            {
                { "A", a },
                { "B", b },
                { "C", c },
                { "D", TARGET - a - b - c }
            };
            System.Diagnostics.Debug.Assert(perm["D"] >= data["D"][0] && perm["D"] <= data["D"].Last());
            permutations3.Add(perm);
        }
    }
}
Console.WriteLine($"Found (for): {permutations3.Count()}");

Логику пропуска можно проиллюстрировать следующими примерами:

Предположим, что максимальные значения B, C, D равны 10, 20, 30 соответственно, тогда A должно быть не менее 40, чтобы иметь сумму 100. Таким образом, A может начинаться с 40, а 0-39 пропускаются (если доступны) .

Аналогичная логика может применяться для пропуска более высоких диапазонов. Предположим, что минимальные значения B, C, D равны 5, 10, 15 соответственно, тогда A не может превышать 70. Потому что в этом случае сумма превысит 100. Таким образом, мы можем остановить цикл, когда A превысит 70.

Применение логики пропуска для всех категорий может привести к приведенному выше коду. Кроме того, последнюю категорию можно вычислить напрямую, не зацикливаясь.

person Ken Hung    schedule 18.04.2020
comment
Кажется, это хорошее приближение к тому, что я планировал, и это действительно улучшило производительность. Однако это решение не позволяет каждой из категорий иметь переменные начальные/конечные значения, оно просто предполагает 1-100. Я намерен разрешить для каждой категории начальное значение, которое может быть 0 или выше, и конечное значение, которое может быть 100 или ниже. Можно ли изменить его, чтобы он принимал предоставленный словарь (data) в качестве входного аргумента для рекурсивного метода? - person Victor Domingos; 18.04.2020
comment
@VictorDomingos Да, вы можете просмотреть обновленную версию. - person Ken Hung; 19.04.2020
comment
В первом методе я получаю синтаксическую ошибку на return GetValidPermutations(target, data, 0, new int[data.Count]) .Select(perm => data.Keys.Zip(perm, KeyValuePair.Create) .ToDictionary(pair => pair.Key, pair => pair.Value)) .ToList(); , где KeyValuePair.Create кажется не найденным. Каков предполагаемый результат этой части? - person Victor Domingos; 19.04.2020
comment
@VictorDomingos Эта часть используется для формирования вывода в формате словаря, поскольку рекурсивный метод возвращает массив int. data.Keys.Zip(perm, KeyValuePair.Create) вернет IEnumerable<KeyValuePair<string, int>>, а затем будет преобразовано в словарь. - person Ken Hung; 19.04.2020
comment
Код @VictorDomingos изменен для ясности. - person Ken Hung; 19.04.2020
comment
Может быть, это что-то с моей установкой VS. Как только using System.Linqдобавляется в исходный код, Intellisense выделяет Create с ошибкой («KeyValuePair» не содержит определения для «Create»). Я заменил его немного более подробным (k, v) => new KeyValuePair<string, int>(k, v), который, кажется, делает то же самое. - person Victor Domingos; 19.04.2020