Sorting a php array of arrays by custom order
Asked Answered
S

8

81

I have an array of arrays:

Array ( 
    [0] => Array (
        [id] = 7867867,
        [title] = 'Some Title'),
    [1] => Array (
        [id] = 3452342,
        [title] = 'Some Title'),
    [2] => Array (
        [id] = 1231233,
        [title] = 'Some Title'),
    [3] => Array (
        [id] = 5867867,
        [title] = 'Some Title')
)

The need to go in a specific order:

  1. 3452342
  2. 5867867
  3. 7867867
  4. 1231233

How would I go about doing that? I have sorted arrays before, and read plenty of other posts about it, but they are always comparison based (i.e. valueA < valueB).

Help is appreciated.

Schoen answered 21/6, 2012 at 19:28 Comment(2)
How do you know what the order is supposed to be for your needs?Succinate
@Succinate he just knows, ok? :) for example, I have a default order for fields in a csv export. The order is somewhat arbitrary ( at least, not in alphabetical order ). But I still need to sort other arrays to match it.Andreaandreana
R
172

You can use usort() to dictate precisely how the array is to be sorted. In this case, the $order array can be used within the comparison function.

The example below uses a closure to make life easier.

$order = array(3452342, 5867867, 7867867, 1231233);
$array = array(
    array('id' => 7867867, 'title' => 'Some Title'),
    array('id' => 3452342, 'title' => 'Some Title'),
    array('id' => 1231233, 'title' => 'Some Title'),
    array('id' => 5867867, 'title' => 'Some Title'),
);

usort($array, function ($a, $b) use ($order) {
    $pos_a = array_search($a['id'], $order);
    $pos_b = array_search($b['id'], $order);
    return $pos_a - $pos_b;
});

var_dump($array);

The key to this working is having the values that are being compared, be the positions of the ids within the $order array.

The comparison function works by finding the positions of the ids of two items to be compared within the $order array. If $a['id'] comes before $b['id'] in the $order array, then the return value of the function will be negative ($a is less so "floats" to the top). If $a['id'] comes after $b['id'] then the function returns a positive number ($a is greater so "sinks" down).

Finally, there is no special reason for using a closure; it's just my go-to way of writing these sorts of throwaway functions quickly. It could equally use a normal, named function.

Romo answered 21/6, 2012 at 19:41 Comment(6)
Brilliant. Thanks. Now what happens when I add items to the array and not to the sort? Logically, I don't care what order they appear, as long as it comes after the ones that I did specify. How do I handle that?Schoen
Whats the best solution if I have an simple array: $array = (0=> values, 1=>values,2=>values,etc) by given array(5,2,4,1,3,0)?Irvingirwin
Ok, after 5 seconds of research I found this: #348910 .Irvingirwin
What if you want to sort by a key that doesn't have unique values? For instance if you sorted by Title but there could be multiple titles. It might be fine in this case, but there could be additional keys like date, or author.Sparklesparkler
what if one of them is nullable? like this id 7867867 doesn't exist but you still want to consider the orderPetulant
this method works perfectly with me but it does not work with the associative arrays, I have simple associative array and doing like this php usort($array, function ($a, $b) { return $a - abs($b); }); Harriettharrietta
H
30

Extending salathe's answer for this additional requirement:

Now what happens when I add items to the array and not to the sort? Logically, I don't care what order they appear, as long as it comes after the ones that I did specify. How do I handle that?

You need to add two additional conditions in the sorting function:

  1. A "dont care" item must be considered greater than a "custom order" item
  2. Two "dont care" items must be considered equal (you could add a tie-breaker condition for this case)

Here is the succinct implementation of the logic:

$array = [
    ["id" => 7867867, "title" => "Must Be #3"],
    ["id" => 3452342, "title" => "Must Be #1"],
    ["id" => 1231233, "title" => "Must Be #4"],
    ["id" => 5867867, "title" => "Must Be #2"],
    ["id" => 1111111, "title" => "Dont Care #1"],
    ["id" => 2222222, "title" => "Dont Care #2"],
    ["id" => 3333333, "title" => "Dont Care #3"],
    ["id" => 4444444, "title" => "Dont Care #4"],
];
$order = [
    3452342,
    5867867,
    7867867,
    1231233
];

