Unix: тени прошлого

Друзья, не обижайтесь, но у меня сейчас совершенно нет времени писать что-то вдумчивое и основательное. Поэтому решил разбавить тишину ссылками на статьи с LWN:

Вкратце о содержимом. В ранние годы Unix были приняты некоторые архитектурные решения, которые определили то, что сейчас принято называть «Unix Way». Над этими решениями думали не самые глупые люди, но предусмотреть, что будет через 40 лет, никто не в силах. Поэтому в свете современных требований некоторые ключевые решения и паттерны до сих пор остались красивыми и согласованными, а некоторые породили проблемы разной величины. В статьях как раз про второй тип: какие выявились проблемы, как их можно решать и как они решаются сейчас.

Лично меня статьи зацепили тем, что многие из описанных проблем я заметил и сам, хотя и не подвергал их такому глубокому анализу.

Как часто бывает на LWN, комменты не менее ценны, чем сами статьи.

Предупреждение

Кривизна моих рук достигла неимоверных высот. Поэтому не удивляйтесь, что в RSS-фиде внезапно появилось несколько старых постов.

Разбор программы, играющей «в города»

Напомню требования к программе.

  1. Входные данные — последовательность строк; каждая строка начинается с прописной буквы, остальные буквы строчные.
  2. Входную последовательность всегда можно переупорядочить в допустимую последовательность, в которой у каждой пары смежных строк первая буква второй строки, переведенная в нижний регистр, совпадает с последней допустимой буквой первой строки. Допустимая буква — это буква, с которой начинается хотя бы одно слово последовательности.
  3. Требуется написать кратчайшую программу, находящую любую допустимую перестановку входной последовательности.

Как это часто бывает, есть два очевидных способа решения этой задачи: один быстрый, другой простой.

Суть быстрого алгоритма: возьмем все первые и последние допустимые буквы всех слов и сделаем их вершинами в графе. Сами слова будут представлены дугами. Теперь достаточно в этом графе найти эйлерову цепь или эйлеров цикл. Если кто не помнит, эйлерова цепь/цикл проходит через каждую дугу графа ровно один раз. В честь Эйлера, который желал странного, бродя по кенигсбергским мостам.

Быстрый алгоритм хорош тем, что он быстрый. И плох тем, что трудно его реализовать компактно. Такое случается с быстрыми алгоритмами, ничего не поделаешь… Из-за этого многие им предпочитают медленные и простые, но ради справедливости код все равно пишут длинный и сложный.  И комментарии к нему не пишут. А потом увольняются, а новый разработчик все переписывает заново. Используя тот же алгоритм. Да… О чем я говорил?

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

Идея простого алгоритма в том, чтобы рассмотреть все перестановки входной последовательности и проверить каждую на допустимость. Одна из них, согласно условию, обязательно подойдет.

От алгоритма перебора всех перестановок требуется только одно: как можно более короткая реализация. Концептуально наиболее просто способ — перемешивать последовательность случайным образом, как карты в колоде. При идеальном генераторе случайных чисел случайный перебор потребует столько же времени, сколько и «честный». К сожалению, в стандартной библиотеке C нет функций вроде shuffle, поэтому напишем честный перебор руками. Я взял кнутовское «Искусство программирования», том 4, выпуск 2, главу 7.2.1.2 и нашел там на странице 71 (в русскоязычном издании) алгоритм генерации перестановок с помощью циклических сдвигов. Автор уверял, что алгоритм отличается чертовски простой реализацией. Итак, имея исходную последовательность $x_1\ldots x_n$:

  1. Установить $a_i = x_i$, $1 \leq i \leq n$.
  2. Посетить перестановку $a_1 \ldots a_n$ и установить $k = n$.
  3. Заменить $a_1a_2\ldots a_k$ на $a_2\ldots a_ka_1$ (циклический сдвиг). Если $a_k \neq x_k$, перейти к п. 2.
  4. Уменьшить $k$ на 1. Если $k > 1$, вернуться к п. 3 (мы будем возвращаться на п.3 без выполнения этой проверки, т.к. знаем, что одна из перестановок нам точно подойдет).

Проверка перестановки на допустимость выполняется элементарно. Построим множество первых букв всех слов. Для каждой пары соседних слов в перестановке:

  1. Отбрасываем с конца первого слова буквы до тех пор, пока последняя буква не будет присутствовать в множестве первых букв. Такая буква всегда существует: в худшем случае это будет первая буква слова.
  2. Сравниваем последнюю оставшуюся букву первого слова с первой буквой второго слова.

Поскольку первые буквы — прописные, их надо преобразовать в строчные. Проще всего это делается для букв в кодировке ASCII или CP1251: нужно просто прибавить к коду буквы 32.

Итак, первый вариант программы:

#include <stdio.h>
#include <string.h>
 
