-1

I have an array of arrays:

$students= array(
    array("name"=>"...", "gender"=>"male"),
    array("name"=>"...", "gender"=>"female"),
    array("name"=>"...", "gender"=>"female"),
    array("name"=>"...", "gender"=>"female"),
    array("name"=>"...", "gender"=>"male"),
    array("name"=>"...", "gender"=>"male"),
    array("name"=>"...", "gender"=>"male"),
);

I would like to sort $students elements by alternating gender to get:

$students= array(
    array("name"=>"...", "gender"=>"male"),
    array("name"=>"...", "gender"=>"female"),
    array("name"=>"...", "gender"=>"male"),
    array("name"=>"...", "gender"=>"female"),
    array("name"=>"...", "gender"=>"male"),
    array("name"=>"...", "gender"=>"female"),
    array("name"=>"...", "gender"=>"male"),
);

How can I do this?

mickmackusa
  • 43,625
  • 12
  • 83
  • 136
Adelbak
  • 11
  • 1
  • 1
    So you want to 'split' the sort so that every second occurrence swaps? You'll need a custom sorting function for that... though I'd conjecture that it would make more sense to sort either all males first or all females first. – Obsidian Age Aug 19 '19 at 02:48
  • 2
    What if there aren't equal numbers of males and females? – Nick Aug 19 '19 at 02:52
  • I think 'split' is part of the solution because I couldn't find the solution with a sorting function. – Adelbak Aug 19 '19 at 02:52
  • 1
    Just because you didn't find a solution to the problem doesn't mean it doesn't exist. The only difficulty is that only YOU know what the *problem* is -- and without knowing the *problem*, it can be hard to find the *solution*. This really does sound like an [**XY problem**](https://meta.stackexchange.com/a/66378). – Obsidian Age Aug 19 '19 at 02:54
  • if there aren't equal numbers of males and females its ok like: male - female - male - female -male - female - female - female – Adelbak Aug 19 '19 at 02:55

4 Answers4

2

I did this by filtering the elements to 2 separate arrays ($males and $females). array_filter preserves keys, so we simply pass it to array_values to get a fresh key list starting from 0. From there, it's a simple for loop, to intertwine them and add them to a final array.

<?php

$students= [
    ["name"=>"...", "gender"=>"male"],
    ["name"=>"...", "gender"=>"female"],
    ["name"=>"...", "gender"=>"female"],
    ["name"=>"...", "gender"=>"female"],
    ["name"=>"...", "gender"=>"male"],
    ["name"=>"...", "gender"=>"male"],
    ["name"=>"...", "gender"=>"male"],
];

$males = array_values(array_filter($students, function($s) { return $s["gender"] === "male"; }));
$females = array_values(array_filter($students, function($s) { return $s["gender"] === "female"; }));

$final = [];
$max = max(count($females), count($males));

for ($i=0; $i<$max; $i++) {
    if (isset($males[$i])) {
        $final[] = $males[$i];
    }

    if (isset($females[$i])) {
        $final[] = $females[$i];
    }
}

print_r($final);

See this demo here.

Blue
  • 22,608
  • 7
  • 62
  • 92
2

Naive solution

You can use array_filter to create two groups according to gender. Then zip the groups into pairs using array_map and run the pairs through array_reduce to flatten the structure:

$males = array_filter($students, function ($e) {
    return $e["gender"] === "male";
});
$females = array_filter($students, function ($e) {
    return $e["gender"] === "female";
});
$zipped = array_map(null, $males, $females);
$result = array_reduce($zipped, function ($a, $e) {
    if ($e[0]) $a[] = $e[0];
    if ($e[1]) $a[] = $e[1];
    return $a;  
}, []);

Time complexity is O(n).


Reducing overhead

If the first solution has too much overhead, consider eliminating function calls. It's still O(n) with two passes but branch prediction should handle situations with a wide quantity imbalance between the genders in the merge loop:

foreach ($students as $student) {
    if ($student["gender"] === "male") {
        $males[]= $student;
    }
    else {
        $females[]= $student;
    }
}

