Skip to content

Commit adfa84b

Browse files
committed
add iterable_combinations function
1 parent e65db04 commit adfa84b

3 files changed

Lines changed: 178 additions & 3 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,32 @@ $subsets = $items
3737

3838
## Functions
3939

40+
### `iterable_combinations(int $n)`
41+
42+
Yield all subsets of **exactly** size `$n` from an iterable. Output is lazy; input is materialized into an array first.
43+
44+
- Preserves the original keys in each subset.
45+
- Yields `C(count(items), n)` combinations (binomial coefficient).
46+
- For `$n = 0`, yields the empty subset only: `[[]]`.
47+
- Throws `InvalidArgumentException` if `$n < 0`.
48+
49+
```php
50+
use Anarchitecture\combinatorics as c;
51+
use Anarchitecture\pipe as p;
52+
53+
$items = ['a' => 1, 'b' => 2, 'c' => 3];
54+
55+
$pairs = $items
56+
|> c\iterable_combinations(2)
57+
|> p\collect(...);
58+
59+
// [
60+
// ['b' => 2, 'c' => 3],
61+
// ['a' => 1, 'c' => 3],
62+
// ['a' => 1, 'b' => 2],
63+
// ]
64+
```
65+
4066
### `iterable_powerset()`
4167

4268
Yield all subsets (the power set) of an iterable. Output is lazy; input is materialized into an array first.

src/combinatorics.php

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,55 @@ function iterable_allocations(int $total): Closure {
5252
};
5353
}
5454

55+
/**
56+
* Yield all combinations (subsets) of size $n from the given items (lazy).
57+
*
58+
* Preserves original keys.
59+
*
60+
* @throws InvalidArgumentException
61+
* @return Closure(iterable<array-key, mixed>, int): iterable<array-key, array<array-key, mixed>>
62+
*/
63+
function iterable_combinations(int $n): Closure {
64+
65+
if ($n < 0) {
66+
throw new InvalidArgumentException("iterable_combinations_n: n must be >= 0");
67+
}
68+
69+
return static function (iterable $items) use ($n): Generator {
70+
71+
$items = \is_array($items) ? $items : \iterator_to_array($items, true);
72+
73+
$iterable_combinations = static function (array $items, int $n) use (&$iterable_combinations): Generator {
74+
75+
if ($n === 0) {
76+
yield [];
77+
return;
78+
}
79+
80+
if ($items === [] || $n > \count($items)) {
81+
return;
82+
}
83+
84+
$k = \array_key_first($items);
85+
$v = $items[$k];
86+
unset($items[$k]);
87+
88+
/** @var array<array-key, mixed> $subset */
89+
foreach ($iterable_combinations($items, $n) as $subset) {
90+
yield $subset;
91+
}
92+
93+
/** @var array<array-key, mixed> $subset */
94+
foreach ($iterable_combinations($items, $n - 1) as $subset) {
95+
yield [$k => $v] + $subset;
96+
}
97+
};
98+
99+
yield from $iterable_combinations($items, $n);
100+
};
101+
}
102+
103+
55104
/**
56105
* Generate (lazy) permutation of iterable (consumed)
57106
*
@@ -97,7 +146,7 @@ function iterable_powerset() : Closure {
97146

98147
$items = \is_array($items) ? $items : \iterator_to_array($items, true);
99148

100-
$gen = static function (array $items) use (&$gen): Generator {
149+
$iterable_powerset = static function (array $items) use (&$iterable_powerset): Generator {
101150

102151
if ($items === []) {
103152
yield [];
@@ -109,12 +158,12 @@ function iterable_powerset() : Closure {
109158
unset($items[$k]);
110159

111160
/** @var array<array-key, mixed> $subset */
112-
foreach ($gen($items) as $subset) {
161+
foreach ($iterable_powerset($items) as $subset) {
113162
yield $subset;
114163
yield [$k => $v] + $subset;
115164
}
116165
};
117166

118-
yield from $gen($items);
167+
yield from $iterable_powerset($items);
119168
};
120169
}

tests/IterableCombinationsTest.php

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Anarchitecture\combinatorics\Tests;
6+
7+
use Generator;
8+
use InvalidArgumentException;
9+
use PHPUnit\Framework\TestCase;
10+
11+
use Anarchitecture\pipe as p;
12+
13+
use function Anarchitecture\combinatorics\iterable_combinations;
14+
15+
final class IterableCombinationsTest extends TestCase
16+
{
17+
public function testItThrowsWhenNIsNegative() : void {
18+
19+
$this->expectException(InvalidArgumentException::class);
20+
21+
iterable_combinations(-1);
22+
}
23+
24+
public function testNZeroYieldsOneEmptyCombination() : void {
25+
26+
$result = ['a' => 1, 'b' => 2]
27+
|> iterable_combinations(0)
28+
|> p\collect(...);
29+
30+
self::assertSame([[]], $result);
31+
}
32+
33+
public function testEmptyInputNZeroYieldsOneEmptyCombination() : void {
34+
35+
$result = []
36+
|> iterable_combinations(0)
37+
|> p\collect(...);
38+
39+
self::assertSame([[]], $result);
40+
}
41+
42+
public function testEmptyInputNPositiveYieldsNothing() : void {
43+
44+
$result = []
45+
|> iterable_combinations(2)
46+
|> p\collect(...);
47+
48+
self::assertSame([], $result);
49+
}
50+
51+
public function testNGreaterThanCountYieldsNothing() : void {
52+
53+
$result = ['a' => 1, 'b' => 2]
54+
|> iterable_combinations(3)
55+
|> p\collect(...);
56+
57+
self::assertSame([], $result);
58+
}
59+
60+
public function testTwoOfThreePreservesKeysAndExpectedOrder() : void {
61+
62+
$items = ['a' => 1, 'b' => 2, 'c' => 3];
63+
64+
$result = $items
65+
|> iterable_combinations(2)
66+
|> p\collect(...);
67+
68+
self::assertContains(['b' => 2, 'c' => 3], $result);
69+
self::assertContains(['a' => 1, 'c' => 3], $result);
70+
self::assertContains(['a' => 1, 'b' => 2], $result);
71+
}
72+
73+
public function testItWorksWithGeneratorInputAndPreservesKeys() : void {
74+
75+
$items = static function (): Generator {
76+
yield 'x' => 10;
77+
yield 'y' => 20;
78+
yield 'z' => 30;
79+
};
80+
81+
$result = $items()
82+
|> iterable_combinations(2)
83+
|> p\collect(...);
84+
85+
self::assertContains(['y' => 20, 'z' => 30], $result);
86+
self::assertContains(['x' => 10, 'z' => 30], $result);
87+
self::assertContains(['x' => 10, 'y' => 20], $result);
88+
}
89+
90+
public function testCountMatchesBinomialCoefficient() : void {
91+
92+
$items = ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5];
93+
94+
$result = $items
95+
|> iterable_combinations(3)
96+
|> p\collect(...);
97+
98+
self::assertCount(10, $result);
99+
}
100+
}

0 commit comments

Comments
 (0)