Arbeitsgruppe Diskrete Mathematik Computerorientierte Mathematik II
Sommersemester 2000
Joswig / Jahn / Liebchen / Schwartz

2. Programmieraufgabe

TU Berlin, Math. Dep.
Fachbereich
Mathematik

Einleitung

Zunächst einmal: Die zweite Programmieraufgabe ist viel einfacher als sie aussieht. Laßt Euch durch die vermeintlich hohe Komplexität des erwarteten Programms nicht abschrecken, wir haben die Aufgabe für Euch in kleine überschaubare Häppchen unterteilt.

Daher vergeßt beim Programmierens eines Häppchens mal all das andere, was noch wartet, und konzentriert Euch ganz auf den aktuellen Programmteil.

Wir sind uns bewußt, daß das Vorgeben von Interfaces Eure Kreativität einschränkt, aber andererseits ist das vielleicht ganz gut.


Inhalt


Überblick

screenshot

Der Kern Eures Programms wird eine Graphenklasse sein, mit der man Wege im Graphen suchen kann. Da Ihr mit Adjazenzlisten arbeiten sollt, benötigt Ihr natürlich eine schöne Listenklasse. Wie später klar wird, ist es notwendig, daß Ihr Knoten nach verschiedenen Kriterien sortieren könnt, also muß Eure Listenklasse eine Sortiermethode haben.

Daten müssen natürlich aus Dateien eingelesen werden. Allerdings liegen die Daten in einer etwas unbequemen Form vor: Als Fliesen (tiles), die irgendwo (durcheinander) in der Ebene liegen und ein bis vier "Ausgänge" haben. Das deutet, daß Ihr in Eurem Graphen nebeneinander liegende Fliesen, die Knoten entsprechen, mit Kanten verbinden müßt, wenn sie sich berührende Ausgänge haben.

Ist der Graph aufgebaut kann man in ihm einfach einen Weg zwischen zwei in der Datei benannten speziellen Knoten finden (wenn es einen gibt). Zur graphischen Ausgabe von Graph und Weg steht Euch eine von uns geschriebene Klasse zur Verfügung, die Ihr einfach in Euren Frame reinpacken könnt.

Ihr sollt dann einen Listener für Mausklicks installieren, mit dem der Benutzer Kanten im Graphen einfügen und löschen kann. Die Logik dabei ist einfach: Gibt es an der angeklickten Stelle schon eine Kante, wird sie gelöscht, ansonsten eingefügt. Berechnet anschließend gleich einen neuen Weg und zeigt ihn an.

Als besonderer Service sind bereits alle Exception-Klassen vorhanden.

Los geht's

Wir stellen Euch ein paar Klassen und interfaces zur Verfügung, die im labyrinth-Package zusammengefaßt sind. Um es zu benutzen, müßt Ihr es zunächst in Euren Bereich ansprechbar machen mit

  ln -s ~co2-004/labyrinth ~/prog2/labyrinth
Mal vorausgesetzt, daß Eurer Verzeichnis für das zweite Programm prog2 heißt und schon existiert. Es wird ein Verzeichnis ~/prog2/labyrinth angelegt (genauer gesagt: ein Link), in dem Ihr die nötigen Dateien findet.

Ersetzt ggf. prog2 durch den Verzeichnisnamen Eures Programmverzeichnisses, z.B. durch Programme/Programm2. Damit obiges Kommando funktioniert, muß das Verzeichnis schon existieren.

In jeder Eurer Dateien müßt Ihr als erste Zeile ein

  import labyrinth.*;
stehen haben, damit der Java-Compiler die gestellten Klassen/Interfaces finden kann. Eine Liste der gestellten Dateien ist verfügbar, außerdem umfangreiche Dokumentation.

Es ist sinnvoll, wenn Ihr in folgender Reihenfolge der Implementierung vorgeht:

Die Liste

