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



Aula 9b - Filas

Nesta aula estudamos o TAD Fila, a segunda estrutura especializada desta unidade. Assim como a pilha (Aula 9a), a fila restringe onde inserções e remoções podem ocorrer — mas em vez de restringir tudo a uma única ponta, ela usa duas pontas diferentes: uma para inserir, outra para remover.


1. O TAD Fila

Uma fila (queue) é uma lista em que inserções ocorrem sempre em uma extremidade (o final, ou rear) e remoções sempre na outra (a frente, ou front). Isso faz com que o primeiro elemento inserido seja sempre o primeiro a ser removido — comportamento conhecido pela sigla FIFO (First-In, First-Out).

frente                                    final
  |                                         |
  v                                         v
+-------+     +-------+     +-------+     +-------+
|   A   | --> |   B   | --> |   C   | --> |   D   |
+-------+     +-------+     +-------+     +-------+
 (sai daqui)                              (entra aqui)

A analogia mais comum é a de uma fila de banco ou de fila de impressão: quem chega primeiro é atendido primeiro; novas chegadas entram sempre no final da fila.

Operações

Operação Descrição
enqueue Insere um novo elemento no final da fila.
dequeue Remove e retorna o elemento da frente da fila.
frente (front) Retorna o elemento da frente, sem removê-lo.
estaVazia Indica se a fila não possui elementos.
tamanho Retorna o número de elementos na fila.

2. Implementação por lista encadeada

Assim como fizemos com a pilha (Aula 9a), implementamos a fila principal desta disciplina sobre uma lista encadeada, sem limite de capacidade. Diferente da pilha, porém, a fila precisa de acesso rápido às duas pontas — a mesma ideia de ListaDupla (Aula 6a), mas com nodos simplesmente encadeados:

typedef struct {
    int cod;
    char nome[50];
    float preco;
} Produto;

typedef struct str_Nodo Nodo;

struct str_Nodo {
    Nodo *prox;
    Produto dado;
};

typedef struct {
    Nodo *frente;
    Nodo *final;
} FilaEnc;
void inicializa(FilaEnc *f) {
    f->frente = NULL;
    f->final = NULL;
}

int estaVazia(const FilaEnc *f) {
    return f->frente == NULL;
}

int enqueue(FilaEnc *f, Produto valor) {
    Nodo *novo = (Nodo*) malloc(sizeof(Nodo));
    if (novo == NULL)
        return 0;

    novo->dado = valor;
    novo->prox = NULL;

    if (f->final != NULL)
        f->final->prox = novo; // o antigo final passa a apontar para o novo nodo
    else
        f->frente = novo;      // fila estava vazia: novo nodo também é a frente

    f->final = novo;
    return 1;
}

int dequeue(FilaEnc *f, Produto *valorRemovido) {
    Nodo *removido;

    if (estaVazia(f)) // underflow
        return 0;

    removido = f->frente;
    *valorRemovido = removido->dado;
    f->frente = removido->prox;

    if (f->frente == NULL) // a fila ficou vazia
        f->final = NULL;

    free(removido);
    return 1;
}

int frente(const FilaEnc *f, Produto *valor) {
    if (estaVazia(f))
        return 0;

    *valor = f->frente->dado; // copia o dado do primeiro nodo sem removê-lo
    return 1;
}

int tamanho(const FilaEnc *f) {
    Nodo *aux = f->frente;
    int contador = 0;

    while (aux != NULL) {
        contador++;
        aux = aux->prox;
    }

    return contador;
}
Atenção ao caso da fila ficar vazia. Ao desenfileirar o último elemento, f->frente passa a ser NULL — mas se não zerarmos também f->final, ele continuaria "pendurado" apontando para um nodo já liberado (dangling pointer, como vimos no Material Extra — Conceitos de Linguagens de Programação), causando erro na próxima chamada de enqueue.

Com os dois ponteiros (frente e final) sempre disponíveis na struct, tanto enqueue quanto dequeue são O(1), e a fila cresce dinamicamente sem limite predefinido. Por isso adotamos esta implementação como principal para o TAD Fila.

