数据结构与算法--基础算法

目录

递归

排序

简单排序算法

归并排序

快速排序

桶排序

计数排序

基数排序

排序算法总结

二分查找

哈希算法

参考


 

 

递归

有两个最难以解的知识点,一个是动态规划,一个是递归
深度优先遍历,前中后序二叉树遍历都会用到递归算法

所有的递归问题都可以用递推公式来表示
f(n) = f(n-1)+1,其中f(1)=1
写递归代码最关键的是 写出递推公式,找到终止条件,剩下将递推公式转化为代码就很简单了

递归需要满足三个条件

  • 一个问题的解可以分解成几个子问题的解
  • 这个问题与分解后的子问题,除了数据规模不同,求解思路完全一样
  • 存在递归终止条件

假设有n个台阶,每次可以跨1个台阶或者2个台阶,走这n个台阶有多少走法?

  • 如7个台阶可以,2-2-2-1这样,也可以1-2-1-1-2这样
  • 可以根据第一步的走法把 所有走法分两类,
  • 第一类是第一步走了1个台阶,另一类是第一步走了2个台阶
  • 所以,n个台阶的走法等于
  • 先走1阶后 n-1个台阶的走法 +  先走2阶后 n-2个台阶的走法,公式为
  • f(n) = f(n-1) + f(n-2)
  • 终止条件是f(1)=1,f(2)=2

最后得到的结果是

int f(int n) {
    if(n == 1) return 1;
    if(n == 2) return 2;
    return f(n-1) + f(n-2);
}

写递归代码的关键

  • 找到如何将大问题分解为小问题的规律, 并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码
  • 只要遇到递归,就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑分解递归的每个步骤

