A couple of date-related questions have come up recently, and on both occasions I have been taken back to my 'early' days where one of my first forays in to programming involved implementing Zeller's Congruence.
Zeller's congruence is a neat calculation because it involves a mathematical approach to date manipulation, and the math relies on both modular and integral arithmetic, combined with a smart way of visualizing time progression.
$$ \begin{align} h =&\ \left(q + \left\lfloor \frac{13(m + 1)}{5} \right\rfloor + K + \left\lfloor \frac{K}{4} \right\rfloor + 5J + \left\lfloor \frac{J}{4} \right\rfloor \right) \mod7\\ where\\ h =&\ \text{the day of the week (0 is Saturday ... 6 is Friday)}\\ q =&\ \text{the day of the month}\\ m =&\ \text{the month (3 = March, 4 = April, ..., 13 = January, 14 = February)}\\ &\ \text{If Jan or Feb, then you have to adjust the year back by 1 year}\\ &\ \text{Jan 2001 is month 13 of year 2000}\\ K =&\ \text{the century count} \implies \left\lfloor\ \frac{\mathtt{adj.year}}{100}\right\rfloor\\ J =&\ \text{the adjusted year in the century} \implies (\ \mathtt{adj.year} \mod 100)\\ \end{align} $$
The two recent questions that have inspired me to go back and re-implement Zeller's Congruence are:
As a secondary exercise, I tested the code using the new-in-Java8 time API.
Zeller's Congruence
Note: The code contains comments which go some way to explaining how the congruence works
// Used for a quick validation of days in a month.
// Note filler at MONTHDAYS[0] because months are 1-based.
private static final int[] MONTHDAYS =
{ 0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
private static final boolean isLeap(final int year) {
return (year % 4) == 0 && (year % 100 != 0 || year % 400 == 0);
}
/**
* Calculate the day of the week (1=Monday, 7=Sunday) for the supplied
* (valid) date.
* @param year The year to calculate (year 1 or more recent)
* @param month The month (1 through 12 for January through December)
* @param day The Day-in-month (1 through 28/29/30/31 depending on the month)
* @return The day of the week (1 represents Monday, 7 represents Sunday).
* @throws IllegalArgumentException if the input values are not a valid date
*/
public static final int computeDayOfWeek(final int year, final int month, final int day)
throws IllegalArgumentException {
// easy checks for valid dates.
if (year < 1 || month < 1 || month > 12 || day < 1 || day > MONTHDAYS[month]) {
throw new IllegalArgumentException(String.format(
"%04d-%02d-%02d is not a valid date", year, month, day));
}
// leap year validation
if (day == 29 && month == 2 && !isLeap(year)) {
throw new IllegalArgumentException(
String.format(
"%04d-%02d-%02d is not a valid date (Feb 29 but not a leap year)",
year, month, day));
}
// Forumla is here: https://en.wikipedia.org/wiki/Zeller%27s_congruence
// Using the wikipedia's variable names:
// h = (q + floor((13*(m+1)/5)) + K + floor(K/4) + floor(J/4) + 5J)
// translate variable names in to algorithm components.
// q is the day of month.
final int q = day;
// m is the month, but Jan and Feb need to be month 13 and 14
// respectively
final int m = month + (month < 3 ? 12 : 0);
// if the month is jan, or feb, then year is of the previous year.
final int calcyear = year - (month < 3 ? 1 : 0);
// K is the year-in-century
final int K = calcyear % 100;
// J is the century number
final int J = calcyear / 100;
/*
Algorithm works by following a concept of adding days in to a
sequence, and performing modular arithmetic. The fundamental concept
is that if you know one specific date's day-of-week, then all you
need to do is calculate how many days you are in front of, or behind
that day.
If you know the days, you can perform a %7 on that, and get the day
difference.
If you choose your algorithm to start on a specific day and call it
0, then the difference from the %7 is simply the day. As it happens,
to make things work well, starting with Saturday as day 0 is right.
*/
// how many days (possibly %7) are we offset at this point in time?
int offset = 0;
/*
Now, how to calculate the days between. Well, there are 36524 days in
a century, unless the century is divisible by 4, in which case there is
an extra day. so, the days between epoch and now is the number of
centuries * days-in-century + number-of-4-centuries.
But, because we only need extra days, we can do these things %7. So,
since 36524%7 is 5, we need to add 5 days for each century since epoch.
Additionally, we need to add another day for each of those special
leap years that are divisible by 400
*/
// 5 days per century plus the number of 400 years too.
offset += J * 5 + J / 4;
// now, inside a century, there's leap years....
// a normal year has 365 days, which is 52 weeks and 1 day.
// so, each year since the start, is 1 more day of offset. And, each
// leap year adds another...
offset += K + K / 4;
/*
So, that gives us the right number of offset days to get us to the
current year.
Now, if we start our logical year on March 1st, we don't need to
worry about the odd day at the end of february. Also, the number of
days in a month, starting from March, is:
Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec,Jan,Feb
31, 30, 31, 30, 31, 31, 30, 31, 30, 31, 31, 28/29
Notice how Aug and Jan are at positions 5 and 10?
Also, a 30-day month adds 2 days of offset, and a 31-day month adds 3
days of offset. So, for each 30 day month we add 2 days, and for each
31 day month we add 3.
This formula can be hard-coded as (13 * (m + 1))/5
*/
offset += (13 * (m + 1)) / 5;
// Now all we need to do is add the days in our current month, to get
// the final offset:
offset += q;
// Then, the actual day of week is the zero-day + offset % 7
int h = offset % 7;
// Now adjust the 0-6 based Saturday-Friday to a 1-7 based Monday-Sunday
return ((h + 5) % 7) + 1;
}
Test Code
I used the Time API to test the code. Obviously, the time API's LocalDateTime
class is able to give you the weekday for a given date. The above code is completely reinventing the wheel... I used that LocalDateTime to validate the results:
/*
* method for testing only.
*/
private static final void check(LocalDateTime then, boolean print) {
// do an us vs. them comparison
int us = computeDayOfWeek(
then.get(ChronoField.YEAR),
then.get(ChronoField.MONTH_OF_YEAR),
then.get(ChronoField.DAY_OF_MONTH));
int them = then.get(ChronoField.DAY_OF_WEEK);
if (us != them || print) {
System.out.printf("%14s %14s is %s%n",
then.toString(), DayOfWeek.of(them), DayOfWeek.of(us));
}
if (us != them) {
throw new IllegalStateException(String.format(
"Unable to correlate our calculation %d to the system %d",
us, them));
}
}
public static void main(String[] args) {
LocalDateTime now = LocalDateTime.now().truncatedTo(ChronoUnit.DAYS);
LocalDateTime past = LocalDateTime.of(1, 1, 1, 0, 0);
LocalDateTime then = past;
// test every day since 1 Jan, 0001 through to today
while (then.isBefore(now)) {
check(then, false);
then = then.plusDays(1);
}
check(now, true);
then = LocalDateTime.of(2, 1, 1, 0, 0);
// recheck, and print the first year's dates.
while (then.isAfter(past)) {
then = then.minusDays(1);
check(then, true);
}
LocalDateTime future = now.plusYears(1);
then = now;
// check, and print the next year's dates.
while (then.isBefore(future)) {
check(then, true);
then = then.plusDays(1);
}
}