E a implementação por vetor? Uma fila também pode ser implementada sobre um vetor de capacidade fixa, mas exige mais cuidado do que a pilha: se implementada de forma ingênua, dequeue se torna O(n) (por deslocar elementos) ou desperdiça espaço já liberado. A solução — uma fila circular, usando aritmética modular — é proposta como exercício ao final da aula.

3. Aplicações

  • Fila de impressão: documentos são impressos na ordem em que chegaram.
  • Escalonamento de processos: um sistema operacional pode atender processos na ordem de chegada (política FCFS, First-Come, First-Served).
  • Atendimento e simulações: filas de banco, call centers, simulações de tráfego.
  • Buffers de dados: comunicação entre um produtor e um consumidor que trabalham em velocidades diferentes (ex: leitura de rede, áudio/vídeo em streaming).
  • Percurso em largura (BFS) em árvores e grafos: um algoritmo que veremos mais adiante na disciplina, e que usa uma fila para visitar elementos "por camadas".

Resumo

  • Fila (FIFO): inserções no final, remoções na frente — o primeiro elemento inserido é o primeiro removido.
  • Operações: enqueue, dequeue, frente (front), estaVazia.
  • Implementação principal: lista encadeada (FilaEnc), guardando ponteiros para frente e final — todas as operações são O(1), sem limite de capacidade. Cuidado especial ao esvaziar a fila (zerar final junto com frente).
  • Também é possível implementar por vetor, mas exige uma fila circular (aritmética modular) para evitar desperdício de espaço — veja o Exercício 8.
  • Aplicações: impressão, escalonamento por ordem de chegada, buffers, e (futuramente) percurso em largura (BFS).

Exercícios

  1. Explique, com suas próprias palavras, o que significa a sigla FIFO e como ela se relaciona com a operação dequeue.
  2. Na implementação por lista encadeada, por que é necessário verificar se f->frente == NULL após um dequeue bem-sucedido, e atualizar f->final nesse caso?
  3. Compare pilha (Aula 9a) e fila: as duas restringem onde inserções e remoções podem ocorrer, mas de formas diferentes. Explique essa diferença, e dê um exemplo de problema em que usar uma pilha no lugar de uma fila (ou vice-versa) produziria um resultado incorreto.
  4. Por que enqueue e dequeue continuam sendo O(1) mesmo a fila guardando dois ponteiros (frente e final) em vez de um só, como na pilha?
  5. Implemente uma função int filaParaVetor(const FilaEnc *f, Produto vetor[]) que copie os elementos da fila (da frente para o final) para vetor, sem remover nenhum elemento da fila, e retorne quantos elementos foram copiados.
  6. Implemente uma função void inverterFila(FilaEnc *f) que inverta a ordem dos elementos de uma FilaEnc, utilizando uma PilhaEnc (Aula 9a) como estrutura auxiliar. Dica: desenfileire tudo empilhando, depois desempilhe enfileirando de volta.
  7. Implemente uma função int filasIguais(FilaEnc *f1, FilaEnc *f2) que retorne 1 se as duas filas possuem exatamente os mesmos elementos, na mesma ordem, ou 0 caso contrário — sem alterar (nem remover elementos de) nenhuma das duas filas.
  8. Implemente a fila circular por vetor. Defina a struct FilaVet, com um vetor de capacidade fixa (CAPACIDADE), um índice frente e uma quantidade qtd de elementos, e implemente inicializa, estaVazia, enqueue e dequeue, usando o operador módulo (%) para calcular as posições. Explique por que a struct guarda qtd em vez de um índice final.

Sugestões de Respostas dos Exercícios

Exercício 1

FIFO significa First-In, First-Out — "o primeiro a entrar é o primeiro a sair". Isso se relaciona diretamente com dequeue: essa operação sempre remove o elemento que está na fila há mais tempo (a frente), nunca o elemento inserido mais recentemente.


Exercício 2

