14

Given a set of n objects in no specific order (n = 5 in this example):

{
    apple,
    orange,
    banana,
    cherry,
    cabbage
}

I'm trying to give a user several questions with three options, like so:

banana      vs.      cabbage
      (no preference)

after every question, it would make a new question with different options (no preference stays the same), efficiently collecting data on the user's preferences.

It would, after several (6 or 7 in this case) questions, give an ordered list (array) of the highest ranked objects in order:

{
    cherry,
    cabbage,
    orange,
    apple,
    banana
}

However, I don't know how such an algorithm would work or when it would know to stop. I'm sorry if this is a bad question, but I'm pretty new to algorithm design.

And I suppose it doesn't really matter, but I'm using JavaScript.


Okay, I'm revisiting this four months later, because I thought of a new method to do the sorting.

Perhaps, it would be more efficient to have a list of inferiors for each object, so that anything which is inferior to one object would be inferior for its superiors. I'm explaining this awfully, so here's a visual:

cherry > cabbage
cabbage > banana
cabbage > apple
apple > orange

thus, cherry > cabbage & banana & apple & orange

With the old method, with score:

apple vs. orange
apple vs. banana (e.g. apple wins)
apple vs. cherry (e.g. cherry wins)
apple vs. cabbage
orange vs. banana
orange vs. cherry
orange vs. cabbage
banana vs. cherry
banana vs. cabbage
cherry vs. cabbage

10 questions

The new method would make cherry vs. banana redundant because we already know that apple > banana and cherry > apple. I really only need to figure out how to code it.

The only problem arises with human circular logic (i.e. a > b > c > a), where this is a possibility, but thankfully this won't be a problem with my particular set.

