Prepararse para entrevistas de codificación puede ser un verdadero desafío, ya que los desarrolladores suelen pasar varias semanas revisando y aprendiendo material nuevo.
La verdad es que la mayoría de los desarrolladores nunca se sienten completamente preparados para sus entrevistas de codificación. No es raro que los desarrolladores pasen semanas resolviendo problemas en LeetCode y todavía no se sientan preparados. Claramente, hay problemas con este enfoque.
Echemos un vistazo más de cerca a algunos problemas asociados con la preparación tradicional de entrevistas de codificación.
Uno de los errores más comunes en la preparación tradicional de una entrevista es la práctica de "pulir". Este enfoque implica resolver tantos problemas de LeetCode como sea posible sin un plan o estrategia claro. Si bien esto puede parecer una estrategia productiva, tiene varios inconvenientes.
Cuando resuelves problemas sin un enfoque estructurado, es posible que no reconozcas tus debilidades. No existe un plan deliberado para su estudio; el objetivo es simplemente resolver tantos problemas como puedas. Como resultado, es posible que sienta que ha aprendido mucho, pero podría haber lagunas importantes en su conocimiento.
Además, el problema con esto es que esencialmente gira en torno a memorizar soluciones a una multitud de problemas. Intentar recordar soluciones a cien problemas de codificación diferentes resulta ineficaz cuando los entrevistadores presentan preguntas que se desvían aunque sea ligeramente de lo que se ha encontrado antes. Esto puede hacer que usted se sienta no preparado para manejar las variaciones del problema.
Mi último problema con esta estrategia es que, a largo plazo, genera más estrés y dolores de cabeza. Si tienes que someterte a la misma sesión de estudio de varias semanas de duración cada vez que quieres cambiar de trabajo, siempre tendrás dificultades. Pasarás semanas reaprendiendo cosas y resolviendo los mismos problemas que antes.
Ante estos desafíos, tiene que haber una mejor manera.
Entonces, ¿existe un enfoque más eficaz y sostenible para codificar la preparación de entrevistas? La respuesta está en comprender y utilizar patrones de codificación.
Cuando me preparo para entrevistas de codificación, doy prioridad a comprender los patrones subyacentes de los problemas. Estos patrones, que incluyen técnicas como dos punteros , ventana deslizante , búsqueda binaria modificada , clasificación topológica y muchas más, proporcionan un marco versátil y potente para abordar una amplia gama de problemas de codificación. El énfasis pasa de memorizar soluciones a técnicas comprobadas de resolución de problemas.
Al centrarse en los patrones de codificación, puede optimizar significativamente la preparación de su entrevista, haciéndola más eficiente.
Recuerde, prepárese de manera más inteligente, no más difícil.
En este artículo, he recopilado el
Si bien cubro mucho en este artículo, si desea capacitación, explicaciones y práctica de codificación más detalladas, puede consultar nuestro curso integral de preparación para entrevistas de codificación en Techinterviews.io. También cubrimos temas cruciales como estructuras de datos , entrevistas de comportamiento e incluso negociación salarial .
Ahora profundicemos en estos patrones de codificación.
La inversión de lista vinculada implica cambiar la dirección de los punteros en una lista vinculada para invertir el orden de los elementos. Es una operación fundamental en las estructuras de datos y, a menudo, requiere una manipulación cuidadosa de las referencias de los nodos.
Esto es útil cuando se trata de una lista vinculada y la restricción es revertirla en su lugar.
El proceso para revertir una lista vinculada es el siguiente:
Comience definiendo tres variables: actual , anterior y siguiente . Establezca actual como encabezado de la lista vinculada e inicialice el anterior y el siguiente como Ninguno.
Mientras la variable actual no sea Ninguna , proceda de la siguiente manera:
Finalmente, establezca el encabezado de la lista invertida en el último nodo alcanzado, que se almacena en la variable anterior .
Indicadores clave:
Código de plantilla:
Pitón
def reverse_linked_list(head): curr = head prev = None while curr: next_node = curr.next curr.next = prev prev = curr curr = next_node return prev
js
function reverseLinkedList(head) { let curr = head; let prev = null; while (curr) { const nextNode = curr.next; curr.next = prev; prev = curr; curr = nextNode; } return prev; }
Java
public ListNode reverseLinkedList(ListNode head) { ListNode curr = head; ListNode prev = null; while (curr != null) { ListNode nextNode = curr.next; curr.next = prev; prev = curr; curr = nextNode; } return prev; }
La búsqueda binaria modificada adapta el algoritmo de búsqueda binaria clásico para resolver varios problemas. Las variaciones incluyen encontrar la primera/última aparición de un elemento o buscar en matrices rotadas. Requiere un manejo cuidadoso de los puntos medios y las condiciones.
Si alguna vez recibe una matriz ordenada, una lista vinculada o una matriz, considere utilizar una búsqueda binaria modificada .
A continuación se muestra un desglose de cómo aplicar este patrón a una estructura de datos ordenada:
middle = start + (end - start) / 2
.
Indicadores clave:
Código de plantilla:
Pitón
def binary_search(arr: List[int], target: int) -> int: left, right = 0, len(arr) - 1 first_true_index = -1 # Perform binary search until left and right pointers meet while left <= right: mid = (left + right) // 2 if feasible(mid): # If the condition is true at mid index, update first_true_index first_true_index = mid right = mid - 1 else: # If the condition is false at mid index, narrow down the search space left = mid + 1 return first_true_index
js
function binarySearch(arr, target) { let left = 0; let right = arr.length - 1; let firstTrueIndex = -1; // Perform binary search until left and right pointers meet while (left <= right) { const mid = Math.floor((left + right) / 2); if (feasible(mid)) { // If the condition is true at mid index, update firstTrueIndex firstTrueIndex = mid; right = mid - 1; } else { // If the condition is false at mid index, narrow down the search space left = mid + 1; } } return firstTrueIndex; }
Java
public int binarySearch(int[] arr, int target) { int left = 0; int right = arr.length - 1; int firstTrueIndex = -1; // Perform binary search until left and right pointers meet while (left <= right) { int mid = left + (right - left) / 2; if (feasible(mid)) { // If the condition is true at mid index, update firstTrueIndex firstTrueIndex = mid; right = mid - 1; } else { // If the condition is false at mid index, narrow down the search space left = mid + 1; } } return firstTrueIndex; }
La técnica de dos punteros implica mantener dos punteros que atraviesan una estructura de datos, generalmente matrices o listas vinculadas, que a menudo se utilizan para problemas que involucran pares o submatrices. Optimiza problemas que requieren comparación entre elementos en diferentes posiciones.
La ventaja de esta técnica radica en su simplicidad y eficiencia, especialmente cuando se trata de estructuras de datos lineales como matrices o cadenas en las que inicialmente se puede usar un solo puntero. Al emplear dos punteros, puede evitar operaciones redundantes y mejorar significativamente la eficiencia del tiempo de ejecución, reduciéndola potencialmente de O(n^2) a O(n) .
El patrón "Dos punteros" abarca varias variaciones, cada una adaptada a escenarios específicos. Estas variaciones incluyen la misma dirección , la dirección opuesta y un método único conocido como "rápido y lento", a menudo denominado técnica de "tortuga y liebre" , que involucra dos punteros que se mueven a diferentes velocidades a través de una estructura de datos, particularmente útil para detectar ciclos.
Si emplea varios punteros para recorrer una estructura de datos, puede categorizar su enfoque siguiendo el patrón "Dos punteros" .
Indicadores clave:
Código de plantilla:
Plantilla para variación de “dirección opuesta”
Pitón
def two_pointers_opposite(arr): left = 0 right = len(arr) - 1 ans = 0 while left < right: # Perform logic using the left and right pointers if CONDITION: left += 1 else: right -= 1 return ans
js
function twoPointersOpposite(arr) { let left = 0; let right = arr.length - 1; let ans = 0; while (left < right) { // Perform logic using the left and right pointers if (CONDITION) { left++; } else { right--; } } return ans; }
Java
public int twoPointersOpposite(int[] arr) { int left = 0; int right = arr.length - 1; int ans = 0; while (left < right) { // Perform logic using the left and right pointers if (CONDITION) { left++; } else { right--; } } return ans; }
La técnica de ventana deslizante implica mantener una ventana dinámica sobre una estructura de datos lineal, como matrices, cadenas o listas vinculadas. El tamaño de la ventana puede variar según la implementación específica y también se puede establecer como un valor fijo. El objetivo principal de esta ventana es monitorear y capturar continuamente datos que satisfagan criterios específicos, lo que la hace particularmente valiosa para resolver eficientemente problemas de subarreglos o subcadenas.
Este patrón suele utilizar un mapa hash para facilitar el seguimiento de datos individuales dentro de la ventana. Sin embargo, es importante tener en cuenta que este enfoque puede dar como resultado una complejidad espacio-temporal de O(n) .
Indicadores clave:
Código de plantilla:
Pitón
def slidingWindow(nums): # Initialize necessary variables left = 0 window = [] # Initialize the window ans = 0 # Initialize the answer variable for right in range(len(nums)): window.append(nums[right]) # Expand the window to the right while invalid(window): # Condition to shrink the window from the left until it is valid again window.pop(0) # Remove the left element from the window left += 1 ans = max(ans, len(window)) # Update the answer, can vary on your implementation return ans
js
function slidingWindow(nums) { let left = 0; const window = []; // Initialize the window let ans = 0; // Initialize the answer variable for (let right = 0; right < nums.length; right++) { window.push(nums[right]); // Expand the window to the right while (invalid(window)) { // Condition to shrink the window from the left until it is valid again window.shift(); // Remove the left element from the window left++; } ans = Math.max(ans, window.length); // Update the answer , can vary on your implementation } return ans; }
Java
public static int slidingWindow(int[] nums) { int left = 0; List<Integer> window = new ArrayList<>(); // Initialize the window int ans = 0; // Initialize the answer variable for (int right = 0; right < nums.length; right++) { window.add(nums[right]); // Expand the window to the right while (invalid(window)) { // Condition to shrink the window from the left until it is valid again window.remove(0); // Remove the left element from the window left++; } ans = Math.max(ans, window.size()); // Update the answer , can vary on your implementation } return ans; }
Este patrón implica encontrar los K elementos más grandes o más pequeños en una colección, a menudo implementado utilizando estructuras de datos como montones o colas de prioridad. Es útil para seleccionar un subconjunto de elementos en función de su valor o frecuencia.
Cada vez que se nos pide que encontremos los elementos 'K' más frecuentes, más pequeños o superiores dentro de un conjunto de datos determinado, podemos considerar usar este patrón.
La forma en que funciona es simple:
La belleza de este enfoque reside en su eficiencia; no necesita recurrir a ningún algoritmo de clasificación, ya que el montón mismo mantiene naturalmente el orden requerido por usted.
Indicadores clave:
Código de plantilla:
Pitón
import heapq def top_k_elements(arr, k): heap = [] for num in arr: # Define your own criteria and logic to push elements onto the heap # For example, if you want to find the top k largest elements, push (-num, num) onto the heap heapq.heappush(heap, (-CRITERIA, num)) if len(heap) > k: heapq.heappop(heap) return [num for _, num in heap]
js
function topKElements(arr, k) { const minHeap = []; for (const num of arr) { // Define your own criteria and logic to push elements onto the heap // For example, if you want to find the top k smallest elements, push num onto the heap minHeap.push(CRITERIA); if (minHeap.length > k) { minHeap.sort((a, b) => a - b).shift(); } } return minHeap.sort((a, b) => a - b); }
Java
import java.util.*; public List<Integer> topKElements(int[] arr, int k) { PriorityQueue<Integer> heap = new PriorityQueue<>(Comparator.comparingInt(a -> -CRITERIA)); for (int num : arr) { // Define your own criteria and logic to push elements onto the heap // For example, if you want to find the top k largest elements, push -num onto the heap heap.offer(-CRITERIA); if (heap.size() > k) { heap.poll(); } } List<Integer> topK = new ArrayList<>(); while (!heap.isEmpty()) { topK.add(-heap.poll()); } Collections.reverse(topK); return topK; }
Two Heaps utiliza dos montones (un montón máximo y un montón mínimo) para optimizar ciertos problemas, como encontrar valores medianos en un conjunto de datos. Este patrón es particularmente útil para mantener una estructura equilibrada.
Así es como funciona:
Indicadores clave:
Código de plantilla:
Pitón
import heapq class TwoHeaps: def __init__(self): self.min_heap = [] # Right heap to store larger half self.max_heap = [] # Left heap to store smaller half def add_num(self, num): if not self.max_heap or num <= -self.max_heap[0]: heapq.heappush(self.max_heap, -num) else: heapq.heappush(self.min_heap, num) # Balance the heaps if necessary if len(self.max_heap) > len(self.min_heap) + 1: heapq.heappush(self.min_heap, -heapq.heappop(self.max_heap)) elif len(self.min_heap) > len(self.max_heap): heapq.heappush(self.max_heap, -heapq.heappop(self.min_heap)) def find_median(self): if len(self.max_heap) == len(self.min_heap): return (-self.max_heap[0] + self.min_heap[0]) / 2.0 else: return -self.max_heap[0] # Usage: two_heaps = TwoHeaps() two_heaps.add_num(1) two_heaps.add_num(2) median = two_heaps.find_median() print("Median:", median)
js
class TwoHeaps { constructor() { this.minHeap = []; // Right heap to store larger half this.maxHeap = []; // Left heap to store smaller half } addNumber(num) { if (this.maxHeap.length === 0 || num <= -this.maxHeap[0]) { this.maxHeap.push(-num); } else { this.minHeap.push(num); } // Balance the heaps if necessary if (this.maxHeap.length > this.minHeap.length + 1) { this.minHeap.push(-this.maxHeap.shift()); } else if (this.minHeap.length > this.maxHeap.length) { this.maxHeap.push(-this.minHeap.shift()); } } findMedian() { if (this.maxHeap.length === this.minHeap.length) { return (-this.maxHeap[0] + this.minHeap[0]) / 2; } else { return -this.maxHeap[0]; } } } // Usage: const twoHeaps = new TwoHeaps(); twoHeaps.addNumber(1); twoHeaps.addNumber(2); const median = twoHeaps.findMedian(); console.log("Median:", median);
Java
import java.util.*; class TwoHeaps { private PriorityQueue<Integer> minHeap; // Right heap to store larger half private PriorityQueue<Integer> maxHeap; // Left heap to store smaller half public TwoHeaps() { minHeap = new PriorityQueue<>(); maxHeap = new PriorityQueue<>(Collections.reverseOrder()); } public void addNumber(int num) { if (maxHeap.isEmpty() || num <= -maxHeap.peek()) { maxHeap.offer(-num); } else { minHeap.offer(num); } // Balance the heaps if necessary if (maxHeap.size() > minHeap.size() + 1) { minHeap.offer(-maxHeap.poll()); } else if (minHeap.size() > maxHeap.size()) { maxHeap.offer(-minHeap.poll()); } } public double findMedian() { if (maxHeap.size() == minHeap.size()) { return (-maxHeap.peek() + minHeap.peek()) / 2.0; } else { return -maxHeap.peek(); } } } // Usage: TwoHeaps twoHeaps = new TwoHeaps(); twoHeaps.addNumber(1); twoHeaps.addNumber(2); double median = twoHeaps.findMedian(); System.out.println("Median: " + median);
Monótono - (de una función o cantidad) que varía de tal manera que nunca disminuye o nunca aumenta.
La pila monotónica mantiene una pila de elementos en orden no creciente ni decreciente, a menudo utilizada para encontrar los elementos más pequeños/mayores más cercanos en una matriz. Es una herramienta poderosa para optimizar ciertos problemas.
El orden es estricto, cada vez que encontramos un elemento que es más pequeño (o mayor) que el que está en la parte superior de la pila, debemos salir de la pila monótona hasta que el elemento que buscamos agregar sea el más pequeño (o mayor) de a ellos.
Indicadores clave:
Código de plantilla:
Pitón
def monotonic_stack(items): stack = [] for item in items: # Adjust the condition for comparisons to suit your needs while stack and stack[-1] <= item: stack.pop() # Do something with the popped item here stack.append(item)
js
function monotonicStack(items) { const stack = []; for (const item of items) { // Adjust the condition for comparisons to suit your needs while (stack.length > 0 && stack[stack.length - 1] <= item) { stack.pop(); // Do something with the popped item here } stack.push(item); } return stack; }
Java
import java.util.*; public static int[] monotonicStack(int[] items) { Deque<Integer> stack = new ArrayDeque<>(); for (int item : items) { // Adjust the condition for comparisons to suit your needs while (!stack.isEmpty() && stack.peekLast() <= item) { stack.pollLast(); // Do something with the popped item here } stack.offerLast(item); } int[] result = new int[stack.size()]; int i = stack.size() - 1; while (!stack.isEmpty()) { result[i--] = stack.pollLast(); } return result; }
DFS , o búsqueda en profundidad primero , es un método transversal en el que se explora lo más profundamente posible a lo largo de una rama antes de retroceder; se aplica ampliamente en problemas que involucran árboles y gráficos, particularmente para operaciones de recorrido y búsqueda.
Para realizar DFS en un árbol, debe comenzar desde la raíz. Luego siga los pasos a continuación:
Diferencia con DFS en un gráfico: la diferencia clave entre DFS de árbol y DFS de gráfico radica en la presencia de ciclos. En un árbol, no hay ciclos por definición, por lo que el árbol DFS garantiza que cada nodo se visite exactamente una vez y, naturalmente, termina cuando se recorre todo el árbol. Por el contrario, el gráfico DFS debe incorporar medidas adicionales para manejar estructuras cíclicas dentro de un gráfico. Para evitar volver a visitar los nodos en un ciclo indefinidamente, el gráfico DFS requiere mecanismos como marcar los nodos visitados y manejar el retroceso de manera adecuada . Esta distinción hace que el gráfico DFS sea más complejo que el árbol DFS debido a la posibilidad de que se formen bucles infinitos en presencia de ciclos.
Indicadores clave:
Código de plantilla:
Pitón
def dfs(root, target): if root is None: # Base case: Check if the current node is None return None if root.val == target: # Base case: Check if the current node value matches the target return root left = dfs(root.left, target) # Recursively search the left subtree if left is not None: # If the target is found in the left subtree, return the result return left return dfs(root.right, target) # Recursively search the right subtree
js
function dfs(root, target) { if (root === null) { // Base case: Check if the current node is null return null; } if (root.val === target) { // Base case: Check if the current node value matches the target return root; } let left = dfs(root.left, target); // Recursively search the left subtree if (left !== null) { // If the target is found in the left subtree, return the result return left; } return dfs(root.right, target); // Recursively search the right subtree }
Java
public TreeNode dfs(TreeNode root, int target) { if (root == null) { // Base case: Check if the current node is null return null; } if (root.val == target) { // Base case: Check if the current node value matches the target return root; } TreeNode left = dfs(root.left, target); // Recursively search the left subtree if (left != null) { // If the target is found in the left subtree, return the result return left; } return dfs(root.right, target); // Recursively search the right subtree }
BFS es una técnica transversal para árboles y gráficos que explora todos los nodos en la profundidad actual antes de pasar al siguiente nivel.
Para realizar BFS en un árbol, debe comenzar desde la raíz. Luego siga los pasos a continuación:
Inicialice una estructura de datos de cola vacía para realizar un seguimiento de los nodos que se visitarán.
Coloque el nodo raíz en la cola.
Ingrese un bucle hasta que la cola esté vacía:
Repita los pasos 3a-3c hasta que la cola quede vacía.
El recorrido BFS se completa cuando todos los nodos del árbol han sido visitados en forma nivelada, de izquierda a derecha.
En esencia, BFS en un árbol explora los nodos nivel por nivel , comenzando desde la raíz y pasando a los secundarios antes de pasar al siguiente nivel. Esto garantiza que se visiten los nodos de cada nivel antes de pasar al siguiente nivel, lo que lo hace particularmente útil para tareas como encontrar el camino más corto en un árbol no ponderado o explorar un árbol nivel por nivel.
Diferencia con BFS en un gráfico: similar a DFS, Graph BFS se adapta a la presencia de ciclos y múltiples rutas entre nodos. Emplea mecanismos como marcar nodos visitados y condiciones de terminación especializadas para navegar de manera eficiente por la intrincada red de relaciones dentro de los gráficos.
Indicadores clave:
Código de plantilla:
Pitón
from collections import deque def bfs(root): # Create a queue and initialize it with the root node queue = deque([root]) # Perform breadth-first search until the queue is empty while len(queue) > 0: # Dequeue the front node from the queue node = queue.popleft() # Process the current node for child in node.children: if is_goal(child): # If the goal condition is satisfied, return the child node return FOUND(child) # Enqueue the child node to explore it in the next iterations queue.append(child) # Return NOT_FOUND if the goal is not reached return NOT_FOUND
js
function bfs(root) { const queue = []; // Create a queue and initialize it with the root node queue.push(root); while (queue.length > 0) { // Perform breadth-first search until the queue is empty const node = queue.shift(); // Dequeue the front node from the queue for (const child of node.children) { // Process the current node if (isGoal(child)) { // If the goal condition is satisfied, return the child node return `FOUND ${child}`; } queue.push(child); // Enqueue the child node to explore it in the next iterations } } return 'NOT_FOUND'; // Return NOT_FOUND if the goal is not reached }
Java
import java.util.LinkedList; import java.util.Queue; public String bfs(Node root) { Queue<Node> queue = new LinkedList<>(); // Create a queue and initialize it with the root node queue.offer(root); while (!queue.isEmpty()) { // Perform breadth-first search until the queue is empty Node node = queue.poll(); // Dequeue the front node from the queue for (Node child : node.getChildren()) { // Process the current node if (isGoal(child)) { // If the goal condition is satisfied, return the child node return "FOUND(child)"; } queue.offer(child); // Enqueue the child node to explore it in the next iterations } } return "NOT_FOUND"; // Return NOT_FOUND if the goal is not reached }
La estructura de datos Union-Find , también conocida como Unión de conjuntos disjuntos (DSU) , se emplea para gestionar y resolver de manera eficiente problemas que involucran conjuntos disjuntos o componentes conectados. Proporciona operaciones para fusionar conjuntos (unión) y determinar el conjunto al que pertenece un elemento (buscar). Union-Find se usa comúnmente en algoritmos como el árbol de expansión mínima de Kruskal y la detección de ciclos en gráficos.
Union Find se implementa de la siguiente manera:
Código de plantilla:
Pitón
class UnionFind: def __init__(self): self.id = {} def find(self, x): y = self.id.get(x, x) if y != x: self.id[x] = y = self.find(y) return y def union(self, x, y): self.id[self.find(x)] = self.find(y)
js
class UnionFind { constructor() { this.id = {}; } /** * Find the root parent of an element in the set. * Implements path compression for better efficiency. * @param x The element to find the root parent for. * @returns The root parent of the element. */ find(x) { let y = this.id[x] || x; if (y !== x) { this.id[x] = y = this.find(y); } return y; } /** * Union two elements into the same set. * @param x The first element. * @param y The second element. */ union(x, y) { this.id[this.find(x)] = this.find(y); } }
Java
import java.util.*; class UnionFind { private Map<String, String> id; public UnionFind() { id = new HashMap<>(); } /** * Find the root parent of an element in the set. * Implements path compression for better efficiency. * @param x The element to find the root parent for. * @return The root parent of the element. */ public String find(String x) { String y = id.getOrDefault(x, x); if (!y.equals(x)) { id.put(x, find(y)); } return y; } /** * Union two elements into the same set. * @param x The first element. * @param y The second element. */ public void union(String x, String y) { id.put(find(x), find(y)); } }
Un gráfico acíclico dirigido (DAG) es un gráfico dirigido sin ciclos dirigidos.
La clasificación topológica es un algoritmo para organizar los nodos de un gráfico acíclico dirigido ( DAG ) en un orden lineal, donde cada nodo aparece antes que sus sucesores. Es crucial para tareas como programar dependencias, compilar código y analizar la precedencia de tareas en diversas aplicaciones.
Aquí hay un desglose paso a paso de la clasificación topológica:
Inicialización: comience con un gráfico acíclico dirigido ( DAG ) que represente tareas o nodos con dependencias. Inicialice una cola para contener los nodos ordenados.
Cálculo en grados: Calcule el grado (el número de aristas entrantes) para cada nodo en el gráfico. Los nodos con un grado de entrada de 0 no tienen dependencias y son adecuados para ser el punto de partida de la clasificación topológica.
Llenado de cola inicial: ponga en cola todos los nodos con un grado de entrada de 0 en la cola. Estos nodos se pueden procesar primero.
Bucle de clasificación topológica: mientras la cola no esté vacía, realice los siguientes pasos:
Finalización: una vez que se completa el ciclo de clasificación topológica, la cola estará vacía y la lista ordenada contendrá todos los nodos en un orden topológico válido.
Detección de ciclos: si en algún momento durante el proceso de clasificación topológica no quedan nodos con un grado de entrada de 0 en la cola, indica la presencia de ciclos en el gráfico, lo que hace imposible la clasificación topológica.
El resultado del Topological Sort es un ordenamiento lineal de nodos que respeta sus dependencias, haciéndolo adecuado para programar tareas o analizar el orden de ejecución en diversas aplicaciones.
Indicadores clave:
Código de plantilla:
Pitón
from collections import deque def find_indegree(graph): # Calculate the indegree of each node in the graph indegree = {node: 0 for node in graph} for node in graph: for neighbor in graph[node]: indegree[neighbor] += 1 return indegree def topological_sort(graph): result = [] queue = deque() indegree = find_indegree(graph) # Add nodes with 0 indegree to the queue for node in indegree: if indegree[node] == 0: queue.append(node) while queue: node = queue.popleft() result.append(node) # Update the indegree of neighboring nodes for neighbor in graph[node]: indegree[neighbor] -= 1 if indegree[neighbor] == 0: queue.append(neighbor) # If all nodes are visited, return the result if len(graph) == len(result): return result else: return None
js
/** * Finds the indegree of each node in the graph. * @param {object} graph - The input graph. * @returns {object} - The indegree of each node. */ function findIndegree(graph) { const indegree = {}; for (const node in graph) { indegree[node] = 0; } for (const node in graph) { for (const neighbor of graph[node]) { indegree[neighbor]++; } } return indegree; } /** * Performs topological sorting on the given graph. * @param {object} graph - The input graph. * @returns {array|null} - The sorted nodes in topological order or null if a cycle is detected. */ function topologicalSort(graph) { const result = []; const queue = []; const indegree = findIndegree(graph); // Add nodes with no incoming edges to the queue for (const node in indegree) { if (indegree[node] === 0) { queue.push(node); } } while (queue.length > 0) { const node = queue.shift(); result.push(node); // Decrement the indegree of neighbors and enqueue if indegree becomes zero for (const neighbor of graph[node]) { indegree[neighbor]--; if (indegree[neighbor] === 0) { queue.push(neighbor); } } } // Check if all nodes have been visited (no cycles) if (Object.keys(graph).length === result.length) { return result; } else { return null; } }
Java
import java.util.*; /** * Finds the indegree of each node in the graph. * @param graph - The input graph. * @return The indegree of each node. */ public Map<String, Integer> findIndegree(Map<String, List<String>> graph) { Map<String, Integer> indegree = new HashMap<>(); for (String node : graph.keySet()) { indegree.put(node, 0); } for (String node : graph.keySet()) { for (String neighbor : graph.get(node)) { indegree.put(neighbor, indegree.getOrDefault(neighbor, 0) + 1); } } return indegree; } /** * Performs topological sorting on the given graph. * @param graph - The input graph. * @return The sorted nodes in topological order or null if a cycle is detected. */ public List<String> topologicalSort(Map<String, List<String>> graph) { List<String> result = new ArrayList<>(); Queue<String> queue = new LinkedList<>(); Map<String, Integer> indegree = findIndegree(graph); // Add nodes with no incoming edges to the queue for (String node : indegree.keySet()) { if (indegree.get(node) == 0) { queue.offer(node); } } while (!queue.isEmpty()) { String node = queue.poll(); result.add(node); // Decrement the indegree of neighbors and enqueue if indegree becomes zero for (String neighbor : graph.get(node)) { indegree.put(neighbor, indegree.get(neighbor) - 1); if (indegree.get(neighbor) == 0) { queue.offer(neighbor); } } } // Check if all nodes have been visited (no cycles) if (graph.size() == result.size()) { return result; } else { return null; } }
Un Trie es una estructura de datos en forma de árbol que se utiliza para la comparación de cadenas y el almacenamiento de palabras de manera eficiente. Destaca en problemas en los que es necesario almacenar y buscar cadenas con prefijos comunes.
A continuación se explica cómo implementar un Trie:
Inicialización: comience con un Trie vacío, que normalmente consta de un nodo raíz sin ningún carácter asociado.
Inserción: para insertar una palabra en el Trie, comience en el nodo raíz y recorra el árbol, un carácter a la vez. Para cada carácter de la palabra:
Finalización de palabras: para comprobar si una palabra existe en Trie, recorrala de forma similar a la inserción. Asegúrese de que cada carácter de la palabra corresponda a un nodo secundario del nodo actual. Si el recorrido llega al final de la palabra y hay un marcador de final válido (por ejemplo, una bandera booleana) en el último nodo de carácter, la palabra existe en el Trie.
Búsqueda de prefijos: Trie sobresale en la búsqueda de prefijos. Para encontrar todas las palabras con un prefijo determinado, comience el recorrido en el nodo raíz y baje en el árbol siguiendo los caracteres del prefijo. Una vez que llegue al último carácter del prefijo, puede realizar una búsqueda en profundidad (DFS) desde ese nodo para encontrar todas las palabras que comparten el mismo prefijo.
Eliminación: para eliminar una palabra del Trie, realice una búsqueda de la palabra. Cuando llegue al final de la palabra, elimine el marcador de final (si existe). Si el nodo no tiene otros hijos, puede eliminar de forma segura toda la rama del Trie, que representa la palabra.
Los intentos pueden consumir mucha memoria, especialmente para vocabularios extensos. Para optimizar la memoria, se pueden aplicar técnicas como la compresión (por ejemplo, usar un mapa en lugar de una matriz de caracteres en cada nodo) y la poda (eliminar nodos sin descendientes).
Los intentos son particularmente útiles para una coincidencia eficiente de cadenas, sugerencias de autocompletar, correctores ortográficos e indexación de palabras con prefijos comunes. Proporcionan una forma rápida y que ahorra espacio para almacenar y buscar palabras o cadenas con prefijos compartidos en una estructura similar a un árbol.
Indicadores clave:
Código de plantilla:
Pitón
class TrieNode: def __init__(self, value): self.value = value self.children = {} def insert(self, s, idx): # idx: index of the current character in s if idx != len(s): self.children.setdefault(s[idx], TrieNode(s[idx])) self.children.get(s[idx]).insert(s, idx + 1)
js
class TrieNode { constructor(value) { this.value = value; this.children = {}; } insert(s, idx) { // idx: index of the current character in s if (idx !== s.length) { if (!this.children[s[idx]]) { this.children[s[idx]] = new TrieNode(s[idx]); } this.children[s[idx]].insert(s, idx + 1); } } }
Java
import java.util.*; class TrieNode { char value; Map<Character, TrieNode> children; TrieNode(char value) { this.value = value; this.children = new HashMap<>(); } void insert(String s, int idx) { // idx: index of the current character in s if (idx != s.length()) { char c = s.charAt(idx); if (!children.containsKey(c)) { children.put(c, new TrieNode(c)); } children.get(c).insert(s, idx + 1); } } }
La programación dinámica es una poderosa técnica de resolución de problemas utilizada en informática y matemáticas para resolver una amplia gama de problemas complejos de manera eficiente. Es especialmente valioso cuando un problema se puede dividir en subproblemas más simples y las soluciones a estos subproblemas se pueden combinar para resolver el problema general.
Estas son sus características clave y cómo funciona:
Subestructura óptima:
Subproblemas superpuestos:
Cómo funciona la programación dinámica:
La programación dinámica se aplica a una amplia gama de problemas, incluida la búsqueda de los caminos más cortos en gráficos, la optimización de la asignación de recursos, el conteo de combinaciones, la resolución de problemas de mochila y mucho más. Su capacidad para optimizar soluciones dividiendo problemas complejos en partes más simples y evitando cálculos redundantes la convierte en una técnica fundamental en el diseño y optimización de algoritmos.
Conceptos importantes: enfoque de abajo hacia arriba, de arriba hacia abajo, memorización, tabulación
Indicadores clave:
Código de plantilla:
La plantilla para la memorización de arriba hacia abajo es una de las muchas formas de implementar la programación dinámica.
Pitón
def top_down_memo(arr): def dp(state): # Base case(s): Define your own base case(s) for the problem if base_case: return 0 # Check if the state has already been memoized if state in memo: return memo[state] # Calculate the result using recurrence relation and memoize it result = recurrence_relation(state) memo[state] = result return result memo = {} # Memoization table to store calculated results return dp(initial_state)
js
function topDownMemo(arr) { function dp(state) { // Base case(s): Define your own base case(s) for the problem if (baseCase) { return 0; } // Check if the state has already been memoized if (memo.has(state)) { return memo.get(state); } // Calculate the result using recurrence relation and memoize it const result = recurrenceRelation(state); memo.set(state, result); return result; } const memo = new Map(); // Memoization map to store calculated results return dp(initialState); }
Java
import java.util.HashMap; import java.util.Map; public int topDownMemo(int[] arr) { Map<StateType, Integer> memo = new HashMap<>(); // Memoization map to store calculated results return dp(initialState, memo); } private int dp(StateType state, Map<StateType, Integer> memo) { // Base case(s): Define your own base case(s) for the problem if (baseCase) { return 0; } // Check if the state has already been memoized if (memo.containsKey(state)) { return memo.get(state); } // Calculate the result using recurrence relation and memoize it int result = recurrenceRelation(state); memo.put(state, result); return result; }
El retroceso es una técnica algorítmica general para resolver problemas de forma incremental probando diferentes posibilidades y deshaciéndolas si no conducen a una solución. Se utiliza cuando se requiere una búsqueda exhaustiva.
A continuación se ofrece un análisis en profundidad de cómo funciona el retroceso:
Aplicaciones del retroceso:
El retroceso se utiliza en varios dominios de problemas, que incluyen:
Indicadores clave:
Código de plantilla:
Pitón
def backtrack(curr, OTHER_ARGUMENTS...): if BASE_CASE: # Modify the answer according to the problem's requirements return ans = 0 for ITERATE_OVER_INPUT: # Modify the current state according to the problem's requirements ans += backtrack(curr, OTHER_ARGUMENTS...) # Undo the modification of the current state (backtrack) return ans
js
function backtrack(curr, ...OTHER_ARGUMENTS) { if (BASE_CASE) { // Modify the answer according to the problem's requirements return; } let ans = 0; for (let i = 0; i < ITERATE_OVER_INPUT.length; i++) { const item = ITERATE_OVER_INPUT[i]; // Modify the current state according to the problem's requirements ans += backtrack(curr, ...OTHER_ARGUMENTS); // Undo the modification of the current state (backtrack) } return ans; }
Java
public int backtrack(StateType curr, OtherArgumentType... OTHER_ARGUMENTS) { if (BASE_CASE) { // Modify the answer according to the problem's requirements return; } int ans = 0; for (int i = 0; i < ITERATE_OVER_INPUT.length; i++) { ItemType item = ITERATE_OVER_INPUT[i]; // Modify the current state according to the problem's requirements ans += backtrack(curr, OTHER_ARGUMENTS); // Undo the modification of the current state (backtrack) } return ans; }
Fusionar intervalos implica combinar intervalos superpuestos o adyacentes en un conjunto consolidado, lo que a menudo se usa en problemas que involucran intervalos de tiempo o programación. Simplifica las operaciones basadas en intervalos.
He aquí un vistazo más de cerca a cómo funciona la combinación de intervalos:
Aplicaciones de intervalos de fusión:
Los intervalos de fusión se utilizan comúnmente en:
Al fusionar intervalos superpuestos, esta técnica simplifica las operaciones basadas en intervalos y mejora la eficiencia en diversas aplicaciones.
Indicadores clave:
Código de plantilla:
Pitón
def merge_intervals(intervals): # 1. Sort the intervals based on their start values. intervals.sort(key=lambda x: x[0]) # 2. Initialize an empty list to store merged intervals. merged_intervals = [] # 3. Iterate through the sorted intervals. for interval in intervals: # 4. If the merged_intervals list is empty or the current interval does not overlap with the last merged interval: if not merged_intervals or interval[0] > merged_intervals[-1][1]: merged_intervals.append(interval) else: # 5. If the current interval overlaps with the last merged interval, merge them. merged_intervals[-1][1] = max(merged_intervals[-1][1], interval[1]) # 6. Return the merged_intervals list. return merged_intervals
js
function mergeIntervals(intervals) { // 1. Sort the intervals based on their start values. intervals.sort((a, b) => a[0] - b[0]); // 2. Initialize an empty array to store merged intervals. const mergedIntervals = []; // 3. Iterate through the sorted intervals. for (const interval of intervals) { // 4. If the mergedIntervals array is empty or the current interval does not overlap with the last merged interval: if (mergedIntervals.length === 0 || interval[0] > mergedIntervals[mergedIntervals.length - 1][1]) { mergedIntervals.push(interval); } else { // 5. If the current interval overlaps with the last merged interval, merge them. mergedIntervals[mergedIntervals.length - 1][1] = Math.max(mergedIntervals[mergedIntervals.length - 1][1], interval[1]); } } // 6. Return the mergedIntervals array. return mergedIntervals; }
Java
public class MergeIntervals { public int[][] merge(int[][] intervals) { // 1. Sort the intervals based on their start values. Arrays.sort(intervals, (a, b) -> a[0] - b[0]); // 2. Create a list to store merged intervals. List<int[]> mergedIntervals = new ArrayList<>(); // 3. Iterate through the sorted intervals. for (int[] interval : intervals) { // 4. If the mergedIntervals list is empty or the current interval does not overlap with the last merged interval: if (mergedIntervals.isEmpty() || interval[0] > mergedIntervals.get(mergedIntervals.size() - 1)[1]) { mergedIntervals.add(interval); } else { // 5. If the current interval overlaps with the last merged interval, merge them. mergedIntervals.get(mergedIntervals.size() - 1)[1] = Math.max(mergedIntervals.get(mergedIntervals.size() - 1)[1], interval[1]); } } // 6. Convert the list to an array and return it. return mergedIntervals.toArray(new int[mergedIntervals.size()][]); } }
Si desea obtener más información sobre estos patrones y cómo puede implementarlos de manera más efectiva durante su próxima entrevista de codificación, ¡visite techinterviews.io hoy! Ofrecemos un plan de estudios integral para prepararlo para su próxima entrevista de codificación, que cubre temas como estructuras de datos , algoritmos , entrevistas de comportamiento e incluso negociación salarial . Incluso tenemos un espacio de trabajo de codificación incorporado para que practiques.
¡Mejora tu preparación para la entrevista de codificación hoy con nuestro código de promoción TECH30 con un descuento de $30!
Imagen destacada “un desarrollador escribiendo código” por @Limarc
Gráficos hechos con Okso.app