Porque, se a fila ficou vazia após a remoção, f->final ainda estaria apontando para o nodo que acabou de ser removido e liberado com free() — um dangling pointer (ver Material Extra — Conceitos de Linguagens de Programação). Se uma próxima chamada de enqueue tentasse usar f->final->prox = novo sem essa verificação, estaria escrevendo em um bloco de memória já liberado, um comportamento indefinido.


Exercício 3

A pilha restringe tudo a uma única extremidade (o topo): tanto inserção quanto remoção acontecem ali, gerando o comportamento LIFO. A fila usa duas extremidades diferentes — uma para inserir (final), outra para remover (frente) — gerando o comportamento FIFO.

Um exemplo prático: em um sistema de atendimento (como uma fila de banco), usar uma pilha por engano faria com que o último cliente a chegar fosse sempre o primeiro a ser atendido, deixando os clientes mais antigos esperando indefinidamente enquanto novos clientes continuassem chegando — claramente incorreto para esse tipo de problema, que exige o comportamento FIFO de uma fila de verdade.


Exercício 4

Porque tanto enqueue quanto dequeue continuam operando sobre um único ponteiro por vez, sem percorrer a lista: enqueue só lê e escreve em f->final, e dequeue só lê e escreve em f->frente. Ter dois ponteiros na struct, em vez de um, não significa que uma operação precise mexer nos dois ao mesmo tempo em todos os casos (exceto quando a fila está vazia ou fica vazia) — cada ponteiro atende a uma ponta, de forma independente.


Exercício 5
int filaParaVetor(const FilaEnc *f, Produto vetor[]) {
    Nodo *aux = f->frente;
    int i = 0;

    while (aux != NULL) {
        vetor[i] = aux->dado;
        i++;
        aux = aux->prox;
    }

    return i;
}

Basta percorrer a lista com um ponteiro auxiliar, do mesmo jeito que já fazíamos em imprime (Aula 4) — sem alterar f->frente nem remover nenhum nodo, a fila permanece intacta após a chamada.


Exercício 6
void inverterFila(FilaEnc *f) {
    PilhaEnc p;
    Produto valor;

    inicializa(&p);

    // esvazia a fila, empilhando cada elemento removido
    while (!estaVazia(f)) {
        dequeue(f, &valor);
        push(&p, valor);
    }

    // desempilha de volta para a fila, já na ordem inversa
    while (!estaVazia(&p)) {
        pop(&p, &valor);
        enqueue(f, valor);
    }
}

Como a pilha inverte a ordem (LIFO) e a fila preserva a ordem de entrada (FIFO), o truque é usar a pilha como uma etapa intermediária: tudo que sai da fila em uma ordem, entra na pilha e sai dela na ordem inversa — que é justamente o que queremos ao devolver os elementos para a fila.


Exercício 7
int filasIguais(const FilaEnc *f1, const FilaEnc *f2) {
    Nodo *aux1 = f1->frente;
    Nodo *aux2 = f2->frente;

    while (aux1 != NULL && aux2 != NULL) {
        // structs não podem ser comparadas com "!=" em C; comparamos campo a campo
        if (aux1->dado.cod != aux2->dado.cod)
            return 0;
        aux1 = aux1->prox;
        aux2 = aux2->prox;
    }

    // iguais somente se as duas terminaram ao mesmo tempo (mesmo tamanho)
    return aux1 == NULL && aux2 == NULL;
}

Usamos dois ponteiros auxiliares, um para cada fila, avançando os dois em paralelo. Como dado é um Produto (uma struct), não podemos compará-lo diretamente com != — por simplicidade, comparamos apenas o campo cod, assumindo que ele identifica o produto de forma única. Se em algum momento os códigos diferirem, as filas já não são iguais. Ao final, é preciso conferir se ambos os ponteiros chegaram a NULL ao mesmo tempo — se um deles ainda tiver elementos e o outro não, as filas têm tamanhos diferentes e não podem ser iguais.


Exercício 8
#define CAPACIDADE 5

typedef struct {
    Produto dados[CAPACIDADE];
    int frente;
    int qtd; // quantidade de elementos atualmente na fila
} FilaVet;

