数据结构与算法(1)数组

1. 数组

1.1 概述

定义

在计算机科学中,数组是由一组元素(值或变量)组成的数据结构,每个元素有至少一个索引或键来标识

因为数组内的元素是连续存储的,所以数组中元素的地址,可以通过其索引计算出来,例如:

int[] array = {1,2,3,4,5}

知道了数组的数据起始地址 B a s e A d d r e s s BaseAddress BaseAddress,就可以由公式 B a s e A d d r e s s + i ∗ s i z e BaseAddress + i * size BaseAddress+isize 计算出索引 i i i 元素的地址

  • i i i 即索引,在 Java、C 等语言都是从 0 开始
  • s i z e size size 是每个元素占用字节,例如 i n t int int 4 4 4 d o u b l e double double 8 8 8

小测试

byte[] array = {1,2,3,4,5}

已知 array 的数据的起始地址是 0x7138f94c8,那么元素 3 的地址是什么?

答:0x7138f94c8 + 2 * 1 = 0x7138f94ca>

空间占用

Java 中数组结构为

  • 8 字节 markword
  • 4 字节 class 指针(压缩 class 指针的情况)
  • 4 字节 数组大小(决定了数组最大容量是 2 32 2^{32} 232
  • 数组元素 + 对齐字节(java 中所有对象大小都是 8 字节的整数倍[^12],不足的要用对齐字节补足)
    例如
int[] array = {1, 2, 3, 4, 5};

的大小为 40 个字节,组成如下

8 + 4 + 4 + 5*4 + 4(alignment)

随机访问性能

即根据索引查找元素,时间复杂度是 O ( 1 ) O(1) O(1)

1.2 动态数组

java 版本

public class DynamicArray implements Iterable<Integer> {
    private int size = 0; // 逻辑大小
    private int capacity = 8; // 容量
    private int[] array = {};


    /**
     * 向最后位置 [size] 添加元素
     *
     * @param element 待添加元素
     */
    public void addLast(int element) {
        add(size, element);
    }

    /**
     * 向 [0 .. size] 位置添加元素
     *
     * @param index   索引位置
     * @param element 待添加元素
     */
    public void add(int index, int element) {
        checkAndGrow();

        // 添加逻辑
        if (index >= 0 && index < size) {
            // 向后挪动, 空出待插入位置
            System.arraycopy(array, index,
                    array, index + 1, size - index);
        }
        array[index] = element;
        size++;
    }

    private void checkAndGrow() {
        // 容量检查
        if (size == 0) {
            array = new int[capacity];
        } else if (size == capacity) {
            // 进行扩容, 1.5 1.618 2
            capacity += capacity >> 1;
            int[] newArray = new int[capacity];
            System.arraycopy(array, 0,
                    newArray, 0, size);
            array = newArray;
        }
    }

    /**
     * 从 [0 .. size) 范围删除元素
     *
     * @param index 索引位置
     * @return 被删除元素
     */
    public int remove(int index) { // [0..size)
        int removed = array[index];
        if (index < size - 1) {
            // 向前挪动
            System.arraycopy(array, index + 1,
                    array, index, size - index - 1);
        }
        size--;
        return removed;
    }


    /**
     * 查询元素
     *
     * @param index 索引位置, 在 [0..size) 区间内
     * @return 该索引位置的元素
     */
    public int get(int index) {
        return array[index];
    }

    /**
     * 遍历方法1
     *
     * @param consumer 遍历要执行的操作, 入参: 每个元素
     */
    public void foreach(Consumer<Integer> consumer) {
        for (int i = 0; i < size; i++) {
            // 提供 array[i]
            // 返回 void
            consumer.accept(array[i]);
        }
    }

    /**
     * 遍历方法2 - 迭代器遍历
     */
    @Override
    public Iterator<Integer> iterator() {
        return new Iterator<Integer>() {
            int i = 0;

            @Override
            public boolean hasNext() { // 有没有下一个元素
                return i < size;
            }

            @Override
            public Integer next() { // 返回当前元素,并移动到下一个元素
                return array[i++];
            }
        };
    }

    /**
     * 遍历方法3 - stream 遍历
     *
     * @return stream 流
     */
    public IntStream stream() {
        return IntStream.of(Arrays.copyOfRange(array, 0, size));
    }
}
  • 这些方法实现,都简化了 index 的有效性判断,假设输入的 index 都是合法的

插入或删除性能

头部位置,时间复杂度是 O ( n ) O(n) O(n)

中间位置,时间复杂度是 O ( n ) O(n) O(n)

尾部位置,时间复杂度是 O ( 1 ) O(1) O(1)(均摊来说)

1.3 二维数组

int[][] array = {
    {11, 12, 13, 14, 15},
    {21, 22, 23, 24, 25},
    {31, 32, 33, 34, 35},
};

内存图如下
请添加图片描述

  • 二维数组占 32 个字节,其中 array[0],array[1],array[2] 三个元素分别保存了指向三个一维数组的引用
  • 三个一维数组各占 40 个字节
  • 它们在内层布局上是连续

更一般的,对一个二维数组 A r r a y [ m ] [ n ] Array[m][n] Array[m][n]

  • m m m 是外层数组的长度,可以看作 row 行
  • n n n 是内层数组的长度,可以看作 column 列
  • 当访问 A r r a y [ i ] [ j ] Array[i][j] Array[i][j] 0 ≤ i < m , 0 ≤ j < n 0\leq i \lt m, 0\leq j \lt n 0i<m,0j<n时,就相当于
    • 先找到第 i i i 个内层数组(行)
    • 再找到此内层数组中第 j j j 个元素(列)

小测试

Java 环境下(不考虑类指针和引用压缩,此为默认情况),有下面的二维数组

byte[][] array = {
    {11, 12, 13, 14, 15},
    {21, 22, 23, 24, 25},
    {31, 32, 33, 34, 35},
};

已知 array 对象起始地址是 0x1000,那么 23 这个元素的地址是什么?

答:

  • 起始地址 0x1000
  • 外层数组大小:16字节对象头 + 3元素 * 每个引用4字节 + 4 对齐字节 = 32 = 0x20
  • 第一个内层数组大小:16字节对象头 + 5元素 * 每个byte1字节 + 3 对齐字节 = 24 = 0x18
  • 第二个内层数组,16字节对象头 = 0x10,待查找元素索引为 2
  • 最后结果 = 0x1000 + 0x20 + 0x18 + 0x10 + 2*1 = 0x104a

1.4 局部性原理

这里只讨论空间局部性

  • cpu 读取内存(速度慢)数据后,会将其放入高速缓存(速度快)当中,如果后来的计算再用到此数据,在缓存中能读到的话,就不必读内存了
  • 缓存的最小存储单位是缓存行(cache line),一般是 64 bytes,一次读的数据少了不划算啊,因此最少读 64 bytes 填满一个缓存行,因此读入某个数据时也会读取其临近的数据,这就是所谓空间局部性

对效率的影响
比较下面 ij 和 ji 两个方法的执行效率

int rows = 1000000;
int columns = 14;
int[][] a = new int[rows][columns];

StopWatch sw = new StopWatch();
sw.start("ij");
ij(a, rows, columns);
sw.stop();
sw.start("ji");
ji(a, rows, columns);
sw.stop();
System.out.println(sw.prettyPrint());

ij 方法

public static void ij(int[][] a, int rows, int columns) {
    long sum = 0L;
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < columns; j++) {
            sum += a[i][j];
        }
    }
    System.out.println(sum);
}

