WDP Dowodzenie poprawności programów: Różnice pomiędzy wersjami

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania
Linia 17: Linia 17:
 
Takie formuły nazywamu ''trójkami Hoare'a'' albo ''formułami częściowej poprawności programu''. Zostały wprowadzone przez jednego z największych informatyków, Anthony'ego Hoare'a, w połowie lat 60-tych.  
 
Takie formuły nazywamu ''trójkami Hoare'a'' albo ''formułami częściowej poprawności programu''. Zostały wprowadzone przez jednego z największych informatyków, Anthony'ego Hoare'a, w połowie lat 60-tych.  
  
%zdjęcie C.A.R. Hoare
+
[[Image:Hoare.jpg||thumb|right|200px|C.A.R. Hoare (1934– )]]
  
 
Zauważmy, że o poprawności formuły Hoare'a możemy mówić w dwóch przypadkach:  
 
Zauważmy, że o poprawności formuły Hoare'a możemy mówić w dwóch przypadkach:  

Wersja z 12:02, 22 wrz 2006

Wprowadzenie

Wiemy, jak bardzo nasze życie zaczyna zależeć od komputerów, a dokładniej od programów komputerowych. Chodzi tu nie tylko o wygodę, ale niekiedy działanie komputerów ma krytyczne znaczenie, gdy mówimy o takich zastosowaniach jak transport, nie tylko lotniczy, loty kosmiczne, nadzorowanie życia pacjenta w czasie operacji, dozowanie leków. Wszelkie zastosowania komputerów, gdzie trzeba mieć zaufanie do programu skłaniają nas do zapytania, z czego wynika nasze zaufanie, że program komputerowy napisany jest dobrze?

Mija właśnie epoka, w której poprawność programu można było ustalić na podstawie słowa honoru danego przez programistę, że się nie pomylił. Oczywiście typowe zachowanie programisty po napisaniu programu, to jego gruntowne przetestowanie. Gruntowne, to nie znaczy całkowite. Rzadko bowiem w czasie testowania mamy wyobraźnię tak bogatą, żeby przedstawić sobie wszystkie możliwe sytuacje, szczególnie wtedy gdy jest ich potencjalnie nieskończenie wiele. Owszem, testy mogą wykazać, że program jest niepoprawny, ale prawie nigdy (poza przypadkami, kiedy liczba przypadków jest skończona i mała – wielka rzadkość) nie mogą wykazać jego poprawności. Informatycy od dawna próbują skonstruować metody wnioskowania o programach i stają się one coraz popularniejsze, szczególnie że raz udowodnione fragmenty programu mogą być włączane do bibliotek jako poprawne moduły, do których już nie trzeba wracać.

Jak zatem udowodnić, że program robi to, o co nam chodzi? Mniej więcej widać, że gdy wykonamy instrukcję i:=i+1 dla , to po wykonaniu jej będziemy mieli . Czy możliwe jest jednak opracowanie ogólnej teorii? Okazuje się, że dla ogromnej większości algorytmów jesteśmy w stanie udowodnić ich poprawność korzystając z metod logiki, opracowanych na początku lat 60-tych XX wieku. Jak się za wkrótce okaże, ta teoria nie tylko pozwala udowodnić poprawność algorytmu, ale i daje metodologię wymyślania algorytmów. Za jej pomocą będziemy wiedzieli, na czym się skupić, gdy zaczynamy projektować pętle.

Będziemy poszukiwali takiej teorii, która pozwoli nam ustalić pewną relację między trzema obiektami: stanem początkowym, instrukcją i stanem końcowym. Relacja ta intuicyjnie ma oznaczać, że jeśli w pierwszym stanie, zwanym początkowym, wykonamy badaną instrukcję, to przejdziemy do stanu drugiego, który nazwiemy końcowym. W rzeczywistości, żeby móc wnioskować o nieskończonej przestrzeni stanów, będziemy agregować wiele z nich za pomocą formuł logicznych. Zatem będziemy rozważali pewien zbiór stanów początkowych, instrukcję oraz pewien zbiór stanów końcowych, które będą względem siebie w odpowiedniej relacji. Stany początkowe i końcowe będziemy określali za pomocą formuł.

