Cómo encontrar el año bisiesto mediante programación en C

Resuelto asked hace 14 años • 16 respuestas

Escribí un programa en C para saber si el año ingresado es bisiesto o no. Pero lamentablemente no funciona bien. Dice que un año es bisiesto y el año anterior no es bisiesto.

#include <stdio.h>
#include <conio.h>

int yearr(int year);

void main(void) {
    int year;
    printf("Enter a year:");
    scanf("%d", &year);
    if (!yearr(year)) {
        printf("It is a leap year.");
    } else {
        printf("It is not a leap year");
    }

    getch();
}

int yearr(int year) {
    if ((year % 4 == 0) && (year / 4 != 0))
        return 1;
    else
        return 0;
}

Después de leer los comentarios edité mi código como:

#include <stdio.h>
#include <conio.h>

int yearr(int year);

void main(void) {
    int year;
    printf("Enter a year:");
    scanf("%d", &year);
    if (!yearr(year)) {
        printf("It is a leap year.");
    } else {
        printf("It is not a leap year");
    }

    getch();
}

int yearr(int year) {
    if (year % 4 == 0) {
        if (year % 400 == 0)
            return 1;
        if (year % 100 == 0)
            return 0; 
    } else
        return 0;
}
 avatar Jul 11 '10 00:07
Aceptado

Prueba de año bisiesto más eficiente:

if ((year & 3) == 0 && ((year % 25) != 0 || (year & 15) == 0))
{
    /* leap year */
}

Este código es válido en C, C++, C#, Java y muchos otros lenguajes similares a C. El código utiliza una única expresión VERDADERO/FALSO que consta de tres pruebas independientes:

  • Prueba de 4to año:year & 3
  • Prueba del centenario:year % 25
  • Prueba de los 400 años:year & 15

A continuación se muestra una discusión completa de cómo funciona este código, pero primero se requiere una discusión del algoritmo de Wikipedia:

El algoritmo de Wikipedia es INEFICIENTE/NO CONFIABLE

Wikipedia ha publicado un algoritmo pseudocódigo (Ver: Wikipedia: Año bisiesto - Algoritmo ) que ha sido sometido a constantes ediciones, opiniones y vandalismo.

¡NO IMPLEMENTE EL ALGORITMO DE WIKIPEDIA!

Uno de los algoritmos de Wikipedia más antiguos (e ineficientes) apareció de la siguiente manera:

if year modulo 400 is 0 then
   is_leap_year
else if year modulo 100 is 0 then
   not_leap_year
else if year modulo 4 is 0 then
   is_leap_year
else
   not_leap_year

El algoritmo anterior es ineficiente porque siempre realiza las pruebas para el año 400 y el año 100, incluso para años que rápidamente fallarían la "prueba del 4º año" (la prueba de módulo 4), ¡que es el 75% de las veces! Al reordenar el algoritmo para realizar primero la prueba del cuarto año, aceleramos las cosas significativamente.

ALGORITMO DE PSEUDOCÓDIGO "MÁS EFICIENTE"

Proporcioné el siguiente algoritmo a Wikipedia (más de una vez):

if year is not divisible by 4 then not leap year
else if year is not divisible by 100 then leap year
else if year is divisible by 400 then leap year
else not leap year

Este pseudocódigo "más eficiente" simplemente cambia el orden de las pruebas para que la división por 4 se realice primero, seguida de las pruebas que ocurren con menos frecuencia. Debido a que "año" no se divide por cuatro el 75 por ciento de las veces, el algoritmo finaliza después de sólo una prueba en tres de cada cuatro casos.

NOTA: He luchado contra varios editores de Wikipedia para mejorar el algoritmo publicado allí, argumentando que muchos programadores novatos (y profesionales) llegan rápidamente a la página de Wikipedia (debido a los listados de los principales motores de búsqueda) e implementan el pseudocódigo de Wikipedia sin ninguna investigación adicional. Los editores de Wikipedia repudiaron y eliminaron cada intento que hice para mejorar, anotar o incluso simplemente poner notas a pie de página en el algoritmo publicado. Aparentemente, creen que encontrar eficiencias es problema del programador. Puede que sea cierto, pero muchos programadores tienen demasiada prisa para realizar una investigación sólida.

DISCUSIÓN DE LA PRUEBA DEL AÑO BISIESTO "MÁS EFICIENTE"

Bit a bit-AND en lugar de módulo:

He reemplazado dos de las operaciones de módulo en el algoritmo de Wikipedia con operaciones AND bit a bit. ¿Porque y como?