int main(int argc, char** argv){
    int k, i, match, first[256];
    char *r, *permut[256];
    int maxidx;
 
    // обнуляем множество первых букв
    for (i = 0; i < 256; i++)
        first[i] = 0;
 
    // входная последовательность задана в командной строке, поэтому
    // пропускаем находящееся в начале массива argv имя программы
    argv++;
 
    // проходим по входным словам (argv завершается нулевым указателем)
    for (i = 0; argv[i]; i++) {
        permut[i] = argv[i]; // начальная перестановка равна исходной
        first[permut[i][0] + 32] = 1; // добавляем первую букву слова в множество
    }
    maxidx = i - 1; // максимальный индекс слова в последовательности
    k = maxidx;
    // когда match станет 1, допустимая последовательность найдена
    for (match = 0; !match;) {
        // циклический сдвиг элементов массива permut с 0 по k-1
        r = permut[k];
        memmove(permut + 1, permut, k*sizeof(char*));
        permut[0] = r;
        // см. алгоритм сдвига
        if (permut[k] == argv[k])
            k--;
        else
            k = maxidx;
        // проверка перестановки на допустимость
        for (i = 0, match = 1; i < maxidx - 1 && match; i++) {
            // получим указатель на последнюю букву первого слова
            r = permut[i] + strlen(permut[i]) - 1;
            // сдвигаем указатель, пока не найдем букву из множества первых букв
            while (!first[*r])
                r--;
            // проверяем совпадение
            match = (*r == *permut[i + 1] + 32);
        }
    }
 
    for (i = 0; i <= maxidx; ++i)
        printf("%s\n", permut[i]);
 
    return 0;
}

А дальше дело техники — надо сжимать программу:

  1. Всем переменным — кратчайшие имена.
  2. Убираем включения заголовочных файлов, оператор return (компилятор сам вставит), все упоминания типа int (это тип по умолчанию, его можно не указывать).
  3. Вспоминаем, что глобальные переменные инициализируются нулями.
  4. Заменим sizeof(char*) на 4 или 8, если компилируем, соответственно, на 32- или 64-разрядной системе.
  5. Используем argc в качестве одной из нужных нам переменных, например, maxidx.

Получим:

k, i, b, f[256];
char *r, *q[256];
main(int l, char** x){
    x++;
    for (i = 0; x[i]; i++) {
        q[i] = x[i];
        f[*q[i] + 32] = 1;
    }
    l = i - 1;
    k = l;
    for (; !b;) {
        r = q[k];
        memmove(q + 1, q, k*4);
        q[0] = r;
        if (q[k] == x[k])
            k--;
        else
            k = l;
        for (i = 0, b = 1; i < l - 1 && b; i++) {
            r = q[i] + strlen(q[i]) - 1;
            while (!f[*r])
                r--;
            b = (*r == *q[i + 1] + 32);
        }
    }
 
    for (i = 0; i <= l; ++i)
        printf("%s\n", q[i]);
}

Ну, и остается только

  1. Избавиться от strlen — указатель на последнюю букву можно найти и короче.
  2. Выполнить кое-какие перестановки присваиваний и инкрементов, использовать разыменование вместо индексации.
  3. Убрать пробелы и переводы строк.

Получим итоговый вариант (разбиение на строки оставлено для читабельности):

k,i,b,f[256];
char*r,*q[256];
main(int l,char**x){
    for(x++;q[k]=x[k];f[*q[k++]+32]++);
    for (l=--k;!b;){
        r=q[k];
        memmove(q+1,q,k*4);
        *q=r;
        k=q[k]==x[k]?k-1:l;
        for (i=0,b=1;i<l&&b;){
            for(r=q[i++];*r++;);
            while(!f[*--r]);
            b=*r==*q[i]+32);
        }
    }
 
    for (i = 0; i <= l; ++i)
        printf("%s\n", q[i]);
}

Вот и все. Должен только заметить, что даже если все допустимые буквы — последние, все равно время работы алгоритма будет что-то вроде $O(n\cdot n!)$, где $n$ — количество строк во входной последовательности. Время работы сильно зависит от порядка слов во входной последовательности и от порядка перебора. На моем компьютере на 14 словах алгоритм работал более 10 минут! Добавить 15-е слово я не решился. Так что с общечеловеческой точки зрения это очень плохой алгоритм.

Кстати, в предыдущем посте у меня в код закралась неиспользуемая переменная, что позволяет сократить решение еще на 2 символа.

УжасноПлохоНормальноХорошоОтлично (Еще не оценили)
Loading ... Loading ...

Code Golf: игра «в города» на C

Вчера наткнулся на давно закончившийся конкурс на написание самой короткой программы для игры «в города». Вкратце: программе подается на вход набор строк, а она их должна выстроить так, чтобы последняя буква i-ой строки совпадала с первой буквой i+1-ой строки. Если строка оканчивается на букву, с которой не начинается ни одна другая строка, разрешается «удалять» последнюю букву до тех пор, пока это условие не нарушится. Решение всегда есть. Самое главное: основная часть программы должна состоять из минимально возможного количества символов, ввод-вывод-подготовка не считаются.