Przykładowo możemy sobie wyobrazić formułę , instrukcję j:=k+1 oraz formułę i próbować udowodnić dość oczywisty fakt, że jeśli stan początkowy spełnia formułę , to po wykonaniu naszej instrukcji stan końcowy będzie spełniał formułę . Wprowadźmy zatem oznaczenie

Formuła częściowej poprawności programu  Trójka   
, to formuła logiczna, która jest  prawdziwa wtedy i tylko wtedy, gdy 
zachodzi następujący warunek. Dla każdego stanu spełniającego , jeżeli program   
zakończy swoje działanie, to stan końcowy będzie spełniał formułę .

Takie formuły nazywamu trójkami Hoare'a albo formułami częściowej poprawności programu. Zostały wprowadzone przez jednego z największych informatyków, Anthony'ego Hoare'a, w połowie lat 60-tych.

C.A.R. Hoare (1934– )

Zauważmy, że o poprawności formuły Hoare'a możemy mówić w dwóch przypadkach:

  1. Kiedy dla danych spełniających program się zatrzyma i dane po zatrzymaniu się programu spełniają
  2. Kiedy dla danych spełniających program się nie zatrzyma.

Jest to dość istotny szczegół techniczny, którym się jeszcze zajmiemy.

Jak zatem wnioskować o programach? Za chwilę podamy zbiór reguł dla podstawowych konstrukcji programistycznych, umożliwiający wnioskowanie na podstawie drzewa wywodu programu. Dla każdego węzła drzewa wywodu, albo dokładniej, dla każdej produkcji gramatyki podamy regułę wnioskowania pozwalającą wyprowadzić konsekwencję wykonania instrukcji na podstawie opisu działania instrukcji składowych. Reguły wnioskowania będziemy zapisywali w postaci

Przesłanki tak są dobrane, aby wniosek wyrażał własność, że dana instrukcja jest częściowo poprawna względem dwóch formuł i .


Instrukcja pusta

Zacznijmy od najprostszej instrukcji pustej:


Czytamy to tak: bez żadnych warunków wstępnych (pusty "licznik"), jeśli dane początkowe spełniają warunek , to po wykonaniu instrukcji pustej (zauważmy, że nie może się wykonanie takiej instrukcji zapętlić) dane końcowe spełniają warunek . Dość oczywiste, zważywszy że instrukcja pusta nie robi nic. Mamy tu pusty zbiór przesłanek.


Instrukcja przypisania

Umówmy się, że jeśli zmienna jest parametrem formuły logicznej , zaś jest wyrażeniem,to przez rozumiemy formułę, która powstaje przez zastąpienie wszystkich wystąpień zmiennej w formule przez . Mamy zatem następującą regułę wnioskowania dla instrukcji przypisania:


Parser nie mógł rozpoznać (błąd składni): {\displaystyle \frac{}{\{\alpha(E)\}{\mbox{\tt z:=E}}\{\alpha(z)\}}}

która mówi nam, że jeśli przed wykonaniem przypisania zachodziła formuła dla wyrażenia , to po wykonaniu instrukcji przypisania formuła ta będzie zachodziła dla zmiennej . Faktycznie skoro przyjęła wartość , dla której formuła była prawdziwa, to nie ma powodu dla którego formuła ta miała by nie zachodzić dla wartości .


Instrukcja złożona

Tym razem musimy wyrazić to, co się dzieje, gdy wykonujemy sekwencję instrukcji. Intuicyjnie bdziemy zakładali, że istnieje ciąg stanów pośrednich, spełniających pośrednie formuły . Mamy zatem

Parser nie mógł rozpoznać (błąd składni): {\displaystyle \frac{\mbox {\alpha=\alpha_0,\{\alpha_0\}P1\{\alpha_1\}, \ldots,\{\alpha_{n-1}\} Pn \{\alpha_n\}, \alpha_n=\beta}}{\mbox {\{\alpha\}{\tt begin P1; \dots Pn end} \{\beta\}}}}