Realizar un cálculo de módulo requiere división. Uno no suele pensar dos veces en esto al programar una PC, pero al programar microcontroladores de 8 bits integrados en dispositivos pequeños, es posible que la CPU no pueda realizar una función de división de forma nativa. En este tipo de CPU, la división es un proceso arduo que implica bucles repetitivos, desplazamiento de bits y operaciones de suma/resta que es muy lento. Es muy recomendable evitarlo.

Resulta que el módulo de potencias de dos se puede lograr alternativamente usando una operación AND bit a bit (ver: Wikipedia: Operación de módulo - Problemas de rendimiento ):

x % 2^n == x y (2^n - 1)

Muchos compiladores de optimización convertirán dichas operaciones de módulo a AND bit a bit, pero es posible que los compiladores menos avanzados para CPU más pequeñas y menos populares no lo hagan. Bitwise-AND es una única instrucción en cada CPU.

Al reemplazar las pruebas modulo 4y con y (ver más abajo: 'Factorizar para reducir las matemáticas') podemos garantizar que se obtenga el código más rápido sin utilizar una operación de división mucho más lenta.modulo 400& 3& 15

No existe una potencia de dos que sea igual a 100. Por lo tanto, nos vemos obligados a continuar usando la operación de módulo para la prueba del año 100; sin embargo, 100 se reemplaza por 25 (ver más abajo).

Factorizar para simplificar las matemáticas:

Además de utilizar AND bit a bit para reemplazar las operaciones de módulo, es posible que observe dos disputas adicionales entre el algoritmo de Wikipedia y la expresión optimizada:

  • modulo 100es reemplazado pormodulo 25
  • modulo 400es reemplazado por& 15

La prueba de los 100 años utiliza modulo 25en lugar de modulo 100. Podemos hacer esto porque 100 factoriza 2 x 2 x 5 x 5. Debido a que la prueba del cuarto año ya verifica factores de 4, podemos eliminar ese factor de 100, dejando 25. Esta optimización probablemente sea insignificante para casi todas las implementaciones de CPU ( ya que tanto 100 como 25 caben en 8 bits).

La prueba de los 400 años utiliza & 15lo que equivale a modulo 16. Nuevamente, podemos hacer esto porque 400 factoriza 2 x 2 x 2 x 2 x 5 x 5. Podemos eliminar el factor de 25 que se prueba mediante la prueba de los 100 años, dejando 16. No podemos reducir más 16 porque 8 es un factor de 200, por lo que eliminar más factores produciría un positivo no deseado durante 200 años.

La optimización del año 400 es muy importante para las CPU de 8 bits, en primer lugar, porque evita la división; pero, más importante aún, porque el valor 400 es un número de 9 bits mucho más difícil de manejar en una CPU de 8 bits.

Operadores lógicos Y/O de cortocircuito:

La optimización final, y la más importante, utilizada son los operadores lógicos de cortocircuito AND ('&&') y OR ('||') (ver: Wikipedia: Evaluación de cortocircuito ), que se implementan en la mayoría de los lenguajes tipo C. . Los operadores de cortocircuito se denominan así porque no se molestan en evaluar la expresión del lado derecho si la expresión del lado izquierdo, por sí sola, dicta el resultado de la operación.

Por ejemplo: si el año es 2003, entonces year & 3 == 0es falso. No hay forma de que las pruebas en el lado derecho del AND lógico puedan hacer que el resultado sea verdadero, por lo que no se evalúa nada más.

Al realizar primero la prueba del cuarto año, solo se evalúa la prueba del cuarto año (un AND bit a bit simple) las tres cuartas partes (75 por ciento) del tiempo. Esto acelera enormemente la ejecución del programa, especialmente porque evita la división necesaria para la prueba del año 100 (la operación del módulo 25).

NOTA SOBRE LA COLOCACIÓN DE PARÉNTESIS

Un comentarista consideró que los paréntesis estaban mal colocados en mi código y sugirió que las subexpresiones se reagruparan alrededor del operador lógico AND (en lugar de alrededor del lógico OR), de la siguiente manera:

if (((year & 3) == 0 && (year % 25) != 0) || (year & 15) == 0) { /* LY */ }

Lo anterior es incorrecto. El operador lógico AND tiene mayor prioridad que el operador lógico OR y se evaluará primero con o sin los nuevos paréntesis. Los paréntesis alrededor de los argumentos lógicos AND no tienen ningún efecto. Esto podría llevar a eliminar por completo los subgrupos:

if ((year & 3) == 0 && (year % 25) != 0 || (year & 15) == 0) { /* LY */ }

Pero, en los dos casos anteriores, el lado derecho del OR lógico (la prueba de los 400 años) se evalúa casi siempre (es decir, años no divisibles entre 4 y 100). De este modo se ha eliminado por error una optimización útil.