void inicializa(FilaVet *f) {
    f->frente = 0;
    f->qtd = 0;
}

int estaVazia(const FilaVet *f) {
    return f->qtd == 0;
}

int enqueue(FilaVet *f, Produto valor) {
    int posicaoFinal;

    if (f->qtd == CAPACIDADE) // overflow
        return 0;

    posicaoFinal = (f->frente + f->qtd) % CAPACIDADE; // "dá a volta" no vetor
    f->dados[posicaoFinal] = valor;
    f->qtd++;
    return 1;
}

int dequeue(FilaVet *f, Produto *valorRemovido) {
    if (estaVazia(f)) // underflow
        return 0;

    *valorRemovido = f->dados[f->frente];
    f->frente = (f->frente + 1) % CAPACIDADE; // "dá a volta" também aqui
    f->qtd--;
    return 1;
}

A struct guarda qtd em vez de um índice final porque, em uma fila circular, a condição frente == final seria ambígua: poderia significar tanto "fila vazia" quanto "fila cheia" (quando o índice deu a volta inteira no vetor e coincidiu de novo com frente). Guardar a quantidade de elementos diretamente resolve essa ambiguidade: fila vazia é qtd == 0, fila cheia é qtd == CAPACIDADE, independente da posição relativa dos índices.


Questões teóricas

  1. A FilaEnc guarda dois ponteiros (frente e final). Seria possível implementar uma fila funcional com apenas um ponteiro? Em que situação isso seria possível, e o que se perderia em termos de complexidade?
  2. Por que enqueue e dequeue continuam sendo O(1) mesmo a FilaEnc guardando dois ponteiros? O fato de haver dois ponteiros não obriga as funções a mexerem nos dois ao mesmo tempo?
  3. Explique o conceito de dangling pointer com um exemplo concreto da FilaEnc: o que aconteceria se um programador esquecesse de zerar f->final após o último dequeue, e então chamasse enqueue em seguida?
  4. Por que a implementação ingênua de fila por vetor — em que dequeue desloca todos os elementos uma posição — tem complexidade O(n), e não O(1)? E a variante que apenas avança o índice de frente sem deslocar — por que desperdiça espaço ao longo do tempo?
  5. Uma pilha e uma fila recebem os mesmos elementos na ordem 1, 2, 3, 4, 5. Compare os elementos removidos, na ordem em que saem de cada estrutura, após cinco operações de remoção. O que essa diferença ilustra sobre LIFO e FIFO?
  6. Em que circunstâncias uma fila com prioridade (heap) seria preferível a uma FilaEnc? Dê um exemplo concreto em que a ordem FIFO pura produziria um resultado inadequado.

Questões de implementação

Nas questões abaixo, todas as soluções devem usar a FilaEnc (e, quando indicado, a PilhaEnc) como clientes: utilize apenas as funções do TAD (inicializa, enqueue, dequeue, frente, estaVazia) — sem acessar nem modificar os campos internos da struct diretamente.

  1. Jogo do batata-quente (hot potato). Implemente Produto jogoHotPotato(Produto pessoas[], int n, int k): enfileire as n pessoas; repita até restar uma: em cada rodada, faça k-1 dequeue+enqueue (os que "passam" a batata), e então um dequeue sem recolocar (o eliminado). Retorne o produto da última pessoa na fila.
  2. Palíndromo com fila e pilha. Implemente int ehPalindromo(char *str, int n) usando uma FilaEnc e uma PilhaEnc juntas: enfileire e empilhe todos os n caracteres; em seguida, compare dequeue com pop posição a posição — se todos coincidirem, é palíndromo. Use o campo cod de Produto para guardar cada caractere (int).
  3. Intercalar duas filas. Implemente void intercalar(FilaEnc *f1, FilaEnc *f2, FilaEnc *resultado): dequeue alternadamente de f1 e f2, enqueue em resultado; quando uma das filas se esgotar, enqueue o restante da outra. Ambas as filas originais devem terminar vazias.
  4. Dividir em pares e ímpares. Implemente void dividirFila(FilaEnc *f, FilaEnc *pares, FilaEnc *impares): dequeue todos os elementos de f; os produtos com cod par vão para pares, os com cod ímpar vão para impares. A fila f deve terminar vazia.
  5. Copiar fila sem modificá-la. Implemente FilaEnc copiarFila(FilaEnc *f) que retorne uma nova FilaEnc com cópias de todos os elementos de f, na mesma ordem, sem modificar f. Dica: use um vetor auxiliar ou uma segunda fila como etapa intermediária para restaurar f.
  6. Simular atendimento FCFS. Implemente int simularAtendimento(FilaEnc *fila, int *tempoTotalEspera): cada produto na fila representa um cliente com cod = duração do seu atendimento. Simule a fila FCFS: o primeiro cliente começa a ser atendido no tempo 0; os demais esperam a soma dos atendimentos anteriores. Calcule o tempo de espera de cada cliente (momento em que começa a ser atendido) e armazene em *tempoTotalEspera a soma de todos os tempos de espera. Retorne o número de clientes atendidos.