Czyli jeśli umiemy udowodnić, że w stanie po wykonaniu P1 przejdziemy do stanu , w stanie po wykonianiu P2 przejdziemy do stanu , a ze stanu po wykonaniu Pn przejdziemy do stanu , to łącznie wykonanie wszystkich instrukcji ze stanu spełniającego przeprowadzi nas do stanu spełniającego .

Instrukcja warunkowa

Podamy dwie reguły: dla instrukcji bez "else" i dla instrukcji z "else".

Parser nie mógł rozpoznać (błąd składni): {\displaystyle \frac{\mbox {\{\alpha \land B\} P \{\beta\}, (\alpha \land \neg B) \rightarrow \beta}}{\mbox {\{\alpha\}{\tt if B then P } \{\beta\}}} }    Parser nie mógł rozpoznać (błąd składni): {\displaystyle \frac{\mbox {\{\alpha \land B\} P \{\beta\}, \{\alpha \land \neg B\} Q \{ \beta\}}}{\mbox {\{\alpha\}{\tt if B then P else Q } \{\beta\}}} }

Odczyt tych reguł jest bardzo naturalny i zgodny oczywiście z semantyką instrukcji warunkowych. W pierwszym przypadku bowiem (bez "else") albo instrukcja wykona się, albo nie, w zależności od prawdziwości warunku . Wystarczy zatem, żeby dla danych spełniających i takich, że warunek jest spełniony wykonanie programu P doprowadziło nas do stanu , aby w tym przypadku cała instrukcja warunkowa była poprawna względem i . Również gdy dla takich danych warunek nie jest spełniony, wówczas żądamy, aby było automatycznie spełnione, bez wykonywania jakiejkolwiek instrukcji – żeby było logiczną konsewkencją i i w obu przypadkach: odpowiednio wykonując instrukcję lub nie, mamy zapewnione spełnienie warunku .

Dla instrukcji warunkowej z "else" mamy jednorodną sytuację: zależnie od tego, czy jest spełnione, czy nie, żądamy częściowej poprawności albo względem instrukcji , albo .


Instrukcja pętli

Gdyby programy składały się tylko z dotychczas omówionych w tym rozdziale instrukcji, wówczas można by automatycznie dowodzić poprawność programów. Instrukcja pętli powoduje, że taka automatyzacja nie jest możliwa. Metoda dowodzenia poprawności pętli ma charakter indukcyjny. Podamy ją tylko dla pętli while, bowiem pozostałe typy instrukcji pętli dadzą się wyprowadzić z niej. Idea reguły dowodzenia leży w wymyśleniu warunku, który będzie zawsze spełniony na początku pętli. Taki warunek będziemy nazywali 'niezmiennikiem' pętli. Od niezmiennika będziemy wymagali trzech rzeczy.

  • żeby był spełniony na początku pętli
  • żeby jeden obrót pętli nie zmieniał jego wartości (czyli żeby niezmiennik pozostawał prawdziwy)
  • żeby po wyjściu z pętli niezmiennik (nadal prawdziwy!) w koniunkcji z zaprzeczeniem dozoru pętli implikował warunek

Ujmijmy nasze żądania w regułę dowodzenia.

Parser nie mógł rozpoznać (błąd składni): {\displaystyle \frac{\mbox {\alpha \Rightarrow N, \{N\land B\}P \{N\},N\land \neg B \Rightarrow \beta }}{\{\alpha\}{\mbox {\tt while B do P }} \{\beta\}}}

Zauważmy, że w regule tej pojawia się warunek , jakby znikąd. Musimy go sami wymyśleć i jeżeli uda się nam znaleźć takie, żeby spełniało wszystkie 3 przesłanki, to udowodnimy częściową poprawność instrukcji pętli.

Zanim przejdziemy do przykładów zauważmy, że poprawne są również dwa schematy dowodzenia, z których często będziemy korzystali. Nazywane są one regułami osłabiania. Osłabić twierdzenie można albo wzmacniając założenie albo osłabiając tezę.

Wzmacnianie założenia

