Zum Inhalt springen

Queue mit zwei Stacks implementieren

Säule aus blanken Stahlscheiben auf Altrosa, eine Scheibe schwebt links davon in der Luft, darunter liegt eine einzelne glühende Kupferscheibe auf dem Boden

In diesem Teil der Tutorial-Serie zeige ich dir, wie du eine Queue mit zwei Stacks implementierst – zuerst auf die einfache Art, bei der jedes Einfügen alle Elemente umschichtet, dann so, wie es die klassische Interviewaufgabe erwartet: mit amortisiert konstanter Laufzeit.

Diese Variante hat keinen praktischen Nutzen, sondern ist in erster Linie eine Übungsaufgabe. Als solche ist sie das Gegenstück zur Implementierung eines Stacks mit einer Queue.

Zur Erinnerung: ein Stack ist eine Datenstruktur, bei der die Elemente in der umgekehrten Reihenfolge des Einfügens abgerufen werden, also eine Last-in-First-out-Datenstruktur (LIFO).

Wie können wir damit eine Queue, also eine First-in-First-out-Datenstruktur (FIFO) implementieren?

Die Lösung – Schritt für Schritt

Das erste Element, das wir in die Queue einfügen, legen wir auf einen Stack (im Beispiel: „banana“). Um es aus der Queue zu entnehmen, holen wir es wieder vom Stack:

Stack mit dem Element „banana“: Push legt es oben auf, Pop nimmt es oben wieder ab
Ein Element auf den Stack legen und wieder entnehmen

Mit dem zweiten Element funktioniert das so schon nicht mehr, denn der Stack arbeitet ja nach dem LIFO-Prinzip. Wenn z. B. „banana“ und „cherry“ auf dem Stack liegen, müssten wir „cherry“ als Erstes wieder entnehmen:

Stack mit „cherry“ über „banana“: „cherry“ liegt oben und würde zuerst entnommen
Zwei Elemente auf einem Stack: „cherry“ käme zuerst wieder herunter

Bei einer Queue wollen wir jedoch das zuerst eingefügte Element (also „banana“) auch als Erstes wieder entnehmen.

Das ist mit einem Stack allein nicht möglich.

Stattdessen gehen wir beim Einfügen eines Elements in die Queue wie folgt vor:

  1. Wir erzeugen einen temporären Stack (in der Grafik unten hellblau, beschriftet mit „Temporärer Stack“) und verschieben alle Elemente des ursprünglichen Stacks auf den temporären Stack.
  2. Wir legen das neue Element auf den ursprünglichen Stack.
  3. Wir schieben alle Elemente zurück vom temporären Stack auf den ursprünglichen Stack. Der temporäre Stack wird dann nicht mehr benötigt.

Die folgende Grafik zeigt die drei Schritte:

Drei Schritte: „banana“ wird auf den temporären Stack verschoben, „cherry“ auf den leeren Stack gelegt, „banana“ zurück auf den Stack verschoben
Zweites Element „cherry“ in die Queue einfügen

Danach liegen die Elemente so auf dem Stack, dass wir als Erstes das zuerst eingefügte Element „banana“ entnehmen können und dann das als Zweites eingefügte Element „cherry“.

Das funktioniert so nicht nur mit zwei Elementen, sondern mit beliebig vielen. Die folgende Grafik zeigt, wie wir ein drittes Element „grape“ in die Queue einfügen. Der erste und der dritte Schritt sind hier in 1a/1b und 3a/3b aufgeteilt, denn verschoben wird immer ein Element nach dem anderen:

Fünf Schritte: „banana“ und „cherry“ werden nacheinander auf den temporären Stack verschoben, „grape“ auf den leeren Stack gelegt, dann „cherry“ und „banana“ zurück auf den Stack
Drittes Element „grape“ in die Queue einfügen

Danach können wir die Elemente in First-in-First-out-Reihenfolge aus der Queue entnehmen, also erst die zuerst eingefügte „banana“, dann die „cherry“ und dann die zuletzt eingefügte „grape“.

Quellcode für die Queue mit Stacks

Der Quellcode für diesen Algorithmus benötigt nur wenige Zeilen Java-Code.

Als Stack verwende ich den im Stack-Tutorial vorgestellten ArrayStack. Genauso gut könntest du eine Deque-Implementierung wie ArrayDeque verwenden – oder die JDK-Klasse Stack, von der allerdings schon ihre eigene Dokumentation abrät. Warum, liest du in „Java Deque vs. Stack: Warum Deque die bessere Wahl ist“.

Du findest den Code in der Klasse StackQueue im GitHub-Repository.

public class StackQueue<E> implements Queue<E> {

  private final Stack<E> stack = new ArrayStack<>();

  @Override
  public void enqueue(E element) {
    // 1. Move elements from main stack to a temporary stack
    Stack<E> temporaryStack = new ArrayStack<>();
    while (!stack.isEmpty()) {
      temporaryStack.push(stack.pop());
    }

    // 2. Push new element on the main stack
    stack.push(element);

    // 3. Move elements back from temporary stack to main stack
    while (!temporaryStack.isEmpty()) {
      stack.push(temporaryStack.pop());
    }
  }

  @Override
  public E dequeue() {
    return stack.pop();
  }

  @Override
  public E peek() {
    return stack.peek();
  }

  @Override
  public boolean isEmpty() {
    return stack.isEmpty();
  }
}

Beachte, dass hier nicht das java.util.Queue-Interface implementiert wird. Dieses erbt von java.util.Collection, sodass wir wesentlich mehr Methoden implementieren müssten.