Los paréntesis en mi código original implementan la solución más optimizada:

if ((year & 3) == 0 && ((year % 25) != 0 || (year & 15) == 0)) { /* LY */ }

Aquí el OR lógico sólo se evalúa para años divisibles por 4 (debido al cortocircuito del AND). El lado derecho del OR lógico sólo se evalúa para los años divisibles por 4 y 100 (debido al cortocircuito OR).

NOTA PARA PROGRAMADORES C/C++

Los programadores de C/C++ pueden sentir que esta expresión está más optimizada:

if (!(year & 3) && ((year % 25) || !(year & 15))) { /* LY */ }

¡Esto no está más optimizado! Si bien las pruebas explícitas == 0y != 0se eliminan, se vuelven implícitas y aún se realizan. Peor aún, el código ya no es válido en lenguajes fuertemente tipados como C#, donde year & 3se evalúa como an int, pero los operadores lógicos AND ( &&), OR ( ||) y NOT ( !) requieren boolargumentos.

Kevin P. Rice avatar Jul 21 '2012 21:07 Kevin P. Rice

Su lógica para determinar un año bisiesto es errónea. Esto debería ayudarte a comenzar (de Wikipedia):

if year modulo 400 is 0
       then is_leap_year
else if year modulo 100 is 0
       then not_leap_year
else if year modulo 4 is 0
       then is_leap_year
else
       not_leap_year

x modulo ysignifica el resto de xdividido por y. Por ejemplo, 12 módulo 5 es 2.

Alok Singhal avatar Jul 10 '2010 17:07 Alok Singhal

Muchas respuestas hablan de rendimiento. Ninguno muestra ninguna medida. Una buena cita de la documentación de gcc __builtin_expectdice esto:

Los programadores son notoriamente malos a la hora de predecir cómo funcionan realmente sus programas.

La mayoría de las implementaciones utilizan el cortocircuito de &&y ||como herramienta de optimización y continúan prescribiendo el orden "correcto" para las comprobaciones de divisibilidad para obtener el "mejor" rendimiento. Vale la pena mencionar que el cortocircuito no es necesariamente una característica de optimización.

De acuerdo, algunas comprobaciones pueden dar una respuesta definitiva (por ejemplo, el año no es múltiplo de 4) y hacer inútiles las pruebas posteriores. Parece razonable regresar inmediatamente a este punto en lugar de seguir con cálculos innecesarios. Por otro lado, los retornos anticipados introducen sucursales y esto podría disminuir el rendimiento. (Consulte esta publicación legendaria ). La compensación entre una predicción errónea de una rama y un cálculo innecesario es muy difícil de adivinar. De hecho, depende del hardware, de los datos de entrada, de las instrucciones exactas de ensamblaje emitidas por el compilador (que pueden cambiar de una versión a otra), etc.

La secuela mostrará las mediciones obtenidas en quick-bench.com . En todos los casos, medimos el tiempo necesario para comprobar si cada valor almacenado en un std::array<int, 65536>es un año bisiesto o no. Los valores son pseudoaleatorios, distribuidos uniformemente en el intervalo [-400, 399]. Más precisamente, se generan mediante el siguiente código:

auto const years = [](){
  std::uniform_int_distribution<int> uniform_dist(-400, 399);
  std::mt19937 rng;
  std::array<int, 65536> years;
  for (auto& year : years)
    year = uniform_dist(rng);
  return years;
}();

Incluso cuando es posible, no lo reemplazo %con &(por ejemplo, year & 3 == 0en lugar de year % 4 == 0). Confío en que el compilador (GCC-9.2 en -O3) lo hará por mí. (Lo hace.)

4-100-400 pruebas

Los cheques para años bisiestos generalmente se escriben en términos de tres pruebas de divisibilidad: year % 4 == 0, year % 100 != 0y year % 400 == 0. La siguiente es una lista de implementaciones que cubren todos los órdenes posibles en los que pueden aparecer estas comprobaciones. Cada implementación tiene como prefijo una etiqueta correspondiente. (Algunas órdenes permiten dos implementaciones diferentes, en cuyo caso, la segunda obtiene un sufijo b). Son:

