In this article, I will share the algorithm I devised, and another that works using linear algebra.
Method 1 - My method
This method is the one we were all taught at school. First, you look at the number of atoms of an element on each side of the equation. Then you add coefficients to certain compounds to make the numbers equal. We simplify further by first adding coefficients to the compounds containing unique elements. And then we operate on the rest of them (usually involving hydrogen and oxygen). To demonstrate we’ll use the complete combustion of methane as an example. We can represent it as the following:
\[CH_4+O_2 \rightarrow CO_2+H_2O\]
which we'll represent as the following:
let reactants = [{C: 1, H: 4}, {O: 2}];
let products = [{C: 1, O: 2}, {H: 2, O: 1}];
To start we need to define the format of our output for the function we'll be writing. It will be an array of two arrays (one for reactants and one for products), which will contain the coefficients of the compounds.
let balancingNumbers = [
new Array(reactants.length).fill(1),
new Array(products.length).fill(1),
];
Now, to find the compounds within the equation which contain unique elements, we will first have to count the number of times an element appears on each side of the equation.
Element appearances
let reactantElements = reactants.map(Object.keys);
let productElements = products.map(Object.keys);
let reactantElementCount = {};
let productElementCount = {};
for (let compoundElements of reactantElements) {
for (let element of compoundElements) {
if (!reactantElementCount[element])
reactantElementCount[element] = 0;
reactantElementCount[element]++;
}
}
for (let compoundElements of productElements) {
for (let element of compoundElements) {
if (!productElementCount[element]) productElementCount[element] = 0;
productElementCount[element]++;
}
}
This results in {C: 1, H: 1, O: 1}
and {C: 1, O: 2, H: 1}
.
Unique compounds
We then look through the reactants. If any of the compounds contain elements with a count of one on both sides of the equation, we store it in an array. We also look through the products for the compound containing that same element. This is so that we can begin to add coefficients to those compounds if necessary.
for (let i = 0; i < reactantElements.length; i++) {
const reactantCompoundElements = reactantElements[i];
for (let element of reactantCompoundElements) {
if (
!(
reactantElementCount[element] === 1 &&
productElementCount[element] === 1
)
)
continue;
uniqueElements.push(element);
for (let j = 0; j < productElements.length; j++) {
const productCompoundElements = productElements[j];
if (!productCompoundElements.includes(element)) continue;
/* Add a coefficient to the compound on the side
with fewer atoms and a lower existing coefficient */
if (
reactants[i][element] <= products[j][element] &&
balancingNumbers[0][i] <= balancingNumbers[1][j]
) {
// We'll look at this function later
balanceCompound(i, 0, element);
} else {
balanceCompound(j, 1, element);
}
}
}
}
In the context of our example, elements ‘C’ (carbon) and ‘H’ (hydrogen) are unique. So, we will compare the first reactant CH_4 with the first product CO_2 for carbon. Then we compare the first reactant with the second product H_2O for hydrogen. We would give the reactant a coefficient in the context of carbon. Then we would give the product a coefficient in the context of hydrogen.
... and the rest
Now, we need to balance the remaining compounds. This will include elements which are not present in the uniqueElements array. We do this by first going through each non-unique element. Then we find any compound within the equation containing that element. To make this more efficient, we can take the one with the fewest number of different elements. This is to keep the coefficient as an integer. Then we add the coefficient to that compound.
elements.forEach((element) => {
if (uniqueElements.includes(element)) return;
let smallestCandidate = {
side: 0, // 0 for reactants, 1 for products
balancingNumber: Infinity,
numElements: Infinity,
index: 0,
};
// Find the compound with the smallest number of elements
for (let i = 0; i < reactants.length; i++) {
const balancingNumber = balancingNumbers[0][i];
const numElements = reactantElements[i].length;
if (!reactantElements[i].includes(element)) continue;
if (numElements >= smallestCandidate.numElements) continue;
smallestCandidate = {
side: 0,
balancingNumber,
numElements,
index: i,
};
}
for (let i = 0; i < products.length; i++) {
const balancingNumber = balancingNumbers[1][i];
const numElements = productElements[i].length;
if (!productElements[i].includes(element)) continue;
if (numElements >= smallestCandidate.numElements) continue;
smallestCandidate = {
side: 1,
balancingNumber,
numElements,
index: i,
};
}
balanceCompound(
smallestCandidate.index,
smallestCandidate.side,
element
);
});
Referring back to our example, ‘O’ (oxygen) is the only element that is not unique. Also, the smallest candidate would be O_2 from the reactants as it only includes 1 element.
Counting atoms
The elements
container will be a Set
so that every value in the container is unique. We also need to count the number of atoms on each side of the equation, as we'll use it in balanceCompound
. Note that the number of atoms will also consider the coefficient in front of the compound. This will be useful later.
let reactantAtoms = {};
let productAtoms = {};
let elements = new Set();
We then add the number of atoms of each element for each compound to a running total. At the same time, we can add elements to our Set
object.
for (let reactant of reactants) {
for (let [element, value] of Object.entries(reactant)) {
elements.add(element);
if (!reactantAtoms[element]) reactantAtoms[element] = 0;
reactantAtoms[element] += value;
}
}
for (let product of products) {
for (let [element, value] of Object.entries(product)) {
if (!productAtoms[element]) productAtoms[element] = 0;
productAtoms[element] += value;
}
}
For our equation, this results in {C: 1, H: 4, O: 2}
and {C: 1, H: 2, O: 3}
for reactantAtoms
and productAtoms
, respectively.
Adding coefficients
Begin by defining a function with the following:
const balanceCompound = (i, side, element) => {
const compound = (side === 0 ? reactants : products)[i];
const sideAtoms = side === 0 ? reactantAtoms : productAtoms;
const otherAtoms = side === 0 ? productAtoms : reactantAtoms;
let oldBalancingNumber = balancingNumbers[side][i];
let balancingNumber = oldBalancingNumber;
To calculate the required coefficient for the element, we first need to see which side of the equation we are on. We then need to calculate the number of atoms needed to equal the number of atoms on the other side. Then we must divide that by the number of atoms in the chosen compound. Add the following code:
const existingAtoms =
sideAtoms[element] - balancingNumber * compound[element];
balancingNumber =
(otherAtoms[element] - existingAtoms) / compound[element];
sideAtoms[element] =
existingAtoms + balancingNumber * compound[element];
Coming again to our example, say we are balancing the number of 'H' atoms in \(H_2O\),
Side atoms | 2 | Existing atoms | 2 - (1 × 2) = 0 |
---|---|---|---|
Other atoms | 4 | Coefficient | (4 - 0) ÷ 2 = 2 |
Polishing equations
Now after updating the coefficient we need to update the atom count of every other element. We do this by first calculating the element’s prior atoms, excluding our compound. Then we add this to the number of atoms of the element in our compound, accounting for the new coefficient. And later we update the balancing numbers array.
for (let otherElement of Object.keys(compound)) {
if (otherElement === element) continue;
const otherExistingAtoms =
sideAtoms[otherElement] -
oldBalancingNumber * compound[otherElement];
sideAtoms[otherElement] =
otherExistingAtoms + balancingNumber * compound[otherElement];
}
balancingNumbers[side][i] = balancingNumber;
Our equation would now be \(CH_4+O_2 \rightarrow CO_2+2H_2O\), and the sideAtoms for 'H' would be four, oxygen would also be four, and carbon would stay at one.
But you may be wondering what would happen if the coefficient added is a decimal number. In terms of chemical equations, leaving a decimal number is fine by all means. But still, to make the output nicer we’ll have to scale up every coefficient in the equation. We scale it by the smallest amount necessary so that each coefficient is an integer. Also, note that compounds without a coefficient will have a coefficient of one.
if ((parseFloat(balancingNumber) | 0) !== balancingNumber) {
// Scale to a whole number
const scale = lcm(balancingNumber, 1) / balancingNumber;
// i goes from 0 to 1, for reactants to products
for (let i = 0; i < balancingNumbers.length; i++) {
const sideBalancingNumbers = balancingNumbers[i];
// j goes up to the number of compounds from each side
for (let j = 0; j < sideBalancingNumbers.length; j++) {
balancingNumbers[i][j] *= scale;
}
}
// Can't forget to update the number of atoms
elements.forEach((element) => {
reactantAtoms[element] *= scale;
productAtoms[element] *= scale;
});
}
But now, say the compound to which we were adding a coefficient had contained a unique element. Let's also say it was not that element which we were trying to balance. This would mean that the number of atoms for that unique element on each side will no longer be equal. To solve this, add the following code:
if (uniqueElements.length > 0) {
for (let key of Object.keys(compound)) {
if (element === key) continue;
if (!uniqueElements.includes(key)) return;
const otherElements =
side === 0 ? productElements : reactantElements;
for (let i = 0; i < otherElements.length; i++) {
const compoundElements = otherElements[i];
if (!compoundElements.includes(key)) continue;
balanceCompound(i, (side === 0) ? 1 : 0, key);
}
}
}
Wrapping up
That concludes the method I came up with. If you run the code with our example, you should see a solution of \(CH_4+2O_2 \rightarrow CO_2+2H_2O\). Here is the code for the main function. And by the way, I created a small app called Resolu with this algorithm a while back, and it'd be great if you could check it out.
An example of how to enter an equation
Method 2 - Linear algebra
This method involves representing the chemical equation as a linear equation. Then it involves applying Gaussian Elimination(also known as row reduction) to solve the equation's coefficients.
Here are the steps needed to carry this out:
Convert reactants and products into an augmented matrix, aligned with each row representing each element present
Apply the row reduction algorithm to the matrix, to leave the matrix in reduced row echelon form
Solve for the variables and scale them to integers
Forming an augmented matrix
First, we need an array of every element in the equation:
let elements = [];
for (let reactant of reactants) {
for (let element of Object.keys(reactant)) {
if (!elements.includes(element)) elements.push(element);
}
}
elements.sort();
Now we need to loop through each element. Within each loop, we need to loop through all the compounds. Then, we add the number of atoms of the element in that compound into an array (row) for that element.
Remember how we represent the number of atoms for an element as a linear equation? That means the total reactant atoms have to equal the total product atoms. But also, we must set the equation to equal 0 by moving the product atoms to the left side. This ends up with a negated total (multiplied by -1). So when we add some atoms for each product, we have to negate it ourselves.
Also, note that we have to use Fraction.js when handling numerical data. This is so that we keep precision when performing calculations involving rational numbers.
Install it from NPM with npm i fraction.js
, and import it with this line:
import Fraction from "fraction.js";
Now add the following:
let matrix = [];
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
for (let j = 0; j < reactants.length; j++) {
const reactant = reactants[j];
if (matrix[i] === undefined) matrix[i] = [];
matrix[i][j] = new Fraction(reactant[element] ?? 0);
}
for (let j = 0; j < products.length; j++) {
const product = products[j];
matrix[i][j + reactants.length] = new Fraction(
-(product[element] ?? 0)
);
}
}
Gaussian elimination
First, we apply forward elimination, and then back substitution. When we apply row reduction to the matrix, the last variable will always be free. This means we can give it any value. So to make things easier we will give it a value of 1, which we can add to our solution
array.
// Number of rows
let n = matrix.length;
// Number of columns
let m = matrix[0].length;
for (let i = 0; i < n; i++) {
// Identifying maximum value and position for a pivot
let iMax = i;
let vMax = matrix[iMax][i];
for (let j = i + 1; j < n; j++) {
if (matrix[j][i].abs() > vMax) {
vMax = matrix[j][i];
iMax = j;
}
}
if (iMax !== i) {
// Swap the current row with the maximum value's row
for (let k = 0; k < m; k++) {
let temp = matrix[i][k];
matrix[i][k] = matrix[iMax][k];
matrix[iMax][k] = temp;
}
}
for (let j = i + 1; j < n; j++) {
const ratio = matrix[j][i].div(matrix[i][i]);
let empty = true;
for (let k = 0; k < m; k++) {
matrix[j][k] = matrix[j][k].sub(ratio.mul(matrix[i][k]));
if (!matrix[j][k].equals(0)) empty = false;
}
// Delete empty rows to avoid later zero division
if (empty) {
n--;
delete matrix[j];
}
}
}
let solution = new Array(n).fill(new Fraction(0));
// Applying back substitution
solution[n - 1] = matrix[n - 1][m - 1].div(matrix[n - 1][m - 2]);
for (let i = n - 2; i >= 0; i--) {
solution[i] = matrix[i][n];
for (let j = i + 1; j < m; j++) {
solution[i] = solution[i].sub(matrix[i][j].mul(solution[j]));
}
solution[i] = solution[i].div(matrix[i][i]);
}
solution.push(new Fraction(1));
Solving for variables
Now we can look through the array again. If we find a value which is not an integer, we will have to scale everything in the array. We scale everything by the smallest amount necessary for our original value to be an integer. We can find the scale by finding the LCM of the number’s reciprocal with 1.
for (let i = 0; i < solution.length; i++) {
solution[i] = solution[i].abs();
if (!solution[i].equals(parseFloat(solution[i].toString()) | 0)) {
let scale = solution[i].inverse().lcm(1);
for (let j = 0; j < solution.length; j++) {
solution[j] = solution[j].mul(scale).abs();
}
}
}
Output
And to see the coefficients calculated we can add the following code:
// Converting Fraction objects back to numbers
solution = solution.map(Number);
console.log([
solution.slice(0, reactants.length),
solution.slice(reactants.length),
]);
Final notes
Here is the code for the linear algebra method used. I’ve certainly learned some new things while making this. I'll see you in the next one.