ji 方法

public static void ji(int[][] a, int rows, int columns) {
    long sum = 0L;
    for (int j = 0; j < columns; j++) {
        for (int i = 0; i < rows; i++) {
            sum += a[i][j];
        }
    }
    System.out.println(sum);
}

执行结果

0
0
StopWatch '': running time = 96283300 ns
---------------------------------------------
ns         %     Task name
---------------------------------------------
016196200  017%  ij
080087100  083%  ji

可以看到 ij 的效率比 ji 快很多,为什么呢?

  • 缓存是有限的,当新数据来了后,一些旧的缓存行数据就会被覆盖
  • 如果不能充分利用缓存的数据,就会造成效率低下

以 ji 执行为例,第一次内循环要读入 [ 0 , 0 ] [0,0] [0,0] 这条数据,由于局部性原理,读入 [ 0 , 0 ] [0,0] [0,0] 的同时也读入了 [ 0 , 1 ] . . . [ 0 , 13 ] [0,1] ... [0,13] [0,1]...[0,13],如图所示
在这里插入图片描述
但很遗憾,第二次内循环要的是 [ 1 , 0 ] [1,0] [1,0] 这条数据,缓存中没有,于是再读入了下图的数据
在这里插入图片描述
这显然是一种浪费,因为 [ 0 , 1 ] . . . [ 0 , 13 ] [0,1] ... [0,13] [0,1]...[0,13] 包括 [ 1 , 1 ] . . . [ 1 , 13 ] [1,1] ... [1,13] [1,1]...[1,13] 这些数据虽然读入了缓存,却没有及时用上,而缓存的大小是有限的,等执行到第九次内循环时
在这里插入图片描述
缓存的第一行数据已经被新的数据 [ 8 , 0 ] . . . [ 8 , 13 ] [8,0] ... [8,13] [8,0]...[8,13] 覆盖掉了,以后如果再想读,比如 [ 0 , 1 ] [0,1] [0,1],又得到内存去读了
同理可以分析 ij 函数则能充分利用局部性原理加载到的缓存数据

