Paljude piirangutega ülesanded: konverentsi ajakava

Paljud tegeliku elu probleemid sisaldavad väga palju piiranguid ja alameesmärke, kusjuures sageli on need vastuolulised - kui püüda saavutada/rahuldada üht, ei ole võimalik saavutada/rahuldada teisi. Kõikki nõudeid rahuldavat lahendust sageli pole üldse olemas, tuleb leida mingi enam-vähem rahuldav lahendus. Kuid sellise leidmine (kõikvõimalike variantide läbivaatamise abil) osutub arvutile sageli üle jõu käivaks probleemiks: raske on hinnata kõigi kitsenduste tähtsust/kaalu, mõnes olukorras võib piirangute olulisus olla teistsugune jne, nii et lõpuks on ikka vaja inimese mõistust.

Sellised on enamus planeerimisülesannetest, näiteks tunniplaani koostamise ülesanne. See on vana ja paljudes koolides ja kõrgkoolides esinev probleem, kuid rahuldavalt töötavat arvutiprogrammi pole seni veel loodud, nii et näiteks Tallinna Tehnikaülikooli tunniplaanid tehakse seniajani käsitsi.

Praktikas lahendatakse selliseid planeerimisprobleeme tavaliselt nii, et arvuti koostab mingid esialgsed plaanivariandid (võttes arvesse kõige olulisemaid piiranguid), mille seast siis inimeksperdid valivad rahuldava või modifitseerivad mõnd arvuti esitatud varianti. Selline strateegia annab tavaliselt täiesti rahuldava lahenduse.

Vaatleme järgnevas 1988. a Tampere Tehnikaülikoolis põhjamaade tehisintellektikonverentsi ajakava koostamisel tekkinud planeerimisprobleemi. Ka siin esines mitmeid vastuolulisi piiranguid, kuid tudengite poolt Prolog-kursuse harjutusülesandena koostatud programm töötas niivõrd hästi, et seda kasutati nii selle kui ka mitme järgneva konverentsi ajakava planeerimisel.

Lähteandmetena olid konverentsi sessioonid ja nende kestvused:
Prolog-programmeerimine_1, 90 min
Prolog-programmeerimine_2, 120 min
Teadmisete_esitamisviisid, 60 min
jne

Sessioonid tuli paigutada Tampere Tehnikaülikooli auditooriumidesse, kui need olid vabad (kooli tunniplaani ei muudetud):
10.00 H210 45 min
12.00 H220 240 min
(s.t. auditoorium H210 on alates kella 10-st 45 minutit vaba jne).

Need algandmed võib esitada predikaatidega sessioon, auditoorium:

sessioon([prolog_programmeerimine1,80]).
sessioon([prolog_programmeerimine2,110]).
sessioon([teadmiste_esitamisviisid,50]).

auditoorium([h210,10,60]).
auditoorium([h212,12,60]).
auditoorium([b200,12,240]).
auditoorium([b210,14,120]).

Predikaat planeeri/1 kogub algul süsteemipredikaadi findallabil kõik sessioonid ja auditooriumid nimistuteks ja käivitab siis tegeliku planeerimispredikaadi planeeri/4, mis paigutab sessioonid auditooriumidesse:

planeeri(Plaan) :-
sessioonid(Sessioonid),
auditooriumid(Auditooriumid),
planeeri(Sessioonid,Auditooriumid,[],Plaan),
esita(Plaan).

Sessioonide auditooriumidesse paigutamisel peab jälgima, et auditoorium oleks vaba piisavalt kaua. Planeerimine lõpeb, kui kõik sessioonid on paigutatud (sesioonide nimistu on tühi): enne kasutajale esitamist järjestatakse plaan sessioonide algusaegade järgi:

planeeri([],_,Plaan,Plaan0) :-
predsort(enne,Plaan,Plaan0).

planeeri([Sessioon|Sessioonid],Auditooriumid,Plaan,Plaan0) :-
member(Auditoorium,Auditooriumid),
sobib(Sessioon, Auditoorium),
delete(Auditooriumid, Auditoorium, Auditooriumid1),
planeeri(Sessioonid, Auditooriumid1,[[Sessioon, Auditoorium]|Plaan],Plaan0).

sobib([Sessioon,Kestvus],[Auditoorium,Aeg,Vapaa]) :-
Kestvus =< Vapaa.

Enne kasutajale esitamist järjestatakse plaan sessioonide algusaegade järgi. Plaan on nimistu, mille liikmed on üsna keeruka struktuuriga : [[Sessioon,Kestvus],[Auditoorium,Aeg,Vapa]] , kuid Swi-Prologi sisseehitatud järjestamispredikaadi predsort abil on ka keerukate liikmetega nimistute järjestamine lihtne: predsort esimene argument on kasutaja poolt kirjeldatud predikaat, mis määrab järjestatava nimistu liikme (termi) struktuuri põhjal, millal liikmete vahel kehtivad suhted < , = ja >; kuna enamus sessiooni kirjeldava termi liikmetest pole olulised, esineb kirjelduses palju anonyymseid muutujaid _ :

