diff --git a/DIRECTORY.md b/DIRECTORY.md index f53a6220c517..fe9e440da3e2 100644 --- a/DIRECTORY.md +++ b/DIRECTORY.md @@ -118,6 +118,7 @@ * [TurkishToLatinConversion](https://github.com/TheAlgorithms/Java/blob/master/src/main/java/com/thealgorithms/conversions/TurkishToLatinConversion.java) * [UnitConversions](https://github.com/TheAlgorithms/Java/blob/master/src/main/java/com/thealgorithms/conversions/UnitConversions.java) * [UnitsConverter](https://github.com/TheAlgorithms/Java/blob/master/src/main/java/com/thealgorithms/conversions/UnitsConverter.java) + * [WordsToNumber](https://github.com/TheAlgorithms/Java/blob/master/src/main/java/com/thealgorithms/conversions/WordsToNumber.java) * datastructures * bags * [Bag](https://github.com/TheAlgorithms/Java/blob/master/src/main/java/com/thealgorithms/datastructures/bags/Bag.java) @@ -840,6 +841,7 @@ * [TurkishToLatinConversionTest](https://github.com/TheAlgorithms/Java/blob/master/src/test/java/com/thealgorithms/conversions/TurkishToLatinConversionTest.java) * [UnitConversionsTest](https://github.com/TheAlgorithms/Java/blob/master/src/test/java/com/thealgorithms/conversions/UnitConversionsTest.java) * [UnitsConverterTest](https://github.com/TheAlgorithms/Java/blob/master/src/test/java/com/thealgorithms/conversions/UnitsConverterTest.java) + * [WordsToNumberTest](https://github.com/TheAlgorithms/Java/blob/master/src/test/java/com/thealgorithms/conversions/WordsToNumberTest.java) * datastructures * bag * [BagTest](https://github.com/TheAlgorithms/Java/blob/master/src/test/java/com/thealgorithms/datastructures/bag/BagTest.java) diff --git a/src/main/java/com/thealgorithms/conversions/WordsToNumber.java b/src/main/java/com/thealgorithms/conversions/WordsToNumber.java new file mode 100644 index 000000000000..e2b81a0f4b47 --- /dev/null +++ b/src/main/java/com/thealgorithms/conversions/WordsToNumber.java @@ -0,0 +1,343 @@ +package com.thealgorithms.conversions; + +import java.io.Serial; +import java.math.BigDecimal; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + A Java-based utility for converting English word representations of numbers + into their numeric form. This utility supports whole numbers, decimals, + large values up to trillions, and even scientific notation where applicable. + It ensures accurate parsing while handling edge cases like negative numbers, + improper word placements, and ambiguous inputs. + * + */ + +public final class WordsToNumber { + + private WordsToNumber() { + } + + private enum NumberWord { + ZERO("zero", 0), + ONE("one", 1), + TWO("two", 2), + THREE("three", 3), + FOUR("four", 4), + FIVE("five", 5), + SIX("six", 6), + SEVEN("seven", 7), + EIGHT("eight", 8), + NINE("nine", 9), + TEN("ten", 10), + ELEVEN("eleven", 11), + TWELVE("twelve", 12), + THIRTEEN("thirteen", 13), + FOURTEEN("fourteen", 14), + FIFTEEN("fifteen", 15), + SIXTEEN("sixteen", 16), + SEVENTEEN("seventeen", 17), + EIGHTEEN("eighteen", 18), + NINETEEN("nineteen", 19), + TWENTY("twenty", 20), + THIRTY("thirty", 30), + FORTY("forty", 40), + FIFTY("fifty", 50), + SIXTY("sixty", 60), + SEVENTY("seventy", 70), + EIGHTY("eighty", 80), + NINETY("ninety", 90); + + private final String word; + private final int value; + + NumberWord(String word, int value) { + this.word = word; + this.value = value; + } + + public static Integer getValue(String word) { + for (NumberWord num : values()) { + if (word.equals(num.word)) { + return num.value; + } + } + return null; + } + } + + private enum PowerOfTen { + THOUSAND("thousand", new BigDecimal("1000")), + MILLION("million", new BigDecimal("1000000")), + BILLION("billion", new BigDecimal("1000000000")), + TRILLION("trillion", new BigDecimal("1000000000000")); + + private final String word; + private final BigDecimal value; + + PowerOfTen(String word, BigDecimal value) { + this.word = word; + this.value = value; + } + + public static BigDecimal getValue(String word) { + for (PowerOfTen power : values()) { + if (word.equals(power.word)) { + return power.value; + } + } + return null; + } + } + + public static String convert(String numberInWords) { + if (numberInWords == null) { + throw new WordsToNumberException(WordsToNumberException.ErrorType.NULL_INPUT, ""); + } + + ArrayDeque wordDeque = preprocessWords(numberInWords); + BigDecimal completeNumber = convertWordQueueToBigDecimal(wordDeque); + + return completeNumber.toString(); + } + + public static BigDecimal convertToBigDecimal(String numberInWords) { + String conversionResult = convert(numberInWords); + return new BigDecimal(conversionResult); + } + + private static ArrayDeque preprocessWords(String numberInWords) { + String[] wordSplitArray = numberInWords.trim().split("[ ,-]"); + ArrayDeque wordDeque = new ArrayDeque<>(); + for (String word : wordSplitArray) { + if (word.isEmpty()) { + continue; + } + wordDeque.add(word.toLowerCase()); + } + if (wordDeque.isEmpty()) { + throw new WordsToNumberException(WordsToNumberException.ErrorType.NULL_INPUT, ""); + } + return wordDeque; + } + + private static void handleConjunction(boolean prevNumWasHundred, boolean prevNumWasPowerOfTen, ArrayDeque wordDeque) { + if (wordDeque.isEmpty()) { + throw new WordsToNumberException(WordsToNumberException.ErrorType.INVALID_CONJUNCTION, ""); + } + + String nextWord = wordDeque.pollFirst(); + String afterNextWord = wordDeque.peekFirst(); + + wordDeque.addFirst(nextWord); + + Integer number = NumberWord.getValue(nextWord); + + boolean isPrevWordValid = prevNumWasHundred || prevNumWasPowerOfTen; + boolean isNextWordValid = number != null && (number >= 10 || afterNextWord == null || "point".equals(afterNextWord)); + + if (!isPrevWordValid || !isNextWordValid) { + throw new WordsToNumberException(WordsToNumberException.ErrorType.INVALID_CONJUNCTION, ""); + } + } + + private static BigDecimal handleHundred(BigDecimal currentChunk, String word, boolean prevNumWasPowerOfTen) { + boolean currentChunkIsZero = currentChunk.compareTo(BigDecimal.ZERO) == 0; + if (currentChunk.compareTo(BigDecimal.TEN) >= 0 || prevNumWasPowerOfTen) { + throw new WordsToNumberException(WordsToNumberException.ErrorType.UNEXPECTED_WORD, word); + } + if (currentChunkIsZero) { + currentChunk = currentChunk.add(BigDecimal.ONE); + } + return currentChunk.multiply(BigDecimal.valueOf(100)); + } + + private static void handlePowerOfTen(List chunks, BigDecimal currentChunk, BigDecimal powerOfTen, String word, boolean prevNumWasPowerOfTen) { + boolean currentChunkIsZero = currentChunk.compareTo(BigDecimal.ZERO) == 0; + if (currentChunkIsZero || prevNumWasPowerOfTen) { + throw new WordsToNumberException(WordsToNumberException.ErrorType.UNEXPECTED_WORD, word); + } + BigDecimal nextChunk = currentChunk.multiply(powerOfTen); + + if (!(chunks.isEmpty() || isAdditionSafe(chunks.getLast(), nextChunk))) { + throw new WordsToNumberException(WordsToNumberException.ErrorType.UNEXPECTED_WORD, word); + } + chunks.add(nextChunk); + } + + private static BigDecimal handleNumber(Collection chunks, BigDecimal currentChunk, String word, Integer number) { + boolean currentChunkIsZero = currentChunk.compareTo(BigDecimal.ZERO) == 0; + if (number == 0 && !(currentChunkIsZero && chunks.isEmpty())) { + throw new WordsToNumberException(WordsToNumberException.ErrorType.UNEXPECTED_WORD, word); + } + BigDecimal bigDecimalNumber = BigDecimal.valueOf(number); + + if (!currentChunkIsZero && !isAdditionSafe(currentChunk, bigDecimalNumber)) { + throw new WordsToNumberException(WordsToNumberException.ErrorType.UNEXPECTED_WORD, word); + } + return currentChunk.add(bigDecimalNumber); + } + + private static void handlePoint(Collection chunks, BigDecimal currentChunk, ArrayDeque wordDeque) { + boolean currentChunkIsZero = currentChunk.compareTo(BigDecimal.ZERO) == 0; + if (!currentChunkIsZero) { + chunks.add(currentChunk); + } + + String decimalPart = convertDecimalPart(wordDeque); + chunks.add(new BigDecimal(decimalPart)); + } + + private static void handleNegative(boolean isNegative) { + if (isNegative) { + throw new WordsToNumberException(WordsToNumberException.ErrorType.MULTIPLE_NEGATIVES, ""); + } + throw new WordsToNumberException(WordsToNumberException.ErrorType.INVALID_NEGATIVE, ""); + } + + private static BigDecimal convertWordQueueToBigDecimal(ArrayDeque wordDeque) { + BigDecimal currentChunk = BigDecimal.ZERO; + List chunks = new ArrayList<>(); + + boolean isNegative = "negative".equals(wordDeque.peek()); + if (isNegative) { + wordDeque.poll(); + } + + boolean prevNumWasHundred = false; + boolean prevNumWasPowerOfTen = false; + + while (!wordDeque.isEmpty()) { + String word = wordDeque.poll(); + + switch (word) { + case "and" -> { + handleConjunction(prevNumWasHundred, prevNumWasPowerOfTen, wordDeque); + continue; + } + case "hundred" -> { + currentChunk = handleHundred(currentChunk, word, prevNumWasPowerOfTen); + prevNumWasHundred = true; + continue; + } + default -> { + + } + } + prevNumWasHundred = false; + + BigDecimal powerOfTen = PowerOfTen.getValue(word); + if (powerOfTen != null) { + handlePowerOfTen(chunks, currentChunk, powerOfTen, word, prevNumWasPowerOfTen); + currentChunk = BigDecimal.ZERO; + prevNumWasPowerOfTen = true; + continue; + } + prevNumWasPowerOfTen = false; + + Integer number = NumberWord.getValue(word); + if (number != null) { + currentChunk = handleNumber(chunks, currentChunk, word, number); + continue; + } + + switch (word) { + case "point" -> { + handlePoint(chunks, currentChunk, wordDeque); + currentChunk = BigDecimal.ZERO; + continue; + } + case "negative" -> { + handleNegative(isNegative); + } + default -> { + + } + } + + throw new WordsToNumberException(WordsToNumberException.ErrorType.UNKNOWN_WORD, word); + } + + if (currentChunk.compareTo(BigDecimal.ZERO) != 0) { + chunks.add(currentChunk); + } + + BigDecimal completeNumber = combineChunks(chunks); + return isNegative ? completeNumber.multiply(BigDecimal.valueOf(-1)) + : + completeNumber; + } + + private static boolean isAdditionSafe(BigDecimal currentChunk, BigDecimal number) { + int chunkDigitCount = currentChunk.toString().length(); + int numberDigitCount = number.toString().length(); + return chunkDigitCount > numberDigitCount; + } + + private static String convertDecimalPart(ArrayDeque wordDeque) { + StringBuilder decimalPart = new StringBuilder("."); + + while (!wordDeque.isEmpty()) { + String word = wordDeque.poll(); + Integer number = NumberWord.getValue(word); + if (number == null) { + throw new WordsToNumberException(WordsToNumberException.ErrorType.UNEXPECTED_WORD_AFTER_POINT, word); + } + decimalPart.append(number); + } + + boolean missingNumbers = decimalPart.length() == 1; + if (missingNumbers) { + throw new WordsToNumberException(WordsToNumberException.ErrorType.MISSING_DECIMAL_NUMBERS, ""); + } + return decimalPart.toString(); + } + + private static BigDecimal combineChunks(List chunks) { + BigDecimal completeNumber = BigDecimal.ZERO; + for (BigDecimal chunk : chunks) { + completeNumber = completeNumber.add(chunk); + } + return completeNumber; + } + } + + class WordsToNumberException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + + enum ErrorType { + NULL_INPUT("'null' or empty input provided"), + UNKNOWN_WORD("Unknown Word: "), + UNEXPECTED_WORD("Unexpected Word: "), + UNEXPECTED_WORD_AFTER_POINT("Unexpected Word (after Point): "), + MISSING_DECIMAL_NUMBERS("Decimal part is missing numbers."), + MULTIPLE_NEGATIVES("Multiple 'Negative's detected."), + INVALID_NEGATIVE("Incorrect 'negative' placement"), + INVALID_CONJUNCTION("Incorrect 'and' placement"); + + private final String message; + + ErrorType(String message) { + this.message = message; + } + + public String formatMessage(String details) { + return "Invalid Input. " + message + (details.isEmpty() ? "" : details); + } + } + + public final ErrorType errorType; + + WordsToNumberException(ErrorType errorType, String details) { + super(errorType.formatMessage(details)); + this.errorType = errorType; + } + + public ErrorType getErrorType() { + return errorType; + } + } diff --git a/src/test/java/com/thealgorithms/conversions/WordsToNumberTest.java b/src/test/java/com/thealgorithms/conversions/WordsToNumberTest.java new file mode 100644 index 000000000000..fbf63e37946b --- /dev/null +++ b/src/test/java/com/thealgorithms/conversions/WordsToNumberTest.java @@ -0,0 +1,114 @@ +package com.thealgorithms.conversions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; + +public class WordsToNumberTest { + + @Test + void testNullInput() { + WordsToNumberException exception = assertThrows(WordsToNumberException.class, () -> WordsToNumber.convert(null)); + assertEquals(WordsToNumberException.ErrorType.NULL_INPUT, exception.getErrorType(), "Exception should be of type NULL_INPUT"); + } + + @Test + void testStandardCases() { + assertEquals("0", WordsToNumber.convert("zero"), "'zero' should convert to '0'"); + assertEquals("5", WordsToNumber.convert("five"), "'five' should convert to '5'"); + assertEquals("21", WordsToNumber.convert("twenty one"), "'twenty one' should convert to '21'"); + assertEquals("101", WordsToNumber.convert("one hundred one"), "'one hundred' should convert to '101'"); + assertEquals("342", WordsToNumber.convert("three hundred and forty two"), "'three hundred and forty two' should convert to '342'"); + } + + @Test + void testLargeNumbers() { + assertEquals("1000000", WordsToNumber.convert("one million"), "'one million' should convert to '1000000'"); + assertEquals("1000000000", WordsToNumber.convert("one billion"), "'one billion' should convert to '1000000000'"); + assertEquals("1000000000000", WordsToNumber.convert("one trillion"), "'one trillion' should convert to '1000000000000'"); + assertEquals("999000000900999", WordsToNumber.convert("nine hundred ninety nine trillion nine hundred thousand nine hundred and ninety nine"), "'nine hundred ninety nine trillion nine hundred thousand nine hundred and ninety nine' should convert to '999000000900999'"); + } + + @Test + void testNegativeNumbers() { + assertEquals("-5", WordsToNumber.convert("negative five"), "'negative five' should convert to '-5'"); + assertEquals("-120", WordsToNumber.convert("negative one hundred and twenty"), "'negative one hundred and twenty' should convert correctly"); + } + + @Test + void testNegativeLargeNumbers() { + assertEquals("-1000000000000", WordsToNumber.convert("negative one trillion"), "'negative one trillion' should convert to '-1000000000000'"); + assertEquals("-9876543210987", WordsToNumber.convert("Negative Nine Trillion Eight Hundred Seventy Six Billion Five Hundred Forty Three Million Two Hundred Ten Thousand Nine Hundred Eighty Seven"), ""); + } + + @Test + void testDecimalNumbers() { + assertEquals("3.1415", WordsToNumber.convert("three point one four one five"), "'three point one four one five' should convert to '3.1415'"); + assertEquals("-2.718", WordsToNumber.convert("negative two point seven one eight"), "'negative two point seven one eight' should convert to '-2.718'"); + assertEquals("-1E-7", WordsToNumber.convert("negative zero point zero zero zero zero zero zero one"), "'negative zero point zero zero zero zero zero zero one' should convert to '-1E-7'"); + } + + @Test + void testLargeDecimalNumbers() { + assertEquals("1000000000.0000000001", WordsToNumber.convert("one billion point zero zero zero zero zero zero zero zero zero one"), "Tests a large whole number with a tiny fractional part"); + assertEquals("999999999999999.9999999999999", + WordsToNumber.convert("nine hundred ninety nine trillion nine hundred ninety nine billion nine hundred ninety nine million nine hundred ninety nine thousand nine hundred ninety nine point nine nine nine nine nine nine nine nine nine nine nine nine nine"), + "Tests maximum scale handling for large decimal numbers"); + assertEquals("0.505", WordsToNumber.convert("zero point five zero five"), "Tests a decimal with an internal zero, ensuring correct parsing"); + assertEquals("42.00000000000001", WordsToNumber.convert("forty two point zero zero zero zero zero zero zero zero zero zero zero zero zero one"), "Tests a decimal with leading zeros before a significant figure"); + assertEquals("7.89E-7", WordsToNumber.convert("zero point zero zero zero zero zero zero seven eight nine"), "Tests scientific notation for a small decimal with multiple digits"); + assertEquals("0.999999", WordsToNumber.convert("zero point nine nine nine nine nine nine"), "Tests a decimal close to one with multiple repeated digits"); + } + + @Test + void testCaseInsensitivity() { + assertEquals("21", WordsToNumber.convert("TWENTY-ONE"), "Uppercase should still convert correctly"); + assertEquals("-100.0001", WordsToNumber.convert("negAtiVe OnE HuNdReD, point ZeRO Zero zERo ONE"), "Mixed case should still convert correctly"); + assertEquals("-225647.00019", WordsToNumber.convert("nEgative twO HundRed, and twenty-Five thOusaNd, six huNdred Forty-Seven, Point zero zero zero One nInE")); + } + + @Test + void testInvalidInputs() { + WordsToNumberException exception; + + exception = assertThrows(WordsToNumberException.class, () -> WordsToNumber.convert("negative one hundred AlPha")); + assertEquals(WordsToNumberException.ErrorType.UNKNOWN_WORD, exception.getErrorType()); + + exception = assertThrows(WordsToNumberException.class, () -> WordsToNumber.convert("twenty thirteen")); + assertEquals(WordsToNumberException.ErrorType.UNEXPECTED_WORD, exception.getErrorType()); + + exception = assertThrows(WordsToNumberException.class, () -> WordsToNumber.convert("negative negative ten")); + assertEquals(WordsToNumberException.ErrorType.MULTIPLE_NEGATIVES, exception.getErrorType()); + + exception = assertThrows(WordsToNumberException.class, () -> WordsToNumber.convert("one hundred hundred")); + assertEquals(WordsToNumberException.ErrorType.UNEXPECTED_WORD, exception.getErrorType()); + + exception = assertThrows(WordsToNumberException.class, () -> WordsToNumber.convert("one thousand and hundred")); + assertEquals(WordsToNumberException.ErrorType.INVALID_CONJUNCTION, exception.getErrorType()); + + exception = assertThrows(WordsToNumberException.class, () -> WordsToNumber.convert("one thousand hundred")); + assertEquals(WordsToNumberException.ErrorType.UNEXPECTED_WORD, exception.getErrorType()); + + exception = assertThrows(WordsToNumberException.class, () -> WordsToNumber.convert("nine hundred and nine hundred")); + assertEquals(WordsToNumberException.ErrorType.INVALID_CONJUNCTION, exception.getErrorType()); + + exception = assertThrows(WordsToNumberException.class, () -> WordsToNumber.convert("forty two point")); + assertEquals(WordsToNumberException.ErrorType.MISSING_DECIMAL_NUMBERS, exception.getErrorType()); + + exception = assertThrows(WordsToNumberException.class, () -> WordsToNumber.convert("sixty seven point hello")); + assertEquals(WordsToNumberException.ErrorType.UNEXPECTED_WORD_AFTER_POINT, exception.getErrorType()); + + exception = assertThrows(WordsToNumberException.class, () -> WordsToNumber.convert("one negative")); + assertEquals(WordsToNumberException.ErrorType.INVALID_NEGATIVE, exception.getErrorType()); + } + + @Test + void testConvertToBigDecimal() { + assertEquals(new BigDecimal("-100000000000000.056"), WordsToNumber.convertToBigDecimal("negative one hundred trillion point zero five six"), "should convert to appropriate BigDecimal value"); + + WordsToNumberException exception = assertThrows(WordsToNumberException.class, () -> WordsToNumber.convertToBigDecimal(null)); + assertEquals(WordsToNumberException.ErrorType.NULL_INPUT, exception.getErrorType(), "Exception should be of type NULL_INPUT"); + } +}