举一反三

  1. I/O 读写时同样可以体现局部性原理

  2. 数组可以充分利用局部性原理,那么链表呢?

    答:链表不行,因为链表的元素并非相邻存储

1.5 越界检查

java 中对数组元素的读写都有越界检查,类似于下面的代码

bool is_within_bounds(int index) const        
{ 
    return 0 <= index && index < length(); 
}

只不过此检查代码,不需要由程序员自己来调用,JVM 会帮我们调用

1.6 二分查找

二分查找算法也称折半查找,是一种非常高效的工作于有序数组的查找算法。

1.6.1 二分查找基础版

需求:在有序数组 A A A 内,查找值 t a r g e t target target

  • 如果找到返回索引
  • 如果找不到返回 − 1 -1 1
算法描述
前提给定一个内含 n n n 个元素的有序数组 A A A,满足 A 0 ≤ A 1 ≤ A 2 ≤ ⋯ ≤ A n − 1 A_{0}\leq A_{1}\leq A_{2}\leq \cdots \leq A_{n-1} A0A1A2An1,一个待查值 t a r g e t target target
1设置 i = 0 i=0 i=0 j = n − 1 j=n-1 j=n1
2如果 i > j i \gt j i>j,结束查找,没找到
3设置 m = f l o o r ( i + j 2 ) m = floor(\frac {i+j}{2}) m=floor(2i+j) m m m 为中间索引, f l o o r floor floor 是向下取整( ≤ i + j 2 \leq \frac {i+j}{2} 2i+j 的最小整数)
4如果 t a r g e t < A m target < A_{m} target<Am 设置 j = m − 1 j = m - 1 j=m1,跳到第2步
5如果 A m < t a r g e t A_{m} < target Am<target 设置 i = m + 1 i = m + 1 i=m+1,跳到第2步
6如果 A m = t a r g e t A_{m} = target Am=target,结束查找,找到了

java 实现

public static int binarySearch(int[] a, int target) {
    int i = 0, j = a.length - 1;
    while (i <= j) {
        int m = (i + j) >>> 1;
        if (target < a[m]) {			// 在左边
            j = m - 1;
        } else if (a[m] < target) {		// 在右边
            i = m + 1;
        } else {
            return m;
        }
    }
    return -1;
}
  • i , j i,j i,j 对应着搜索区间 [ 0 , a . l e n g t h − 1 ] [0,a.length-1] [0,a.length1](注意是闭合的区间), i < = j i<=j i<=j 意味着搜索区间内还有未比较的元素, i , j i,j i,j 指向的元素也可能是比较的目标
    • 思考:如果不加 i = = j i==j i==j 行不行?
    • 回答:不行,因为这意味着 i , j i,j i,j 指向的元素会漏过比较
  • m m m 对应着中间位置,中间位置左边和右边的元素可能不相等(差一个),不会影响结果
  • 如果某次未找到,那么缩小后的区间内不包含 m m m

1.6.2 二分查找改变版

另一种写法

