Skip to main content

初识算法

什么是算法?

定义

在数学和计算机科学领域,算法是一系列有限的严谨指令,通常用于解决一类特定问题或执行计算

In mathematics and computer science, an algorithm (/ˈælɡərɪðəm/) is a finite sequence of rigorous instructions, typically used to solve a class of specific problems or to perform a computation.1

Introduction to Algorithm2

不正式的说,算法就是任何定义优良的计算过程:接收一些值作为输入,在有限的时间内,产生一些值作为输出。

Informally, an algorithm is any well-defined computational procedure that takes some value, or set of values, as input and produces some value, or set of values, as output in a finite amount of time.

什么是数据结构?

定义

在计算机科学领域,数据结构是一种数据组织、管理和存储格式,通常被选择用来高效访问数据

In computer science, a data structure is a data organization, management, and storage format that is usually chosen for efficient access to data

Introduction to Algorithm 2

数据结构是一种存储和组织数据的方式,旨在便于访问和修改

A data structure is a way to store and organize data in order to facilitate access and modifications

接下来我们通过对一个非常著名的二分查找算法的讲解来认识一下算法

二分查找 3

二分查找算法也称折半查找,是一种非常高效的工作于有序数组的查找算法。后续的课程中还会学习更多的查找算法,但在此之前,不妨用它作为入门。

二分查找基础版

需求:在有序数组 AA 内,查找值 targettarget

  • 如果找到返回索引
  • 如果找不到返回 1-1
算法描述
前提给定一个内含 nn 个元素的有序数组 AA,满足 A0A1A2An1A_{0}\leq A_{1}\leq A_{2}\leq \cdots \leq A_{n-1},一个待查值 targettarget
1设置 i=0i=0j=n1j=n-1
2如果 i>ji \gt j,结束查找,没找到
3设置 m=floor(i+j2)m = floor(\frac {i+j}{2})mm 为中间索引,floorfloor 是向下取整(i+j2\leq \frac {i+j}{2} 的最小整数)
4如果 target<Amtarget < A_{m} 设置 j=m1j = m - 1,跳到第2步
5如果 Am<targetA_{m} < target 设置 i=m+1i = m + 1,跳到第2步
6如果 Am=targetA_{m} = 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; //无符号右移,相当于除以2
if (target < a[m]) { // 在左边
j = m - 1;
} else if (a[m] < target) { // 在右边
i = m + 1;
} else {
return m;
}
}
return -1;
}
  • i,ji,j 对应着搜索区间 [0,a.length1][0,a.length-1](注意是闭合的区间),i<=ji<=j 意味着搜索区间内还有未比较的元素,i,ji,j 指向的元素也可能是比较的目标
    • 思考:如果不加 i==ji==j 行不行?
    • 回答:不行,因为这意味着 i,ji,j 同时指向的元素会漏过比较
  • mm 对应着中间位置,中间位置左边和右边的元素可能不相等(差一个),不会影响结果
  • 如果某次未找到,那么缩小后的区间内不包含 mm
  • 为什么建议使用>>>1代替除以2,因为i+ji + j的值如果超过整数范围会在循环过程中越界,而且用的是无符号右移就(逻辑右移)
  • 建议比较都使用小于符号,符合直观感受

二分查找改变版

另一种写法

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,ji,j 对应着搜索区间 [0,a.length)[0,a.length)(注意是左闭右开的区间),i<ji<j 意味着搜索区间内还有未比较的元素,jj 指向的一定不是查找目标
    • 思考:为啥这次不加 i==ji==j 的条件了?
    • 回答:这回 jj 指向的不是查找目标,如果还加 i==ji==j 条件,就意味着 jj 指向的还会再次比较,找不到时,jj还是等于mm,会死循环
  • 如果某次要缩小右边界,那么 j=mj=m,因为此时的 mm 已经不是查找目标了

衡量算法好坏