Parser nie mógł rozpoznać (błąd składni): {\displaystyle \frac{\mbox {\alpha \Rightarrow \gamma, \{\gamma\}P \{ \beta\}}}{\mbox {\{\alpha\}{\tt P} \{\beta\}}}}

Osłabianie tezy

Parser nie mógł rozpoznać (błąd składni): {\displaystyle \frac{\mbox {\{\alpha\} P \{ \gamma\}, \gamma \Rightarrow \beta}}{\mbox {\{\alpha\}{\tt P} \{\beta\}}}}

Podajmy teraz parę przykładów zastosowania podanych reguł dowodzenia zastosowanych do stwierdzania poprawności instrukcji.

Przykład [1]

Na początek prosty przykład. Udowodnijmy, że jeśli , to po instrukcji j:=k+1 mamy . Ponieważ mamy po prawej stronie instrukcji przypisania wyrażenie , więc musimy warunek początkowy zapisać w równoważnej formie, w której miejsce zmiennej zajmuje wyrażenie . Zatem jest rónoważne i stosując regułę dla instrukcji przypisania otrzymujemy, że , co akurat w przypadku zmiennych całkowitych jest równoważne . Gdyby jednak zmienne oraz były typu rzeczywistego, to końcowe stwierdzenie otrzymalibyśmy osłabiając tezę: wszak implikuje .

Przykład [2]

Tym razem spróbujmy przeanalizować fragment programu, w którym chcemy sprawdzić, czy w tablicy A[1..n] of Integer znajduje się zadana wartość . Zakładamy tu, że nie wiemy nic o uporządkowaniu tablicy, w szczególności nie możemy zakładać posortowania danych. Nie wiemy również, jaka jest wartość poza tym, że jest nieujemna (dla n=0 mamy do czynienia z pustą tablicą). Przeszukiwanie zaczniemy od pierwszego elementu i będziemy sprawdzali, czy w kolejnych elementach tablicy jest szukane . Pierwszy pomysł polega na tym, że pętlą for przejdziemy przez całą tablicę i jeśli znajdziemy , zmienimy początkową waratość logicznej zmiennej jest z false na true.


 jest := false; 
 for k:=1 to n do if A[k]=x then jest:=true

Pomysł tyleż prosty, co niezbyt ambitny: jeśli element jest na początku tablicy, to pętla for niepotrzebnie będzie wykonywała dalsze sprawdzanie. Spróbujmy udowodnić jednak, że program ten jest poprawny. Zacznijmy od zapisania go przy użyciu pętli while.

 jest := false; 
 k:=1;
 jest=false, k=1
 while k<=n do  Parser nie mógł rozpoznać (błąd składni): {\displaystyle {\mbox{\not jest \Leftrightarrow (\forall 1\le  j < k: A[j] \neq x) \land (1\le k\le n+1)}}}

   begin    
    if A[k]=x then jest:=true;
    k:=k+1
  end
 Parser nie mógł rozpoznać (błąd składni): {\displaystyle {\mbox {\not jest \Leftrightarrow (\forall 1\le j \le n: A[j] \neq x)}}}

Sprawdźmy, że podany niezmiennik pętli spełnia wszystkie warunki. Oznaczmy przez wartość formuły logicznej

.

Zauważmy po pierwsze, że w trywialny sposób inicjalizuje się. Warunek jest zawsze prawdziwy: nie ma bowiem żadnej wartości większej lub równej 1 a jednocześnie mniejszej od 1, wskutek czego pierwszy czynnik koniunkcji jest trywialnie prawdziwy. Wszelkie stwierdzenia poprzedzone kwantyfikatorem ogólnym dla elementów zbioru pustego są prawdziwe. Oczywiście dla oraz mamy też , więc drugi czynnik też jest prawdziwy.

Po drugie wykonanie jednego obrotu pętli nie zmienia prawdziwości niezmiennika. Jeśli bowiem na początku pętli dla pewnej wartości kwartość zmiennej jest równa sie false, to na mocy założenia indukcyjnego zachodzi

.

