Tengo que estar de acuerdo en que es bastante extraño la primera vez que ves un algoritmo O (log n) ... ¿de dónde diablos viene ese logaritmo? Sin embargo, resulta que hay varias formas diferentes de hacer que un término de registro se muestre en notación O grande. A continuación, presentamos algunos:
Dividiendo repetidamente por una constante
Tome cualquier número n; digamos, 16. ¿Cuántas veces puedes dividir n por dos antes de obtener un número menor o igual a uno? Para 16, tenemos eso
16 / 2 = 8
8 / 2 = 4
4 / 2 = 2
2 / 2 = 1
Tenga en cuenta que esto termina tomando cuatro pasos para completar. Curiosamente, también tenemos ese log 2 16 = 4. Hmmm ... ¿qué pasa con 128?
128 / 2 = 64
64 / 2 = 32
32 / 2 = 16
16 / 2 = 8
8 / 2 = 4
4 / 2 = 2
2 / 2 = 1
Esto tomó siete pasos y log 2 128 = 7. ¿Es una coincidencia? ¡No! Hay una buena razón para esto. Supongamos que dividimos un número n por 2 i veces. Luego obtenemos el número n / 2 i . Si queremos resolver el valor de i donde este valor es como máximo 1, obtenemos
n / 2 yo ≤ 1
n ≤ 2 yo
log 2 n ≤ i
En otras palabras, si elegimos un entero i tal que i ≥ log 2 n, luego de dividir n por la mitad i veces tendremos un valor que es como máximo 1. La i más pequeña para la que esto está garantizado es aproximadamente log 2 n, entonces si tenemos un algoritmo que divide por 2 hasta que el número se vuelve lo suficientemente pequeño, entonces podemos decir que termina en O (log n) pasos.
Un detalle importante es que no importa por qué constante está dividiendo n (siempre que sea mayor que uno); si divide por la constante k, se necesitarán log k n pasos para llegar a 1. Por lo tanto, cualquier algoritmo que divida repetidamente el tamaño de entrada por alguna fracción necesitará O (log n) iteraciones para terminar. Esas iteraciones pueden llevar mucho tiempo, por lo que el tiempo de ejecución neto no necesita ser O (log n), pero el número de pasos será logarítmico.
Entonces, ¿de dónde viene esto? Un ejemplo clásico es la búsqueda binaria , un algoritmo rápido para buscar un valor en una matriz ordenada. El algoritmo funciona así:
- Si la matriz está vacía, devuelva que el elemento no está presente en la matriz.
- De otra manera:
- Mira el elemento del medio de la matriz.
- Si es igual al elemento que estamos buscando, devuelve éxito.
- Si es mayor que el elemento que buscamos:
- Deseche la segunda mitad de la matriz.
- Repetir
- Si es menor que el elemento que estamos buscando:
- Deseche la primera mitad de la matriz.
- Repetir
Por ejemplo, para buscar 5 en la matriz
1 3 5 7 9 11 13
Primero miraríamos el elemento del medio:
1 3 5 7 9 11 13
^
Dado que 7> 5, y dado que la matriz está ordenada, sabemos con certeza que el número 5 no puede estar en la mitad posterior de la matriz, por lo que podemos descartarlo. Esto deja
1 3 5
Así que ahora miramos el elemento del medio aquí:
1 3 5
^
Como 3 <5, sabemos que 5 no puede aparecer en la primera mitad de la matriz, por lo que podemos lanzar la primera mitad de la matriz para dejar
5
Nuevamente miramos el medio de esta matriz:
5
^
Dado que este es exactamente el número que estamos buscando, podemos informar que 5 está en la matriz.
Entonces, ¿qué tan eficiente es esto? Bueno, en cada iteración desechamos al menos la mitad de los elementos restantes de la matriz. El algoritmo se detiene tan pronto como la matriz está vacía o encontramos el valor que queremos. En el peor de los casos, el elemento no está allí, por lo que seguimos reduciendo a la mitad el tamaño de la matriz hasta que nos quedemos sin elementos. ¿Cuánto tiempo lleva esto? Bueno, dado que seguimos cortando la matriz por la mitad una y otra vez, terminaremos en la mayoría de O (log n) iteraciones, ya que no podemos cortar la matriz a la mitad más de O (log n) veces antes de ejecutar fuera de los elementos de la matriz.
Los algoritmos que siguen la técnica general de dividir y conquistar (cortar el problema en partes, resolver esas partes y luego volver a armar el problema) tienden a tener términos logarítmicos por esta misma razón: no puede seguir cortando un objeto en la mitad más que O (log n) veces. Es posible que desee ver la ordenación combinada como un gran ejemplo de esto.
Procesando valores de un dígito a la vez
¿Cuántos dígitos hay en el número n de base 10? Bueno, si hay k dígitos en el número, entonces tendríamos que el dígito más grande es un múltiplo de 10 k . El número más grande de k dígitos es 999 ... 9, k veces, y esto es igual a 10 k + 1 - 1. En consecuencia, si sabemos que n tiene k dígitos, entonces sabemos que el valor de n es como máximo 10 k + 1 - 1. Si queremos resolver k en términos de n, obtenemos
n ≤ 10 k + 1 - 1
n + 1 ≤ 10 k + 1
log 10 (n + 1) ≤ k + 1
(log 10 (n + 1)) - 1 ≤ k
De donde obtenemos que k es aproximadamente el logaritmo en base 10 de n. En otras palabras, el número de dígitos de n es O (log n).
Por ejemplo, pensemos en la complejidad de sumar dos números grandes que son demasiado grandes para caber en una palabra de máquina. Supongamos que tenemos esos números representados en base 10, y llamaremos a los números my n. Una forma de sumarlos es a través del método de la escuela primaria: escriba los números un dígito a la vez, luego trabaje de derecha a izquierda. Por ejemplo, para sumar 1337 y 2065, comenzaríamos escribiendo los números como
1 3 3 7
+ 2 0 6 5
==============
Sumamos el último dígito y llevamos el 1:
1
1 3 3 7
+ 2 0 6 5
==============
2
Luego agregamos el penúltimo dígito ("penúltimo") y llevamos el 1:
1 1
1 3 3 7
+ 2 0 6 5
==============
0 2
A continuación, agregamos el penúltimo dígito ("antepenúltimo"):
1 1
1 3 3 7
+ 2 0 6 5
==============
4 0 2
Finalmente, agregamos el cuarto al último ("pre-penúltimo" ... Me encanta el inglés):
1 1
1 3 3 7
+ 2 0 6 5
==============
3 4 0 2
Ahora, ¿cuánto trabajo hicimos? Hacemos un total de O (1) trabajo por dígito (es decir, una cantidad constante de trabajo), y hay O (max {log n, log m}) dígitos totales que necesitan ser procesados. Esto da un total de O (max {log n, log m}) complejidad, porque necesitamos visitar cada dígito en los dos números.
Muchos algoritmos obtienen un término O (log n) al trabajar un dígito a la vez en alguna base. Un ejemplo clásico es el ordenamiento por radix , que ordena los números enteros un dígito a la vez. Hay muchos tipos de ordenación de base, pero generalmente se ejecutan en el tiempo O (n log U), donde U es el entero más grande posible que se está ordenando. La razón de esto es que cada pasada del tipo toma O (n) tiempo, y hay un total de O (log U) iteraciones requeridas para procesar cada uno de los dígitos O (log U) del número más grande que se está ordenando. Muchos algoritmos avanzados, como el algoritmo de rutas más cortas de Gabow o la versión de escala del algoritmo de flujo máximo de Ford-Fulkerson , tienen un término logarítmico en su complejidad porque trabajan un dígito a la vez.
En cuanto a su segunda pregunta sobre cómo resolver ese problema, es posible que desee ver esta pregunta relacionada que explora una aplicación más avanzada. Dada la estructura general de los problemas que se describen aquí, ahora puede tener una mejor idea de cómo pensar en los problemas cuando sabe que hay un término logarítmico en el resultado, por lo que le desaconsejaría mirar la respuesta hasta que la haya dado. Algún pensamiento.
¡Espero que esto ayude!
O(log n)
puede verse como: Si duplica el tamaño del probleman
, su algoritmo solo necesita un número constante de pasos más.