b4_100_400  : year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
b4_100_400b : (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
b4_400_100  : year % 4 == 0 && (year % 400 == 0 || year % 100 != 0)
b100_4_400  : (year % 100 != 0 && year % 4 == 0) || year % 400 == 0
b100_400_4  : (year % 100 != 0 || year % 400 == 0) && year % 4 == 0
b400_4_100  : year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
b400_100_4  : year % 400 == 0 || (year % 100 != 0 && year % 4 == 0)
b400_100_4b : (year % 400 == 0 || year % 100 != 0) && year % 4 == 0

Los resultados se muestran a continuación. (Véalos en vivo ).

4-100-400 pruebas

Al contrario de lo que muchos han aconsejado, comprobar 4primero la divisibilidad no parece ser lo mejor que se puede hacer. Por el contrario, al menos en estas mediciones, las tres primeras barras se encuentran entre las cinco peores. El mejor es

b100_400_4  : (year % 100 != 0 || year % 400 == 0) && year % 4 == 0

25-04-16 pruebas

Otro consejo proporcionado (que debo confesar que pensé que era bueno) es reemplazarlo year % 100 != 0con year % 25 != 0. Esto no afecta la corrección ya que también verificamos year % 4 == 0. (Si un número es múltiplo de 4entonces la divisibilidad por 100es equivalente a la divisibilidad por 25). De manera similar, year % 400 == 0se puede reemplazar con year % 16 == 0debido a la presencia de verificación de divisibilidad por 25.

Como en la última sección, tenemos 8 implementaciones que utilizan comprobaciones de divisibilidad 4-25-16:

b4_25_16  : year % 4 == 0 && (year % 25 != 0 || year % 16 == 0)
b4_25_16b : (year % 4 == 0 && year % 25 != 0) || year % 16 == 0
b4_16_25  : year % 4 == 0 && (year % 16 == 0 || year % 25 != 0)
b25_4_16  : (year % 25 != 0 && year % 4 == 0) || year % 16 == 0
b25_16_4  : (year % 25 != 0 || year % 16 == 0) && year % 4 == 0
b16_4_25  : year % 16 == 0 || (year % 4 == 0 && year % 25 != 0)
b16_25_4  : year % 16 == 0 || (year % 25 != 0 && year % 4 == 0)
b16_25_4b : (year % 16 == 0 || year % 25 != 0) && year % 4 == 0

Resultados (en vivo aquí ):

25-04-16 pruebas

Nuevamente, comprobar la divisibilidad 4primero no parece una buena idea. En esta ronda el ayuno es

b25_16_4  : (year % 25 != 0 || year % 16 == 0) && year % 4 == 0

4-100-400 pruebas (sin ramificaciones)

As previously mentioned branching might degrade performance. In particular, short-circuiting might be counterproductive. When this is the case, a classical trick is replacing logical operators && and || with their bit-wise counterparts & and |. The implementations become:

nb4_100_400  : (year % 4 == 0) & ((year % 100 != 0) | (year % 400 == 0))
nb4_100_400b : ((year % 4 == 0) & (year % 100 != 0)) | (year % 400 == 0)
nb4_400_100  : (year % 4 == 0) & ((year % 400 == 0) | (year % 100 != 0))
nb100_4_400  : ((year % 100 != 0) & (year % 4 == 0)) | (year % 400 == 0)
nb100_400_4  : ((year % 100 != 0) | (year % 400 == 0)) & (year % 4 == 0)
nb400_4_100  : (year % 400 == 0) | ((year % 4 == 0) & (year % 100 != 0))
nb400_100_4  : (year % 400 == 0) | ((year % 100 != 0) & (year % 4 == 0))
nb400_100_4b : ((year % 400 == 0) | (year % 100 != 0)) & (year % 4 == 0)

Results (live here):

4-100-400 pruebas (sin ramificaciones)

A noticeable feature is that performance variation is not as pronounced as in the branching case and it makes difficult to declare a winner. We pick this one:

nb100_400_4  : ((year % 100 != 0) | (year % 400 == 0)) & (year % 4 == 0)

4-25-16 tests (no branching)

To complete the exercise, we consider the no-branching case with 4-25-16 divisibility tests:

nb4_25_16  : (year % 4 == 0) & ((year % 25 != 0) | (year % 16 == 0))
nb4_25_16b : ((year % 4 == 0) & (year % 25 != 0)) | (year % 16 == 0)
nb4_16_25  : (year % 4 == 0) & ((year % 16 == 0) | (year % 25 != 0))
nb25_4_16  : ((year % 25 != 0) & (year % 4 == 0)) | (year % 16 == 0)
nb25_16_4  : ((year % 25 != 0) | (year % 16 == 0)) & (year % 4 == 0)
nb16_4_25  : (year % 16 == 0) | ((year % 4 == 0) & (year % 25 != 0))
nb16_25_4  : (year % 16 == 0) | ((year % 25 != 0) & (year % 4 == 0))
nb16_25_4b : ((year % 16 == 0) | (year % 25 != 0)) & (year % 4 == 0)

Results (live here):

4-25-16 pruebas (sin ramificaciones)

Once again, it's difficult to define the best and we select this one:

nb25_16_4  : ((year % 25 != 0) | (year % 16 == 0)) & (year % 4 == 0)

Champions League

It's now time to pick the best of each previous section and compare them:

b100_400_4  : (year % 100 != 0 || year % 400 == 0) && year % 4 == 0
b25_16_4    : (year % 25 != 0 || year % 16 == 0) && year % 4 == 0
nb100_400_4 : ((year % 100 != 0) | (year % 400 == 0)) & (year % 4 == 0)
nb25_16_4   : ((year % 25 != 0) | (year % 16 == 0)) & (year % 4 == 0)

Results (live here):

Liga de Campeones

This chart suggests that short-circuiting is, indeed, an optimization but divisibility by 4 should be the last to be checked rather then the first. For better performance one should check divisibility by 100 first. This is rather surprising! After all, the latter test is never enough to decide whether the year is leap or not and a subsequent test (either by 400 or by 4) is always required.

Also surprising is that for the branching versions using simpler divisors 25 and 16 is not better than using the more intuitive 100 and 400. I can offer my "theory" which also partially explains why testing for 100 first is better than testing for 4. As many pointed out, the divisibility test by 4 splits execution into (25%, 75%) parts whereas the test for 100 splits it into (1%, 99%). It doesn't matter that after the latter check, the execution must carry on to another test because, at least, the branch predictor is more likely to guess correctly which way to go. Similarly, checking divisibility by 25 splits execution into (4%, 96%) which is more challenging for the branch predictor than (1%, 99%). It looks like that it is better to minimize the entropy of the distribution, helping the branch predictor, rather than maximizing the probability of early return.

For the no branching versions, simplified divisors do offer better performance. In this case the branch predictor plays no role and, therefore, the simpler the better. Generally, the compiler can perform better optimizations with smaller numbers.

Is this it?

Did we hit the wall and found out that

b100_400_4  : (year % 100 != 0 || year % 400 == 0) && year % 4 == 0

is the most performant check for leap years? Definitely not. We haven't for instance, mixed branching operators && or || with no branching ones & and |. Perhaps... Let's see and compare the above with two other implementations. The first is

m100_400_4 : (year % 100 != 0 || year % 400 == 0) & (year % 4 == 0)

(Notice the mix of branching || and non-branching & operators.) The second is an obscure "hack":

bool b;
auto n = 0xc28f5c29 * year;
auto m = n + 0x051eb850;
m = (m << 30 | m >> 2);
if (m <= 0x28f5c28)
  b = n % 16 == 0;
else
  b = n % 4 == 0;
return b

Does the latter work? Yes it does. Instead of giving a mathematical proof I suggest comparing the code emitted for the above with the one for this more readable source:

bool b;
if (year % 100 == 0)
  b = year % 16 == 0;
else
  b = year % 4 == 0;
return b;

in Compiler Explorer. They are almost identical, the only difference being that one uses an add instruction when the other uses a lea. This should convince you that the hack code does work (as long as the other does).

Benchmark results (live here):

Final

Hold on, I hear you say, why not using the more readable code in the picture above? Well, I've tried and learned another lesson. When this code is inserted into the benchmark loop, the compiler looked at the source as a whole and decided to emit different code than when it sees the source in isolation. The performance was worse. Go figure!

And now? Is this it?

I don't know! There are many things that we could explore. The last section, for instance, showed yet another version using an if statement rather that short-circuiting. That could be a way to get even better performance. We could also try the ternary operator ?.

Be aware that all measurements and conclusions were based on GCC-9.2. With another compiler and/or version things might change. For instance, GCC from version 9.1 introduced a new improved algorithm for divisibility checks. Hence, older versions have different performances and the trade-off between an unnecessary calculation and a branch misprediction have likely changed.

The points that we can definitely conclude are:

  1. Do not overthink. Prefer clear code over difficult to understand optimizations (unless you're a library writer offering higher level APIs for your users around very efficient implementations). After all, a hand-crafted snippet might not be the most performant option when you upgrade compiler. As ex-nihilo said in a comment, "There is little point in fussing over a piece of code like this unless it is known to be a performance bottleneck."
  2. Guessing is difficult and it's better to measure.
  3. Measuring is difficult but is better than guessing.
  4. Short-circuiting/branching can be good for performance but it depends on many things including how much the code helps the branch predictor.
  5. Code emitted for a snippet in isolation might be different when it's inlined somewhere.
Cassio Neri avatar Mar 12 '2020 02:03 Cassio Neri