usort($array, function ($a, $b) use ($order) {
    $ai = array_search($a["id"], $order);
    $bi = array_search($b["id"], $order);
    if ($ai === false && $bi === false) {
        // both items are dont cares
        // relative order of a and b is irrelevant
        return 0;
    } elseif ($ai === false) {
        // a is a dont care
        // put a after b
        return 1;
    } elseif ($bi === false) {
        // b is a dont care
        // put a before b
        return -1;
    } else {
        // put a and b in ascending order
        return $ai - $bi;
    }
});

And here is the faster version with fewer lines of code:

$lookup = array_flip($order);

usort($array, function ($a, $b) use ($lookup) {
    $ai = $lookup[$a["id"]] ?? PHP_INT_MAX;
    $bi = $lookup[$b["id"]] ?? PHP_INT_MAX;
    return $ai <=> $bi;
});
Homicidal answered 22/6, 2012 at 14:53 Comment(1)
Let's throw one more wrench into this problem. What if my array doesn't have array("id" => 5867867, "title" => "Must Be #2") but I still need array("id" => 7867867, "title" => "Must Be #3") to be #3 so I need #2 to be blankBalkanize
B
16

The other answers which are using methods with iterated calls of array_search() are not as efficient as they can be. By restructuring/flipping the "order" lookup array, you can completely omit all array_search() calls -- making your task much more efficient and brief. I'll use the most modern "spaceship operator" (<=>), but earlier techniques will work the same for the comparison line. The "null coalescing operator" (??) will act to check the existence of a given id value in the lookup array in the same way that isset() would -- this is always more efficient than array_search() or in_array().

Code: (Demo with 7.4 arrow function syntax) (Demo with lower than 7.4) (Demo with lower than PHP7)

// restructure with values as keys, and keys as order (ASC)
$order = array_flip([3452342, 5867867, 7867867, 1231233]);
// generating $order = [3452342 => 0, 5867867 => 1, 7867867 => 2, 1231233 => 3];

$default = count($order);
// generating $default = 4

usort(
    $array,
    fn($a, $b) => ($order[$a['id']] ?? $default) <=> ($order[$b['id']] ?? $default)
);

var_export($array);

Or instead of declaring $count, PHP_INT_MAX can be used as seen in these posts:

Buster answered 2/2, 2018 at 7:34 Comment(0)
A
6

More Efficient Solution

$dict = array_flip($order);
$positions = array_map(function ($elem) use ($dict) { return $dict[$elem['id']] ?? INF; }, $array);
array_multisort($positions, $array);

Don't recalculate positions on every comparison

When your array is large or getting the id is costlier, using usort() can get bad, because you recalculate the id for each comparison. Try array_multisort() with pre-calculated positions (see mediumsort or fastsort in the example below), which isn't any more complicated.

Also searching for the id in the order array on each comparison (like in the accepted answer) doesn't improve performance, since you iterate over it every comparison. Calculate it once.

In the code snippet below you can see the main three sorting functions:

  • slowsort
    The accepted answer. Searches the position on every comparison.
  • mediumsort
    Improved slowsort by calculating the positions in advance
  • fastsort
    Improved mediumsort by avoiding searching alltogher.

Note that these handle elements with an id not in given in the order by providing a fallback value of INF. If your order array matches the ids of the original array 1-to-1, then avoid sorting alltogether and just inserting the elements in the right position is the way to go. I added a function cheatsort that does exactly that.

You can more generally sort an array by a weight (see weightedsort in the example). Make sure to calculate the weight only once, to achieve good performance.

Performance (for an array of length 1000)

fastsort     about  1 ms
mediumsort   about  3 ms
slowsort     about 60 ms

Hint: For larger arrays the difference gets worse.

Sorting Function Comparison

<?php

/**
 * accepted answer
 *
 * re-evaluate position in order on each comparison
 */
function slowsort(&$array, $order, $key = 'id')
{
  usort($array, function ($a, $b) use ($order, $key) {
    $pos_a = array_search($a[$key], $order);
    $pos_b = array_search($b[$key], $order);
    return $pos_a - $pos_b;
  });
}

/**
 * calculate element positions once
 */
function mediumsort(&$array, $order, $key = 'id')
{
  $positions = array_map(function ($elem) use ($order, $key) {
    return array_search($elem[$key], $order);
  }, $array);
  array_multisort($positions, $array);
}

/**
 * calculate positions without searching
 */
