← Índex del curs Exercicis

Unitat 5. Esquemes Algorítmics

Unitat Didàctica 5:

En aquesta unitat aprendràs:

Concepte d'esquema algorítmic i la decisió clau: Recorregut vs Cerca
Esquemes de recorregut: comptadors, acumuladors (suma/mitjana), extrems (màxims i mínims) i banderes (flags)
Esquemes de cerca: cerca lineal amb sortida anticipada i cerca dicotòmica o binària sobre dades ordenades
Cerca dicotòmica o binària sobre col·leccions ordenades: estratègia divideix i venceràs i cost logarítmic
Algorismes clàssics d'ordenació: Bombolla (Bubble Sort), Selecció directa (Selection Sort) i Inserció directa
Cerca d’un patró contigu dins d’un array o una cadena
Finestra lliscant de mida fixa: suma màxima de k elements consecutius
Mescla (Merge) de dues seqüències ordenades en temps lineal

1. Què és un esquema algorítmic?

En programació, molts problemes aparentment diferents comparteixen en realitat la mateixa estructura lògica fonamental. Un esquema algorítmic és un patró de disseny provat i reutilitzable per resoldre una família concreta de problemes sobre col·leccions o fluxos de dades.

En lloc de reinventar la roda cada vegada, els programadors identifiquem quin esquema s'adapta millor a la situació i l'apliquem amb seguretat. Tots els esquemes iteratius sobre seqüències consten de cinc fases bàsiques:

  1. Inicialització: establir l'estat inicial de les variables de treball (comptadors a 0, acumuladors a 0 o 1, índexs inicials, banderes booleanes).
  2. Condició de continuació: expressió booleana que governa el bucle i determina quan continuar o quan aturar-se.
  3. Tractament de l'element: inspeccionar o operar amb l'element actual de la seqüència.
  4. Avanç: passar a la següent posició o element de la col·lecció.
  5. Finalització: càlcul o tractament posterior un cop acabat el bucle (per exemple, dividir la suma entre el total per obtenir la mitjana, o comprovar si s'ha trobat l'element).

La Gran Decisió: Recorregut vs. Cerca

Davant de qualsevol problema sobre una col·lecció de dades, la primera pregunta que t'has de fer és:

2. Esquemes de Recorregut

Els esquemes de recorregut examinen cada element d'una seqüència des del primer fins a l'últim sense interrupció.

2.1 Comptadors

Un comptador és una variable entera que s'inicialitza a zero i s'incrementa en una quantitat constant (generalment +1) cada vegada que un element compleix una determinada condició.

2.2 Acumuladors

Un acumulador és una variable que agrega o suma els valors dels elements d'una seqüència. Si l'acumulació és una suma, s'inicialitza a 0 (l'element neutre de la suma); si és un producte, s'inicialitza a 1 (l'element neutre de la multiplicació).

A continuació pots veure en acció tots dos esquemes combinats: comptem quants alumnes han aprovat (comptador) i sumem les seves notes (acumulador) per després calcular la mitjana dels aprovats.

int[] notes = {4, 7, 2, 9, 5, 8}; int aprovats = 0; int suma = 0; for (int i = 0; i < notes.length; i++) { int nota = notes[i]; if (nota >= 5) { aprovats++; suma += nota; } } println("Aprovats: " + aprovats); println("Suma aprovats: " + suma);

2.3 Màxims i Mínims (Extrems)

La determinació del valor màxim o mínim d'una seqüència és un esquema de recorregut: tret que la col·lecció estigui ordenada, cal examinar obligatòriament tots els elements per assegurar que cap altre supera el candidat actual.

Regla d'or dels Extrems (Màxims i Mínims)

Quan busques el màxim o mínim d'un array, mai inicialitzis el màxim a zero ni a un valor arbitrari! Si tots els números de l'array fossin negatius (per exemple: {-10, -5, -20}), un màxim inicialitzat a 0 retornaria erròniament 0 com a resultat.

La manera correcta i universal és inicialitzar sempre la variable amb el primer element de la seqüència (valors[0]) i començar el bucle des de l'índex 1:

A més del propi valor extrem, sovint necessitem registrar la posició (índex) on s'ha trobat. Observa com s'actualitzen de manera coordinada tant max com posMax:

int[] valors = {12, 45, 67, 23, 89, 34}; int max = valors[0]; int posMax = 0; for (int i = 1; i < valors.length; i++) { if (valors[i] > max) { max = valors[i]; posMax = i; } } println("Maxim: " + max + " a posicio " + posMax);

2.4 Banderes o Indicadors (Flags)

Una bandera (flag) és una variable booleana que registra si s'ha complert o no una propietat al llarg del recorregut. Es distingeixen dos tipus fonamentals de preguntes lògiques:

// Exemple: Comprovar si TOTS els números d'un array són positius int[] numeros = {12, 5, 8, -3, 19}; boolean totsPositius = true; for (int i = 0; i < numeros.length; i++) { if (numeros[i] <= 0) { totsPositius = false; break; // Ja hem trobat un contraexemple, no cal continuar! } } println("Són tots positius? " + totsPositius);

3. Esquemes de Cerca

3.1 Cerca Seqüencial (Lineal)

La cerca seqüencial és l'algorisme de cerca més intuïtiu: recorre els elements un rere l'altre des del principi fins que troba l'element buscat o arriba al final de l'array. És l'únic mètode que podem utilitzar quan les dades no estan ordenades.

Fixa't en l'ús de break per finalitzar immediatament tan bon punt es localitza l'índex:

int[] dades = {14, 25, 8, 42, 19}; int objectiu = 42; int pos = -1; for (int i = 0; i < dades.length; i++) { if (dades[i] == objectiu) { pos = i; break; } } println("Trobat a index: " + pos);

3.2 Cerca Dicotòmica o Binària

Quan tenim una col·lecció de dades prèviament ordenada, no cal examinar els elements d'un en un. La cerca binària aprofita l'ordre aplicant la potent estratègia de divideix i venceràs:

  1. Calculem l'índex central de l'interval: mig = (esquerra + dreta) / 2.
  2. Si l'element del mig és exactament el que busquem, hem acabat!
  3. Si l'element del mig és més petit que el buscat, sabem amb certesa absoluta que el nostre valor ha d'estar a la meitat dreta: ajustem esquerra = mig + 1.
  4. Si l'element del mig és més gran, ha d'estar a la meitat esquerra: ajustem dreta = mig - 1.
  5. Repetim mentre esquerra <= dreta. Si es creuen i no l'hem trobat, l'element no és a l'array.

Observa en aquest stepper com els límits esq i dre s'acosten ràpidament fins a localitzar el número 23 en només 3 comparacions:

int[] ordenat = {2, 5, 8, 12, 16, 23, 38, 56, 72, 91}; int clau = 23; int esq = 0; int dre = ordenat.length - 1; int trobat = -1; while (esq <= dre) { int mig = (esq + dre) / 2; if (ordenat[mig] == clau) { trobat = mig; break; } else if (ordenat[mig] < clau) { esq = mig + 1; } else { dre = mig - 1; } } println("Index clau: " + trobat);

Per què la cerca binària és tan revolucionària?

A cada iteració, la cerca binària descarta la meitat dels elements restants. El seu cost computacional és $O(\log_2 n)$ enfront del cost lineal $O(n)$ de la cerca seqüencial:

3.3 Cerca d'un Patró Contigu

Podem ampliar la cerca lineal per localitzar un patró: una seqüència d'elements que ha d'aparéixer en el mateix ordre i en posicions consecutives dins d'un altre array. Provem cada possible posició inicial i comparem els elements del patró amb els de l'array a partir d'aquella posició.

Si n és la longitud de les dades i m la del patró, l'últim inici possible és n - m, inclòs. Per a cada inici i, comparem dades[i + j] amb patro[j]:

  1. Suposem que el patró coincideix en la posició actual.
  2. Si trobem una diferència, descartem aquesta posició i interrompem el bucle interior.
  3. Si tots els elements coincideixen, guardem l'inici i interrompem també la cerca exterior.
int[] dades = {2, 1, 2, 3, 1}; int[] patro = {2, 3}; int pos = -1; for (int i = 0; i <= dades.length - patro.length; i++) { boolean coincideix = true; for (int j = 0; j < patro.length; j++) { if (dades[i + j] != patro[j]) { coincideix = false; break; } } if (coincideix) { pos = i; break; } } println("Inici del patro: " + pos);