public static int binarySearch(int[] a, int target) {
    int i = 0, j = a.length;
    while (i < j) {
        int m = (i + j) >>> 1;
        if (target < a[m]) {			// 在左边
            j = m;
        } else if (a[m] < target) {		// 在右边
            i = m + 1;
        } else {
            return m;
        }
    }
    return -1;
}
  • i , j i,j i,j 对应着搜索区间 [ 0 , a . l e n g t h ) [0,a.length) [0,a.length)(注意是左闭右开的区间), i < j i<j i<j 意味着搜索区间内还有未比较的元素, j j j 指向的一定不是查找目标
    • 思考:为啥这次不加 i = = j i==j i==j 的条件了?
    • 回答:这回 j j j 指向的不是查找目标,如果还加 i = = j i==j i==j 条件,就意味着 j j j 指向的还会再次比较,找不到时,会死循环
  • 如果某次要缩小右边界,那么 j = m j=m j=m,因为此时的 m m m 已经不是查找目标了

1.6.3 衡量算法好坏

时间复杂度

下面的查找算法也能得出与之前二分查找一样的结果,那你能说出它差在哪里吗?

public static int search(int[] a, int k) {
    for (
        int i = 0;
        i < a.length;
        i++
    ) {
        if (a[i] == k) {
            return i;
        }
    }
    return -1;
}

考虑最坏情况下(没找到)例如 [1,2,3,4] 查找 5

  • int i = 0 只执行一次
  • i < a.length 受数组元素个数 n n n 的影响,比较 n + 1 n+1 n+1
  • i++ 受数组元素个数 n n n 的影响,自增 n n n
  • a[i] == k 受元素个数 n n n 的影响,比较 n n n
  • return -1,执行一次

粗略认为每行代码执行时间是 t t t,假设 n = 4 n=4 n=4 那么

  • 总执行时间是 ( 1 + 4 + 1 + 4 + 4 + 1 ) ∗ t = 15 t (1+4+1+4+4+1)*t = 15t (1+4+1+4+4+1)t=15t
  • 可以推导出更一般地公式为, T = ( 3 ∗ n + 3 ) t T = (3*n+3)t T=(3n+3)t

如果套用二分查找算法,还是 [1,2,3,4] 查找 5

public static int binarySearch(int[] a, int target) {
    int i = 0, j = a.length - 1;
    while (i <= j) {
        int m = (i + j) >>> 1;
        if (target < a[m]) {			// 在左边
            j = m - 1;
        } else if (a[m] < target) {		// 在右边
            i = m + 1;
        } else {
            return m;
        }
    }
    return -1;
}
  • int i = 0, j = a.length - 1 各执行 1 次

  • i <= j 比较 f l o o r ( log ⁡ 2 ( n ) + 1 ) floor(\log_{2}(n)+1) floor(log2(n)+1) 再加 1 次

  • (i + j) >>> 1 计算 f l o o r ( log ⁡ 2 ( n ) + 1 ) floor(\log_{2}(n)+1) floor(log2(n)+1)

  • 接下来 if() else if() else 会执行 3 ∗ f l o o r ( log ⁡ 2 ( n ) + 1 ) 3* floor(\log_{2}(n)+1) 3floor(log2(n)+1) 次,分别为

    • if 比较
    • else if 比较
    • else if 比较成立后的赋值语句
  • return -1,执行一次

结果:

  • 总执行时间为 ( 2 + ( 1 + 3 ) + 3 + 3 ∗ 3 + 1 ) ∗ t = 19 t (2 + (1+3) + 3 + 3 * 3 +1)*t = 19t (2+(1+3)+3+33+1)t=19t
  • 更一般地公式为 ( 4 + 5 ∗ f l o o r ( log ⁡ 2 ( n ) + 1 ) ) ∗ t (4 + 5 * floor(\log_{2}(n)+1))*t (4+5floor(log2(n)+1))t

注意:

左侧未找到和右侧未找到结果不一样,这里不做分析

两个算法比较,可以看到 n n n 在较小的时候,二者花费的次数差不多

在这里插入图片描述

但随着 n n n 越来越大,比如说 n = 1000 n=1000 n=1000 时,用二分查找算法(红色)也就是 54 t 54t 54t,而蓝色算法则需要 3003 t 3003t 3003t

在这里插入图片描述

画图采用的是 Desmos | 图形计算器

计算机科学中,时间复杂度是用来衡量:一个算法的执行,随数据规模增大,而增长的时间成本

  • 不依赖于环境因素

