Om å lete gjennom «alle muligheter» Eller: Kombinatorisk søking, rekursjon og avskjæring 1 Innledning Problemer, instanser og løsningsalgoritmer

Like dokumenter
IN2010: Forelesning 11. Kombinatorisk søking Beregnbarhet og kompleksitet

INF1010. Sekvensgenerering Alle mulige sekvenser av lengde tre av tallene 0, 1 og 2: Sekvensgenerering. Generalisering. n n n! INF1010 INF1010 INF1010

INF2220: Forelesning 7. Kombinatorisk søking

Kombinatorisk søking, rekursjon, avskjæring

En algoritme for permutasjonsgenerering

INF1010 Rekursjon. Marit Nybakken 1. mars 2004

Backtracking som løsningsmetode

UNIVERSITETET I OSLO

Backtracking som løsningsmetode

INF1010 Sortering. Marit Nybakken 1. mars 2004

Dagens plan. INF Algoritmer og datastrukturer. Koding av tegn. Huffman-koding

Obligatorisk oppgave 1 INF1020 h2005

Norsk informatikkolympiade runde

Eksamen iin115, 14. mai 1998 Side 2 Oppgave 1 15 % Du skal skrive en prosedyre lagalle som i en global character array S(1:n) genererer alle sekvenser

UNIVERSITETET I OSLO

Norsk informatikkolympiade runde

Oppgave 3 a. Antagelser i oppgaveteksten. INF1020 Algoritmer og datastrukturer. Oppgave 3. Eksempelgraf

Løsnings forslag i java In115, Våren 1998

INF Algoritmer og datastrukturer

EKSAMEN. Dato: 9. mai 2016 Eksamenstid: 09:00 13:00

Eksamen i IN 110, 18. mai 1993 Side 2 Del 1 (15%) Vi skal se på prioritetskøer av heltall, der vi hele tiden er interessert i å få ut den minste verdi

"behrozm" Oppsummering - programskisse for traversering av en graf (dybde først) Forelesning i INF februar 2009

Rekursjon. (Big Java kapittel 13) Fra Urban dictionary: recursion see recursion. IN1010 uke 8 våren Dag Langmyhr

Norsk informatikkolympiade runde. Sponset av. Uke 46, 2017

Matchinger i ikke-bipartite grafer

København 20 Stockholm

EKSAMEN. Dato: 18. mai 2017 Eksamenstid: 09:00 13:00

Hva er en algoritme? INF HØSTEN 2006 INF1020. Kursansvarlige Ragnar Normann E-post: Dagens tema

UNIVERSITETET I OSLO

Løsnings forslag i java In115, Våren 1996

UNIVERSITETET I OSLO

INF1010 notat: Binærsøking og quicksort

INF Algoritmer og datastrukturer

UNIVERSITETET I OSLO

2 Om statiske variable/konstanter og statiske metoder.

Norsk informatikkolympiade runde. Sponset av. Uke 46, 2016

Rekursjon. (Big Java kapittel 13) Fra Urban dictionary: recursion see recursion. IN1010 uke 8 våren Dag Langmyhr

Obligatorisk oppgave 2 - inf

INF2220: Forelesning 1. Praktisk informasjon Analyse av algoritmer (kapittel 2) (Binær)trær (kapittel )

INF2220: Time 8 og 9 - Kompleksitet, beregnbarhet og kombinatorisk søk

Oppgavesettet består av 7 sider, inkludert denne forsiden. Kontroll& at oppgaven er komplett før du begynner å besvare spørsmålene.

UNIVERSITETET I OSLO

Enkle generiske klasser i Java

IN1010 V18, Obligatorisk oppgave 5

MAT1030 Forelesning 28

Kodegenerering, del 2: Resten av Kap. 8 pluss tilleggsnotat (fra kap. 9 i ASU ) INF5110 V2007

Binære søketrær. Et notat for INF1010 Stein Michael Storleer 16. mai 2013

Algoritmer og Datastrukturer IAI 21899

PG 4200 Algoritmer og datastrukturer Innlevering 1. Frist: 2.februar kl 21.00

Obligatorisk oppgave 5: Labyrint

INF1020 Algoritmer og datastrukturer GRAFER

MAT-INF 1100: Obligatorisk oppgave 1

Backtracking: Kombinatorikk og permutasjoner

6. oktober Dagens program: Første time: Andre time, gjesteforelesning: Uavgjørbarhet. Stein Krogdahl. (Ikke pensum, egne foiler legges ut)

2 Om statiske variable/konstanter og statiske metoder.

INF1000 EKSTRATILBUD. Stoff fra uke 1-5 (6) 3. oktober 2012 Siri Moe Jensen

Forelesning 30: Kompleksitetsteori

Forelesning inf Java 4

Rekursjon. Binærsøk. Hanois tårn.

i=0 Repetisjon: arrayer Forelesning inf Java 4 Repetisjon: nesting av løkker Repetisjon: nesting av løkker 0*0 0*2 0*3 0*1 0*4