Ihr sollt eine Listenklasse implementieren, die dem SortableListWithPointer-Interface genügt. Da SortableListWithPointer von ListWithPointer abgeleitet ist, muß Eure Klasse auch diesem Interface entsprechen. Ihr braucht jedenfalls nur eine Listenklasse implementieren!

Die meisten der benötigten Methoden sind Euch so (oder ähnlich) bereits vorgemacht worden und sollten daher kein Problem darstellen.

Der Clou an der Listenklasse ist, daß sie sich per sort selbst sortieren kann. Dafür sollt Ihr Mergesort auf Listen implementieren. (Tip: Achtet darauf, daß Eure Referenz auf die jeweiligen ersten Listenelemente immer stimmt.) Damit nach verschiedenen Kriterien sortiert werden kann, wird an sort eine Instanz eines Comparers gegeben, deren einzige Methode isGreaterThan zum Vergleich benutzt wird.

Die Korrektheit Eures Mergesorts testet bitte mit folgendem Hauptprogramm (einfach in die Listenklasse einfügen). Statt MyList müßt Ihr natürlich den Namen Eurer Klasse eintragen.

  /**
   * Main for testing the sort algorithm.
   *
   * @param argv ignored.
   */  
  public static void main(String argv[])
  {
    // Test list implementation for lists with increasing length.
    try
      {
	for(int length = 0; length <= 40; length++)
	  {
	    MyList L = new MyList();  // Use your list implementation here!
	    for(int i = 0; i < length; i++)
	      L.insertFront(new Integer((int)Math.round(Math.random() * 100 - 50)));

	    System.out.println("length=" + length);

            Comparer cmp;


	    // Sort in ascending order.
            cmp = new Comparer() {
	      public boolean isGreaterThan(Object o1, Object o2) throws CompareException
		{
		  return ((Integer)o1).intValue() > ((Integer)o2).intValue();
		}
	      };

     	    L.sort(cmp);
	    
	    System.out.print("ascending: " + L + " "); 
	    if (L.isSorted(cmp))
              System.out.println("OK");
	    else
              System.out.println("failed");
	    

	    // Sort in descending order.
            cmp = new Comparer() {
	      public boolean isGreaterThan(Object o1, Object o2) throws CompareException
		{
		  return ((Integer)o1).intValue() < ((Integer)o2).intValue();
		}
	      };

     	    L.sort(cmp);
	    
	    System.out.print("descending: " + L + " "); 
	    if (L.isSorted(cmp))
              System.out.println("OK");
	    else
              System.out.println("failed");
	    
	  }
      }
    catch(CompareException e)
      {
	System.err.println("Got " + e.getMessage() + ". This should never happen.");
      }
  }    

Tip: Es ist dringend anzuraten, immer am Ende der Methode sort die Methode isSorted zu fragen, ob das Sortieren funktioniert hat!

Die Graphenklasse

Für den (ungerichteten!) Graph sind zwei Interfaces zu implementieren: Node für Knoten und LabyrinthGraph für den eigentlichen Graphen.

Für die Adjazenzlisten (Listen von Knoten!) verwendet Ihr natürlich Eure Listenklasse. Dabei werden für jeden Knoten die Knoten gespeichert, zu denen er eine Kante besitzt. Das bedeutet, daß jede Kante durch insgesamt zwei Einträge in Adjazenzlisten repräsentiert wird: der eine Endknoten in der Liste des anderen und umgekehrt. Beachtet das insbesondere bei der Implementation von insertEdge.

Die Methode computePath laßt Ihr zunächst (in der ersten Woche) leer:

  public Path computePath(Node n1, Node n2) { return null; }

Die Knoten beinhalten außerdem noch die Information, welche Sorte Fliese denn (gerade) ihnen jeweils entspricht. Die dazu notwendige Klasse Tile ist netterweise von uns für Euch schon geschrieben worden.

Der Zwei-Phasen-Graph

Im Leben Eures Graphen sind zwei Phasen zu unterscheiden. In der ersten wird er eingelesen und aufgebaut, in der zweiten benutzt. Diese Unterscheidung ist wichtig, was die Behandlung des Fliesentypen angeht:

Einlesen der Graphen

Das Dateiformat ist sehr einfach: Pro Fliese ist eine Zeile vorgesehen, die aus vier bzw. fünf (durch mind. ein Leerzeichen getrennte) Tokens besteht:

tile Spalte Zeile Fliesentyp [s|d]

Spalte und Zeile sind ganze Zahlen (auch negative), der Fliesentyp ist eine ganze Zahl von 1 bis 15. An jeweils genau einer Fliese der Datei steht als fünftes Token s bzw. d, was anzeigt, daß diese Fliese der Anfang (s) bzw. das Ende (d) des zu berechnenden Weges sein soll. Beispiel:

A simple labyrinthe
tile 50 50 9
tile 51 51 6 d
tile 50 51 3
tile 51 50 12 s

Anmerkung: Da der Graph ungerichtet ist, ist es egal, ob ein Weg von s zu d oder umgekehrt berechnet wird.

Weitere Labyrinthe sind auch verfügbar.

Zulässige Labyrinthe

Eine solche Menge von Fliesen nennen wir zulässig, wenn je zwei aneinander grenzende Fliesen entweder beide eine Verbindung über "Ausgänge" zueinander haben, oder beide keine haben. "In der Luft hängende" Fliesenausgänge darf es nicht geben (und müssen von Euch erkannt werden, s.u.) Ebenso ist erforderlich daß genau ein Knoten als Startknoten und genau ein anderer als Zielknoten gekennzeichnet ist.

Beispiele:

erlaubtverboten
tile 1tile 5 tile 4
tile 1tile 0 tile 4
tile 9tile 5 tile 12
tile 3tile 5 tile 6
tile 9tile 5 tile 12
tile 13tile 5 tile 7
tile 9tile 13 tile 12
tile 3tile 7 tile 6
tile 9tile 5 tile 12
tile 3tile 7 tile 6

Wohl erlaubt ist, daß das Labyrinth nicht zusammenhängend ist, solange keine offenen Wegstücke vorhanden sind:

ein erlaubtes Labyrinth

Die Fliesentypen

Die Zuordnung der Fliesen läßt sich aus folgender Tabelle entnehmen:

tile 1 tile 2 tile 3 tile 4 tile 5 tile 6 tile 7 tile 8 tile 9 tile 10 tile 11 tile 12 tile 13 tile 14 tile 15
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15

Ihr müßt (und sollt!) die Zuordnung von Fliesentyp zu verfügbaren Richtungen nicht selbst vornehmen, dafür gibt es in Tile schon vorgefertigt die Methoden getDirection, getDirections, setDirection und setDirections, mit denen Ihr abfragen/setzen könnt, ob es einen "Ausgang" in einer bestimmten Richtung gibt.

Die Benutzung von "leeren" Knoten für "leere" Felder ist verboten.

Einfügen der Kanten

Beim Einlesen fügt Ihr zunächst nur die Knoten in den Graphen ein (mit Koordinaten und Fliesentyp). Erst in einem weiteren Schritt werden in der Methode addEdgesAndCheckTiles, die am Ende von readFromFile aufgerufen werden soll, die Kanten erstellt.

Um zu entscheiden, ob an einem Knoten in einer bestimmten Richtung (die die Fliese angibt), eine Kante eingefügt werden soll -- oder ob gar eine unzulässige Datei vorliegt  muß man den direkt in dieser Richtung benachbarten Knoten anschauen und prüfen, ob seine Fliese eine entgegenkommende Richtung vorsieht.