Stattdessen habe ich für dieses Tutorial ein eigenes Queue-Interface geschrieben, das nur die Methoden enqueue(), dequeue(), peek() und isEmpty() definiert:

public interface Queue<E> {
  void enqueue(E element);
  E dequeue();
  E peek();
  boolean isEmpty();
}

Zum Einsatz der Queue kannst du dir das Demo-Programm QueueDemo ansehen.

Laufzeit

Jede enqueue()-Operation verschiebt alle Elemente, die schon auf dem Stack liegen, auf den temporären Stack und wieder zurück. Bei n Elementen sind das 2 n Verschiebungen – enqueue() kostet also O(n). dequeue(), peek() und isEmpty() greifen nur auf das oberste Element des Stacks zu und laufen in konstanter Zeit, O(1).

Du kannst die Kosten auch umdrehen: Das neue Element kommt einfach oben auf den Stack, enqueue() läuft dann in O(1). Dafür muss dequeue() alle Elemente bis auf das unterste auf den temporären Stack schieben, das unterste entnehmen und die anderen wieder zurückschieben – und kostet O(n), genau wie peek().

Es geht aber besser als in diesen beiden Varianten.

Queue mit zwei Stacks in amortisiert O(1)

Der Trick: Wir behalten beide Stacks dauerhaft und geben ihnen feste Rollen. Auf den Eingabe-Stack legt enqueue() die neuen Elemente. Vom Ausgabe-Stack nimmt dequeue() sie herunter. Nur wenn der Ausgabe-Stack leer ist, schichten wir den gesamten Eingabe-Stack auf den Ausgabe-Stack um – das kehrt die Reihenfolge der Elemente um, und das zuerst eingefügte Element liegt oben.

Die folgende Grafik zeigt das für „banana“, „cherry“ und „grape“:

Sechs Schritte: „banana“ und „cherry“ kommen auf den Eingabe-Stack; beim ersten Entnehmen ist der Ausgabe-Stack leer, „cherry“ und „banana“ werden umgeschichtet und „banana“ entnommen; „grape“ kommt auf den Eingabe-Stack; beim nächsten Entnehmen liegt „cherry“ schon oben auf dem Ausgabe-Stack
Queue mit Eingabe- und Ausgabe-Stack: Umgeschichtet wird nur, wenn der Ausgabe-Stack leer ist

Die Klasse TwoStackQueue im GitHub-Repository setzt das so um:

public class TwoStackQueue<E> implements Queue<E> {

  private final Stack<E> inputStack = new ArrayStack<>();
  private final Stack<E> outputStack = new ArrayStack<>();

  @Override
  public void enqueue(E element) {
    inputStack.push(element);
  }

  @Override
  public E dequeue() {
    refillOutputStackIfEmpty();
    return outputStack.pop();
  }

  @Override
  public E peek() {
    refillOutputStackIfEmpty();
    return outputStack.peek();
  }

  @Override
  public boolean isEmpty() {
    return inputStack.isEmpty() && outputStack.isEmpty();
  }

  private void refillOutputStackIfEmpty() {
    if (outputStack.isEmpty()) {
      while (!inputStack.isEmpty()) {
        outputStack.push(inputStack.pop());
      }
    }
  }
}

Ein einzelner dequeue()-Aufruf kann immer noch O(n) kosten – dann nämlich, wenn er den Ausgabe-Stack auffüllen muss. Aber jedes Element wird dabei genau einmal vom Eingabe-Stack genommen und einmal auf den Ausgabe-Stack gelegt; dazu kommen das eine push() beim Einfügen und das eine pop() beim Entnehmen. Mehr als diese vier Stack-Operationen fallen für ein Element nie an, egal wie viele Aufrufe dazwischenliegen. Für eine beliebige Folge von n enqueue()- und dequeue()-Aufrufen sind das zusammen höchstens 4 n Operationen – im Durchschnitt also konstant viele pro Aufruf. Diese Betrachtung nennt man amortisierte Laufzeit: enqueue() und dequeue() laufen in amortisiert O(1).

Genau diese Variante ist die Lösung der bekannten Interviewaufgabe „Implement Queue using Stacks“. Auch mit amortisiert konstanter Laufzeit bleibt sie für die Praxis eine Übungsaufgabe: Eine ArrayDeque leistet dasselbe mit einem einzigen Array und ohne Umschichten.

Ausblick

Im nächsten Teil des Tutorials erfährst du, wie man eine Queue mit einer verketteten Liste implementiert.

Wenn dir der Artikel weitergeholfen hat, würde ich mich sehr über eine positive Bewertung auf meinem ProvenExpert-Profil freuen. Dein Feedback hilft mir, meine Inhalte weiter zu verbessern und motiviert mich, neue informative Artikel zu schreiben.

👉 Bewertung abgeben

Datenstrukturen wirklich verstehen?

Dieser Artikel zeigt eine Datenstruktur. „Mastering Data Structures in Java“ zeigt dir alle – und vor allem, wann du welche einsetzt und warum das über die Laufzeit deiner Anwendung entscheidet.

Der Premium-Online-Kurs ist gerade geschlossen. Auf der Warteliste erfährst du als Erste:r, wenn er wieder öffnet – und bekommst das Angebot vor allen anderen.

Zur Warteliste

Werde ein:e bessere:r Java-Entwickler:in

Mit meinem kostenlosen Newsletter bleibst du vorn. Modernes Java: neue Versionen & Features, Performance und JVM-Insights – 1x im Monat.

Suche