21 октомври 2008

Ситуирани агенти

"Ситуиран агент" е контролер, разположен в динамична, сложна и относително неструктурирана среда. Ситуирани агенти могат да бъдат хардуер или софтуер( или и двете). Софтуерът се включва почти винаги, а хардуер е необходим , ако средата е физична. В такива случаи хардуерът има ролята на интерфейс, въпреки че може и да участва в изчисленията. Типичните хардуерни ситуирани агенти включват подвижни роботи , автономни подводници, интелигентни стаи и други подобни.

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

По принцип агентите се опитват да постигнат няколко цели чрез взаимодействие с околната среда чрез техните сензори и манипулатори. Често целите са конкурентни една на друга и агентът трябва сам да решава конфликтите. Това е трудна задача. Сложната и динамична природа на средата определя ,че агентът:

  • Няма пълен контрол върху средата.
  • Няма капацитет за изграждане на пълен модел на средата.

  • Не притежава пълна информация за средата.

  • Не може напълно да вярва на информацията , която има, защото тя е неопределена, неточна, стара или съдържа шум .
Агентът трябва да действа последователно за постигане на целта си, неговите действия трябва го водят постепенно до целта ( например мобилен робот да достигне определено положение в средата). От друга страна агентът трябва да реагира адекватно на неочаквани събития, които се случват в средата( например ако се появи неочаквано препятствие по пътя на робота). Но обикновенно е невъзможно да се изградят правила за противоречиви цели като тези ако няма идеални познания и планиране. За разрешаване на този проблем дизайнерите на ситуирани системи трябва да имат инструменти за представяне на реактивни правила като последователни действия в тяхните планове.

Обикновенно ситуираните агенти нямат цялата информация, от която се нуждаят, затова те трябва да действат за събиране на информация( като противоположност на действия за достигане на първоначалните цели).

И на последно място използването на инструкции в зависимост от ситуацията включва проверяване дали прилагането на тези инструкции има смисъл ( заюитена интерпретация) и как те се вписват в текущата ситуация( контекстно зависима интерпретация).

Тези проблеми получиха необходимата доза интерес след изместването на фокуса от създаването на нетелесни мозъци( например шахматни програми) към създаването на по-предметни форми на интелект. Ефектът от тази промяна е, че някои области от ИИ се препокриват с апекти от програмирането на embed системи.

В много области се прилагат и тестват техники от дизайна на ситуираните агенти. Вероятно най- познатата област е тази на подвижните роботи. Марсоходът на NASA е най- обсъжданият (полуавтономен) ситуиран агент. Но има и много по-земни приложения- балансиране на натоварване на мрежа, интелигентни стаи, интелигентни изкуствени създания за виртуални светове и много други.

В тази статия ще покажа подход за програмиране на реактивни ситуирани агенти, който е азирана на паралелни функционални дървета с решения. В процеса на работа ще представя библиотеката на C++ "InSitu" и система за ситуирани агенти , които се справят с изложените по-горе проблеми и изисквания.

В моя подход при проблема за програмиране на реактивни ситуирани агенти първо създадох реактивна система на C++ и след това създадох C++ класове за добавяне на функционалност за последователност, активни възприятия и използване на ситуирани инструкции. Резултатът беше InSitu. Повечето среди за embed системи са проектирани по друг начин - те изпозват последователен програмен език ( като C++) и добавят нишки, прекъсвания и обработка на събития за достигане на реактивност .


Рене е доктор по компютърни науки в лабораторията по Изкуствен Интелект в Цюрихския университет, Швейцария.

15 октомври 2008

HMAC алгоритъм

Установяването на съобщенията е процедура, която позволява комуникиращите страни да проверят дали получените съобщения са автентични. Трябва да се провери дали не е променяно съдържаниео на съпбщението и дали източникът е автентичният. за извършване на проверка на съобщенията широко се използва техниката Message Authentication Code (MAC). Разновидност на MAC алгоритъма се използва като Интернет стандарт при голямо разнообразие от приложения - HMAC, съкратено от "Keyed-Hashing for Message Authentication."(хеширан ключ за достоверност на съобщенията). HMAC беше представен в "Keying Hash Functions for Message Authentication," от M. Bellare, R. Canetti и H. Krawczyk през 1996 година.

MAC

MAC алгоритмите включват използването на таен ключ за генериране на малък блок данни, познат като "message authentication code," , който се добавя към съобщението. Тази техника предполага, че двете комуникиращи страни, да ги наречем A и B, споделят таен ключ K. Когато A има съобщение за изпращане до B, пресмята кода за удостоверяване като функция от съобщението и ключа. Съобщението и кода се изпращат на получателя. Получателят прави същото изчисление за полученото съобщение, използвайки същият таен ключ и получава някакъв код. Полученият код се сравнява с пресметнатият.

Като предполагаме, че само изпращачът и получателят знаят тайния ключ, еднаквост на получения и изчисления код тогава:

1. Получателят е сигурен, че съобщението не е променяно. Ако някой злонамерено промени съобщението, но не промени кода изчисленият от получателя код ще се различава от получения. Но тъй като предполагаме, че никой атакуващ комуникацията не знае тайния ключ, той не може да промени кода, така че да отговаря на промененото съобщение.

2. Получателят е сигурен, че съобщението е от очаквания източник. Тъй като никой друг не знае тайния ключ никой друг не може да състави съобщение с правилен код.

3. Ако съобщението съдържа последователно число (каквито се използват при X.25, HDLC и TCP) получателят е сигурен че последователността е правилна, защити не може злонамерено да се промени числото за последоватесност.