Teraz mamy dwa przypadki.

  • Jeśli dodatkowo , to możemy powiedzieć równoważnie, że , czyli , a to jest równoważne . Warunek ten obrazuje stan obliczeń po wykonaniu badanej instrukcji warunkowej, gdyż wartość zmiennej {\tt jest} pozostanie równa false. W jednym kroku pętli wykonuje się jeszcze tylko instrukcja k:=k+1. Po tym przypisaniu warunek nasz będzie spełniony dla nowego k, czyli niezmiennik zostaje odtworzony.
  • Jeśli natomiast , to wykona się instrukcja jest := true i wtedy, dla nowej wartości , formuła będzie fałszywa, czyli równoważna wartości .


Podobnie gdy zmienna jest ma wartość true, to na mocy założenia istnieje takie że . Niezależnie od tego, czy przypisanie jest :=true zostanie wykonane w ramach instrukcji warunkowej, czy nie, po wykonaniu instrukcji k:=k+1 niezmiennik się również odtworzy. To swtierdzenie kończy dowód niezmienniczości formuły .

Zostało nam w końcu do udowodnienia, że po wyjściu z pętli, gdy mamy oraz zachodzi , otrzymamy tezę. Zatem ze względu na to, że dochodzimy do wniosku, że , co łącznie z pierwszym czynnikiem niezmiennika daje nam (po zastosowanu reguły osłabienia tezy) końcowy warunek

.

W naszym przypadku niezmiennik opisywał ogólną sytuację po kilku obrotach pętli: zmienna jest ma wartość true wtedy i tylko wtedy gdy na lewo od znajduje się wartość . Kiedy szukamy niezmiennika, staramy się możliwie dokładnie wyobrazić sobie, jak może wyglądać sytuacja, gdy już pętla wykona parę obrotów i zapisujemy to w postaci formuły logicznej. Jeśli nie mamy wystarczająco dużo wyobraźni, to niezmiennik wyjdzie za słaby i trudno będzie korzystając tylko z niego i z dozoru pętli jego niezmienniczość udowodnić (choć może być prawdziwy). Również za słaby niezmiennik może być powodem trudności pokazania trzeciej z implikacji, kończącej wywód. W naszym przypadku na przykład nieuwzględnienie w niezmienniku warunku spowodowałoby kłopoty z przekształceniem niezmiennika do postaci , z czego wynikała od razu teza. Z kolei za mocny niezmiennik oznacza kłopoty z jego inicjalizacją. Niezmiennik powienien być w sam raz.

Zauważmy jeszcze jedną rzecz. W momencie gdy zmienna jest przyjmuje wartość true, możemy dalsze przeszukiwanie zakończyć, gdyż na false już zmienić swojej wartości nie może z powrotem. Zatem dozór pętli możemy wzmocnić o dodatkowy warunek, tak aby przerwać obliczenia, gdy tylko znajdziemy. Tym razem, ponieważ liczba obrotów pętli będzie dynamicznie zależała od danych, nie można użyć pętli for, tylko od razu użyjemy pętli while.


jest := false; 
k:=1;
jest=false, k=1
while (not jest) and ( k<=n) do  Parser nie mógł rozpoznać (błąd składni): {\displaystyle {\mbox{\not jest \Leftrightarrow (\forall 1\le j < k: A[j] \neq x \land 1\le k\le n+1)}}}

  begin 
    if A[k]=x then jest:=true;
    k:=k+1
  end


Dowód poprawności przebiega identycznie, jak poprzednio. Zmienia się tylko złożoność algorytmu: ten wykona być może mniej kroków, choć w najgorszym przypadku – gdy x w tablicy A nie ma - w obu przypadkach wykonamy tę samą liczbę obrotów pętli. Zauważmy, że to są kroki trochę droższe, bo mamy bardziej skomplikowany warunek dozoru. Czy skórka jest warta wyprawki?

Powiedzmy tak: jeśli mamy podejrzenie, że znalezienie x jest prawdopodobne, a danych jest dużo, warto zastosować wcześniejsze przerwanie pętli. Kiedy jednak danych jest niewiele lub przypuszczalnie wartości x nie ma w tablicy, wtedy pętla for jest sensowniejszym rozwiązaniem. Nie ma sensu optymalizować tego typu pętli, gdy mamy np. do czynienia z tablicą kilkunastoelementową. W każdym razie to, od kiedy zaczyna się opłacać robić tego typu optymalizacje, zależy często od testów – wystarczy napisać obie wersje programu i sprawdzić, czy zyskujemy coś na którejś z nich. Na pewno jednak dobry programista powinien umieć napisać obie wersje.