Die Fliesen liegen jedoch im allgemeinen völlig durcheinander in der Knotenliste Eures Graphen, so daß man den Nachbar anhand seiner Koordinaten erstmal suchen müßte, was uns mit O(#Anzahl Knoten) zu lange dauert.

Deshalb sollt Ihr eine etwas fortgeschrittene Methode einsetzen, bei der zuerst alle waagerechten Kanten und dann alle senkrechten Kanten eingefügt werden:

  1. Zunächst wird mit sortNodesRowThenColumn die Knotenliste des Graphen so sortiert, daß alle Knoten einer Zeile von links nach rechts hintereinander liegen. Erreicht wird dies durch Euren Mergesort, dem ein entsprechender Comparer gegeben wird, der eine lexikographische Ordnung auf den Knoten vorgibt, bei der der Zeilenindex Vorrang hat vor dem Spaltenindex.
  2. Wenn Ihr jetzt die Knotenliste von vorne an durchlauf, erfolgt der Durchlauf im wesentlichen zeilenweise. Da natürlich alle Zeilen hintereinander in der Liste liegen, müßt Ihr aufpassen, wann Ihr eine neue Zeile betretet.

    In jeder Zeile ist es jetzt einfach zu überprüfen, ob zwei hintereinanderliegende Fliesen zueinander passen. Wenn ja, fügt eine Kante zwischen ihren Knoten ein, wenn nein, gebt eine Fehlermeldung aus. Beachtet, daß Ihr den ersten und letzten Knoten jeder Zeile gesondert betrachten müßt, da es ja sein könnte, daß er verbotene "in der Luft hängende" Ausgänge nach außen hat. Beachtet auch, daß bei "Löchern" in einer Zeile die Knoten links und rechts eines Loches zwar hintereinander in der Liste liegen, aber keinesfalls verbunden werden dürfen.

  3. Jetzt wird das ganze mit den Spalten gemacht. Sortiert also die Knotenliste lexikographisch mit Spalte hat Vorrang vor Zeile (sortNodesColumnThenRow).
  4. Nun durchlauft ganz analog zum zweiten Punkt die Liste spaltenweise und prüft hintereinanderliegende Fliesen und fügt ggf. senkrechte Kanten ein.

Beachtet: Innerhalb von addEdgesAndCheckTiles darf nicht findNode. Zum Testen schenken wir Euch ein toString für den Graphen:

  /**
   * Convert whole graph to textual representation.
   *
   * @return string.
   */
  public String toString()
  {
    StringBuffer sb = new StringBuffer();
    Node n1, n2;
    ListWithPointer nodes = getNodes(), adjacentNodes;
    
    for(nodes.reset(); !nodes.atEnd(); nodes.advance())
      {
	n1 = (Node)nodes.getCurrent();
	sb.append(n1 + " adjacent nodes: ");
	adjacentNodes = n1.getAdjacentNodes();
	for(adjacentNodes.reset(); !adjacentNodes.atEnd(); adjacentNodes.advance())
	  {
	    n2 = (Node)adjacentNodes.getCurrent();
	    sb.append(n2 + " ");
	  }
	sb.append("\n");
      }
    return sb.toString();
  }

Mit einem einfachen main läßt sich dann ein Graph einlesen, aufbauen und ausgeben:

  /**
   * This is just for debugging.
   *
   * @param argv first argument has to be a filename.
   */   
  public static void main(String[] argv)
  {
    LabyrinthGraph G = null;

    if(argv.length != 1)
      {
	System.err.println("Filename as argument expected!");
	return;
      }
    try
      {
	G = new MyGraph(argv[0]);  // Read graph. Use your class name!
      }
    catch(IOException e)
      {
	System.err.println("IOException: " + e.getMessage());
	return;
      }
    catch(LabyrinthException e)
      {
	System.err.println("LabyrinthException: " + e.getMessage());
	return;
      }
    
    System.out.println(G);  // toString() is your friend.
  }

So, das war der Teil der ersten Woche. Und nicht vergessen: Es sieht schlimmer aus als es ist.

Wege suchen

Als leichte Übung ist zunächst als Klasse für Wege das Interface Path zu implementieren.

Ein Weg im Graphen zwischen den als Start und Ziel ausgezeichneten Knoten -- so einer überhaupt existiert -- läßt sich nun sehr leicht rekursiv über Tiefensuche (DFS - Depth First Search) finden. Es genügt uns, wenn computePath irgendeinen Weg zwischen diesen Knoten zurückliefert, er muß weder kurz noch schön sein.

Visualisierung des Labyrinths

Dank der schon vorprogrammierten Klasse LabyrinthViewer ist es Euch ein leichtes, das Labyrinth graphisch anzuzeigen. Entwerft wie üblich eine Klasse, die von Frame abgeleitet ist und den ganzen Oberflächenkram übernimmt. Alle anderen Euren Klassen sollen von Oberfläche nichts wissen.

Der LabyrinthViewer wird wie jede andere Component in Euer Fenster per add eingefügt. Der anzuzeigende Graph mit/ohne Weg wird dann per setLabyrinth gesetzt. Das Malen geschieht dann automatisch.

Da ein neuer mit setLabyrinth gesetzter Graph eine andere Ausdehnung als der alte haben, paßt sich der LabyrinthViewer automatisch in der Größe an. Dafür muß sich allerdings auch die Fenstergröße ändern, weshalb Ihr direkt nach dem Aufruf von setLabyrinth einmal die pack Methode Eures Frames aufrufen solltet:

    setLayout(new BorderLayout());
    add("Center", viewer = new LabyrinthViewer());
    .
    .
    .
    viewer.setLabyrinth(graph, path);
    pack();

Ihr sollt eine Menüzeile vorsehen, in deren "File"- (oder "Datei"-)Menü sich ein Punkt zum Laden eines neuen Labyrinths befindet (über einen FileDialog).

Per Maus das Labyrinth ändern

Wie schon oben erklärt, soll der Benutzer in Eurem Graphen Kanten einfügen und löschen können. Nach einer solchen Aktion sollt Ihr sofort einen neuen Weg berechnen (vielleicht kommt allerdings wieder der gleiche raus) und ihn mitsamt dem geänderten Graphen anzeigen.

Zum Abfragen der Mausklicks könnt Ihr ganz analog zum bekannten ActionListener einen LabyrinthClickListener mit addLabyrinthClickListener beim LabyrinthViewer installieren.

    viewer.addLabyrinthClickListener(new LabyrinthClickListener() {
      public void actionPerformed(LabyrinthClickEvent e)
	{
	  handleClick(e);
	}
    });

Bei geeignetem Mausklick wird dann das actionPerformed des Listeners aufgerufen. Übergeben wird ihm dabei ein LabyrinthClickEvent-Objekt, das die Koordinaten der Fliese enthält und den Bereich der Fliese, auf den geklickt wurde (getPart).

Jetzt gilt es herauszufinden, ob sich an dieser Stelle bereits eine Kante befindet, und wenn ja, welche. Beachtet, daß Ihr ggf. neue Knoten erzeugen müßt, wenn der Benutzer eine Kante irgendwo haben möchte, wo noch nicht ein oder gar beide Endknoten existieren. Ebenso sollt Ihr einen Knoten löschen, wenn er keine zu ihm inzidende Kante mehr besitzt (also seine Adjazenzliste leer ist). Das gilt nicht für den Start- bzw. Endknoten, diese müssen immer existieren.

Das angezeigte Labyrinth soll stets im obigen Sinne zulässig sein, es darf keine "in der Luft hängende" Wegstücke geben. Spätestens hier wird Euch auffallen, wenn Eure Routinen zum Einfügen und Löschen von Kanten nicht dafür sorgen, daß die Fliesen der Knoten immer auf dem neuesten Stand sind.

Wer Lust hat, kann jetzt noch eine Speicherroutine einbauen und hat einen praktischen Labyrintheditor, um den ihn jeder beneiden wird.

Wir wünschen Euch viel Spaß und vor allem viele lehrreiche Momente mit dieser Programmieraufgabe.

Ressourcen


Alexander Schwartz
Olaf Jahn
Last modified: Tue Apr 25 17:23:34 MET DST 2000
Valid HTML 4.0!