Нахождение числа сочетаний Рассмотрим простейшую комбинаторную задачу: сколькими способами можно из данных n 0 предметов выбрать некоторые k предметов ( 0kn), если порядок их выбора не важен? Ответом на эту задачу является величина Cnk = , называемая числом сочетаний из n элементов по k. Запись n! обозначает произведение 1 . 2 . 3 . ... . n, называемое факториалом числа n, при этом считается, что 0! = 1.Эту формулу несложно вывести. Расставить n предметов в ряд можно n! способами. Рассмотрим все n! расстановок и будем считать, что первые k предметов в ряду мы выбрали, а оставшиесяn - k предметов -- нет. Тогда каждый способ выбрать k предметов мы посчитали k!(n - k)! раз, поскольку k выбранных предметов можно переставить k! способами, а n - k невыбранных -- (n - k)! способами. Поделив общее количество перестановок на количество повторов каждой выборки, получим число выборок: Cnk = .
Наивный методКак же вычислить значение Cnk по данным n и k? Воспользуемся выведенной формулой. Функция factorial(n) возвращает факториал числа n, а функция C(n,k) возвращает значение Cnk.
int factorial(int n) { int f=1; for(int i=2;i<=n;++i) f=f*i; return f; } int C(int n, int k) { return factorial(n)/factorial(k)/factorial(n-k); } Данный алгоритм понятен, очень быстр (его вычислительная сложность O(n), поскольку вычисление значения n! требует n - 1 умножение), использует ограниченный размер дополнительной памяти. Но у него есть существенный недостаток: он будет корректно работать только для небольших значений n и k. Дело в том, что величина n! очень быстро растет с увеличением n, например, значение 13! = 6 227 020 800 превосходит максимальное возможное значение, которое можно записать в 32-разрядном целом числе, а величина
21! = 51 090 942 171 709 440 000
не помещается в 64-разрядном целом числе. Поэтому пользоваться данной функцией можно только для n12 при использовании 32-битной арифметики или для n20 при использовании 64-битной арифметики. При этом само значение Cnk может быть невелико, но при проведении промежуточных вычислений факториалов может возникнуть переполнение. Например, C3015 = 155117520 можно записать с использованием 32-битной арифметики, однако, значение 30! при этом не поместится и в 64-разрядном целом числе и вычислить значение C3015 таким методом не удастся.
Рекурсивный методПроблем с переполнением можно избежать, если воспользоваться для вычисления известной формулой: Cnk = Cn - 1k - 1 + Cn - 1k (при 0 < k < n).Действительно, выбрать из n предметов k можно Cnk способами. Пометим один из данных n предметов. Тогда все выборки можно разбить на две группы: в которые входит помеченный предмет (их будет Cn - 1k - 1) и не содержащие помеченного предмета (таких выборок будет Cn - 1k). Добавив к этой формуле краевые значения: Cnn = Cn0 = 1 (выбрать все предметы или не выбрать ни одного предмета можно единственным способом), можно написать рекурсивную функцию вычисления числа сочетаний:
int C(int n, int k) { if (k==0 || k==n) return 1; else return C(n-1,k-1)+C(n-1,k); } При таком решении задачи проблем с переполнением не возникнет: если значение конечного ответа не приводит к переполнению, то поскольку при его нахождении мы суммируем меньшие натуральные числа, все промежуточные значения, возвращаемые функцией C(n,k) тоже не будут приводить к переполнению, и такая программа, например, сможет вычислить значение C3015. Но это решение крайне плохо тем, что работать оно будет очень долго, поскольку функция C(n,k) будет вызываться многократно для одних и тех же значений параметров n и k. Например, если мы вызовем функцию C(30,15), то она вызовет функции C(29,14) и C(29,15). Функция C(29,14) вызовет функции C(28,13) и C(28,14), а C(29,15) вызовет функции C(28,14) и C(28,15). Мы видим, что функция C(28,14) будет вызвана дважды. С увеличением глубины рекурсии количество повторяющихся вызовов функции быстро растет: функция C(27,13) будет вызвана три раза (дважды ее вызовет функция C(28,14) и еще один раз ее вызовет C(28,13) и т. д. При этом каждая функция C(n,k) может либо вернуть значение 1, либо вернуть сумму результатов двух других рекурсивных вызовов, а, значит, любое значение, которое вернула функция C(n,k) получается сложением в различных комбинациях чисел 1, которыми заканчиваются все рекурсивные вызовы. Значит, при вычислении Cnk инструкция return 1 в приведенной программе будет выполнена ровно Cnk раз, то есть сложность такого рекурсивного алгоритма для вычисления Cnk не меньше, чем само значение Cnk.
Метод динамического программированияИтак, одна из проблем рекурсивных алгоритмов (и эта проблема возникает весьма часто, не только в рассмотренном примере) -- длительная работа рекурсии за счет повторяющихся вызовов рекурсивной функции для одного и того же набора параметров. Чтобы не тратить машинное время на вычисление значений рекурсивной функции, которые мы уже когда-то вычислили, можно сохранить эти значения в массиве и вместо рекурсивного вызова функции мы будем брать это значение из массива. В задаче вычисления числа сочетаний создадим двумерный массив B и будем в элементе массива B[n][k] хранить величину Cnk. В результате получим следующий массив (по строчкам -- значения n, начиная с 0, по столбцам -- значения k, начиная с 0, каждый элемент массива B[n][k] равен сумме двух элементов: стоящего непосредственно над ним B[n-1][k] и стоящего над ним слева B[n-1][k-1]).
Полученная числовая таблица, составленная из значений Cnk, в которой каждый элемент равен сумме двух элементов, стоящих над ним, называется треугольником Паскаля. Поскольку каждый элемент этого массива равны сумме двух элементов, стоящих в предыдущей строке, то этот массив нужно заполнять по строкам. Соответствующая функция, заполняющая массив и вычисляющая необходимое число сочетаний может быть такой:1
int C(int n, int k) { int B[n+1][n+1]; // Создаем массив B из n+1 строки for(int i=0;i<=n;++i) // Заполняем i-ю строку массива { B[i][0]=1; // На концах строки стоят единицы B[i][i]=1; for(int j=1;j<i;++j) { // Заполняем оставшиеся элементы i-й строки B[i][j]=B[i-1][j-1]+B[i-1][j]; } } return B[n][k]; } Приведенный алгоритм для вычисления Cnk требует объем памяти O(n2) и такая же его временная сложность (для заполнения одного элемента массива требуется O(1) операций, всего же элементов в массиве O(n2)). Подобный метод решения, когда одна задача сводится к меньшим подзадачам, вычисленные же ответы для меньших подзадач сохраняются в массиве или иной структуре данных, называетсядинамическим программированием. В рассмотренной задаче вычисления числа сочетаний использование метода динамического программирования вместо рекурсии позволяет существенно уменьшить время выполнения программы за счет некоторого увеличения объема используемой памяти. Тем не менее, приведенный алгоритм тоже имеет небольшие недостатки. Мы вычисляем значения всех элементов последней строки, хотя нам необходим только один из них -- B[n][k]. Аналогично, в предпоследней строке нас интересуют только два элемента -- B[n-1][k-1] и B[n-1][k], в строке, стоящей над ней -- три элемента, и т. д., в то время, как мы вычисляем все элементы во всех строках. Кроме того, мы не используем почти половину созданного массива B[n+1][n+1], а именно все элементы, стоящие выше главной диагонали. От этих недостатков можно избавиться, более того, можно уменьшить объем используемой памяти до O(n), идея для построения такого алгоритма будет изложена в разделе «Маршруты на плоскости». Если же в программе часто возникает необходимость вычисления числа сочетаний Cnk для каких-то ограниченных значений n, то лучше всего создать глобальный массив для хранения всевозможных значений Cnk для нужных нам значений n, заполнить его в начале программы, а затем сразу же брать значение числа сочетаний из этого массива, не вычисляя его каждый раз заново.
Промежуточные итогиСоставим план решения задачи методом динамического программирования.
Далее мы рассмотрим несколько типичных задач, решаемых при помощи динамического программирования. |