Problem jest co prawda prosty, ale bardzo ważny, więc pokażemy jeszcze dwa ulepszenia tego algorytmu. Oto pierwsze z nich.

Wyszukiwanie liniowe proste

Tym razem musimy założyć, że tablica jest niepusta, czyli że . Oto kolejna wersja rozwiązania problemu wyszukiwania liniowego.


 Parser nie mógł rozpoznać (błąd składni): {\displaystyle {\mbox{ n\ge 1}}}

 k:=1;
 Parser nie mógł rozpoznać (błąd składni): {\displaystyle {\mbox{ k=1, n\ge 1}}}

 while (k<n) and (A[k]<> x) do Parser nie mógł rozpoznać (błąd składni): {\displaystyle {\mbox{(\forall 1\le j < k: A[j] \neq x) \land (1\le k\le n)}}}

    k:=k+1; 
   Parser nie mógł rozpoznać (błąd składni): {\displaystyle {\mbox{ (A[k]=x) \Leftrightarrow ((\exists 1\le j \le n: A[j] = x) \land (1\le k\le n))}}}

   jest := A[k]=x
 Parser nie mógł rozpoznać (błąd składni): {\displaystyle {\mbox{ (jest) \LeftRightarrow ((\exists 1\le j \le n: A[j] = x) \land (k\le n))}}}

Ulepszenie polega na uproszczeniu treści pętli. Zauważmy, że pętla przerwie swoje działanie, gdy tylko k dojdzie do n lub będzie równe . Możliwe jest też, że pętla przerwie działanie z obu powodów na raz. Pozbywamy się tu też dodatkowej zmiennej logicznej jest i działamy bezpośrednio na elementach tablicy .

Sprawdzenie, że warunki początkowe dobrze inicjalizują niezmiennik jest proste. Również nieskomplikowany jest dowód niezmienniczości. Najtrudniejsze tym razem będzie pokazanie, że negacja dozoru i niezmiennik implikują warunek

Parser nie mógł rozpoznać (błąd składni): {\displaystyle {\mbox{ (A[k]=x) \Leftrightarrow ((\exists 1\le j \le n: A[j] = x) \land (1\le k\le n))}}} .

Zanim to zrobimy zauważmy, że zachodzą następujące tożsamości logiczne.

 Parser nie mógł rozpoznać (błąd składni): {\displaystyle ((p \land q) \Rightarrow r) \Leftrightarrow ((p\Rightarrow r) \lor (q\Rightarrow r)) ((p \lor q) \Rightarrow r) \Leftrightarrow ((p\Rightarrow r) \land (q\Rightarrow r))\\ }

Sprawdzenie prawdziwości tych tautologii pozostawiamy jako ćwiczenie. Są one o tyle ważne, że bardzo często dowodzimy wynikania czegoś z koniunkcji albo alternatywy warunków i tautologie te pozwalają nam zredukować dowody do prostszych przypadków. Zauważmy charakterystyczną zamianę rolami spójników logicznych oraz . Kiedy dowodzimy, że coś wynika z koniunkcji warunków, wystarczy że udowodnimy, że wynika z jednego z nich. Natomiast gdy dowodzimy, że coś wynika z alternatywy warunków, wówczas musimy pokazać, że wynika z każdego z nich.

Dowiedziemy zatem prawdziwości następującej formuły:

.

Lewa strona implikacji, to zaprzeczenie dozoru i niezmiennik, a prawa strona implikacji, to warunek, który jest nam konieczny do dowodu ostatecznej formuły: wyraża on fakt, że to, czy w tablicy znajduje się wartość , czy nie, zależy wyłącznie od tego, czy równa się po wyjściu z pętli. W poprzedniku implikacji mamy alternatywę i dwie koniunkcje. Korzystając z praw de Morgana rozbijamy alternatywę otrzymując równoważny poprzednikowi implikacji warunek

