Return a minimum number of ranges from a collection of ranges
I’ll try to go over your five points. Keep in mind that I’m not your interviewer and my interpretation might not be what they really meant.


While loops are not bad per se, but they are not the preferred way to iterate over a datastructure. I started solving the exercise also using a while loop to reduce a partially reduced list until reaching a fixed point. But I realized that, once the original list is sorted, you only need a single pass (more on that later). This is why solving this problem with a while loop is a weird thing to do.
Compare these toy examples:

counts = []
running_total = 0
for amount, should_separate in some_input:
    running_total += amount
    if should_separate:
        counts.append(running_total)
        count = 0


and

counts = [0]
for amount, should_separate in some_input:
    counts[-1] = counts[-1] + amount
    if should_separate:
        counts.append(0)


even though the second one is shorter, the core of the computation (counts[-1] = counts[-1] + amount) loose its readability due to the indexing. Plus, using one less variable, you lose the opportunity to document the code using an expressive name.
I’m assuming Python 2 here, as in Python 3 range produces a range object, pretty much the same as xrange in Python 2. So yes, use xrange instead, you’ll save both on memory and time as checking if an element is in a list is \$\mathcal{O}(n)\$. But for such simple checks where you already have the bounds up-front, you can use the shorthand comparisons: if low - 1 &lt;= value &lt;= high + 1:. Especially given the fact that:
Once the list is sorted, only two consecutive ranges can be part of the same "mega"range. If they are not, then you know your output contains another "mega"range that starts at this moment because no other element will start before this new range. This also simplifies the check for range inclusion as you only need to check for the lower bound of the current element to be in the current range; and since items are sorted, you know that that lower bound won't be lower than the lower bound of the current range either.
The ….pop(0) will make the for loop a no-op when initial length is 1, so the loop will stop itself on the next iteration, so the len(…) == 1 check is really redundant with the rest of your code. Drop code that serve no purpose as it is error-prone.


Others remarks I can make:


Why generate an exception on empty input? Simply return an empty list as output: no range in, no range out, I don't see any problem. You won't even enter the while so return an empty list anyway, again, this code is somewhat redundant with the rest.
Why modify the original list by .sort()ing in place? Since you return a different result, you should not modify the input.




Proposed improvements:

def zip_range_reducer(ranges):
    if not ranges:
        return []

    ranges = sorted(ranges)
    reduced = []

    lowest, highest = ranges[0]
    for low, high in ranges:
        # Since we sorted ranges, we know of overlapping
        # ranges by this only check.
        if low &lt;= highest + 1:
            lowest = min(lowest, low)
            highest = max(highest, high)
        else:
            reduced.append([lowest, highest])
            lowest = low
            highest = high
    reduced.append([lowest, highest])

    return reduced


Another interesting way to put this code and remove the boilerplate (empty check, first item extraction) is to use an helper generator function that will trigger interesting behaviours of how iteration works in Python:

def zip_range_reducer(ranges):
    ranges = iter(sorted(ranges))
    return list(_zip_range_helper(ranges))


def _zip_range_helper(sorted_ranges_iterator):
    lowest, highest = next(sorted_ranges_iterator)

    for low, high in sorted_ranges_iterator:
        # Since we have sorted ranges, we know
        # of overlaping ranges by this only check.
        if low &lt;= highest + 1:
            lowest = min(lowest, low)
            highest = max(highest, high)
        else:
            yield [lowest, highest]
            lowest = low
            highest = high

    yield [lowest, highest]


Here the empty check is implicit by the fact that next will raise StopIteration when ranges is empty. But this StopIteration will be interpreted by list() as "there is no more elements to put in here" and an empty list will be returned. Otherwise the first element will be consumed to populate lowest and highest; and each element of the resulting list will be yielded one by one.