$male_count = count($males);
$female_count = count($females);

for ($i = 0, $j = 0; $i < $male_count || $j < $female_count;) {
    if ($i < count($males)) {
        $result[]= $males[$i++];
    }

    if ($j < count($females)) {
        $result[]= $females[$j++];
    }
}

Generalization

The above code assumes two things: (1) "male" should always be first even if it produces a suboptimal interleaving (per OP's specification) and (2) only two "gender" values exist.

The first problem can be solved by modifying the above snippets to swap the array order during the zipping stage to prefer the longest array first.

The second problem can be solved using array_reduce to create groupings of array elements per unique value for the target key, then removing hardcoded values in favor of iteration over these groups sorted descending by frequency (tie-breaking logic can be added).

Time complexity for the below code is O(n + k*log(k)) where k is the number of unique values. Worst case, all entries are totally or nearly unique, in which case we have an O(n log(n)) solution due to a superfluous sort, but it's O(n) if k is constant, as in OP's case.

Note that PHP sort routines are not stable, so you'll need to pack and unpack the array into index/element pairs or use a custom tie-breaking policy other than index.

<?php

function interleave_values($arr, $key) {
    $unique_values = array_unique(array_column($arr, $key));
    $buckets = array_reduce($arr, function ($a, $e) use ($key) {
        $a[$e[$key]][] = $e;
        return $a;
    }, []);
    rsort($buckets);
    $zipped = array_map(null, ...$buckets);
    return array_reduce($zipped, function ($a, $e) {
        foreach ($e as $f) {
            if (!$f) break;

            $a[] = $f;
        }

        return $a;  
    }, []);
}

$test = [
    ["k" => 1],
    ["k" => 2],
    ["k" => 1],
    ["k" => 3],
    ["k" => 3],
    ["k" => 1],
    ["k" => 2],
    ["k" => 2],
    ["k" => 2],
];
var_export(interleave_values($test, "k"));

Output:

array (
  0 => 
  array (
    'k' => 2,
  ),
  1 => 
  array (
    'k' => 1,
  ),
  2 => 
  array (
    'k' => 3,
  ),
  3 => 
  array (
    'k' => 2,
  ),
  4 => 
  array (
    'k' => 1,
  ),
  5 => 
  array (
    'k' => 3,
  ),
  6 => 
  array (
    'k' => 2,
  ),
  7 => 
  array (
    'k' => 1,
  ),
  8 => 
  array (
    'k' => 2,
  ),
)
ggorlen
  • 44,755
  • 7
  • 76
  • 106
  • 1
    Interesting use of `array_map()`. Nice alternative. – Blue Aug 19 '19 at 03:14
  • Thanks, but I realize it's pretty much the same as yours. – ggorlen Aug 19 '19 at 03:14
  • 1
    I just wish php had shorthand for those ungodly lambda functions. `function() { return $bleh; }` is so ugly. – Blue Aug 19 '19 at 03:16
  • Thank you ggorlen and FrankerZ, After a simple execution time benchmark, I think FrankerZ method is fastest. – Adelbak Aug 19 '19 at 03:38
  • If you want speed, I'd skip `array_filter` entirely and use primitive loops all the way through. The trafeoff is pretty code vs slightly faster code, but the complexity is O(n) regardless, so it might be a premature optimization depending on your use case. – ggorlen Aug 19 '19 at 03:45
  • `count()` is called 4 times per iteration, despite never changing, in your primative loops snippet. These values should be cached and the variables re-used. – mickmackusa Oct 10 '20 at 11:56
  • I was not concerned with performance and was only considering DRY programming. – mickmackusa Oct 10 '20 at 22:22
1

Array_filter will traverse the array, and doing so twice just to split an array is unnecessary.
Instead loop it with foreach and split it.
Then array_combine each part with even or uneven number keys and merge the two.

foreach($students as $stu){
    if($stu['gender'] == 'male'){
        $male[] = $stu;
    }else{
        $female[] = $stu;
    }
}


$male = array_combine(range(0,(count($male)-1)*2,2),$male); // make keys even starting with 0
$female = array_combine(range(1,count($female)*2,2),$female); // make keys uneven starting with 1
$all = array_replace($male, $female); // replace can be used since they keys do not create any problems
ksort($all); //sort on key
$all = array_values($all);

var_dump($all);

https://3v4l.org/TZCKN


Another method is to assign the keys in the foreach then just do the array_replace.
That should be faster since there is less array functions involved.

$i = 0;
$j = 1;
foreach($students as $stu){
    if($stu['gender'] == 'male'){
        $male[$i] = $stu;
        $i +=2;
    }else{
        $female[$j] = $stu;
        $j +=2;
    }
}

$all = array_replace($male, $female);
ksort($all);
$all = array_values($all);

var_dump($all);

https://3v4l.org/k3MMj

Andreas
  • 23,610
  • 6
  • 30
  • 62
  • You're right about `filter`, but at least the overall time complexity is O(n), regardless of the number of passes. But using `ksort` pushes the complexity up to O(n log(n)). I haven't benchmarked anything, but there's some n that this will blow up on, I would imagine. – ggorlen Aug 19 '19 at 03:44
  • Sure you are not forgetting 2 x array_values that will traverse the array another two times? – Andreas Aug 19 '19 at 03:46
  • I'm not using `array_values` but it's irrelevant from a time complexity standpoint--drop the constant and you're left with O(n) either way. – ggorlen Aug 19 '19 at 03:52
  • My bad, that was the other answer – Andreas Aug 19 '19 at 03:54
1

I don't think I recommend creating temporary gender-specific arrays then zipper-merging them.

I like the efficiency of maintaining two gender-specific counters and only iterating them in a single loop. My loop makes no internal function calls.

In fact, determining which gender should come first requires more handling than the new key assignments.

Code: (Demo)

$students = [
    ["name" => "...", "gender" => "male"],
    ["name" => "...", "gender" => "female"],
    ["name" => "...", "gender" => "female"],
    ["name" => "...", "gender" => "female"],
    ["name" => "...", "gender" => "male"],
    ["name" => "...", "gender" => "male"],
    ["name" => "...", "gender" => "male"],
];

$counters = ['female' => 1, 'male' => 1];

// determine which gender should start from 0
$genderCounts = array_count_values(
    array_column($students, 'gender')
);
arsort($genderCounts);
--$counters[key($genderCounts)];

// assign keys
$result = [];
foreach ($students as $student) {
    $gender = $student['gender'];
    $result[$counters[$gender]] = $student;
    $counters[$gender] += 2;
}

ksort($result);

var_export($result);
mickmackusa
  • 43,625
  • 12
  • 83
  • 136
  • As with [this answer](https://stackoverflow.com/a/57550225/6243352), `ksort` and `arsort` increase time complexity to O(n log(n)) from O(n). – ggorlen Dec 17 '20 at 14:17
  • Your first snippet and your second snippet unconditionally put males first, resulting in potential consecutive female entries. https://3v4l.org/SSrUK and https://3v4l.org/KBckm the reason that my answer increases time complexity is so that the result better separates genders. If someone paid me to reduce the time complexity of my answer, I might find a way, but this is what I came up with on the day to provide an accurate result. – mickmackusa Dec 17 '20 at 21:20
  • OP [seems to imply `"male"` is first in a comment](https://stackoverflow.com/questions/57549931/how-to-sort-an-array-of-associative-arrays-by-alternating-gender-value/64291232?noredirect=1#comment101562635_57549931). Nonetheless, I think you raise a strong point that the best interleaving would be the common case, so I updated my code and generalized it to arbitrary values. However, there's no reason to explode the time complexity for this case--looking at your code closer, the `arsort` is OK since it's tied to the number of unique values and is inescapable, but the `ksort` is the bottleneck. – ggorlen Dec 17 '20 at 22:41