如何表示时间复杂度呢?

  • 假设算法要处理的数据规模是 n n n,代码总的执行行数用函数 f ( n ) f(n) f(n) 来表示,例如:

    • 线性查找算法的函数 f ( n ) = 3 ∗ n + 3 f(n) = 3*n + 3 f(n)=3n+3
    • 二分查找算法的函数 f ( n ) = ( f l o o r ( l o g 2 ( n ) ) + 1 ) ∗ 5 + 4 f(n) = (floor(log_2(n)) + 1) * 5 + 4 f(n)=(floor(log2(n))+1)5+4
  • 为了对 f ( n ) f(n) f(n) 进行化简,应当抓住主要矛盾,找到一个变化趋势与之相近的表示法

O O O 表示法[^4]

在这里插入图片描述

其中

  • c , c 1 , c 2 c, c_1, c_2 c,c1,c2 都为一个常数
  • f ( n ) f(n) f(n) 是实际执行代码行数与 n 的函数
  • g ( n ) g(n) g(n) 是经过化简,变化趋势与 f ( n ) f(n) f(n) 一致的 n 的函数

渐进上界

渐进上界(asymptotic upper bound):从某个常数 n 0 n_0 n0开始, c ∗ g ( n ) c*g(n) cg(n) 总是位于 f ( n ) f(n) f(n) 上方,那么记作 O ( g ( n ) ) O(g(n)) O(g(n))

  • 代表算法执行的最差情况

例1

  • f ( n ) = 3 ∗ n + 3 f(n) = 3*n+3 f(n)=3n+3
  • g ( n ) = n g(n) = n g(n)=n
  • c = 4 c=4 c=4,在 n 0 = 3 n_0=3 n0=3 之后, g ( n ) g(n) g(n) 可以作为 f ( n ) f(n) f(n) 的渐进上界,因此表示法写作 O ( n ) O(n) O(n)

例2

  • f ( n ) = 5 ∗ f l o o r ( l o g 2 ( n ) ) + 9 f(n) = 5*floor(log_2(n)) + 9 f(n)=5floor(log2(n))+9
  • g ( n ) = l o g 2 ( n ) g(n) = log_2(n) g(n)=log2(n)
  • O ( l o g 2 ( n ) ) O(log_2(n)) O(log2(n))

已知 f ( n ) f(n) f(n) 来说,求 g ( n ) g(n) g(n)

  • 表达式中相乘的常量,可以省略,如
    • f ( n ) = 100 ∗ n 2 f(n) = 100*n^2 f(n)=100n2 中的 100 100 100
  • 多项式中数量规模更小(低次项)的表达式,如
    • f ( n ) = n 2 + n f(n)=n^2+n f(n)=n2+n 中的 n n n
    • f ( n ) = n 3 + n 2 f(n) = n^3 + n^2 f(n)=n3+n2 中的 n 2 n^2 n2
  • 不同底数的对数,渐进上界可以用一个对数函数 log ⁡ n \log n logn 表示
    • 例如: l o g 2 ( n ) log_2(n) log2(n) 可以替换为 l o g 10 ( n ) log_{10}(n) log10(n),因为 l o g 2 ( n ) = l o g 10 ( n ) l o g 10 ( 2 ) log_2(n) = \frac{log_{10}(n)}{log_{10}(2)} log2(n)=log10(2)log10(n),相乘的常量 1 l o g 10 ( 2 ) \frac{1}{log_{10}(2)} log10(2)1 可以省略
  • 类似的,对数的常数次幂可省略
    • 如: l o g ( n c ) = c ∗ l o g ( n ) log(n^c) = c * log(n) log(nc)=clog(n)

常见大 O O O 表示法

在这里插入图片描述

按时间复杂度从低到高

  • 黑色横线 O ( 1 ) O(1) O(1),常量时间,意味着算法时间并不随数据规模而变化
  • 绿色 O ( l o g ( n ) ) O(log(n)) O(log(n)),对数时间
  • 蓝色 O ( n ) O(n) O(n),线性时间,算法时间与数据规模成正比
  • 橙色 O ( n ∗ l o g ( n ) ) O(n*log(n)) O(nlog(n)),拟线性时间
  • 红色 O ( n 2 ) O(n^2) O(n2) 平方时间
  • 黑色朝上 O ( 2 n ) O(2^n) O(2n) 指数时间
  • 没画出来的 O ( n ! ) O(n!) O(n!)

渐进下界

渐进下界(asymptotic lower bound):从某个常数 n 0 n_0 n0开始, c ∗ g ( n ) c*g(n) cg(n) 总是位于 f ( n ) f(n) f(n) 下方,那么记作 Ω ( g ( n ) ) \Omega(g(n)) Ω(g(n))

