Ranked-Choice Voting Algorithms in JavaScript

Example ballot for Ranked-Choice Voting in New York City

New York City, for the first time in history, is implementing ranked-choice voting for its 2021 mayoral election. As I was reading about it in the news, I thought, “How does this actually work under the hood?” So I set about writing my own ranked-choice voting algorithm to calculate the winner of such an election. I actually found that the method NYC is using is kind of unfair, and not really true to what I think of as ranked-choice voting. So, I decided to improve it.

According to nyc.gov, the way it works is this:

“You can rank up to five candidates in order of preference, instead of choosing just one. If a candidate receives more than 50% of first-choice votes, they are the winner. If no candidate earns more than 50% of first-choice votes, then counting will continue in rounds. At the end of each round, the candidate with the fewest votes will be eliminated. If you ranked that candidate first, your vote will go to the next highest ranked candidate on your ballot. This process will continue until there are 2 candidates left. The candidate with the most votes wins.”

The issue I take with this is that if your number one pick doesn’t get enough votes in the first round of counting, that candidate is completely eliminated. It seems to me this defeats the purpose of ranked-choice voting, which is supposed to give underdogs a chance.

For example:

Let’s say three candidates make up the majority of the first-run votes. The lowest-ranking candidate in that round would be completely disqualified. But what if 90% of voters had that now-disqualified candidate as their number 2 choice? Had that candidate not been disqualified, they might have remained competitive on the second round, possibly making up 25% or more of the votes, and that may have actually represented the people’s interests. So here is my algorithm that mitigates this issue, and that more accurately represents “ranked-choice voting,” in my humble opinion.

The way it works is this: I convert every voter into a stack via a singly-linked-list, and then I remove the root of any voter (who is now a SLL) whose top choice was a loser, and then I recount. The difference between NYC’s method and mine is that candidates are never really “eliminated” from the running. I just use actual ranked-choice tallying.

First we make our Node class and Voter class.

class Node {
constructor(val) {
this.val = val;
this.next = null;
}
}

class Voter {
constructor(voter, numChoices) {
this.root = new Node(voter[1]);

let current = this.root;
for (let i = 2; i <= numChoices; i++) {
current.next = new Node(voter[i]);
current = current.next;
}
return this;
}

shift() {
if (!this.root) return null;
let returnVal = this.root;
if (this.root.next) {
this.root = this.root.next;
} else {
this.root = null;
}
return returnVal;
}
}

The next part is the business end — our rankChoice function. It’s a recursive function that keeps calling itself until two conditions are met: That there is a candidate whose votes are greater than a 50% majority of voters, and that there is only one candidate left. The way it mitigates ties is that if a multiple candidates have a majority, it will keep cascading down voters’ choices and tallying them until one of those candidates is the winner. It takes in one argument (voters). The candidates are already defined in the Voter objects themselves.

First we make our results object, majority variable and winners and losers arrays.

let results = {};
let majority = voters.length / 2;
let winners = [],
losers = [];

Next we tally up everybody’s first choice.

voters.forEach((voter, index) => {
if (voter.root && voter.root.val) {
results[voter.root.val] = results[voter.root.val] + 1 || 1;
} else {
// if voter is out of votes, remove them from array
voters.splice(index, 1);
}
});

The reason for removing voters from the voters array is two-fold: One: to avoid unnecessary computation time on empty objects, and two: to update what it means to have a majority. When all of your candidates have been exhausted (and all of them losers), you are no longer represented in the majority count. Sadly, all of your votes were wasted. 😢

Next, we find the winners and losers of this round. Knowing who the winners are doesn’t really matter until a majority is reached, but the losers we need to know, so we can remove them voter singly-linked-list via .shift().

let max = Math.max(...Object.values(results));
let min = Math.min(...Object.values(results));

for (let candidate in results) {
if (results[candidate] === max) {
winners.push(candidate);
}

if (results[candidate] === min) {
losers.push(candidate);
}
}

Now we’re ready to check if there’s a winner yet.

// If someone has the majority, and there are no ties, return the winner!

Next, we update our voters. Any voters whose first-choice was a loser has had that candidate remove from their list, and now their next-favorite candidate is at the top of their list.

voters.forEach(voter => {
if (voter.root && losers.includes(voter.root.val)) voter.shift();
});

And then we recurse! If there was a winner, it would have returned that value and broken out of the call stack by now. If there’s not, check for winners again with the updated voters.

return rankChoice(voters);

And that’s it!

The runtime for a real election, as you might expect, is not instantaneous. For an election of about 3 million (the anticipated number of votes for NYC), it took about 7 minutes. Also, according to my faux-election based on randomized ballots, Ray McGuire won. We’ll see what the real results will hold! Maybe I’ve written an electoral oracle.

Full stack web developer, creative technologist, self-proclaimed space journalist. | React | Redux | Ruby on Rails | JavaScript