Algoritmer og datastrukturer Kapittel 11 - Delkapittel 11.2

LITT OM OPPLEGGET. INF1000 EKSTRATILBUD Stoff fra uke September 2012 Siri Moe Jensen EKSEMPLER

INF1000 (Uke 15) Eksamen V 04

INF1000 (Uke 15) Eksamen V 04

TDT4100 Objektorientert programmering

Løse reelle problemer

Algoritmer og Datastrukturer

MAT-INF 1100: Obligatorisk oppgave 1

Norsk informatikkolympiade runde. Sponset av. Uke 46, 2014

Oblig2 - obligatorisk oppgave nr. 2 (av 4) i INF1000 v2008

INF Algoritmer og datastrukturer

Norsk informatikkolympiade runde

Norsk informatikkolympiade runde

UNIVERSITETET I OSLO

programeksempel Et større En større problemstilling Plan for forelesingen Problemstillingen (en tekstfil) inneholdt ordet "TGA"

UNIVERSITETET I OSLO

INF oktober Stein Krogdahl. Kap 23.5: Trær og strategier for spill med to spillere

UNIVERSITETET I OSLO

Endret litt som ukeoppgave i INF1010 våren 2004

UNIVERSITETET I OSLO

Algoritmer og datastrukturer A.1 BitInputStream

INF 1000 høsten 2011 Uke september

Innhold uke 4. INF 1000 høsten 2011 Uke 4: 13. september. Deklarasjon av peker og opprettelse av arrayobjektet. Representasjon av array i Java

Løsningsforslag til INF110 h2001

INF1000 undervisningen INF 1000 høsten 2011 Uke september

Oppsummering. Kort gjennomgang av klasser etc ved å løse halvparten av eksamen Klasser. Datastrukturer. Interface Subklasser Klasseparametre

Løsningsforslag ukeoppg. 6: 28. sep - 4. okt (INF Høst 2011)

UNIVERSITETET I OSLO

INF1000: noen avsluttende ord

INF oktober Stein Krogdahl. Altså: Hva kan ikke gjøres raskt (med mindre P = NP)

Oppgave 1. Oppgave 2. Oppgave 3. Prøveeksamen i INF1000. Ole Christian og Arne. 23. november 2004

Algoritmer og datastrukturer Kapittel 9 - Delkapittel 9.1

Oblig2 - obligatorisk oppgave nr. 2 (av 4) i INF1000 v2009

Hvorfor sortering og søking? Søking og sortering. Binære søketrær. Ordnet innsetting forbereder for mer effektiv søking og sortering INF1010 INF1010

Prøveeksamen i INF1000. Ole Christian og Arne. 23. november 2004

Algoritmer og datastrukturer Løsningsforslag

Grunnleggende Grafteori

Rekursiv programmering

Løsnings forslag i java In115, Våren 1999

Eksamen iin115 og IN110, 15. mai 1997 Side 2 Oppgave 1 Trær 55 % Vi skal i denne oppgaven se på en form for søkestrukturer som er spesielt godt egnet

Transkript:

Om å lete gjennom «alle muligheter» Eller: Kombinatorisk søking, rekursjon og avskjæring Notat til IN2010 Stein Krogdahl og Arne Maus Ifi, UiO, November 2018 1 Innledning Kurset IN2010 går i stor grad ut på å studere algoritmer som løser kombinatoriske ( ikke-numeriske ) problemer. Problemene blir gjerne presentert ut fra en litt idealisert datastruktur (f.eks. en «graf», et «tre», en «sekvens», etc.), der man ønsker å finne en «konstellasjon» ut fra visse kriterier (f.eks. korteste vei mellom to noder i en graf) eller en omstrukturering av datastrukturen (f.eks. sortering av en sekvens). For mange slike problemer finnes heldigvis effektive algoritmer som kan finne en løsning på ganske store utgaver av problemet uten å bruke all verdens tid. Det skal imidlertid ikke mye variasjon til i problemstillingen før vi ikke lenger har noen fornuftig algoritme å tilby. Om man f.eks. i en graf med ikke-negative lengder på kantene spør etter den lengste veien mellom to noder uten å være innom samme node flere ganger, i stedet for den korteste, så har ingen, hverken teoretikere eller praktikere, klart å komme opp med en algoritme som går noe i nærheten av like fort som en for å finne korteste vei (f.eks. Dijkstras algoritme). Vi har da forskjellige muligheter: Konkludere med at dette ikke går innen den ressursbruken vi kan tillate oss Gi opp å finne det absolutt optimale svar, men f.eks. forsøke å finne en algoritme som gir en «god nok» løsning for det praktiske problemet vi ønsker å løse. Vi kan f.eks. for lengste-vei-problemet si: «Ja, ja vi får gå gjennom alle mulige veier da, selv om det er ufyselig mange, og underveis registrere den lengste av dem». Det er den siste måten vi skal se på her, altså hvordan man kan programmere det å gå gjennom «alle muligheter», for eventuelt å finnes én som har de egenskapene vi er ute etter. Om vi er ute etter å finne den «beste» etter et visst kvalitetsmål (og alle har en kvalitet som kan bergnes!), så vil en beste alltid finnes, men om vi har et annet kriterium (f.eks. at vi ønsker en vei gjennom grafen som er innom alle uten å være innom noen to ganger), så vil vi ikke på forhånd vite om en slik finnes i det hele tatt. Problemer, instanser og løsningsalgoritmer Men før vi går nærmere inn på dette skal vi se på litt terminologi som gjerne brukes i forbindelse med den type spørsmål vi her ser på. Merk at, ut fra det detaljeringsnivået vi her arbeider på, vil vi ikke gjøre forskjell på begrepene «algoritme» og «program». De representerer begge en generell beskrivelse av en sekvens av steg og tester som skal utføres, og som skal ha en input som beskriver den utgaven av problemet som skal løses (og som altså vil variere fra utførelse til utførelse). Man må i denne sammenheng også bestemme hva som utgjør «elementærsteg» i programmet/algoritmen og hvilken tid disse tar. Vi regner så at eksekveringstiden for en gitt input er summen av tiden for alle de steg som utføres før programmet stopper. Hva så med begrepet «et problem», og hva betyr det at en algoritme «løser» et gitt problem? Et greit eksempel på et problem er «sortering», og en standard måte å beskrive et problem på er ved å beskrive en «generell instans», samt hva programmet skal svare på i forbindelse med en slik. Vi kan f.eks. for sortering si at en generell instans er en sekvens av tall, og at oppgaven er å levere de samme tallene i stigende (eller ikke-synkende) rekkefølge. Vi definerer også en «spesifikk instans» (eller oftest rett og slett en «instans») til, i dette tilfellet, å være en bestemt konkret tall-sekvens som skal sorteres.

