Matches elements in l1 and l2 using normalization functions.

    Splits the elements in each list into buckets given by the normalization
    function. If the same normalization value points to a bucket from the
    first list and a bucket from the second list, both with a single element
    we consider the elements in the list as matching if the distance between
    them is less (or equal) than the threshold.

    e.g. l1 = ['X1', 'Y1', 'Y2', 'Z5'], l2 = ['X1', 'Y3', 'Z1']
         norm_fn = lambda x: x[0]
         dist_fn = lambda e1, e2: 0 if e1 == e2 else 1
         thresh = 0

    The buckets will then be:
        l1_bucket = {'X': ['X1'], 'Y': ['Y1', 'Y2'], 'Z': ['Z5']}
        l2_bucket = {'X': ['X1'], 'Y': ['Y3'], 'Z': ['Z1']}

    For each normalized value:
        'X' -> consider 'X1' equal with 'X1' since the distance is equal with
               the thershold
        'Y' -> skip the lists since we have multiple possible matches
        'Z' -> consider 'Z1' and 'Z5' as different since the distance is
               greater than the threshold.
    Return:
        [('X1', 'X2')]

def _match_by_norm_func(l1, l2, norm_fn, dist_fn, thresh):
    """Matches elements in l1 and l2 using normalization functions.

    Splits the elements in each list into buckets given by the normalization
    function. If the same normalization value points to a bucket from the
    first list and a bucket from the second list, both with a single element
    we consider the elements in the list as matching if the distance between
    them is less (or equal) than the threshold.

    e.g. l1 = ['X1', 'Y1', 'Y2', 'Z5'], l2 = ['X1', 'Y3', 'Z1']
         norm_fn = lambda x: x[0]
         dist_fn = lambda e1, e2: 0 if e1 == e2 else 1
         thresh = 0

    The buckets will then be:
        l1_bucket = {'X': ['X1'], 'Y': ['Y1', 'Y2'], 'Z': ['Z5']}
        l2_bucket = {'X': ['X1'], 'Y': ['Y3'], 'Z': ['Z1']}

    For each normalized value:
        'X' -> consider 'X1' equal with 'X1' since the distance is equal with
               the thershold
        'Y' -> skip the lists since we have multiple possible matches
        'Z' -> consider 'Z1' and 'Z5' as different since the distance is
               greater than the threshold.
    Return:
        [('X1', 'X2')]
    """
    common = []

    l1_only_idx = set(range(len(l1)))
    l2_only_idx = set(range(len(l2)))

    buckets_l1 = _group_by_fn(enumerate(l1), lambda x: norm_fn(x[1]))
    buckets_l2 = _group_by_fn(enumerate(l2), lambda x: norm_fn(x[1]))

    for normed, l1_elements in buckets_l1.items():
        l2_elements = buckets_l2.get(normed, [])
        if not l1_elements or not l2_elements:
            continue
        _, (_, e1_first) = l1_elements[0]
        _, (_, e2_first) = l2_elements[0]
        match_is_ambiguous = not (
            len(l1_elements) == len(l2_elements) and (
                all(e2 == e2_first for (_, (_, e2)) in l2_elements) or
                all(e1 == e1_first for (_, (_, e1)) in l1_elements)
            )
        )
        if match_is_ambiguous:
            continue
        for (e1_idx, e1), (e2_idx, e2) in zip(l1_elements, l2_elements):
            if dist_fn(e1, e2) > thresh:
                continue
            l1_only_idx.remove(e1_idx)
            l2_only_idx.remove(e2_idx)
            common.append((e1, e2))

    l1_only = [l1[i] for i in l1_only_idx]
    l2_only = [l2[i] for i in l2_only_idx]

    return common, l1_only, l2_only