时间复杂度

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

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 受数组元素个数 nn 的影响,比较 n+1n+1
  • i++ 受数组元素个数 nn 的影响,自增 nn
  • a[i] == k 受元素个数 nn 的影响,比较 nn
  • return -1,执行一次

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

  • 总执行时间是 (1+4+1+4+4+1)t=15t(1+4+1+4+4+1)*t = 15t
  • 可以推导出更一般地公式为,T=(3n+3)tT = (3*n+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 比较 floor(log2(n)+1)floor(\log_{2}(n)+1) 再加 1 次

  • (i + j) >>> 1 计算 floor(log2(n)+1)floor(\log_{2}(n)+1)

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

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

结果:

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

注意:

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

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

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

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

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

  • 不依赖于环境因素

如何表示时间复杂度呢?

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

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

OO 表示法[^4]

image-20221108103846566

其中

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

渐进上界

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

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

例1

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

例2

  • f(n)=5floor(log2(n))+9f(n) = 5*floor(log_2(n)) + 9
  • g(n)=log2(n)g(n) = log_2(n)
  • O(log2(n))O(log_2(n))

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

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

常见大 OO 表示法

image-20221108114915524

按时间复杂度从低到高

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

渐进下界

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

渐进紧界

渐进紧界(asymptotic tight bounds):从某个常数 n0n_0开始,f(n)f(n) 总是在 c1g(n)c_1*g(n)c2g(n)c_2*g(n) 之间,那么记作 Θ(g(n))\Theta(g(n)) ,最好最坏情况要相同函数。

空间复杂度

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

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(logn)O(\log n)
  • 最好情况:如果待查找元素恰好在数组中央,只需要循环一次 O(1)O(1)

空间复杂度

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

二分查找平衡版

目的是防止按顺序下来比较次数增多

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 { //一个if-else可以平衡(最好最坏情况都是一样的)
i = m;
}
}
return (a[i] == target) ? i : -1;
}

思想:

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

二分查找 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;
int midVal = a[mid];

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

插入元素

/*

                ⬇
            [2, 5, 8]       a
            [2, 0, 0, 0]    b
            [2, 4, 0, 0]    b
            [2, 4, 5, 8]    b
 
*/
int[] a = {2, 5, 8};
int target = 4;
int i = Arrays.binarySearch(a, target);
assertTrue(i < 0);
        // i = -插入点 - 1  因此有 插入点 = abs(i+1)
int insertIndex = Math.abs(i + 1); // 插入点索引
int[] b = new int[a.length + 1];
System.arraycopy(a, 0, b, 0, insertIndex);
b[insertIndex] = target;
System.arraycopy(a, insertIndex, b, insertIndex + 1, a.length - insertIndex);
assertArrayEquals(new int[]{2, 4, 5, 8}, b);

Leftmost 与 Rightmost

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

  • 对于数组 [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],查找元素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 返回值的另一层含义:<target\lt 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;
}
  • 大于等于中间值,都要向右找

几个名词

image-20221125174155058

范围查询

  • 查询 x<4x \lt 40..leftmost(4)10 .. leftmost(4) - 1
  • 查询 x4x \leq 40..rightmost(4)0 .. rightmost(4)
  • 查询 4<x4 \lt xrightmost(4)+1..rightmost(4) + 1 .. \infty
  • 查询 4x4 \leq xleftmost(4)..leftmost(4) .. \infty
  • 查询 4x74 \leq x \leq 7leftmost(4)..rightmost(7)leftmost(4) .. rightmost(7)
  • 查询 4<x<74 \lt x \lt 7rightmost(4)+1..leftmost(7)1rightmost(4)+1 .. leftmost(7)-1

求排名leftmost(target)+1leftmost(target) + 1

  • targettarget 可以不存在,如:leftmost(5)+1=6leftmost(5)+1 = 6
  • targettarget 也可以存在,如:leftmost(4)+1=3leftmost(4)+1 = 3

求前任(predecessor)leftmost(target)1leftmost(target) - 1

  • leftmost(3)1=1leftmost(3) - 1 = 1,前任 a1=2a_1 = 2
  • leftmost(4)1=1leftmost(4) - 1 = 1,前任 a1=2a_1 = 2

求后任(successor)rightmost(target)+1rightmost(target)+1

  • rightmost(5)+1=5rightmost(5) + 1 = 5,后任 a5=7a_5 = 7
  • rightmost(4)+1=5rightmost(4) + 1 = 5,后任 a5=7a_5 = 7

求最近邻居

  • 前任和后任距离更近者

附录

参考文章

下一篇

1Array

Footnotes

  1. "Definition of ALGORITHM". Merriam-Webster Online Dictionary. Archived from the original on February 14, 2020. Retrieved November 14, 2019.

  2. Introduction to Algorithm 中文译作《算法导论》 2

  3. 主要参考文档 https://en.wikipedia.org/wiki/Binary_search_algorithm