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

递归

简而言之,就是自己调自己。

当满足如下条件时,则可用递归来解决:

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

如何编写递归代码?

递归方法,必须 返回值能继续当成入参, 有条件终止调用。
如果没有条件中止调用,则会报出栈内存溢出。

递归的核心思想其实还是循环调用,是可以用for来替代的,只是代码利用了方法栈来进行循环,代码看起来更加简洁。

排序

冒泡 O(n²)

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。

从另一个角度来看,就是每一次对所有数据的交换,就把数据总体的对应位数上的值确定了 , 譬如第一次对所有数据进行遍历交换,则确定了最后一个位置的值。第二次再遍历就不用遍历最后一个位置了,第二次遍历就确定了倒数第二位的值。依次类推。
所以按照上述思路,我们实现则需要两个for循环,内循环进行数组遍历交换,外循环进行位置控制。 那么内循环和外循环则可以互动,外循环+1之后 ,内循环便可以不用再处理相应的数据:

    for(int i=0;ia[j+1]){
                        a[j] =a[j]^a[j+1];
                        a[j+1] = a[j]^a[j+1];
                        a[j] = a[j]^a[j+1];
                    }
              }
          }

插入排序(Insertion Sort)O(n²)

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

换句话说,也就是把首元素看作原点,依次遍历数据,遍历一次都把之前的数据看成一个已经排好序的数据群,把遍历到的数据插入到前面舒数据群中以维持当前位置向前的数据均已排好序,然后继续下一个元素。

  static void charu(int[] a){
        // 第一层for循环用于控制数据往后取
        for(int i=1;i0 && a[k-1]>flag;k--){
                    a[k]=a[k-1];
            }
          //数据插入
            a[k] =flag;
        }
    }

【注: 这里虽然有三层for循环,但是第三层和之前两层是由联系的,所以此处平均时间复杂度仍然为O(n²)】

选择排序(Selection Sort) O(n²)

也就是选择出一个最小的,然后放到最前面。其实跟插入排序差不多。