For hver instans av et problem definerer man så en «størrelse» («size») som vanligvis vil være omtrent den lengden (f.eks. antall tegn) vi bruker på å beskrive denne instansen (input-lengden) Det er selvfølgelig mange måter å angi en bestemt instans på, og disse vil som regel gi noe forskjellige størrelser for den samme instansen. Man kan f.eks. si at størrelsen av en tallsekvens er antall tall i sekvensen, men man kan også være mer nøyaktig å si at størrelsen er antall tegn i beskrivelsen av sekvensen, der antall siffer i hvert av tallene også blir regnet med. For de fleste formål (og for de vi skal se på) vil denne forskjellen spille liten rolle, men for visse typer effektivitetsvurderinger kan denne distinksjonen utgjøre en viktig forskjell. En ting å merke seg her er at om instansen f.eks. består av bare ett enkelt heltall (og oppgaven f.eks. er å avgjøre om tallet er et primtall), så vil man normalt regne antall siffer i tallet som størrelsen på instansen. Men det er lett å gå seg vill her, og i stedet regne verdien av tallet som størrelsen på instansen. Det sistnevnte vil gi merkelige vurderinger av hvor effektive eventuelle løsningsalgoritmer er. Hva vil det si at program «løser» et problem Så tilbake til problemer og programmer: For å kunne si at et program «løser» et problem må programmet, startet opp med en vilkårlig instans av problemet som input, i løpet av «endelig tid» komme opp med riktig svar på denne instansen. Om det er en sjanse for at programmet går i evig løkke eller ikke kommer opp med riktig svar for noen av instansene, kan vi ikke si at det løser problemet. Merk at de fleste problemer slik de er beskrevet, i utgangspunktet har et ubegrenset antall instanser. Men i forbindelse med det praktiske problemet man vil løse, vil man ofte også vite at de instansene man kommer borti har en eller annen begrensning, f.eks. at de sekvensene som skal sorteres ikke vil ha fler enn en million elementer. En spissfindighet om O-notasjon En grunn til at vi ønsker å anse problemer for å ha ubegrenset mange (og dermed også ubegrenset store) instanser, er at ellers gir det ikke mening å snakke om O-notasjon og liknende begreper. For definisjonen av O-notasjon er jo som kjent slik: Vi sier at eksekveringstiden for et program er «O(f(n))» dersom det finnes konstanter K og N slik at eksekveringstiden for en instans med størrelse n aldri overgår K f(n) for noen n > N. Vi ser her at denne definisjonen bare fungerer på en fornuftig måte dersom det finnes så store instanser man bare måtte ønske. For, om det finnes en endelig maksimal størrelse av instansene, vil algoritmens tidsbruk alltid være O(1)! Det er bare å velge K og N store nok! Eksekveringstiden for å lete gjennom «alle muligheter» Vi antydet lenger opp at eksekveringstiden for å lete gjennom alle muligheter ofte blir veldig stor, og det viser seg at den gjerne blir O(a n ), for en eller annen a > 1. Vi sier da gjerne at eksekveringstiden vokser «eksponensielt» i forhold til størrelsen av instansen, og da blir eksekveringstiden fort uhorvelig stor. Om konstanten a er f.eks. 2 vil altså eksekveringstiden doble seg hver gang instansen blir én større (og nettopp det vil gjelde om man f.eks. vil se gjennom alle mulige bit-sekvenser av lengde n). Dersom en algoritme har eksekveringstid O(2 n ), og instansen har størrelse 100, vil altså eksekveringstiden bli av størrelsesorden 2 100. Dette er jo veldig stort, nemlig omkring 10 30 (siden 2 10 =1024, altså ca. 10 3 ). Om vi, veldig optimistisk, regner at det tar et nanosekund (10-9 sek) å generere og undersøke hvert alternativ vil vi klare ca. 3 10 16 alternativer i året, og det hele vil derfor ta mer enn 10 13 år. Dette overgår flere hunder ganger tiden fra «Big Bang» til nå, som antas å være ca. 1.4 10 10 år. Men selv om dette kan virke skremmende kan det jo ofte være at mindre instanser av problemer kan være av interesse å få løst, f.eks. om man vil optimalisere et eller annet i et fjøs med 30 kuer, og man ikke finner noen rask algoritme for det. Tallet 2 30 (altså ca 10 9 ) er jo høyst overkommelig, og ut fra 2

