Inleiding
Romeinse cijfers worden niet vaak gebruikt, maar ik vond het deze week eens tijd om de logica van dat systeem in PHP te gieten. Kabouter Wesley zou terecht vragen: "Wat is daar 't praktisch nut van?". Een antwoord blijf ik dit keer schuldig.
Eén en ander begint met de gebruikte symbolen, waarbij specifieke
letters staan voor een bepaalde waarde – ongeacht hun positie in
het getal. Hierbij valt meteen op dat er geen symbool is voor het cijfer
0
en dat negatieve getallen niet kunnen worden genoteerd.
<?php
$symbols = [
1000 => 'M',
900 => 'CM',
500 => 'D',
400 => 'CD',
100 => 'C',
90 => 'XC',
50 => 'L',
40 => 'XL',
10 => 'X',
9 => 'IX',
5 => 'V',
4 => 'IV',
1 => 'I'
];
Logica
Aldus begon ik met de conversie van normale, decimale cijfers (positieve
gehele getallen) naar Romeinse notatie. Om geen extreem lange, onleesbare
getallen te krijgen, beperkte ik de code van 1
tot 4999
. Als jaartallen
kom je daarmee al een heel eind.
<?php
function dec2roman(int $i) : string
{
if ($i <= 0 || $i > 4999) {
return '?';
}
global $symbols;
$symbolKeys = array_keys($symbols);
$symbolValues = array_values($symbols);
$o = '';
foreach ($symbolKeys as $ix => $k) {
if ($i >= $k) {
$d = intdiv($i, $k);
$o .= str_repeat($symbolValues[$ix], $d);
$i -= ($d * $k);
}
}
return $o;
}
De functie loopt door de bekende symbolen en kijkt of de waarde van het huidige symbool in het getal past. Zo ja, dan wordt dat aantal keer het symbool toegevoegd aan de output. De input wordt verminderd met diezelfde factor.
Ik moet eerlijk toegeven: de volgorde en volledigheid van de lijst met
symbolen was wel bepalend voor het succes van deze functie. Ik had even
nogig om te beseffen dat de lijst van groot (M
= 1000) naar klein (I
= 1) gesorteerd moest worden. En dat de buitenbeentjes (CM
= 900, CD
= 400, XC
= 90, XL
= 40, IX
= 9 en IV
= 4) in deze lijst moesten
staan.
Andersom
Daarna volgde natuurlijk ook de omgekeerde berekening: van een Romeins cijfer terug naar een decimaal getal. Hier maakte ik opnieuw gebruik van de lijst met symbolen en hun waarden. Ik zocht mijn toevlucht tot een uitgebreide reguliere expressie:
$romanNumeralRegexp
= "!^ # anchor the regex's start
(?<m>M*) # zero or more 'M's in group 'm'
(?<cm>CM)? # zero or one 'CM' in group 'cm'
(?:(?<d>D)|(?<cd>CD))? # either 'D' or 'CD' in separate groups
(?:(?<c>C{0,3})|(?<xc>XC))? # zero to three 'C's, or 'XC' in separate groups
(?:(?<l>L)|(?<xl>XL))? # either 'L' or 'XL' in separate groups
(?:(?<x>X{0,3})|(?<ix>IX))? # zero to three 'X's, or 'IX' in separate groups
(?:(?<v>V)|(?<iv>IV))? # either 'V' or 'IV' in separate groups
(?<i>I{0,3})? # zero to three 'I's in group 'i'
$!x"; // anchor the regex's end
Hiermee kon ik niet alleen een ingevoerd Romeins cijfer valideren, maar ook meteen de verschillende onderdelen (symbolen) extraheren voor later gebruik.
function roman2dec(string $s) : int
{
if (empty($s)) {
return -1;
}
global $symbols, romanNumeralRegexp;
$m = [];
if (!preg_match($romanNumeralRegexp, $s, $m)) {
return -1;
}
$o = 0;
foreach ($symbols as $v => $c) {
$factor = 1;
if (in_array($v, [1000, 100, 10, 1])) {
$factor = strlen($m[strtolower($c)]);
}
if (!empty($m[strtolower($c)])) {
$o += ($factor * $v);
}
}
return $o;
}
Als de reguliere expressie niet matcht, is er geen geldig cijfer ingevoerd;
de functie geeft dan -1
terug. Daarna wordt de lijst van symbolen
doorlopen en bepaald hoeveel van dezelfde symbolen achter elkaar staan
(bij M
, C
, X
en I
). Tot slot volgt waar het daadwerkelijk om
draait: als de betreffende regex groep gevuld is, dan moet de waarde van
dat symbool (vermenigvuldigd met de factor) meetellen in de output.
Conclusie
Dit project was weer eens ouderwets code kloppen en logisch nadenken; een welkome uitdaging. Met dank aan rapidtables.com voor een handige tabel en zelfs een online converter ter controle.