static void xuanze(int[] a){
      //  最小值 和  最小值角标
       int x  ;
       int index ;
      //第一层遍历代表外层  顺序过去  一个坑一个坑的填
       for(int i =0 ;ii;j--){
               if(a[j]

数据结构与算法-基础算法_第1张图片
image.png

原地排序及空间复杂度为O(1)
稳定指的是相同数据是否有可能交换位置

归并排序O(logN)

利用递归思想,把大的分成两份小的,把小的排好序。再把排好序的小的组合成一个排好序的大的。依次递归 ,则得到排好序的数据。

 static int[] guibing(int[] a){
        int[]  b;
        int[]  c=null;
        //循环终止条件,不能再分了就中止
        if(a.length>1){
              b = new int[a.length/2];
              c =  new int[a.length-b.length];
            for(int x=0; x

快速排序(Quicksort)O(logN)

一堆数据里,随机拿一个数据作为标杆,比它大的放右边,比它小的放左边。此时标杆把数据分为了两堆,再用上述思想进行递归,当最后一堆的数据为1个的时候,则表示已经排好了。 快排核心思想就是分治和分区

 static void kuaisu(int[] a,int begin ,int end){
        if(begin>=end){
            return;
        }
        //取中间位置数据作为比较点
        int p =  (end-begin)/2+begin;
        for(int i =begin;i<=end;i++){
            //如果遍历的数据比标杆数据大,并且位置在标杆的左边
            if(a[i]>a[p] && i

p){ //与上同理 int f = a[i]; for(int k = i;k>begin;k--){ a[k] =a[k-1]; } a[begin]=f; //这里不需要i++,因为原来数据的位置是被标杆替代了 p++; } } kuaisu(a,begin,p-1); kuaisu(a,p+1,end); }

从上述例子可以看出,归并排序和快速排序的思想都是分区分治。但是两者的根本区别在于 归并是从下往上排序,把子节点处理好,组合成一个好的大节点。快速排序是从大节点粗粒度治理好,再分开两份把小节点处理好。 归并因为要组合,所以空间复杂度为O(n²),快速排序可以在数据内部进行位置交换,所以空间复杂度是O(1)。但是两者的平均时间复杂都是O(logN)。 所以一般都是采取快速排序。

桶排序 O(n)

核心思想就是把数据分成小区间内的,且所有的小区间有序,再让每个小区间内的数据有序,最后把有序的小区间合并,就达到了排序的目的。

桶排序,顾名思义,会用到“桶”,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。

数据结构与算法-基础算法_第2张图片
image.png

如果要排序的数据有 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)。

桶排序看起来很优秀,那它是不是可以替代我们之前讲的排序算法呢?

答案当然是否定的。为了让你轻松理解桶排序的核心思想,我刚才做了很多假设。实际上,桶排序对要排序数据的要求是非常苛刻的。

首先,要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。

其次,数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。

桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。

比如说我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?

现在我来讲一下,如何借助桶排序的处理思想来解决这个问题。

我们可以先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描之后我们得到,订单金额最小是 1 元,最大是 10 万元。我们将所有订单根据金额划分到 100 个桶里,第一个桶我们存储金额在 1 元到 1000 元之内的订单,第二桶存储金额在 1001 元到 2000 元之内的订单,以此类推。每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00,01,02…99)。

理想的情况下,如果订单金额在 1 到 10 万之间均匀分布,那订单会被均匀划分到 100 个文件中,每个小文件中存储大约 100MB 的订单数据,我们就可以将这 100 个小文件依次放到内存中,用快排来排序。等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,那这个文件中存储的就是按照金额从小到大排序的订单数据了。

不过,你可能也发现了,订单按照金额在 1 元到 10 万元之间并不一定是均匀分布的 ,所以 10GB 订单数据是无法均匀地被划分到 100 个文件中的。有可能某个金额区间的数据特别多,划分之后对应的文件就会很大,没法一次性读入内存。这又该怎么办呢?

针对这些划分之后还是比较大的文件,我们可以继续划分,比如,订单金额在 1 元到 1000 元之间的比较多,我们就将这个区间继续划分为 10 个小区间,1 元到 100 元,101 元到 200 元,201 元到 300 元…901 元到 1000 元。如果划分之后,101 元到 200 元之间的订单还是太多,无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止。

计数排序(Counting sort) O(n)

计数排序其实是一种特殊的桶排序,设置一个数组把桶的数据步长为1,然后一个曹对应一个桶,遍历原来的数组,把每个桶应该放的数据量记录下来,然后再遍历桶数组,把每个桶的值设置为前面所有的桶的和,那么每个桶记录的就是当前值对应的数组角标位置。最后再遍历原数组,遍历到的元素值所对应的桶的数据取出来作为角标存入新数组,如果冲突则往后移一位。这样得到的新数组就是一个排好序的数组。

基数排序(Radix sort) O(n)

我们再来看这样一个排序问题。假设我们有 10 万个手机号码,希望将这 10 万个手机号码从小到大排序,你有什么比较快速的排序方法呢?
首先比较第一位,把顺序排列好,再把第一位的数据看出一个桶,把桶里的数据都进行排序,依此类推,则把所有号码都排序好了。

基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。

总结

稳定:相等的元素,其位置关系不会变。
原地:不开辟新空间。


数据结构与算法-基础算法_第3张图片
image.png

在算法的选择中,要根据实际数据量的大小和特点,选择合适的算法。譬如说,如果数据量比较小,就可以选择归并排序,此时速度很快,虽然需要开辟一个空间来处理,但是由于数据量小,所以可以接受。当数据量大的时候就不可以选择这种,快速排序会更好。

如何优化快速排序?
快排的最坏时间复杂度是O(n²) ,这种情况其实是标杆数取的不够好,那么优化快排其实就是如何取更合理的标杆。
最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。

  1. 三数取中:选头、尾、中三个数,选其中的中间数。
    2.随机取数:每次都进行随机取数,这样每次取到极端数的概率将变得很小。

二分查找O(logn)

二分查找的思想很简单,每次都猜中间数,然后进行比较大小。

那么,基于这样的思想,其实现有哪些限制?

1.数据必须是有顺序的,也就是从大到小或者从小到大。

2.其数据结构必须是数组,因为数组的元素随机访问是O(1),如果是链表其随机访问是O(n),所以,如果数据使用链表存储,二分查找的时间复杂就会变得很高O(n)。实际上如果是链表的话,二分还不如直接遍历,直接遍历会更快。

3.数据量太小没必要二分查找。

4.数据量太大也不能二分查找,因为二分对数据的要求是数组,数组则必须开辟连续的内存,如果数据量太大,譬如1G,内存里是很难开辟一个连续的1G内存的。大部分时候内存都是零散的。

哈希算法

将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是哈希算法,而通过原始数据映射之后得到的二进制值串就是哈希值。

哈希算法的常见运用:安全加密、唯一标识、数据校验、散列函数、负载均衡、数据分片、分布式存储。

安全加密

最常用于加密的哈希算法是MD5(MD5 Message-Digest Algorithm,MD5 消息摘要算法)和SHA(Secure Hash Algorithm,安全散列算法)。
无法做到0冲突,必定会有重复的数据,只是概率很低。

唯一标识

我先来举一个例子。如果要在海量的图库中,搜索一张图是否存在,我们不能单纯地用图片的元信息(比如图片名称)来比对,因为有可能存在名称相同但图片内容不同,或者名称不同图片内容相同的情况。那我们该如何搜索呢?

我们可以给每一个图片取一个唯一标识,或者说信息摘要。比如,我们可以从图片的二进制码串开头取 100 个字节,从中间取 100 个字节,从最后再取 100 个字节,然后将这 300 个字节放到一块,通过哈希算法(比如 MD5),得到一个哈希字符串,用它作为图片的唯一标识。通过这个唯一标识来判定图片是否在图库中,这样就可以减少很多工作量。

数据校验

对传输的数据先进行hash运算,算出哈希值,传输结束后把哈希值传给对方,对方接受到数据后,然后对数据哈希,算出的哈希值与之对比,如果一致,则表示数据完整。

散列函数

我们前两节讲到,散列函数是设计一个散列表的关键。它直接决定了散列冲突的概率和散列表的性能。不过,相对哈希算法的其他应用,散列函数对于散列算法冲突的要求要低很多。即便出现个别散列冲突,只要不是过于严重,我们都可以通过开放寻址法或者链表法解决。

不仅如此,散列函数对于散列算法计算得到的值,是否能反向解密也并不关心。散列函数中用到的散列算法,更加关注散列后的值是否能平均分布,也就是,一组数据是否能均匀地散列在各个槽中。除此之外,散列函数执行的快慢,也会影响散列表的性能,所以,散列函数用的散列算法一般都比较简单,比较追求效率。

负载均衡

我们知道,负载均衡算法有很多,比如轮询、随机、加权轮询等。那如何才能实现一个会话粘滞(session sticky)的负载均衡算法呢?也就是说,我们需要在同一个客户端上,在一次会话中的所有请求都路由到同一个服务器上。

最直接的方法就是,维护一张映射关系表,这张表的内容是客户端 IP 地址或者会话 ID 与服务器编号的映射关系。客户端发出的每次请求,都要先在映射表中查找应该路由到的服务器编号,然后再请求编号对应的服务器。这种方法简单直观,但也有几个弊端:

如果客户端很多,映射表可能会很大,比较浪费内存空间;

客户端下线、上线,服务器扩容、缩容都会导致映射失效,这样维护映射表的成本就会很大;

如果借助哈希算法,这些问题都可以非常完美地解决。我们可以通过哈希算法,对客户端 IP 地址或者会话 ID 计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到的值就是应该被路由到的服务器编号。 这样,我们就可以把同一个 IP 过来的所有请求,都路由到同一个后端服务器上。

数据分片

如何快速判断图片是否在图库中?上一节我们讲过这个例子,不知道你还记得吗?当时我介绍了一种方法,即给每个图片取唯一标识(或者信息摘要),然后构建散列表。

假设现在我们的图库中有 1 亿张图片,很显然,在单台机器上构建散列表是行不通的。因为单台机器的内存有限,而 1 亿张图片构建散列表显然远远超过了单台机器的内存上限。

我们同样可以对数据进行分片,然后采用多机处理。我们准备 n 台机器,让每台机器只维护某一部分图片对应的散列表。我们每次从图库中读取一个图片,计算唯一标识,然后与机器个数 n 求余取模,得到的值就对应要分配的机器编号,然后将这个图片的唯一标识和图片路径发往对应的机器构建散列表。

当我们要判断一个图片是否在图库中的时候,我们通过同样的哈希算法,计算这个图片的唯一标识,然后与机器个数 n 求余取模。假设得到的值是 k,那就去编号 k 的机器构建的散列表中查找。

现在,我们来估算一下,给这 1 亿张图片构建散列表大约需要多少台机器。

散列表中每个数据单元包含两个信息,哈希值和图片文件的路径。假设我们通过 MD5 来计算哈希值,那长度就是 128 比特,也就是 16 字节。文件路径长度的上限是 256 字节,我们可以假设平均长度是 128 字节。如果我们用链表法来解决冲突,那还需要存储指针,指针只占用 8 字节。所以,散列表中每个数据单元就占用 152 字节(这里只是估算,并不准确)。

假设一台机器的内存大小为 2GB,散列表的装载因子为 0.75,那一台机器可以给大约 1000 万(2GB*0.75/152)张图片构建散列表。所以,如果要对 1 亿张图片构建索引,需要大约十几台机器。在工程中,这种估算还是很重要的,能让我们事先对需要投入的资源、资金有个大概的了解,能更好地评估解决方案的可行性。

实际上,针对这种海量数据的处理问题,我们都可以采用多机分布式处理。借助这种分片的思路,可以突破单机内存、CPU 等资源的限制。

分布式存储

redis的集群。
现在互联网面对的都是海量的数据、海量的用户。我们为了提高数据的读取、写入能力,一般都采用分布式的方式来存储数据,比如分布式缓存。我们有海量的数据需要缓存,所以一个缓存机器肯定是不够的。于是,我们就需要将数据分布在多台机器上。

该如何决定将哪个数据放到哪个机器上呢?我们可以借用前面数据分片的思想,即通过哈希算法对数据取哈希值,然后对机器个数取模,这个最终值就是应该存储的缓存机器编号。

但是,如果数据增多,原来的 10 个机器已经无法承受了,我们就需要扩容了,比如扩到 11 个机器,这时候麻烦就来了。因为,这里并不是简单地加个机器就可以了。

原来的数据是通过与 10 来取模的。比如 13 这个数据,存储在编号为 3 这台机器上。但是新加了一台机器中,我们对数据按照 11 取模,原来 13 这个数据就被分配到 2 号这台机器上了。

数据结构与算法-基础算法_第4张图片
image.jpeg

因此,所有的数据都要重新计算哈希值,然后重新搬移到正确的机器上。这样就相当于,缓存中的数据一下子就都失效了。所有的数据请求都会穿透缓存,直接去请求数据库。这样就可能发生雪崩效应,压垮数据库。

所以,我们需要一种方法,使得在新加入一个机器后,并不需要做大量的数据搬移。这时候,一致性哈希算法就要登场了。

假设我们有 k 个机器,数据的哈希值的范围是 [0, MAX]。我们将整个范围划分成 m 个小区间(m 远大于 k),每个机器负责 m/k 个小区间。当有新机器加入的时候,我们就将某几个小区间的数据,从原来的机器中搬移到新的机器中。这样,既不用全部重新哈希、搬移数据,也保持了各个机器上数据数量的均衡。

堆排序O(nlogn)

利用堆这种数据结构的排序叫堆排序。原来就是每次都选出一个堆顶元素,然后把堆顶元素屏蔽再选堆顶元素。这么看,堆排序就是原地排序。切时间复杂度为O(logN)

堆排序的过程大致分解成两个大的步骤,建堆和排序。
1.建堆O(n)
建堆的目的是找出堆根,也就是找出最大或者最小的数。
那么,我们可以从后往前比较,拿最后一个数。
因为叶子节点往下堆化只能自己跟自己比较,所以我们直接从第一个非叶子节点开始,依次堆化就行了。

如下图:
数据结构与算法-基础算法_第5张图片
image.png

2.排序O(nlogn)
上面建堆可以把最值找出来,然后把最值放到最后一个位置,数据长度减一,再进行建堆。这样循环n减一次,数据便排好序了。

所以 堆排序的时间复杂度为O(nlogN)

在实际开发中,为什么快速排序要比堆排序性能好?

第一点,堆排序数据访问的方式没有快速排序友好。
对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问的。 比如,堆排序中,最重要的一个操作就是数据的堆化。比如下面这个例子,对堆顶节点进行堆化,会依次访问数组下标是 1,2,4,8 的元素,而不是像快速排序那样,局部顺序访问,所以,这样对 CPU 缓存是不友好的。

第二点,对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。
我们在讲排序的时候,提过两个概念,有序度和逆序度。对于基于比较的排序算法来说,整个排序过程就是由两个基本的操作组成的,比较和交换(或移动)。快速排序数据交换的次数不会比逆序度多。
但是堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低。比如,对于一组已经有序的数据来说,经过建堆之后,数据反而变得更无序了。

深度和广度优先搜索

深度优先搜索算法和广度优先搜索算法都是基于“图”这种数据结构的。

广度优先搜索(BFS)

广度优先就是根据原顶点,一层层搜索,先搜索所有的一度,再搜索所有的二度,再搜索三度。。。
结合我们的邻接表,这种搜索的实现就要考虑几个方面的问题:
1.基于外面一层的数据的搜索时候,需要对内层数据进行过滤。
2.遍历第n层数据的时候,怎么再一次找到N+1层。

所以我们在设计代码的时候,需要设置三个中间变量。数组visited ,queue队列,数组prev
visited是用来记录已经被访问的顶点,用来避免顶点被重复访问。如果顶点 q 被访问,那相应的 visited[q] 会被设置为 true。

queue是一个队列,用来存储已经被访问、但相连的顶点还没有被访问的顶点。因为广度优先搜索是逐层访问的,也就是说,我们只有把第 k 层的顶点都访问完成之后,才能访问第 k+1 层的顶点。当我们访问到第 k 层的顶点的时候,我们需要把第 k 层的顶点记录下来,稍后才能通过第 k 层的顶点来找第 k+1 层的顶点。所以,我们用这个队列来实现记录的功能。

prev用来记录搜索路径。当我们从顶点 s 开始,广度优先搜索到顶点 t 后,prev 数组中存储的就是搜索的路径。不过,这个路径是反向存储的。prev[w] 存储的是,顶点 w 是从哪个前驱顶点遍历过来的。比如,我们通过顶点 2 的邻接表访问到顶点 3,那 prev[3] 就等于 2。

如下图:
数据结构与算法-基础算法_第6张图片
image.png

其实也就是在一个顶点出队的时候,把它的关联顶点更已搜索数组里的数据比较,没有被搜索就入队。
V 表示顶点的个数,E 表示边的个数。
所以,广度优先的时间复杂度为O(V+E),空间复杂度为O(V)。

深度优先搜索

深度优先就是一条路走到底,走不通再回头试别的路。结合图的特点,不难发现,这种的实现肯定是基于递归。
如果把图看成一棵树,那么深度优先搜索其实就是树的先序遍历。
既然这样,那么相对于广度优先,我们不需要队列来进行数据消费,但是我们需要定义一个布尔变量,来作为递归终止的条件。

所以,深度优先每条边最多会被访问两次,一次是遍历,一次是回退。所以,深度优先搜索算法的时间复杂度是 O(E)。空间复杂度是O(V)。

你可能感兴趣的:(数据结构与算法-基础算法)