Poyo
  • 554
  • 1
  • 9
  • 23
  • 3
    Your question might have mathematical issues when it comes to user preferences. An ordered list needs a partial ordered relation: http://en.wikipedia.org/wiki/Partially_ordered_set. Imagine a scenario when user prefers apple to orange and orange to cherry and cherry to apple in separate questions. – Mehdi Jul 08 '15 at 21:09
  • The "new method to do the sorting" is just a sort. If the elements have a well-defined order, you can sort them in O(n logn). If you can have things like `a ≤ b ≤ a` then `≤` is not a [partial order](https://en.wikipedia.org/wiki/Partially_ordered_set#Formal_definition) because it doesn't have transitivity. You can still define `a ≤ b` for every pair `a,b` with cost O(n^2). – Oriol Nov 14 '15 at 15:27

10 Answers10

12

I looked into this some time ago as part of my answer to a related question (Collaborative sorting algorithm based on 1 vs 1 choice) and found that creating an ordered list based on "do you prefer A or B?" style questions, using as few questions as possible, and while avoiding cycles (as in: A>B, B>C, C>A), is best done using binary insertion sort, or a variation thereof.

What this means in practise, is that you introduce the elements into the ordered list one by one, find their place in the list, insert them, and then move on to the next element.
To reduce the number of comparisons to the strictest minimum, you use binary insertion; this means that every new element is first compared to the element in the middle of the ordered list; this tells you whether the new element goes in the upper or lower half; then you compare it to the element in the middle of that half, and so on, until its place is found.

As an example, consider a list of 10 elements that need to be sorted. If you compared every element with every other element, you'd have to ask 45 questions. With binary insertion, this is reduced to 19 ~ 25 questions, with an average of 22.2 questions.
binary insertion - list of comparisons for 10 elements
The exact number of questions depends on the input: to insert 1 into the list [2,4,6,8], you'd compare it with 4, then with 2, and you'd know its location with two comparisons; to insert 7 into the list [2,4,6,8], you'd compare it with 4, then with 6, then with 8, and only know its location after three comparisons. In general, inserting the n-th elements takes either log2(n) or log2(n)+1 comparisons (always log2(n) if n is a power of 2). The overall number of comparisons < n.loge(n).

If you accept "no preference" answers, the number of questions can be lower, down to n-1 if most of the answers are "no preference".

Below is a Javascript code snippet I wrote for the related question. It asks "A or B?" questions, takes "A", "B" or "no preference" as answers, and creates an ordered list. A random generator acts as the person giving the answers.

The algorithm could be adapted to sort the array in-place. You'd start by considering the first element as the sorted array, and the second element as the element to be inserted, and swap them if necessary; then you'd consider the first two elements as the sorted list, and the third element as the element to be inserted, and so on. For variations of binary insertion sort, and strategies to reduce the number of swaps, see e.g. this Wikipedia article.

function PrefList(n) {
    this.size = n;
    this.items = [{item: 0, equals: []}];
    this.current = {item: 1, try: 0, min: 0, max: 1};

    this.addAnswer = function(x, y, pref) {
        if (pref == 0) {
            this.items[this.current.try].equals.push(this.current.item);
            this.current = {item: ++this.current.item, try: 0, min: 0, max: this.items.length};
        } else {
            if (pref == -1) this.current.max = this.current.try
            else this.current.min = this.current.try + 1;
            if (this.current.min == this.current.max) {
                this.items.splice(this.current.min, 0, {item: this.current.item, equals: []});
                this.current = {item: ++this.current.item, try: 0, min: 0, max: this.items.length};
            }
        }
    }

    this.getQuestion = function() {
        if (this.current.item >= this.size) return null;
        this.current.try = Math.floor((this.current.min + this.current.max) / 2);
        return({a: this.current.item, b: this.items[this.current.try].item});
    }

    this.getOrder = function() {
        var index = [];
        for (var i in this.items) {
            var equal = [this.items[i].item];
            for (var j in this.items[i].equals) {
                equal.push(this.items[i].equals[j]);
            }
            index.push(equal);
        }
        return(index);
    }
}

// THIS FUNCTION ACTS AS THE PERSON ANSWERING THE QUESTIONS
function preference(a, b) {
    if (Math.random() > 0.6) return -1; else if (Math.random() > 0.333) return  1; else return 0;
}

// CREATE TABLE AND ASK QUESTIONS UNTIL TABLE IS COMPLETE
var fruit = ["orange", "apple", "pear", "banana", "kiwifruit", "grapefruit", "peach", "cherry", "starfruit", "strawberry"];
var t = new PrefList(10), c = 0, q;
while (q = t.getQuestion()) {
    document.write(++c + ". " + fruit[q.a] + " or " + fruit[q.b] + "?<BR>");
    var answer = preference(fruit[q.a], fruit[q.b]);
    document.write("&nbsp;&rarr; " + [fruit[q.a], "no preference", fruit[q.b]][answer + 1] + "<BR>");
    t.addAnswer(q.a, q.b, answer);
}

// PERFORM SORT BASED ON TABLE AND DISPLAY RESULT
var index = t.getOrder();
document.write("LIST IN ORDER:<BR>");
for (var i = 0, pos = 1; i < index.length; i++) {
    var pre = pos + ". ";
    for (var j = 0; j < index[i].length; j++) {
        document.write(pre + fruit[index[i][j]] + "<BR>");
        pre = "&nbsp;&nbsp;&nbsp;&nbsp;";
    }
    pos += index[i].length;
}
Community
  • 1
  • 1
  • Great, almost exactly what I was looking for! The only real problem is the lack of ties. When I set the algorithm to output only 0s, it just spits out the list in the same order. It's logically expected by the way it's written currently, but there should be a way to indicate that two items are of equal ranking. – Poyo Nov 17 '15 at 07:18
  • 1
    @Poyo I changed the display method (I forgot I'd written a better version for the last code snippet in my answer to the related question); ties are now displayed correctly. – m69's been on strike for years Nov 18 '15 at 06:18
  • "*is best done using binary insertion sort*". Insertion sort has `O(n²)` average case complexity. There are better sorting algorithms. You only count `log₂(n)` to find the insertion place, but you ignore the cost of inserting at the middle of an array. – Oriol Mar 11 '16 at 14:18
  • 1
    @Oriol I understand what you're saying, but the OP's main concern was to reduce the number of comparisons, so we focused on that, and not on the cost of swapping/inserting; maybe my use of the term "complexity" is misleading here. (Btw, could the cost of insertion not be addressed by using a linked list instead of an array?) – m69's been on strike for years Mar 11 '16 at 18:47
  • @m69 Fair enough, the number of comparisons in your algorithm is minimal. The problem with linked lists is that they don't have constant time random access. – Oriol Mar 11 '16 at 19:04
4

You can use sort:

var sorted = "cherry,cabbage,orange,apple,banana".split(",").sort(function(a,b) {
  return prompt([
    "If you prefer " + a + ", enter -1",
    "If you prefer " + b + ", enter 1",
    "If you don't mind , enter 0"
  ].join("\n"));
});
Oriol
  • 274,082
  • 63
  • 437
  • 513
  • 1
    Your question might have mathematical issues when it comes to user preferences. An ordered list needs a partial ordered relation: https://en.wikipedia.org/wiki/Partially_ordered_set. Imagine a scenario when user prefers `apple to orange` and `orange to cherry` and `cherry to apple` in separate questions. – Mehdi Jul 08 '15 at 17:17
  • 1
    @Mehdi True, the user should provide a consistent answers, otherwise the result will be undefined. However, the compare function should be called as few times as possible, so browsers shouldn't probably ask `cherry vs apple` if they already know it. – Oriol Jul 08 '15 at 17:22
  • 1
    @Oriol, you will need to do step(3) of my answer below to optimize the number of user prompts. For example, suppose the user has mentioned banana < cherry and later we prompt and understand that apple < banana, we should never ask the user the apple to cherry relationship again. Depending on your sorting algorithm this may be asked and you should prevent that. – user1952500 Jul 08 '15 at 17:52
  • 1
    I think tastes in food are such a good example, when this conclusion that from a > b and b > c follows a > c is really wrong. Just do the test and answer all the questions, you will notice how human beeings are not 100% logical. But if the questions aims to gain data to define a specific theoretic utility function, I completle agree that you have to force the transitivity. – Andreas Grünh Jul 08 '15 at 18:30
  • @AndreasGrünh, in that case the user could potentially need to answer O(n^2) prompts. – user1952500 Jul 08 '15 at 20:48
  • I guess I put the comment in wrong post! I will put it in main question. – Mehdi Jul 08 '15 at 21:09
1

I wrote a small jsFiddle for your problem using angular-js (You don't need to use angular)

The idea is to compare every element to every other element. Once every element has been compared to every other element, you'r done. In the code, focus on the getNext() function:

$scope.getNext = function(string) {
    if(string == 'one') {
        $scope.foodArray[x].score++;
    } else {
        $scope.foodArray[y].score++;                
    }
    if(y < $scope.foodArray.length -1) {
        y++;
        $scope.foodTwo = $scope.foodArray[y];
    } else if(x < $scope.foodArray.length -2) {
        y = 2 + x;
        x++;
        $scope.foodOne = $scope.foodArray[x];
        $scope.foodTwo = $scope.foodArray[y];
    } else {
        finish();   
    }
}

The first two if statements are used to determine the winner.

As you can see, I'm using variables x and y to store the current position in your matrix. First I compare food number 0 (= x) with 1, 2, 3, 4 ( =y). When y reaches array.length-1, you add 1 to x and set y to x +1. When x reaches array.length-2 and y array.length-1 it means, that you compared everything to everything else. Now you can sort the array by the score and you are done :)

Edit: Here is the new Fiddle which adds the possibility to answer with "indifferent".

One more thing you need to consider: When dealing with preferences in theory, there are three axioms Some explanation:

  1. Completeness: Every Object can be compared to every other object
  2. Reflexivity: Every Object is at least as good as itself
  3. Transitivity: If a > b and b > c, this has to mean that a > c.

These axioms have to apply, so that you can calculate with utility functions.

But in practice especially the third one doesn't apply. You will always find people who say: Oranges > apples, apples > bananas BUT ALSO bananas > oranges

In my fiddle I ignored those axioms and let the user decide wether they want to act completely logical or not.

Andreas Grünh
  • 336
  • 1
  • 6
  • This is easily the best answer yet, but it doesn't account for the possibility of not having a preference. Spamming "no preference" should result in every object having the same score, rather than continuing the algorithm forever. – Poyo Jul 08 '15 at 17:38
  • 1
    I will adapt the fiddle in 10 mins, but you only need to add a button for indifferent, an dont increase a score. The algorithm is done, when every comparison is done, it doesn't care about the descision – Andreas Grünh Jul 08 '15 at 17:50
1

You don't need to compare every item against every other item. That would require you to ask the user 15 questions to sort 5 items. It's been proven that you can get a total ordering of 5 items in 7 comparisons.

You can accomplish this with a sorting network. For any N, you can create a series of comparisons and swaps that will sort the items. There are known optimum sequences for sorting up to 16 items. Beyond that there are algorithms that will generate sorting networks (see Constructing sorting networks in the linked article). Although those networks are not proven to be optimum, they're probably more efficient than a general-purpose sorting algorithm.

Your larger problem, which I think you gloss over too easily, is the very real possibility of circular logic. Transitivity typically doesn't hold when it comes to user preferences.

Jim Mischel
  • 131,090
  • 20
  • 188
  • 351
  • Well, that's why I made the edit four months later. I realised that certain matchups would make other questions entirely redundant (ignoring circular logic, of course). I really just need to find a way to recursively loop through all of the "inferiors." – Poyo Nov 11 '15 at 19:57
  • A sorting network seems like the way to go though it seems useful to try to label them with values (or a range of values) to help with circular logic. Though, the labeling process may be rather tricky depending on how accurate you want the sorting. – Nuclearman Nov 14 '15 at 05:57
  • 1
    "It's been proven that you can get a total ordering of 5 items in 9 comparisons", isn't that 7? http://cs.stackexchange.com/a/44982/53177 – AXO Jun 22 '16 at 00:00
  • @AXO: Yes, that was supposed to be 7. No telling how I got the 9 there. Thanks for the correction. – Jim Mischel Jun 22 '16 at 04:18
0

Well you could add weight to each item based on the users answers. So for instance in your example of

banana      vs.      cabbage
      (no preference)

When the user selects banana you add a plus one to the banana item. If they select cabbage you give plus one to cabbage and then if they select no preference then you can just give neither a plus one. Then you can sort the list from the largest value to the smallest.

trinityalps
  • 397
  • 4
  • 19
  • You will need to do some very complex rearrangement of indices. For example banana < cabbage and also banana < cherry < orange < cabbage. So essentially banana should be at least 3 less than cabbage. – user1952500 Jul 08 '15 at 17:09
  • 2
    Unfortunatly this it not quite correct. The voting (banana < cabbage), (orange < cabbage), (banana < cherry), (cherry < orange) would result in the scoring: [banana(0), cherry(1), orange(1), cabbage(2)], i.e. your algorithm does not make clear that orange was rated higher than cherry. Depending on the sort algorithm used it may give a wrong result. – Bastian Voigt Jul 08 '15 at 18:06
0

Use a map (javascript object can act so) with object as the key and integer (set to 0 initially for all objects) as the value.

For each question, you increment the value of the respective chosen key. Since no preference affects none of them, nothing to be done in case it's chosen.

Then iterate the map, finding the key-value pair with highest value. Return the key.

LeleDumbo
  • 9,192
  • 4
  • 24
  • 38
  • The problem with this is that there is a possibility of not having an even number of questions for each key. It's not a "round robin," if you will. Doing such a thing is neither necessary, nor viable at larger amounts of keys (i.e. 15+). – Poyo Jul 08 '15 at 17:34
0
  1. Keep all of the items in an array. Use a separate array / hashmap for relations between pairs. Then use your favorite comparison-based search algorithm (say quicksort).

  2. When two elements are to be compared, find out relative precedence using the map. If there is no known precedence, ask the user.

  3. When a new precedence is added, adjust the precedence order of the whole matrix. For example when the user says apple < banana and then separately banana < cherry, add apple < cherry to the hashmap.

The comparison-based search algorithms are designed to optimize number of comparisons and this translates to optimization of the number of questions asked to the user in this case.

user1952500
  • 6,611
  • 3
  • 24
  • 37
0

Keep your elements in an array like this:

var preferences = [
  {
    "name": "banana",
    "predecessors": []
  },
  {
    "name": "orange",
    "predecessors": []
  },
  {
    "name": "cabbage",
    "predecessors": []
  },
];

Now add the predecessors to the elements as the users choose their preferences. Let's say a user rated banana over orange, cabbage over orange, and cabbage over banana. Result:

var preferences = [
  {
    "name": "banana",
    "predecessors": ["orange"]
  },
  {
    "name": "orange",
    "predecessors": []
  },
  {
    "name": "cabbage",
    "predecessors": ["orange", "banana"]
  },
];

Now sort the array using a custom comparator function:

preferences.sort(function(a,b) {
  if(b.predecessors.contains(a.name)) {
    return -1;
  } 
  if(a.predecessors.contains(b.name)) {
    return 1;
  }
  return 0;
});

Note that javascript has no native "contains" function for arrays. You can use $.inArray from jQuery or _.contains from underscore, or write one yourself.

Bastian Voigt
  • 5,311
  • 6
  • 47
  • 65
0

Make it simple:

    var options = {
      A: 0,
      B: 0,
      C: 0,
      D: 0
    };
    var optionsNames = Object.keys(options);

    var results = document.getElementById('results');
    function printResults() {
      var res = optionsNames.map(function(option) {
        return option + ': ' + options[option];
      });
      
      results.innerHTML = res.join('<br>');
    }
    
    function getNextQuestion() {
      var unclear = [];
      optionsNames.sort(function(a, b) {
        if (options[a] == options[b]) unclear.push([a, b]);
        else return options[b] - options[a];
        return 0;
      });
    
      return unclear[0];
    }

    var next;
    function choose(nextIdx) {
      if (next && next[nextIdx] && options[next[nextIdx]] !== undefined) {
        options[next[nextIdx]] += 1;
      }
      
      console.log('click', options, next[nextIdx]);
      
      updateView();
    }
    
    var btn1 = document.getElementById('one');
    var btn2 = document.getElementById('two');
    
    function updateView() {
      next = getNextQuestion();
      if (next) {
        btn1.innerHTML = next[0];
        btn2.innerHTML = next[1];
      } else {
        btn1.innerHTML = 'FINISH';
        btn2.innerHTML = 'FINISH';
      }
      
      printResults();
    }
    
    updateView();
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<button id="one" onclick="choose(0)"></button> vs. <button id="two" onclick="choose(1)"></button>
<div id="results"></div>
webdeb
  • 12,993
  • 5
  • 28
  • 44
0

Try creating an array of items having selection names , using input type="radio" elements , removing elements as selected , pushing values to one of three properties of an object. Property 1 of object would contain one item , property 2 would contain input array length / 3 items , property 3 would contain remainder of selections; "none" would be provided when one selection option remains in form.

var arr = [
  "apple",
  "orange",
  "banana",
  "cherry",
  "cabbage" 
];

var data = {
  "1": [],
  "2": [],
  "3": []
};

var form = document.forms["choices"];

var html = arr.map(function(val, index) {
  var input = document.createElement("input");
  var label = document.createElement("label");
  label.textContent = val + ":";
  input.type = "radio";
  input.name = "choice";
  input.className = "choices";
  input.value = val;
  label.appendChild(input);
  return label.outerHTML
});

form.lastElementChild
.insertAdjacentHTML("beforebegin", html.join("") + "<br>");

form.onchange = function(e) {
  e.preventDefault();
  if (data[1].length + data[2].length + data[3].length < arr.length - 2) {
    if (data[1].length === 0) {
      data[1].push(e.target.value);
    } else {
      if (data[2].length < arr.length / 3) {
        data[2].push(e.target.value);
      } else {
        data[3].push(e.target.value);
      }
    }
    form.removeChild(e.target.parentElement);
  } else {
    if (data[1].length + data[2].length + data[3].length === arr.length - 2) {
      data[3].push(e.target.value);
      form.removeChild(e.target.parentElement);
      var input = document.createElement("input");
      var label = document.createElement("label");
      label.textContent = "none";
      input.type = "radio";
      input.name = "choice";
      input.className = "choices";
      input.value = "none";
      label.appendChild(input);
      form.getElementsByClassName("choices")[0].parentElement
        .insertAdjacentHTML("afterend", label.outerHTML)
    } else {
      data[3].push(e.target.value);
      form.removeChild(e.target.parentElement);
      form.removeChild(form.getElementsByClassName("choices")[0].parentElement);
      form.firstElementChild.innerHTML = "Results:";
      form.querySelector("pre").textContent = JSON.stringify(data, null, 2)
    }
  }

  console.log(e.target.value, data);
}
<form name="choices">
  <span>Select one:</span>
  <br>
  <pre name="result"></pre>
</form>
guest271314
  • 1
  • 15
  • 104
  • 177