Sugestões de Respostas — Questões teóricas e de implementação

Questão teórica 1

Com apenas um ponteiro para o início (frente), enqueue precisaria percorrer a fila inteira até o último nodo para encaixar o novo elemento — O(n). Com apenas um ponteiro para o final (final), dequeue não conseguiria avançar para o segundo elemento sem percorrer a lista a partir do início — ou seria impossível sem um ponteiro para o início. Em listas simplesmente encadeadas, a única forma de ter ambas as operações O(1) é manter dois ponteiros.


Questão teórica 2

Porque cada operação mexe em apenas um ponteiro por vez em quase todos os casos: enqueue só lê e escreve em f->final, e dequeue só lê e escreve em f->frente. Os dois ponteiros só precisam ser atualizados juntos nas situações excepcionais: quando a fila estava vazia antes de um enqueue (o novo nodo é ao mesmo tempo frente e final), ou quando a fila fica vazia após um dequeue (é preciso zerar também f->final). Fora desses casos, as operações são independentes — daí o O(1).


Questão teórica 3

Após o último dequeue, f->frente passa a ser NULL (fila vazia), mas f->final ainda aponta para o nodo que acabou de ser removido e liberado com free(). Esse é um dangling pointer: um ponteiro que aponta para memória já devolvida ao sistema operacional. Se enqueue for chamado em seguida, a condição if (f->final != NULL) é verdadeira (o ponteiro não é NULL, apenas inválido), então f->final->prox = novo escreve em um bloco já liberado — comportamento indefinido que pode corromper silenciosamente outras alocações ou causar segmentation fault.


Questão teórica 4

Na variante que desloca: ao remover o primeiro elemento, todos os demais precisam ser movidos uma posição para a esquerda para que frente fique sempre no índice 0 — isso custa O(n) por dequeue.
Na variante que apenas avança o índice: dequeue é O(1), mas o espaço da posição 0 fica para sempre inutilizado. Após k remoções, as primeiras k posições do vetor são espaço desperdiçado; mesmo que a fila contenha poucos elementos, as novas inserções podem ser recusadas por "overflow" quando o índice final alcança o fim do vetor — mesmo havendo espaço livre no início. A fila circular (aritmética modular) resolve esse problema.


Questão teórica 5
EstruturaOrdem de saída
Pilha (LIFO)5, 4, 3, 2, 1 — o último inserido sai primeiro
Fila (FIFO)1, 2, 3, 4, 5 — o primeiro inserido sai primeiro

A pilha inverte a ordem de entrada; a fila a preserva. Isso ilustra diretamente porque a pilha é útil para "desfazer" operações (saída na ordem inversa) e a fila é útil para escalonamento justo por ordem de chegada.


Questão teórica 6