渐进紧界

渐进紧界(asymptotic tight bounds):从某个常数 n 0 n_0 n0开始, f ( n ) f(n) f(n) 总是在 c 1 ∗ g ( n ) c_1*g(n) c1g(n) c 2 ∗ g ( n ) c_2*g(n) c2g(n) 之间,那么记作 Θ ( g ( n ) ) \Theta(g(n)) Θ(g(n))

空间复杂度

与时间复杂度类似,一般也使用大 O O O 表示法来衡量:一个算法执行随数据规模增大,而增长的额外空间成本

public static int binarySearchBasic(int[] a, int target) {
    int i = 0, j = a.length - 1;    // 设置指针和初值
    while (i <= j) {                // i~j 范围内有东西
        int m = (i + j) >>> 1;
        if(target < a[m]) {         // 目标在左边
            j = m - 1;
        } else if (a[m] < target) { // 目标在右边
            i = m + 1;
        } else {                    // 找到了
            return m;
        }
    }
    return -1;
}

二分查找性能

下面分析二分查找算法的性能

时间复杂度

  • 最坏情况: O ( log ⁡ n ) O(\log n) O(logn)
  • 最好情况:如果待查找元素恰好在数组中央,只需要循环一次 O ( 1 ) O(1) O(1)

空间复杂度

  • 需要常数个指针 i , j , m i,j,m i,j,m,因此额外占用的空间是 O ( 1 ) O(1) O(1)

1.6.4 二分查找平衡版

public static int binarySearchBalance(int[] a, int target) {
    int i = 0, j = a.length;
    while (1 < j - i) {
        int m = (i + j) >>> 1;
        if (target < a[m]) {
            j = m;
        } else {
            i = m;
        }
    }
    return (a[i] == target) ? i : -1;
}

