From ecceb3487a525d84b2898d5cc0a90862c050ef93 Mon Sep 17 00:00:00 2001 From: lzear Date: Mon, 21 Dec 2020 19:51:38 +0100 Subject: [PATCH 1/2] fix(ranked pairs): implement Tarjan algorithm to avoid very long computation time on big cycles Ranked pairs was performing very poorly because of its tie-breaking implementation on edges of same weight forming cycles. Using Tarjan algorithm, the computation happens in polynomial time and works. --- src/methods/ranked-pairs/index.ts | 125 ++++--------------- src/methods/ranked-pairs/rankedPairs.test.ts | 38 ++++++ src/methods/ranked-pairs/tarjan.ts | 97 ++++++++++++++ 3 files changed, 161 insertions(+), 99 deletions(-) create mode 100644 src/methods/ranked-pairs/tarjan.ts diff --git a/src/methods/ranked-pairs/index.ts b/src/methods/ranked-pairs/index.ts index da5d83b..6d3afa0 100644 --- a/src/methods/ranked-pairs/index.ts +++ b/src/methods/ranked-pairs/index.ts @@ -1,8 +1,6 @@ -import concat from 'lodash/concat' -import flatten from 'lodash/flatten' import groupBy from 'lodash/groupBy' import range from 'lodash/range' -import without from 'lodash/without' +import uniq from 'lodash/uniq' import zipObject from 'lodash/zipObject' import { Matrix, @@ -10,89 +8,26 @@ import { SystemUsingMatrix, VotingSystem, } from '../../types' +import { Tarjan, Vertex } from './tarjan' type Edge = { from: number; to: number; value: number } -const findGraphRoots = (graph: Edge[]) => { - const { sources } = graph.reduce( - ({ sources, targets }, { from, to }) => ({ - sources: concat( - without(sources, to), - targets.includes(from) || sources.includes(from) ? [] : [from], - ), - targets: targets.includes(to) ? targets : [...targets, to], - }), - { sources: [] as number[], targets: [] as number[] }, +const generateAcyclicGraph = (graph: Edge[], edgesToAdd: Edge[]): Edge[] => { + const allEdges = [...graph, ...edgesToAdd] + const vDict = {} as { [i: number]: Vertex } + uniq(allEdges.flatMap((e) => [e.from, e.to])).forEach( + (c) => (vDict[c] = new Vertex(c)), ) - return sources + allEdges.forEach((e) => vDict[e.from].connect(vDict[e.to])) + const tarjan = new Tarjan(Object.values(vDict)) + tarjan.run() + return [ + ...graph, + ...edgesToAdd.filter( + (edge) => vDict[edge.from].lowlink !== vDict[edge.to].lowlink, + ), + ] } - -const canAddEdgeToAcyclicGraph = (graph: Edge[], toAdd: Edge) => { - const active: number[] = [] - const visited: number[] = [] - let cur: number | undefined = toAdd.to - while (cur !== undefined) { - visited.push(cur) - for (const { to } of graph.filter(({ from }) => from === cur)) { - if (to === toAdd.from) return false - if (!visited.includes(to) && !active.includes(to)) active.push(to) - } - cur = active.pop() - } - return true -} - -const graphSignature = (graph: Edge[]) => - graph - .sort((a, b) => b.from - a.from || b.to - a.to) - .map((edge) => `${edge.from}-${edge.to}`) - .join(';') - -const dedupe = (graphs: Edge[][]) => - graphs.reduce( - (acc, graph) => { - const signature = graphSignature(graph) - if (acc.signatures.includes(signature)) return acc - return { - deduped: [...acc.deduped, graph], - signatures: [...acc.signatures, signature], - } - }, - { deduped: [] as Edge[][], signatures: [] as string[] }, - ).deduped - -const generateAcyclicGraphs = (graph: Edge[], edgesToAdd: Edge[]): Edge[][] => { - const validEdgesToAdd = edgesToAdd.filter((toAdd) => - canAddEdgeToAcyclicGraph(graph, toAdd), - ) - return validEdgesToAdd.length - ? dedupe( - validEdgesToAdd.flatMap((toAdd, k) => - generateAcyclicGraphs( - [...graph, toAdd], - validEdgesToAdd.filter((_, kk) => kk !== k), - ), - ), - ) - : [graph] -} - -const sortTopEdges = (group: Edge[]): Edge[][] => { - const sources = findGraphRoots(group) - if (sources.length) { - const { bot, top } = group.reduce( - ({ top, bot }, cur) => - sources.includes(cur.from) - ? { bot, top: [...top, cur] } - : { bot: [...bot, cur], top }, - { bot: [] as Edge[], top: [] as Edge[] }, - ) - if (top.length > 0 && top.length < group.length) - return [top, ...sortTopEdges(bot)] - } - return [group] -} - export const rankedPairs: SystemUsingMatrix = { type: VotingSystem.RankedPairs, computeFromMatrix(matrix: Matrix): ScoreObject { @@ -107,42 +42,34 @@ export const rankedPairs: SystemUsingMatrix = { const edgesGroups = groupBy(allEdges, 'value') const groups = Object.keys(edgesGroups) .sort((a, b) => Number(b) - Number(a)) - .flatMap((value) => sortTopEdges(edgesGroups[value])) - const acyclicGraphs = groups.reduce( - (graphs: Edge[][], edgesToAdd) => - graphs.flatMap((graph) => generateAcyclicGraphs(graph, edgesToAdd)), - [[]] as Edge[][], + .map((value) => edgesGroups[value]) + + const acyclicGraph = groups.reduce( + (graph: Edge[], edgesToAdd) => generateAcyclicGraph(graph, edgesToAdd), + [] as Edge[], ) - const graphsWinners = acyclicGraphs.map((acyclicGraph) => - range(matrix.candidates.length).filter( - (candidate, key) => !acyclicGraph.some(({ to }) => to === key), - ), + const graphsWinners = range(matrix.candidates.length).filter( + (candidate, key) => !acyclicGraph.some(({ to }) => to === key), ) - - const scores = flatten(graphsWinners).reduce((acc, curr) => { + const scores = graphsWinners.reduce((acc, curr) => { acc[curr] = (acc[curr] || 0) + 1 return acc }, {} as { [k: number]: number }) - const maxScore1 = Math.max(...Object.values(scores)) - const winnersIdx = range(matrix.candidates.length).filter( (i) => scores[i] === maxScore1, ) - if (winnersIdx.length === matrix.candidates.length) return zipObject( matrix.candidates, Array(matrix.candidates.length).fill(1), ) - const nextRound = { + const nextResults = rankedPairs.computeFromMatrix({ array: matrix.array .filter((c, k) => !winnersIdx.includes(k)) .map((row) => row.filter((c, k) => !winnersIdx.includes(k))), candidates: matrix.candidates.filter((c, k) => !winnersIdx.includes(k)), - } - - const nextResults = rankedPairs.computeFromMatrix(nextRound) + }) const maxScore2 = Math.max(...Object.values(nextResults)) return winnersIdx.reduce( (scoreObject, winnerIdx) => ({ diff --git a/src/methods/ranked-pairs/rankedPairs.test.ts b/src/methods/ranked-pairs/rankedPairs.test.ts index 629a643..091ae69 100644 --- a/src/methods/ranked-pairs/rankedPairs.test.ts +++ b/src/methods/ranked-pairs/rankedPairs.test.ts @@ -62,4 +62,42 @@ describe('ranked pairs', () => { e: 3, }) }) + it('completes computation in decent time', () => { + expect( + rankedPairs.computeFromMatrix({ + array: [ + [0, 1, -1, -1, -1, -1, 1, -1, 1], + [-1, 0, -1, 1, -1, -1, -1, 1, -1], + [1, 1, 0, 1, -1, 0, 1, -1, 0], + [1, -1, -1, 0, -1, -1, 0, -1, 0], + [1, 1, 1, 1, 0, -1, 1, -1, 1], + [1, 1, 0, 1, 1, 0, 0, -1, 1], + [-1, 1, -1, 0, -1, 0, 0, -1, -1], + [1, -1, 1, 1, 1, 1, 1, 0, 1], + [-1, 1, 0, 0, -1, -1, 1, -1, 0], + ], + candidates: [ + 'bwLvxwn4', + 'Bi8rD2kq', + 'XuHBc1ME', + 'xhAvdxz2', + 'MBDuJLcU', + '4lNHn78L', + 'hNtQKVPG', + 'KXxHiFYK', + '2PWfQstO', + ], + }), + ).toEqual({ + '2PWfQstO': 3, + '4lNHn78L': 4, + Bi8rD2kq: 1, + KXxHiFYK: 4, + MBDuJLcU: 4, + XuHBc1ME: 4, + bwLvxwn4: 4, + hNtQKVPG: 2, + xhAvdxz2: 4, + }) + }) }) diff --git a/src/methods/ranked-pairs/tarjan.ts b/src/methods/ranked-pairs/tarjan.ts new file mode 100644 index 0000000..ec60cfc --- /dev/null +++ b/src/methods/ranked-pairs/tarjan.ts @@ -0,0 +1,97 @@ +// From chadhutchins: +// https://gist.github.com/chadhutchins/1440602 + +export class Vertex { + index: number + lowlink: number + connections: Vertex[] + name: number | string + constructor(name: number | string) { + this.connections = [] + this.name = name + + // used in tarjan algorithm + // went ahead and explicity initalized them + this.index = -1 + this.lowlink = -1 + } + + equals(vertex: Vertex): boolean { + // equality check based on vertex name + return this.name == vertex.name + } + + connect(vertex: Vertex): void { + this.connections.push(vertex) + } +} + +class VertexStack { + vertices: Vertex[] + + constructor() { + this.vertices = [] + } + + contains(vertex: Vertex) { + for (const i in this.vertices) + if (this.vertices[i].equals(vertex)) return true + return false + } +} + +export class Tarjan { + index: number + stack: VertexStack + graph: Vertex[] + // scc: Vertex[][] + constructor(graph: Vertex[]) { + this.index = 0 + this.stack = new VertexStack() + this.graph = graph + // this.scc = [] + } + run(): void { + for (const i in this.graph) + if (this.graph[i].index < 0) this.strongconnect(this.graph[i]) + // return this.scc + } + private strongconnect(vertex: Vertex) { + // Set the depth index for v to the smallest unused index + vertex.index = this.index + vertex.lowlink = this.index + this.index = this.index + 1 + this.stack.vertices.push(vertex) + + // Consider successors of v + // aka... consider each vertex in vertex.connections + for (const i in vertex.connections) { + const v = vertex + const w = vertex.connections[i] + if (w.index < 0) { + // Successor w has not yet been visited; recurse on it + this.strongconnect(w) + v.lowlink = Math.min(v.lowlink, w.lowlink) + } else if (this.stack.contains(w)) { + // Successor w is in stack S and hence in the current SCC + v.lowlink = Math.min(v.lowlink, w.index) + } + } + + // If v is a root node, pop the stack and generate an SCC + if (vertex.lowlink == vertex.index) { + // start a new strongly connected component + const vertices: Vertex[] = [] + let w = null + if (this.stack.vertices.length > 0) + do { + w = this.stack.vertices.pop() + // add w to current strongly connected component + w && vertices.push(w) + } while (w && !vertex.equals(w)) + // output the current strongly connected component + // ... i'm going to push the results to a member scc array variable + // if (vertices.length > 0) this.scc.push(vertices) + } + } +} From 0126633dc010d45b9ba155668f1e8d43ab5fae45 Mon Sep 17 00:00:00 2001 From: lzear Date: Mon, 21 Dec 2020 21:14:10 +0100 Subject: [PATCH 2/2] style: Add private/public to class properties and methods, 1 class per file --- .../ranked-pairs/{tarjan.ts => Tarjan.ts} | 51 +++---------------- src/methods/ranked-pairs/Vertex.ts | 27 ++++++++++ src/methods/ranked-pairs/VertexStack.ts | 18 +++++++ src/methods/ranked-pairs/index.ts | 3 +- 4 files changed, 55 insertions(+), 44 deletions(-) rename src/methods/ranked-pairs/{tarjan.ts => Tarjan.ts} (67%) create mode 100644 src/methods/ranked-pairs/Vertex.ts create mode 100644 src/methods/ranked-pairs/VertexStack.ts diff --git a/src/methods/ranked-pairs/tarjan.ts b/src/methods/ranked-pairs/Tarjan.ts similarity index 67% rename from src/methods/ranked-pairs/tarjan.ts rename to src/methods/ranked-pairs/Tarjan.ts index ec60cfc..880a96c 100644 --- a/src/methods/ranked-pairs/tarjan.ts +++ b/src/methods/ranked-pairs/Tarjan.ts @@ -1,62 +1,27 @@ // From chadhutchins: // https://gist.github.com/chadhutchins/1440602 -export class Vertex { - index: number - lowlink: number - connections: Vertex[] - name: number | string - constructor(name: number | string) { - this.connections = [] - this.name = name - - // used in tarjan algorithm - // went ahead and explicity initalized them - this.index = -1 - this.lowlink = -1 - } - - equals(vertex: Vertex): boolean { - // equality check based on vertex name - return this.name == vertex.name - } - - connect(vertex: Vertex): void { - this.connections.push(vertex) - } -} - -class VertexStack { - vertices: Vertex[] - - constructor() { - this.vertices = [] - } - - contains(vertex: Vertex) { - for (const i in this.vertices) - if (this.vertices[i].equals(vertex)) return true - return false - } -} +import { Vertex } from './Vertex' +import { VertexStack } from './VertexStack' export class Tarjan { - index: number - stack: VertexStack - graph: Vertex[] + private index: number + private stack: VertexStack + private readonly graph: Vertex[] // scc: Vertex[][] + constructor(graph: Vertex[]) { this.index = 0 this.stack = new VertexStack() this.graph = graph // this.scc = [] } - run(): void { + public run(): void { for (const i in this.graph) if (this.graph[i].index < 0) this.strongconnect(this.graph[i]) // return this.scc } - private strongconnect(vertex: Vertex) { + private strongconnect(vertex: Vertex): void { // Set the depth index for v to the smallest unused index vertex.index = this.index vertex.lowlink = this.index diff --git a/src/methods/ranked-pairs/Vertex.ts b/src/methods/ranked-pairs/Vertex.ts new file mode 100644 index 0000000..4a4a9ad --- /dev/null +++ b/src/methods/ranked-pairs/Vertex.ts @@ -0,0 +1,27 @@ +// From chadhutchins: +// https://gist.github.com/chadhutchins/1440602 + +export class Vertex { + public index: number + public lowlink: number + public connections: Vertex[] + private readonly name: number | string + constructor(name: number | string) { + this.connections = [] + this.name = name + + // used in tarjan algorithm + // went ahead and explicity initalized them + this.index = -1 + this.lowlink = -1 + } + + public equals(vertex: Vertex): boolean { + // equality check based on vertex name + return this.name == vertex.name + } + + public connect(vertex: Vertex): void { + this.connections.push(vertex) + } +} diff --git a/src/methods/ranked-pairs/VertexStack.ts b/src/methods/ranked-pairs/VertexStack.ts new file mode 100644 index 0000000..16495ff --- /dev/null +++ b/src/methods/ranked-pairs/VertexStack.ts @@ -0,0 +1,18 @@ +// From chadhutchins: +// https://gist.github.com/chadhutchins/1440602 + +import { Vertex } from './Vertex' + +export class VertexStack { + public vertices: Vertex[] + + constructor() { + this.vertices = [] + } + + public contains(vertex: Vertex): boolean { + for (const i in this.vertices) + if (this.vertices[i].equals(vertex)) return true + return false + } +} diff --git a/src/methods/ranked-pairs/index.ts b/src/methods/ranked-pairs/index.ts index 6d3afa0..e99ec1e 100644 --- a/src/methods/ranked-pairs/index.ts +++ b/src/methods/ranked-pairs/index.ts @@ -8,7 +8,8 @@ import { SystemUsingMatrix, VotingSystem, } from '../../types' -import { Tarjan, Vertex } from './tarjan' +import { Tarjan } from './Tarjan' +import { Vertex } from './Vertex' type Edge = { from: number; to: number; value: number }