function fastsort(&$array, $order, $key = 'id')
{
  $dict = array_flip($order);
  $positions = array_map(function ($elem) use ($dict, $key) {
    return $dict[$elem[$key]] ?? INF;
  }, $array);
  array_multisort($positions, $array);
}

/**
 * when each order element gets used exactly once, insert elements directly
 */
function cheatsort(&$array, $order, $key = 'id')
{
  $dict = array_flip($order);
  $copy = $array;
  foreach ($copy as $elem) {
    $pos = $dict[$elem[$key]];
    $array[$pos] = $elem;
  }
}

/**
 * Sort elements in $array by their weight given by $weight_func
 * 
 * You could rewrite fastsort and mediumsort by replacing $position by a weight function
 */
function weightedsort(&$array, $weight_func)
{
  $weights = array_map($weight_func, $array);
  array_multisort($weights, $array);
}



/**
 * MEASUREMENTS
 */

/**
 * Generate the sorting problem
 */
function generate($size = 1000)
{
  $order = array();
  $array = array();

  for ($i = 0; $i < $size; $i++) {
    $id = random_int(0, PHP_INT_MAX);
    $order[] = $id;
    $array[] = array('id' => $id);
  }
  shuffle($order);
  return [$array, $order];
}

/**
 * Time $callable in ms
 */
function time_it($callable)
{
  $then = microtime(true);
  $callable();
  $now = microtime(true);
  return 1000 * ($now - $then);
}

/**
 * Time a sort function with name $sort_func
 */
function time_sort($sort_func) 
{
  echo "Timing $sort_func", PHP_EOL;
  [$array, $order] = generate();
  echo time_it(function () use ($sort_func, &$array, $order) {
    $sort_func($array, $order);
  }) . ' ms' . PHP_EOL;
}

time_sort('cheatsort');
time_sort('fastsort');
time_sort('mediumsort');
time_sort('slowsort');
Allman answered 17/4, 2020 at 8:34 Comment(1)
slowsort(), mediumsort(), and cheatsort() are not designed to work with order arrays that are missing values from the input array to be sorted.Buster
W
2

Without sort you also can get it.

  1. If there are no duplicate ids and $order contains all id values from $array and the id column in $array contains all values in $order, you can achieve the same result by flipping the values into keys in $order, then assign temporary first level keys to array, then either merge or replace $array into $order.

    $order = array(3452342, 5867867, 7867867, 1231233);
    $array = array(
        array('id' => 7867867, 'title' => 'Some Title'),
        array('id' => 3452342, 'title' => 'Some Title'),
        array('id' => 1231233, 'title' => 'Some Title'),
        array('id' => 5867867, 'title' => 'Some Title'),
    );
    
    $order = array_flip($order);
    $array = array_column($array,null,"id");
    $result = array_replace($order,$array);
    var_dump(array_values($result));
    
  2. With potentially duplicate ids in $array:

    $order = array(3452342, 5867867, 7867867, 1231233);
    $array = array(
        array('id' => 7867867, 'title' => 'Some Title'),
        array('id' => 3452342, 'title' => 'Some Title'),
        array('id' => 1231233, 'title' => 'Some Title'),
        array('id' => 5867867, 'title' => 'Some Title'),
    );
    
    $order_dict = array_flip($order);
    $order_dict = array_combine($order, array_fill(0, count($order), []));
    foreach($array as $item){
        $order_dict[$item["id"]][] = $item;
    }
    //$order_dict = array_filter($order_dict);  // if there is empty item on some id in $order array
    $result = [];
    foreach($order_dict as $items){
        foreach($items as $item){
            $result[] = $item;
        }
    }
    var_dump($result);
    
Weide answered 16/1, 2019 at 6:13 Comment(1)
What is the point of array_flip() in 2. if you are immediately overwriting the variable on the next line. Why array_combine(array_fill())? Do you kean array_fill_keys()? The 2nd snippet is doing 6 iterating techniques -- would you do this in a real project?Buster
S
1

@salathe For those of you that are having a hard time understanding what salathe's usort is doing:

Each item in $array is a 'champion' in a tournament to be at the start of a new array (except instead of being number one they want to be number 0).

$a is the home champion, and $b the opponent champion in a match.

$pos_a and $pos_b from the callback are what attributes will be used in the fight for champion a and b. In this case this attribute is the index of the champions id in $order.