Могат да се използват различни алгоритми за генериране на кода. Националното Бюро по Стандартите (NBS) в публикацията DES Mode of Operation (http://www.nist.gov/itl/div897/pubs/fip113.htm) препоръчва използването на DES алгоритъм. Алгоритъмът DES се използва за генериране на криптирана версия на съобщението и последните битове на криптирания текст се използват за код. Нормално се използват 16 или 32 бита за код. HMAC е по-ефективен и с нарастваща популярност алтернатива.

Описаният по-горе процес е подобен на криптиране. Има разлика е това, че алгоритъма за удостоверяване може да е необратим, както е необходимо при декриптиране. Това е така, защото поради математическите свойства на удостоверяващата функция, тя е по-трудно да бъде разбита от криптираща функция.

HMAC

В последните години се увеличи интереса в разработването на производни на MAC с криптографски хеш код като MD5, SHA-1 или RIPEMD-160. Мотивацията за това е:

  • Криптографските хеш функции по принцип се изпълняват по-бързо отколкото симетричните шифри като DES.
  • Библиотеките с код за криптографските хеш функции са широко разпространени.

  • Няма рестрикции за криптографските хеш функции, докато симетричните шифри са забранени да се ползват дори и за MAC.

Хеш функции като MD5 не са създадени за използването им като MAC и не могат да се използват пряко за тази цел, защото те не използват таен ключ. Имаше различни предложения за вмъкване на таен ключ в съществуващите хеш алгоритми. Най- голяма подкрепа получи HMAC. HMAC беше избран като задължителен за IP Security и се използва в други Интернет протоколи, като Transport Layer Security (TLS, скоро заменен от Secure Sockets Layer) и Secure Electronic Transaction (SET).

HMAC- Цел при Дизайн

Целите на дизайна, които RFC 2104 задава за HMAC съдържат:

  • Да се използват без промяна съществуващите хеш функции. В частност това са хеш функции, които са добре представени в софтуера, чиито код е свободен и широко разпространен.
  • Да позволява лесна замяна на вградената хеш функция в случай на откриване или налагане на по-бърза или по-сигурна хеш функция.

  • Да запази първоначалната производителност на хеш функцията, без да я намалява забележително.

  • Да използва и обработва по прост начин ключовете.

  • Да има добре разбран криптографски анализ на здравината на удостоверителния механизъм, базиран на обосновани предположения за вградената хеш функция.

Първите две изисквания са важни за приемливостта на HMAC. За HMAC хеш функцията е черна кутия. Това има две ползи. Първата е , че съществуваща реализация на хеш функция може да се използва като модул при реализиране на HMAC. Основата на HMAC кода е предварително пакетирана и готова за употреба без изменения. Втората е , че замяната на дадена хеш функция в реализация на HMAC е просто премахване на текущия модул и слагане на новия. Това може да се направи ако се изисква по-бърза функция. Важно е , че ако сигурността на вградената хеш функция е разбита, сигурността на HMAC моеже да се възстанови чрез проста замяна на вградената хеш функция с по-сигурна(например замяна на MD5 с SHA-1).

Последната цел в по-горния списък е всъщност главното предимство на HMAC спрямо останалите предложени хеш-базирани схеми. HMAC може да се докаже като сигурен, ако вградената хеш функция има обоснована криптографска здравина.

Алгоритъмът HMAC

Където

H- вградена хеш функция;M- съобщението( включително отместването, заради хеш функцията) ; Yi- i-тия блок от M ; К+- допълнен с нули отляво до достигане на дължината на b; ipad- 00110110( 36 шестнадесетично) повторено b/8 пъти; opad- 01011100( 5C шестнадесетично ) повторено b/8 пъти.


HMAC може да се представи чрез израз


С други думи:

1. Добавяме нули отляво на K до достигането на низ с дължина b K+ (например ако K е с дължина 160 бита и b = 512 K ще се допълни с 44 нулеви байта 0x00).

2. XOR (побитово зключващо или OR) K+ с ipad за генериране на b битов блок Si.

3. Добавяме M към Si.

4. Прилагаме H върху потока, генериран в стъпка 3.

5. XOR K+ с opad за генериране на b битов блок So.

6. Добавяме хеш резултатът от Стъпка 4 към So.

7. Прилагае H върху потока, генериран в стъпка 6 и извеждаме резулатът.

Забележете , че XOR с ipad дава като резултат промяната на половината битове на K. По същата логика XOR с opad дава като резултат промяна на половината от битовете на K,но на различни битове. В крайна сметка с преминаването на Si и So през компресиращата функция на хеш алгоритъма ще имаме два псевдослучайни ключа от K. При дълги съобщения HMAC трябва да се изпълни за приблизително същото време като вградената хеш функция.



Уйлям е консултант, лектор и автор на книги за комуникации и компютърни мрежи.

14 октомври 2008

Симулирана рекурсия

В началото на 60-те C.A. Hoare разработи метода за сортиране Quicksort. Quicksort разделя данните в последователно намаляващи групи. Само по себе си това не звучи кой знае как, но тестовете показват, че Quicksort може да сортира големи обеми от данни десетки или стотици път по-бързо от повечето често използвани алгоритми. Но има един проблем.

Quicksort използва рекурсия- процес, в който процедура или функция извиква сама себе си. Рекурсията е много мощна при решаване на проблеми, при които се повтарят едни и същи въпроси и се изпълняват едни и същи действия, но всеки действие се прилага върху резултатите от предното. Разглеждането на структури от данни като свързани списъци и дървета са идеални за рекурсивно програмиране. В езици като C или Pascal е позволена рекурсията, но в други като Cobol по принцип е забранена. Заобикаляне на ограниченията на езика може да се постигне чрез симулиране на рекурсия, по начин, който може да се приложи към почти всички програмни езици.

Рекурсивната програма разглежда и действа с данни, докато не достигне до точка, в която логиката предполага, че имаме нов набор данни, които да се обработят от ново копие на програмата, която се изпълнява. В тази точка програмата се извиква сама себе си . По принцип при това извикване се предават новите данни, които трябва д асе обработят от следващата итерация. Постепенно се стига до точка, в която логиката казва, че няма повече нужда от рекурсия и активната версия от кода стига до края, след това се връща изпълнението на инструкцията, която е след извикването на рекурсивния модул. След приключването си всяко копие на кода предава информация обратно до извикалото го копие. Когато контрола се предаде на оригиналното копие и то изпълни до края инструкциите си приключва рекурсивният процес.


Симулиране на рекурсия

Знаейки начина, по който рекурсията предава данни надолу и нагоре през модулите, може да изолираме и запазим уникални за всяка рекурсивен стъпка променливи и просто да повтаряме даден код, за да симулираме рекурсия. Тъй като повтарянето започва от едно и също място в кода всеки път трябва да имаме маркер, който да показва откъде да започне текущата итерация.

Данните, използвани от всяка итеративна стъпка могат да са във всякаква форма и са функция от програмните изисквания. Вероятно най-добрата форма за маркер е двоичен ключ. Комбинацията от необходимите данни и маркера формират "снимка" на състоянието по време ня всяка стъпка от рекурсията. Трябва да създадем таблица, в която да записваме тези снимки. Указател към този стек от снимки ще позволи на програмата да избира подходящ набор от стойности за конкретната итерация на рекурсивния процес. Дълбочината на стека трябва да бъде достатъчна за побирането на всички стъпки , необходими за изпълнението на програмата.

Пример за Quicksort чрез симулирана рекурсия

Примерът, който ще представя тук демонстрира симулирана рекурсия в Cobol. Това е най-враждебната среда за рекурсивно програмиране. Идеята е "Ако може да се направи тук, може навсякъде". Данните за Quicksort ще изглеждат:

01  TABLE-TO-BE-SORTED.
02 TABLE-ENTRY PIC X(100) OCCURS 32000 TIMES.
*
01 SWAP-ELEMENT PIC X(100).
01 PIVOT-VALUE PIC X(100).
*
01 WORKING-INDEXES.
02 FURST PIC 9(5) USAGE BINARY.
02 LAAST PIC 9(5) USAGE BINARY.
02 PIVOT PIC 9(5) USAGE BINARY.
02 HOW-RETURN PIC 9(5) USAGE BINARY.
*
01 TABLE-OF-INDEXES.
02 CURRENT-INDEXES OCCURS 100 TIMES.
03 FILLER PIC 9(5) USAGE BINARY.
03 FILLER PIC 9(5) USAGE BINARY.
03 FILLER PIC 9(5) USAGE BINARY.
03 FILLER PIC 9(5) USAGE BINARY.
01 SESSION-INDEX PIC 9(5) USAGE BINARY.
*
01 LEFT-INDEX PIC 9(5) USAGE BINARY.
01 RIGHT-INDEX PIC 9(5) USAGE BINARY.

Тук таблица от 32,000 100-байтови елемента е данните,които ще се сортират. SWAP-ELEMENT и PIVOT-VALUE са работни области, които ще съдържат елементи от време на време. WORKING-INDEXES сочи към елементите на таблицата и са целочислени . FURST и LAAST са указатели към първия и последния елемент от таблицата в текущата група. Размерът и положението на тази група се променя постоянно по време на процеса Quicksort.

HOW-RETURN е маркерът, позволяващ на програмата да започне изпълнението от точното място при влизане в повтарящия се код. Концепцията на маркера е критична за възможността фиксиран повтарящ се код да емулира рекурсивен процес

TABLE-OF-INDEXES е стек от WORKING-INDEXES. При напредване на програмата надолу (извикване на нова итерация на QUICKSORT) или нагоре( връщане към предната итерация) се записват или извличат стойностите на указателите и маркера. Всеки елемент в TABLE-OF-INDEXES е набор от WORKING-INDEXES. SESSION-INDEX е указател към набора от стойности, който се използва в момента. LEFT и RIGHT-INDEX са работни указатели, които се използват във всяка фаза на Quicksort.

Инициализирането е:

INITIALIZE-SORT.

MOVE LOW-VALUES TO TABLE-OF-INDEXES.
MOVE 1 TO FURST
HOW-RETURN
SESSION-INDEX.
MOVE 32000 TO LAAST.
MOVE WORKING-INDEXES TO
CURRENT-INDEXES (SESSION-INDEX).

Задаваме на стека на работните указатели нули. След това се инициализира набор от работни указатели, като указателят FURST сочи първият елемент от данните, а LAAST сочи последния. Задаването на 1 за HOW-RETURN показва, че това е нова итерация на кода на Quicksort и трябва за започнем от началото. SESSION-INDEX се задава 1 (това е първият индекс) и данните се зареждат в стека.

Кодът за всяка итерация е :

CALL-QUICKSORT.
MOVE CURRENT-INDEXES (SESSION-INDEX) TO
WORKING-INDEXES.
IF HOW-RETURN = 2 GO TO DO-PIVOT-TO-LAST.
IF HOW-RETURN = 3 GO TO SEE-IF-WE-ARE-ALL-DONE.
IF FURST NOT <>

Първо извлича данни от стека и ги слага в WORKING-INDEXES. Стойността на SESSION-INDEX определя кои данни да се извлекат. Контрола върху повтарящия се код се извършва чрез стойностите на SESSION-INDEX и HOW-RETURN .

Програмната логика е :

SPLIT-THE-LIST.
MOVE TABLE-ENTRY (FURST) TO PIVOT-VALUE.
MOVE FURST TO LEFT-INDEX.
COMPUTE RIGHT-INDEX = LAAST + 1.
PERFORM WITH TEST AFTER UNTIL
RIGHT-INDEX <= LEFT-INDEX PERFORM WITH TEST AFTER UNTIL TABLE-ENTRY (LEFT-INDEX) >= PIVOT-VALUE
ADD 1 TO LEFT-INDEX
END-PERFORM
PERFORM WITH TEST AFTER UNTIL
TABLE-ENTRY (RIGHT-INDEX) <= PIVOT-VALUE SUBTRACT 1 FROM RIGHT-INDEX END-PERFORM IF LEFT-INDEX <>

проверява специфична група от таблични елементи. Въпреки че разглежданите елементи се менят постоянно, процесът е съвсем същият и имаме рекурсивна обработка.

Програмната логика взема първият елемент за централна точка, с която се сравняват останалите елементи. Те се проверяват от ляво на дясно и от дясно наляво и се разменят елементи, където е необходимо. Крайният резултат е ,че централният елемент е преместен и разделя групата на две по-малки групи , в които елементите отляво на централния са по-малки от него, а всички елементи отдясно са по-големи. Това води до две нови, по-малки групи, които се обработват по същия начин.

Подготвянето на Quicksort за разглеждане на лявата и дясната част е :

DO-FIRST-TO-PIVOT.
MOVE 2 TO HOW-RETURN.
MOVE WORKING-INDEXES TO
CURRENT-INDEXES (SESSION-INDEX).
COMPUTE LAAST = PIVOT - 1.
MOVE 1 TO HOW-RETURN.
ADD 1 TO SESSION-INDEX.
MOVE WORKING-INDEXES TO
CURRENT-INDEXES (SESSION-INDEX).
GO TO CALL-QUICKSORT.

*
DO-PIVOT-TO-LAST.
MOVE 3 TO HOW-RETURN.
MOVE WORKING-INDEXES TO
CURRENT-INDEXES (SESSION-INDEX).
COMPUTE FURST = PIVOT + 1.
MOVE 1 TO HOW-RETURN.
ADD 1 TO SESSION-INDEX.
MOVE WORKING-INDEXES TO
CURRENT-INDEXES (SESSION-INDEX).
GO TO CALL-QUICKSORT.

Когато се зададат стойностите на индексите програмата просто извиква Quicksort отново. След като Quicksort обработи и двата низа и върне контрола на тази итерация от кода, тя проверява дали е първата. Ако е така сортирането е приключило. Ако не е се намаля с едно указателят за сесийните данни и когато се извика отново Quicksort контролът се вруща на предната итерация.

SEE-IF-WE-ARE-ALL-DONE.
SUBTRACT 1 FROM SESSION-INDEX.
IF SESSION-INDEX > 0
GO TO CALL-QUICKSORT.
STOP RUN.

Каква е разликата между типичното сортиране, където всеки елемент се сравнява с всички останали, и рекурсивният Quicksort? При големи обеми от данни разликите при сортирането за забележителни. Рекурсивният Quicksort може да спести минути или дори часове, когато се сортират големи обеми от данни. Рекурсивния процес е много мощен и има много употреби в математическия анализ. Quicksort е само едно от приложенията на този тип програмна логика.

Заключение

Този пример показва употребата на симулиран рекурсивен Quicksort в Cobol, но този процес може да бъде извършен в много други езици като C и Pascal. Използването на рекурсивен Quicksort може дмного да намали времето за сортиране на големи обеми от данни, което е полезно, независимо от програмния език.


Earl е старши консултант в Keane Inc., фирма за ИТ консултации в Бостън

13 октомври 2008

Натрупващо построяване на алгоритми

Интуитивно при разглеждане на алгоритмичен проблем се опитваме да мислим за всички входни данни и да намерим решение за тях. Носим със себе си интуицията в истинския свят - когато трябва да се реши проблем, той трябва да се реши нацяло. Натрупващият метод е различен. При него се предполага, че проблема вече е решен и се налага да се допълни решението за малко по-голям проблем. Ако може да се намери общ начин за допълване на решението може да се започне с нулеви входни данни, които постепенно да се увеличават. Концентрирането върху нарастването често е по-лено и води до мощни техники за решаване на проблеми.

Нарекох този метод "индуктивен метод" в моята книга Introduction to Algorithms: A Creative Approach (Addison-Wesley, 1989), защото използва същият принцип като математическата индукция. След това установих, че индукцията обърква много хора при използването й в този контекст, затова тук се нарича "натрупващметод" , което е по-ясно и описателно име.

Проблемът за максималния правоъгълник , описан превъзходно от David Vandevoorde е идеален пример за илюстриране на натрупващия метод. Натрупващият метод не е перфектен и не винаги е най-добрият, но е приложим в много случаи и определено е техника, която трябва да присъства в арсенала на всеки дизайнер на алгоритми.

Проблемът за максималния правоъгълник

Проблемът за максималния правоъгълник може да бъде обобщен като:

Вход: Двоична матрица N×M B.

Изход: правоъгълник в B с максимална площ, обхващащ само единици

Първият проблем при натрупващия метод е определянето на натрупването. При матрици няма голям избор: или ще добавяме нов ред или нова колона (По късно ще виидм, че това не винаги е вярно). Нека добавим нов ред. Предполагаме, че сме намерили най-големият правоъгълник в първоначалната матрица и сега имаме един допълнителен ред. Трябва да определим дали има въобще по-голям правоъгълник с новия ред. Това ограничава търсенето. Трябва да се имат предвид само правоъгълници, завършващи на последния ред. По тази логика изключваме всички нули в последния ред. Това означава, че може да разделим проблема на няколко подпроблеми чрез разглеждане само на интервалите в последния ред, коит осъдържат само единици

Подпроблемите са независими един от друг, така че трябва да разгледаме само един от тях . Същото решение ще се приложи и за останалите.

Гледайки всеки подпроблем, сега търсим най-големият правоъгълник, който завършва на последния ред, за който знаем че има само единици. За целта трябва да знаем за всяка колона i дължината на долния блок от единици, да го наречем Ci. Тази информация лесно се намира при добавянето на ред- за всяка колона или увеличаваме дължината на блока ( ако последния ред съдържа 1) или се прави 0 (ако съдържа 0). Разделянето на подпроблеми е използвано смао за простота- лесно може да се смята целият ред(с нулите) за един проблем. След тази информация проблемът може да се определи като едноразмерен:

Вход: поредица от цели числа, C1, C2, ..., Ck.

Изход: интервалът [i,j] , в който е максимално произведението на дължината на интервалът (j-i) и минималното число в него.

(Лицето на правоъгълника от единици отговаря на това произведение : ширината на правоъгълника отговаря на дължината на интервала,а височината му на най-малкото число в него).

Натрупващият метод ни позлволи да намалим двуизмерният проблем в едноизмерен. Ако искаме да намерим оптимално репение O(MN) за първоначалния проблем трябва да решим този едноизмерен проблем за линейно време O(k). Използвайки натрупващият метод отново, предполагаме че знаем как да решим проблема при k числа и трябва да изследваме k+1 число, да го наречем Ck+1. Ако е успешно това ще намали проблема още един път и ще ни позволи да се концентрираме върху само едно число.

Трябва да се концентрираме само върху интервали, които завършват в Ck и проверяваме как ги променя Ck+1 . Ако Ck+1>=Ck най-донрият интервал който завършва в Ck може да бъде удължен и да завършва в Ck+1 без промяна на неговото минимално число, така че той е най-добрият възможен интервал. Но ако Ck+1<Ck неможе лесно да се определи най-добрият интервал.


Без Ck+1=3 най-добрият интервал (2a) се състои от последните две числа имащи произведение min{6,5}×2=10. Увеличаването на този интервал с числото 3 (2b) дава min{6,5,3}×3=9, което не е най-доброто. Но тъй като най-малкото число сега е 3 може да използваме по-голям интервал (2c), имащ произведение min{3,6,5,3}×4=12. Ако увеличим на цял ред (2d) ще получим произведение min{4,2,3,6,5,3}×6=12. Няма лесен начин за определяне само от гледане на Ck+1 и най-добрият интервал до Ck. Това изглежда като провал за натрупващия метод, защото само концентрирането върху Ck+1 не изглежда достатъчно.

Но натрупващият метод може да бъде приложен по много начини и затова е толкова мощен. Натрупването на по едно число в края на поредицата е естествен, но не единстен начин за натрупване. Може би вместо при натрупване да гледаме последното число Ck+1 трябва да гледаме първото число C1? В този случай първото число има съвсем същият ефект както последното, така че това няма да помогне. Но защо да не използваме най-малкото число в интервала или най-голямото? Получава се ,че използването на най-малкото или най-голямото число води до два различни оптимални алгоритъма.

Да започнем с най-малкото число, да го наречем m. За простота ще използвам m за стойността и за променливата. Как може m да влияе на решението ? Ако най-добрият интервал съдържа m, то този интервал съдържа всичкo. С други думи ако m е в най-добрия интервал, то тогава този интервал е цялата поредица. Запомняме това и проверяваме алтернативите. Ако m не се съдържа в най-добрия интервал, тогава този интервал е отстрани на m или отляво или отдясно. Сега разделяме проблема на два независими подпроблема, всеки от който може да се реши рекурсивно, така че проблема е решен. Например все виждат последователно избраните интервали отгоре надолу 2, 3, 4, 5 и 6. Получаването на реализация на линейно време на това решение не е очевидно, но има надежда , тъй като обработката на m е за константно време.

Да започнем натрупващият метод при отчитане на максималното число, да го наречем M. Това се явява малко по-хитро, но води до по-лесен за реализация алгоритъм. Отново - как M може да влияе н арешението? Ако M е част от интервалът има две възможности. Интервалът се състои само от M ,което дава лице M×1 = M или интервалът включва и някой от съседните на M числа. Във втория случай M няма да допринася повече отколкото неговият съсед. Така ако можем да открием подходящият съсед на М , можем да му дадем приноса на М, като определим, че съседът му има двоен принос и не гледаме повече М. Ако може да правим това безпроблемно имаме натрупващо решение, защото може да обработваме по едно максимално число .

Може да направим брояч за всяко число, който да показва колко от неговите съседни трябва да се смятат, когато това число се използва за най-добрия интервал. След това премахваме М и увеличаваме броячът за някой от съседните му (тайната кое съседно число да изберем ще бъде разкрита след малко). Всъщност какво ще стане когато се гледа най-малкото число в интервал - всички негови по-големи съседи са били рагледани преди това и имат собствени броячи. Така най-малкото число ще знае колко е голям интервалът му.

Да предположим, че числата са са в намляващ ред започвайки от ляво. Алгоритъмът ще вземе C1 като първи правоъгълник , след това броячът на C2 се увеличава на 2. На втората стъпка се брои два пъти C2 (защото неговият брояч е 2) и неговият правоъгълник е 2×C2. Проверяваме дали това е повече ор предишния най-добър правоъгълник (C1) и вземаме по-големият от тях. След това задаваме брояч на C3 , който става 3 и има лице 3×C3 и така нататък. Това е най-простият случай и по принцип алгоритъмът работи добре.

Кой съсед да използваме - по-големият или по-малкият? Отговорът е по-големият, защото е по-безпроблемният избор. Всеки интервал, който съдържа някой от съседите на М ще съдържа поне М и по-големият съсед (може да съдържа и двата съседа). Чрез добавянето на приноса на M към по-големия съсед ни дава сигурност, че М се дава принос към всеки интервал, който го съдържа.Пример за подобно смятане е :


Приносът на 6 е 1×6. Второто по големина е 5, чиито брояч сега е 2, така че има принос 2×5. Следващото е 4, което дава своя принос на съседа си 2. Следващото е 3 (Аз избрах дясното 3, но без проблеми може и да е лявото), което има принос 3×3. Когато се разглежда другото 3 броячът е 4, така че лицето става 4×3=12. Последното е 2, чиито брояч става 6 и води до друго най-добро решение 12.

Двата предложени алгоритъма не са пълни. Трябва внимателно да се изберат структури от данни, които извършват ефективно всички операции.Дяволът е в детайлите. Но детайлите са по-лесни след цялостното схващане на алгоритъма, каквото направихме сега. Една от силните страни на натрупващия метод е в лесното намиране на алгоритмична идея.

Алгоритъмът показан по-горе се сътои от цхетири стъпки, които се повтарят за всяко число:

1. Намираме следващото по големина M.

2. Проверяваме текущото лице спрямо предичното най-добро.

3. Увеличаваме броячът на по-големия съсед на M.

4. Изтриваме M.

За да получим списъкът с максиммумите , трябва да сортираме числата. За радост всички числа са от 1 до N (тъй като те са броя на единиците в колона) и можем да ги сортираме за линейно време използвайки прост метод за сортиране. Намирането на съседните числа може да стане чрез използване на двойно свързан списък, който позволява изтриването на число. Всички тези операции могат да се извършат за линейно време. (Алгоритъмът може да се опрости допълнително след забележката, че сортирането не е фатално)

Заключение

Натрупващият метод е полезн начин за откриване на нови алгоритми. Той може да доведе и до нови начини за решение. Но не е магическа пръчка и не може да замени интуицията и опита, само помага те да се развият.


Уди е професор по компютърни науки в Аризонския университет.

10 октомври 2008

Регулярни изрази

Правилният програмен език може да направи голяма разлика в трудността н аписане на програма.Затова в арсенала на програмиста съдържа не само езици с общо предназначение като C и сродните му, но и shell програмиране, скриптови езици и множество зависими от приложението езици.

Силата на доброто записване надминава традиционното програмиране и стига до специфични проблеми домейни. HTML ни позволява да създаваме интерактивни документи, често използвайки вградени програми на други езици като JavaScript. PostScript изразява цял документ като специализиран програма. Електронните таблици и текстовите редактори често съдържат езици за изпълняване на изрази, достъп до информация и контрол.

Регулярните изрази за един от най-широко приложимите специализирани езици, компактен и ефективен начин за описание на текстови шаблони. Регулярните изрази са алгоритмично интересни, лесни за реализация в по-простите им форми и много полезни.

Пегулярните изрази са представяни по различни начини. Прост пример са така наречените "wildcards", които се използват в конзолите за съставяне на шаблони за имена на файлове. По принцип "*" означава "произволен низ". Например

del *.exe

използва шаблон "*.exe" ,който съвпада с всички файлове, чиито имена съдържат какъв да е низ, следван от низа ".exe".

Регулярните изрази са навсяйъде в UNIX - в редактори, инструменти като grep и скриптови езици като Awk, Perl и Tcl. Въпреки че от вариациите в различните програми може да се предположи, че регулярните изрази са ад хок механизъм, те всъщност са език със силно техническо чувство - неговата структура се определя от формална граматика и всеки израз има точно определено значение. Като допълнение - правилната реализация може да върви много бързо, комбинацията от теория и инженерна практика дава много добри резултати.

Езикът на регулярните изрази

Регулярен израз е поредица от символи, които определят шаблон. Повечето символи в шаблона просто се съпоставят на тях самите в низ, например регулярния израз "abc" съвпада с поредица от три символа, където и да е в проверявания низ. Няколко символа се изпозват като метасимволи за означаване на повторение, групиране или позициониране. В регулярни изрази POSIX "^" означава началото на низа, "$" е краят, например "^x" съвпада с "x" само в началото на низ "x$" съвпада с "x" само в края, "^x$" съвпада с "x" само ако е единствен символ в низа и "^$" е празен низ.

Символът "." (точка) съвпада с всеки символ, например "x.y" съвпада с "xay," "x2y," и така нататък но не на "xy" или "xyxy." Регулярният израз "^.$" съвпдада с низ, който съдържа който и да е единичен символ.

Сбор ор символи в скоби "[]" съвпада с всеки един от затворените в скоби символи, например "[0123456789]" съвпада с цифра. Този шаблон може да бъде записан кратко "[0-9]."

Тези градивни елементи могат да се комбинират със скоби за групиране, "|" за алтернатива, "*" нула или повече срещания, "+" за едни или повече срещания и "?" за нула или едни срещания. Последният символ "\" се използва като префикс за премахване на специалното значение на метасимволите.

Тези символи могат да бъдат комбинирани в забележително големи шаблони. Например "\.[0-9]+" съвпада с точка, следвана от една или повече цифри; "[0-9]+\.?[0-9]*" съвпада с една или повече цифри, следвани от незадължителна точка и нула или повече цифри; "(\+|-)" съвпада с плюс или минус ("\+" е символът за занк плюс) и "[eE](\+|-)?[0-9]+" съвпада с "e" или "E" , следвано от незадължителен знак и една или повече цифри. След комбинирането им се получава шаблон за числа с плаваща запетая:

(\+|-)?([0-9]+\.?[0-9]*|\.[0-9]+)([eE](\+|-)?[0-9]+)?

09 октомври 2008

Бързо и лесно заделяне на място

Въведение

Всеки знае за димачното заделяне на паметта. Никое сериозно приложение не може без този механизъм - директно или индиректно. Неизбежно подразбиращата се имплементация на динамично заделяне на паметта трябва да бъде много опростена и генерална, за да се покрие широк спектър от възможни вариации. За тази всеобхватност често се заплаща с времето за заделяне/освобождаване на памет или заделяне на излишна памет.

Някои очевидни примери са имплементациите на заделяне на памет в стандартната C/C++ библиотека и бързата библиотека , която има оригиналната дистрибуция BSD 4.2 . Първата е относително икономична и мнооого бавна, докато втората е по-бърза, но консумира средно 50 процента повече памет от стандартната. Неефективното използване на паметта се получава от факта, че библиотеката заделя фиксирани по големина блокове памет(Всеки следващ е два пъти по-голям от предишния). Ако има заявка за блок от памет малко по-голям от най-близкия възможен размер ще се задели почти два ъти повече от заявката.

C++ ни позволява да си напишем собствен ефективен алокатор на памет, а след това да го интегрираме невидимо в класовете, които правим. За съжаление много програмисти/практици са прекалено обхжанати от всекидневната разработка, за да мислят за подобни проблеми като оптимизация на производителността и използването на паметта, преди да е станало прекалено късно за интегриране на подобрения в съществуващия дизайн. Попадайки неведнъж в подобна ситуация реших да отделя няколко нощи, за да напиша практичен и лесен за употреба класс за заделяне на памет. Моите цели включваха:

  • оптимизация на механизма на заделяне/освобождаване на памет,
  • простота на употреба и интеграция с други класове,
  • минимално използване на излишна статична и операционна памет, и
  • възможност за проверка на цялосста на заделените блокове с памет.

Имам чувството, че останалите програмисти ще има полза от тази имплементация.

Основната идея не е нова. тя е обяснена в доста списания и книги. Стартовата точка за оптимизация е предположението,че повечето заделяния на памет са свързани с инстанции на клас. Това означава,че блокове с еднакъв размер се заделят и освонождават отново и отново. Този размер е размерът на инстанцията. Тъй като знаем размера можем да заделим достаъчно големи блокове, които да съдържат повече от една инстанция и да поддържаме пуул от освободени блокове, за да ги използваме по-късно пак, вместо постоянно да ги освобождаваме.

Нищо ново. Но след като пробвах предварителната имплементация на идеята бях изумен от откритието, че тя работеше единадесет(!) пъти по-бързо от стандартното заделяне. В крайна сметка, с помощта на малко подозрителни операции успях да подобря производителността с десет до 20 пъти, в зависимост от шаблона за заделяне и опците на компилатора.

Примерна употреба

Освен за производителността доста помислих и за простотата на употреба и интегриране в останалите класове. Алокаторите са единствено поддържащи класове и ,което е важно, те играят второстепенна роля в разработката на софтуер. Порати това интерфейса на класа алокатор трябва да е минимален, а капсулацията максимална. Например:

class Foo
{
public: // the Foo specific stuff

void* operator new (size_t)
{ return _allocator.allocate(); }
void operator delete (void* storage)
{_allocator.deallocate(storage); }

private: // more of the Foo stuff
static TheAllocator _allocator;
};

TheAllocator Foo::_allocator;
операторите new и delete на класа Foo са предефинирани, за да се замени заделянето на памет по подразбиране. Целият нов механизъм за заделяне е напълно капсулиран в класа TheAllocator. Непретенциозния интерфейс - allocate() и deallocate(void*) - е достатъчен. Инстанция от класа TheAllocator _allocator е свързана с класа Foo чрез статична декларация в класа. Алокатор с подобен интерфейс може да се включи за секунди и е много трудно да се сбърка. Като допълнителна опция е осигурен прост дебъгер, който се извиква чрез допълнителен параметър на конструктора на TheAllocator . Не някакво чудо, но може да е полезен в началото на разработката при преглеждане на цялостта на заделената памет. Просто променете статичното инициализиране от :
TheAllocator Foo::_allocator;
в:
TheAllocator Foo::_allocator(true);
и всяко парче памет ще бъде тествано преди да се освободи. Ако парчето е повредено по някакъв начин( презаписано е началото или в края) ще се появи изключение. За да обработите изключението може да промените оператора delete :
void operator delete (void* storage)
{
try {
_allocator.deallocate(storage);
}
catch(char*) {
// Do something about it.
}
};
Подобна проста помощ не може да се сравни с продуктите за дебъг на паметта като Purify и подобните му, но го има и може да го използвате. Струва си да се спомене, че премахването на кода за проверка на цялостта на паметта подобрява производителността с осем процента.

Имплементацията

Имплементацията се състои от два класа - MemoryAllocator и TheAllocator. Функционалността лесно може да бъде реализирана и само с един клас. Самодостатъчният, основен и безтипов MemoryAllocator реализира целият механизъм без да има нужда от шаблонни методиTheAllocator осигурява по-добър интерфейс за класа. Когато създаваме инстанция от класа подаваме размера на паметта за обекта, която да бъде заделена(unit_size) и броят на обектите, които да се заделят на един път (block_size). Останалото е почти автоматично - всяко извикване на allocate() връща парче от памет с размер unit_size. Методът взема първата налична свободна памет от списък със свободни. Неговата единствена допълнителна работа е да поиска повече свободни парчета, ако списъкът е празен. Методът deallocate(void*) е прост и бърз като allocate(). Той прави обратното - слага блост свободна памет в листа със свободни за бъдещо използване. Не е чудно , че алокаторът е бърз - методите не правят много. Само методите allocate() и deallocate(void*) са inline, поради производителността, но дори и те може да не са такива. Имплементацията на по-малко използваните функции бе преместена от хедърен файл в сорс файл за намаляване на inline функциите. Не забелязах намаляне на производителността. Кода на тези функции е :
#include "include/allocmem.h"

#define TRAILING_ 0x55555555
#define HEADING_ 0x55555555

MemoryAllocator::MemoryAllocator(uint unit_size, uint block_size)
: _allocated_size (max(unit_size, sizeof(Free))),
_requested_size(unit_size),
_block_size(block_size),
_num_allocated_blocks(0),
_allocated_blocks(0),
_free(0),
_debug (false)
{}

MemoryAllocator::MemoryAllocator(uint unit_size, uint block_size,
bool debug)
: _allocated_size(max(unit_size, sizeof(Free))),
_requested_size(unit_size),
_block_size(block_size),
_num_allocated_blocks(0),
_allocated_blocks(0),
_free(0),
_debug(debug)
{
if (_debug)
_allocated_size += 2 * sizeof(int);
}

MemoryAllocator::~MemoryAllocator()
{
for (int k = 0; k < _num_allocated_blocks; ++k)
{
::operator delete(_allocated_blocks[k]);
}
::operator delete(_allocated_blocks);
}

void
MemoryAllocator::more() throw(char*)
{
Free* new_block = (Free*)
::operator new(_allocated_size *_block_size);
void** new_blocks = (void**) ::operator new(sizeof(void*)
* (_num_allocated_blocks + 1));
int last_element = _block_size - 1;
if (!new_block || !new_blocks)
throw("Memory allocation failed.");
if (_allocated_blocks)
{
memcpy(new_blocks, _allocated_blocks, sizeof(void*)
* _num_allocated_blocks);
::operator delete(_allocated_blocks);
}
_allocated_blocks = new_blocks;
_allocated_blocks[_num_allocated_blocks++] = new_block;
_free = new_block;

for (int k = 0; knext)
{
new_block->next = (Free*) ((char*) new_block
+ _allocated_size);
}

new_block->next = 0;
}

void*
MemoryAllocator::debug_correct(void*& storage)
{
*(int*) storage = HEADING_;
storage = (int*) storage + 1;
*(int*) ((char*) storage + _requested_size) = TRAILING_;
return storage;
}

void*
MemoryAllocator::debug_check(void*& storage) throw(char*)
{
int* tail = (int*) ((char*) storage + _requested_size);
int* head = (int*) (storage = (int*) storage - 1);

if (*tail != TRAILING_)
throw("Block tail has been overrun.");
if (*head != HEADING_)
throw("Block header has been overrun.");
return storage;
}
//End of File
Както е отбелязано по-горе, когато даден блок от памет не се използва той се добавя в списъка със свободни. Мястото на обект,който се освобождава се използва за запазване на указател на следващия свободен обект. Указателят се записва в началото на текущото паряе . Това означава, че размера на блока не може да е по-малък от размера на указател. Това изискване се изпълнява в конструктора на MemoryAllocator, в който размера на блока памет е:
_allocated_size = max(unit_size, sizeof(Free))
който може да е различен от реално поисканото място. Методът, който върши работата е по-голям. Той иска от системата повече памет и подготвя листа със свободните. Но тъй като заделяме памет за няколко обекта (по подразбиране 16) , правим доста по-рядко заявки от стандартната имплементация. Още повече, че подобно заделяне не причинява използване на толкова излишна памет, както стандартната имплементация. Ако искаме да разрешим проверка на цялостта ще се заделя малко повече памет за всеки обект:
if (_debug)
_allocated_size += 2 * sizeof(int);
Информационната част от парчето памет е оградено с две целочислени числа, глава и опашка. Методът debug_correct извършва необходимите корекции. Тези числа се проверяват при освобождаване на парчета памет, за да е сигурно . че техните стойности не са променени. тази проверка се извършва от метода debug_check . Класът MemoryAllocator е завършен и може да се използва самостоятелно. Но мисля, че не е много добър. Липсва визуална асоциация с класа за който заделя памет , Foo в нашия пример. Шаблонния клас TheAllocator изпълнява ролята на посредник. Той позволява използването на MemoryAllocator без нищо допълнително. Неговият публияен интерфайс е :
template
class TheAllocator : public MemoryAllocator
{
public:
TheAllocator(bool debug = false, uint block_size =16)
: MemoryAllocator(sizeof(T), block_size, debug)
{}
};

Тестване

Тствах имплементацията на IBM PC с OS/2 версия 2.11 и UnixWare 2.03. Тестът беше много прост - само измерих времето за изпълнение на този код:
#define NUM_CYCLES 50000
#define NUM_CHUNKS 18

CLASS* array[222];

for (int n = 0; n < NUM_CYCLES; ++n) {
for (int k = 0; k < NUM_CHUNKS; ++k)
array[k] = new CLASS;
for (k = 0; k < NUM_CHUNKS; ++k)
delete array[k];
}
Тествах следните два подобни класа, използвайки стандартната и оптимизираната схема :
struct Std
{
int _1;
int _2;
int _3;
int _4;
};
struct New
{
int _1;
int _2;
int _3;
int _4;
void* operator new(size_t)
{ return _allocator.allocate(); }
void operator delete(void* p)
{ _allocator.deallocate(p); }
static TheAllocator _allocator;
};
Тази реализация бе от 10.6 до 10.9 пъти по-производителна от стандартнатапри операционна система OS/2 . Дори с увеличаване на NUM_CHUKS на 180 се постига резултат от 11.5 до 11.7 . На UnixWare резултатът бе още по-впечатляващ. За NUM_CHUNKS равно на 18 и 180 съответното подобрение в производителността беше 13.0 и 19.9 пъти. Като интересно наблюдение искам да отбележа, че нашият алокатор се представя константно, независимо от броя на парчетата памет, коиото се заделят. Подобрението в относителната производителност при по-голям брой заделени парчета памет (180) е заради намалянето на производителността на стандартния алокатор.

Ограничения и подобрения

Първото и очевидно "ограничение" е ,че TheAllocator не е напълно общ клас. Негови инстанции се прикрепът към класа, за който заделя памет- в нашия пример класа Foo. Сложих "ограничение" в кавички, защото това не е истинско ограничение. Точно обратното - това е логичен начин за капсулиране на всички свързани с класа детайли, включително заделянето на паметта, в рамките на този клас. Сигурна катастрофа е използването на MemoryAllocator за постигане на допълнителна оптимизация, например:
class ABAllocator : public MemoryAllocator
{
public:
ABAllocator(bool debug = false, uint block_size =16)
: MemoryAllocator(8, block_size, debug)
{}
};
extern ABAllocator allocator;

class A
{
public:
void* operator new(size_t)
{ return allocator.allocate(); }
void operator delete(void* p)
{ allocator.deallocate(p); }
private:
int _1;
int _2;
};

// Instances of B happened to be the
// same size as instances of A
class B
{
public:
void* operator new(size_t)
{ return allocator.allocate(); }
void operator delete(void* p)
{ allocator.deallocate(p); }
private:
int _1;
int _2;
};
Второ ограничение може да е неспособността на класа TheAllocator да заделя памет за масив от обекти от клас Foo. Аз не мисля че това е голям проблем. Лично аз не съм стигал до подобно положение, защото използвам масиви от указатели за самостоятелно заделени инстанции от класа Foo. Масивите от указатели не са тежки за паметта, а са по-гъвкави за употреба. Очевидно тези масиви поддържат сортиране, вмъкване, премахване и други подобни. С заделянето на инстанциите една по една вместо заделянето им в масив не губим от производителността. Освен това получаваме възможноостта да създаваме елементите на тези масиви с помощта на специфични конструктори, а не само конструктора по подразбиране. Трето ограничение е повече предупреждение или добавяне на фукционалност. Деструкторът на MemoryAllocator просто обикаля списъка от заделени блокове и ги освобождава като блок памет. Очевидно ако създадем инстанция от класа Foo и не я изтрием преди да сме премахнем инстанцията на алокатора, няма да се извика деструктора на Foo. Това трябва да се очаква, защото MemoryAllocator само заделя памет. Той не знае абсолютно нищо за това как заделената памет се използва. Този проблем може да се реши с предефниране на деструктора на TheAllocator. За всеки обект в паметта във всеки заделен блок трябва да сканираме списъка със свободни, за да проверим дали обектът е бил освободен. Ако не е може да извикаме нарочно деструкторът на Foo. Деструкторът на алокаторът най-вероятно ще бъде извикан при затваряне на приложението, така че ще има предостатъчно време за минаване през подобни пруцедури, за да подсигурим освобождаването на цялата памет. Сега деструктора на TheAllocator е:
template
TheAllocator::~TheAllocator()
{
for (int k = 0;
k < _num_allocated_blocks;
++k)
{
void* the_block =
_allocated_blocks[k];
for (int n = 0;
n < _block_size; ++n)
{
void* the_unit =
(char*) the_block + n *
_allocated_size;
for (Free* free_unit = _free;
free_unit;
free_unit = free_unit->next)
{
if (free_unit == the_unit)
// The unit has already
// been freed
break;
}
if (!free_unit)
// is still in use
((T*) the_unit)->~T();
}
}
}
Владимир Батов в софтуерен разработчик, живеещ в Австралия.

08 октомври 2008

Структури от данни с променлива дължина

Повечето книги разглеждат основни структури от данни като дървета и хеш таблици като започват с масиви с фиксиран размер. Въпреки че са бързи, масивите с фиксиран размер рядко са ефективни за практически цели -- модерният софтуер трябва да работи с каквито и да е входни данни, а предварително оразмерените даннови структури не са достатъчно гъвкави, за да изпълнят подобни изисквания. От друга страна свързаните структури са по -гъвкави, но са по-сложни и фрагментацията на паметта може намали производителността. Джон Бойер обяснява лесен и бърз начин за промяна на големината на масиви и данновите структури, които ги изпозват.

-- Тим Кницъл

Едуард Ситарски обясни как Хеширани Дървета от Масиви, чрез добавяне на сложни програмни техники могат да се използват за нарастващи и намаляващи масиви.В тази статия аз ще покажа как да правите същото, използвайки толкова прости техники, че може веднага да се добавят към вашия програмен репертоар. Още повече , че можете да добавите тези техники към съществуващи програми, без да променъате структурите от данни или случайно да се появи нов бъг.

Аз разработих тези техники, докато добавях променяеми хеш таблици към Universal Forms Description Language (http:// www.uwi.com/). Сорс кода за променяемите хеш таблици, които използват масиви с променлива дължина моеже да бъде намерен в електронен вариант.


Малко Напрежение

Предимствата на хеширането се намляват, когато хеш таблицата се препълни. Но промяната на големината на таблицата изисква наново вмъкване на всичките елементи, защото последната операция за определяне на хеш ключа е модул по големината на таблицата. Без някои трикове програмата ви ще страда при всяко уголемяване и намаляване на таблицата.

Наскоро правих лексически анализатор като част от нов парсър. Този анализатор трябваше да чете затворени в кавички низове с произволна дължина . Тъй като синтаксиса на много езици не позволява да се ограничава сължината на затворен в кавички литерал е необходимо низът д анараства динамично, докато не се стигне до затварящата кавичка.

Като част от статия за дърво на Фибоначи през Януари 1997 аз сравних двоично и Ф-дърво. Тъй като Ф-дървото може да стане произволно голямо, моето двоично дърво заделяше наново масива си, когато се напълни. Предполага се, че двоичните сървета имат O(log n) вмъкване, но ако се погледне внимателно се вижда, че това се базира на предположението ,че място за нов елемент може да се осигури за O(log n) или по-малко време. В повечето книги се изполват фиксирани масиви за съхранение,така че няма допълнителни разходи за осигуряване на място на новите елементи. Въпреки това показах ,че моят код има O(n) при вмъкване.


Проблемът за разширяващия се масив

Всички тези проблеми могат да се решат с масив,които може да расте динамично. Очевидният подход е да се добавят k елементи всеки път, когато има нужда от повече място. За съжаление функцията на C realloc() може да копира целия масив на ново място. Ако се използват операторите на C++ new/delete трябва ръчно да се заделя място, което гарантира, че елементите на масива трябва да се преместят. Така ако масивът порастне до произволен размер n елементите ще се преместят общо k+2k+3k+4k+...+n-k пъти. Крайният резултат е O(n2) или O(n) за елемент

Представете си предните примери. На лексикалният анализатор ще му трябва O(n2) време за прочитане на n символа, което е неприемливо за парсър(който трябва да има O(n) представяне). Двоичното върво се забави от O(log n) до O(n) и вмъкването в хеш таблицата от O(1) до O(n). Може да намалите скритата константа чрез увеличаване на уголемяването k, но крайния резултат все още е O(n) за елемент от масива.


Решението

Решението е да се удвоява размера на масива всеки път, когато трябва да се увеличи

char *lex_growbuffer(char *Buffer, long *pBufferSize){
char *TempP;
long NewSize;
if (*pBufferSize==0) NewSize = 16;
else NewSize = *pBufferSize * 2;
TempP = (char *) realloc(Buffer, NewSize+1);
if (TempP != NULL) *pBufferSize = NewSize;
return TempP;
}
... /* Snippet to add a character to the lex buffer */



if (BufferLen == BufferSize)
Buffer = lex_growbuffer(Buffer, &BufferSize);
if (Buffer == NULL) return NULL;
Buffer[BufferLen++] = NewChar;
Buffer[BufferLen] = '\0';
return Buffer;
...
/* After string is read, reduce to exact size */
Buffer = (char *) realloc(Buffer, BufferLen+1);

Листингът е lex_growbuffer() , който е типичен за променливите масиви. Размерът на буферът се удвоява, вместо да се променя с константна стойност.Също така този пример връща масива до първоначалния му размер след като се достигне до завършването на низа.

В C++ няма друг избор за промяна на размера освен копиране на масива. С удвояването на масива 2,4,8,...,n трябва да се копират масиви с размери 1,2,4,...n/2. Това са общо n-1 копирани елементи. Размерът се увеличава до n, когато се вмъква елемент (n/2+1). Следователно цената за копирането по време на заделянето на място никога не надвишава два времеви интервала, което е константно време за елемент.

В най-лошите моменти масивът ще е два оъти по-голям от необходимото, но това се уравновесява от увеличената скорост на заделяне на място и простотата от използването на тази техника е нов или съществуващ код.


А намаляването ?

В някои приложения може да се наложи извършването на изтриване от масив с променлива дължина. Двоичното дърво е добър пример. Добре ще е ако след произволна поредица от операции за добавяне/изтриване големината на масива на дървото е линейно зависим от броя на елементите в дървото. Това трябва да осигури,че локално натрупване на операции вмъкване няма да предизвика дървото да продължи да заема ненужна памет.

Вариант може да бъде намалянето на масива наполовина, точн окогато е наполовина пълен. По принцип това е правилната идея, но има слабости. Представете си случайната поредица е 1024 вмъквания, последвани от редуващи се операции за вмъкване и изтриване.Първото вмъкване от редуваната поредица ще накара масива на дървото да се увеличи до 2048 елемента, тъй като това е най-малката степен на две, която е по-голяма от 1025. Първото изтриване ще намали размера на таблицата до 1024 лемента, което ще доведе до намаляне на масива до 1024. Следващото вмъкване ще увеличи масива отново, а следващото изтриване ще го намали. Тъй като всяка операция ще променя масива тя ще има представяне O(n) .

Повечето произволни поредици от операции за вмъкване и изтриване нямат да доведат до този проблем, но аз исках да се гарантира, че в най-лошия случай ще има константно време за операция.За да се постиген това аз намалям масива до половината му размер, когато той е една четвърт пълен. В най-лошия случай размера ще се променя от 2k до 2(k+1) след вмъквания, а изтриванията ще променятброя на елементите от 2(k-1) до 2k+1. Когато се достигнат 2k+1 елемента се преместват 2k елемента в нов масив, преди да се вмъкне новият елемент. След това са необходими поредица от най-малко 2(k-1)+1 изтривания, за да се предизвика намаляне на масива, което ще премести 2(k-1) оставащи елементи в по-малък масив. След това трябва да има още 2(k-1)+1 вмъквания, за да се върне масива до 2k+1 елементи и да се предизвика увеличаване. Следователно всяка поредица от 2(k-1)+1 изтривания, последвана от 2(k-1)+1 вмъквания ще причини намаляне и увеличаване. Тази промяна на размера ще се появи 2(k-1) хода след фазата на изтриване и 2k хода сле вмъкване. Това е очевидно константно време за операция, тъй като се правят по-малко от 2k+2k-1 местения на елементи за всеки 2k+2 операции.

Следователно, дори и при изтриване, може да се поддържа константно време за операция и данновата структура никога няма да използва повече от четири пъти от необходимата памет - дори ако в поредицата има големи интервали от операции за вмъкване .


Хеш таблица

Аз се запознах с хеш таблици с променлив размер от книгата на T. Cormen, C. Leiserson, R. Rivest Introduction to Algorithms, (MIT Press, 1990). Докато създавах двоичното дърво за статията ми за дървото на Фибоначи аз осъзнах възможността за прилагане на удвоителната техника към всички проблеми на масивите с променяема дължина.

Ключът към променянето на хеш таблици е в простото създаване на нов масив, както е описано по-горе, копиране на съществуващите елементи в него, използвайки хеш функция за вмъкване и след това да се премахва старият масив . Изграждането наново на цялата таблица може да изглежда като груба сила, но няма разлика между копирането на елемент(константно време) и вмъкване в хеш таблица (което се очаква да е константно време). Използването на хеш функция може да е по-дълго от копиране в паметта, но това е просто по-голяма константа.

realloc() не е подходящо за пробразуване на хеш таблица, защото две таблици с различни размери ще съдържат много елемнти на съвършенно различни места.Следователно сте принудени ръчно да местите обектите от стария в новия масив.


Java и STL

Имплементацията на хеш таблица в Java използва удвояване на масива при увеличаване на размера на хеш таблицата, но не се намаля, когато се намали таблицата. Класът vector от C++ Standard Template Library (STL) , който се използва като база за приритетната опашка(двоично дърво), също използва удвояване на масива за увеличаване на размера на вектора, но игнорира заявката за запазване на памет, по-малка от сегашния капацитет на контеинера. Това трябва да се промени, защото структурите от данни (като дърветата, използвани в програми,работещи дълго време, като сървъри) трябва да могат да намаляват използваната памет след пик от данни.

Текущата имплементация на хеш таблица в STL не увеличава или намалява автоматично броят на позициите в хеш таблицата (всяка позиция нараства зада събере колизиите, което увеличава зареждането на хеш таблицата). Там има функция resize() ,която създава наново хеш таблица с брой позиции, зьададени като параметър. Така че , ако използвате STL хеш таблица, трябва да използвате функцията resize() само, за да удвоите или намалите наполовина броя на позициите, когато хеш таблицата е съответно пълна или само една четвърт е пълна. Това ще подсигури очакваният размер O(1) на списъка на колизиите.

Добри Хеш Функции

Когато се използва тази техника за промяна на броя позиции в хвш таблицата за запазване на малко натоварване е много важно да има добра хеш функция, която постига голямо разпръсване независимо от размера на таблицата. Ефективността на хеш функцията се измерва чрез разделяне на броя на позициите на броя на елемнтите в хеш таблицата. Идеалният хеш има 100% ефективност, защото всеки елемент има собствена позиция.


typedef unsigned long ulong;typedef unsigned char uchar;
ulong HashJB(uchar *KeyStr, ulong N)
{
ulong Hash=0, I=0;
uchar Ch;
for (;Ch=*KeyStr++; I^=1)
if (I)
Hash *= Ch;
else Hash += Ch;
Hash += ((Hash&0xFFFF0000)>>16);
Hash += ((Hash&0x0000FF00)>>8);
return Hash & (N-1);
}

Това в прост пример за хеш фунйция, която извършва изчисления върху двоичните кодове на символите в ключа. Позицията на записа в таблицата се изчислява само на базата на ключа, а не на колко елемента има в таблицата. Това обяснява защо операциите вмъкване, изтриване и търсене заемат константно време.

HashJB редува добавяне и умножение. Добавянето е много по-бързо, но умножението дава бързо нарастване на хеш стойността(така че тя може да достигне всяка позиция в таблицата , без значение колко голяма тя стане). Забележете и използването на Key&(N-1) за извършване на модул Key%N. Тъй като големината на масива се удвоява N винаги е степен на две и модулът може да се замине с много по-бързата операция на изваждане и побитово AND.

Проблем е отрязването на резилтатния ключ , защото се изхвърля информацията от старшите битове от стойността на хеша. По-късно добавих два реда код, който добавя старшите битове в младшите, което намали броят на колизиите в моите тестове.

Заключение

Забелязал съм в повече от половин дузина проекти, че техниката за удвояване/намаляване на половина на масива е довела до разтърсващо подобрение в производителността, сравнено с променянето с константна дълйина. Още повече, че идеята обикновенно се прилага в големи проекти за минути, вместо продължителната промяна на дизайна и на кода, които се налагат поради нови структури от данни. Тази техника ползва излишна памет, но понякога това може да бъде премахнато( както с лексическия анализатор, който намаля низовете). Но дори и когато излишното заемане на памет не може да бъде премахнато(както с хеш таблица), се разменя памет за скорост на изпълнение и простотата на тази техника.


Джон Бойер е ИТ мениджър в UWI Unisoft Wares Inc.

07 октомври 2008

В търсене на добри практики

Ще бъдат разгледани опитите за рационализация на процеса на разработка на софтуер, като се фокусираме на мащабни проекти.

Един добре познат случай е пилотиращият софтуер на совалките от космическата програма, проект който е отличен от всички за завидното си качество, продуктивност и предвидливост. Някои теоретици видяха проекта като "узрял" - достигнал ниво 5 по скалата за узрялост на Института за Софтуерно инженерство в Карнеги Мелън. Марката на такава узрялост е възможността за успешно прилагане на същите методи за разработка в друг проект. За съжаление се показа, че независимо от узрялостта, която е постигната в проекта за совалка, методът не е готов да бъде наложен на други проекти. Свидетелство е провалът на предложената от FAA система за контрол на трафика, проект на стойност 6 милиарда долара, прекратен от правителството поради раздуване на бюджета и забавяния в крайния срок.

Миражът на узрялостта

Узрялост от ниво 5 изглежда като приказка : теоретична конструкция лишена от реални примери. Това не е за омаловажаване на постиженията на космическата програма, които са неуспорими. Нейните почти нулеви дефекти и постоянно навременни рилийзи са уникални в софтуерната индустрия.

Но да обърнем внимание, че този проект се въззползваше от определени предимства, които рядко се срещат другаде: хардуерна платформа, която не се променя две десетилетия, добре дефинирани и последователни изисквания, постепенно нарастване на добре разбран проблем, огромен бюджет,позволяващ изработка на непотребни прототипи , натиск от ръководителите за множество нива на тестове, непроменящ се персонал и план, който не се променя от действия на конкуренти или промени на пазара. Като се имат предвид всички тези предимства не е необходимо да се притежава стратосферни нива на узрялост за да се обясни просуктивността и качеството на космическата програма.

Несъмнено голяма заслуга за успеха на проекта имат и големите специалисти и техници. Но методологистите на обичат да показват тази част от проекта, защото съществуващите методи не указват пътя за изграждането на такъв тийм.

CASE Доиде и си замина

В средата на 80-те, по същото време, когато Фредерик Бруукс предвиди, че следващото десетилетие няма да донесе разтърсващи подобрения в качеството и продуктивността на софтуера, производителите на CASE туул-ове се арздвижиха с вълна от продукти и white papers, обещавайки не що подобно на това.

В борбата с върколака на софруернато развитие, всеки сребърен куршум, без значение колко е тънък, има голям отзвук. Вълната на CASE, която премина през индустрията остави някои наивни мениджъри, които харчиха $10-50 хиляди за стол, на сухо. Сега тази вълна се отдръпна и много от производителите, които останаха, както свидетели в програмата за защита на свидетели, си смениха имената и се опитват да имат нова идентичност (маркетинговият термин за това е "репозициониране"). Някои от тези играчи сега са яхнали текущата вълна : обектно ориентирани методологии и шаблони.

Въпреки че инструментите и техниките на CASE и неговите обектно-ориентирани наследници не са безполезни, много изпълнения все още изглеждат направени във вакуум. Много от класическата литература за софтуерно инженерство и процес на разработка не изглеждат приложими за истинския свят, в който изискванията се сменят с всяко връщане на маркетинговият отдел от обяд, много от средата е недокументирано или в бета версия, трябва да се разработват алгоритми и мениджърите сърпат шалтера на тестовете, защото е дошла датата за издаване.

Дори проекти, които са далеч от крайности срещат проблеми.Много са чували за проблемие в автоматичната система за багаж на денвърското летище. Има и маса по-малко познати случаи. Автоматичната система на Bay Area Rapid Transit (BART), метрото в Сан Франциско, беше декларирана като неподходяща и прекъсната четири години преди датата на завършването, което доведе до $40 милиона загуба. Bank of America преживя $100 милиона загуба от забавен и "бъгав" софтуер за управление на сметките, който бе изваден от употреба. Гигантът в здравеопазването Kaiser Permanente похарчи милиони последното десетилетие, опитвайки се да автоматизира картоните на пациентите, без видим ефект. Подразделението на Kaiser в Южна Калифорния стигна до инсталиране на пневматична тръба за доставка на хартиени копия на документите из сградите по старомодния начин.

Като резултат много програмисти са незадоволени от празните бюрократични процедури, които много CASE туул-ове и техники се опитват да наложат.Жан Станфорд обяснява в статия за Computerworld (4 Септември 1995) как програмистите в един проект се оправят с системата за менажиране на кода :

Те гордо демонстрираха хитроумен инструмент, който бяха написали, който лъже централното хранилище и им позволява да избегнат проверката за грешки при всяка промяна. Те трябваше да проверяват файловете при всяка промяна. Вмеато това те държаха файловете в домашните си директории. Веднъж на всеки три месеца техният малък инструмент изпразваше директориите им и пускаше за проверка цялата подсистема. Не бе чудо, че тестовете се проваляха.

Създателят на PERL Лару Уол пише за " трите добродетеля на програмиста: мързел, нетърпение и високомерие." Въпреки че израза на Уол не означава това, което изглежда на първо четене (той всъщност говори за това как тези качества мотивират програмистите да пишат страхотни профрами), мисълта е срещу командите на "абсурдния " мениджмънт, който има "страстно, но погрешно желание да притисне твоята мисъл, за да пасне на техните идеи, да опетнят твоят шаблон на мислене като безрадостно съществуване".

Гай Кавазаки от Ейпъл е обяснил идеята на много програмисти за процеса на разработка на софтуер. Типичната последователност(леко променена от авторът) е нещо като това :

Скицирай фанелка, напиши кодът, напиши документацията, достави софтуерът, потърси си нова работа, тествай софтуерът и ако остане време напиши спецификации и коментирай кодът. Повтаряй безкрайно този цикъл с промени в персонала.

С настроения като тези сред най-добрите и креативни програмисти, които по принцип служат за модел на останалите, не е учудващо че 70 процента от CASE средите стоят по лавиците и се превърнаха в украса за объркани мениджъри.