forutsetningen i forrige avsnitt (ett nanosekund pr alternativ) ville det ta bare ett sekund å se gjennom alle muligheter. I praksis måtte vi vel heller regne et mikro-sekund (10-6 sekund) på hver mulighet, og da ville det hele ta omkring 20 minutter. Som regel kan man også gjøre lure ting underveis ut fra egenskaper ved det vi leter etter, og da kan eksekveringstiden ofte reduseres anda en god del (selv om algoritmen vanligvis fortsatt vil være eksponensiell!). Slike teknikker kalles ofte «avskjæring» (på engelsk «cutoff» eller «pruning»). Vi ser mer på det lenger ned. Ut fra dette er det altså av interesse å se på teknikker for å kunne «gå gjennom alle muligheter» for eventuelt å finne en som tilfredsstiller de krav man har. Og ikke minst er det interessant å studere teknikker for avskjæring ut fra hva man leter etter. I denne forbindelse vil rekursiv programmering nesten alltid spille en viktig rolle. I engelsk litteratur brukes ofte betegnelsen backtracking algorithms på slike algoritmer. Et annet begrep som ofte brukes i denne forbindelse er Branch-and-Bound. Dette er betegnelsen på en spesiell teknikk for å gjøre avskjæring når man ønsker å finne den beste løsningen i en eller annen forstand, og man vurderer da hele tiden om den delløsningen man har i øyeblikket i det hele tatt kan bygges ut til en løsning som er bedre enn den beste man allerede har sett. Om ikke, bygger man ikke videre på denne delløsningen. 2 Systematisk generering av alle sekvenser Som et første eksempel skal vi se på det å generere alle sekvenser med en viss lengde der vi får lov å gjenta symboler så mye man ønsker i sekvensen. Vi må da ha et gitt endelig alfabet, og vi vil anta at symbolene i dette har en naturlig rekkefølge, slik at det er lett å finne «neste symbol». Det å generere alle sekvenser blir da en oppgave som på svært mange måter tilsvarer det å telle, og (om vi tenker i 10-tall-systemet) vil alfabetet da bestå av sifrene fra 0 til 9. Når vi teller fra null (som vi her bør skrive 000) til 999 generer vi nettopp alle sekvenser av lengde 3, av sifrene 0 til 9. Som vi vet går denne telleprosessen ut på å telle det første sifferet sakte opp, og for hvert slikt første siffer, å generere alle mulige sekvenser i de to siste posisjonene. Alle sekvenser i de to siste posisjonene fremkommer så etter samme mønster. For å ha et litt mindre eksempel skal vi anta at alfabetet bare er {0, 1, 2, 3, og vi vil altså lage alle mulige sekvenser av lengde tre over dette alfabetet. Det kan gjøres helt tilsvarende det over, og vi får sekvensene i den rekkefølge som angitt under (leses linje for linje). Merk at det venstre sifferet skiftes saktest (bare fra linje til linje), mens det høyre sifferet skiftes hele tiden. Det midterste skiftes hver gang det høyre har telt helt opp til til 3. Det blir altså 4 3 (= 64) sekvenser, og disse er: 000 001 002 003 010 011 012 013 020 021 022 023 030 031 032 033 100 101 102 103 110 111 112 013 120 121 122 123 130 131 132 133 200 201 202 203 210 211 212 213 220 221 222 223 230 231 232 233 300 301 302 303 310 311 312 313 320 321 322 323 330 331 332 333 Om vi vil lage et program som genererer dette (og som f.eks. skriver ut hver siffer-sekvens etter hvert som de genereres) så kan vi ha en «global» (i forhold til metodene) array «int array sekv [ ] = new int [4]» hvor sekvensene etter hvert skal produseres. Vi kan da passelig skrive tre metoder: gen0, gen1 og gen2, som er ansvarlige for å genere alle kombinasjoner av sifrene i henholdsvis intervallene sekv[0..2], sekv[1..2] og sekv[2..2], samt at gen2 også kan sørge for utskrift. Om vi skal generere alle sekvenser fra 000 til 333, så kan vi skrive metodene slik (inne i klassen Gen, som også har deklarert arrayen sekv): 3

class Gen { int[] sekv = new int[4]; void gen0() { for(int siff = 0; siff < 4; siff++){ sekv[0] = siff; gen1(); void gen1() { for(int siff = 0; siff < 4; siff++){ sekv[1] = siff; gen2(); void gen2() { for(int siff = 0; siff < 4; siff++){ sekv[2] = siff; System.out.println(" Og for å sette det hele i gang kan vi ha: "+sekv[0]+sekv[1]+sekv[2]); public class GenProg{ public static void main(string [] args){ new Gen().gen0(); 3 Rekursiv programmering Programmet over gjør jobben, men har mange ulemper. For det første virker det nokså irriterende å måtte skrive flere metoder som er nesten like (gen0, gen1 og gen2). For det andre blir det derved veldig tungvint å skulle generere sekvenser med andre lengder, og med alfabeter av andre størrelser. Vi vil i første omgang bare se på problemet med at vi må skrive flere versjoner av gen-metoden. Vi prøver i stedet å skrive bare én metode, som da må være generalisering av alle tre, og som kan styres til å virke nøyaktig som gen0, gen1 eller gen2 f.eks ved hjelp av en parameter som passelig kan ha verdiene 0, 1 eller 2. Denne gen-metoden kunne være omtrent slik: void gen(int pos){ for(int siff = 0; siff < 4; siff++){ sekv[pos] = siff; if (pos < 3){ gen(pos+1); // Rekursivt kall else { System.out.println("" + sekv[0]+ sekv[1] + sekv[2]); Om vi skifter ut de tre metodene over med denne ene metoden, og gjør om kallet i main-metoden til new Gen().gen(0); så skulle vi få nøyaktig det samme resultatet som før. Det vi her gjør er altså, mens vi utfører ett kall av en metode m( ), å gjøre et nytt kall på den samme metoden. Dette kalles gjerne rekursiv bruk av metoden m( ), og de aller fleste moderne språk tillater dette (men FORTRAN gjør det ikke). Det viktige når man vil tillate dette er at det ved kall (enten det er på den metoden du nå utfører eller en annen, så settes det alltid av ny plass i maskinens lager til parametere og variable for dette nye kallet, slik at det ikke blir noen sammenblanding av variable/parametere til det nye kallet og de tilhørende gamle kall. 4

Men programmet over kan fremdeles ikke ha en annen størrelse på alfabetet eller en annen lengde på sekvensene. Dette kan vi ordne ved å ha begge disse størrelsene som parametere til klassen Gen, og den kan da f.eks. ta seg slik ut: class SekvGen { int[] sekv; // Array hvor sekvensene genereres. int alf; // Alfabetets størrelse int lng; // Lengden av sekvensene SekvGen(int le, al){ lng = lengde; alf = al; sekv = new int[lng]; // Konstruktør med lengde og alfabet-størrelse void sekvgen(int pos){ // pos angir siffer-posisjon vi er ved for(int siff = 0; siff < alf; siff++){ sekv[pos] = siff; if (pos < lng-1) { sekvgen(pos+1); // Rekursivt kall else { System.out.println(""); // Ny linje for (int i = 0; I < lng; i++) System.out.print(sekv[i]); Og for å sette det hele i gang med å produsere alle sekvenser av f.eks. lengde 7 over alfabetet {0, 1, 2, 3, 4 kan vi gjøre slik: public class GenProg{ public static void main (String[] args){ new SekvGen(7, 5).sekvGen(0); 4 Avskjæring Man kan merke seg at vi over genererte sekvenser ved stadig å bygge videre ut halvferdige sekvenser, slik at vi til slutt fikk ferdige sekvenser som vi så kan teste om tilfredsstiller det faktiske kravet vi har. Eksempler på slike krav kan være: 1. Anta at vi har en skoleklasse, og vet hvilke (par) av elevene som er uvenner. Vi ønsker (om mulig) å finne en måte å stille elevene på rekke slik at ingen som er uvenner blir stående ved siden av hverandre. 2. Anta at en selger må innom en bestemt mengde av byer og så tilbake til startbyen, og at vi kjenner korteste kjøreavstand mellom ethvert par av byer. Finn den rekkefølgen av byer som gir minst total kjørelengde. Dette problemet kalles tradisjonelt «the traveling salesman s problem», og er mye brukt i eksempler og oppgaver i lærebøker og andre steder. 3. Plasser 8 dronninger på et sjakkbrett, slik at ingen av dronningene kan slå hverandre. Dette er en øvingsoppgave med lange tradisjoner, og man finner den igjen mange steder i bøker og på nettet. 4. Finn en måte å farge landene på et kart slik at ingen naboland får samme fargen, når man bare har et begrenset antall farger (Det klarer seg alltid med 4 farger). 5

5. En kunstforening får en gave på like mange bilder som foreningen har medlemmer, og hvert medlem setter opp en liste med bilder de liker. Finn, om mulig, en utdeling av bilder slik at alle får et bilde de liker. For mange av problemene over (f.eks. nr. 1, 2 og litt indirekte også 3 og 5) er det ikke primært alle sekvenser av at antall symboler vi er ute etter, men heller alle «permutasjoner» av symbolene, altså de sekvensene der hvert symbol forekommer én og bare én gang. Om det er n symboler i alfabetet vårt observerer vi at sekvensene også må ha lengde lik n. Vi skal her se på hvordan man på en enkel og effektiv måte kan gjøre om programmet over (som lager alle sekvenser) til et program som lager alle permutasjoner. Da må altså variablene alf og lng være like, og vi kaller nå denne felles verdien for n. Videre bruker vi verdiene fra 0 til n-1 som de n symbolene. En rett etter nesa måte å generere alle permutasjoner på er å generere alle sekvenser på samme måte som i programmet over, men med alf = lng = n, og så, hver gang vi får en ny sekvens, teste om alle n tallene i sekvensen er forskjellige (eller likeverdig: om alle tallene er til stede), og i så fall har vi en ny permutasjon. Vi vil på denne måten også få generert alle mulige permutasjoner av de n verdiene. Vi vet at det her blir n n forskjellige sekvenser (generelt: alf lng ), men at det «bare» er n! = 1 2 3 n («n fakultet») forskjellige permutasjoner. Verdien av n n blir fort veldig mye større enn n!. For n=10 er f.eks. antall sekvenser 10 10 = ti milliarder, mens antall permutasjoner er 10!, som bare er rundt tre millioner, så her blir det mange tester (ca. 3000) for hvert tilslag. En måte å forbedre denne situasjonen på er å forsøke å gjøre testing underveis, og passe på at man ikke går videre med halvferdige sekvenser som umulig kan bli til noe som tilfredsstiller permutasjons-kravet. Vi må altså ikke går videre med sekvenser som allerede inneholder samme tall to ganger, og om vi vil gjøre dette så effektivt som mulig kan vi rett og slett, for en gitt pos-verdi (se programmet over), ikke bruke om igjen noen av de siff-verdiene som allerede er satt inn i sekv-arrayen til venstre for oss. Vi kan da tenke at disse verdiene er «brukt opp». Dette er det lett å legge inn for-løkka i gen-metoden i sekvensgeneratoren over, nemlig slik (der både alf og len er forandret til n): for(int siff = 0; siff < n; siff++){ <NYTT: Test om siff finnes blant verdiene sekv[0],,sekv[pos-1]. I så fall bare gå videre til neste siff-verdi i for-løkka>; sekv[pos] = siff; if (pos < n-1) { sekvgen(pos+1); // Rekusivt kall else { System.out.println(""); // Ny linje for (int i = 0; i < n; i++) System.out.print(sekv[i]); Det å teste om en verdi siff allerede er brukt kan gjøres på flere måter, men en rask og grei måte er å ha en tabell som viser hvilke tall som allerede er brukt til venstre for den posisjonen vi nå holder på med. Denne tabellen kan være en «boolean [] brukt = new boolean [n]», og den kan passelig være true for de verdier som er brukt, og false ellers. Programmeringen av dette blir da nokså rett etter nesa. Det eneste som kan være lett å glemme er at vi må ta vekk igjen avmerkingen i arrayen brukt når vi er ferdig med en siff-verdi i en gitt posisjon, og skal gå videre til neste verdi. Programmet kan da f.eks. ta seg slik ut: 6

class PermGen { int[] sekv; boolean[] brukt; int n; PermGen(int lengde){ // Konstruktør, initialisering n = lengde; sekv = new int[n]; brukt = new boolean[n]; for(int i = 0; i<n; i++) brukt[i] = false; // For ordens skyld void permgen(int pos){ for(int siff = 0; siff < n; siff++){ if (! brukt[siff]){ brukt[siff] = true; // Herfra er siff i bruk i denne pos sekv[pos] = siff; if (pos < n-1 ) { permgen(pos+1); // Rekursivt kall else { // Ny fullstendig sekvens er generert, og vi VET nå // at dette er en permutasjon (fullst. avskjæring) < Skriv ut sekv, eller test den for brukbarhet >; brukt[siff] = false; // Nå er ikke siff i bruk mer. Fullstendig avskjæring I programmet over ser vi at når vi endelig får fram en hel sekvens, så vil den, ut fra avskjæringen, også ha den egenskapen vi var ute etter, nemlig det å være en permutasjon. Og slik vil det ofte være når man legger inn så mye avskjæring på veien som vi klarer å få til, og vi kunne kalle dette fullstendig avskjæring. Merk at det ofte kan være lurt å legge ganske mye arbeid ned i å tenke ut gode avskjæringer, siden det potensielt er mye å spare på å skjære større «grener» av søketreet. 5 Korteste reiserute Vi skal så se på en mer praktisk oppgave, nemlig problem 2 i listen over, om en «reisende salgsmann» som må innom n byer, og som vil gjøre reisen så kort som mulig. Her er det en rekkefølge av byer vi skal finne, og vi kan anta at byene han/hun skal innom er nummerert fra 0 til n-1, og at det hele starter i by nummer 0, og man også skal tilbake dit. Avstanden mellom ethvert par av byer kan vi tenke oss finnes i en to-dimensjonal array avstand (som er symmetrisk, altså at avstand [i][j] = avstand [j][i]). En rekkefølge å besøke byene i kan vi se som en permutasjon av byene. Vi kan derved løse oppgaven ved å generere alle slike permutasjoner, regne ut reiselengdn for hver av dem og til slutt å velge den som gav minst lengde. Det blir, som omtalt over, vanligvis et svært stort antall permutasjoner å gå gjennom, og enhver avskjæring vil derfor være av interesse. Her er det mange mer eller mindre intrikate metoder og triks som kunne vurderes, men vi skal nøye oss med å se på en type avskjæring som typisk er aktuell når man, som her, er ute etter et maksimum eller et minimum. Dette vil dermed også være et eksempel på det som kalles «Branch-and-Bound». Den avskjæringen vi skal bruke går ut på å stoppe videre vurdering av en halvferdig vei så fort vi innser at videre utbygging av denne veien må føre til en løsning som er dårligere enn den beste vi allerede har sett. I vårt konkrete tilfelle kan vi generere permutasjonene slik vi gjorde lenger opp, samt 7

hele tiden holde orden på hvor lang den ruten vi har reist til nå, er. Når vi så vurderer hvilken by vi skal reise til som den neste, kan vi rett og slett teste om avstanden fram til denne byen pluss avstanden derfra direkte tilbake til startstedet, er lenger enn den korteste (fullstendige) reiseruten vi allerede har sett. Vi hopper her selvfølgelig også over de byene vi har vært innom, men det ligger allerede i avskjæringen for å lage permutasjoner og ikke sekvenser. Programmet under forutsetter i sin avskjæring at den såkalte trekant-ulikheten gjelder for avstandene. Det vil si at avstanden direkte mellom to byer aldri er lenger enn det å gå via en tredje vilkårlig valgt by. Vi kan observere at om vi gjør denne avskjæringen også når vi til slutt skal tilbake til startbyen fra siste by så blir dette en fullstendig avskjæring, i den betydning at når vi først kommer ut med en ny fullstendig rundreise, så er dette også en kortere reiserute enn den korteste vi har sett til nå. Programmeringen av dette blir ikke spesielt komplisert, og vi bruker permutsjonsprogrammet vi skrev over som utgangspunkt: class KortesteVei { int[] sekv; boolean[] brukt; int n; float[][] avst; // Avstand mellom alle par av byer. Er symmetrisk int[] bestevei; // Den beste fullstendige veien vi har sett så langt float bestelengde; // Lengden av bestevei float lengde; // Lengde fra start (by nr 0) til der vi er nå KortesteVei(int antall, float[][] avstand;){ // Initialisering n = antall; avst = avstand; sekv = new int[n]; brukt = new boolean[n]; for(int i = 0; i<n; i++) brukt[i] = false; bestevei = new int[n]; bestelengde = 10000000; // Må være større enn alle mulige veier // Legger nå inn by nr 0 som startby, og der ligger den hele tiden // MERK: Startkallet skal da være "kortestevei(1);" sekv[0] = 0; brukt[0] = true; // Testes egentlig ikke noen steder, men lengde = 0.0; void kortestevei(int pos){ for(int by = 1; by < n; by++){ // By nr 0 ligger fast i sekv[0]. if (! brukt[by] && lengde+avst[sekv[pos-1]][by] < bestelengde){ //Ny avskjæring brukt[by] = true; // Herfra er 'by' i bruk i denne pos lengde = lengde + avst[sekv[pos-1]][by]; // Oppdater avstand sekv[pos] = by; if (pos < n-1 ) { kortestevei(pos+1); // Rekursivt kall else { // Vet pos == n-1, og derfor ved siste by // Gjør her avskjærings-testen i spesiell variant: if (lengde + avst[by][0] < bestelengde){ // Registrer ny beste vei: for (int i=0; i < n; i++) { bestevei[i] = sekv[i]; bestelengde = lengde + avst[by][0]; brukt[siff] = false; // Nå er ikke siff i bruk mer lengde = lengde - avst[sekv[pos-1]][by]; // Avstand resettes 8

Man kan også lett tenke seg andre muligheter som kunne forbedret programmet. F.eks. vurderer programmet byene vi kan gå til i neste steg i en fast rekkefølge. Kanskje kunne man i hvert steg systematisk førsøke de nærmeste byene først, for derved tidligere å få en løsning som er rimelig god, og derved få bedre avskjæring i fortsettelsen av søket. Generelt kan vi si at det ofte kan lønne seg å bruke nokså tidkrevende tester til avskjæringen dersom det er håp om at de kan oppdage håpløse delløsninger på et tidlig tidspunkt. 6 Oppsummering om effektiv avskjæring Som en avslutning presenterer vi noen tommelfingerregler man kan vurdere når man skal forsøke å løse et kombinatorisk søkeproblem så effektivt som mulig. Vi er altså ute etter å finne «konstellasjoner» som tilfredstiller bestemte krav. Det vi har gjort i dette notatet kan da sees som at vi først deler kravet vårt i to deler, nemlig en «enkel» del og en «vrien» del. Den enkle delen bør da være slik at det er enkelt å generere alle konstellasjoner som tilfredstiller dette, og vi generer i utgangspunktet alle slike konstellasjoner, og for hver av disse må vi så sørge for at de også tilfredstiller den vriene delen av kravet. Dette vil man så forsøke å gjøre ved hjelp av kraftigst mulig avskjæring underveis. Oppdelingen av kravet i en enkel og en vrien del bør gjøres slik at mest mulig av kravet kommer inn i den enkle delen, men samtidig slik at man effektivt kan generere alle løsninger som tilfredstiller det enkle kravet. Måten man systematisk genererer alle løsninger på det enkle kravet bør være slik at det underveis er lettest mulig å teste ting som har med det vriene kravet å gjøre. At man så tidlig som mulig under genereringen av de enkle løsningene, klarer å kjenne igjen de del-løsninger som umulig kan utbygges til en fullstendig løsning som tilfredstiller den vriene delen av kravet (eller bli noen bedre løsning enn den beste vi allerede har funnet). I disse tilfellene må man da sørge for å avskjære videre generering av enkle løsninger. 7 Noen oppgaver Oppgave 1. Elever som skal stille opp Som et eksempel på hvordan vi kan lage en effektiv avskjæring, kan vi tenke oss at de forskjellige rekkefølgene blir generert slik at vi over en periode holder de til venstre for et visst punkt fast, mens vi til høyre for dette genererer alle mulige kombinasjoner av de resterende elevene. Over en periode under genereringen står da kanskje Anne og Britt helt til venstre i rekkefølgen, og om Anne og Britt er uvenner er det da tydeligvis bare tøv å generere alle mulige rekkefølger ut fra dette, da ingen av disse tydeligvis vil tilfredstille den vriene delen av testen. Vi skal siden se at det ofte er ganske lett å programmere slik at man ikke bruker tid på denslags tøv. Oppgave 2. Alle utplukk av n tall fra tallmengden {1, 2, 3,, m, der n m Vi skal vi se på det å gå gjennom alle mulige utplukk av f.eks. 7 elementer fra en mengde på 30. Vi kan tenke oss at vi skal plukke ut et lag på 7 personer fra en gruppe på 20 til en spesiell oppgave, og at reglene for hva som er «et godt lag» er så kompliserte at vi ikke finner noen rask algoritme til å plukke ut det beste laget. Vi satser derfor på å gå gjennom alle utplukk og vurdere hvert av dem. Ut fra hvordan reglene for «godheten» av et lag kan det sikkert også gjøres en del avskjæring, men oppgaven her er bare skrive selve programmet for å generere alle utplukk, og verdiene i utplukket skal ligge i 9

stigende rekkefølge. Oppgave 3. Sjakkbrett med 8-dronninger Vi skal her tenke oss at vi har et sjakkbrett (8 8 felter) og åtte dronning-brikker som vi ønsker å plassere på brettet slik at ingen av dem kan slå hverandre. To dronninger kan altså slå hverandre om de står i samme rad, samme kolonne eller samme diagonal. Skriv programmet slik at det virker for brett med N N felter, og slik at det finner alle løsninger for denne N-verdien. Det bør være mulig å finne løsninger også for noen N-verdier større enn 8. Oppgave 4. Plassering av 9 sifre Vi skal forsøke å finne måter å ordne de ni sifrene fra1 til 9 til en sekvens, slik at (det desimale) tallet som utgjøres av de to første sifrene er delelig med 2, tallet om utgjør de tre første er delelig med 3 osv., helt til at hele tallet er delelig med 9. Finn én, eller alle, måter å ordne de ni sifrene på slik at dette gjelder. Oppgave 5. Magiske kvadrater Vi tenker oss at et kvadratisk skjema på n ganger n ruter skal fylles med tallene fra 1 til n 2, slik at summen av tallene i hver kolonnene, i hver linje, samt i begge hoved-diagonalene blir den samme. Skriv et program som finner alle slike konstellasjoner for en gitt n. Det programmet undertegnede endte opp med lot seg lett utføre for n = 3, men allerede for n = 4 tar det gjerne noen minutter før den første løsninger kommer, og for n = 5 ser det håpløst ut allerede å få ut den første løsningen. Altså: Bedre avskjæring etterlyses! Kanskje finnes noen lure triks på nettet? 10