Python Sort in Constant Space O(1)
Asked Answered
L

2

11

I want to sort a list using Python 3 in place with no extra space.

To my knowledge, Python sorts lists either using sorted(myList), which creates a new sorted array, obviously taking O(N) extra space. Or using myList.sort() which uses Timsort which also has a worst-case space complexity of O(N).

I searched through the documentations, but didn't find any built-in functions for a constant space algorithm (selection sort, insertion sort, shell sort, heap sort, cocktail sort, etc.)

I know I can find implementations for these algorithms, but a built-in hand-optimized implementation is the best I am hoping to find.

Any suggestion is appreciated.

Loaves answered 11/6, 2020 at 17:14 Comment(11)
There is no built-in constant-space sort.Plott
Yes, looks like it. But is there any efficient workarounds other than implementing the algorithms? @PlottLoaves
can you describe more about your constraints? What is the nature / size of your data?Plott
Typically integers that can always fit in memory.Loaves
Nobody is stopping you from writing your own sorting function.Ampere
Are you sure you need it to be constant space? Are you running at the very limits of your memory? Note, Python just copies the underlying buffer of object pointers, not all the objects themselves inside the list when you do sorted.Plott
@Ampere Of course, but I believe it is worth making sure I'm not reinventing the wheel.Loaves
@Plott Yes, I know it might sound weird, but I need to minimize extra space as much as possible.Loaves
You're limiting the usefulness of the answers you're getting by being so reluctant to talk about your underlying problem (instead of about your presumed solution). That said, Juanpa is right that there is no constant-space sort built in. But if you have a whole lot of native-size machine integers (can't guess, but sounds likely), you should probably be using numpy anyway, and that does come with a heap sort (among others). numpy.org/doc/1.18/reference/generated/numpy.sort.htmlLuminal
@TimPeters the table of characteristics in the link you gave shows 'quicksort' having 0 work space while being faster on average than 'heapsort'. Wouldn't that make it a better choice or is the table incorrect in some way?Hereof
@MarkRansom, only the OP can say which tradeoffs they favor. Heapsort genuinely uses O(1) extra space, but most tables lie ;-) about quicksort, which generally requires O(log n) extra space (either implicitly due to recursion, or explicitly to maintain a stack of slice bounds remaining to be sorted). In practice that's essentially "0" to almost everyone, but can't guess about the OP here.Luminal
R
5

Your best option would be to use heap sort, which is both reasonably time efficient (with an O(n log n) time complexity) and space efficient (with a guaranteed O(1) space complexity).