Uma fila com prioridade é preferível quando a ordem de atendimento não depende apenas da chegada, mas de uma chave de prioridade. Exemplo concreto: escalonamento de processos em sistemas operacionais modernos — processos de baixa latência (leitura de teclado) devem ser atendidos antes de processos de processamento intensivo em lote (renderização de vídeo), independentemente de quem chegou primeiro. Com FIFO puro, um processo pesado que chegou antes bloquearia todos os mais leves que chegaram depois.


Questão de implementação 1
Produto jogoHotPotato(Produto pessoas[], int n, int k) {
    FilaEnc f;
    Produto p;
    int i, j;
    inicializa(&f);

    for (i = 0; i < n; i++)
        enqueue(&f, pessoas[i]);

    while (tamanho(&f) > 1) {
        for (j = 0; j < k - 1; j++) { // passa k-1 vezes
            dequeue(&f, &p);
            enqueue(&f, p);
        }
        dequeue(&f, &p); // elimina quem segurou a batata
    }

    frente(&f, &p);
    return p;
}

Questão de implementação 2
int ehPalindromo(char *str, int n) {
    FilaEnc f;
    PilhaEnc p;
    Produto c, da_fila, da_pilha;
    int i;
    inicializa(&f);
    inicializa(&p);

    for (i = 0; i < n; i++) {
        c.cod = str[i];
        enqueue(&f, c);
        push(&p, c);
    }

    for (i = 0; i < n; i++) {
        dequeue(&f, &da_fila);
        pop(&p, &da_pilha);
        if (da_fila.cod != da_pilha.cod)
            return 0;
    }

    return 1;
}

A fila retorna os caracteres na ordem original (FIFO); a pilha na ordem inversa (LIFO). Se todos os pares coincidirem, a string é palíndromo.


Questão de implementação 3
void intercalar(FilaEnc *f1, FilaEnc *f2, FilaEnc *resultado) {
    Produto p;

    while (!estaVazia(f1) && !estaVazia(f2)) {
        dequeue(f1, &p); enqueue(resultado, p);
        dequeue(f2, &p); enqueue(resultado, p);
    }

    while (!estaVazia(f1)) { dequeue(f1, &p); enqueue(resultado, p); }
    while (!estaVazia(f2)) { dequeue(f2, &p); enqueue(resultado, p); }
}

Questão de implementação 4
void dividirFila(FilaEnc *f, FilaEnc *pares, FilaEnc *impares) {
    Produto p;

    while (!estaVazia(f)) {
        dequeue(f, &p);
        if (p.cod % 2 == 0)
            enqueue(pares, p);
        else
            enqueue(impares, p);
    }
}

Questão de implementação 5
FilaEnc copiarFila(FilaEnc *f) {
    FilaEnc aux, copia;
    Produto p;
    inicializa(&aux);
    inicializa(&copia);

    // esvazia f em aux, construindo copia ao mesmo tempo
    while (!estaVazia(f)) {
        dequeue(f, &p);
        enqueue(&aux, p);
        enqueue(&copia, p);
    }

    // restaura f a partir de aux
    while (!estaVazia(&aux)) {
        dequeue(&aux, &p);
        enqueue(f, p);
    }

    return copia;
}

Questão de implementação 6
int simularAtendimento(FilaEnc *fila, int *tempoTotalEspera) {
    Produto cliente;
    int tempoAtual = 0;
    int contagem = 0;
    *tempoTotalEspera = 0;

    while (!estaVazia(fila)) {
        dequeue(fila, &cliente);
        *tempoTotalEspera += tempoAtual; // espera = momento em que começa a ser atendido
        tempoAtual += cliente.cod;       // duração do atendimento deste cliente
        contagem++;
    }

    return contagem;
}

Exemplo: 3 clientes com durações [4, 2, 3]. Esperas: 0 (1º começa imediatamente) + 4 (2º espera o 1º) + 6 (3º espera os dois anteriores) = 10. Tempo médio de espera: 10/3 ≈ 3,33.


Código para Download

O TAD FilaEnc completo, exatamente como visto nesta aula, dividido em interface (filaEnc.h) e implementação (filaEnc.c), junto com um main.c de exemplo:

filaEnc.zip — filaEnc.h, filaEnc.c e main.c.