El resultat és 2; si el patró no apareix, pos conserva el valor -1. Si el patró és més llarg que les dades, el bucle exterior no s'executa. Amb un patró buit, aquest codi retorna 0, seguint el conveni que la seqüència buida apareix al principi.

En el pitjor cas es comparen fins a m elements per cada inici possible: el cost és $O((n - m + 1) m)$ quan $1 ≤ m ≤ n$, habitualment expressat com $O(nm)$.

Subcadena i subseqüència

En una cadena de text, un patró contigu és una subcadena. Podem aplicar el mateix algorisme utilitzant length() i charAt(i + j) / charAt(j) per comparar els caràcters de les dades i del patró. Una subseqüència, en canvi, permet saltar elements mantenint-ne l'ordre: {2, 3} és subseqüència de {2, 1, 3}, però no és un segment contigu.

4. Esquemes d'Ordenació

Tenir les dades ordenades és el requisit previ per fer cerques ultraràpides. Vegem els tres algorismes clàssics d'ordenació directa que tot programador ha de dominar.

4.1 Mètode de la Bombolla (Bubble Sort)

El mètode de la bombolla compara repetidament elements adjacents (veïns) i els intercanvia si estan en l'ordre incorrecte. En cada passada completa pel bucle exterior, el valor més gran restant "flota" fins a la seva posició definitiva al final de l'array, com si fos una bombolla d'aire en l'aigua.

int[] arr = {5, 1, 4, 2, 8}; int n = arr.length; for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - i - 1; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } println("Primer: " + arr[0] + ", ultim: " + arr[n - 1]);

4.2 Selecció Directa (Selection Sort)

L'algorisme de selecció divideix virtualment l'array en dues parts: la subllista d'elements ja ordenats (a l'esquerra) i la subllista d'elements pendents d'ordenar (a la dreta).

A cada iteració, cerca l'element mínim de la part desordenada i l'intercanvia amb el primer element pendent. Té l'avantatge que realitza molts menys intercanvis de memòria que la bombolla:

int[] a = {29, 10, 14, 37, 13}; int n = a.length; for (int i = 0; i < n - 1; i++) { int minIdx = i; for (int j = i + 1; j < n; j++) { if (a[j] < a[minIdx]) { minIdx = j; } } int tmp = a[i]; a[i] = a[minIdx]; a[minIdx] = tmp; } println("Ordenat primer: " + a[0]);

4.3 Inserció Directa (Insertion Sort)

És la forma natural en què la majoria de persones ordenem les cartes quan juguem a la baralla: mantenim les cartes ordenades a la mà esquerra i, en rebre una nova carta, la desplacem cap enrere comparant-la amb les anteriors fins a trobar el seu lloc exacte.