enne(<,[[_,_],[_,Aeg,_]], [[_,_],[_,Aeg1,_]]):-
Aeg < Aeg1,!.

enne(=,[[_,_],[_,Aeg,_]], [[_,_],[_,Aeg,_]]):-!.

enne(>,_,_).

Ülaltoodu on täiesti "sirgjooneline" planeerimisprogramm. Sobiva ajakava saamiseks tuleb arvesse võtta ka palju kitsendusi: sessioonide vahele peab sobitama kohvipausid, samateemalised (Prolog-programmeerimine_1, Prolog-programmeerimine_2 jt) sessioonid peaks toimuma võimalikult üksteise järel, kuid mitte samal ajal (kõigil sellest teemast huvitatutel peaks olema võimalik kuulata kõikki ettekandeid); kõigi konverentsipäevade koormus (konverents kestis 3 päeva) peaks olema enam-vähem sama jne, sageli esitasid ettekandjad lisatingimusi (mõni ei jõudnud kohale konverentsi alguseks või pidi lahkuma enne konverentsi lõppu jne). Kõigi tingimuste arvessevõtmisel poleks lahendust üldse leidunud, sellepärast anti tingimustele kaalud: need, mida tingimata pidi arvesse võtma (esinejate kohalolek) said suure kaalu, kuid need, mille suhtes võis tingida (samateemaliste sessioonide ajaline lähedus) said väiksema kaalu ja plaani koostamise järel arvutas programm kohe ka selle kaalu (võttes arvesse kõigile piirangutele antud kaale).
Kui ülesanne on suur, (kolmepäevasel konverentsil oli kokku ligi viiskümmend sessiooni), ei ole mõtet lasta arvutil otsida kõigi plaanide seast parimat, pealegi sõltub parima valik kitsendustele antud kaaludest ja sellepärast ei oleks võibolla praktikas üldse sobinud. Programm hakkas väljastama nn lokaalseid maksimume: ta arvutas uusi plaane kuni plaanid paranesid (kirjeldatud kaalude põhjal); kui järgmisena leitud plaan osutus halvemaks kui senine parim, väljastati senine parim (lokaalne optimum) ja kasutaja pidi otsustama, kas jätkata plaanide arvutamist või oli äsjaväljastatud plaan kasutamiseks sobiv. Kuna programmi töö algul leiti senistest paremaid plaane suhteliselt sageli ja senine parim kaal kasvas kiiresti, ei olnud mõtet väljastada neid kõikki, programm hakkas lokaalselt optimaalseid plaane väljastama alles siis, kui uus maksimum enam palju ei erinenud eelnevast. Selline strateegia osutus väga õnnestunuks, tavaliselt kõlbas juba teine-kolmas arvuti poolt väljastatud plaan ja edasisel arvutamisel need enam oluliselt ei paranenud.

Olgu plaanile näitek esitatud nõuded:
- paralleelsessioone ei või olla;
- sessioonide vahelised vaheajad (kokku) peavad olema võimalikult väikesed
(ka need nõuded on sisuliselt vastuolulised: kui kõik sessioonid toimuks samal ajal, ei oleks vaheaegu üldse!)
Paralleelsessioonidega plaanide avastamine on lihtne (kuna kõik loengud algasid alati täistundidel):

pole_samaaegseid([[[_,_],[_,Aeg,_]]|Plaan]):-
not(member([[_,_],[_,Aeg,_]],Plaan)),
pole_samaaegseid(Plaan).

pole_samaaegseid([]).

Lokaalselt (senileitud plaanide seas) optimaalse (minimaalse "tühja ajaga") plaani leidmiseks peab planeerimisprogramm kogu aeg meeles pidama seniste plaanide parimat ja selle kaalu (iga plaani kaalu võrreldakse sellega); käivitamisel antakse parim kaal - 10000 (et esimesel korral kohe võrdlus toimiks). Järjekordse plaani leidmise järel arvutatakse selle kaal (vaheaegade summa), võrreldakse seda ja võrreldakse seda senise miinimumiga; kui äsjaleitud plaani kaal on väiksem kui senine miinimum, saab uueks miinimumiks selle plaani kaal ja plaan peetakse meeles kui seniste seas parim; kui plaani kaal on suurem kui senine miinimum, võib nii plaani kui ka selle kaalu unustada. Senist parimat plaani ja selle kaalu salvestatakse predikaatidega senine_min, parim:

seni_parim :-
sessioonid(Sessioonid),
auditooriumid(Auditooriumid),
retractall(senine_min(_)),
assert(senine_min(100)),
retractall(parim(_)),
assert(parim([])),
planeeri(Sessioonid,Auditooriumid,[],Plaan),
kontrolli(Plaan).
Predikaat kontrolli võrdleb äsjaleitud plaani kaalu senise optimumiga; kui leitud plaan on parem, asendatakse senine optimum, kui pole, väljastatakse senine optimum ja kasutaja peab otsustama, kas arvutamist jätkata: kui kasutaja sisestab S või s (Stop), arvutamine lõpeb:
kontrolli(Plaan):-
pole_samaaegseid(Plaan),
kaal(Plaan,Kaal),
senine_min(Min),
((Kaal < Min,! ,
retractall(senine_min(_)),
assert(senine_min(Kaal)),
retractall(parim(_)),
assert(parim(Plaan)),
fail);
(parim(Senine_Parim),
write('Seni parim: '),nl,
esita(Senine_Parim),
write('Kaal: '),write(Min),nl,
get_char(C),
((C='s';C='S');fail))).
Plaani kaal on kõigi sessioonide vaheaegade summa:
kaal(Plaan,Vahed):-
vahed(Plaan,0,Vahed).

vahed([[[Sessioon,Kestvus],[Auditoorium,Aeg,Vapaa]],[[Sessioon1,Kestvus1],[Auditoorium1,Aeg1,Vapaa1]]|Plaan],Vahed,Vahed0):-

Vaheaeg is Aeg1 - (Aeg + Kestvus/60),
Vahed1 is Vahed + Vaheaeg,
vahed([[[Sessioon1,Kestvus1],[Auditoorium1,Aeg1,Vapaa1]]|Plaan], Vahed1,Vahed0).
vahed([_],Vahed,Vahed).

(kuna sessioonide algused on antud ajana (tundidena) ja sessioonide kestvused - minutites, teisendatakse ka kestvused tundideks).
Käivitamisel väljastab ülalkirjeldatud predikaat seni_parim parima plaanina plaani kaaluga 0.833333. Tegelikult pole see parim, nagu selgub päringust, mis sunnib väljastama kõik plaanid ja nende kaalud:
?- planeeri(P),kaal(P,K),write(K),nl,fail.

Kell 10.00 auditooriumis h210 teadmiste_esitamisviisid, kestvus: 50 min

Kell 12.00 auditooriumis b200 prolog_programmeerimine1, kestvus: 80 min

Kell 14.00 auditooriumis b210 prolog_programmeerimine2, kestvus: 110 min

1.83333

...

Kell 11.00 auditooriumis h212 teadmiste_esitamisviisid, kestvus: 50 min

Kell 12.00 auditooriumis b200 prolog_programmeerimine2, kestvus: 110 min

Kell 14.00 auditooriumis b210 prolog_programmeerimine1, kestvus: 80 min

0.333333

Probleem on selles, et predikaat kontrolli väljastab senise parima vaid siis, kui ta leiab, et järgmine plaan on halvem; kui parim plaan on viimane, järgmist pole ja parimat ei väljastata. Sellepärast tuleb lisada predikaadile seni_parim veel üks lause, mis väljastab viimasena salvestatud plaani:
seni_parim :-
senine_min(Min),
parim(Plaan),
write('Viimane: '),nl,
esita(Plaan),
write('Kaal: '),write(Min),nl.
Kogu programmi algul peaks olema lause, mis kirjeldab nn dünaamilised predikaadid (sellised, mida muudetakse lausetega assert, retract) ja nende argumentide arvu
:-dynamic senine_min/1, parim/1.

Ülesandeid:
1.

Lennukompanii lennuplaan on salvestatud formaadis: Hind Lennukood Lähtelinn Lähteaeg Sihtkoht Saabumisaeg; hind on reaalarv (kümnendosa eraldajaks koma ja kümnendosa kohal '-'), lennukood -kaks suurtähte ja kolm või neli numbrit, lähte ja sihtkoht esitatakse kolmest suurtähest koosneva koodina ja ajad formaadis tund : min, näiteks Finnairi lennud Brüsselisse näevad välja nii:

111,- AY6783 HEL 13:30 BRU 15:10
111,- AY817 HEL 16:35 BRU 18:15

Koosta predikaat, mis oskaks planeerida mitmest järjestikusest lennust koosneva reisi ja arvutada selle kogumaksumuse; mitme võimaliku marsruudi korral valida odavaim; saabumise ja järgmise reisi lähteaja vahel peab olema vähemalt 45 min, kuid mitte rohkem kui 3 tundi; reis võib minna ka üle kesköö, näit saabumisaja 23.30 järel järgmine lähteaeg võib olla 00:45



Küsimused, probleemid: ©2004 Jaak Henno