.

Mamy tu do czynienia z postacią właśnie omawianej tautologii, kiedy to z alternatywy oraz wynika . Rolę pełni tu formuła

, rolę

formuła ,

zaś rolę pełni formuła

.

Osobno udowodnimy więc dwie implikacje:

i .

Zacznijmy od . Załóżmy, że zachodzi . Wtedy ponieważ oraz , więc . Skoro tak, to w trzecim czynniku koniunkcji wchodzącej w skład mamy . Zatem istnieje pomiędzy a takie, że wtedy i tylko wtedy gdy , czyli , co było do pokazania. Oczywiście drugi czynnik koniunkcji w formule jest trywialnie prawdziwy dla .

Pozostało nam do pokazania, że . Z wynika, że oraz że . Zatem istnieje takie (równe ), że .

Pokazana implikacja kończy cały dowód.

Oczywiście możemy tu narzekać, że dość prosty program, którego poprawność była "oczywista", miał tak skomplikowany dowód poprawności. Nie zapominajmy jednak, że

  • Po pierwsze, gdy już udowodnimy poprawność takiego kodu, możemy opakować go w procedurę i więcej do niego nie zaglądać, wiedząc, że na pewno dobrze zadziała.
  • Po drugie, co można zaproponować poza takim dowodem? Solenne przyrzeczenie programisty, że się nie pomylił?
  • Po trzecie, jak się wkrótce okaże, technika niezmienników służy nie tylko do dowodzenia poprawności programów, ale też do prawidłowego konstruowania pętli.
  • Po czwarte, często przeprowadzając dowody nie zauważa się szczególnych przypadków. W analizowanym programie na przykład można łatwo przeoczyć konieczność założenia . Puste tablice też się zdarzają i akurat w naszym przypadku mógłby wystąpić błąd na samym początku przy sprawdzaniu pierwszego dozoru pętli. Jest tam bowiem wyrażenie A[k]=x i gdyby wartość A[1] byłaby nieokreślona, to mógłby wystąpić błąd. Również po zakończeniu pętli zaglądamy do A[1] i jeśli przypadkiem znajdzie się tam , to otrzymamy błędną odpowiedź (powinna być false, a byłaby true). Co gorsza, tego typu błąd może w żadnych testach nie wyjść!

Zauważmy, że dla wszystko jest w porządku: wszędzie tam, gdzie odwołujemy się do indeksu tablicy , mieści się on w zakresie określoności [1..n], a gwarantuje nam to właśnie niezmiennik. Zapamiętajmy

Ilekroć odwołujemy się do tablicy, powinniśmy móc udowodnić, że indeks tablicy
mieści się w dziedzinie określoności.

Wyszukiwanie liniowe ze strażnikiem

Podany wyżej algorytm wydaje się tak prosty, że trudno jest cokolwiek uprościć. Pokażemy jego dość istotne ulepszenie, którego wiele odmian stosuje się w podobnych sytuacjach. Zauważmy, że gdybyśmy wiedzieli, że znajduje się gdzieś w tablicy i na przykład chcieli poznać jego indeks, to nie trzeba byłoby się zabezpieczać przed końcem tablicy i jedynym warunkiem dozoru byłoby A[k]<>x. Pomysł jest następujący. Dodajmy jeden element na końcu tablicy deklarując ją na początku dłuższą o 1 niż potrzeba. Zatem tablica będzie miała zakres [1..n+1], ale prawdziwe wartości, wśród których będziemy szukali będą tylko pomiędzy 1 a n.

