Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions Doc/library/collections.rst
Original file line number Diff line number Diff line change
Expand Up @@ -367,9 +367,11 @@ Several mathematical operations are provided for combining :class:`Counter`
objects to produce multisets (counters that have counts greater than zero).
Addition and subtraction combine counters by adding or subtracting the counts
of corresponding elements. Intersection and union return the minimum and
maximum of corresponding counts. Equality and inclusion compare
corresponding counts. Each operation can accept inputs with signed
counts, but the output will exclude results with counts of zero or less.
maximum of corresponding counts. Symmetric difference returns the difference
between the maximum and minimum of the corresponding counts. Equality and
inclusion compare corresponding counts. Each operation can accept inputs
with signed counts, but the output will exclude results with counts of zero
or below.

.. doctest::

Expand All @@ -383,6 +385,8 @@ counts, but the output will exclude results with counts of zero or less.
Counter({'a': 1, 'b': 1})
>>> c | d # union: max(c[x], d[x])
Counter({'a': 3, 'b': 2})
>>> c ^ d # max(c[x], d[x]) - min(c[x], d[x])
Counter({'a': 2, 'b': 1})
>>> c == d # equality: c[x] == d[x]
False
>>> c <= d # inclusion: c[x] <= d[x]
Expand All @@ -400,6 +404,9 @@ or subtracting from an empty counter.
.. versionadded:: 3.3
Added support for unary plus, unary minus, and in-place multiset operations.

.. versionadded:: next
Added support for the symmetric difference multiset operation, ``c ^ d``.

.. note::

Counters were primarily designed to work with positive integers to represent
Expand Down
9 changes: 9 additions & 0 deletions Doc/whatsnew/3.15.rst
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,15 @@ New modules
Improved modules
================

collections
-----------

* Added :meth:`!collections.Counter.__xor__` and
:meth:`!collections.Counter.__ixor__` to compute the symmetric difference
between :class:`~collections.Counter` objects.
(Contributed by Raymond Hettinger in :gh:`138682`.)


dbm
---

Expand Down
46 changes: 46 additions & 0 deletions Lib/collections/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,7 @@ def __repr__(self):
# set(cp - cq) == sp - sq
# set(cp | cq) == sp | sq
# set(cp & cq) == sp & sq
# set(cp ^ cq) == sp ^ sq

def __eq__(self, other):
'True if all counts agree. Missing counts are treated as zero.'
Expand Down Expand Up @@ -908,6 +909,35 @@ def __and__(self, other):
result[elem] = newcount
return result

def __xor__(self, other):
'''Symmetric difference. Absolute value of count differences.

The symmetric difference p ^ q is equivalent to:

(p - q) | (q - p).

For each element, symmetric difference gives the same result as:

max(p[elem], q[elem]) - min(p[elem], q[elem])

>>> Counter(a=5, b=3, c=2, d=2) ^ Counter(a=1, b=3, c=5, e=1)
Counter({'a': 4, 'c': 3, 'd': 2, 'e': 1})

'''
if not isinstance(other, Counter):
return NotImplemented
result = Counter()
for elem, count in self.items():
newcount = abs(count - other[elem])
if newcount:
result[elem] = newcount
for elem, count in other.items():
if elem not in self:
newcount = abs(count)
if newcount:
result[elem] = newcount
return result

def __pos__(self):
'Adds an empty counter, effectively stripping negative and zero counts'
result = Counter()
Expand Down Expand Up @@ -990,6 +1020,22 @@ def __iand__(self, other):
self[elem] = other_count
return self._keep_positive()

def __ixor__(self, other):
'''Inplace symmetric difference. Absolute value of count differences.

>>> c = Counter(a=5, b=3, c=2, d=2)
>>> c ^= Counter(a=1, b=3, c=5, e=1)
>>> c
Counter({'a': 4, 'c': 3, 'd': 2, 'e': 1})

'''
for elem, count in self.items():
self[elem] = abs(count - other[elem])
for elem, count in other.items():
if elem not in self:
self[elem] = abs(count)
return self._keep_positive()


