INF01203 - Estruturas de Dados - Instituto de Informática (UFRGS) - Prof. Dennis Giovani Balreira



Aula 9c - Deques

Para fechar esta unidade, vemos o TAD Deque (double-ended queue, fila de duas pontas): uma generalização de pilhas e filas, que permite inserir e remover em ambas as extremidades.


1. O TAD Deque

Um deque não impõe a restrição LIFO da pilha nem a restrição FIFO da fila — ele simplesmente permite operar nas duas pontas, deixando a cargo de quem usa o deque decidir que disciplina (LIFO, FIFO, ou uma mistura) faz sentido para o problema:

insere/remove aqui                    insere/remove aqui
        |                                     |
        v                                     v
     +-------+     +-------+     +-------+
     |   A   | --> |   B   | --> |   C   |
     +-------+     +-------+     +-------+

Operações

Operação Descrição
insereInicio Insere um elemento na frente do deque.
insereFim Insere um elemento no final do deque.
removeInicio Remove e retorna o elemento da frente.
removeFim Remove e retorna o elemento do final.
estaVazio Indica se o deque não possui elementos.

Repare que uma pilha nada mais é do que um deque em que só usamos insereInicio/removeInicio (ou só insereFim/removeFim); e uma fila é um deque em que usamos insereFim para inserir e removeInicio para remover. O deque é, portanto, mais geral que as duas estruturas anteriores.


2. Implementação: reaproveitando a lista duplamente encadeada

Para que todas as quatro operações de inserção/remoção sejam O(1), precisamos de acesso direto às duas pontas e capacidade de andar nos dois sentidos — exatamente o que a ListaDupla da Aula 6a já oferece. Não é por acaso: um deque é, na prática, uma lista duplamente encadeada usada através de uma interface mais restrita.

// Reaproveitando NodoD e ListaDupla da Aula 6a
typedef ListaDupla Deque;

void inicializa(Deque *d)                          { inicializa(d); }
int  estaVazio(Deque *d)                            { return d->ini == NULL; }
int  insereInicioDeque(Deque *d, Produto prod)      { return insereInicio(d, prod); }
int  insereFimDeque(Deque *d, Produto prod)         { return insereFim(d, prod); }
Removendo pelas pontas. As Aulas 4/6a implementaram remoção por código (removePorCod), não pelas pontas. Para um deque de verdade, seriam necessárias duas novas funções — removeInicio e removeFim — que removem especificamente o primeiro ou o último nodo, sem buscar por código. A lógica é parecida com removePorCod, mas mais simples: já sabemos exatamente qual nodo remover (d->ini ou d->fim), sem precisar percorrer a lista.

3. Aplicações

  • Histórico de navegação com "voltar" e "avançar" nos dois sentidos (como já mencionado na Aula 6a) pode ser modelado como um deque;
  • Algoritmos de janela deslizante (por exemplo, encontrar o máximo em cada sub-intervalo de tamanho fixo de um vetor) costumam usar um deque para inserir/remover elementos em ambas as pontas conforme a janela se move;
  • Escalonadores com work stealing: cada processo/thread mantém uma fila de tarefas em formato de deque — insere e remove suas próprias tarefas de uma ponta, enquanto outros processos ociosos podem "roubar" tarefas da ponta oposta.

Resumo

  • Deque: permite inserir e remover em ambas as extremidades — generaliza pilha e fila.
  • Pilha = deque usado só em uma ponta (LIFO). Fila = deque usado com inserção em uma ponta e remoção na outra (FIFO).
  • Implementação natural: a lista duplamente encadeada (Aula 6a) já oferece tudo que um deque precisa, em O(1) nas duas pontas.
  • Aplicações: histórico de navegação bidirecional, algoritmos de janela deslizante, escalonadores com work stealing.

Exercícios

  1. Explique por que uma pilha e uma fila podem ser vistas como "casos particulares" de um deque.
  2. Por que a lista duplamente encadeada da Aula 6a é uma base natural para implementar um deque, e não a lista simplesmente encadeada da Aula 4?
  3. Se você tivesse que implementar um deque usando um vetor (em vez de lista encadeada), que problema da fila por vetor (Aula 9b) você esperaria enfrentar novamente, e como o resolveria?
  4. Implemente uma função int removeInicio(Deque *d, Produto *prodRemovido) que remova o primeiro elemento de um deque (baseado em ListaDupla), devolvendo o produto removido via prodRemovido.
  5. Implemente o deque por vetor circular. Defina a struct DequeVet, com um vetor de capacidade fixa (CAPACIDADE), um índice frente e uma quantidade qtd de elementos (no mesmo estilo da FilaVet da Aula 9b), e implemente inicializa, estaVazio, insereFim e removeInicio. Depois, implemente também insereInicio e removeFim — as duas novas operações que um deque tem e uma fila não tem. Dica: para "andar para trás" a partir de frente sem obter um índice negativo, use (frente - 1 + CAPACIDADE) % CAPACIDADE.

