J'ai la question de devoirs suivante:
Implémentez les méthodes de pile Push (x) et pop () à l'aide de deux files d'attente.
Cela me semble étrange car:
J'ai cherché autour de:
et a trouvé quelques solutions. Voici ce que j'ai fini avec:
public class Stack<T> {
LinkedList<T> q1 = new LinkedList<T>();
LinkedList<T> q2 = new LinkedList<T>();
public void Push(T t) {
q1.addFirst(t);
}
public T pop() {
if (q1.isEmpty()) {
throw new RuntimeException(
"Can't pop from an empty stack!");
}
while(q1.size() > 1) {
q2.addFirst( q1.removeLast() );
}
T popped = q1.pop();
LinkedList<T> tempQ = q1;
q1 = q2;
q2 = tempQ;
return popped;
}
}
Mais je ne comprends pas quel est l'avantage par rapport à l'utilisation d'une seule file d'attente; la version à deux files d'attente semble inutilement compliquée.
Supposons que nous choisissions que les poussées soient les plus efficaces des 2 (comme je l'ai fait ci-dessus), Push
resterait le même, et pop
nécessiterait simplement d'itérer le dernier élément et de le renvoyer . Dans les deux cas, le Push
serait O(1)
, et le pop
serait O(n)
; mais la version à file d'attente unique serait considérablement plus simple. Il ne devrait nécessiter qu'une seule boucle for.
Suis-je en train de manquer quelque chose? Tout aperçu ici serait apprécié.
Il n'y a aucun avantage: il s'agit d'un exercice purement académique.
A il y a très il y a très longtemps, quand j'étais étudiant de première année à l'université, j'ai fait un exercice similaire1. L'objectif était d'enseigner aux étudiants comment utiliser la programmation orientée objet pour implémenter des algorithmes au lieu d'écrire des solutions itératives à l'aide de boucles for
avec des compteurs de boucles. Au lieu de cela, combinez et réutilisez les structures de données existantes pour atteindre vos objectifs.
Vous n'utiliserez jamais ce code dans le monde réelTM. Ce que vous devez retirer de cet exercice, c'est comment "sortir des sentiers battus" et réutiliser le code.
Veuillez noter que vous devez utiliser l'interface Java.util.Queue dans votre code au lieu d'utiliser directement l'implémentation:
Queue<T> q1 = new LinkedList<T>();
Queue<T> q2 = new LinkedList<T>();
Cela vous permet d'utiliser d'autres implémentations Queue
si vous le souhaitez, ainsi que de masquer2 les méthodes qui sont sur LinkedList
qui pourraient contourner l'esprit de l'interface Queue
. Cela inclut get(int)
et pop()
(pendant que votre code compile, il y a une erreur logique à cause des contraintes de votre affectation. Déclarer vos variables comme Queue
au lieu de LinkedList
le révélera). Lecture connexe: Comprendre la "programmation vers une interface" et Pourquoi les interfaces sont-elles utiles?
1Je me souviens encore: l'exercice consistait à inverser une pile en utilisant uniquement les méthodes sur l'interface de la pile et aucune méthode utilitaire dans Java.util.Collections
Ou autre " statiques uniquement "classes utilitaires. La bonne solution consiste à utiliser d'autres structures de données comme objets de conservation temporaires: vous devez connaître les différentes structures de données, leurs propriétés et comment les combiner pour le faire. Perplexe la plupart de ma classe CS101 qui n'avait jamais programmé auparavant.
2Les méthodes sont toujours là, mais vous ne pouvez pas y accéder sans transtypages ou réflexion. Il n'est donc pas facile d'utiliser ces méthodes sans file d'attente.
Il n'y a aucun avantage. Vous avez correctement réalisé que l'utilisation de files d'attente pour implémenter une pile entraîne une horrible complexité temporelle. Aucun programmeur (compétent) ne ferait jamais quelque chose comme ça dans la "vraie vie".
Mais c'est possible. Vous pouvez utiliser une abstraction pour en implémenter une autre et vice versa. Une pile peut être implémentée en termes de deux files d'attente, et vous pouvez également implémenter une file d'attente en termes de deux piles. L'avantage de cette exercice est:
En fait, c'est un excellent exercice. Je devrais le faire moi-même maintenant :)
Il y a certainement un véritable objectif de faire une file d'attente à partir de deux piles. Si vous utilisez des structures de données immuables à partir d'un langage fonctionnel, vous pouvez pousser dans une pile d'éléments pouvant être poussés et extraire d'une liste d'éléments pouvant être sautés. Les éléments détachables sont créés lorsque tous les éléments ont été retirés, et la nouvelle pile détachable est l'inverse de la pile pouvant être poussée, où la nouvelle pile pouvant être poussée est maintenant vide. C'est efficace.
Quant à une pile composée de deux files d'attente? Cela peut être logique dans un contexte où vous disposez d'un tas de files d'attente grandes et rapides. C'est définitivement inutile car ce genre d'exercice Java. Mais cela peut avoir du sens s'il s'agit de canaux ou de files d'attente de messagerie. (Par exemple: N messages mis en file d'attente, avec un O(1) opération pour déplacer (N-1) éléments à l'avant dans une nouvelle file d'attente.)
L'exercice est inutilement conçu d'un point de vue pratique. Le point est de vous forcer à utiliser l'interface de la file d'attente de manière intelligente pour implémenter la pile. Par exemple, votre solution "Une file d'attente" nécessite que vous parcouriez la file d'attente pour obtenir la dernière valeur d'entrée pour l'opération "pop" de la pile. Cependant, une structure de données de file d'attente ne permet pas d'itérer sur les valeurs, vous êtes limité pour y accéder dans le premier entré, premier sorti (FIFO).
Comme d'autres l'ont déjà noté: il n'y a pas d'avantage réel.
Quoi qu'il en soit, une réponse à la deuxième partie de votre question, pourquoi ne pas simplement utiliser une file d'attente, est au-delà de Java.
Dans Java même l'interface Queue
a une méthode size()
et toutes les implémentations standard de cette méthode sont O (1).
Ce n'est pas nécessairement vrai pour la liste chaînée naïve/canonique qu'un programmeur C/C++ implémenterait, ce qui ne ferait que garder des pointeurs vers le premier et le dernier élément et chaque élément un pointeur vers l'élément suivant.
Dans ce cas, size()
est O(n) et doit être évité dans les boucles. Ou l'implémentation est opaque et ne fournit que le strict minimum add()
et remove()
.
Avec une telle implémentation, vous devez d'abord compter le nombre d'éléments en les transférant dans la deuxième file d'attente, transférez n-1
éléments de retour à la première file d'attente et retourne l'élément restant.
Cela dit, cela ne constituerait probablement pas quelque chose comme ça si vous vivez en Java.
Il est difficile d'imaginer une utilisation pour une telle implémentation, c'est vrai. Mais l'essentiel est de prouver que cela peut être fait .
En termes d'utilisations réelles de ces choses, cependant, je peux penser à deux. Une utilisation pour cela consiste à implémenter des systèmes dans des environnements contraints qui n'ont pas été conçus pour cela : par exemple, blocs redstone de Minecraft turn pour représenter un système complet de Turing, que les gens ont utilisé pour implémenter des circuits logiques et même des processeurs entiers. Aux premiers jours du jeu basé sur des scripts, la plupart des premiers robots de jeu ont également été mis en œuvre de cette façon.
Mais vous pouvez également appliquer ce principe à l'envers, en vous assurant que quelque chose n'est pas possible dans un système quand vous ne le voulez pas . Cela peut se produire dans des contextes de sécurité: par exemple, des systèmes de configuration puissants peuvent être un atout, mais il existe encore des degrés de puissance que vous préféreriez ne pas donner aux utilisateurs. Cela limite ce que vous pouvez autoriser le langage de configuration, de peur qu'il ne soit renversé par un attaquant, mais dans ce cas, c'est ce que vous voulez.