Победило в конкурсе решение, кто бы сомневался, на Перле в 62 символа (и фиг с ним). Меня поразило решение на C, в котором вся программа состоит из 229 символов. На такое я спокойно смотреть не мог. Несколько часов работы — и получилось то же самое выразить в 225 символах! Давненько я не получал такого удовольствия от возни с кодом!

k,i,t,b,f[256];char*r,*q[256];main(int l,char**s){
for(s++;q[k]=s[k];f[*q[k++]+32]++);for(l=--k;!b;){r=q[k];memmove(q+1,q,k*8);*q=r;k=q[k]==s[k]?k-1:l;
for(i=0,b=1;i<l&&b;){for(r=q[i++];*r++;);while(!f[*--r]);b=*r==*q[i]+32;}}}

А если оставить только код, решающий задачу, то получится 174 символа:

for(s++;q[k]=s[k];f[*q[k++]+32]++);for(l=--k;!b;){
r=q[k];memmove(q+1,q,k*8);*q=r;k=q[k]==s[k]?k-1:l;for(i=0,b=1;i<l&&b;){
for(r=q[i++];*r++;);while(!f[*--r]);b=*r==*q[i]+32;}}

Переводы строк, как наверное многие догадались, перед подсчетом надо убрать.

Имена городов передаются в командной строке через пробел. Первая буква каждого имени должна быть прописная. Кодировка cp1251 (если русскими буквами) или ASCII. Компилировать gcc на 64-битной системе. Например:

$ ./cities Kaliningrad Vologda Dalmatovo Dmitrov Arkhangelsk Vladivistok Krakov
Dalmatovo
Vladivostok
Kaliningrad
Dmitrov
Vologda
Arkhangelsk
Krakov

Чтобы программа выводила результат, нужно вставить в main код вывода:

for (i = 0; i <= l; ++i)
        printf("%s\n", q[i]);

Если интересно, как это работает, напишу отдельным постом.

УжасноПлохоНормальноХорошоОтлично (2 голосов, средний: 3,00 из 5)
Loading ... Loading ...

Нахождение всех путей между двумя вершинами, матричный подход

В теории графов я не силен, как, впрочем, и во всем остальном тоже. Но наткнувшись на этот метод поиска всех простых путей между двумя вершинами в графе, я немало удивился. А когда убедился, что метод работает, удивился еще больше. Приведу здесь наглядное описание метода.

  1. Составляем структурную матрицу $A$ графа $G$. Элемент $a_{ij}$ матрицы содержит:
    • $0$, если в графе нет ребра $(i, j)$.
    • $1$, если $i = j$.
    • Имя булевской переменной, идентифицирующей ребро $(i, j)$. Если $i < j$, указывается сама переменная, а если $i > j$, то ее отрицание, например, $a_{ij}=x$ и $a_{ji}=\bar x$. Если между вершинами $i$ и $j$ несколько ребер, указывается их конъюнкция, например, $a_{ij}=x \wedge y$ и $a_{ji}=\bar x \vee \bar y$.
  2. Для нахождения всех простых путей между вершинами $i$ и $j$ необходимо найти дополнительный минор $\bar{M}^j_i$ (вычеркнуть $j$-ую строку и $i$-ый столбец и найти определитель оставшейся матрицы) по следующим правилам:
    • вместо сложения/вычитания используется дизъюнкция;
    • вместо умножения используется конъюнкция;
    • $1$ выступает в роли истинного значения, а $0$ — ложного;
    • $x \wedge \bar x$ уничтожаются, ну и вообще, можно выполнять обычные булевские преобразования.
  3. Получили булевское выражение в дизъюнктивной форме. Каждая конъюнктивная группа в нем соответствует маршруту между выбранными вершинами, а каждая переменная, входящая в группу, обозначает соответствующее ребро. Отрицания можно убрать, они показывают направление прохождения ребер.

Немного запутанно выглядит, да? Рассмотрим на конкретном примере. Возьмем полный граф с тремя вершинами. Ребрам сопоставим следующие переменные:

  • $(1, 2)$ — $x$;
  • $(1, 3)$ — $y$;
  • $(2, 3)$ — $z$.

Структурная матрица тогда получится такой:

$$
A=\left( \begin{array}{ccc}
1 && x && y\\
\bar x && 1 && z \\
\bar y && \bar z && 1
\end{array} \right)
$$

Найдем все маршруты между вершинами 1 и 3. Вычеркнув третью строку и первый столбец, найдем значение минора:

$$
\bar M^3_1 = \left| \begin{array}{cc}
x && y \\
1 && z
\end{array} \right| = (x \wedge z) \vee y
$$

Вот и получилось, что есть два маршрута, один через ребра $x$ и $z$, а второй — через ребро $y$.

Красивый хак, а? Что интересно, как я ни старался, не смог найти описание этого метода в англоязычных источниках. Может, кто-то видел подобное?

P.S. Включите JavaScript для отображения формул. Если не работает, пишите в комменты вместе с версией браузера.

УжасноПлохоНормальноХорошоОтлично (2 голосов, средний: 5,00 из 5)
Loading ... Loading ...

Обновление движка

Обновил движок блога, некоторое время могут быть глюки. Не обращайте внимания.