注意事项

  • 递归代码要警惕堆栈溢出
  • 递归代码要警惕重复计算(下台阶问题,可以用hash表保存重复值
  • 递归的空间复杂度很高

下台阶的整个计算分解过程如下图

下台阶改成非递归实现

int f(int n) {
    if(n == 1) return 1;
    if(n == 2) return 2;
    int ret = 0;
    int pre = 2;
    int prepre = 1;
    for(int i=3;i<=n;i++) {
        ret = pre + prepre;
        prepre = pre;
        pre = ret;
    }
    return ret;
}

笼统的讲,所有的递归代码都可以改写为迭代循环的非递归写法。
如何做?抽象出递推公式、初始值和边界条件,然后用迭代循环实现。


寻找最终推荐人

/**
   寻找最终推荐人的方式
   不过这段代码有问题
   1.递归可能很深
   2.脏数据情况下可能会死循环
   更高级的处理方式,自动检测A-B-C-A这种环的存在
**/
long findRootReferrerId(long actorId) {
    long referrerId = select referrer_id from [table] where actor_id=actorId;
    if(referrerId == null) return actorId;
    return findRootReferrerId(referrerId);
}

 

排序

分析一个排序算法的好坏
最好情况,最坏情况,平均情况时间复杂度
时间复杂度的系数,常数,低阶
比较次数和交换(移动)次数
空间复杂度为O(1)的叫原地排序
排序算法的稳定性

稳定排序算法使用的场景
假设有一笔订单,订单有两个属性(下单时间,订单金额),如有10W条订单数据,按金额从小到大排序,
对于金额相同的订单,按照下单时间从早到晚有序
首先,按照下单时间给订单排序
之后,再用稳定排序算法,按订单金额重新排序
两边排序之后,得到的订单数据就是按金额从小到大排序的,金额相同则按下单时间从早到晚有序

 

 

简单排序算法

冒泡排序

空间复杂度是O(1),是原地排序算法
是稳定排序算法
最好情况时间复杂度是O(n),最坏是O(n^2),平均是O(n^2)

有序度是数组中具有 有序关系的元素对的个数,有序元素对用数学表达式表示为
有序元素对: a[i] <= a[j],如果i<j

对于一个倒排列的数组,如6,5,4,3,2,1 有序度为0,对于一个完全有序的数组如1,2,3,4,5,6,

  • 有序度为n*(n-1)/2,为15,这种叫做满有序度
  • 逆序度正好跟有序度相反(默认从小到大为有序),其定义如下
  • 逆序元素对:a[i] > a[j],如果 i<j
  • 关于这三个概念,可以得到一个公式: 逆序度 = 满有序度 - 有序度

排序的过程是增加有序度,减少逆序度的过程,最后到达满有序度,说明排序完成
假设待排序的数组初始是 4,5,6,3,2,1 其中有序元素读有(4,5)(4,6)(5,6),有序度为3,n=6
排序完之后满有序度为 n*(n-1)/2=15

冒泡排序包含两个操作原子,比较和交换,每交换一次,有序度+1,不管怎么改进,交换次数总是确定的,即逆序度
也就是n*(n-1)/2 初始有序度
对包含n个数据的数组进行冒泡排序,最坏有序度是0,要进行n*(n-1)/2次交换,
最好情况有序度是n*(n-1)/2,不需要交换
平均交换次数可以取中间值 n*(n-1)/4,也就是0(n^2)
这个平均时间复杂度推导过程并不严格,但很多时候很实用

 


插入排序

将数组中的数据分为两个区域,已排序区域 和 未排序区域
插入算法的核心思想是取未排序区域中的元素,在已排序区间中找到合适的插入位置将其插入,
并保证已排序区间一直有序,重复这个过程,直到未排序区间中元素为空,算法结束

插入排序包括两个操作,元素比较,元素移动
移动操作的次数就等于逆序度
以下图为列,满有序度为15,初始有序度为5,逆序度为10,所以数据移动次数为10

插入排序总结

  • 空间复杂度为O(1),是原地排序算法
  • 是稳定的排序算法
  • 最好情况时间复杂度为O(n),最坏为O(n^2),平均为O(n^2)

 

选择排序

类似插入排序,也分已排序区间和未排序区间,但选择排序每次都会从未排序区间中找到最小的元素,将其放到已排序区间的末尾

选择排序总结

  • 空间复杂度为O(1),是原地排序算法
  • 最好情况时间复杂度为O(n),最坏为O(n^2),平均为O(n^2)
  • 选择排序是不稳定排序算法,从上图中可以看到,选择排序每次偶读要找剩余未排序元素中最小值,并和前面的元素交换位置,
  • 这样破坏了稳定性,所以相比冒泡排序和插入排序,选择排序就逊色了

 

 

归并排序

使用的是分治思想,分治算法一般都是用递归来实现的
分治是一种解决问题的处理思想,递归是一种编程技巧

算法实现

// 归并排序算法, A 是数组,n 表示数组大小
merge_sort(A, n) {
  merge_sort_c(A, 0, n-1)
}

// 递归调用函数
merge_sort_c(A, p, r) {
  // 递归终止条件
  if p >= r  then return

  // 取 p 到 r 之间的中间位置 q
  q = (p+r) / 2
  // 分治递归
  merge_sort_c(A, p, q)
  merge_sort_c(A, q+1, r)
  // 将 A[p...q] 和 A[q+1...r] 合并为 A[p...r]
  merge(A[p...r], A[p...q], A[q+1...r])
}

merge(A[p...r], A[p...q], A[q+1...r]) {
  var i := p,j := q+1,k := 0 // 初始化变量 i, j, k
  var tmp := new array[0...r-p] // 申请一个大小跟 A[p...r] 一样的临时数组
  while i<=q AND j<=r do {
    if A[i] <= A[j] {
      tmp[k++] = A[i++] // i++ 等于 i:=i+1
    } else {
      tmp[k++] = A[j++]
    }
  }
 
  // 判断哪个子数组中有剩余的数据
  var start := i,end := q
  if j<=r then start := j, end:=r
 
  // 将剩余的数据拷贝到临时数组 tmp
  while start <= end do {
    tmp[k++] = A[start++]
  }
 
  // 将 tmp 中的数组拷贝回 A[p...r]
  for i:=0 to r-p do {
    A[p+i] = tmp[i]
  }
}

归并排序图解

两个数组的合并过程

归并排序总结
1.是稳定的排序算法
2.最好最坏平均情况的时间复杂度都是O(n*logn)
3.空间复杂度是O(n)

 

快速排序

算法实现

// 快速排序,A 是数组,n 表示数组的大小
quick_sort(A, n) {
  quick_sort_c(A, 0, n-1)
}

// 快速排序递归函数,p,r 为下标
quick_sort_c(A, p, r) {
  if p >= r then return
 
  q = partition(A, p, r) // 获取分区点
  quick_sort_c(A, p, q-1)
  quick_sort_c(A, q+1, r)
}

partition(A, p, r) {
  pivot := A[r]
  i := p
  for j := p to r-1 do {
    if A[j] < pivot {
      swap A[i] with A[j]
      i := i+1
    }
  }
  swap A[i] with A[r]
  return i
}

关于pivot点的选择

partition()函数的执行过程

快速排序 vs 归并排序

归并排序是由下到上的,先处理子问题,然后再合并
快速排序是由上到下的,先分区,再处理子问题

快速排序总结
1.不是稳定的排序算法
2.平均情况时间复杂度为O(n*logn),最坏是O(n^2)
3.空间复杂度是O(logn),堆栈调用需要空间

O(n)时间复杂度内求无序数组中的第K大元素
如4,2,5,12,3这样的一组数据,第3大元素是4
随机找一个pivot划分数组成三部分
A[0..p-1],A[p],A[p+1...n-1]
如果p+1=K,那么A[p]就是要求的元素,如果K>p+1则第K大元素出现在A[p+1...n-1]区间,否则出现在A[0...p-1]区间
继续递归的在下一个区间中查找

第一次查找要遍历n个元素,第二次是n/2,第三次是n/4,再是n/8...直到区间缩小为1
这是一个等比数列,最后的和等于2n-1,所以时间复杂度为O(n)

算法实现

// 计数排序,a 是数组,n 是数组大小。假设数组中存储的都是非负整数。
public void countingSort(int[] a, int n) {
  if (n <= 1) return;

  // 查找数组中数据的范围
  int max = a[0];
  for (int i = 1; i < n; ++i) {
    if (max < a[i]) {
      max = a[i];
    }
  }

  int[] c = new int[max + 1]; // 申请一个计数数组 c,下标大小 [0,max]
  for (int i = 0; i <= max; ++i) {
    c[i] = 0;
  }

  // 计算每个元素的个数,放入 c 中
  for (int i = 0; i < n; ++i) {
    c[a[i]]++;
  }

  // 依次累加
  for (int i = 1; i <= max; ++i) {
    c[i] = c[i-1] + c[i];
  }

  // 临时数组 r,存储排序之后的结果
  int[] r = new int[n];
  // 计算排序的关键步骤,有点难理解
  for (int i = n - 1; i >= 0; --i) {
    int index = c[a[i]]-1;
    r[index] = a[i];
    c[a[i]]--;
  }

  // 将结果拷贝给 a 数组
  for (int i = 0; i < n; ++i) {
    a[i] = r[i];
  }
}

 

 


桶排序

将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序,桶内排序完之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的

如果要排序的数据有n个,把他们均匀的划分到m个桶内,每个桶内有k=n/m个元素
每个桶里使用快速排序,时间复杂度为O(k*logk),m个桶排序的时间复杂度就是O(m*k*logk),因为k=n/m,所以整个桶排序时间复杂度为O(n*log(n/m))
当桶的个数m接近数据个数n时,log(n/m)就是一个非常小的常量,这个时候桶排序的时间复杂度接近O(n)


桶排序要求各个桶之间的分布是比较均匀的,有些桶里的数据非常多,有些非常少很不平均,那桶内数据排序的时间复杂度就不是长良机了,
在极端情况下,如果数据都被划分到一个桶里,那就退化为O(n*logn)的排序算法

桶排序比较适合在外部排序中,比如10G的订单数据,希望按订单金额(假设金额都是正整数)进行排序,假设内存有限只有几百M,没法把10GB装入内存
订单金额最小是1元,最大是10W元,按照金额划分到100个桶里,第一个桶存储金额在1元到1000元之内的订单,
第二个桶存储金额1001元到2000元之内的订单
如果订单金额在1到10W之间均匀分布,订单会被均匀分布到100个文件中,每个小文件存储100M订单数据,可以将这个100个小文件依次放到内存中,
用快速排序完成,等所有文件排序好后,只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,那这个文件中存储的就是按照金额从小到大排序的订单数据了
如果订单金额在1元到1000元之间的比较多,可以继续将这个区间划分为10个小区间,1元到100元,101元到200元,201到300元
如果还是无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止
 


计数排序

这种排序算法是桶排序的一种特殊情况,当要排序的n个数据所处的范围并不大的时候,如最大值是k,可以把数据划分成k个桶,
每个桶内的数据只都是相同的省掉了桶内排序的时间
假设某个省有50W考生,考生成绩在0--900之间,这个数据范围很小,可以划分成901个桶,对应分数从0分到900分,根据考生的成绩,
将这50W个考生划分到901个桶里
桶内的数据都是分数相同的考生无须再排序,只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就事先了50W考生的排序
其时间复杂度为O(n)

记数排序的一个例子
假设考生有8个,分数在0-5之间,考生成绩放到了数组A[8]中,分别为2,5,3,0,2,3,0,3
假设考生成绩从0-5,有6种情况,使用大小为6的数组C[6]表示桶,对应下标分数,C[6]中每个下标存储的是考生的个数
遍历一遍考生分数,就可以得到C[6]的值

对C[6]数组顺序求和,C[6]存储的数据就变成下面这样,C[k]里存储小雨等于分数k的考生个数

从后到前依次遍历数组A,如扫描到3时,可以从数组C中取出下标为3的值7,这表示到目前为止,包括自己在内,分数小雨等于3的考生
有7个,3就是数组R中的第7个元素(也就是数组R中下标为6的位置),当3放入到数组R后,小雨等于3的元素就只剩下6个了,所以C[3]要
减去1,变成6
以此类推,当扫描到第2个分数为3的考生时,就把他放入数组R中第6个元素位置(也就是下标为5的位置),当扫描完整个数组A后,
数组R内的数据就是按照分数从小到大有序了

这种利用另外一个数组计数的实现方式,就是这种排序算法叫计数排序的原因
计数排序只能用在数据范围不大的场景中,如果数据范围k比要排序的数据n大很多,就不适用计数排序
计数排序只能给非负整数排序,如果要排序的数据是其他类型,要将其在不改变相对大小的情况下,转化为非负整数

如考生成绩精确到小数后一位,就要将所有分数先乘以10转化为整数再放到9010个桶里
如果排序的数据中有负数,范围是[-1000,1000],就要先对每个数据都加1000,转化为非负整数

算法实现

// 计数排序,a 是数组,n 是数组大小。假设数组中存储的都是非负整数。
public void countingSort(int[] a, int n) {
  if (n <= 1) return;

  // 查找数组中数据的范围
  int max = a[0];
  for (int i = 1; i < n; ++i) {
    if (max < a[i]) {
      max = a[i];
    }
  }

  int[] c = new int[max + 1]; // 申请一个计数数组 c,下标大小 [0,max]
  for (int i = 0; i <= max; ++i) {
    c[i] = 0;
  }

  // 计算每个元素的个数,放入 c 中
  for (int i = 0; i < n; ++i) {
    c[a[i]]++;
  }

  // 依次累加
  for (int i = 1; i <= max; ++i) {
    c[i] = c[i-1] + c[i];
  }

  // 临时数组 r,存储排序之后的结果
  int[] r = new int[n];
  // 计算排序的关键步骤,有点难理解
  for (int i = n - 1; i >= 0; --i) {
    int index = c[a[i]]-1;
    r[index] = a[i];
    c[a[i]]--;
  }

  // 将结果拷贝给 a 数组
  for (int i = 0; i < n; ++i) {
    a[i] = r[i];
  }
}

 

 

基数排序

对10W个手机号排序,因为范围太大,桶排序,计数排序都不合适了
两个手机号a,b的大小可以有这样的规律:如果前面几位中a手机号码 已经比 b手机号码大了,那么后面几位就不用比较了
借助稳定排序算法,先排序最后一位,再排序倒数第二位,最后按第一位重新排序
经过11次排序后,手机号码就有序了
手机号码太长了,这里用字符串排序举例子

注意排序的算法必须是稳定的
如果排序数据不是等长的,如单词长度不同,可以把所有单词补齐到相同长度,位数不够的可以在后面补“0”
根据ASCII值,所有字母都大于0,补0不会影响到原有大小顺序
基数排序对数据有要求,需要可以分割出独立的“位”来比较,而且位之间劝递进的关系,如果a的数据的高位比b数据大,
剩下的低位就不用比较了
每一位的数据范围不能太大,可以用线性排序算法来排序,否则基数排序就就无法达到O(n)了

 

 


排序算法总结

简单排序总结

插入排序和冒泡排序平均时间复杂度都是O(n^2),但实际情况是插入排序性能更好
原因是冒泡排序的数据交换要比插入排序的数据移动要复杂
冒泡排序要3个赋值操作,而插入排序只需要1个

把执行一个赋值语句的时间粗略计算为单位时间 unit_time,然后分别用冒泡排序 和 插入排序
对同一个逆序度是K的数组进行排序,用冒泡排序,需要K次交换操作,每次需要3个赋值语句,所以
总耗时就是3*K单位时间,而插入排序数据移动操作只需要K个单位时间

下图是对 冒泡排序,插入排序选择排序的比较

所有排序的图表总结

线性排序适用场景有限,普通排序时间复杂度高,作为通用排序只能选择高级排序算法
归并排序是稳定排序,而且平均最坏情况时间复杂度也是O(n*logn),但是空间复杂度是O(n)所以没有被器重

对快速排序的优化
选择第一个,中间,最后一个点,三点选一,如果数据量很大可以5选1甚至10选1,选择一个中间值做pivot
也可以随机取一个值做pivot

glic中的qsort()函数,当数据量小时候会选择归并排序,当数据量大时候则用快速排序(3点选1做pivot)
为防止递归太深导致栈溢出,qsort()通过自己实现一个堆上栈,手动模拟递归来解决的
qsort()在快速排序过程中,当排序的区间中元素小于等于4时,就用插入排序完成
数据量很小时O(n^2)复杂度不一定比O(n*logn)差,因为复杂度中省略了低阶和系数
Java中则使用堆排序作为排序实现函数

 

 

 

二分查找

容易出错的地方

  1. 循环退出条件
  2. mid的取值
  3. low和high的更新

二分查找的局限

  1. 二分查找依赖的是顺序表结构,也就是数组
  2. 二分查找针对的是有序数组
  3. 数据量太小不适合二分查找
  4. 数据量太大也不适合二分查找,需要连续的大量空间
  5. 二分查找底层依赖数组,二叉树和散列表需要额外空间
  6. 二分查找类似分治的思想

4种常见的二分查找变形问题(数组中元素有重复)

  1. 查找第一个值等于给定值的元素
  2. 查找最后一个值等于给定值的元素
  3. 查找第一个大于等于给定值的元素
  4. 查找最后一个小雨等于给定值的元素

第一种和第二种变体

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
        //查找第一个值等于给定值的元素
        if ((mid == 0) || (a[mid - 1] != value)) {
            return mid;
        }
        //查找最后一个值等于给定值的元素
        if ((mid == n - 1) || (a[mid + 1] != value)) {
            return mid;
        } 
        else {
            //查找第一个等于给定值的元素
            high = mid - 1;

            //查找最后一个值等于给定值的元素
            low = mid + 1;
        }
    }
  }
  return -1;
}

