GH-49677 [Python][C++][Compute] Add search sorted compute kernel (#49679)

### Rationale for this change
Add the implemenation of the search sorted compute kernel based on the numpy function: https://numpy.org/doc/stable/reference/generated/numpy.searchsorted.html

### What changes are included in this PR?
Implementation of the C++ kernel + Python API.
Tests in C++ and Python

### Are these changes tested?
Yes

### Are there any user-facing changes?
No breaking change

* GitHub Issue: #49677

Lead-authored-by: Alexis Placet <2400067+Alex-PLACET@users.noreply.github.com>
Co-authored-by: Alexis Placet <alexis.placet.dev@pm.me>
Co-authored-by: Antoine Pitrou <antoine@python.org>
Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Antoine Pitrou <antoine@python.org>
diff --git a/cpp/src/arrow/CMakeLists.txt b/cpp/src/arrow/CMakeLists.txt
index f6671fe..d2b3eac 100644
--- a/cpp/src/arrow/CMakeLists.txt
+++ b/cpp/src/arrow/CMakeLists.txt
@@ -871,6 +871,7 @@
        compute/kernels/vector_rank.cc
        compute/kernels/vector_replace.cc
        compute/kernels/vector_run_end_encode.cc
+       compute/kernels/vector_search_sorted.cc
        compute/kernels/vector_select_k.cc
        compute/kernels/vector_sort.cc
        compute/kernels/vector_statistics.cc
diff --git a/cpp/src/arrow/array/array_binary.cc b/cpp/src/arrow/array/array_binary.cc
index 1266819..e0f1938 100644
--- a/cpp/src/arrow/array/array_binary.cc
+++ b/cpp/src/arrow/array/array_binary.cc
@@ -91,7 +91,7 @@
 Status LargeStringArray::ValidateUTF8() const { return internal::ValidateUTF8(*data_); }
 
 BinaryViewArray::BinaryViewArray(std::shared_ptr<ArrayData> data) {
-  ARROW_CHECK_EQ(data->type->id(), Type::BINARY_VIEW);
+  ARROW_CHECK(is_binary_view_like(data->type->id()));
   SetData(std::move(data));
 }
 
diff --git a/cpp/src/arrow/compute/api_vector.cc b/cpp/src/arrow/compute/api_vector.cc
index 57b2eda..048b6a8 100644
--- a/cpp/src/arrow/compute/api_vector.cc
+++ b/cpp/src/arrow/compute/api_vector.cc
@@ -51,6 +51,7 @@
 using compute::NullPlacement;
 using compute::RankOptions;
 using compute::RankQuantileOptions;
+using compute::SearchSortedOptions;
 
 template <>
 struct EnumTraits<FilterOptions::NullSelectionBehavior>
@@ -82,6 +83,22 @@
     return "<INVALID>";
   }
 };
+
+template <>
+struct EnumTraits<SearchSortedOptions::Side>
+    : BasicEnumTraits<SearchSortedOptions::Side, SearchSortedOptions::Left,
+                      SearchSortedOptions::Right> {
+  static std::string name() { return "SearchSortedOptions::Side"; }
+  static std::string value_name(SearchSortedOptions::Side value) {
+    switch (value) {
+      case SearchSortedOptions::Left:
+        return "Left";
+      case SearchSortedOptions::Right:
+        return "Right";
+    }
+    return "<INVALID>";
+  }
+};
 template <>
 struct EnumTraits<RankOptions::Tiebreaker>
     : BasicEnumTraits<RankOptions::Tiebreaker, RankOptions::Min, RankOptions::Max,
@@ -125,6 +142,8 @@
 static auto kArraySortOptionsType = GetFunctionOptionsType<ArraySortOptions>(
     DataMember("order", &ArraySortOptions::order),
     DataMember("null_placement", &ArraySortOptions::null_placement));
+static auto kSearchSortedOptionsType = GetFunctionOptionsType<SearchSortedOptions>(
+    DataMember("side", &SearchSortedOptions::side));
 static auto kSortOptionsType = GetFunctionOptionsType<SortOptions>(
     CoercedDataMember("sort_keys", &SortOptions::sort_keys, &SortOptions::GetSortKeys));
 static auto kPartitionNthOptionsType = GetFunctionOptionsType<PartitionNthOptions>(
@@ -182,12 +201,15 @@
       null_placement(null_placement) {}
 constexpr char ArraySortOptions::kTypeName[];
 
+SearchSortedOptions::SearchSortedOptions(SearchSortedOptions::Side side)
+    : FunctionOptions(internal::kSearchSortedOptionsType), side(side) {}
+constexpr char SearchSortedOptions::kTypeName[];
+
 ARROW_SUPPRESS_DEPRECATION_WARNING
 SortOptions::SortOptions(std::vector<SortKey> sort_keys)
     : FunctionOptions(internal::kSortOptionsType),
       sort_keys(std::move(sort_keys)),
       null_placement(std::nullopt) {}
-
 SortOptions::SortOptions(std::vector<SortKey> sort_keys,
                          std::optional<NullPlacement> null_placement)
     : FunctionOptions(internal::kSortOptionsType),
@@ -277,6 +299,7 @@
   DCHECK_OK(registry->AddFunctionOptionsType(kDictionaryEncodeOptionsType));
   DCHECK_OK(registry->AddFunctionOptionsType(kRunEndEncodeOptionsType));
   DCHECK_OK(registry->AddFunctionOptionsType(kArraySortOptionsType));
+  DCHECK_OK(registry->AddFunctionOptionsType(kSearchSortedOptionsType));
   DCHECK_OK(registry->AddFunctionOptionsType(kSortOptionsType));
   DCHECK_OK(registry->AddFunctionOptionsType(kPartitionNthOptionsType));
   DCHECK_OK(registry->AddFunctionOptionsType(kSelectKOptionsType));
@@ -318,6 +341,11 @@
   return result.make_array();
 }
 
+Result<Datum> SearchSorted(const Datum& values, const Datum& needles,
+                           const SearchSortedOptions& options, ExecContext* ctx) {
+  return CallFunction("search_sorted", {values, needles}, &options, ctx);
+}
+
 Result<Datum> ReplaceWithMask(const Datum& values, const Datum& mask,
                               const Datum& replacements, ExecContext* ctx) {
   return CallFunction("replace_with_mask", {values, mask, replacements}, ctx);
diff --git a/cpp/src/arrow/compute/api_vector.h b/cpp/src/arrow/compute/api_vector.h
index 2de7137..b55ed9e 100644
--- a/cpp/src/arrow/compute/api_vector.h
+++ b/cpp/src/arrow/compute/api_vector.h
@@ -103,6 +103,21 @@
   NullPlacement null_placement;
 };
 
+class ARROW_EXPORT SearchSortedOptions : public FunctionOptions {
+ public:
+  enum Side {
+    Left,
+    Right,
+  };
+
+  explicit SearchSortedOptions(Side side = Side::Left);
+  static constexpr const char kTypeName[] = "SearchSortedOptions";
+  static SearchSortedOptions Defaults() { return SearchSortedOptions(); }
+
+  /// Whether to return the leftmost or rightmost insertion point.
+  Side side;
+};
+
 class ARROW_EXPORT SortOptions : public FunctionOptions {
  public:
   explicit SortOptions(std::vector<SortKey> sort_keys = {});
@@ -598,6 +613,29 @@
                                                const SelectKOptions& options,
                                                ExecContext* ctx = NULLPTR);
 
+/// \brief Find insertion indices that preserve sorted order.
+///
+/// The `values` datum must be a plain array, chunked array, or run-end encoded
+/// array (including chunked run-end encoded) sorted in ascending order.
+/// `needles` may be a scalar, plain array, chunked array, or run-end encoded
+/// array (including chunked run-end encoded) whose logical value type matches
+/// `values`.
+///
+/// Nulls in `values` are supported when clustered entirely at the start or the
+/// end of the sorted array. Non-null needles are matched only against the
+/// non-null portion of `values`. Null needles yield null outputs.
+///
+/// \param[in] values sorted array to search within
+/// \param[in] needles scalar or array-like values to search for
+/// \param[in] options selects left or right insertion semantics
+/// \param[in] ctx the function execution context, optional
+/// \return insertion indices as uint64 scalar or array
+ARROW_EXPORT
+Result<Datum> SearchSorted(
+    const Datum& values, const Datum& needles,
+    const SearchSortedOptions& options = SearchSortedOptions::Defaults(),
+    ExecContext* ctx = NULLPTR);
+
 /// \brief Return the indices that would sort an array.
 ///
 /// Perform an indirect sort of array. The output array will contain
diff --git a/cpp/src/arrow/compute/initialize.cc b/cpp/src/arrow/compute/initialize.cc
index d88835d..ec531e8 100644
--- a/cpp/src/arrow/compute/initialize.cc
+++ b/cpp/src/arrow/compute/initialize.cc
@@ -48,6 +48,7 @@
   internal::RegisterVectorNested(registry);
   internal::RegisterVectorRank(registry);
   internal::RegisterVectorReplace(registry);
+  internal::RegisterVectorSearchSorted(registry);
   internal::RegisterVectorSelectK(registry);
   internal::RegisterVectorSort(registry);
   internal::RegisterVectorRunEndEncode(registry);
diff --git a/cpp/src/arrow/compute/kernels/CMakeLists.txt b/cpp/src/arrow/compute/kernels/CMakeLists.txt
index 15955b5..d07356a 100644
--- a/cpp/src/arrow/compute/kernels/CMakeLists.txt
+++ b/cpp/src/arrow/compute/kernels/CMakeLists.txt
@@ -121,6 +121,13 @@
                        arrow_compute_kernels_testing
                        arrow_compute_testing)
 
+add_arrow_compute_test(vector_search_sorted_test
+                       SOURCES
+                       vector_search_sorted_test.cc
+                       EXTRA_LINK_LIBS
+                       arrow_compute_kernels_testing
+                       arrow_compute_testing)
+
 add_arrow_compute_test(vector_selection_test
                        SOURCES
                        vector_selection_test.cc
@@ -141,6 +148,7 @@
 add_arrow_compute_benchmark(vector_partition_benchmark)
 add_arrow_compute_benchmark(vector_topk_benchmark)
 add_arrow_compute_benchmark(vector_replace_benchmark)
+add_arrow_compute_benchmark(vector_search_sorted_benchmark)
 add_arrow_compute_benchmark(vector_selection_benchmark)
 
 # ----------------------------------------------------------------------
diff --git a/cpp/src/arrow/compute/kernels/meson.build b/cpp/src/arrow/compute/kernels/meson.build
index fb68244..0a8c16f 100644
--- a/cpp/src/arrow/compute/kernels/meson.build
+++ b/cpp/src/arrow/compute/kernels/meson.build
@@ -132,6 +132,7 @@
     'vector_partition_benchmark',
     'vector_topk_benchmark',
     'vector_replace_benchmark',
+    'vector_search_sorted_benchmark',
     'vector_selection_benchmark',
 ]
 