While Python has a built-in module heapq that implements a binary heap, it only exports one of the two functions necessary to perform an in-place heap sort, heapify, which transforms a list into a min heap; the other necessary function, _siftup, a function that bubbles up the smaller child of a given starting position (and so on with that child's children, etc) until hitting a leaf, is left unexported.

Without _siftup, one can only perform a heap sort by popping smallest values from a heap into a new list as documented, which costs O(n) in space complexity:

A heapsort can be implemented by pushing all values onto a heap and then popping off the smallest values one at a time:

>>> def heapsort(iterable):
...     h = []
...     for value in iterable:
...         heappush(h, value)
...     return [heappop(h) for i in range(len(h))]
... 
>>> heapsort([1, 3, 5, 7, 9, 2, 4, 6, 8, 0])
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

What's more, heapify transforms a list into a min heap, which is not ideal for in-place sorting because we want to swap the larger sorted items towards the end, not the other way around. Quote from heapq's documentation:

Our pop method returns the smallest item, not the largest (called a “min heap” in textbooks; a “max heap” is more common in texts because of its suitability for in-place sorting).

Luckily, for the needs of heapq.merge in reverse mode, heapq actually also implements a max heap with other unexported functions, including _heapify_max, a max heap version of heapify, and _siftup_max, a max heap version of _siftup.

However, the heapq._siftup_max function doesn't take an end position as an argument, which is needed to limit the size of the heap to preserve the already sorted items towards the end of the list. So to work around the lack of an end position argument while maintaining an O(1) space complexity, we can pass to it a sliced memoryview of an array.array instead because you mentioned in a comment that what you have are "typically integers that can always fit in memory", which can easily be loaded as an array of type 'q' (64-bit signed integers).

But then, the heapq module would try to import from its C implementation, _heapq, if available, and _heapq._heapify_max would specifically validate the argument as a list and deny an array. The Python implementation of heapq._heapify_max doesn't have this limitation, so to import it we would need to first import _heapq ourselves and delete _heapify_max from the _heapq module object so that heapq's _heapify_max would not be overwritten when we import heapq:

import sys
import _heapq
del sys.modules['_heapq']._heapify_max
from heapq import _heapify_max, _siftup_max

So here's how you can perform heap sort with heapq's built-in functions, by first heapifying the array with _heapify_max, and then iteratively swapping the largest number at the root with the leaf at the end and using _siftup_max to sift it up to where all of its children are smaller:

def heapsort(arr):
    _heapify_max(arr)
    view = memoryview(arr)
    for size in reversed(range(1, len(arr))):
        arr[0], arr[size] = arr[size], arr[0]
        _siftup_max(view[:size], 0)

or to perform heap sort with a min heap, you would have to reverse the result in the end:

import sys
import _heapq
del sys.modules['_heapq'].heapify
from heapq import heapify, _siftup
from array import array

def heapsort(arr):
    view = memoryview(arr)
    heapify(view)
    for size in reversed(range(1, len(arr))):
        view[0], view[size] = view[size], view[0]
        _siftup(view[:size], 0)
    arr.reverse()

so that:

arr = array('q', [1, 5, 0, 7, 3, 6, 9, 8, 2, 4])
heapsort(arr)
print(arr)

outputs:

array('q', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

Demo here with max heap and min heap

Alternatively, as @KellyBundy pointed out in the comments, we can work around heapq._siftup's lack of an end position argument by using a proxy object that limits the size of the heap with an artificially set size attribute when sliced and reports that attribute as the length of the heap when its __len__ method is called.

Besides allowing any list as an input, a bonus benefit with this approach compared to memoryview is that we don't need to import _heapq first to delete heapify because we can now pass to it the actual list:

from heapq import heapify, _siftup

class heapsort:
    def __init__(self, lst):
        self.list = lst
        heapify(lst)
        for self.size in reversed(range(1, len(lst))):
            lst[0], lst[self.size] = lst[self.size], lst[0]
            _siftup(self, 0)
        lst.reverse()

    def __len__(self):
        return self.size

    def __getitem__(self, index):
        return self.list[index]

    def __setitem__(self, index, value):
        self.list[index] = value

Demo here with proxy object

Similarly, we can use a proxy object to make the heappop-based heap sort work in-place by making the proxy object merely return the item at the end of the heap instead of actually removing it when popped.

In this case, however, the C implementation of heappop would not only validate the proxy object as a list object (which can be remedied by making the proxy object inherit from list) but also access its length directly as a C attribute instead of calling our overridden __len__ method, so we do have to delete the C implementation to invoke the Python version instead.

This approach has the benefit of sticking to the publicly available API and is therefore the least prone to be affected by a change in heapq's implementation:

import sys
import _heapq
del sys.modules['_heapq'].heappop
from heapq import heapify, heappop

class heapsort:
    def __init__(self, lst):
        heapify(lst)
        self.list = lst
        for self.size in range(len(lst), 0, -1):
            lst[self.size - 1] = heappop(self)
        lst.reverse()

    def __len__(self):
        return self.size

    def __getitem__(self, index):
        return self.list[index]

    def __setitem__(self, index, value):
        self.list[index] = value

    def pop(self):
        return self.list[self.size - 1]

Demo here with heappop

Finally, note that you can always fall back to implementing heap sort from scratch in the highly unlikely event that heapq completely changes its implementation to the point that none of the approaches above would work (again, highly unlikely especially for the heappop-based solution):

def heapsort(lst):
    for child in range(1, length := len(lst)):
        while lst[child] > lst[parent := int((child - 1) / 2)]:
            lst[child], lst[child := parent] = lst[parent], lst[child]
    for size in range(length - 1, 0, -1):
        lst[child := 0], lst[size] = lst[size], lst[0]
        while (parent := child) < size:
            if (child := 2 * parent + 1) < size - 1 and lst[child] < lst[child + 1]:
                child += 1
            if child < size and lst[parent] < lst[child]:
                lst[parent], lst[child] = lst[child], lst[parent]
Rheta answered 3/1 at 7:48 Comment(19)
Ha. How did you just stumble upon this old question? I completely independently came here when looking for constant-space stdlib-supported sorting.Bolanger
A min heap isn't unsuitable, you can use it to sort in reverse and then trivially reverse the result.Bolanger
Messing with the C version to get the Python version is next-level hacking :-). With that, I guess I could even implement my idea of using a tiny proxy object that pretends to be a list (by offering len/getitem/setitem methods) but really modifies the real list. Like your memoryview, but for a list instead of an array. I had given up because of the the C heapq functions' insistence on list (they do accept list subclass instances, but that doesn't work because it doesn't use my methods...).Bolanger
Haha unbelievable you found my post independently only several hours after I posted this. What are the chances! With the recent downturn in quality new questions I turned to my custom filter of unanswered questions with highest votes looking for something interesting and stumbled upon this. You totally got me on the suitability of a min heap for in-place sort. I actually had the code of the min heap sort ready to go but decided in the last minute not to include it in my answer, fearing that it would make my answer too long for most to bother to read. But now I had to post it thanks to you. ;-DRheta
Ah, I get your proxy object idea now. Great idea. Will attempt it in a bit.Rheta
Haven't read your update yet, but just remembered that the C module doesn't export all functions, in particular not the sifts. So maybe you can heapify the list directly and then use the Python sift on the proxy. Without having to delete the C implementation's function.Bolanger
Done. Added a bonus solution with a proxy object around heappop instead. :-)Rheta
Messing with implementation details to this degree, and deoptimizing anything else in the program that relies on heapq, really isn't worth it compared to just writing a heapsort yourself. (Or a quicksort.)Maroon
And for that matter, if anything else in the program imports heapq before you mess with _heapq, your imports will end up importing the _heapq versions of those functions. (And if you try to get around this with importlib.reload, you invite weird errors like PicklingError: Can't pickle <built-in function heappop>: attribute lookup heappop on _heapq failed when something tries to pickle the original version of one of the functions you messed with.)Maroon
@Maroon A better workaround would be sys.modules.pop instead of importlib.reload. Demo hereRheta
@blhsing: That still breaks pickling, though. References to the original versions of any functions you messed with will fail to pickle, leading to really weird exceptions in parts of the program completely unrelated to your code.Maroon
@Maroon Ah yes I misunderstood your point. To work around it one can use gc.get_referrers to obtain all references to the original heappop and replace them with the new value. Demo with a Python 3 port of pyjack.replace_all_refs: ideone.com/wt2cKARheta
You can do that, and it might work, as long as you don't hit any of the cases pyjack.replace_all_refs can't actually handle (like __slots__), but you're piling on even more complexity and weird edge cases. Writing your own heapsort has way less issues.Maroon
@Maroon Good catch. Well that's what happens with constraint programming. The OP asked an admittedly unnecessarily self-constraining quesiton, and we're here offering help within those constraints.Rheta
@Maroon If issues relating to deleting existing attributes of _heapq are a concern then the first proxy object solution may be the better compromise after all.Rheta
Making heapsort itself a class ... interesting. My version, couldn't resist also giving it a go.Bolanger
@KellyBundy Very cool using closure to trim boilerplate definitions with simple assignments indeed!Rheta
I just reused that technique to import+delete a C implementation. Did you come up with that yourself, or do you have a source with more information about that?Bolanger
Cool to see your usage. Yeah I did come up with the hack myself so thanks for making me aware of more good use cases. :-)Rheta
B
1