第三种变体

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] >= value) {
      if ((mid == 0) || (a[mid - 1] < value)) return mid;
      else high = mid - 1;
    } else {
      low = mid + 1;
    }
  }
  return -1;
}

第四种变体

public int bsearch7(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else {
      if ((mid == n - 1) || (a[mid + 1] > value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}

 

 

哈希算法

优秀的哈希算法需要满足的条件

  1. 从哈希值不能反向推导出原始数据(所以叫单向哈希算法)
  2. 对输入数据非常敏感,只要改变一个bit最后的哈希值也大不相同
  3. 散列冲突的概率要小,对于不同的原始数据,哈希值相同的概率非常小
  4. 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速计算出哈希值

哈希算法的应用

  • 安全加密,MD5,SHA,DES,AES,根据鸽笼原理哈希算法没法做到不冲突
  • 图片做唯一标识(取图片前中后个100字节做标识),
  • BT下载的P2P算法,1个文件被分成100块,每个块都被计算了哈希值,下载后的块要跟哈希值对比
  • 散列函数,这个对冲突要求低很多

哈希算法的分布式应用

  • 负载均衡,通过客户端IP/端口/会话ID做哈希,将取得的值预服务器的大小进行取模运算,得到最终发服务器编号
  • 数据分片,对1T日志分片,多台机器处理,将搜索的关键字做哈希再取模,得到的结构就是机器编号最后再汇总,这是map-reduce
  • 数据分片,判断图片是否在图库中,1亿张图片,每次从图库中读取一个图片计算唯一标识再与机器个数取模得到机器编号,再将图片的唯一标识和图片路径发往赌赢的机器构建散列表
  • 一致性hash

哈希算法的其他应用
CRC校验
Git commit id等

 

 

 

 

参考

希尔排序

 

 

 

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值