思想:

  1. 左闭右开的区间, i i i 指向的可能是目标,而 j j j 指向的不是目标
  2. 不奢望循环内通过 m m m 找出目标, 缩小区间直至剩 1 个, 剩下的这个可能就是要找的(通过 i i i
    • j − i > 1 j - i > 1 ji>1 的含义是,在范围内待比较的元素个数 > 1
  3. 改变 i i i 边界时,它指向的可能是目标,因此不能 m + 1 m+1 m+1
  4. 循环内的平均比较次数减少了
  5. 时间复杂度 Θ ( l o g ( n ) ) \Theta(log(n)) Θ(log(n))

1.6.5 二分查找Java版

private static int binarySearch0(long[] a, int fromIndex, int toIndex,
                                     long key) {
    int low = fromIndex;
    int high = toIndex - 1;

    while (low <= high) {
        int mid = (low + high) >>> 1;
        long midVal = a[mid];

        if (midVal < key)
            low = mid + 1;
        else if (midVal > key)
            high = mid - 1;
        else
            return mid; // key found
    }
    return -(low + 1);  // key not found.
}
  • 例如 [ 1 , 3 , 5 , 6 ] [1,3,5,6] [1,3,5,6] 要插入 2 2 2 那么就是找到一个位置,这个位置左侧元素都比它小
    • 等循环结束,若没找到,low 左侧元素肯定都比 target 小,因此 low 即插入点
  • 插入点取负是为了与找到情况区分
  • -1 是为了把索引 0 位置的插入点与找到的情况进行区分

1.6.6 Leftmost与Rightmost

有时我们希望返回的是最左侧的重复元素,如果用 Basic 二分查找

  • 对于数组 [ 1 , 2 , 3 , 4 , 4 , 5 , 6 , 7 ] [1, 2, 3, 4, 4, 5, 6, 7] [1,2,3,4,4,5,6,7],查找元素4,结果是索引3

  • 对于数组 [ 1 , 2 , 4 , 4 , 4 , 5 , 6 , 7 ] [1, 2, 4, 4, 4, 5, 6, 7] [1,2,4,4,4,5,6,7],查找元素4,结果也是索引3,并不是最左侧的元素

public static int binarySearchLeftmost1(int[] a, int target) {
    int i = 0, j = a.length - 1;
    int candidate = -1;
    while (i <= j) {
        int m = (i + j) >>> 1;
        if (target < a[m]) {
            j = m - 1;
        } else if (a[m] < target) {
            i = m + 1;
        } else {
            candidate = m; // 记录候选位置
            j = m - 1;     // 继续向左
        }
    }
    return candidate;
}

如果希望返回的是最右侧元素

public static int binarySearchRightmost1(int[] a, int target) {
    int i = 0, j = a.length - 1;
    int candidate = -1;
    while (i <= j) {
        int m = (i + j) >>> 1;
        if (target < a[m]) {
            j = m - 1;
        } else if (a[m] < target) {
            i = m + 1;
        } else {
            candidate = m; // 记录候选位置
            i = m + 1;	   // 继续向右
        }
    }
    return candidate;
}

应用

对于 Leftmost 与 Rightmost,可以返回一个比 -1 更有用的值

Leftmost 改为

public static int binarySearchLeftmost(int[] a, int target) {
    int i = 0, j = a.length - 1;
    while (i <= j) {
        int m = (i + j) >>> 1;
        if (target <= a[m]) {
            j = m - 1;
        } else {
            i = m + 1;
        }
    }
    return i; 
}
  • leftmost 返回值的另一层含义: < t a r g e t \lt target <target 的元素个数
  • 小于等于中间值,都要向左找

Rightmost 改为

public static int binarySearchRightmost(int[] a, int target) {
    int i = 0, j = a.length - 1;
    while (i <= j) {
        int m = (i + j) >>> 1;
        if (target < a[m]) {
            j = m - 1;
        } else {
            i = m + 1;
        }
    }
    return i - 1;
}
  • 大于等于中间值,都要向右找

几个名词

在这里插入图片描述

范围查询

  • 查询 x < 4 x \lt 4 x<4 0.. l e f t m o s t ( 4 ) − 1 0 .. leftmost(4) - 1 0..leftmost(4)1
  • 查询 x ≤ 4 x \leq 4 x4 0.. r i g h t m o s t ( 4 ) 0 .. rightmost(4) 0..rightmost(4)
  • 查询 4 < x 4 \lt x 4<x,$rightmost(4) + 1 … \infty $
  • 查询 4 ≤ x 4 \leq x 4x l e f t m o s t ( 4 ) . . ∞ leftmost(4) .. \infty leftmost(4)..∞
  • 查询 4 ≤ x ≤ 7 4 \leq x \leq 7 4x7 l e f t m o s t ( 4 ) . . r i g h t m o s t ( 7 ) leftmost(4) .. rightmost(7) leftmost(4)..rightmost(7)
  • 查询 4 < x < 7 4 \lt x \lt 7 4<x<7 r i g h t m o s t ( 4 ) + 1.. l e f t m o s t ( 7 ) − 1 rightmost(4)+1 .. leftmost(7)-1 rightmost(4)+1..leftmost(7)1

求排名 l e f t m o s t ( t a r g e t ) + 1 leftmost(target) + 1 leftmost(target)+1

  • t a r g e t target target 可以不存在,如: l e f t m o s t ( 5 ) + 1 = 6 leftmost(5)+1 = 6 leftmost(5)+1=6
  • t a r g e t target target 也可以存在,如: l e f t m o s t ( 4 ) + 1 = 3 leftmost(4)+1 = 3 leftmost(4)+1=3

求前任(predecessor) l e f t m o s t ( t a r g e t ) − 1 leftmost(target) - 1 leftmost(target)1

  • l e f t m o s t ( 3 ) − 1 = 1 leftmost(3) - 1 = 1 leftmost(3)1=1,前任 a 1 = 2 a_1 = 2 a1=2
  • l e f t m o s t ( 4 ) − 1 = 1 leftmost(4) - 1 = 1 leftmost(4)1=1,前任 a 1 = 2 a_1 = 2 a1=2

求后任(successor) r i g h t m o s t ( t a r g e t ) + 1 rightmost(target)+1 rightmost(target)+1

  • r i g h t m o s t ( 5 ) + 1 = 5 rightmost(5) + 1 = 5 rightmost(5)+1=5,后任 a 5 = 7 a_5 = 7 a5=7
  • r i g h t m o s t ( 4 ) + 1 = 5 rightmost(4) + 1 = 5 rightmost(4)+1=5,后任 a 5 = 7 a_5 = 7 a5=7

求最近邻居

  • 前任和后任距离更近者
  • 14
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值