Then there is the fight at the return. Now we look to see if having more or less of the attribute is better. In a usort battle the home champion wants a negative number so he can be sooner in the array. The away champion wants a positive number. And should there be a 0 it is a tie.

So following this analogy when away champions attribute(index in $order) is subtracted from the home teams attribute, the larger the away champions attribute is the less likely it is to win by getting a positive number. However if you were to reverse the way the attributes are used, now home champion's attribute is subtracted from away champion's. In this case a larger number for the away champion is more likely to make him have the match end in a positive number.

Code would look like:

note: code is run many times much like a real tournament has many battles in order to decide who gets first(ie 0 / start of array)

//tournament with goal to be first in array
    usort($champions, function ($home, $away) use ($order) {
        $home_attribute = array_search($a['id'], $order);
        $away_attribute = array_search($b['id'], $order);
        //fight with desired outcome for home being negative and away desiring positive
        return $home_attribute - $away_attribute;
    });
Sweepstakes answered 17/8, 2016 at 19:43 Comment(2)
Hopefully this does not just further confuse people ;)Sweepstakes
I think this will only add confusion: "fight with desired outcome for home being negative and away desiring positive" I don't find that statement to be accurate at all.Buster
E
0

You need to define your own comparison function and use usort or uasort if you want to maintain index association.

Enjambement answered 21/6, 2012 at 19:33 Comment(1)
This partial solution came just 5 minutes after the question was posted. It is low-value because it is essentially only offering two hyperlinks to the manual and would be better placed as a comment. Over 5 years later, this answer is overshadowed by complete answers, rendering this post useless. Please consider removing. (Not going to downvote this post)Buster
V
0

I bump into this same problem and @mickmackusa has the answer I need. The selected answer does not sort when there is a NULL value. For example:

$order = array(3, 2, 10);
$array = array(
    array('id' => NULL, 'title' => 'any order since null but not top'),
    array('id' => NULL, 'title' => 'any order since null but not top'),
    array('id' => NULL, 'title' => 'any order since null but not top'),
    array('id' => 2, 'title' => 'should be top'),
);
usort($array, function ($a, $b) use ($order) {
    $pos_a = array_search($a['id'], $order);
    $pos_b = array_search($b['id'], $order);
    return $pos_a - $pos_b;
});

The results above will show an output of:

array(4) {
  [0]=>
  array(2) {
    ["id"]=>
    NULL
    ["title"]=>
    string(32) "any order since null but not top"
  }
  [1]=>
  array(2) {
    ["id"]=>
    NULL
    ["title"]=>
    string(32) "any order since null but not top"
  }
  [2]=>
  array(2) {
    ["id"]=>
    NULL
    ["title"]=>
    string(32) "any order since null but not top"
  }
  [3]=>
  array(2) {
    ["id"]=>
    int(2)
    ["title"]=>
    string(13) "should be top"
  }
}

In @mickmackusa's answer, not only it eliminates null in the sorting but also put in the first whatever is available in the order basis. So since in the array the only available is 2 then that would be on top.

Although it doesn't work in PHP 5.6. So I convert it to PHP 5.6 compatible. This is what I got

usort($array, function($a, $b) use($order, $default) {
    $a = (isset($order[$a['id']]) ? $order[$a['id']] : $default);
    $b = (isset($order[$b['id']]) ? $order[$b['id']] : $default);

    if($a == $b) return 0;
    elseif($a > $b) return 1;
    return -1;
});

The result for the sort above would be

array(4) {
  [0]=>
  array(2) {
    ["id"]=>
    int(2)
    ["title"]=>
    string(13) "should be top"
  }
  [1]=>
  array(2) {
    ["id"]=>
    NULL
    ["title"]=>
    string(32) "any order since null but not top"
  }
  [2]=>
  array(2) {
    ["id"]=>
    NULL
    ["title"]=>
    string(32) "any order since null but not top"
  }
  [3]=>
  array(2) {
    ["id"]=>
    NULL
    ["title"]=>
    string(32) "any order since null but not top"
  }
}

I hope my conversion of the code would help dev who works on an obsolete server with lower php version.

Voyeurism answered 9/12, 2022 at 6:15 Comment(2)
...sounds like an upgrade is called for then. :) That last line in the function body (elseif($a < $b) return -1;) can be just return -1; because it has no choice but to be true.Buster
Yes sorry @Buster I just edited my answer for more detail explanation.Voyeurism

© 2022 - 2024 — McMap. All rights reserved.