Oto propozycja programu:

 Parser nie mógł rozpoznać (błąd składni): {\displaystyle \{ {\mbox{ n\ge 1}}\}}

 k:=1;
 A[n+1]:=x;
 Parser nie mógł rozpoznać (błąd składni): {\displaystyle \{{\mbox{ n\ge 1, k=1, A[n+1]=x}}\}}

 while A[k]<> x do  Parser nie mógł rozpoznać (błąd składni): {\displaystyle {\mbox{\{(\forall 1\le j < k: A[j] \neq x) \land (1\le k\le n+1) \land (A[n+1]=x)}}\}}

  k:=k+1; 
 Parser nie mógł rozpoznać (błąd składni): {\displaystyle \{{\mbox{ (k\le n) \Leftrightarrow (\exists 1\le j \le n: A[j]=)}}\}}

 jest := k<=n
 Parser nie mógł rozpoznać (błąd składni): {\displaystyle \{{\mbox{ (jest) \LeftRightarrow (\exists 1\le j \le n: A[j] = x) }}\}}


Zobaczmy, jak uprościł się nam program. W warunku dozoru zniknęła koniunkcja oraz konieczność wyliczania jednego warunku logicznego ( k<n). Przez to dowód poprawności też będzie prostszy. Skupmy się na samej pętli. Widać, że przy wejściu do pętli niezmiennik jest prawdziwy – wynika wprost z warunku wstępnego.

Jeśli chodzi o niezmienniczość w czasie wykonywania pętli, to podobnie, jak zeszłym razem odtworzenie klauzuli

przy jednokrotnym obrocie pętli jest dość oczywiste, tak jak w poprzednim programie. Ostatni warunek cały czas zachowuje prawdziwość, bo nic się w tablicy nie zmienia w czasie wykonywania pętli. Najbardziej ambitne będzie pokazanie, że . Dowód tego faktu polega na zauważeniu, że gdy wchodzimy do pętli to – na mocy założenia indukcyjnego - to mamy z poprzedniego obrotu pętli – oraz że - to mamy z dozoru pętli. Stąd po uwzględnieniu tego, że dostajemy, że . A skoro oraz , to oznacza, że , czyli Parser nie mógł rozpoznać (nieznana funkcja „\lek”): {\displaystyle 2 \lek+1 < n+2} , z czego wnioskujemy że , co po przypisaniu {\tt k:=k+1} staje się z powrotem na mocy reguły przypisania.

Pozostaje udowodnienie, że zaprzeczenie dozoru i niezmiennik razem implikują warunek końcowy, czyli że

.

Tym razem wygodnie będzie nam skorzystać z następującej klasycznej tautologii:

 jest równoważne 

Ponieważ mamy udowodnić równoważność warunków oraz , więc osobno pokażemy obie implikacje.

Pierwsza z nich

.

Ta implikacja jest oczywista: wystarczą trzy warunki z przesłanek: , oraz , żeby udowodnić tezę.

Druga implikacja:

jest prawdziwa: ponieważ , a jest z przedziału , więc gdyby było równe , wówczas otrzymalibyśmy sprzeczność z warunkiem mówiącym o tym, że na lewo od wartości nie ma, czyli , który jest w poprzedniku implikacji.

To kończy dowód poprawności tego algorytmu.

Warto dla dużych tablic przekonać się, że metoda ze strażnikiem jest nieco szybsza, niż poprzednia. Przyspieszenie powinno być rzędu kilkunastu procent. Zawsze coś!

Co jednak zrobić, gdy nie mamy wpływu na decyzję o deklaracji tablicy. Ktoś nam daje tablicę, w której mamy namierzyć wartość i nie ma ekstra elementu o indeksie . Mamy na to sposób.


 Parser nie mógł rozpoznać (błąd składni): {\displaystyle {\mbox{ n\ge 1}}}

 if A[n]=x then jest := true
 else 
   begin 
   v:=A[n]  // zapamiętujemy ostatnią wartość tablicy
   k:=1;
   A[n]:=x; // i teraz możemy ją zamazać bez obawy o jej utracenie
   while A[k]<> x do // stosujemy znany algorytm ze strażnikiem
     k:=k+1; 
   jest := k<n;   // ten warunek nieco się zmienił
  A[n] := v   // a tutaj odtwarzamy zamazaną wartość
 Parser nie mógł rozpoznać (błąd składni): {\displaystyle {\mbox{ (jest) \LeftRightarrow (\exists 1\le j \le n: A[j] = x)}}}


Czasem bardzo proste pomysły są niezwykle skuteczne!