int[] valors = {8, 3, 5, 1, 9}; for (int i = 1; i < valors.length; i++) { int clau = valors[i]; int j = i - 1; // Desplacem els elements més grans que la clau cap a la dreta while (j >= 0 && valors[j] > clau) { valors[j + 1] = valors[j]; j--; } valors[j + 1] = clau; }

5. Mescla de Seqüències Ordenades (Merge)

La fusió o mescla és un esquema que rep dos arrays $A$ i $B$ que ja estan ordenats i construeix un nou array $C$ amb tots els elements de tots dos, també perfectament ordenat.

Utilitza tres punters (i, j, k) que avancen compassadament comparant els elements del capdamunt de cada llista. Funciona en temps estrictament lineal $O(n + m)$ i és el cor del famós algorisme d'ordenació Merge Sort:

int[] a = {2, 5, 9, 14}; int[] b = {3, 7, 8, 12, 20}; int[] c = new int[a.length + b.length]; int i = 0, j = 0, k = 0; // Mentre quedin elements a TOTS DOS arrays: while (i < a.length && j < b.length) { if (a[i] <= b[j]) { c[k] = a[i]; i++; } else { c[k] = b[j]; j++; } k++; } // Buidem els elements que hagin quedat pendents: while (i < a.length) { c[k++] = a[i++]; } while (j < b.length) { c[k++] = b[j++]; }

6. Finestra Lliscant de Mida Fixa

Una finestra és un segment d'elements consecutius d'una seqüència. Si té mida k i comença a l'índex i, inclou les posicions de i a i + k - 1. Desplaçar-la una posició significa eliminar l'element de l'esquerra i incorporar-ne un de nou per la dreta.

Volem trobar la suma màxima de k elements consecutius i l'índex on comença el primer segment que l'aconsegueix. Suposem que 1 ≤ k ≤ dades.length.

6.1 Solució amb Bucles Anidats

Una primera solució consisteix a provar tots els inicis possibles i calcular cada suma des de zero:

int[] dades = {2, 1, 5, 1, 3}; int k = 3; int sumaMax = 0; int iniciMax = 0; for (int inici = 0; inici <= dades.length - k; inici++) { int suma = 0; for (int j = 0; j < k; j++) { suma += dades[inici + j]; } if (inici == 0 || suma > sumaMax) { sumaMax = suma; iniciMax = inici; } } println("Suma maxima: " + sumaMax); println("Inici: " + iniciMax);

La condició inici == 0 fa que la primera suma es guarde sempre, encara que siga negativa. Després, només actualitzem el màxim si la nova suma és superior. Aquesta solució fa k sumes per cada finestra: cost $O((n - k + 1) k)$, amb una cota $O(nk)$.

6.2 Reutilitzar la Suma: Finestra Lliscant

Dues finestres consecutives comparteixen k - 1 elements. Per tant, podem obtindre la nova suma amb una resta i una suma: restem l'element que ix i sumem el que entra.

IniciFinestraCàlcul de la suma
02, 1, 52 + 1 + 5 = 8
11, 5, 18 − 2 + 1 = 7
25, 1, 37 − 1 + 3 = 9
  1. Calculem la suma de la primera finestra i la prenem com a màxim inicial.
  2. Recorrem els elements des de l'índex k. Per a cada índex i, ix dades[i - k] i entra dades[i].
  3. Comparem la suma actualitzada amb el màxim. L'inici de la nova finestra és i - k + 1.
int[] dades = {2, 1, 5, 1, 3}; int k = 3; int suma = 0; for (int i = 0; i < k; i++) { suma += dades[i]; } int sumaMax = suma; int iniciMax = 0; for (int i = k; i < dades.length; i++) { suma -= dades[i - k]; suma += dades[i]; if (suma > sumaMax) { sumaMax = suma; iniciMax = i - k + 1; } } println("Suma maxima: " + sumaMax); println("Inici: " + iniciMax);

El resultat és una suma màxima de 9 amb inici a l'índex 2. En cas d'empat, conservem la primera finestra perquè comparem amb >. L'algorisme també funciona amb valors negatius: el màxim inicial és la suma de la primera finestra, no zero.

El primer bucle processa k elements i el segon en processa n - k. En total, el cost és $O(n)$ i l'espai addicional és $O(1)$. Si k == dades.length, només hi ha una finestra i el segon bucle no s'executa.

Desplaçar un segment i reutilitzar el càlcul

En la cerca de patrons també desplacem un segment, però tornem a comparar els elements en cada posició. En aquesta suma amb finestra lliscant, la millora consisteix a actualitzar el resultat anterior sense recalcular tota la finestra.

7. Resum Comparatiu dels Esquemes

Esquema Objectiu Precondició Complexitat
Recorregut Comptar, sumar, transformar tots els elements Cap $O(n)$
Cerca Lineal Localitzar element en dades desordenades Cap $O(n)$
Cerca Binària Localitzar element ràpidament Array ordenat $O(\log n)$
Bombolla / Selecció Ordenar arrays Cap $O(n^2)$
Patró contigu Localitzar una seqüència d'elements consecutius Patró no buit per a aquesta cota $O(nm)$
Finestra lliscant (suma) Suma màxima de k elements consecutius $1 ≤ k ≤ n$ $O(n)$
Mescla (Merge) Unir dues llistes ordenades Totes dues ordenades $O(n + m)$

Exercicis Pràctics i Qüestionaris

Posa a prova els teus coneixements d'esquemes algorítmics completant els reptes interactius i els projectes d'ordenació i cerca.

Exercicis