########################################################################
### ChainMap
Expand Down
43 changes: 43 additions & 0 deletions Lib/test/test_collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -2140,6 +2140,7 @@ def correctly_ordered(seq):
self.assertTrue(correctly_ordered(p - q))
self.assertTrue(correctly_ordered(p | q))
self.assertTrue(correctly_ordered(p & q))
self.assertTrue(correctly_ordered(p ^ q))

p, q = Counter(ps), Counter(qs)
p += q
Expand All @@ -2157,6 +2158,10 @@ def correctly_ordered(seq):
p &= q
self.assertTrue(correctly_ordered(p))

p, q = Counter(ps), Counter(qs)
p ^= q
self.assertTrue(correctly_ordered(p))

p, q = Counter(ps), Counter(qs)
p.update(q)
self.assertTrue(correctly_ordered(p))
Expand Down Expand Up @@ -2239,6 +2244,7 @@ def test_multiset_operations(self):
(Counter.__sub__, lambda x, y: max(0, x-y)),
(Counter.__or__, lambda x, y: max(0,x,y)),
(Counter.__and__, lambda x, y: max(0, min(x,y))),
(Counter.__xor__, lambda x, y: max(0, max(x,y) - min(x,y))),
]:
result = counterop(p, q)
for x in elements:
Expand All @@ -2256,6 +2262,7 @@ def test_multiset_operations(self):
(Counter.__sub__, set.__sub__),
(Counter.__or__, set.__or__),
(Counter.__and__, set.__and__),
(Counter.__xor__, set.__xor__),
]:
counter_result = counterop(p, q)
set_result = setop(set(p.elements()), set(q.elements()))
Expand All @@ -2274,6 +2281,7 @@ def test_inplace_operations(self):
(Counter.__isub__, Counter.__sub__),
(Counter.__ior__, Counter.__or__),
(Counter.__iand__, Counter.__and__),
(Counter.__ixor__, Counter.__xor__),
]:
c = p.copy()
c_id = id(c)
Expand Down Expand Up @@ -2349,6 +2357,7 @@ def test_multiset_operations_equivalent_to_set_operations(self):
self.assertEqual(set(cp - cq), sp - sq)
self.assertEqual(set(cp | cq), sp | sq)
self.assertEqual(set(cp & cq), sp & sq)
self.assertEqual(set(cp ^ cq), sp ^ sq)
self.assertEqual(cp == cq, sp == sq)
self.assertEqual(cp != cq, sp != sq)
self.assertEqual(cp <= cq, sp <= sq)
Expand Down Expand Up @@ -2376,6 +2385,40 @@ def test_gt(self):
self.assertTrue(Counter(a=3, b=2, c=0) > Counter('aab'))
self.assertFalse(Counter(a=2, b=1, c=0) > Counter('aab'))

def test_symmetric_difference(self):
pop = (-4, -3, -2, -1, 0, 1, 2, 3, 4)

for a, b1, b2, c in product(pop, repeat=4):
p = Counter(a=a, b=b1)
q = Counter(b=b2, c=c)
r = p ^ q

# Elementwise invariants
for k in ('a', 'b', 'c'):
self.assertEqual(r[k], max(p[k], q[k]) - min(p[k], q[k]))
self.assertEqual(r[k], abs(p[k] - q[k]))

# Invariant for all positive, negative, and zero counts
self.assertEqual(r, (p - q) | (q - p))

# Invariant for non-negative counts
if a >= 0 and b1 >= 0 and b2 >= 0 and c >= 0:
self.assertEqual(r, (p | q) - (p & q))

# Zeros and negatives eliminated
self.assertTrue(all(value > 0 for value in r.values()))

# Output preserves input order: p first and then q
keys = list(p) + list(q)
indices = [keys.index(k) for k in r]
self.assertEqual(indices, sorted(indices))

# Inplace operation matches binary operation
pp = Counter(p)
qq = Counter(q)
pp ^= qq
assert pp == r


def load_tests(loader, tests, pattern):
tests.addTest(doctest.DocTestSuite(collections))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added symmetric difference support to :class:`collections.Counter` objects.