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
Improved Binary Search (API Change Proposal) #81
Comments
Can yo provide more details? Like how that isn't covered by sorting (which has to be done anyway for a binary search) and then using
I don't see how this is necessary for deduplication.
But so does partition_point? |
Thanks for your perceptive questions. Yes, it is true that Vec::dedup will, in principle, solve the outlined example application. In practice, my proposal will often make it more efficient. For example, when we want to remove only duplicates of a specific value (possibly from lots of lists). Then it is wasteful to search linearly all of them for all the duplicates. We might not even want to remove the other duplicates at all. Different orders may not be necessary for deduplication but when you already have a sort of a very long list, you do not want to have to reverse it just because your binary search can cope with only one direction. Also, there are many other applications for a properly implemented general binary search. Yes, I acknowledge that the |
Or you can use
|
Yes, or just use a complement index: |
I'll cc rust-lang/rfcs#2184, where something like this has been discussed (inspired by https://en.cppreference.com/w/cpp/algorithm/equal_range) for almost 5 years now. I think the |
Yes, if the existing As to the functionality, there is an argument for keeping that simple too, as per my suggestion. That is, using implied PartialOrd instead of passing in a full blown comparator closure. After all, as has been rightly pointed out, the PartialOrd is already an improvement in generality on the Ord of the existing Regards the above discussion about working on both ascending and descending orders: yes, that is to some extent optional but I have included it because it is practically a zero cost exercise ( a single test of a bool ). I note that if you accidentally use the wrong order, the existing I am going to detect the order automatically, for convenience and safety. |
I'm not on libs-api, but I'd be very surprised if a
See also how there's no What you might consider instead -- as a separate ACP! -- would be something to make it easier to use For example, if there was a method to go
|
See my above edit. PS: there is bool on my implementations of |
You could go for a builder style API. Hand-wavy and without real names... a_slice.binary_finder().first()
a_slice.binary_finder().last()
a_slice.binary_finder().any()
a_slice.binary_finder().range() |
Your suggestion can be used to unite my proposal, let us call it All of those fields can be trivially obtained from my |
imho it'd be useful to be able to binary search on non-slice things, e.g. a custom data type that is indexable, but data is not contiguous in memory. |
I am offering also my |
It works like this (self is a slice of any type &[T]): /// Binary search of an index sorted slice in ascending or descending order.
/// Like `binsearch` but using indirection via sort index `idx`.
fn binsearch_indexed(self, idx:&[usize], val:&T) -> Range<usize>
where T: PartialOrd; Where idx is obtained by: /// Makes a sort index for self, using key generating closure `keyfn`
fn keyindex(self, keyfn:fn(&T)->f64, ascending:bool) -> Vec<usize>; The beauty of it is that T can be any arbitrarily complex user defined type but as long as you supply the When calling |
that's nice, but it doesn't actually do what imho is needed, which is more like: // TODO: pick better name
// TODO: add versions with u32, u64, and u128 instead of usize -- needed
// because you might be searching on disk or over the network rather
// than in-memory and usize can be too small.
// TODO: switch to using Try trait rather than Result.
// Note how this needs no slice to be constructed in order to
// search, not everything that can be searched fits in memory or
// wants to pay the up-front cost of building a slice, e.g. if you're
// searching in something with 400 trillion entries, nearly no one
// has that much memory to construct a slice of, nor wants to
// spend hours filling memory with indexes before it can conduct
// a simple <1s search.
pub fn try_bin_search_algo<E>(mut search_in: Range<usize>, mut cmp_at: impl FnMut(usize) -> Result<Ordering, E>) -> Result<Result<usize, usize>, E> {
// hope i got this right
while !search_in.is_empty() {
let mid = (search_in.end - search_in.start) / 2 + search_in.start;
match cmp_at(mid)? {
Ordering::Less => search_in.end = mid,
Ordering::Greater => search_in.start = mid + 1,
Ordering::Equal => return Ok(Ok(mid)),
}
}
Ok(Err(search_in.start))
} |
So, you envisage combining this with a random disk/internet access? I have not thought that far. PS. I think you also need to check that the sought value is actually inside the range, i.e be more careful about the Err value. |
yes, or something else other than just an index-into-slice-and-compare, e.g.: // compute sqrt(a) via binary search.
try_bin_search_algo(0..(1 << 32), |v| Ok(if Some(v2) = v.checked_mul(v) { a.cmp(&v2) } else { Ordering::Less }))
probably... |
Yes, like I said, I can subsume the |
It seems to me that there is enough design space left that this isn't ready for the standard library yet. |
This makes me think that Rust needs something in the standard library like C++'s Maybe |
Just to note, this isn't always possible. E.g., in string interners, it's really nice to store type Span = Range<u32>;
type Symbol = NonZeroU32;
pub struct Interner<S> {
hasher: S,
string_to_symbol: HashSet<Symbol, ()>,
symbol_to_span: Vec<Span>,
span_to_string: String,
} with then hash/comparison being of This is just what I'm the most familiar with, but this pattern of |
I struggle to understand this. |
See https://github.com/CAD97/strena as an example. The HashMap |
Then why not just do your binary search over the symbols? |
Because the symbols aren't sorted intrinsically. They're indexes, sorted by the string which they reference, which to recover you need to provide outside data. ex.: impl Interner {
pub fn get(&self, s: &str) -> Option<Symbol> {
let string_to_symbol: &[Symbol] = &self.string_to_symbol;
let symbol_to_span: &[Span] = &self.symbol_to_span;
let span_to_string: &str = &self.span_to_string;
let range = binary_find(0..string_to_symbol.len(), |ix| {
span_to_string[symbol_to_span[string_to_symbol[ix]].clone()].cmp(s)
});
Some(range)
.filter(|range| !range.is_empty())
.map(|range| string_to_symbol[range.start])
}
} adjusted implementationpub fn binary_find(range: Range<usize>, cmp: impl Fn(usize) -> Ordering) -> Range<usize> {
use Ordering::*;
// `last` finds the exclusive end of the range of the matching items
let last = |ix: usize| -> usize {
(ix + 1..range.end)
.find(|&i| cmp(i) == Greater)
.unwrap_or(range.end)
};
// `first` finds the inclusive start of the range of the matching items
let first = |ix: usize| -> usize {
(range.start..ix)
.rfind(|&i| cmp(i) == Less)
.map_or(range.start, |i| i + 1)
};
// Checking for errors, special cases and order
if range.is_empty() {
return range;
};
match (cmp(range.start), cmp(range.end - 1)) {
// searchee is before range.start
(Greater, _) => return range.start..range.start,
// searchee is beyond range.end
(_, Less) => return range.end..range.end,
// search range is all equal
(Equal, Equal) => return range,
// searchee at range.start
(Equal, _) => return range.start..last(range.start),
// searchee at range.end
(_, Equal) => return first(range.end - 1)..range.end,
_ => (),
}
// Clean binary search
let mut hi = range.end - 1; // initial high index
let mut lo = range.start; // initial low index
loop {
let mid = lo + (hi - lo) / 2; // binary chop here with truncation
if mid > lo {
// still some range left
match cmp(mid) {
// still too low
Less => lo = mid,
// still too high
Greater => hi = mid,
// found it!
Equal => return first(mid)..last(mid),
}
} else {
// interval is exhausted, searchee not found
return hi..hi;
};
}
} |
Yes but now every cmp closure has to check for the descending order on every invocation, which is not practical. If you can solve that problem, I will be happy to adopt your proposal. Also, we can not return an empty range when the searchee is outside it, as the empty range is reserved for indicating the insert order of a missing searchee within the range. However, this is not such a problem and if you consult my latest code, you will see that I have solved it already by returning the range limit in the direction of the outlying index as I like the probe closure being explicit. I think it makes the code much easier to follow than burying it, the searchee and everything, inside the opaque captured environment of cmp. This seems just as bad as any global variables ever were. Any reason why we can not have both |
No, because you just prestore that information in the closure the same way you're doing currently. Or use If anything, this is better, because with this version you can pass in a static order whereas your code always does a dynamic comparison ordering.
If I return Though that does mean the
I'm going to turn this question around. What benefit is there to having a separate index and compare step? Having it in one step saves a (potentially) dynamic call. Rust likes its stateful closures; in fact, this should probably even take Having a unified compare does have benefits, in that it (potentially) lowers monomorphization pressure, but more importantly, your API requires the key to be sized, copy, Also, always indexing in We probably need to get the bounds by continuing the binary search as well, rather than falling back to a linear probe. Or at a minimum use some sort of exponential probe to avoid the worst case performance of |
AIUI, given a sorted two-element slice
and these are all possible outcomes of a binary search on a sorted list. Where do we disagree? I suppose, if this is a subslice, insertion at the edges may not maintain the entire slice being sorted. However, this is still a determinable output condition, and there's no reason to assume that binary searching a subslice that your item is not in will give you a position that keeps the superslice still sorted; the correct answer is to say inserting at the edge would keep the input slice range sorted. I think the more important distinction, though, is that "insertion at an element" is meaningless. You insert between elements. The range When I say "insertion position is before the first element," I do mean the position directly before the first element. I'm also going to repeat my counterquery for visibility:
Because the benefit of not is that a separate index step really wants a signature of |
Ah, I see what is going on! I was referring to the subscripts of the input range, as supplied. Assuming that any insertion process would on seing On the count of having an all-in-one comparator, I think you persuade me about the benefits. I accept that the current functions/methods implicitly assume that everything is in ascending order but that seems to me an unwarranted assumption. In any case, I would like to relax it. I think that having both orders automatically recognised and correctly acted upon has benefits in generality and code robustness. Presumably, this will now have to be done externally to Thank you for your clarifications, I find them very useful. |
No, one call is sufficient; just do a
For intrinsic ordering, I might agree with you. But when injecting a comparator/ordering, it is absolutely reasonable to assume that the ordering is the same that the ordered input is ordered by.
... no? Given |
Thanks to your excellent suggestions we are making progress. I have now implemented all of them apart from replacing also the linear search of matching items with binary search. In a few extreme cases it will be much quicker but also, in many usual cases where there are only a few matching items, it will be slower. If there is a feeling that it is worth it, I can certainly do that: basically a recursive call with a comparator that looks at two adjacent items to find the edge cases. The biggest latest improvement I made is generic search Range, so that the user can choose what index type suits best: use core::ops::Range;
#[test]
fn roottest() {
let num:f64 = 1234567890.0;
let root:f64 = 5.3;
let range = broot(num,root);
println!("{} to the power of {YL}1/{}{UN}\nbinary_find:\t{} error: {}\npowf:\t\t{} error: {}",
num.yl(), root, range.start.gr(), (num-range.start.powf(5.3)).gr(),
num.powf(1./root).gr(),(num-num.powf(1./root).powf(root)).gr());
}
///num.powf(1./root) using binary search
fn broot(num:f64,root:f64) -> Range<f64> {
binary_find(1_f64..num,
|&probe| {
if probe.powf(root) < num { Less }
else if probe.powf(root) > num { Greater }
else { Equal }})
}
running 1 test
1234567890 to the power of 1/5.3
binary_find: 51.92543988913025 error: -0.000000476837158203125
powf: 51.92543988913026 error: -0.0000011920928955078125
test roottest ... ok |
In std, the range index type would probably be bound by However, I'm bowing out on design here. I have strong opinions on the proper dependency injection API, but less so on the rest of the details. (Though I do want to note that in parallel to Footnotes
|
more than just trait StepHalfway: Step {
/// returns `(self + other) / 2` but without overflowing.
fn halfway(&self, other: &Self) -> Self;
} |
also it shouldn't be limited to just integer types, e.g. binary searching a list of SHA1 or SHA256 hashes would be very useful for git stuff. (admittedly that could be done with u128 indexes searching in a ordered list on disk, but >128-bit indexes would still be useful). other examples: searching for the 10^600-th prime by binary searching on PrimePi, this would use |
|
As a meta-comment, this thread feels very much like what I'd expect in a crate design discussion. That's not saying that these ideas are bad, but the super-general version of all this feels like something that belongs in a crate, not in Especially if it's already implemented in a crate, then people can just use that crate today, as opposed to the many months (at least) it'll take before they could use it in |
I must be missing something in this discussion of This is the signature: pub fn binary_find<T,F>(range: Range<T>,cmpr: F ) -> Range<T>
where T: PartialOrd+Copy+Add<Output=T>+Sub<Output=T>+Div<Output=T>+From::<u8>,
F: Fn(&T)->Ordering { Also, I miss the purpose of making the comparator closure FnMut rather than just Fn. Could anyone who thinks it is needed please give a simple example of how it might be actually useful? Would you want to change what you are looking for in the middle of the search? I think not. |
some simple reasons to have it be |
(Reformatted to be legible. Please, use rustfmt, at least if you're posting code on rust-lang repositories. Maintaining a consistent style helps code to be quickly understood.) This signature is still vastly more complicated than any other bound in the standard library APIs. And it's not even what you say it is! You're not requiring "add one and divide by two", you're requiring
That said, though, binary searching over a dataset with more elements (which is sorted in some fashion!) than the address space is an extremely niche use case. And will likely want to switch strategies when moving from impossibly sparse to some reasonable scale to work on. |
Your formatting has actually destroyed my signature snippet. It mismatched the trait arguments and chopped off the end of the bounds. No wonder you have difficulties reading it. Are we talking about the same thing here? My type T is the type of the index, not of the data. |
I second the request to please use rustfmt in these discussions. I've had some (slight) difficulties reading these code snippets because I'm so used to reading Rust code that is formatted using it. |
OK, here it is: pub fn binary_find<T, F>(range: Range<T>, cmpr: &mut F) -> Range<T>
where
T: PartialOrd + Copy + Add<Output = T> + Sub<Output = T> + Div<Output = T> + From<u8>,
F: FnMut(&T) -> Ordering, |
and here is the source listing of the latest version (Edit: made the comparator FnMut) /// General Binary Search
/// Search within the specified Range<T>, which is always ascending.
/// The (indexing) range values can be of any generic type T satisfying the listed bounds.
/// Typically usize for searching efficiently in-memory, u128 for searching whole disks or internet,
/// or f64 for solving equations.
/// Comparator closure `cmpr` is comparing against a search item captured from its environment.
/// The sort order reflected by `cmpr` can be either ascending or descending (increasing/decreasing).
/// When item is in order before range.start, empty range range.start..range.start is returned.
/// When item is in order after range.end-1, range.end..range.end is returned.
/// Normally binary_find returns Range of all the consecutive values
/// that are PartiallyEqual to the sought item.
/// When item is not found, then the returned range will be empty and
/// its start (and end) will be the sort position where the item can be inserted.
pub fn binary_find<T, F>(range: Range<T>, cmpr: &mut F) -> Range<T>
where
T: PartialOrd + Copy + Add<Output = T> + Sub<Output = T> + Div<Output = T> + From<u8>,
F: FnMut(&T) -> Ordering,
{
let one = T::from(1); // generic one
let two = T::from(2); // generic two
let lasti = range.end - one;
// Closure to find the last matching item in direction up/down from idx
// or till limit is reached. Equality is defined by `cmpr`.
let scan = |idx: &T, limit: &T, cpr: &mut F, up: bool| -> T {
let mut probe = *idx;
let step = |p: &mut T| if up { *p = *p + one } else { *p = *p - one };
step(&mut probe);
while cpr(&probe) == Equal {
// exits at the first non-equal item
if probe == *limit {
step(&mut probe);
break;
};
step(&mut probe);
}
if up {
probe
} else {
probe + one
} // into Range limit
};
// Checking end cases
if range.is_empty() {
return range;
};
match cmpr(&range.start) {
Greater => {
return range.start..range.start;
} // item is before the range
Equal => {
if cmpr(&range.end) == Equal {
return range;
}; // all in range match
return range.start..scan(&range.start, &lasti, cmpr, true);
}
_ => (),
};
match cmpr(&lasti) {
Less => {
return range.end..range.end;
} // item is after the range
Equal => {
return scan(&lasti, &range.start, cmpr, false)..range.end;
}
_ => (),
};
// Binary search
let mut hi = lasti; // initial high index
let mut lo = range.start; // initial low index
loop {
let mid = lo + (hi - lo) / two; // binary chop here with truncation
if mid > lo {
// still some range left
match cmpr(&mid) {
Less => lo = mid,
Greater => hi = mid,
Equal => {
return scan(&mid, &range.start, cmpr, false)..scan(&mid, &lasti, cmpr, true)
}
}
} else {
return hi..hi;
}; // interval is exhausted, val not found
}
} |
once you found one equal item, rather than using a linear search in |
I've seen exponential search used for this, too — still O(log n) in the worst case, but clearly better (or at least fewer comparisons) than binary search for a small number of equal elements. |
Great advice, thanks! |
I have just realised that I can get even better results by reusing the last bounds of the already performed binary search! |
As I've been saying, that trait is
let distance = Step::steps_between(&start, &end)?;
let mid = Step::forward_unchecked(start, distance / 2); With pub fn binary_find<K: Step>(range: Range<K>, cmp: impl Fn(&K) -> Ordering) -> Range<K> {
let trans = |i: usize| Step::forward(range.start.clone()).unwrap();
if let Some(length) = Step::distance_between(&range.start, &range.end) {
let out = your_binary_find(0..length, |i| cmp(&trans(i));
trans(out.start)..trans(out.end)
} else {
range
}
} (Sorry about the broken formatting in my last post I did it on a phone without checking the output and just before I went to sleep.) |
That sounds good. Do you have any clearer idea, how it actually obtains the Here is my current version which implements everything up to here, except that Edit: I can find only |
so if you want to search in the range |
I keep saying the same things.
e.g. smth like untested fn split(x: i128) -> (i64, u64) {
(((x as u128) >> 64) as u64 as i64, x as u128 as u64)
}
fn unsplit(hi: i64, lo: u64) -> i128 {
lo as i128 & ((hi as u64 as u128) << 64) as i128
}
fn search(range: Range<i128>, cmp: impl Fn(i128) -> Ordering) -> Range<i128> {
let cmp_hi = |&hi| cmp(unsplit(hi, 0));
let hi = binary_find(split(range.start).0..=split(range.end).0);
let cmp_lo = |hi| |&lo| cmp(unsplit(hi, lo));
let lo = binary_find(0..=u64::MAX, cmp_lo(hi.start)).start..binary_find(0..=u64::MAX, cmp_lo(hi.end)).end;
unsplit(hi.start, lo.start)..unsplit(hi.end, lo.end)
} (requires |
that has several issues:
imho if I'd have to go through all that just to binary search on Therefore, I think we need |
I will second that. I have gone into quite a lot of trouble to avoid any such access, even to
Which is what I have done and then generalised it to all enumerable range types T. The only benefit I see to Step is that it is somehow 'more idiomatic'. However, I always took 'idiomatic' as equivalent to 'simpler' and/or 'more general', otherwise there is no point in it. My explicit trait bounds fulfil the purpose right now, in stable Rust.
Yes, I agree, this would persuade me. Probably some other trait, as Step is embroiled in long discussions since 2017 and still marked as 'fly-by-night' only. Might it work for an fn ratio(&self, end: &Self, r: usize) -> Self |
It is clear that we are at this point no longer discussing an API for std but instead the implementation of indxvec. I'm muting this. We already have (The implementation for i128 is an example of the tiered search technique, not a proposal of how to do it. It would be not too difficult to fix to avoid the mentioned deficiencies.) |
I have now completed the general design & implementation, satisfying all the requirements mentioned in these discussions. I commend it to your attention and adoption. It is in the trait It is true that it now applies to Range rather than to a slice, thus formally justifying the closure of this proposal. However, I think you would do well to adopt it in some form or another. I am always ready to answer any more questions. It is not using Step, which is not ready and would not, in any case, enable solving equations with Also, the Search trait is ready 'off the shelf' for implementing other searches with divisions not limited to 2. |
Improved Binary Search
The problem
Quoting from: primitive.slice:
"If there are multiple matches, then any one of the matches could be returned. The index is chosen deterministically, but is subject to change in future versions of Rust. If the value is not found then Result::Err is returned, containing the index where a matching element could be inserted while maintaining sorted order."
Some of these issues are since to some extent addressed by partition_point with a different functionality.
Motivation, use-cases
Solution
The proposed solution has the following new benefits:
binary_search
varieties use Err mechanism to deliver a genuine sort-order result, which is arguably not quite right. This is corrected. No errors are returned.Links and related work
I have implemented the proposal as function
binary_find
. It can be presently seen in my crate indxvec.The text was updated successfully, but these errors were encountered: