算法与数据结构概要

最近研究 MIT 6.828 操作系统课程,也重温了一下机器语言,还 IDA 这样的逆向屠龙宝刀,还有基于 Rust 的 Deno,研究了其架构及阅读了部分源代码。Rust QuickJS V8 Deno TypeScript 等太有吸引力,估计相今后当长的时间都会在玩这几个玩具。

另外,总结了一下数据结构与算法,花了相当多的时间在 Binary Tree 特别是 Red-Black Tree 上面。

本文相当长,涉及了以下排序或搜索算法,都是最基本要掌握的,感觉也写得相当简明了,基本没有公式什么的:

  • 🚩 Algrithms & Data Structures
  • 🚩 Insertion sort
  • 🚩 Selection sort
  • 🚩 Counting sort
  • 🚩 Shell sort
  • 🚩 Quick Sort NORMAL
  • 🚩 Heap sort
  • 🚩 Interpolation Search

🚩 Algrithms & Data Structures 算法与数据结构

网络有大量算法训练平台,如 AlgoExpert 是一个 LeetCode 精选的服务,因为 LeetCode 现在的题目实在是太多了,但解答、提示等内容的质量又参差不齐。AlgoExpert 解决了这些问题:首先,它提供不到 100 道算法题,覆盖不同难度和不同知识点;其次,每道题都有一系列循序渐进的提示,使得你可以从毫无头绪开始一步一步做到最优解;最后,每道题最后都有视频讲解,从最笨的解法开始一步一步讲解如何优化,最后达成最优解。

Data structures & algorithms 是计算机语言的基石,是软件灵魂的支撑。研究计算机编程本质上就是在弄清楚底层的数据结构与算法,了解信息的数字化与其保存在内存的结构排列,与各种算法和数据结构的性能。

面向对象编程与传统的函数式编程最大差别就是,从数据 --> 函数 --> 结果的这条流程转变为,对象包含函数方法,不同的对象实现不同的功能,对象本身包含数据,也可以处理数据的输入输出。

与函数式编程简洁的表达不同,面向对象编程引入的一套抽象理论和对象封装方法 Abstraction & Encapsulation,会大大增加软件的复杂度。所以无论是 OOP - Object-Oriented Programming 还 FP - Functional Programming 没有绝对的好坏,只有合适的才是最好的。

面向对象的语言设计,基于抽象的数据类型定义 ADT - Abstract Data Type,将最基本的数据类型,始数值、字符串等等重新做了整体组织,抽象成面向对象的编程体系。包括不仅限于 stacks, queues, deques, ordered lists, sorted lists, hash and scatter tables, trees, priority queues, sets, and graphs.

在设计算法时,需要考虑知种因素,如内存需求量、时间效率等复杂度,Time Factor、Space Factor 这两个的指标是最关键,有各种渐进评价方法 Asymptotic Notations,如 Big O, Little o, Theta, Omega, Little w。

根据算法的具体特点差异,可能对不同的数据会有不同的表现,所以 Worst Case、 Average Case、 Best Case 三种情况也是比较重要的评判依据。

渐近分析法大 O 表示法较常见,根据输入的不同,复杂度可以如下表示:

  • O(1) 常数复杂度 constant,最好,表示与输入没有关系,算法保持固定的时间或空间效率。
  • O(logn) 对数复杂度 logarithmic,相当好。
  • O(n) 线性复杂度 linear,和输入成正相关,还不错,输入数据越多需要时间也越多。
  • O(nlogn) 线性对数阶 n log n,QuickSort 排序属于这个级别,输入变量引起的复杂度是线性复杂度的倍率增长。
  • O(n^2) 平方阶 quadratic,有点问题了。
  • O(n^3) 立方阶 cubic,算法特没效率。
  • O(2^n) 指数阶 polynomial,幂复杂度,非常复杂,输入数据稍有增加,需要消耗的时间会剧增。
  • O(n!) 阶乘级 exponential,Traveling Salesman Problem 旅行商问题在计算机科学领域是无解的,n 个城市就有 n! 种规划方案。

作为数据结构入门,一般都会从搜索算法、排序算法、递归等基础问题入手,配合相应的数据结构,如数组、链表、Stack、Queue、二叉树,由简单到复杂:

  • Searching Techniques
    • Linear Search
    • Binary Search
    • Interpolation Search
    • Hash Table
  • Sorting Techniques
    • Comparison Sorting
      • Bubble Sort
      • Selection Sort
      • Insertion Sort
      • Shell Sort
      • Merge Sort
      • Comb Sort
      • Quck Sort
    • Bucket Sort
    • Counting Sort
    • Radix Sort
    • Heap Sort
  • Recursion
    • Recursion Basics
    • Tower of Hanoi
    • Fibonacci Series

数据结构和算法是密不可分,前者重于数据的组织,而后者着重于具体问题的方法逻辑实现。根据问题的不同,算法的一般策略有以下这些:

  • Greedy Algorithms 贪婪算法
    • Travelling Salesman Problem
    • Prim's Minimal Spanning Tree Algorithm
    • Kruskal's Minimal Spanning Tree Algorithm
    • Dijkstra's Minimal Spanning Tree Algorithm
    • Graph - Map Coloring
    • Graph - Vertex Cover
    • Knapsack Problem
    • Job Scheduling Problem
  • Divide and Conquer 分而治之
    • Merge Sort
    • Quick Sort
    • Binary Search
    • Strassen's Matrix Multiplication
    • Closest pair (points)
  • Dynamic Programming 动态规划
    • Fibonacci number series
    • Knapsack problem
    • Tower of Hanoi
    • All pair shortest path by Floyd-Warshall
    • Shortest path by Dijkstra
    • Project scheduling

动态规划(Dynamic Programming,DP)是运筹学的一个分支,是求解决策过程最优化的过程。20 世纪 50 年代初,美国数学家贝尔曼(R.Bellman)等人在研究多阶段决策过程的优化问题时,提出了著名的最优化原理,从而创立了动态规划。动态规划的应用极其广泛,包括工程技术、经济、工业生产、军事以及自动化控制等领域,并在背包问题、生产经营问题、资金管理问题、资源分配问题、最短路径问题和复杂系统可靠性问题等中取得了显著的效果。

动态规划和分治法非常类似,但是子问题间的解决方案联系更密切:

  • 这个问题应该可以划分成更小的重叠子问题。
  • 用较小的子问题的最优解可以得到最优解。
  • 动态算法使用记忆,已有的解可以辅助解决未解决的子问题。

数组可能是最简单的抽象类型了,在连续的内存区域开辟一块专用区域就可以用来保存数组:

int[] a = new int[5];
int[][] x = new int[3][5];

然后通过下标访问每个数组元素,通过抽象概念将保存数据的每个内存地址表达为一个元素。多维数据和一维数组没有本质区别,就是增加一个维度计算的变量,同时二维数组还可以抽象为矩阵 Matrices。

比数组复杂一点的就是单向链表了 Singly-Linked Lists,每个元素连接下一个元素。只需要知道链表的起点,就可以依次访问到尾端元素。每个元素只需要 head data tail 三个基本属性,data 存放数据,另外两个作为指针引用链表的前后端元素。

#include <iostream>
using namespace std;

struct listNode {
    int value;
    listNode * next;

    // 链表节点默认构造函数,显式使用 nullptr 初始化空指针
    listNode(): next(nullptr) { }
    listNode(int theValue): value(theValue), next(nullptr) {
        // listNode(); 
    }

    listNode * attachNode(int value) {
        listNode *n = new listNode(value);
        n->next = this;
        return n;
    }
};

int main() {

    listNode * node = new listNode(0);

    node = node->attachNode(1)->attachNode(2)->attachNode(3)->attachNode(4);

    int indexNode = 0;
    while (node != nullptr) {
        cout << "链表节点 " << indexNode << ": " << node->value << " @" << node << endl;
        node = node->next;
        indexNode++;
    }

    return 0;
}

🚩 Insertion sort

Insertion sort 是和 Bubbble sort 差不多难度的算法,和打扑克牌时对手上的卡片进行排序的过程类似:

  • 先以第 1 张作为参考,比较 1、2 张牌,然后将小的放前面,即交换位置。
  • 再比较第 2、3 张,将小的交换到前面,如果比前面一张要大就表示已经是从小到大排序好了。
  • 重复这个操作直到所有牌都排序完毕。

这样的算法对少量的数据是很有效率的,时间复杂度为 O(n^2),随着数据数量的增加,时间要求按 2 次方增加,如果能对前面已排序的数据进行二分法查找将大量节省时间,对有序数据进行 Binary search 折半查找,在大量数据处理中效率是明显的,它能将 isort 算法内层的 for 循环,即查找部分的时间复杂度降为 Ο(log n),而不是 Linear search 的 O(n^2),对于大量数据的排序,isort 的主要消耗就在内层循环。

int isort(unsigned char a[], int n){
   unsigned char k;
   for (size_t i = 1; i < n; i++)
   {
       k = a[i];
       for (size_t j = i; j > 0; j--)
       {
           if (k < a[j-1]) {
               a[j] = a[j-1];
               a[j-1] = k;
           }
       }
   }
   return n;
}

unsigned char s[0xff] = "You would supposed to type some words:";
printf("%s\n", s);
scanf("%[^\n]", s);
isort(s, strlen(s));
printf("isort:%s", s);

scanf 可以用来获取一行内容输入,但是不能处理非 ASCII 字符,如 "French suits of trèfles" 将获取不到后面的 4 个字符。

🚩 Selection sort

选择和插入排序很像,只是方向反过来操作。

− 将第 1 个数据作为 MIN 参考值。
− 搜索后面比它小的数,如果有更小的,就更新 MIN,直到找到所有数据中最小的数,并将最小的数交换到前面。
− 将 MIN 参考值设置为第 2 个数据,按以上同样操作,直到所有数据排序完成。

console.log(selectionSort(genArray(20)).join(","));
function genArray(len: number){
  let arr = [], scale = len*10;
  while(len-->0) arr.push(Math.ceil(Math.random()*scale));
  console.log(arr.join(","));
  return arr;
}

function selectionSort(a: number[]){
    let length = a.length;
    for (var i = 0; i < length; ++i) {
        let k = 0, min = a[i];
        for (var j = i+1; j < length; ++j) {
            if(min>a[j]) {
                k = j;
                min = a[j];
            }
        }
        if(k){
            let t = a[i];
            a[i] = a[k];
            a[k] = t;
        }
    }
    return a;
}

🚩 Counting sort

计数排序的核心在于将输入的数据值转化为 key 并将相同值的出现的次数记录在另外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数,所以,如果数据数值的跨度在将会导致消耗大量的内存。

输入的元素是 n 个 0 到 k 之间的整数时,它的运行时间是 Θ(n + k)。计数排序不是比较排序,排序的速度快于任何比较排序算法。

计数排序不适合按字母顺序排序人名,但是,可以用在基数排序中,来排序数据范围很大的数组。

算法步骤如下:

  • 统计数据元素出现的次数,记录到数组 C,数据的值作为数组元素索引。
  • 按统计数量,将值按顺序回填到原数组,得到的就是排好序的数据。

以下代码为 TypeScript,使用脚本的好处就是方便,可以直接使用键值对存储数据,如果使用 C 语言需要使用动态内存分配,需要找最大值,或者预设最大值。

console.log(countingSort(genArray(20)).join(","));
function genArray(len: number){
  let arr = [], scale = len*10;
  while(len-->0) arr.push(Math.ceil(Math.random()*scale));
  console.log(arr.join(","));
  return arr;
}

function countingSort(a: number[]){
    let c:{[key:number]:number} = {};
    let key = 0;
    for (var i = 0; i < a.length; ++i) {
        key = a[i];
        c[key] = c[key]? c[key] + 1:1;
    }
    key = 0;
    for(var k in c){
        for (var j = 0; j < c[k]; ++j) {
            a[key++] = +k;
        }
    }
    return a;
}

🚩 Shell sort

The C Programming Language 教材为了解析 for 循环,演示了 Shell sort 排序算法:

/* shellsort:  sort v[0]...v[n-1] into increasing order */
void shellsort(int v[], int n)
{
   int gap, i, j, temp;

   for (gap = n/2; gap > 0; gap /= 2)
       for (i = gap; i < n; i++)
           for (j=i-gap; j>=0 && v[j]>v[j+gap]; j-=gap) {
               temp = v[j];
               v[j] = v[j+gap];
               v[j+gap] = temp;
           }
}

这是一个三层 for 嵌套的函数结构,外层定义 gap 负责控制两个子集比较的粒度,从 n/2 到 1 的粒度逐次缩小。中间层负责遍历所有数据,而最内层负责比较并更新顺序。

在多个嵌套循环保持循环控制集中的优势明显,Shell sort 这种排序算法的基本思想是 D. L. Shell 设计的,名字念希尔。在比较初期阶段,用粗粒度间距对元素进行粗排,而不是简单的交换相邻的元素。这种粗排往往会很快消除大量的紊乱,使后期的工作更少。随着比较元素之间的间隔逐渐减小为 1,此时排序有效地成为相邻交换方法,所有元素最终都会正确排序。

注意 for 的通用性使外循环与其他循环以相同的形式拟合,即使它不是算术级数,即可以指定 step 值。因为每轮的间隔在递减,但粗排序好的数据在增加,所以也叫递减增量排序,增量意思指数据的有序性在增加。正是这种逐次减半的粒度控制,使用得所有数据都得到正常的排序,而不被遗漏。类似的概念的排序还 Comb sort,梳排序,同时它结合 Bubble sort 的比较逻辑。

列如,对一个单词的字母进行排序:

supposed <- gap = 4
^   ^
ouppssed
 ^   ^
osppsued
  ^   ^
osepsupd
   ^   ^
osedsupp <- gap = 2
^ ^
esodsupp
 ^ ^
edossupp
    ^ ^
edospusp
     ^ ^
edosppsu
   ^ ^
edoppssu <- gap = 1
^^
deoppssu

🚩 Quick Sort NORMAL

快速排序核心思维是二分法加递归处理,而巧妙之处在于二分策略,即分治法(Divide and conquer)策略,按一个参考值将数据分为大小 2 个子集,然后递归地排序两个子序列。

它使用了 3 个元素,基本排序流程如下:

  • pivot 中枢元素,指向一个参考值,将数据分成两个区;
  • low 左边界,从左向右与 pivot 进行比较,将更大的值交换到右区,并进入下一步骤;
  • high 右边界,从右向左与 pivot 进行比较,将更小的值交换到左区,完全分区后,将 pivot 放到新的中枢位,进入新一轮递归处理;

就是这三个元素完成了 Quick Sort 的最巧妙的二分策略,在快速分组的同时,又进行了初步的排序。

Complexity

Name Best Average Worst Memory Stable
Quick sort n log(n) n log(n) n^2 log(n) No

而对于渐进有序的数组来说,每次区分其实都是极其不平衡的,普通快排甚至会退化成 O(n^2),可能需要多路快排。

选择合适的 pivot 值对效率有很大影响,比如 [ 7, 6, 5, 4, 3, 2, 1 ] 选择了 1 作为 pivot 就很没效率。

console.log(sortArray(genArray(200)).join(","));
function genArray(len: number){
  let arr = [], scale = len*100;
  while(len-->0) arr.push(Math.ceil(Math.random()*scale));
  console.log(arr.join(","));
  return arr;
}

function sortArray(nums: number[]): number[] {
    quickSort(nums, 0, nums.length-1);
    return nums;
};

function quickSort(nums: number[], low:number, high: number){
    let s = low, e = high;
    if(low >= high) return;
    let p = nums[high];
    while(low<high){
        while(low<high && nums[low]<p) low++;
        nums[high] = nums[low];
        while(low<high && nums[high]>=p) high--;
        nums[low] = nums[high];
    }
    nums[high] = p;
    quickSort(nums, s, low-1);
    quickSort(nums, low+1, e);
}

🚩 Heap sort

堆排序是利用 Binary Heap 二叉堆,也叫 Heap Tree 堆积树,这种树状数据结构而设计的一种排序算法,是一种选择排序,它的最坏,最好,平均时间复杂度均为 O(nlogn),它也是不稳定排序。

在计算机科学中,二叉堆是二叉树形状的堆结构,所以称为堆积树,是最常见的实现优先级队列的方法,在很多主流语言的标准算法库中都能看到它们的身影。同时它也是很多算法中需要用到的底层数据结构,能够快速地掌握这些已有的标准库和类,能够很高效地实现诸多算法。

binary treee

学习 Heap sort 之前,需要对二叉树的主要概念有一定理解:

  • Path − Path refers to the sequence of nodes along the edges of a tree.
  • Root − The node at the top of the tree is called root. There is only one root per tree and one path from the root node to any node.
  • Parent − Any node except the root node has one edge upward to a node called parent.
  • Child − The node below a given node connected by its edge downward is called its child node.
  • Leaf − The node which does not have any child node is called the leaf node.
  • Subtree − Subtree represents the descendants of a node.
  • Visiting − Visiting refers to checking the value of a node when control is on the node.
  • Traversing − Traversing means passing through nodes in a specific order.
  • Levels − Level of a node represents the generation of a node.
  • keys − Key represents a value of a node based on which a search operation is to be carried out for a node.

堆积树是一种完全二叉树,有两种形式,min-heap 小根堆要求节点小于或等于子节点值,max-heap 大根堆要求节点大于或等于子节点值。

      max-heap          min-heap
         9                 3        <-- Leevl 0
     ┌───┴───┐         ┌───┴───┐     
     8       7         4       5    <-- Leevl 1
   ┌─┴─┐   ┌─┴─┐     ┌─┴─┐   ┌─┴─┐   
   6   5   4   3     6   7   8   9  <-- Leevl 2
 ┌─┴─┐             ┌─┴                
 6   5             7                <-- Leevl 3

所谓完全二叉树,即除最底层 L 的叶节点外,L-2 层以上的所有节点都有 2 个子节点,如果 L-1 层以上所有节点都有 2 个子节点则称为完满二叉树。

完全二叉树是一种效率很高的数据结构,通常采用数组形式存储,可以快速计算出一个节点的父子节点,同时不需要额外存储索引信息。

根据叶节点的分布不同,二叉树可以按以下分类:

  • 完美二叉树 Perfect Binary Tree 所有叶节点深度相同,所有非叶节点都有两点子节点。也称为满二叉树,深度为 k(>=-1) 且有 2^(k+1) - 1 个节点。
  • 完全二叉树 Complete Binary Tree,除了最底层,所有节点都是完全充满子节点,而叶子节点从左到友依次排列,不需要填满二叉树。
  • 完满二叉树 Full Binary Tree/Strictly Binary Tree,除叶节点外,所有节点都有两个子节点。换句话说,只要有子节点,就必有两个子节点。
  • 平衡二叉树 Balance Binary Tree 要求左右子树的高度差至多为 1 个节点。
  • 自平衡二叉树 Self-Balancing Binary Search Tree 简称 AVL 平衡二叉树,名字来源于它的发明作者 G.M. Adelson-Velsky 和 E.M. Landis。

二叉堆的空间复杂度和相关操作的时间复杂度如下表所示:

| Algorithm | Average  | Worst Case |
|-----------|----------|------------|
| Space     | O(n)     | O(n)       |
| Search    | O(n)     | O(n)       |
| Insert    | O(1)     | O(log n)   |
| Delete    | O(log n) | O(log n)   |
| Peek      | O(1)     | O(1)       |

堆排序基本思想:将序列数据构造成大顶堆,整个序列的最大值就是根节点,将其交换到末尾,作为已排序数据。然后将剩余 n-1 个元素重新构造成一个堆,这样会得到 n 个元素的次大值。重复操作,直到得到一个有序序列。

所以,堆排序的要点在于构造二叉堆,还有摘顶后二叉堆的重建,这个过程称为 heapify。事实上,在排序过程中并不需要定义一个 BinaryHeap 对象,在一个堆中,k = 0 即顶级节点,位置 k 的子节点的父元素的位置是 (k+1)/2-1,而它的两个子节点的位置分别是 2k+1 和 2k+2,这样节点就和数组的索引关联起来了。对于一个 N 元素的堆积树,其节点也同样有 N 个,包括叶节点。

假设数据为 8 个元素的数组 [4,2,5,7,1,9,8,0],升序采用大顶堆,降序采用小顶堆。

首先,将数组的数据按先后顺序填充到二叉堆,从顶层 Level 0 到底层,从左节点到右节点依次填充,并依次给节点编号。

      max-heap     
         4         <-- Leevl 0
     ┌───┴───┐      
     2       5     <-- Leevl 1
   ┌─┴─┐   ┌─┴─┐    
   7   1   9   8   <-- Leevl 2
 ┌─┴               
 0                 <-- Leevl 3

从最后底层的非叶子结点开始调整,按按照数组的线性结构,最后的 7 号数组元素对应 3 号节点,7 = 2 * 3 + 1,节点对应位置。

从左至右,从下至上进行调整,因为节点值 7 比唯一的子节点值 0 大,所以不用调整,通过 2k+2 超出数组范围可以判断出没有第二个子节点。

接下来,对 3 号节点的父节点调整,依次比较两个子节点并按需要交换位置。即对 1 号节点调整,从子节点也可以反推父节点 3 = ( 2 * x) + 1,就是解个方程式。

这个过程就是一个沉降或抬升的操作,可以定义一个 heapify 函数将数据交换到合适位置,当移除根节点时,可以直接将数组最后元素提升为根节点,然后,再通过沉降函数交换到合适的位置上。

堆排序算法可以概括如下几个步骤:

  • 使用 heapify 函数将数组 H[0……n-1] 转换为一个二叉堆;
  • 把堆首(最大值)和堆尾互换;
  • 把堆的尺寸缩小 1,即将堆尾的元素排除,并调用 heapify 将新的根节点调整到合适位置;
  • 重复操作,直到堆的尺寸为 1 即完成排序。

注意,设计 heapify 函数时,需要先从节点树最底部抬升或沉降,相当于一个粗排,然后在后续对上层节点进行的 heapify 操作中,再细化一次以确保数据满足二叉堆的要求,这个过程相当巧妙。

如果使用 max-heap,那么就从数组的末尾即二叉树最底层节点开始抬升大值数据。如果使用 min-heap,则从底层开始沉降小值数据。

可以参考旧金山大学 University of San Francisco 官方网站,David Galles 助教制作的数据结构与算法动画演示工具。

let result = heapSort(genArray(8));
console.log(result.join(","));
verify(result);

function genArray(len: number){
  let arr = [], scale = len*10;
  while(len-->0) arr.push(Math.ceil(Math.random()*scale));
  console.log(arr.join(","));
  return arr;
}

function verify(a: number[]){
    for(let i=0; i<a.length; i++){
        if (a[i]>a[i+1]) {
            return console.log(a.slice(0, i).join(",").replace(/./g, " ") + "^ VERIFY FAILURE AT "+i);
        }
    }
    return console.log("VERIFY PASSED!");
}

function heapSort(a: number[]){
    // build an max-heap
    let length = a.length;
    for (var i = length/2; i >= 0; --i) {
        heapfy(a, Math.floor(i), length);
    }
    console.log("HEAPFIED!");
    // sort all nodes
    for(let i=length-1; i>=0; i--){
        swap(a, 0, i);
        heapfy(a, 0, i);
    }
    return a;
}

function heapfy(a:number[], k:number, N:number) {
    while (2*k+1 < N) {
        let g = Math.floor(2*k + 1);
        if (g < N-1 && a[g+1] >= a[g]){
            g ++;
        }
        if (a[k] < a[g]){
            swap(a, k, g);
        }
        k = g;
    }
}

function swap(a:number[], x:number, y:number){
    let tag = a.slice(0, x+1).join(",").replace(/./g, " ")+"^"+
        a.slice(x, y).join(",").replace(/./g, " ")+"^";
    let pad = a.join(",").slice(0, a.join(",").length-tag.length+2).replace(/./g, " ");
    console.log(a.join(","));
    console.log(tag+pad+a[x]+" <==> "+a[y]);
    let t = a[x];
    a[x] = a[y];
    a[y] = t;
}

测试输出:

11,55,59,37,65,3,23,21                     34,9,9,24,80,15,42,25             
     ^        ^         55 <==> 65                  ^           ^ 24 <==> 25 
11,65,59,37,55,3,23,21                     34,9,9,25,80,15,42,24             
  ^  ^                  11 <==> 65               ^          ^     9 <==> 42  
65,11,59,37,55,3,23,21                     34,9,42,25,80,15,9,24             
     ^        ^         11 <==> 55             ^       ^          9 <==> 80  
HEAPFIED!                                  34,80,42,25,9,15,9,24             
65,55,59,37,11,3,23,21                       ^  ^                 34 <==> 80 
  ^                   ^ 65 <==> 21         HEAPFIED!                         
21,55,59,37,11,3,23,65                     80,34,42,25,9,15,9,24             
  ^     ^               21 <==> 59           ^                  ^ 80 <==> 24 
59,55,21,37,11,3,23,65                     24,34,42,25,9,15,9,80             
        ^          ^    21 <==> 23           ^     ^              24 <==> 42 
59,55,23,37,11,3,21,65                     42,34,24,25,9,15,9,80             
  ^                ^    59 <==> 21           ^                ^   42 <==> 9  
21,55,23,37,11,3,59,65                     9,34,24,25,9,15,42,80             
  ^  ^                  21 <==> 55          ^ ^                   9 <==> 34  
55,21,23,37,11,3,59,65                     34,9,24,25,9,15,42,80             
     ^     ^            21 <==> 37             ^    ^             9 <==> 25  
55,37,23,21,11,3,59,65                     34,25,24,9,9,15,42,80             
  ^              ^      55 <==> 3            ^            ^       34 <==> 15 
3,37,23,21,11,55,59,65                     15,25,24,9,9,34,42,80             
 ^ ^                    3 <==> 37            ^  ^                 15 <==> 25 
37,3,23,21,11,55,59,65                     25,15,24,9,9,34,42,80             
    ^    ^              3 <==> 21            ^          ^         25 <==> 9  
37,21,23,3,11,55,59,65                     9,15,24,9,25,34,42,80             
  ^          ^          37 <==> 11          ^    ^                9 <==> 24  
11,21,23,3,37,55,59,65                     24,15,9,9,25,34,42,80             
  ^     ^               11 <==> 23           ^       ^            24 <==> 9  
23,21,11,3,37,55,59,65                     9,15,9,24,25,34,42,80             
  ^        ^            23 <==> 3           ^ ^                   9 <==> 15  
3,21,11,23,37,55,59,65                     15,9,9,24,25,34,42,80             
 ^ ^                    3 <==> 21            ^    ^               15 <==> 9  
21,3,11,23,37,55,59,65                     9,9,15,24,25,34,42,80             
  ^    ^                21 <==> 11          ^ ^                   9 <==> 9   
11,3,21,23,37,55,59,65                     9,9,15,24,25,34,42,80             
  ^  ^                  11 <==> 3           ^^                    9 <==> 9   
3,11,21,23,37,55,59,65                     9,9,15,24,25,34,42,80             
 ^^                     3 <==> 3           VERIFY PASSED!                    
3,11,21,23,37,55,59,65                    
VERIFY PASSED!                            

🚩 Interpolation Search

在有序数据搜索算法中,Linear search 是直观但也是最没有效率的,它从第一个数据查找一到到数据结束,如果刚好目标是最后一个,那么就是它的最差表现,算法复杂度为 O(n)。

如果在大量数据的查找,使用二分法查找 Binary Search,则可以大大节省时间。

mid = Lo + (Hi - Lo) / 2

首先,将数据对半分成两部分,目标的值落在哪个范围就找哪个部分的数据,重复进行数据的拆分,直到找到目标,复杂度为 Ο(log n),表现相当好了。

二分法的搜索过程相当于建立了一个二叉树,所以叫做 BST - Binary Search Tree。

但是对有序数据的利用还不够充分,而插值查找 Interpolation Search 则利用插值算法来分拆数据,使得分拆点更合理。

mid = Lo + ((Hi - Lo) / (A[Hi] - A[Lo])) * (X - A[Lo])

where −
   A    = list
   Lo   = Lowest index of the list
   Hi   = Highest index of the list
   A[n] = Value stored at index n in the list

插值算法通过计算差值空间,挑选出更靠近目标值 X 的拆分点,这样也就间接地减少了比较次数。

同样思想衍生出来的还有利用 Fibonacci Series 来定拆分点,裴波那契数列最重要的一个性质是每个数都等于前两个数之和,满足 F(0)=1,F(1)=1, F(n)=F(n-1)+F(n-2) (n>=2,n∈N),两个相邻项的比值会逐渐逼近 0.618 黄金分割比值,这个神奇的数列在自然界广泛存在。

对于表长较大,而关键字分布又比较均匀的查找表来说,插值查找算法的平均性能比折半查找要好的多。但是,数组中如果分布非常不均匀,那么插值查找未必是很合适的选择。

算法时间复杂度 Ο(log (log n)) 优于二分法搜索 Ο(log n)。

另外,计算分布需要用浮点数,如果数据中有重复值,那么公式将可能出现除 0 异常,正好通过一个小数偏差可以解决类型转换和除 0 问题。

#include <stdio.h>
#include <string.h>

// Interpolation Search
int isearch(char a[], int n, char x){
    int lo = 0, mi, hi = n-1;
    printf("%s\n", a);
    while (lo<hi) {
        mi = lo + (hi-lo)/(a[hi] - a[lo] + .1)*(x - a[lo]);
        printf("%*c%*c%*c<---try to find %c\n", 
            lo+1, '[', mi-lo, '^', hi-mi, ']', x);
        if (a[mi]==x){
            return mi;
        } else if (a[mi]<x) {
            lo = mi + 1;
        } else {
            hi = mi - 1;
        }
    }
    return a[lo] == x? lo : -1;
}

// Insertion sort
int isort(char a[], int n){
    char k;
    for (size_t i = 1; i < n; i++)
    {
        k = a[i];
        for (size_t j = i; j > 0; j--)
        {
            if (k < a[j-1]) {
                a[j] = a[j-1];
                a[j-1] = k;
            }
        }
    }
    return n;
}

int main()
{
    char s[0xff] = "You would supposed to type some words then a character:";
    char key;
    printf("%s\n", s);
    scanf(" %[^\n] %c", s, &key);
    isort(s, strlen(s));
    int k = isearch(s, strlen(s), key);
    printf("%s\n", s);
    printf("%*c[key at %d]", k+1, key, k);
    return 0;
}

测试输出:

You would supposed to type some words then a character:
a
         :Yaaaccdddeeeeehhlmnooooooppprrrssssttttuuuwwy
[                                      ^              ]<---try to find a
[                             ^       ]<---try to find a
[                      ^     ]<---try to find a
[                   ^ ]<---try to find a
[                ^ ]<---try to find a
[              ^]<---try to find a
[            ^]<---try to find a
         :Yaaaccdddeeeeehhlmnooooooppprrrssssttttuuuwwy
             a[key at 13]
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270

推荐阅读更多精彩内容