You mentioned insertion sort. Here's a simple and fairly fast one that almost certainly only takes O(1) space:

from bisect import insort

for i in range(len(a)):
    insort(a, a.pop(i), 0, i)

This might take more than O(1) space if the list was created as a longer list, then you removed precisely as many elements so that the pop() causes a realloc, and if that realloc moves to a different memory region. Or if CPython changes its allocation strategy or you use a different Python implementation. But in any case, I think it's highly unlikely that this does more than shifting memory around inside the allocated space.

The pops/inserts do take linear time, but that's fast low-level memory moves. So while that does make the whole thing take O(n²) time, that's relatively fast O(n²), i.e., a pretty small hidden constant. The insertion points are found with binary search, so that's only O(n log n) comparisons.

With some testing (Attempt This Online!):

import random
from bisect import insort

def sort(a):
    for i in range(len(a)):
        insort(a, a.pop(i), 0, i)

for _ in range(5):
    a = random.choices(range(10000), k=10000)
    expect = sorted(a)
    sort(a)
    print(a == expect)
Bolanger answered 4/1 at 13:20 Comment(3)
True, since the OP considered those slower sorting methods to begin with it implies that a simple solution like this may be acceptable to the OP even if it does cost O(n ^ 2) in time complexity.Rheta
@Rheta Yeah, although it's odd. Wanting to avoid even the little linear space that Timsort takes sounds like they have a very large list, but then even a fast quadratic one is very slow, so that makes no sense. (Btw, Timsort's linear space matters most if there are very few different objects in the list that appear many times, so that it's actually the list's own memory that takes almost all memory, and so Timsort adds 50% extra memory. And for such cases, counting sort might be good. Oh well... speculations...)Bolanger
Agreed. If the OP has such a large list that minimizing additional memory allocation becomes critical then it should naturally imply that minimizing time complexiy would also be a top priority. Hard to speculate without more context from the OP, but it's been fun. Thanks. :-)Rheta

© 2022 - 2024 — McMap. All rights reserved.