diff --git a/cpp/src/arrow/compute/kernels/vector_search_sorted.cc b/cpp/src/arrow/compute/kernels/vector_search_sorted.cc
new file mode 100644
index 0000000..e2e90ef
--- /dev/null
+++ b/cpp/src/arrow/compute/kernels/vector_search_sorted.cc
@@ -0,0 +1,881 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include "arrow/compute/api_vector.h"
+
+#include <algorithm>
+#include <memory>
+#include <optional>
+#include <ranges>
+#include <type_traits>
+#include <utility>
+
+#include "arrow/array/array_primitive.h"
+#include "arrow/array/array_run_end.h"
+#include "arrow/array/concatenate.h"
+#include "arrow/array/util.h"
+#include "arrow/buffer_builder.h"
+#include "arrow/chunk_resolver.h"
+#include "arrow/compute/function.h"
+#include "arrow/compute/kernels/codegen_internal.h"
+#include "arrow/compute/kernels/vector_sort_internal.h"
+#include "arrow/compute/registry.h"
+#include "arrow/compute/registry_internal.h"
+#include "arrow/type_traits.h"
+#include "arrow/util/checked_cast.h"
+#include "arrow/util/float16.h"
+#include "arrow/util/logging_internal.h"
+#include "arrow/util/ree_util.h"
+#include "arrow/util/unreachable.h"
+
+namespace arrow {
+
+using internal::checked_cast;
+using util::Float16;
+
+namespace compute::internal {
+namespace {
+
+/// Return the static default options instance used by the meta-function.
+const SearchSortedOptions* GetDefaultSearchSortedOptions() {
+  static const auto kDefaultSearchSortedOptions = SearchSortedOptions::Defaults();
+  return &kDefaultSearchSortedOptions;
+}
+
+const FunctionDoc search_sorted_doc(
+    "Find insertion indices for sorted input",
+    ("Return the index where each needle should be inserted in a sorted input array\n"
+     "to maintain ascending order.\n"
+     "\n"
+     "With side='left', returns the first suitable index (lower bound).\n"
+     "With side='right', returns the last suitable index (upper bound).\n"
+     "\n"
+     "The searched values may be provided as an array or chunked array and must\n"
+     "already be sorted in ascending order. Null values in the searched array are\n"
+     "supported when clustered entirely at the start or\n"
+     "entirely at the end. Non-null needles are matched only against the non-null\n"
+     "portion of the searched array. Needles may be a scalar, array, or chunked\n"
+     "array. Null needles emit nulls in the output."),
+    {"values", "needles"}, "SearchSortedOptions");
+
+#define VISIT_SEARCH_SORTED_PHYSICAL_TYPES(VISIT) \
+  VISIT(BooleanType)                              \
+  VISIT(Int8Type)                                 \
+  VISIT(Int16Type)                                \
+  VISIT(Int32Type)                                \
+  VISIT(Int64Type)                                \
+  VISIT(UInt8Type)                                \
+  VISIT(UInt16Type)                               \
+  VISIT(UInt32Type)                               \
+  VISIT(UInt64Type)                               \
+  VISIT(HalfFloatType)                            \
+  VISIT(FloatType)                                \
+  VISIT(DoubleType)                               \
+  VISIT(BinaryType)                               \
+  VISIT(LargeBinaryType)                          \
+  VISIT(BinaryViewType)
+
+template <typename ArrowType>
+using SearchValue = typename GetViewType<ArrowType>::T;
+
+struct NonNullValuesRange {
+  int64_t offset = 0;
+  int64_t length = 0;
+
+  /// Return whether the range spans the full searched values input.
+  bool is_identity(int64_t full_length) const {
+    return (offset == 0) && (length == full_length);
+  }
+};
+
+// The three first members are ordered by "nullness"
+enum class NullGeometry : int { NoNulls, AllNans, AllNulls, Empty, AtStart, AtEnd };
+
+inline bool IsNanPrimitive(const Array& array, int64_t index) {
+  switch (array.type_id()) {
+    case Type::FLOAT:
+      return std::isnan(checked_cast<const FloatArray&>(array).Value(index));
+    case Type::DOUBLE:
+      return std::isnan(checked_cast<const DoubleArray&>(array).Value(index));
+    case Type::HALF_FLOAT:
+      return Float16::FromBits(checked_cast<const HalfFloatArray&>(array).Value(index))
+          .is_nan();
+    default:
+      return false;
+  }
+}
+
+/// Detect the NullGeometry of a primitive or run-end-encoded array
+NullGeometry DetectNullGeometry(const Array& array) {
+  if (array.length() == 0) {
+    return NullGeometry::Empty;
+  }
+  if (array.type_id() == Type::RUN_END_ENCODED) {
+    const auto& ree_array = checked_cast<const RunEndEncodedArray&>(array);
+    auto range = ::arrow::ree_util::FindPhysicalRange(*array.data(), array.offset(),
+                                                      array.length());
+    return DetectNullGeometry(*ree_array.values()->Slice(range.first, range.second));
+  }
+  bool null_at_start = array.IsNull(0);
+  bool null_at_end = array.IsNull(array.length() - 1);
+  if (null_at_start && !null_at_end) {
+    return NullGeometry::AtStart;
+  }
+  if (!null_at_start && null_at_end) {
+    return NullGeometry::AtEnd;
+  }
+  if (null_at_start && null_at_end) {
+    return NullGeometry::AllNulls;
+  }
+
+  // No nulls, look at NaNs
+  bool nan_at_start = IsNanPrimitive(array, 0);
+  bool nan_at_end = IsNanPrimitive(array, array.length() - 1);
+  if (nan_at_start && !nan_at_end) {
+    return NullGeometry::AtStart;
+  }
+  if (!nan_at_start && nan_at_end) {
+    return NullGeometry::AtEnd;
+  }
+  if (nan_at_start && nan_at_end) {
+    return NullGeometry::AllNans;
+  }
+  return NullGeometry::NoNulls;
+}
+
+/// Detect the NullGeometry of a chunked array
+NullGeometry DetectNullGeometry(const ChunkedArray& chunked_array) {
+  auto previous_geometry = NullGeometry::Empty;
+
+  for (const auto& chunk : chunked_array.chunks()) {
+    auto null_geometry = DetectNullGeometry(*chunk);
+    if (null_geometry == NullGeometry::Empty) {
+      continue;
+    }
+    if (null_geometry == NullGeometry::AtStart || null_geometry == NullGeometry::AtEnd) {
+      // We can conclude from this chunk alone
+      return null_geometry;
+    }
+    // If the previous chunk had different results, we can compare and decide
+    if (previous_geometry != NullGeometry::Empty && previous_geometry != null_geometry) {
+      return previous_geometry < null_geometry ? NullGeometry::AtEnd
+                                               : NullGeometry::AtStart;
+    }
+    previous_geometry = null_geometry;
+  }
+  return previous_geometry;
+}
+
+/// Validate the supplied null counts and produce the logical non-null window
+/// that will actually participate in binary search.
+NonNullValuesRange MakeNonNullValuesRange(int64_t full_length, int64_t null_count,
+                                          int64_t leading_null_count,
+                                          int64_t trailing_null_count) {
+  if (leading_null_count > 0) {
+    return {.offset = leading_null_count, .length = full_length - leading_null_count};
+  } else {
+    return {.offset = 0, .length = full_length - trailing_null_count};
+  }
+}
+
+/// Build the searchable non-null window once the side containing clustered
+/// nulls is already known.
+NonNullValuesRange MakeNonNullValuesRangeFromNullPlacement(int64_t full_length,
+                                                           int64_t null_count,
+                                                           NullPlacement null_placement) {
+  return MakeNonNullValuesRange(
+      full_length, null_count, null_placement == NullPlacement::AtStart ? null_count : 0,
+      null_placement == NullPlacement::AtStart ? 0 : null_count);
+}
+
+// Convert ArrayData to its physical representation so that typed accessors
+// can be constructed with a physical ArrowType (e.g. Date32 → Int32).
+// For REE arrays, only the values child type is converted; the REE wrapper
+// type stays unchanged.
+std::shared_ptr<ArrayData> ToPhysicalData(
+    const std::shared_ptr<ArrayData>& data,
+    const std::shared_ptr<DataType>& physical_type) {
+  if (data->type->id() == Type::RUN_END_ENCODED) {
+    const auto& ree_type = checked_cast<const RunEndEncodedType&>(*data->type);
+    auto result = data->Copy();
+    auto values_copy = result->child_data[1]->Copy();
+    values_copy->type = physical_type;
+    result->type = run_end_encoded(ree_type.run_end_type(), physical_type);
+    result->child_data[1] = std::move(values_copy);
+    return result;
+  }
+  auto result = data->Copy();
+  result->type = physical_type;
+  return result;
+}
+
+/// Read a run-end value from any supported run-end integer representation.
+int64_t GetRunEndValue(const ArraySpan& run_ends, int64_t physical_index) {
+  switch (run_ends.type->id()) {
+    case Type::INT16:
+      return run_ends.GetValues<int16_t>(1)[physical_index];
+    case Type::INT32:
+      return run_ends.GetValues<int32_t>(1)[physical_index];
+    case Type::INT64:
+      return run_ends.GetValues<int64_t>(1)[physical_index];
+    default:
+      DCHECK(false) << "Unexpected run-end type for search_sorted values: "
+                    << run_ends.type->ToString();
+      return 0;
+  }
+}
+
+class SearchWindow {
+ public:
+  explicit SearchWindow(NonNullValuesRange non_null_range)
+      : offset_(non_null_range.offset), length_(non_null_range.length) {}
+
+  int64_t length() const { return length_; }
+
+  int64_t LogicalInsertionIndex(int64_t index) const { return index + physical_offset(); }
+
+ protected:
+  int64_t physical_offset() const { return offset_; }
+
+ private:
+  int64_t offset_ = 0;
+  int64_t length_;
+};
+
+/// Access logical values from a plain Arrow array.
+template <typename ArrowType>
+class PlainArrayAccessor : public SearchWindow {
+ public:
+  using ArrayType = typename TypeTraits<ArrowType>::ArrayType;
+  using ValueType = SearchValue<ArrowType>;
+
+  PlainArrayAccessor(const std::shared_ptr<ArrayData>& array_data,
+                     NullPlacement null_placement)
+      : PlainArrayAccessor(array_data, MakeNonNullValuesRangeFromNullPlacement(
+                                           array_data->length, array_data->GetNullCount(),
+                                           null_placement)) {}
+
+  PlainArrayAccessor(const std::shared_ptr<ArrayData>& array_data,
+                     NonNullValuesRange non_null_range)
+      : SearchWindow(non_null_range), array_(array_data) {}
+
+  /// Return the logical value at the given position within the search window.
+  ValueType Value(int64_t index) const {
+    return GetViewType<ArrowType>::LogicalValue(
+        array_.GetView(physical_offset() + index));
+  }
+
+ private:
+  ArrayType array_;
+};
+
+class RunEndEncodedValuesAccessorBase {
+ public:
+  explicit RunEndEncodedValuesAccessorBase(RunEndEncodedArray array)
+      : array_(std::move(array)),
+        array_span_(*array_.data()),
+        run_ends_span_(::arrow::ree_util::RunEndsArray(array_span_)),
+        physical_range_(::arrow::ree_util::FindPhysicalRange(array_span_, array_.offset(),
+                                                             array_.length())) {
+    values_ = array_.values()->Slice(physical_range_.first, physical_range_.second);
+  }
+
+ protected:
+  int64_t PhysicalIndex(int64_t index) const {
+    return physical_range_.first + /*search_offset_ + */ index;
+  }
+
+  RunEndEncodedArray array_;
+  std::shared_ptr<Array> values_;
+  ArraySpan array_span_;
+  ArraySpan run_ends_span_;
+  std::pair<int64_t, int64_t> physical_range_;
+};
+
+/// Access logical values from a run-end encoded Arrow array.
+template <typename ArrowType>
+class RunEndEncodedValuesAccessor : public RunEndEncodedValuesAccessorBase {
+ public:
+  using ArrayType = typename TypeTraits<ArrowType>::ArrayType;
+  using ValueType = SearchValue<ArrowType>;
+
+  RunEndEncodedValuesAccessor(RunEndEncodedArray array, NullPlacement null_placement)
+      : RunEndEncodedValuesAccessorBase(std::move(array)),
+        physical_accessor_(values_->data(), null_placement) {}
+
+  RunEndEncodedValuesAccessor(const std::shared_ptr<ArrayData>& array_data,
+                              NullPlacement null_placement)
+      : RunEndEncodedValuesAccessor(RunEndEncodedArray(array_data), null_placement) {}
+
+  /// Return the logical value at the given physical run position within the
+  /// search window.
+  ValueType Value(int64_t index) const { return physical_accessor_.Value(index); }
+
+  int64_t length() const { return physical_accessor_.length(); }
+
+  int64_t LogicalInsertionIndex(int64_t index) const {
+    auto physical_index = physical_accessor_.LogicalInsertionIndex(index);
+
+    DCHECK_GE(physical_index, 0);
+    DCHECK_LE(physical_index, physical_range_.second);
+    if (physical_index == 0) {
+      return 0;
+    } else if (physical_index == physical_range_.second) {
+      return array_.length();
+    } else {
+      auto run_end =
+          GetRunEndValue(run_ends_span_, physical_index + physical_range_.first - 1);
+      DCHECK_GE(run_end, array_.offset());
+      DCHECK_LE(run_end, array_.offset() + array_.length());
+      return run_end - array_.offset();
+    }
+  }
+
+ protected:
+  PlainArrayAccessor<ArrowType> physical_accessor_;
+};
+
+/// Return the logical type of a datum, unwrapping run-end encoding when present.
+const DataType& LogicalType(const Datum& datum) {
+  const auto& type = *datum.type();
+  if (type.id() == Type::RUN_END_ENCODED) {
+    return *checked_cast<const RunEndEncodedType&>(type).value_type();
+  }
+  return type;
+}
+
+/// Reject nested run-end encoded values. TODO: Support this case in the future if there
+/// is demand for it.
+Status ValidateRunEndEncodedLogicalValueType(const DataType& type, const char* name) {
+  const auto& ree_type = checked_cast<const RunEndEncodedType&>(type);
+  if (ree_type.value_type()->id() == Type::RUN_END_ENCODED) {
+    return Status::TypeError("Nested run-end encoded ", name, " are not supported");
+  }
+  return Status::OK();
+}
+
+/// Validate the searched values input shape and supported encoding.
+Status ValidateSortedValuesInput(const Datum& datum) {
+  if (!(datum.is_array() || datum.is_chunked_array())) {
+    return Status::TypeError("search_sorted values must be an array or chunked array");
+  }
+
+  const auto& type = *datum.type();
+  if (type.id() == Type::RUN_END_ENCODED) {
+    return ValidateRunEndEncodedLogicalValueType(type, "values");
+  }
+
+  return Status::OK();
+}
+
+/// Validate the needles input shape and supported encoding.
+/// Needles can be a scalar, array, or chunked array. Array-like needles must not have
+/// nested run-end encoding since that is not currently supported.
+Status ValidateNeedleInput(const Datum& datum) {
+  if (!(datum.is_array() || datum.is_chunked_array() || datum.is_scalar())) {
+    return Status::TypeError(
+        "search_sorted needles must be a scalar, array, or chunked array");
+  }
+
+  if ((datum.is_array() || datum.is_chunked_array()) &&
+      datum.type()->id() == Type::RUN_END_ENCODED) {
+    return ValidateRunEndEncodedLogicalValueType(*datum.type(), "needles");
+  }
+  return Status::OK();
+}
+
+/// Find the insertion point into a dense array
+template <typename ArrowType, typename Accessor>
+int64_t FindInsertionDense(const Accessor& array, const SearchValue<ArrowType>& needle,
+                           SearchSortedOptions::Side side, NullPlacement null_placement) {
+  // When looking for the Left side, we want equal values to be considered greater
+  // than the needle (1), otherwise smaller (-1).
+  const int on_equality = (side == SearchSortedOptions::Left) ? 1 : -1;
+  int64_t first = 0;
+  int64_t count = array.length();
+
+  auto compare = [&](auto left, auto right) {
+    // The same comparison function as used for sorting, taking account null_placement
+    // when NaNs are involved.
+    // XXX Instead of detecting NaN-ness during each comparison, we could
+    // take advantage of null_placement to single out the range of NaNs that's at
+    // the beginning or end of the array.
+    return CompareTypeValues<ArrowType>(left, right, SortOrder::Ascending, null_placement,
+                                        /*on_equality=*/on_equality);
+  };
+
+  while (count > 0) {
+    const int64_t step = count / 2;
+    const int64_t it = first + step;
+    const bool advance = compare(array.Value(it), needle) < 0;
+    if (advance) {
+      first = it + 1;
+      count -= step + 1;
+    } else {
+      count = step;
+    }
+  }
+  return first;
+}
+
+/// Find the insertion chunk in an array of vector chunks. The chunk index is returned.
+template <typename ArrowType, typename Accessor>
+int64_t FindInsertionChunk(const std::vector<Accessor>& chunks,
+                           const SearchValue<ArrowType>& needle,
+                           SearchSortedOptions::Side side, NullPlacement null_placement) {
+  // When looking for the Left side, we want equal values to be considered greater
+  // than the needle (1), otherwise smaller (-1).
+  const int on_equality = (side == SearchSortedOptions::Left) ? 1 : -1;
+  int64_t first = 0;
+  int64_t count = static_cast<int64_t>(chunks.size());
+
+  auto compare = [&](auto left, auto right) {
+    // The same comparison function as used for sorting, taking account null_placement
+    // when NaNs are involved.
+    // XXX Instead of detecting NaN-ness during each comparison, we could
+    // take advantage of null_placement to single out the range of NaNs that's at
+    // the beginning or end of the sorted_values.
+    return CompareTypeValues<ArrowType>(left, right, SortOrder::Ascending, null_placement,
+                                        on_equality);
+  };
+
+  while (count > 0) {
+    const int64_t step = count / 2;
+    const int64_t it = first + step;
+    bool advance;
+    const auto& chunk = chunks[it];
+    if (chunk.length() == 0) {
+      // If nulls are clustered at the start, advance towards the end.
+      advance = (null_placement == NullPlacement::AtStart);
+    } else {
+      auto chunk_first = chunk.Value(0);
+      auto chunk_last = chunk.Value(chunk.length() - 1);
+      if (compare(chunk_first, needle) > 0) {
+        // First chunk value too large => go left
+        advance = false;
+      } else if (compare(chunk_last, needle) < 0) {
+        // Last chunk value too small => go right
+        advance = true;
+      } else {
+        // Insertion point is in this chunk
+        first = it;
+        break;
+      }
+    }
+    if (advance) {
+      first = it + 1;
+      count -= step + 1;
+    } else {
+      count = step;
+    }
+  }
+  return first;
+}
+
+/// Find the insertion point into a chunked array.
+template <typename ArrowType, typename Accessor>
+ChunkLocation FindInsertionChunked(const std::vector<Accessor>& chunks,
+                                   const SearchValue<ArrowType>& needle,
+                                   SearchSortedOptions::Side side,
+                                   NullPlacement null_placement) {
+  // A naive implementation would search directly in the chunked array,
+  // with each indexed access taking O(log n) time.
+  // It is much faster to first narrow down the search to a single chunk
+  // (by using a binary search among chunk boundaries, see FindInsertionChunk)
+  // and then do a dense binary search (FindInsertionDense).
+  DCHECK_GT(chunks.size(), 0);
+  int64_t chunk_index =
+      FindInsertionChunk<ArrowType>(chunks, needle, side, null_placement);
+  int64_t index_in_chunk;
+  if (chunk_index == static_cast<int64_t>(chunks.size())) {
+    // Inserting at the right of the last chunk
+    --chunk_index;
+    index_in_chunk = chunks.back().length();
+  } else {
+    index_in_chunk =
+        FindInsertionDense<ArrowType>(chunks[chunk_index], needle, side, null_placement);
+  }
+  return {chunk_index, chunks[chunk_index].LogicalInsertionIndex(index_in_chunk)};
+}
+
+template <typename ArrowType, typename Accessor>
+class ChunkedSearchSorted {
+ public:
+  ChunkedSearchSorted(const ArrayVector& chunks, SearchSortedOptions::Side side,
+                      NullPlacement null_placement)
+      : side_(side), null_placement_(null_placement) {
+    // Initialize accessors from non-empty chunks
+    chunk_accessors_.reserve(chunks.size());
+    chunk_offsets_.reserve(chunks.size());
+    int64_t offset = 0;
+    for (const auto& chunk : chunks) {
+      if (chunk->length() > 0) {
+        auto accessor = Accessor(chunk->data(), null_placement);
+        chunk_accessors_.push_back(std::move(accessor));
+        chunk_offsets_.push_back(offset);
+        offset += chunk->length();
+      }
+    }
+  }
+
+  int64_t FindLogicalInsertionIndex(const SearchValue<ArrowType>& needle) const {
+    if (chunk_accessors_.empty()) {
+      return 0;
+    }
+    ChunkLocation location =
+        FindInsertionChunked<ArrowType>(chunk_accessors_, needle, side_, null_placement_);
+    DCHECK_LT(location.chunk_index, static_cast<int64_t>(chunk_offsets_.size()));
+    return chunk_offsets_[location.chunk_index] + location.index_in_chunk;
+  }
+
+ protected:
+  SearchSortedOptions::Side side_;
+  NullPlacement null_placement_;
+  std::vector<Accessor> chunk_accessors_;
+  std::vector<int64_t> chunk_offsets_;
+};
+
+template <typename ArrowType>
+using VisitedNeedle = std::optional<SearchValue<ArrowType>>;
+
+/// Read one logical needle value from a physical array position.
+template <typename ArrowType, typename ArrayType>
+VisitedNeedle<ArrowType> ReadVisitedNeedle(const ArrayType& array,
+                                           int64_t physical_index) {
+  if (array.IsNull(physical_index)) {
+    return std::nullopt;
+  }
+  const auto needle = GetViewType<ArrowType>::LogicalValue(array.GetView(physical_index));
+  return std::optional<SearchValue<ArrowType>>(needle);
+}
+
+/// Visit each plain-array needle as single-element logical runs.
+template <typename ArrowType, typename Visitor>
+Status VisitArrayNeedleRuns(const std::shared_ptr<ArrayData>& needles_data,
+                            Visitor&& visitor) {
+  using ArrayType = typename TypeTraits<ArrowType>::ArrayType;
+
+  auto physical_type = TypeTraits<ArrowType>::type_singleton();
+  auto physical_data = ToPhysicalData(needles_data, physical_type);
+  ArrayType array(physical_data);
+  for (int64_t index = 0; index < array.length(); ++index) {
+    RETURN_NOT_OK(visitor(ReadVisitedNeedle<ArrowType>(array, index)));
+  }
+  return Status::OK();
+}
+
+/// Visit scalar or plain-array needles through a uniform callback interface
+/// of logical elements.
+template <typename ArrowType, typename Visitor>
+Status VisitNeedleRuns(const Datum& needles, Visitor&& visitor) {
+  if (needles.is_scalar()) {
+    if (!needles.scalar()->is_valid) {
+      return visitor(std::optional<SearchValue<ArrowType>>{});
+    }
+    ARROW_ASSIGN_OR_RAISE(auto scalar_array, MakeArrayFromScalar(*needles.scalar(), 1));
+    return VisitArrayNeedleRuns<ArrowType>(scalar_array->data(),
+                                           std::forward<Visitor>(visitor));
+  }
+
+  const auto& needle_data = needles.array();
+  return VisitArrayNeedleRuns<ArrowType>(needle_data, visitor);
+}
+
+/// Build uint64 insertion-index arrays with an optional null bitmap.
+class InsertionIndexBuilder {
+ public:
+  explicit InsertionIndexBuilder(MemoryPool* pool, bool nullable)
+      : indices_builder_(pool), null_bitmap_builder_(pool), nullable_(nullable) {}
+
+  /// Reserve the final output size up front so append operations can use the
+  /// builders' unchecked fast path.
+  Status Init(int64_t length) {
+    expected_length_ = length;
+    RETURN_NOT_OK(indices_builder_.Reserve(length));
+    if (nullable_) {
+      RETURN_NOT_OK(null_bitmap_builder_.Reserve(length));
+    }
+    return Status::OK();
+  }
+
+  /// Append a null output slot for a null needle.
+  Status AppendNull() {
+    DCHECK(nullable_);
+    indices_builder_.UnsafeAppend(uint64_t{0});
+    null_bitmap_builder_.UnsafeAppend(false);
+    ++null_count_;
+    return Status::OK();
+  }
+
+  /// Append one computed insertion index for a non-null needle.
+  Status AppendValue(uint64_t insertion_index) {
+    indices_builder_.UnsafeAppend(insertion_index);
+    if (nullable_) {
+      null_bitmap_builder_.UnsafeAppend(true);
+    }
+    return Status::OK();
+  }
+
+  /// Finish building the output UInt64 array, attaching the null bitmap only
+  /// when nullable output was requested.
+  Result<std::shared_ptr<Array>> Finish() && {
+    DCHECK_EQ(indices_builder_.length(), expected_length_);
+    ARROW_ASSIGN_OR_RAISE(auto indices, indices_builder_.Finish());
+
+    std::shared_ptr<Buffer> null_bitmap;
+    if (nullable_) {
+      DCHECK_EQ(null_bitmap_builder_.length(), expected_length_);
+      ARROW_ASSIGN_OR_RAISE(null_bitmap, null_bitmap_builder_.Finish());
+    }
+
+    return MakeArray(ArrayData::Make(uint64(), expected_length_,
+                                     {std::move(null_bitmap), std::move(indices)},
+                                     null_count_));
+  }
+
+ private:
+  TypedBufferBuilder<uint64_t> indices_builder_;
+  TypedBufferBuilder<bool> null_bitmap_builder_;
+  bool nullable_;
+  int64_t expected_length_ = 0;
+  int64_t null_count_ = 0;
+};
+
+Result<Datum> ComputeRunEndEncodedNeedleInsertionIndices(
+    const Datum& values, const RunEndEncodedArray& needles,
+    SearchSortedOptions::Side side, ExecContext* ctx) {
+  ExecContext* exec_ctx = ctx != NULLPTR ? ctx : default_exec_context();
+
+  // Search each physical REE value once, then rebuild the run-end encoded shape
+  // and decode back to the dense logical result expected by the public API.
+  ARROW_ASSIGN_OR_RAISE(auto physical_results,
+                        SearchSorted(values, Datum(needles.LogicalValues()),
+                                     SearchSortedOptions(side), exec_ctx));
+
+  ARROW_ASSIGN_OR_RAISE(auto logical_run_ends,
+                        needles.LogicalRunEnds(exec_ctx->memory_pool()));
+  ARROW_ASSIGN_OR_RAISE(auto ree_result,
+                        RunEndEncodedArray::Make(needles.length(), logical_run_ends,
+                                                 physical_results.make_array()));
+  return RunEndDecode(Datum(ree_result), exec_ctx);
+}
+
+template <typename ArrowType, typename ValuesAccessor>
+Result<Datum> ComputeInsertionIndicesWithAccessor(
+    const Datum& sorted_values, const Datum& needles, SearchSortedOptions::Side side,
+    NullPlacement null_placement, uint64_t insertion_offset, ExecContext* ctx) {
+  // Only emit a null bitmap if necessary
+  const bool has_nulls = needles.ComputeLogicalNullCount() > 0;
+  InsertionIndexBuilder output(ctx->memory_pool(), has_nulls);
+  ARROW_RETURN_NOT_OK(output.Init(needles.length()));
+
+  // Array and ChunkedArray follow the same path, an Array having just a single chunk.
+  // The trivial case with one chunk does not add overhead, so it's not worth
+  // the maintenance hassle to have a separate path for Array.
+  ChunkedSearchSorted<ArrowType, ValuesAccessor> search_sorted(sorted_values.chunks(),
+                                                               side, null_placement);
+
+  auto emit_search_result = [&](const VisitedNeedle<ArrowType>& needle) -> Status {
+    if (!needle.has_value()) {
+      return output.AppendNull();
+    }
+    const auto insertion_index = search_sorted.FindLogicalInsertionIndex(*needle);
+    return output.AppendValue(static_cast<uint64_t>(insertion_index));
+  };
+
+  RETURN_NOT_OK(VisitNeedleRuns<ArrowType>(needles, emit_search_result));
+
+  return std::move(output).Finish().As<Datum>();
+}
+
+template <typename ArrowType>
+Result<Datum> ComputeInsertionIndices(const Datum& sorted_values, const Datum& needles,
+                                      SearchSortedOptions::Side side,
+                                      NullPlacement null_placement,
+                                      uint64_t insertion_offset, ExecContext* ctx) {
+  auto physical_type = TypeTraits<ArrowType>::type_singleton();
+  DCHECK_NE(physical_type->id(), Type::RUN_END_ENCODED);
+
+  if (sorted_values.type()->id() == Type::RUN_END_ENCODED) {
+    return ComputeInsertionIndicesWithAccessor<ArrowType,
+                                               RunEndEncodedValuesAccessor<ArrowType>>(
+        sorted_values, needles, side, null_placement, insertion_offset, ctx);
+  } else {
+    return ComputeInsertionIndicesWithAccessor<ArrowType, PlainArrayAccessor<ArrowType>>(
+        sorted_values, needles, side, null_placement, insertion_offset, ctx);
+  }
+}
+
+/// Meta-function implementation for the search_sorted public compute entrypoint.
+/// Validates input shapes and types, normalizes to logical value accessors, and
+/// dispatches to the typed search implementation.
+class SearchSortedMetaFunction : public MetaFunction {
+ public:
+  /// Construct the registry entry with default options and documentation.
+  SearchSortedMetaFunction()
+      : MetaFunction("search_sorted", Arity::Binary(), search_sorted_doc,
+                     GetDefaultSearchSortedOptions()) {}
+
+  /// Validate inputs, normalize options, and dispatch to the typed search
+  /// implementation.
+  Result<Datum> ExecuteImpl(const std::vector<Datum>& args,
+                            const FunctionOptions* options,
+                            ExecContext* ctx) const override {
+    RETURN_NOT_OK(ValidateSortedValuesInput(args[0]));
+    RETURN_NOT_OK(ValidateNeedleInput(args[1]));
+
+    const auto& values_type = LogicalType(args[0]);
+    const auto& needles_type = LogicalType(args[1]);
+    if (!values_type.Equals(needles_type)) {
+      return Status::TypeError(
+          "search_sorted arguments must have matching logical types, got ",
+          values_type.ToString(), " and ", needles_type.ToString());
+    }
+
+    // Chunked needles are handled at the top level so the typed dispatch
+    // below only ever sees non-chunked (scalar /array) needles.
+    if (args[1].is_chunked_array()) {
+      return ExecuteChunkedNeedles(args[0], *args[1].chunked_array(),
+                                   static_cast<const SearchSortedOptions&>(*options),
+                                   ctx);
+    }
+
+    auto null_placement = DetectNullPlacement(args[0]);
+    ARROW_ASSIGN_OR_RAISE(auto non_null_values_range,
+                          FindNonNullValuesRange(args[0], null_placement));
+    auto result = DispatchByType(args[0], non_null_values_range, args[1],
+                                 static_cast<const SearchSortedOptions&>(*options),
+                                 null_placement, ctx);
+    return result;
+  }
+
+ private:
+  /// Process each needle chunk independently and concatenate the results.
+  Result<Datum> ExecuteChunkedNeedles(const Datum& values, const ChunkedArray& needles,
+                                      const SearchSortedOptions& options,
+                                      ExecContext* ctx) const {
+    if (needles.num_chunks() == 0) {
+      return MakeEmptyArray(uint64(), ctx->memory_pool()).As<Datum>();
+    }
+    ArrayVector result_chunks;
+    result_chunks.reserve(static_cast<size_t>(needles.num_chunks()));
+    for (const auto& chunk : needles.chunks()) {
+      ARROW_ASSIGN_OR_RAISE(auto chunk_result,
+                            ExecuteImpl({values, Datum(chunk)}, &options, ctx));
+      result_chunks.push_back(chunk_result.make_array());
+    }
+    ARROW_ASSIGN_OR_RAISE(auto out, Concatenate(result_chunks, ctx->memory_pool()));
+    return Datum(std::move(out));
+  }
+
+  /// Compute the non-null search window on the logical view of the values
+  /// input, regardless of its physical storage.
+  Result<NonNullValuesRange> FindNonNullValuesRange(const Datum& values,
+                                                    NullPlacement null_placement) const {
+    const int64_t null_count = values.ComputeLogicalNullCount();
+    return MakeNonNullValuesRangeFromNullPlacement(values.length(), null_count,
+                                                   null_placement);
+  }
+
+  NullPlacement DetectNullPlacement(const Datum& values) const {
+    const auto null_geometry = values.is_chunked_array()
+                                   ? DetectNullGeometry(*values.chunked_array())
+                                   : DetectNullGeometry(*values.make_array());
+    switch (null_geometry) {
+      case NullGeometry::AtStart:
+        return NullPlacement::AtStart;
+      case NullGeometry::AtEnd:
+        return NullPlacement::AtEnd;
+      default:
+        // Shouldn't matter as there are either no nulls or only nulls
+        DCHECK(values.null_count() == 0 || values.null_count() == values.length());
+        return NullPlacement::AtEnd;
+    }
+  }
+
+  /// Dispatch the logical value type to the matching template specialization.
+  /// Resolves logical types to physical types via GetPhysicalType() so that
+  /// types sharing the same physical layout (e.g. Date32/Int32, String/Binary)
+  /// share a single code path, reducing template instantiations.
+  Result<Datum> DispatchByType(const Datum& values,
+                               const NonNullValuesRange& non_null_values_range,
+                               const Datum& needles, const SearchSortedOptions& options,
+                               NullPlacement null_placement, ExecContext* ctx) const {
+    // Resolve to logical type first (stripping REE wrapper if present).
+    auto logical_type_ptr = values.type();
+    if (logical_type_ptr->id() == Type::RUN_END_ENCODED) {
+      logical_type_ptr =
+          checked_cast<const RunEndEncodedType&>(*logical_type_ptr).value_type();
+    }
+
+    auto physical_type = GetPhysicalType(logical_type_ptr);
+    switch (physical_type->id()) {
+#define VISIT(TYPE)                                                                     \
+  case TYPE::type_id:                                                                   \
+    return DispatchHaystack<TYPE>(values, non_null_values_range, needles, options.side, \
+                                  null_placement, ctx);
+      VISIT_SEARCH_SORTED_PHYSICAL_TYPES(VISIT)
+#undef VISIT
+      default:
+        break;
+    }
+    return Status::NotImplemented("search_sorted is not implemented for type ",
+                                  logical_type_ptr->ToString());
+  }
+
+  /// Dispatch the physical representation of the searched values.
+  template <typename ArrowType>
+  Result<Datum> DispatchHaystack(const Datum& values,
+                                 const NonNullValuesRange& non_null_values_range,
+                                 const Datum& needles, SearchSortedOptions::Side side,
+                                 NullPlacement null_placement, ExecContext* ctx) const {
+    if (needles.is_scalar()) {
+      auto scalar = needles.scalar();
+      if (!scalar->is_valid) {
+        return Datum(std::make_shared<UInt64Scalar>());
+      }
+
+      ARROW_ASSIGN_OR_RAISE(auto scalar_arr, MakeArrayFromScalar(*scalar, 1));
+      ARROW_ASSIGN_OR_RAISE(
+          auto result,
+          DispatchHaystack<ArrowType>(values, non_null_values_range, Datum(scalar_arr),
+                                      side, null_placement, ctx));
+      ARROW_ASSIGN_OR_RAISE(auto result_scalar, result.make_array()->GetScalar(0));
+      return Datum(std::move(result_scalar));
+    }
+
+    // XXX This doesn't need to be in the type-specialized DispatchHaystack
+    if (needles.type()->id() == Type::RUN_END_ENCODED) {
+      return ComputeRunEndEncodedNeedleInsertionIndices(
+          values, RunEndEncodedArray(needles.array()), side, ctx);
+    }
+
+    return ComputeInsertionIndices<ArrowType>(
+        values, needles, side, null_placement,
+        static_cast<uint64_t>(non_null_values_range.offset), ctx);
+  }
+};
+
+}  // namespace
+
+/// Register the search_sorted vector kernel in the global compute registry.
+void RegisterVectorSearchSorted(FunctionRegistry* registry) {
+  DCHECK_OK(registry->AddFunction(std::make_shared<SearchSortedMetaFunction>()));
+}
+
+}  // namespace compute::internal
+}  // namespace arrow
diff --git a/cpp/src/arrow/compute/kernels/vector_search_sorted_benchmark.cc b/cpp/src/arrow/compute/kernels/vector_search_sorted_benchmark.cc
new file mode 100644
index 0000000..1caf79a
--- /dev/null
+++ b/cpp/src/arrow/compute/kernels/vector_search_sorted_benchmark.cc
@@ -0,0 +1,275 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include "benchmark/benchmark.h"
+
+#include <algorithm>
+#include <cmath>
+#include <concepts>
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include "arrow/array.h"
+#include "arrow/array/builder_binary.h"
+#include "arrow/array/builder_primitive.h"
+#include "arrow/compute/api_vector.h"
+#include "arrow/datum.h"
+#include "arrow/testing/gtest_util.h"
+#include "arrow/testing/random.h"
+#include "arrow/util/benchmark_util.h"
+
+namespace arrow {
+namespace compute {
+
+constexpr auto kSeed = 0x5EA4C42;
+constexpr int32_t kStringMinLength = 8;
+constexpr int32_t kStringMaxLength = 20;
+
+struct DenseBenchmarkArgs {
+  int64_t logical_length_;
+  double null_probability_;
+  SearchSortedOptions::Side side_;
+
+  explicit DenseBenchmarkArgs(const benchmark::State& state)
+      : logical_length_(state.range(0)), null_probability_(state.range(1) / 100.0) {
+    side_ = std::array{SearchSortedOptions::Left, SearchSortedOptions::Right}.at(
+        state.range(2));
+  }
+
+  static void SetArgs(benchmark::internal::Benchmark* bench) {
+    bench->Unit(benchmark::kMicrosecond);
+    bench->ArgNames({"length", "null_probability", "side"});
+    for (const auto size : std::vector<int64_t>{kL1Size, kL2Size}) {
+      for (const double null_probability : {0.0, 0.5, 0.9}) {
+        for (const bool right_side : {false, true}) {
+          bench->Args(
+              {size / 4, static_cast<int64_t>(null_probability * 100), right_side});
+        }
+      }
+    }
+  }
+};
+
+struct DenseBenchmarkBase {
+  double null_probability_;
+  random::RandomArrayGenerator rand_;
+
+  explicit DenseBenchmarkBase(const DenseBenchmarkArgs& args)
+      : null_probability_(args.null_probability_), rand_(kSeed) {}
+};
+
+struct Int64Benchmark : public DenseBenchmarkBase {
+  using DenseBenchmarkBase::DenseBenchmarkBase;
+
+  std::shared_ptr<Array> BuildSortedValues(int64_t length) {
+    auto values = std::static_pointer_cast<Int64Array>(RandomArray(length));
+    std::vector<int64_t> data(values->raw_values(),
+                              values->raw_values() + values->length());
+    std::ranges::sort(data);
+
+    Int64Builder builder;
+    ABORT_NOT_OK(builder.AppendValues(data));
+    return builder.Finish().ValueOrDie();
+  }
+
+  std::shared_ptr<Array> BuildNeedles(int64_t length) {
+    // NOTE it's not required to have the same number of needles as values;
+    // it just makes the benchmarks simpler.
+    return RandomArray(length);
+  }
+
+ protected:
+  std::shared_ptr<Array> RandomArray(int64_t length) {
+    return rand_.Int64(length, 0, /*max=*/length * 4, null_probability_);
+  }
+};
+
+struct StringBenchmark : public DenseBenchmarkBase {
+  using DenseBenchmarkBase::DenseBenchmarkBase;
+
+  std::shared_ptr<Array> BuildSortedValues(int64_t length) {
+    auto values = std::static_pointer_cast<StringArray>(
+        rand_.String(length, kStringMinLength, kStringMaxLength, null_probability_));
+
+    std::vector<std::string_view> data;
+    data.reserve(static_cast<size_t>(values->length()));
+    for (int64_t index = 0; index < values->length(); ++index) {
+      data.push_back(values->GetView(index));
+    }
+    std::ranges::sort(data);
+
+    StringBuilder builder;
+    ABORT_NOT_OK(builder.Reserve(values->length()));
+    ABORT_NOT_OK(builder.ReserveData(values->total_values_length()));
+    for (const auto view : data) {
+      builder.UnsafeAppend(view);
+    }
+    return std::static_pointer_cast<StringArray>(builder.Finish().ValueOrDie());
+  }
+
+  std::shared_ptr<Array> BuildNeedles(int64_t length) {
+    return rand_.String(length, kStringMinLength, kStringMaxLength, null_probability_);
+  }
+};
+
+template <typename DenseBenchmark, bool kREENeedles>
+  requires std::derived_from<DenseBenchmark, DenseBenchmarkBase>
+struct REEBenchmark {
+  static constexpr int64_t kAverageRunLength = 50;
+  int64_t logical_length_;
+  int64_t physical_length_;
+  DenseBenchmark dense_benchmark_;
+
+  explicit REEBenchmark(const DenseBenchmarkArgs& args) : dense_benchmark_(args) {}
+
+  std::shared_ptr<Array> BuildSortedValues(int64_t logical_length) {
+    return Encode(dense_benchmark_.BuildSortedValues(logical_length / kAverageRunLength),
+                  logical_length);
+  }
+
+  std::shared_ptr<Array> BuildNeedles(int64_t logical_length) {
+    if constexpr (kREENeedles) {
+      return Encode(dense_benchmark_.BuildNeedles(logical_length / kAverageRunLength),
+                    logical_length);
+    } else {
+      return dense_benchmark_.BuildNeedles(logical_length);
+    }
+  }
+
+ protected:
+  std::shared_ptr<Array> Encode(std::shared_ptr<Array> values, int64_t logical_length) {
+    return dense_benchmark_.rand_.RunEndEncoded(values, logical_length);
+  }
+};
+
+template <typename DenseBenchmark>
+using REEValuesDenseNeedlesBenchmark =
+    REEBenchmark<DenseBenchmark, /*kREENeedles=*/false>;
+
+template <typename DenseBenchmark>
+using REEValuesREENeedlesBenchmark = REEBenchmark<DenseBenchmark, /*kREENeedles=*/true>;
+
+struct NoOpChunker {
+  Datum operator()(std::shared_ptr<Array> array) { return array; }
+};
+
+struct StaticChunker {
+  static constexpr int kNumChunks = 8;
+
+  Datum operator()(std::shared_ptr<Array> array) {
+    ArrayVector chunks;
+    int64_t chunk_start = 0;
+    for (int64_t i = 0; i < kNumChunks; ++i) {
+      const int64_t chunk_end = static_cast<int64_t>(
+          ceil(static_cast<double>(i + 1) / kNumChunks * array->length()));
+      chunks.push_back(
+          array->SliceSafe(chunk_start, chunk_end - chunk_start).ValueOrDie());
+      chunk_start = chunk_end;
+    }
+    ARROW_CHECK_EQ(chunk_start, array->length());
+    auto chunked_array = ChunkedArray::Make(std::move(chunks)).ValueOrDie();
+    ARROW_CHECK_EQ(chunked_array->length(), array->length());
+    return chunked_array;
+  }
+};
+
+void SetBenchmarkCounters(benchmark::State& state, const Datum& values,
+                          const Datum& needles, SearchSortedOptions::Side side) {
+  const auto needles_length = needles.length();
+  state.SetItemsProcessed(state.iterations() * needles_length);
+}
+
+void RunSearchSortedBenchmark(benchmark::State& state, const Datum& values,
+                              const Datum& needles, SearchSortedOptions::Side side) {
+  const SearchSortedOptions options(side);
+  for (auto _ : state) {
+    auto result = SearchSorted(values, needles, options);
+    ABORT_NOT_OK(result.status());
+    benchmark::DoNotOptimize(result.ValueUnsafe());
+  }
+  SetBenchmarkCounters(state, values, needles, side);
+}
+
+template <typename Benchmark, typename Chunker>
+void RunSearchSortedBenchmark(benchmark::State& state, DenseBenchmarkArgs args,
+                              Benchmark benchmark, SearchSortedOptions::Side side) {
+  Chunker chunker;
+  RunSearchSortedBenchmark(state,
+                           chunker(benchmark.BuildSortedValues(args.logical_length_)),
+                           chunker(benchmark.BuildNeedles(args.logical_length_)), side);
+}
+
+template <typename Benchmark, typename Chunker>
+void RunSearchSortedBenchmark(benchmark::State& state, DenseBenchmarkArgs args) {
+  RunSearchSortedBenchmark<Benchmark, Chunker>(state, args, Benchmark(args), args.side_);
+}
+
+static void SearchSortedDenseInt64Array(benchmark::State& state) {
+  RunSearchSortedBenchmark<Int64Benchmark, NoOpChunker>(state, DenseBenchmarkArgs(state));
+}
+
+static void SearchSortedDenseStringArray(benchmark::State& state) {
+  RunSearchSortedBenchmark<StringBenchmark, NoOpChunker>(state,
+                                                         DenseBenchmarkArgs(state));
+}
+
+static void SearchSortedDenseInt64ChunkedArray(benchmark::State& state) {
+  RunSearchSortedBenchmark<Int64Benchmark, StaticChunker>(state,
+                                                          DenseBenchmarkArgs(state));
+}
+
+static void SearchSortedDenseStringChunkedArray(benchmark::State& state) {
+  RunSearchSortedBenchmark<StringBenchmark, StaticChunker>(state,
+                                                           DenseBenchmarkArgs(state));
+}
+
+static void SearchSortedREEInt64Array(benchmark::State& state) {
+  RunSearchSortedBenchmark<REEValuesDenseNeedlesBenchmark<Int64Benchmark>, NoOpChunker>(
+      state, DenseBenchmarkArgs(state));
+}
+
+static void SearchSortedREEInt64ChunkedArray(benchmark::State& state) {
+  RunSearchSortedBenchmark<REEValuesDenseNeedlesBenchmark<Int64Benchmark>, StaticChunker>(
+      state, DenseBenchmarkArgs(state));
+}
+
+static void SearchSortedREEInt64ArrayREENeedles(benchmark::State& state) {
+  RunSearchSortedBenchmark<REEValuesREENeedlesBenchmark<Int64Benchmark>, NoOpChunker>(
+      state, DenseBenchmarkArgs(state));
+}
+
+static void SearchSortedREEInt64ChunkedArrayREENeedles(benchmark::State& state) {
+  RunSearchSortedBenchmark<REEValuesREENeedlesBenchmark<Int64Benchmark>, StaticChunker>(
+      state, DenseBenchmarkArgs(state));
+}
+
+BENCHMARK(SearchSortedDenseInt64Array)->Apply(DenseBenchmarkArgs::SetArgs);
+BENCHMARK(SearchSortedDenseStringArray)->Apply(DenseBenchmarkArgs::SetArgs);
+BENCHMARK(SearchSortedDenseInt64ChunkedArray)->Apply(DenseBenchmarkArgs::SetArgs);
+BENCHMARK(SearchSortedDenseStringChunkedArray)->Apply(DenseBenchmarkArgs::SetArgs);
+
+BENCHMARK(SearchSortedREEInt64Array)->Apply(DenseBenchmarkArgs::SetArgs);
+BENCHMARK(SearchSortedREEInt64ChunkedArray)->Apply(DenseBenchmarkArgs::SetArgs);
+
+BENCHMARK(SearchSortedREEInt64ArrayREENeedles)->Apply(DenseBenchmarkArgs::SetArgs);
+BENCHMARK(SearchSortedREEInt64ChunkedArrayREENeedles)->Apply(DenseBenchmarkArgs::SetArgs);
+
+}  // namespace compute
+}  // namespace arrow
diff --git a/cpp/src/arrow/compute/kernels/vector_search_sorted_test.cc b/cpp/src/arrow/compute/kernels/vector_search_sorted_test.cc
new file mode 100644
index 0000000..00144db
--- /dev/null
+++ b/cpp/src/arrow/compute/kernels/vector_search_sorted_test.cc
@@ -0,0 +1,672 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include <memory>
+#include <ostream>
+#include <string>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+#include "arrow/array/concatenate.h"
+#include "arrow/compute/api.h"
+#include "arrow/compute/kernels/test_util_internal.h"
+#include "arrow/testing/gtest_util.h"
+
+namespace arrow {
+
+using internal::checked_cast;
+
+namespace compute {
+namespace {
+
+Result<std::shared_ptr<Array>> REEFromJSON(const std::shared_ptr<DataType>& ree_type,
+                                           const std::string& json) {
+  auto ree_type_ptr = checked_cast<const RunEndEncodedType*>(ree_type.get());
+  auto array = ArrayFromJSON(ree_type_ptr->value_type(), json);
+  ARROW_ASSIGN_OR_RAISE(
+      auto datum, RunEndEncode(array, RunEndEncodeOptions{ree_type_ptr->run_end_type()}));
+  return datum.make_array();
+}
+
+void CheckSearchSorted(const Datum& values, const Datum& needles,
+                       SearchSortedOptions::Side side, const std::string& expected_json) {
+  ASSERT_OK_AND_ASSIGN(auto result,
+                       SearchSorted(values, needles, SearchSortedOptions(side)));
+  ASSERT_TRUE(result.is_array());
+  ASSERT_OK(result.make_array()->ValidateFull());
+
+  AssertArraysEqual(*ArrayFromJSON(uint64(), expected_json), *result.make_array());
+}
+
+void CheckSearchSorted(const Datum& values, const Datum& needles,
+                       const std::string& expected_left_json,
+                       const std::string& expected_right_json) {
+  CheckSearchSorted(values, needles, SearchSortedOptions::Left, expected_left_json);
+  CheckSearchSorted(values, needles, SearchSortedOptions::Right, expected_right_json);
+}
+
+void CheckSimpleSearchSorted(const std::shared_ptr<DataType>& type,
+                             const std::string& values_json,
+                             const std::string& needles_json,
+                             const std::string& expected_left_json,
+                             const std::string& expected_right_json) {
+  auto values = ArrayFromJSON(type, values_json);
+  auto needles = ArrayFromJSON(type, needles_json);
+
+  CheckSearchSorted(Datum(values), Datum(needles), expected_left_json,
+                    expected_right_json);
+}
+
+void CheckScalarSearchSorted(const Datum& values, const std::shared_ptr<Array>& needles,
+                             const std::string& expected_left_json,
+                             const std::string& expected_right_json) {
+  auto expected_left = ArrayFromJSON(uint64(), expected_left_json);
+  auto expected_right = ArrayFromJSON(uint64(), expected_right_json);
+
+  ASSERT_EQ(needles->length(), expected_left->length());
+  ASSERT_EQ(needles->length(), expected_right->length());
+
+  for (int64_t index = 0; index < needles->length(); ++index) {
+    ASSERT_OK_AND_ASSIGN(auto needle, needles->GetScalar(index));
+    ASSERT_OK_AND_ASSIGN(auto left,
+                         SearchSorted(values, Datum(needle),
+                                      SearchSortedOptions(SearchSortedOptions::Left)));
+    ASSERT_OK_AND_ASSIGN(auto right,
+                         SearchSorted(values, Datum(needle),
+                                      SearchSortedOptions(SearchSortedOptions::Right)));
+
+    ASSERT_TRUE(left.is_scalar());
+    ASSERT_TRUE(right.is_scalar());
+
+    ASSERT_OK_AND_ASSIGN(auto expected_left_scalar, expected_left->GetScalar(index));
+    ASSERT_OK_AND_ASSIGN(auto expected_right_scalar, expected_right->GetScalar(index));
+    AssertScalarsEqual(*expected_left_scalar, *left.scalar());
+    AssertScalarsEqual(*expected_right_scalar, *right.scalar());
+  }
+}
+
+void CheckSimpleScalarSearchSorted(const std::shared_ptr<DataType>& type,
+                                   const std::string& values_json,
+                                   const std::string& needles_json,
+                                   const std::string& expected_left_json,
+                                   const std::string& expected_right_json) {
+  auto values = ArrayFromJSON(type, values_json);
+  auto needles = ArrayFromJSON(type, needles_json);
+  CheckScalarSearchSorted(Datum(values), needles, expected_left_json,
+                          expected_right_json);
+}
+
+void CheckSimpleSearchSortedAndScalar(const std::shared_ptr<DataType>& type,
+                                      const std::string& values_json,
+                                      const std::string& needles_json,
+                                      const std::string& expected_left_json,
+                                      const std::string& expected_right_json) {
+  auto values = ArrayFromJSON(type, values_json);
+  auto needles = ArrayFromJSON(type, needles_json);
+
+  CheckSearchSorted(Datum(values), Datum(needles), expected_left_json,
+                    expected_right_json);
+  CheckScalarSearchSorted(Datum(values), needles, expected_left_json,
+                          expected_right_json);
+}
+
+void CheckChunkedSearchSortedAndConcatenated(const std::shared_ptr<ChunkedArray>& values,
+                                             const std::shared_ptr<ChunkedArray>& needles,
+                                             const std::string& expected_left_json,
+                                             const std::string& expected_right_json) {
+  CheckSearchSorted(Datum(values), Datum(needles), expected_left_json,
+                    expected_right_json);
+
+  ASSERT_OK_AND_ASSIGN(auto concatenated_values, Concatenate(values->chunks()));
+  ASSERT_OK_AND_ASSIGN(auto concatenated_needles, Concatenate(needles->chunks()));
+
+  CheckSearchSorted(Datum(concatenated_values), Datum(concatenated_needles),
+                    expected_left_json, expected_right_json);
+}
+
+const std::vector<std::shared_ptr<DataType>> kSupportedFloatTypes{float16(), float32(),
+                                                                  float64()};
+
+struct SearchSortedSmokeCase {
+  std::string name;
+  std::shared_ptr<DataType> type;
+  std::string values_json;
+  std::string needles_json;
+  std::string expected_left_json;
+  std::string expected_right_json;
+
+  // Define a custom print since the default Googletest print trips Valgrind
+  friend std::ostream& operator<<(std::ostream& os, const SearchSortedSmokeCase& param) {
+    os << "SearchSortedSmokeCase{\"" << param.name << "\"}";
+    return os;
+  }
+};
+
+std::vector<SearchSortedSmokeCase> SupportedTypeSmokeCases() {
+  return {
+      {"Boolean", boolean(), "[false, false, false, true, true]", "[false, true]",
+       "[0, 3]", "[3, 5]"},
+      {
+          "Int8",
+          int8(),
+          "[1, 3, 3, 5, 8]",
+          "[0, 3, 9]",
+          "[0, 1, 5]",
+          "[0, 3, 5]",
+      },
+      {
+          "Int16",
+          int16(),
+          "[1, 3, 3, 5, 8]",
+          "[0, 3, 9]",
+          "[0, 1, 5]",
+          "[0, 3, 5]",
+      },
+      {
+          "Int32",
+          int32(),
+          "[1, 3, 3, 5, 8]",
+          "[0, 3, 9]",
+          "[0, 1, 5]",
+          "[0, 3, 5]",
+      },
+      {
+          "Int64",
+          int64(),
+          "[1, 3, 3, 5, 8]",
+          "[0, 3, 9]",
+          "[0, 1, 5]",
+          "[0, 3, 5]",
+      },
+      {
+          "UInt8",
+          uint8(),
+          "[1, 3, 3, 5, 8]",
+          "[0, 3, 9]",
+          "[0, 1, 5]",
+          "[0, 3, 5]",
+      },
+      {
+          "UInt16",
+          uint16(),
+          "[1, 3, 3, 5, 8]",
+          "[0, 3, 9]",
+          "[0, 1, 5]",
+          "[0, 3, 5]",
+      },
+      {
+          "UInt32",
+          uint32(),
+          "[1, 3, 3, 5, 8]",
+          "[0, 3, 9]",
+          "[0, 1, 5]",
+          "[0, 3, 5]",
+      },
+      {
+          "UInt64",
+          uint64(),
+          "[1, 3, 3, 5, 8]",
+          "[0, 3, 9]",
+          "[0, 1, 5]",
+          "[0, 3, 5]",
+      },
+      {"Float16", float16(), "[1.0, 3.0, 3.0, 5.0, 8.0]", "[0.0, 3.0, 9.0]", "[0, 1, 5]",
+       "[0, 3, 5]"},
+      {"Float32", float32(), "[1.0, 3.0, 3.0, 5.0, 8.0]", "[0.0, 3.0, 9.0]", "[0, 1, 5]",
+       "[0, 3, 5]"},
+      {"Float64", float64(), "[1.0, 3.0, 3.0, 5.0, 8.0]", "[0.0, 3.0, 9.0]", "[0, 1, 5]",
+       "[0, 3, 5]"},
+      {
+          "Date32",
+          date32(),
+          "[1, 3, 3, 5, 8]",
+          "[0, 3, 9]",
+          "[0, 1, 5]",
+          "[0, 3, 5]",
+      },
+      {
+          "Date64",
+          date64(),
+          "[86400000, 259200000, 259200000, 432000000, 691200000]",
+          "[0, 259200000, 777600000]",
+          "[0, 1, 5]",
+          "[0, 3, 5]",
+      },
+      {"Time32", time32(TimeUnit::SECOND), "[1, 3, 3, 5, 8]", "[0, 3, 9]", "[0, 1, 5]",
+       "[0, 3, 5]"},
+      {"Time64", time64(TimeUnit::NANO), "[1, 3, 3, 5, 8]", "[0, 3, 9]", "[0, 1, 5]",
+       "[0, 3, 5]"},
+      {"Timestamp", timestamp(TimeUnit::SECOND),
+       R"(["1970-01-02", "1970-01-04", "1970-01-04", "1970-01-06", "1970-01-09"])",
+       R"(["1970-01-01", "1970-01-04", "1970-01-10"])", "[0, 1, 5]", "[0, 3, 5]"},
+      {"Duration", duration(TimeUnit::NANO), "[1, 3, 3, 5, 8]", "[0, 3, 9]", "[0, 1, 5]",
+       "[0, 3, 5]"},
+      {"Binary", binary(), R"(["aa", "bb", "bb", "dd", "ff"])",
+       R"(["a", "c", "bb", "z"])", "[0, 3, 1, 5]", "[0, 3, 3, 5]"},
+      {"String", utf8(), R"(["aa", "bb", "bb", "dd", "ff"])", R"(["a", "c", "bb", "z"])",
+       "[0, 3, 1, 5]", "[0, 3, 3, 5]"},
+      {"LargeBinary", large_binary(), R"(["aa", "bb", "bb", "dd", "ff"])",
+       R"(["a", "c", "bb", "z"])", "[0, 3, 1, 5]", "[0, 3, 3, 5]"},
+      {"LargeString", large_utf8(), R"(["aa", "bb", "bb", "dd", "ff"])",
+       R"(["a", "c", "bb", "z"])", "[0, 3, 1, 5]", "[0, 3, 3, 5]"},
+      {"BinaryView", binary_view(), R"(["aa", "bb", "bb", "dd", "ff"])",
+       R"(["a", "c", "bb", "z"])", "[0, 3, 1, 5]", "[0, 3, 3, 5]"},
+      {"StringView", utf8_view(), R"(["aa", "bb", "bb", "dd", "ff"])",
+       R"(["a", "c", "bb", "z"])", "[0, 3, 1, 5]", "[0, 3, 3, 5]"},
+  };
+}
+
+class SearchSortedSupportedTypesTest
+    : public ::testing::TestWithParam<SearchSortedSmokeCase> {};
+
+TEST(SearchSorted, BasicLeftRight) {
+  CheckSimpleSearchSorted(int64(), "[100, 200, 200, 300, 300]", "[50, 200, 250, 400]",
+                          "[0, 1, 3, 5]", "[0, 3, 3, 5]");
+}
+
+TEST(SearchSorted, ScalarNeedle) {
+  auto values = ArrayFromJSON(int32(), "[1, 3, 5, 7]");
+
+  ASSERT_OK_AND_ASSIGN(
+      auto result, SearchSorted(Datum(values), Datum(std::make_shared<Int32Scalar>(5)),
+                                SearchSortedOptions(SearchSortedOptions::Right)));
+
+  ASSERT_TRUE(result.is_scalar());
+  ASSERT_EQ(checked_cast<const UInt64Scalar&>(*result.scalar()).value, 3);
+}
+
+TEST(SearchSorted, ScalarStringNeedle) {
+  auto values = ArrayFromJSON(utf8(), R"(["aa", "bb", "bb", "cc"])");
+
+  ASSERT_OK_AND_ASSIGN(
+      auto result,
+      SearchSorted(Datum(values), Datum(std::make_shared<StringScalar>("bb")),
+                   SearchSortedOptions(SearchSortedOptions::Right)));
+
+  ASSERT_TRUE(result.is_scalar());
+  ASSERT_EQ(checked_cast<const UInt64Scalar&>(*result.scalar()).value, 3);
+}
+
+TEST(SearchSorted, EmptyHaystack) {
+  CheckSimpleSearchSorted(int16(), "[]", "[1, 2, 3]", "[0, 0, 0]", "[0, 0, 0]");
+}
+
+TEST(SearchSorted, ValuesWithLeadingNulls) {
+  CheckSimpleSearchSorted(int32(), "[null, 200, 300, 300]", "[50, 200, 250, 400]",
+                          "[1, 1, 2, 4]", "[1, 2, 2, 4]");
+}
+
+TEST(SearchSorted, ValuesWithTrailingNulls) {
+  CheckSimpleSearchSorted(int32(), "[200, 300, 300, null, null]", "[50, 200, 250, 400]",
+                          "[0, 0, 1, 3]", "[0, 1, 1, 3]");
+}
+
+TEST(SearchSorted, FloatValuesWithNaNs) {
+  for (const auto& type : kSupportedFloatTypes) {
+    // Nulls last, NaN in needles but not in haystack
+    CheckSimpleSearchSortedAndScalar(type, "[1.0, 3.0, 3.0, 5.0]",
+                                     "[3.0, 0.0, NaN, 4.0, 6.0]", "[1, 0, 4, 3, 4]",
+                                     "[3, 0, 4, 3, 4]");
+    CheckSimpleSearchSortedAndScalar(type, "[1.0, 3.0, 3.0, 5.0, null]",
+                                     "[3.0, 0.0, NaN, 4.0, 6.0]", "[1, 0, 4, 3, 4]",
+                                     "[3, 0, 4, 3, 4]");
+    // Nulls last, NaN in needles and in haystack
+    CheckSimpleSearchSortedAndScalar(type, "[1.0, 3.0, 3.0, 5.0, NaN, NaN]",
+                                     "[3.0, 0.0, NaN, 4.0]", "[1, 0, 4, 3]",
+                                     "[3, 0, 6, 3]");
+    CheckSimpleSearchSortedAndScalar(type, "[1.0, 3.0, 3.0, 5.0, NaN, NaN, null]",
+                                     "[3.0, 0.0, NaN, 4.0]", "[1, 0, 4, 3]",
+                                     "[3, 0, 6, 3]");
+    // Nulls first, NaN in needles but not in haystack
+    CheckSimpleSearchSortedAndScalar(type, "[null, 1.0, 3.0, 3.0, 5.0]",
+                                     "[3.0, 0.0, NaN, 4.0, 6.0]", "[2, 1, 1, 4, 5]",
+                                     "[4, 1, 1, 4, 5]");
+    // Nulls first, NaN in needles and in haystack
+    CheckSimpleSearchSortedAndScalar(type, "[NaN, NaN, NaN, 1.0, 3.0, 3.0, 5.0]",
+                                     "[3.0, 0.0, NaN, 4.0, 6.0]", "[4, 3, 0, 6, 7]",
+                                     "[6, 3, 3, 6, 7]");
+    CheckSimpleSearchSortedAndScalar(type, "[null, NaN, NaN, 1.0, 3.0, 3.0, 5.0]",
+                                     "[3.0, 0.0, NaN, 4.0, 6.0]", "[4, 3, 1, 6, 7]",
+                                     "[6, 3, 3, 6, 7]");
+  }
+}
+
+TEST(SearchSorted, NullNeedlesEmitNull) {
+  CheckSimpleSearchSorted(int32(), "[null, 200, 300, 300]", "[null, 50, 200, null, 400]",
+                          "[null, 1, 1, null, 4]", "[null, 1, 2, null, 4]");
+
+  auto values = ArrayFromJSON(int32(), "[null, 200, 300, 300]");
+
+  ASSERT_OK_AND_ASSIGN(auto scalar_result,
+                       SearchSorted(Datum(values), Datum(std::make_shared<Int32Scalar>()),
+                                    SearchSortedOptions(SearchSortedOptions::Left)));
+  ASSERT_TRUE(scalar_result.is_scalar());
+  ASSERT_FALSE(scalar_result.scalar()->is_valid);
+  ASSERT_TRUE(scalar_result.scalar()->type->Equals(uint64()));
+}
+
+TEST(SearchSorted, ChunkedValues) {
+  auto values = std::make_shared<ChunkedArray>(ArrayVector{
+      ArrayFromJSON(int32(), "[10, 10]"),
+      ArrayFromJSON(int32(), "[10, 30, 50]"),
+  });
+  auto needles = ArrayFromJSON(int32(), "[10, 20, 60]");
+  CheckSearchSorted(Datum(values), Datum(needles), "[0, 3, 5]", "[3, 3, 5]");
+
+  // Zero chunks
+  values = std::make_shared<ChunkedArray>(ArrayVector{}, int32());
+  CheckSearchSorted(Datum(values), Datum(needles), "[0, 0, 0]", "[0, 0, 0]");
+}
+
+TEST(SearchSorted, ChunkedNeedles) {
+  auto values = ArrayFromJSON(int32(), "[1, 1, 3, 5, 8]");
+  auto needles = std::make_shared<ChunkedArray>(ArrayVector{
+      ArrayFromJSON(int32(), "[null, 0, 1]"),
+      ArrayFromJSON(int32(), "[4, null, 9]"),
+  });
+  CheckSearchSorted(Datum(values), Datum(needles), "[null, 0, 0, 3, null, 5]",
+                    "[null, 0, 2, 3, null, 5]");
+
+  // Zero chunks
+  needles = std::make_shared<ChunkedArray>(ArrayVector{}, int32());
+  CheckSearchSorted(Datum(values), Datum(needles), "[]", "[]");
+}
+
+TEST(SearchSorted, ChunkedValuesChunkedNeedles) {
+  auto values = std::make_shared<ChunkedArray>(ArrayVector{
+      ArrayFromJSON(int32(), "[1, 1]"),
+      ArrayFromJSON(int32(), "[3]"),
+      ArrayFromJSON(int32(), "[5, 8]"),
+  });
+  auto needles = std::make_shared<ChunkedArray>(ArrayVector{
+      ArrayFromJSON(int32(), "[null, 0, 1]"),
+      ArrayFromJSON(int32(), "[4]"),
+      ArrayFromJSON(int32(), "[null, 9]"),
+  });
+
+  CheckChunkedSearchSortedAndConcatenated(values, needles, "[null, 0, 0, 3, null, 5]",
+                                          "[null, 0, 2, 3, null, 5]");
+}
+
+TEST(SearchSorted, ChunkedFloatValuesWithNaNs) {
+  for (const auto& type : kSupportedFloatTypes) {
+    auto needles = ArrayFromJSON(type, "[3.0, 0.0, NaN, 4.0, 6.0]");
+    {
+      // Nulls last
+      auto values_without_nans =
+          ChunkedArrayFromJSON(type, {"[]", "[1.0, 3.0]", "[3.0, 5.0]"});
+      // (stress NullPlacement detection by chunking in different ways)
+      auto values_with_nans1 =
+          ChunkedArrayFromJSON(type, {"[]", "[1.0, 3.0]", "[3.0, 5.0, NaN, NaN]"});
+      auto values_with_nans2 =
+          ChunkedArrayFromJSON(type, {"[]", "[1.0, 3.0]", "[3.0, 5.0]", "[NaN, NaN]"});
+      auto values_with_nans_and_nulls1 = ChunkedArrayFromJSON(
+          type, {"[]", "[1.0, 3.0]", "[3.0, 5.0, NaN, NaN, null, null]"});
+      auto values_with_nans_and_nulls2 = ChunkedArrayFromJSON(
+          type, {"[]", "[1.0, 3.0]", "[3.0, 5.0]", "[NaN, NaN]", "[null, null]"});
+      auto values_with_nans_and_nulls3 = ChunkedArrayFromJSON(
+          type, {"[]", "[1.0, 3.0]", "[3.0, 5.0]", "[NaN]", "[NaN, null, null]"});
+      CheckSearchSorted(Datum(values_without_nans), Datum(needles), "[1, 0, 4, 3, 4]",
+                        "[3, 0, 4, 3, 4]");
+      CheckSearchSorted(Datum(values_with_nans1), Datum(needles), "[1, 0, 4, 3, 4]",
+                        "[3, 0, 6, 3, 4]");
+      CheckSearchSorted(Datum(values_with_nans2), Datum(needles), "[1, 0, 4, 3, 4]",
+                        "[3, 0, 6, 3, 4]");
+      CheckSearchSorted(Datum(values_with_nans_and_nulls1), Datum(needles),
+                        "[1, 0, 4, 3, 4]", "[3, 0, 6, 3, 4]");
+      CheckSearchSorted(Datum(values_with_nans_and_nulls2), Datum(needles),
+                        "[1, 0, 4, 3, 4]", "[3, 0, 6, 3, 4]");
+      CheckSearchSorted(Datum(values_with_nans_and_nulls3), Datum(needles),
+                        "[1, 0, 4, 3, 4]", "[3, 0, 6, 3, 4]");
+    }
+    {
+      // Nulls first
+      auto values_with_nans1 =
+          ChunkedArrayFromJSON(type, {"[]", "[NaN, NaN, 1.0, 3.0]", "[3.0, 5.0]"});
+      auto values_with_nans2 =
+          ChunkedArrayFromJSON(type, {"[]", "[NaN, NaN]", "[1.0, 3.0]", "[3.0, 5.0]"});
+      auto values_with_nans_and_nulls1 = ChunkedArrayFromJSON(
+          type, {"[]", "[null, null, NaN, NaN, 1.0, 3.0]", "[3.0, 5.0]"});
+      auto values_with_nans_and_nulls2 = ChunkedArrayFromJSON(
+          type, {"[]", "[null, null, NaN]", "[NaN, 1.0, 3.0]", "[3.0, 5.0]"});
+      auto values_with_nans_and_nulls3 = ChunkedArrayFromJSON(
+          type, {"[]", "[null, null]", "[NaN]", "[NaN, 1.0, 3.0]", "[3.0, 5.0]"});
+      CheckSearchSorted(Datum(values_with_nans1), Datum(needles), "[3, 2, 0, 5, 6]",
+                        "[5, 2, 2, 5, 6]");
+      CheckSearchSorted(Datum(values_with_nans2), Datum(needles), "[3, 2, 0, 5, 6]",
+                        "[5, 2, 2, 5, 6]");
+      CheckSearchSorted(Datum(values_with_nans_and_nulls1), Datum(needles),
+                        "[5, 4, 2, 7, 8]", "[7, 4, 4, 7, 8]");
+      CheckSearchSorted(Datum(values_with_nans_and_nulls2), Datum(needles),
+                        "[5, 4, 2, 7, 8]", "[7, 4, 4, 7, 8]");
+      CheckSearchSorted(Datum(values_with_nans_and_nulls3), Datum(needles),
+                        "[5, 4, 2, 7, 8]", "[7, 4, 4, 7, 8]");
+    }
+  }
+}
+
+TEST(SearchSorted, ChunkedRunEndEncodedValues) {
+  auto values_type = run_end_encoded(int16(), int32());
+  ASSERT_OK_AND_ASSIGN(auto left_chunk, REEFromJSON(values_type, "[10, 10, 10]"));
+  ASSERT_OK_AND_ASSIGN(auto right_chunk, REEFromJSON(values_type, "[30, 30, 50]"));
+  auto values = std::make_shared<ChunkedArray>(ArrayVector{left_chunk, right_chunk});
+  auto needles = ArrayFromJSON(int32(), "[5, 10, 20, 30, 40, 50, 60]");
+
+  CheckSearchSorted(Datum(values), Datum(needles), "[0, 0, 3, 3, 5, 5, 6]",
+                    "[0, 3, 3, 5, 5, 6, 6]");
+}
+
+TEST(SearchSorted, SlicedChunkedRunEndEncodedValues) {
+  auto values_type = run_end_encoded(int16(), int32());
+  ASSERT_OK_AND_ASSIGN(auto left_chunk, REEFromJSON(values_type, "[10, 10, 10]"));
+  ASSERT_OK_AND_ASSIGN(auto right_chunk, REEFromJSON(values_type, "[30, 30, 50]"));
+  auto values = std::make_shared<ChunkedArray>(
+      ArrayVector{left_chunk->Slice(1, 2), right_chunk->Slice(0, 2)});
+  auto needles = ArrayFromJSON(int32(), "[5, 10, 20, 30, 40, 50]");
+
+  CheckSearchSorted(Datum(values), Datum(needles), "[0, 0, 2, 2, 4, 4]",
+                    "[0, 2, 2, 4, 4, 4]");
+}
+
+TEST(SearchSorted, ChunkedRunEndEncodedNeedles) {
+  auto values = ArrayFromJSON(int32(), "[1, 1, 3, 5, 8]");
+  auto needles_type = run_end_encoded(int32(), int32());
+  ASSERT_OK_AND_ASSIGN(auto left_chunk, REEFromJSON(needles_type, "[0, 0, 1, 1]"));
+  ASSERT_OK_AND_ASSIGN(auto right_chunk, REEFromJSON(needles_type, "[4, 4, 9]"));
+  auto needles = std::make_shared<ChunkedArray>(ArrayVector{left_chunk, right_chunk});
+
+  CheckSearchSorted(Datum(values), Datum(needles), SearchSortedOptions::Right,
+                    "[0, 0, 2, 2, 3, 3, 5]");
+}
+
+TEST(SearchSorted, ChunkedRunEndEncodedValuesLeadingNullsAcrossEmptyChunks) {
+  auto values_type = run_end_encoded(int16(), int32());
+  ASSERT_OK_AND_ASSIGN(auto empty_chunk, REEFromJSON(values_type, "[]"));
+  ASSERT_OK_AND_ASSIGN(auto null_chunk, REEFromJSON(values_type, "[null, null]"));
+  ASSERT_OK_AND_ASSIGN(auto data_chunk, REEFromJSON(values_type, "[2, 4, 4]"));
+  auto values = std::make_shared<ChunkedArray>(
+      ArrayVector{empty_chunk, null_chunk, empty_chunk, data_chunk});
+  auto needles = ArrayFromJSON(int32(), "[1, 4, 8]");
+
+  CheckSearchSorted(Datum(values), Datum(needles), "[2, 3, 5]", "[2, 5, 5]");
+}
+
+TEST(SearchSorted, ChunkedRunEndEncodedValuesTrailingNullsAcrossEmptyChunks) {
+  auto values_type = run_end_encoded(int16(), int32());
+  ASSERT_OK_AND_ASSIGN(auto data_chunk, REEFromJSON(values_type, "[2, 4, 4]"));
+  ASSERT_OK_AND_ASSIGN(auto empty_chunk, REEFromJSON(values_type, "[]"));
+  ASSERT_OK_AND_ASSIGN(auto null_chunk, REEFromJSON(values_type, "[null, null]"));
+  auto values =
+      std::make_shared<ChunkedArray>(ArrayVector{data_chunk, empty_chunk, null_chunk});
+  auto needles = ArrayFromJSON(int32(), "[1, 4, 8]");
+
+  CheckSearchSorted(Datum(values), Datum(needles), "[0, 1, 3]", "[0, 3, 3]");
+}
+
+TEST(SearchSorted, ChunkedValuesLeadingNullsAcrossEmptyChunks) {
+  auto values = ChunkedArrayFromJSON(int32(), {"[]", "[null, null]", "[]", "[2, 4, 4]"});
+  auto needles = ArrayFromJSON(int32(), "[1, 4, 8]");
+
+  CheckSearchSorted(Datum(values), Datum(needles), "[2, 3, 5]", "[2, 5, 5]");
+}
+
+TEST(SearchSorted, ChunkedValuesTrailingNullsAcrossEmptyChunks) {
+  auto values = ChunkedArrayFromJSON(int32(), {"[]", "[2, 4, 4]", "[]", "[null, null]"});
+  auto needles = ArrayFromJSON(int32(), "[1, 4, 8]");
+
+  CheckSearchSorted(Datum(values), Datum(needles), "[0, 1, 3]", "[0, 3, 3]");
+}
+
+TEST(SearchSorted, RunEndEncodedDate32Values) {
+  auto values_type = run_end_encoded(int16(), date32());
+  ASSERT_OK_AND_ASSIGN(auto values, REEFromJSON(values_type, "[1, 1, 3]"));
+  auto needles = ArrayFromJSON(date32(), "[2]");
+
+  CheckSearchSorted(Datum(values), Datum(needles), "[2]", "[2]");
+}
+
+TEST(SearchSorted, RunEndEncodedNulls) {
+  auto values_type = run_end_encoded(int16(), int32());
+  ASSERT_OK_AND_ASSIGN(auto ree_values,
+                       REEFromJSON(values_type, "[null, null, 2, 4, 4]"));
+  auto needles_type = run_end_encoded(int16(), int32());
+  ASSERT_OK_AND_ASSIGN(auto ree_needles,
+                       REEFromJSON(needles_type, "[null, null, 1, 4, 4, null, 8]"));
+
+  CheckSearchSorted(Datum(ree_values), Datum(ree_needles),
+                    "[null, null, 2, 3, 3, null, 5]", "[null, null, 2, 5, 5, null, 5]");
+}
+
+TEST(SearchSorted, RunEndEncodedValuesWithTrailingNulls) {
+  auto values_type = run_end_encoded(int16(), int32());
+  ASSERT_OK_AND_ASSIGN(auto ree_values,
+                       REEFromJSON(values_type, "[2, 4, 4, null, null]"));
+  auto needles = ArrayFromJSON(int32(), "[1, 4, 8]");
+
+  CheckSearchSorted(Datum(ree_values), Datum(needles), "[0, 1, 3]", "[0, 3, 3]");
+}
+
+TEST(SearchSorted, SlicedRunEndEncodedValuesIgnoreNullRunsOutsideSlice) {
+  auto values_type = run_end_encoded(int16(), int32());
+  ASSERT_OK_AND_ASSIGN(auto ree_values,
+                       REEFromJSON(values_type, "[null, null, 2, 4, 4, null]"));
+  auto sliced = ree_values->Slice(2, 3);
+  auto needles = ArrayFromJSON(int32(), "[1, 4, 8]");
+
+  CheckSearchSorted(Datum(sliced), Datum(needles), "[0, 1, 3]", "[0, 3, 3]");
+}
+
+TEST(SearchSorted, RunEndEncodedNeedlesWithNullRuns) {
+  auto values = ArrayFromJSON(int32(), "[1, 1, 3, 5, 8]");
+  auto needles_type = run_end_encoded(int32(), int32());
+  ASSERT_OK_AND_ASSIGN(
+      auto ree_needles,
+      REEFromJSON(needles_type, "[null, null, 0, 0, 0, 1, 1, 4, 4, 4, null, 9, 9]"));
+
+  CheckSearchSorted(Datum(values), Datum(ree_needles),
+                    "[null, null, 0, 0, 0, 0, 0, 3, 3, 3, null, 5, 5]",
+                    "[null, null, 0, 0, 0, 2, 2, 3, 3, 3, null, 5, 5]");
+}
+
+TEST(SearchSorted, RejectMismatchedTypes) {
+  auto values = ArrayFromJSON(int32(), "[1, 2, 3]");
+  auto needles = ArrayFromJSON(int64(), "[2]");
+
+  ASSERT_RAISES(TypeError, SearchSorted(Datum(values), Datum(needles)));
+}
+
+TEST(SearchSorted, RunEndEncodedValues) {
+  auto values_type = run_end_encoded(int16(), int32());
+  ASSERT_OK_AND_ASSIGN(auto ree_values, REEFromJSON(values_type, "[1, 1, 1, 3, 3, 5]"));
+  auto needles = ArrayFromJSON(int32(), "[0, 1, 2, 3, 4, 5, 6]");
+
+  CheckSearchSorted(Datum(ree_values), Datum(needles), "[0, 0, 3, 3, 5, 5, 6]",
+                    "[0, 3, 3, 5, 5, 6, 6]");
+}
+
+TEST(SearchSorted, RunEndEncodedNeedles) {
+  auto values = ArrayFromJSON(int32(), "[1, 1, 3, 5, 8]");
+  auto needles_type = run_end_encoded(int32(), int32());
+  ASSERT_OK_AND_ASSIGN(auto ree_needles,
+                       REEFromJSON(needles_type, "[0, 0, 1, 1, 4, 4, 9]"));
+
+  CheckSearchSorted(Datum(values), Datum(ree_needles), SearchSortedOptions::Right,
+                    "[0, 0, 2, 2, 3, 3, 5]");
+}
+
+TEST(SearchSorted, SlicedRunEndEncodedNeedles) {
+  auto values = ArrayFromJSON(int32(), "[1, 1, 3, 5, 8]");
+  auto needles_type = run_end_encoded(int32(), int32());
+  ASSERT_OK_AND_ASSIGN(auto ree_needles,
+                       REEFromJSON(needles_type, "[null, 0, 0, 1, 1, 4, 4, 9, null]"));
+  auto sliced = ree_needles->Slice(1, 7);
+
+  CheckSearchSorted(Datum(values), Datum(sliced), "[0, 0, 0, 0, 3, 3, 5]",
+                    "[0, 0, 2, 2, 3, 3, 5]");
+}
+
+TEST(SearchSorted, SlicedRunEndEncodedValues) {
+  auto values_type = run_end_encoded(int32(), int32());
+  ASSERT_OK_AND_ASSIGN(auto ree_values,
+                       REEFromJSON(values_type, "[10, 10, 20, 20, 20, 40, 40, 90]"));
+  auto sliced = ree_values->Slice(1, 5);
+  auto needles = ArrayFromJSON(int32(), "[5, 10, 20, 30, 40, 80, 90, 100]");
+  CheckSearchSorted(Datum(sliced), Datum(needles), "[0, 0, 1, 4, 4, 5, 5, 5]",
+                    "[0, 1, 4, 4, 5, 5, 5, 5]");
+
+  sliced = ree_values->Slice(3, 4);
+  CheckSearchSorted(Datum(sliced), Datum(needles), "[0, 0, 0, 2, 2, 4, 4, 4]",
+                    "[0, 0, 2, 2, 4, 4, 4, 4]");
+}
+
+TEST(SearchSorted, SlicedRunEndEncodedValuesWithLeadingNulls) {
+  auto values_type = run_end_encoded(int32(), int32());
+  ASSERT_OK_AND_ASSIGN(
+      auto ree_values,
+      REEFromJSON(values_type, "[null, null, 10, 20, 20, 20, 40, 40, 90]"));
+  auto sliced = ree_values->Slice(1, 5);
+  auto needles = ArrayFromJSON(int32(), "[5, 10, 20, 30, 40, 50]");
+  CheckSearchSorted(Datum(sliced), Datum(needles), "[1, 1, 2, 5, 5, 5]",
+                    "[1, 2, 5, 5, 5, 5]");
+
+  sliced = ree_values->Slice(3, 4);
+  CheckSearchSorted(Datum(sliced), Datum(needles), "[0, 0, 0, 3, 3, 4]",
+                    "[0, 0, 3, 3, 4, 4]");
+}
+
+TEST_P(SearchSortedSupportedTypesTest, ArraySmoke) {
+  const auto& param = GetParam();
+  CheckSimpleSearchSorted(param.type, param.values_json, param.needles_json,
+                          param.expected_left_json, param.expected_right_json);
+}
+
+TEST_P(SearchSortedSupportedTypesTest, ScalarSmoke) {
+  const auto& param = GetParam();
+  CheckSimpleScalarSearchSorted(param.type, param.values_json, param.needles_json,
+                                param.expected_left_json, param.expected_right_json);
+}
+
+INSTANTIATE_TEST_SUITE_P(SupportedTypes, SearchSortedSupportedTypesTest,
+                         ::testing::ValuesIn(SupportedTypeSmokeCases()),
+                         [](const ::testing::TestParamInfo<SearchSortedSmokeCase>& info) {
+                           return info.param.name;
+                         });
+
+}  // namespace
+}  // namespace compute
+}  // namespace arrow
diff --git a/cpp/src/arrow/compute/kernels/vector_sort_internal.h b/cpp/src/arrow/compute/kernels/vector_sort_internal.h
index c19acc5..8bb10ef 100644
--- a/cpp/src/arrow/compute/kernels/vector_sort_internal.h
+++ b/cpp/src/arrow/compute/kernels/vector_sort_internal.h
@@ -31,6 +31,8 @@
 #include "arrow/table.h"
 #include "arrow/type.h"
 #include "arrow/type_traits.h"
