Let's solve FizzBuzz! Detailed problem-solving process

Introduction

FizzBuzz is probably one of the most well-known programming problems, and the task is as follows:

  • Print the numbers from 1 to 100
    • If the number is a multiple of 3, print Fizz
    • If the number is a multiple of 5, print Buzz
    • If the number is a multiple of both 3 and 5, print FizzBuzz

Problem Solving

Basic Solution

The most straightforward solution is to create a function that prints the numbers from 1 to 100 and checks the rules to determine whether to print Fizz, Buzz, or FizzBuzz. However, this approach clearly hardcodes all values into the program, making it inflexible for future condition expansions.

function fizzBuzz(max) {
for (let i = 1; i <= max; i++) {
if (i % 3 === 0 && i % 5 === 0) {
console.log('FizzBuzz');
} else if (i % 3 === 0) {
console.log('Fizz');
} else if (i % 5 === 0) {
console.log('Buzz');
} else {
console.log(i);
}
}
}
fizzBuzz(100);

Separating Data and Logic

Since we can expect the rules to be some number should print some result, we might use an object to record these key-value structured data:

function fizzBuzz(max) {
const map = {
3: 'Fizz',
5: 'Buzz',
15: 'FizzBuzz',
};
for (let i = 1; i <= max; i++) {
if (i % Object.keys(map)[0] === 0 && i % Object.keys(map)[1] === 0) {
console.log(Object.values(map)[2]);
} else if (i % Object.keys(map)[0] === 0) {
console.log(Object.values(map)[0]);
} else if (i % Object.keys(map)[1] === 0) {
console.log(Object.values(map)[1]);
} else {
console.log(i);
}
}
}

The data has been separated, but the logic still relies on specific contents in the designated map object. Let’s write a loop to automatically extract the contents of the map object, allowing the data to drive the logic:

function fizzBuzz(max) {
const map = {
3: 'Fizz',
5: 'Buzz',
};
for (let i = 1; i <= max; i++) {
let output = '';
for (const key in map) {
if (i % key === 0) {
output += map[key];
}
}
console.log(output || i);
}
}

Keep Values Immutable

The above solution shows that we have defined two variables, i and output, and continuously modified their contents in the program. In some programming styles, this is considered a habit to avoid. We can try to change the part where variables are overwritten to use pure functions🔗.

numberReplacer(100, {
3: 'Fizz',
5: 'Buzz',
}).forEach((value) => console.log(value));
function numberReplacer(max, replacementMap) {
return Array.from({ length: max }, (_, index) => {
const currentNumber = index + 1;
const replacement = Object.entries(replacementMap).reduce(
(acc, [divisor, replaceWord]) => (currentNumber % divisor === 0 ? acc + replaceWord : acc),
'',
);
return replacement || currentNumber;
});
}

Document Completion and Boundary Case Checking

JavaScript is a dynamically typed language, so others may not be clear about the specific parameter requirements when using this function. Here, we use JSDoc to complete the documentation, and we can also consider using TypeScript for type checking or runtime type validation:

/**
* Generates an array that replaces numbers from 1 to max with specified strings
*
* @param {number} max - Maximum value
* @param {Object<number, string>} replacementMap - key is the divisor, value is the replacement string
* @returns {Array<string | number>} - The replaced values
* @throws {TypeError} if max is not a positive integer
* @throws {TypeError} if replacementMap is not a non-empty object
* @throws {TypeError} if the keys in replacementMap are not positive integers
* @example
* numberReplacer(15, { 3: 'Fizz', 5: 'Buzz' });
* // Returns [1, 2, 'Fizz', 4, 'Buzz', 'Fizz', 7, 8, 'Fizz', 'Buzz', 11, 'Fizz', 13, 14, 'FizzBuzz']
*/
function numberReplacer(max, replacementMap) {
if (!Number.isInteger(max) || max <= 0) {
throw new TypeError('max must be a positive integer');
}
if (typeof replacementMap !== 'object' || replacementMap === null) {
throw new TypeError('replacementMap must be a non-empty object');
}
return Array.from({ length: max }, (_, index) => {
const currentNumber = index + 1;
const replacement = Object.entries(replacementMap).reduce((acc, [divisor, replaceWord]) => {
const numDivisor = Number(divisor);
if (!Number.isInteger(numDivisor) || numDivisor <= 0) {
throw new TypeError('keys in replacementMap must be positive integers');
}
return currentNumber % numDivisor === 0 ? acc + replaceWord : acc;
}, '');
return replacement || currentNumber;
});
}
try {
numberReplacer(100, {
3: 'Fizz',
5: 'Buzz',
}).forEach((value) => console.log(value));
} catch (error) {
console.error(error.message);
}

Simplifying numberReplacer with Partial Application

You might be thinking: Oh my! Why do we have to calculate using numberReplacer every time just to print FizzBuzz? Is there a way to create a function that only takes the maximum value? Just like at the beginning? Let’s simplify this by using partial application to create abstraction:

const createFizzBuzz = (max) => {
return numberReplacer(max, {
3: 'Fizz',
5: 'Buzz',
});
};
const fizzBuzz100 = createFizzBuzz(100);
fizzBuzz100.forEach((value) => console.log(value));
const fizzBuzz15 = createFizzBuzz(15);
fizzBuzz15.forEach((value) => console.log(value));

In this problem-solving process, I applied the DRY🔗 principle and improved the readability and maintainability of the code through continuous refactoring. However, we can also consider whether it’s really necessary to create more abstraction. Premature optimization is the root of all evil, perhaps YAGNI🔗.

Further Reading