Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revise sort.md and docstrings in sort.jl #48363

Merged
merged 11 commits into from
Jul 10, 2023
30 changes: 26 additions & 4 deletions base/sort.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1881,16 +1881,37 @@ struct MergeSortAlg <: Algorithm end
"""
PartialQuickSort{T <: Union{Integer,OrdinalRange}}

Indicate that a sorting function should use the partial quick sort
algorithm. Partial quick sort returns the smallest `k` elements sorted from smallest
to largest, finding them and sorting them using [`QuickSort`](@ref).
Indicate that a sorting function should use the partial quick sort algorithm.
Partial quick sort is like quick sort, but is only required to find and sort the
elements that would end up in `v[k]` were `v` fully sorted.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bug fix. In 1.8 we have

julia> sort(rand(100), alg=PartialQuickSort(70))
100-element Vector{Float64}:
 0.08554408610058495
 0.11471083183551722
 0.22328988544282513
 0.14111179015558972
 
 0.7224556463142775
 0.9572936633487816
 0.8133704423642836

Which
a) returns the whole array and
b) gets the kth element right, but does not sort the elements before index k

LilithHafner marked this conversation as resolved.
Show resolved Hide resolved

Characteristics:
* *not stable*: does not preserve the ordering of elements which
compare equal (e.g. "a" and "A" in a sort of letters which
ignores case).
* *in-place* in memory.
* *divide-and-conquer*: sort strategy similar to [`MergeSort`](@ref).

Note that `PartialQuickSort(k)` does not necessarily sort the whole array. For example,

```jldoctest
julia> x = rand(100);

julia> k = 50:100;

julia> s1 = sort(x; alg=QuickSort);

julia> s2 = sort(x; alg=PartialQuickSort(k));

julia> map(issorted, (s1, s2))
(true, false)

julia> map(x->issorted(x[k]), (s1, s2))
(true, true)

julia> s1[k] == s2[k]
true
```
LilithHafner marked this conversation as resolved.
Show resolved Hide resolved
"""
struct PartialQuickSort{T <: Union{Integer,OrdinalRange}} <: Algorithm
k::T
Expand Down Expand Up @@ -1925,7 +1946,8 @@ Characteristics:
* *stable*: preserves the ordering of elements which compare
equal (e.g. "a" and "A" in a sort of letters which ignores
case).
* *not in-place* in memory.
* *not in-place* in memory — requires a temporary
array of half the size of the input array.
LilithHafner marked this conversation as resolved.
Show resolved Hide resolved
* *divide-and-conquer* sort strategy.
"""
const MergeSort = MergeSortAlg()
Expand Down
109 changes: 35 additions & 74 deletions doc/src/base/sort.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Sorting and Related Functions

Julia has an extensive, flexible API for sorting and interacting with already-sorted arrays of
values. By default, Julia picks reasonable algorithms and sorts in standard ascending order:
Julia has an extensive, flexible API for sorting and interacting with already-sorted arrays
of values. By default, Julia picks reasonable algorithms and sorts in ascending order:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"standard" is a bit presumptuous imo. I hope what we chose as Julia's standard is also what the user things of as standard, but maybe they are used to seeing big things first. "ascending" is the more useful and unambiguous descriptor here.


```jldoctest
julia> sort([2,3,1])
Expand All @@ -11,7 +11,7 @@ julia> sort([2,3,1])
3
```

You can easily sort in reverse order as well:
You can sort in reverse order as well:

```jldoctest
julia> sort([2,3,1], rev=true)
Expand All @@ -21,7 +21,8 @@ julia> sort([2,3,1], rev=true)
1
```

To sort an array in-place, use the "bang" version of the sort function:
`sort` constructs a sorted copy leaving its input unchanged. Use the "bang" version of
the sort function to mutate an existing array:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sort! was not always in-place by default in 1.8, it is even less often in place by default in 1.9. The foo! convention is defined by mutation, not allocation.

1.8:

julia> @btime sort!(x) setup=(x=rand(300)) evals=1;
  8.784 μs (0 allocations: 0 bytes)

julia> @btime sort!(x; alg=QuickSort) setup=(x=rand(300)) evals=1;
  8.855 μs (0 allocations: 0 bytes)

julia> @btime sort!(x) setup=(x=rand(1:100, 300)) evals=1;
  1.363 μs (1 allocation: 896 bytes)

julia> @btime sort!(x; alg=QuickSort) setup=(x=rand(1:100, 300)) evals=1;
  1.480 μs (1 allocation: 896 bytes)

1.9:

julia> @btime sort!(x) setup=(x=rand(300)) evals=1;
  5.661 μs (1 allocation: 2.50 KiB)

julia> @btime sort!(x; alg=QuickSort) setup=(x=rand(300)) evals=1;
  8.472 μs (0 allocations: 0 bytes)

julia> @btime sort!(x) setup=(x=rand(1:100, 300)) evals=1;
  1.309 μs (1 allocation: 896 bytes)

julia> @btime sort!(x; alg=QuickSort) setup=(x=rand(1:100, 300)) evals=1;
  7.965 μs (0 allocations: 0 bytes)


```jldoctest
julia> a = [2,3,1];
Expand All @@ -35,8 +36,8 @@ julia> a
3
```

Instead of directly sorting an array, you can compute a permutation of the array's indices that
puts the array into sorted order:
Instead of directly sorting an array, you can compute a permutation of the array's
indices that puts the array into sorted order:

```julia-repl
julia> v = randn(5)
Expand Down Expand Up @@ -64,7 +65,7 @@ julia> v[p]
0.382396
```

Arrays can easily be sorted according to an arbitrary transformation of their values:
Arrays can be sorted according to an arbitrary transformation of their values:

```julia-repl
julia> sort(v, by=abs)
Expand Down Expand Up @@ -100,9 +101,12 @@ julia> sort(v, alg=InsertionSort)
0.382396
```

All the sorting and order related functions rely on a "less than" relation defining a total order
All the sorting and order related functions rely on a "less than" relation defining a
[strict partial order](https://en.wikipedia.org/wiki/Partially_ordered_set#Strict_partial_order)
LilithHafner marked this conversation as resolved.
Show resolved Hide resolved
on the values to be manipulated. The `isless` function is invoked by default, but the relation
can be specified via the `lt` keyword.
can be specified via the `lt` keyword, a function that takes two array elements and returns true
LilithHafner marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
can be specified via the `lt` keyword, a function that takes two array elements and returns true
can be specified via the `lt` or `order` keyword, a function that takes two array elements and returns true

if and only if the first argument is "less than" the second. See [Alternate orderings](@ref) for
LilithHafner marked this conversation as resolved.
Show resolved Hide resolved
more info.
LilithHafner marked this conversation as resolved.
Show resolved Hide resolved

## Sorting Functions

Expand Down Expand Up @@ -134,88 +138,45 @@ Base.Sort.partialsortperm!

LilithHafner marked this conversation as resolved.
Show resolved Hide resolved
## Sorting Algorithms

There are currently four sorting algorithms available in base Julia:
There are currently four sorting algorithms publicly available in base Julia:

* [`InsertionSort`](@ref)
* [`QuickSort`](@ref)
* [`PartialQuickSort(k)`](@ref)
* [`MergeSort`](@ref)

`InsertionSort` is an O(n²) stable sorting algorithm. It is efficient for very small `n`,
and is used internally by `QuickSort`.
By default, the `sort` family of functions uses stable sorting algorithms that are fast
on most inputs. The exact algorithm choice is an implementation detail to allow for
future performance improvements. Currently, a hybrid of `RadixSort`, `ScratchQuickSort`,
`InsertionSort`, and `CountingSort` is used based on input type, size, and composition.
Implementation details are subject to change but currently availible in the extended help
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Implementation details are subject to change but currently availible in the extended help
Implementation details are subject to change but currently available in the extended help

of `??Base.DEFAULT_STABLE` and the docstrings of internal sorting algorithms listed there.

`QuickSort` is a very fast sorting algorithm with an average-case time complexity of
O(n log n). `QuickSort` is stable, i.e., elements considered equal will remain in the same
order. Notice that O(n²) is worst-case complexity, but it gets vanishingly unlikely as the
pivot selection is randomized.

`PartialQuickSort(k::OrdinalRange)` is similar to `QuickSort`, but the output array is only
sorted in the range of `k`. For example:

```jldoctest
julia> x = rand(1:500, 100);

julia> k = 50:100;

julia> s1 = sort(x; alg=QuickSort);

julia> s2 = sort(x; alg=PartialQuickSort(k));

julia> map(issorted, (s1, s2))
(true, false)

julia> map(x->issorted(x[k]), (s1, s2))
(true, true)

julia> s1[k] == s2[k]
true
```
LilithHafner marked this conversation as resolved.
Show resolved Hide resolved

!!! compat "Julia 1.9"
The `QuickSort` and `PartialQuickSort` algorithms are stable since Julia 1.9.
LilithHafner marked this conversation as resolved.
Show resolved Hide resolved

`MergeSort` is an O(n log n) stable sorting algorithm but is not in-place – it requires a temporary
array of half the size of the input array – and is typically not quite as fast as `QuickSort`.
It is the default algorithm for non-numeric data.

The default sorting algorithms are chosen on the basis that they are fast and stable.
Usually, `QuickSort` is selected, but `InsertionSort` is preferred for small data.
You can also explicitly specify your preferred algorithm, e.g.
`sort!(v, alg=PartialQuickSort(10:20))`.

The mechanism by which Julia picks default sorting algorithms is implemented via the
`Base.Sort.defalg` function. It allows a particular algorithm to be registered as the
default in all sorting functions for specific arrays. For example, here is the default
method from [`sort.jl`](https://github.com/JuliaLang/julia/blob/master/base/sort.jl):

```julia
defalg(v::AbstractArray) = DEFAULT_STABLE
```

You may change the default behavior for specific types by defining new methods for `defalg`.
You can explicitly specify your preferred algorithm with the `alg` keyword
(e.g. `sort!(v, alg=PartialQuickSort(10:20))`) or reconfigure the default sorting algorithm
for a custom types by adding a specialized method to the `Base.Sort.defalg` function.
LilithHafner marked this conversation as resolved.
Show resolved Hide resolved
For example, [InlineStrings.jl](https://github.com/JuliaStrings/InlineStrings.jl/blob/v1.3.2/src/InlineStrings.jl#L903)
defines the following method:
```julia
Base.Sort.defalg(::AbstractArray{<:Union{SmallInlineStrings, Missing}}) = InlineStringSort
```

!!! compat "Julia 1.9"
The default sorting algorithm (returned by `Base.Sort.defalg`) is guaranteed
to be stable since Julia 1.9. Previous versions had unstable edge cases when sorting numeric arrays.
The default sorting algorithm (returned by `Base.Sort.defalg`) is guaranteed to be stable
since Julia 1.9. Previous versions had unstable edge cases when sorting numeric arrays.

## Alternate orderings

By default, `sort` and related functions use [`isless`](@ref) to compare two
elements in order to determine which should come first. The
[`Base.Order.Ordering`](@ref) abstract type provides a mechanism for defining
alternate orderings on the same set of elements. Instances of `Ordering` define
a [total order](https://en.wikipedia.org/wiki/Total_order) on a set of elements,
so that for any elements `a`, `b`, `c` the following hold:

* Exactly one of the following is true: `a` is less than `b`, `b` is less than
`a`, or `a` and `b` are equal (according to [`isequal`](@ref)).
* The relation is transitive - if `a` is less than `b` and `b` is less than `c`
then `a` is less than `c`.
By default, `sort`, `searchsorted`, and related functions use [`isless`](@ref) to compare
two elements in order to determine which should come first. The
[`Base.Order.Ordering`](@ref) abstract type provides a mechanism for defining alternate
orderings on the same set of elements. Instances of `Ordering` define a
[strict partial order](https://en.wikipedia.org/wiki/Partially_ordered_set#Strict_partial_order).
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above, strict because we are talking about < not <=. Partial because that is all that we can actually guarantee. By(abs) is an example of a strict partial order that is not a strict total order because none of lt(By(abs), -1, 1), lt(By(abs), 1, -1), and isequal(-1, 1) hold.

Weakening the guarantee of totality is a bug fix because we never honored it nor could reasonably have honored it.

Copy link
Member

@knuesel knuesel Jan 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think sorting algorithms often require something a bit stronger: a strict weak ordering. Is it not the case in Julia?

(Compared to a strict partial ordering, a strict weak ordering also requires that !lt(a,b) && !lt(b,a) together with !lt(b,c) && !lt(c,b) imply !lt(a,c) && !lt(c,a), meaning that the "non-comparability" is transitive.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If !isless(b,a) and !isless(c,b), then sort([a,b,c]) will call isless twice and return [a,b,c] without checking isless(c,a). So yes, a strict weak ordering is required. Thank you.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC it is equally powerful to state that !lt(a,b) && !lt(b,c) implies !lt(a,c).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC it is equally powerful to state that !lt(a,b) && !lt(b,c) implies !lt(a,c).

I don't think that's right, for example consider the following (invalid) order on complex numbers:

lt(a,b) = isreal(a) && isreal(b) ? a<b : abs(a)<abs(b)

Then with a, b, c = 1, im, -1:

julia> !lt(a,b) && !lt(b,c)
true

julia> !lt(a,c)
true

but

incomparable(a,b) = !lt(a,b) && !lt(b,a)

julia> incomparable(a,b) && incomparable(b,c)
true

julia> incomparable(a,c)
false

I think even if we find a way to reduce the conditions to something simpler, it's probably a bad idea: better give the conditions that are standard in the literature and that make the point clear, for example here the point is "transitiveness of incomparability", which would not be obvious if we gave a kind of reduced condition...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is not a valid counterexample because for a, b, c = 0, -1, im we have

julia> !lt(a,b) && !lt(b,c)
true

julia> !lt(a,c)
true

To prove that transitivity of not less than implies transitivity of incomparability, take arbitrary lt which satisfies !lt(a,b) && !lt(b,c) implies !lt(a,c). Take arbitrary a, b, c with incomparable(a, b) && incomparable(b, c), expand to (!lt(a,b) && !lt(b,a)) && (!lt(b,c) && !lt(c,b)), re-arrange to (!lt(a,b) && !lt(b,c)) && (!lt(c,b) && !lt(b,a)), and apply transitivity of not less than to each half to get !lt(a,c) && !lt(c,a) which is incomparable(a,c). Because a, b, and c were arbitrary that satisfy incomparable(a, b) && incomparable(b, c), we know lt satisfies transitivity of incomparability. Because lt was and arbitrary function with !lt(a,b) && !lt(b,c) implies !lt(a,c), we have that transitivity of not less than implies transitivity of incomparability.

The converse is more difficult. Take arbitrary lt which satisfies transitivity and transitivity of incomparability. Take arbitrary a, b, c with !lt(a, b) && !lt(b, c). Assume to reach a contradiction that lt(a,c). By antisymetry, we have !lt(c,a). If lt(b, a), then we could apply transitivity to claim lt(b,c) which is false, and similarly if lt(c,b) then we could apply transitivity to claim lt(c,a) which is also false. Consequuently, we have !lt(b,a) and !lt(c,b). We can now apply transitivity of incomaprability to claim a and c are incomparable which contradicts lt(a,c). Therefore our original assumption was false so !lt(a,c). Because a, b, and c were arbitrary with !lt(a, b) && !lt(b, c), we know that lt satisfies transitivity of not less than. Because lt was arbitrary with transitivity and transitivity of incomparability, we know that transitivity together with transitivity of incomparability implies transitivity of not less than.

In conclusion, if we already have transitivity (which we do) "!lt(a,b) && !lt(b,c) implies !lt(a,c)" and "incomparable(a,b) && incomarable(b,c) implies incomarable(a,c)" are mathematically interchangeable.

It is equally valid to think of the criterion as "transitivity of incomparability" and "transitivity of not less than", though switching back and forth rigorously is nontrivial.

That said, I agree with you that incomparability is a better criterion to pick for intuition.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the correction, my counterexample was wrong indeed. And I understand now that you were talking of equivalence in the context where the other conditions (in practice, transitivity) are satisfied.

To be a strict partial order, for any elements `a`, `b`, `c` the following hold:

* if `a == b`, then `lt(a, b) == false`;
* `lt(a, b) && lt(b, a) == false`; and
* if `lt(a, b) && lt(b, c) == true`, then `lt(a, c) == true`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is == used in the sorting algorithms? I'm a bit surprised to see it here while the documentation of isless mentions isequal:

Test whether x is less than y, according to a fixed total order (defined together with isequal).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It turns out that neither == nor isequal are used in sorting to compare elements.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then maybe the first point should read like the following?

  • lt(a, a) == false

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I deleted it entirely because it is now a strict subset of what was point two.

Copy link
Member

@knuesel knuesel Jan 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed in #48387 it might be good to leave the irreflexivity condition even if it's implied by the asymmetric condition (to follow the usual definition and help the reader get an important point)...


The [`Base.Order.lt`](@ref) function works as a generalization of `isless` to
test whether `a` is less than `b` according to a given order.
LilithHafner marked this conversation as resolved.
Show resolved Hide resolved
Expand Down