+#include "arrow/util/float16.h"
+#include "arrow/util/math_internal.h"
 
 namespace arrow::compute::internal {
 
@@ -77,19 +79,24 @@
 
 template <typename TypeClass>
 constexpr bool has_null_like_values() {
-  return is_physical_floating(TypeClass::type_id);
+  return is_floating(TypeClass::type_id);
+}
+
+template <typename TypeClass, typename Value>
+bool is_null_like(Value value) {
+  return ::arrow::internal::IsNan(value);
 }
 
 // Compare two values, taking NaNs into account
 
 template <typename Type, typename Value>
 int CompareTypeValues(Value&& left, Value&& right, SortOrder order,
-                      NullPlacement null_placement) {
+                      NullPlacement null_placement, int on_equality_result = 0) {
   if constexpr (has_null_like_values<Type>()) {
-    const bool is_nan_left = std::isnan(left);
-    const bool is_nan_right = std::isnan(right);
+    const bool is_nan_left = is_null_like<Type>(left);
+    const bool is_nan_right = is_null_like<Type>(right);
     if (is_nan_left && is_nan_right) {
-      return 0;
+      return on_equality_result;
     } else if (is_nan_left) {
       return null_placement == NullPlacement::AtStart ? -1 : 1;
     } else if (is_nan_right) {
@@ -98,7 +105,7 @@
   }
   int compared;
   if (left == right) {
-    compared = 0;
+    compared = on_equality_result;
   } else if (left > right) {
     compared = 1;
   } else {
@@ -252,17 +259,22 @@
 template <typename ArrayType, typename Partitioner>
 NanPartition PartitionNans(std::span<uint64_t> indices, const ArrayType& values,
                            int64_t offset, NullPlacement null_placement) {
-  if constexpr (has_null_like_values<typename ArrayType::TypeClass>()) {
+  using TypeClass = typename ArrayType::TypeClass;
+  if constexpr (has_null_like_values<TypeClass>()) {
     Partitioner partitioner;
     if (null_placement == NullPlacement::AtStart) {
       auto non_null_like_tail = partitioner(indices, [&values, &offset](uint64_t ind) {
-        return std::isnan(values.GetView(static_cast<int64_t>(ind) - offset));
+        const auto value = GetViewType<TypeClass>::LogicalValue(
+            values.GetView(static_cast<int64_t>(ind) - offset));
+        return is_null_like<TypeClass>(value);
       });
       return NanPartition{.non_null_like_range = non_null_like_tail,
                           .nan_range = {indices.data(), non_null_like_tail.data()}};
     } else {
       auto nan_tail = partitioner(indices, [&values, &offset](uint64_t ind) {
-        return !std::isnan(values.GetView(static_cast<int64_t>(ind) - offset));
+        const auto value = GetViewType<TypeClass>::LogicalValue(
+            values.GetView(static_cast<int64_t>(ind) - offset));
+        return !is_null_like<TypeClass>(value);
       });
       return NanPartition{.non_null_like_range = {indices.data(), nan_tail.data()},
                           .nan_range = nan_tail};
diff --git a/cpp/src/arrow/compute/registry_internal.h b/cpp/src/arrow/compute/registry_internal.h
index 5b9d7f8..d457f6f 100644
--- a/cpp/src/arrow/compute/registry_internal.h
+++ b/cpp/src/arrow/compute/registry_internal.h
@@ -50,6 +50,7 @@
 void RegisterVectorNested(FunctionRegistry* registry);
 void RegisterVectorRank(FunctionRegistry* registry);
 void RegisterVectorReplace(FunctionRegistry* registry);
+void RegisterVectorSearchSorted(FunctionRegistry* registry);
 void RegisterVectorSelectK(FunctionRegistry* registry);
 void RegisterVectorSelection(FunctionRegistry* registry);
 void RegisterVectorSort(FunctionRegistry* registry);
diff --git a/cpp/src/arrow/meson.build b/cpp/src/arrow/meson.build
index fea26ef..418165e 100644
--- a/cpp/src/arrow/meson.build
+++ b/cpp/src/arrow/meson.build
@@ -610,6 +610,7 @@
         'compute/kernels/vector_rank.cc',
         'compute/kernels/vector_replace.cc',
         'compute/kernels/vector_run_end_encode.cc',
+        'compute/kernels/vector_search_sorted.cc',
         'compute/kernels/vector_select_k.cc',
         'compute/kernels/vector_sort.cc',
         'compute/kernels/vector_statistics.cc',
diff --git a/cpp/src/arrow/testing/random.cc b/cpp/src/arrow/testing/random.cc
index ce73b3f..db7a60e 100644
--- a/cpp/src/arrow/testing/random.cc
+++ b/cpp/src/arrow/testing/random.cc
@@ -966,24 +966,42 @@
 }
 
 std::shared_ptr<Array> RandomArrayGenerator::RunEndEncoded(
-    std::shared_ptr<DataType> value_type, int64_t logical_size, double null_probability) {
-  Int32Builder run_ends_builder;
-  pcg32 rng(seed());
+    std::shared_ptr<DataType> value_type, int64_t logical_size, double null_probability,
+    int64_t average_run_length) {
+  const auto physical_size = std::max<int64_t>(1, logical_size / average_run_length);
+  std::shared_ptr<Array> values =
+      ArrayOf(std::move(value_type), physical_size, null_probability);
+  return RunEndEncoded(values, logical_size);
+}
 
-  DCHECK_LE(logical_size, std::numeric_limits<int32_t>::max());
-
-  std::uniform_int_distribution<int64_t> distribution(1, 100);
-  int64_t current_end = 0;
-  while (current_end < logical_size) {
-    current_end += distribution(rng);
-    current_end = std::min(current_end, logical_size);
-    ARROW_CHECK_OK(run_ends_builder.Append(static_cast<int32_t>(current_end)));
+std::shared_ptr<Array> RandomArrayGenerator::RunEndEncoded(
+    const std::shared_ptr<Array>& values, int64_t logical_size) {
+  if (logical_size == 0) {
+    return MakeEmptyArray(run_end_encoded(int32(), values->type())).ValueOrDie();
   }
 
-  std::shared_ptr<Array> run_ends = *run_ends_builder.Finish();
-  std::shared_ptr<Array> values =
-      ArrayOf(std::move(value_type), run_ends->length(), null_probability);
+  ARROW_CHECK_GT(values->length(), 0);
+  ARROW_CHECK_LE(logical_size, std::numeric_limits<int32_t>::max());
+  ARROW_CHECK_GE(logical_size, values->length());
 
+  int64_t current_end = 0;
+  Int32Builder run_ends_builder;
+  ARROW_CHECK_OK(run_ends_builder.Reserve(values->length()));
+
+  // Ideally, we would generate random run-ends, but we need to make both unique
+  // and monotonic, so we use the same (approximate) run length instead.
+  for (int64_t i = 0; i < values->length(); ++i) {
+    auto remaining_logical_size = logical_size - current_end;
+    auto remaining_physical_size = values->length() - i;
+    // Runs must never be empty.
+    auto run_length = static_cast<int64_t>(
+        ceil(static_cast<double>(remaining_logical_size) / remaining_physical_size));
+    current_end += run_length;
+    run_ends_builder.UnsafeAppend(static_cast<int32_t>(current_end));
+  }
+  DCHECK_LE(current_end, logical_size);
+
+  std::shared_ptr<Array> run_ends = *run_ends_builder.Finish();
   return RunEndEncodedArray::Make(logical_size, run_ends, values).ValueOrDie();
 }
 
diff --git a/cpp/src/arrow/testing/random.h b/cpp/src/arrow/testing/random.h
index bc21307..a953547 100644
--- a/cpp/src/arrow/testing/random.h
+++ b/cpp/src/arrow/testing/random.h
@@ -560,7 +560,20 @@
   /// \return a generated Array
   std::shared_ptr<Array> RunEndEncoded(std::shared_ptr<DataType> value_type,
                                        int64_t logical_size,
-                                       double null_probability = 0.0);
+                                       double null_probability = 0.0,
+                                       int64_t average_run_length = 50);
+
+  /// \brief Generate a random RunEndEncodedArray
+  ///
+  /// \param[in] values The underlying physical values
+  /// \param[in] logical_size The logical length of the generated array
+  ///
+  /// `logical_size` must be at least as large as the length of `values`,
+  /// and `values` must be non-empty if `logical_size` is non-zero.
+  ///
+  /// \return a generated Array
+  std::shared_ptr<Array> RunEndEncoded(const std::shared_ptr<Array>& values,
+                                       int64_t logical_size);
 
   /// \brief Generate a random SparseUnionArray
   ///
diff --git a/cpp/src/arrow/testing/random_test.cc b/cpp/src/arrow/testing/random_test.cc
index 279fb6d..e1fddbd 100644
--- a/cpp/src/arrow/testing/random_test.cc
+++ b/cpp/src/arrow/testing/random_test.cc
@@ -647,20 +647,48 @@
 TEST(RandomRunEndEncoded, Basics) {
   random::RandomArrayGenerator rng(42);
   for (const double null_probability : {0.0, 0.1, 1.0}) {
-    SCOPED_TRACE("null_probability = " + std::to_string(null_probability));
-    auto array = rng.ArrayOf(run_end_encoded(int32(), int16()), 12345, null_probability);
+    ARROW_SCOPED_TRACE("null_probability = ", null_probability);
+    for (const int64_t logical_length : {12345}) {
+      auto check_run_end_encoded = [&](const std::shared_ptr<Array>& array,
+                                       int64_t average_run_length) {
+        ASSERT_OK(array->ValidateFull());
+        ASSERT_EQ(array->length(), logical_length);
+        const auto& ree_array = checked_cast<const RunEndEncodedArray&>(*array);
+        ASSERT_EQ(*ree_array.type(), *run_end_encoded(int32(), int16()));
+        const int64_t physical_length = ree_array.run_ends()->length();
+        ASSERT_EQ(ree_array.values()->length(), physical_length);
+        const auto actual_average_run_length =
+            static_cast<double>(logical_length) / physical_length;
+        ASSERT_GE(actual_average_run_length, average_run_length * 0.9);
+        ASSERT_LE(actual_average_run_length, average_run_length * 1.1);
+        if (null_probability == 0.0) {
+          ASSERT_EQ(ree_array.values()->null_count(), 0);
+        }
+        if (null_probability == 1.0) {
+          ASSERT_EQ(ree_array.values()->null_count(), physical_length);
+        }
+      };
+
+      auto array = rng.ArrayOf(run_end_encoded(int32(), int16()), logical_length,
+                               null_probability);
+      // 50 is the default value in RandomArrayGenerator::RunEndEncoded
+      check_run_end_encoded(array, /*average_run_length=*/50);
+
+      for (const int64_t physical_length : {100, 1000}) {
+        auto values =
+            rng.Int16(physical_length, /*min=*/0, /*max=*/16384, null_probability);
+        auto array = rng.RunEndEncoded(values, logical_length);
+        check_run_end_encoded(array,
+                              /*average_run_length=*/logical_length / physical_length);
+      }
+    }
+  }
+  // Small-sized REE arrays should be generated adequately
+  for (const int64_t logical_length : {0, 1, 10, 55}) {
+    auto array = rng.ArrayOf(run_end_encoded(int32(), int16()), logical_length,
+                             /*null_probability=*/0.2);
     ASSERT_OK(array->ValidateFull());
-    ASSERT_EQ(array->length(), 12345);
-    const auto& ree_array = checked_cast<const RunEndEncodedArray&>(*array);
-    ASSERT_EQ(*ree_array.type(), *run_end_encoded(int32(), int16()));
-    const int64_t physical_length = ree_array.run_ends()->length();
-    ASSERT_EQ(ree_array.values()->length(), physical_length);
-    if (null_probability == 0.0) {
-      ASSERT_EQ(ree_array.values()->null_count(), 0);
-    }
-    if (null_probability == 1.0) {
-      ASSERT_EQ(ree_array.values()->null_count(), physical_length);
-    }
+    ASSERT_EQ(array->length(), logical_length);
   }
 }
 
diff --git a/cpp/src/arrow/util/math_internal.h b/cpp/src/arrow/util/math_internal.h
index a57083c..0375fc5 100644
--- a/cpp/src/arrow/util/math_internal.h
+++ b/cpp/src/arrow/util/math_internal.h
@@ -21,6 +21,7 @@
 #include <cmath>
 #include <initializer_list>
 
+#include "arrow/util/float16.h"
 #include "arrow/util/macros.h"
 #include "arrow/util/visibility.h"
 
@@ -75,4 +76,8 @@
 static_assert(ReversePow2(4) == 2);
 static_assert(ReversePow2(2) == 1);
 
+inline bool IsNan(float v) { return std::isnan(v); }
+inline bool IsNan(double v) { return std::isnan(v); }
+inline bool IsNan(::arrow::util::Float16 v) { return v.is_nan(); }
+
 }  // namespace arrow::internal
diff --git a/docs/source/cpp/compute.rst b/docs/source/cpp/compute.rst
index 1e067c5..f24fea2 100644
--- a/docs/source/cpp/compute.rst
+++ b/docs/source/cpp/compute.rst
@@ -1879,6 +1879,8 @@
 +-----------------------+------------+---------------------------------------------------------+-------------------+-------------------------------+----------------+
 | sort_indices          | Unary      | Boolean, Numeric, Temporal, Binary- and String-like     | UInt64            | :struct:`SortOptions`         | \(1) \(6)      |
 +-----------------------+------------+---------------------------------------------------------+-------------------+-------------------------------+----------------+
+| search_sorted         | Binary     | Boolean, Numeric, Temporal, Binary- and String-like     | UInt64            | :struct:`SearchSortedOptions` | \(8)           |
++-----------------------+------------+---------------------------------------------------------+-------------------+-------------------------------+----------------+
 
 
 * \(1) The output is an array of indices into the input, that define a
@@ -1907,6 +1909,13 @@
 * \(7) The output is an array of indices into the input, that define a
   non-stable sort of the input.
 
+* \(8) The first argument must be sorted in ascending order. If it contains
+  nulls, they must be clustered entirely at the start or the end, and non-null
+  needles are matched only against the non-null portion. The second argument
+  may be a scalar, array, or run-end encoded array. Null needles yield null
+  outputs. Both arguments must have the same logical type. A scalar needle
+  yields a UInt64 scalar; otherwise the result is a UInt64 array.
+
 .. _cpp-compute-vector-structural-transforms:
 
 Structural transforms
diff --git a/python/pyarrow/_compute.pyx b/python/pyarrow/_compute.pyx
index da237d9..1c0c779 100644
--- a/python/pyarrow/_compute.pyx
+++ b/python/pyarrow/_compute.pyx
@@ -2080,6 +2080,14 @@
     _raise_invalid_function_option(null_placement, "null placement")
 
 
+cdef CSearchSortedSide unwrap_search_sorted_side(side) except *:
+    if side == "left":
+        return CSearchSortedSide_Left
+    elif side == "right":
+        return CSearchSortedSide_Right
+    _raise_invalid_function_option(side, "search sorted side")
+
+
 cdef class _PartitionNthOptions(FunctionOptions):
     def _set_options(self, pivot, null_placement):
         self.wrapped.reset(new CPartitionNthOptions(
@@ -2249,6 +2257,27 @@
         self._set_options(order, null_placement)
 
 
+cdef class _SearchSortedOptions(FunctionOptions):
+    def _set_options(self, side):
+        self.wrapped.reset(new CSearchSortedOptions(
+            unwrap_search_sorted_side(side)))
+
+
+class SearchSortedOptions(_SearchSortedOptions):
+    """
+    Options for the `search_sorted` function.
+
+    Parameters
+    ----------
+    side : str, default "left"
+        Whether to return the leftmost or rightmost insertion point.
+        Accepted values are "left", "right".
+    """
+
+    def __init__(self, side="left"):
+        self._set_options(side)
+
+
 cdef class _SortOptions(FunctionOptions):
     def _set_options(self, sort_keys, null_placement):
         if null_placement is None:
diff --git a/python/pyarrow/_compute_docstrings.py b/python/pyarrow/_compute_docstrings.py
index 079f00d..e5189f0 100644
--- a/python/pyarrow/_compute_docstrings.py
+++ b/python/pyarrow/_compute_docstrings.py
@@ -42,6 +42,41 @@
     ]
     """
 
+function_doc_additions["search_sorted"] = """
+    Examples
+    --------
+    >>> import pyarrow as pa
+    >>> import pyarrow.compute as pc
+    >>> values = pa.array([1, 1, 3, 5, 8])
+    >>> pc.search_sorted(values, pa.array([0, 1, 4, 9]))
+    <pyarrow.lib.UInt64Array object at ...>
+    [
+      0,
+      0,
+      3,
+      5
+    ]
+    >>> with_nulls = pa.array([None, 200, 300, 300], type=pa.int64())
+    >>> pc.search_sorted(
+    ...     with_nulls, pa.array([50, 200, None, 400], type=pa.int64())
+    ... )
+    <pyarrow.lib.UInt64Array object at ...>
+    [
+      1,
+      1,
+      null,
+      4
+    ]
+    >>> pc.search_sorted(values, pa.array([0, 1, 4, 9]), side="right")
+    <pyarrow.lib.UInt64Array object at ...>
+    [
+      0,
+      2,
+      3,
+      5
+    ]
+    """
+
 function_doc_additions["mode"] = """
     Examples
     --------
diff --git a/python/pyarrow/compute.py b/python/pyarrow/compute.py
index 477ad7d..d34eccd 100644
--- a/python/pyarrow/compute.py
+++ b/python/pyarrow/compute.py
@@ -68,6 +68,7 @@
     RoundToMultipleOptions,
     ScalarAggregateOptions,
     ScatterOptions,
+    SearchSortedOptions,
     SelectKOptions,
     SetLookupOptions,
     SkewOptions,
diff --git a/python/pyarrow/includes/libarrow.pxd b/python/pyarrow/includes/libarrow.pxd
index 16784b5..455d11d 100644
--- a/python/pyarrow/includes/libarrow.pxd
+++ b/python/pyarrow/includes/libarrow.pxd
@@ -2831,6 +2831,16 @@
         CSortOrder order
         CNullPlacement null_placement
 
+    cdef enum CSearchSortedSide \
+            "arrow::compute::SearchSortedOptions::Side":
+        CSearchSortedSide_Left "arrow::compute::SearchSortedOptions::Left"
+        CSearchSortedSide_Right "arrow::compute::SearchSortedOptions::Right"
+
+    cdef cppclass CSearchSortedOptions \
+            "arrow::compute::SearchSortedOptions"(CFunctionOptions):
+        CSearchSortedOptions(CSearchSortedSide side)
+        CSearchSortedSide side
+
     cdef cppclass CSortKey" arrow::compute::SortKey":
         CSortKey(CFieldRef target, CSortOrder order)
         CSortKey(CFieldRef target, CSortOrder order, CNullPlacement null_placement)
diff --git a/python/pyarrow/tests/test_compute.py b/python/pyarrow/tests/test_compute.py
index 68fe9a9..83e36a7 100644
--- a/python/pyarrow/tests/test_compute.py
+++ b/python/pyarrow/tests/test_compute.py
@@ -3267,6 +3267,96 @@
         pc.array_sort_indices(arr, order="nonscending")
 
 
+def test_search_sorted():
+    values = pa.array([1, 1, 3, 5, 8])
+    needles = pa.array([0, 1, 3, 4, 5, 8, 9])
+
+    expected_left = pa.array([0, 0, 2, 3, 3, 4, 5], type=pa.uint64())
+    expected_right = pa.array([0, 2, 3, 3, 4, 5, 5], type=pa.uint64())
+
+    assert pc.search_sorted(values, needles).equals(expected_left)
+    assert pc.search_sorted(values, needles, side="left").equals(expected_left)
+    assert pc.search_sorted(values, needles, "right").equals(expected_right)
+    assert pc.search_sorted(
+        values, needles, options=pc.SearchSortedOptions(side="right")
+    ).equals(expected_right)
+
+    assert pc.search_sorted(values, pa.scalar(5, type=pa.int64())).as_py() == 3
+    assert pc.search_sorted(
+        values, pa.scalar(5, type=pa.int64()), side="right"
+    ).as_py() == 4
+
+
+def test_search_sorted_null_values():
+    needles = pa.array([50, 200, 250, 400], type=pa.int64())
+
+    values = pa.array([None, 200, 300, 300], type=pa.int64())
+    expected_left = pa.array([1, 1, 2, 4], type=pa.uint64())
+    expected_right = pa.array([1, 2, 2, 4], type=pa.uint64())
+    assert pc.search_sorted(values, needles, side="left").equals(expected_left)
+    assert pc.search_sorted(values, needles, side="right").equals(expected_right)
+
+    values = pa.array([200, 300, 300, None, None], type=pa.int64())
+    expected_left = pa.array([0, 0, 1, 3], type=pa.uint64())
+    expected_right = pa.array([0, 1, 1, 3], type=pa.uint64())
+    assert pc.search_sorted(values, needles, side="left").equals(expected_left)
+    assert pc.search_sorted(values, needles, side="right").equals(expected_right)
+
+
+def test_search_sorted_null_needles_emit_null():
+    values = pa.array([None, 200, 300, 300], type=pa.int64())
+    needles = pa.array([None, 50, 200, None, 400], type=pa.int64())
+
+    expected_left = pa.array([None, 1, 1, None, 4], type=pa.uint64())
+    expected_right = pa.array([None, 1, 2, None, 4], type=pa.uint64())
+
+    assert pc.search_sorted(values, needles, side="left").equals(expected_left)
+    assert pc.search_sorted(values, needles, side="right").equals(expected_right)
+
+    scalar_result = pc.search_sorted(values, pa.scalar(None, type=pa.int64()))
+    assert scalar_result.as_py() is None
+
+
+def test_search_sorted_run_end_encoded():
+    run_ends = pa.array([2, 3, 4, 5], type=pa.int16())
+    encoded_values = pa.array([1, 3, 5, 8], type=pa.int64())
+    values = pa.RunEndEncodedArray.from_arrays(run_ends, encoded_values)
+    needles = pa.array([0, 1, 3, 4, 5, 8, 9], type=pa.int64())
+
+    expected_left = pa.array([0, 0, 2, 3, 3, 4, 5], type=pa.uint64())
+    assert pc.search_sorted(values, needles).equals(expected_left)
+
+    ree_needles = pa.RunEndEncodedArray.from_arrays(
+        pa.array([2, 4, 6], type=pa.int16()),
+        pa.array([1, 4, 9], type=pa.int64())
+    )
+    expected_right = pa.array([2, 2, 3, 3, 5, 5], type=pa.uint64())
+    assert pc.search_sorted(values, ree_needles, side="right").equals(
+        expected_right
+    )
+
+
+def test_search_sorted_run_end_encoded_nulls():
+    values = pa.RunEndEncodedArray.from_arrays(
+        pa.array([2, 3, 5], type=pa.int16()),
+        pa.array([None, 2, 4], type=pa.int64())
+    )
+    needles = pa.RunEndEncodedArray.from_arrays(
+        pa.array([2, 3, 5, 6], type=pa.int16()),
+        pa.array([None, 1, 4, None], type=pa.int64())
+    )
+
+    expected = pa.array([None, None, 2, 3, 3, None], type=pa.uint64())
+    assert pc.search_sorted(values, needles, side="left").equals(expected)
+
+
+def test_search_sorted_errors():
+    values = pa.array([1, 1, 3, 5, 8])
+
+    with pytest.raises(ValueError, match='"middle" is not a valid search sorted side'):
+        pc.search_sorted(values, pa.array([1]), side="middle")
+
+
 def test_sort_indices_array():
     arr = pa.array([1, 2, None, 0])
     result = pc.sort_indices(arr)