Sugestões de Respostas dos Exercícios

Exercício 1

Porque ambas podem ser expressas usando apenas um subconjunto das operações de um deque: uma pilha usa insereInicio/removeInicio (ou, de forma equivalente, só insereFim/removeFim) — sempre a mesma ponta. Uma fila usa insereFim para inserir e removeInicio para remover — pontas diferentes para cada operação. O deque não impõe nenhuma dessas restrições, permitindo os dois comportamentos (e combinações deles) através da mesma estrutura.


Exercício 2

Porque um deque exige acesso O(1) às duas pontas para inserção e remoção. A lista simplesmente encadeada só oferece isso para o início (Aula 4); remover do final exigiria percorrer a lista inteira para encontrar o penúltimo nodo, tornando essa operação O(n). A lista duplamente encadeada já resolve isso, guardando ini e fim, e permitindo andar nos dois sentidos com ant/prox.


Exercício 3

O mesmo problema da fila ingênua por vetor (Aula 9b): sem tratamento especial, os índices das duas pontas só andariam em uma direção, desperdiçando espaço já liberado. A solução seria a mesma: tratar o vetor como circular, usando aritmética modular (% CAPACIDADE) para calcular as posições das duas pontas, de forma parecida com a fila circular vista na Aula 9b.


Exercício 4
int removeInicio(Deque *d, Produto *prodRemovido) {
    NodoD *removido;

    if (d->ini == NULL) // deque vazio
        return 0;

    removido = d->ini;
    *prodRemovido = removido->dado;

    d->ini = removido->prox;
    if (d->ini != NULL)
        d->ini->ant = NULL;
    else
        d->fim = NULL; // deque ficou vazio

    free(removido);
    return 1;
}

A lógica é uma versão simplificada de removePorCod (Aula 6a): como já sabemos que o nodo a remover é exatamente d->ini, não é preciso percorrer a lista para localizá-lo — só tratar a atualização de d->ini (e, se necessário, d->fim, no caso de o deque ficar vazio).


Exercício 5
#define CAPACIDADE 5

typedef struct {
    Produto dados[CAPACIDADE];
    int frente;
    int qtd;
} DequeVet;

void inicializa(DequeVet *d) {
    d->frente = 0;
    d->qtd = 0;
}

int estaVazio(DequeVet *d) {
    return d->qtd == 0;
}

int insereFim(DequeVet *d, Produto valor) {
    int posicaoFinal;

    if (d->qtd == CAPACIDADE)
        return 0;

    posicaoFinal = (d->frente + d->qtd) % CAPACIDADE;
    d->dados[posicaoFinal] = valor;
    d->qtd++;
    return 1;
}

int removeInicio(DequeVet *d, Produto *valorRemovido) {
    if (estaVazio(d))
        return 0;

    *valorRemovido = d->dados[d->frente];
    d->frente = (d->frente + 1) % CAPACIDADE;
    d->qtd--;
    return 1;
}

// As duas operações "novas" em relação à FilaVet da Aula 9b:

int insereInicio(DequeVet *d, Produto valor) {
    if (d->qtd == CAPACIDADE)
        return 0;

    d->frente = (d->frente - 1 + CAPACIDADE) % CAPACIDADE; // "anda para trás"
    d->dados[d->frente] = valor;
    d->qtd++;
    return 1;
}

int removeFim(DequeVet *d, Produto *valorRemovido) {
    int posicaoFinal;

    if (estaVazio(d))
        return 0;

    posicaoFinal = (d->frente + d->qtd - 1) % CAPACIDADE;
    *valorRemovido = d->dados[posicaoFinal];
    d->qtd--;
    return 1;
}

insereFim e removeInicio são idênticas às da FilaVet (Aula 9b). As novidades são insereInicio, que precisa "andar para trás" a partir de frente — daí o truque (frente - 1 + CAPACIDADE) % CAPACIDADE, que evita obter um índice negativo do operador módulo em C — e removeFim, que calcula diretamente a posição do último elemento a partir de frente e qtd, sem precisar de um índice fim separado.