数据结构与算法之算法篇

算法

  • 算法: 算法是指解题方案的准确而完整的描述,是一系列解决问腿的清晰指令,算法代表着用系统的方法描述解决问题的策略机制。
  • 算法的效率: 是指算法执行的时间,算法执行时间需要通过算法编制的程序在计算机上运行时所消耗的时间来衡量。
  • 一个算法的优劣可以用空间复杂度和时间复杂度来衡量
    在这里插入图片描述
    在这里插入图片描述

时间复杂度

  • 时间复杂度:
    在代码编程中指的是要测量的代码在运行中会执行多少个步骤。步骤越少,肯定执行的效率越高。用一些算法函数来表示,它定性描述该算法的运行时间。时间复杂度常用大O符号表述,不包括这个函数的低阶项和首项系数。相同大小的不同输入值仍可能造成算法的运行时间不同,因此我们通常使用算法的最坏情况复杂度,记为T(n),定义为任何大小的输入n所需的最大运行时间。
    一般情况下,算法中基本操作重复执行的次数是问题规模n的某个函数,用T(n)表示,若有某个辅助函数f(n),使得当n趋近于无穷大时,T(n)/f (n)的极限值为不等于零的常数,则称f(n)是T(n)的同数量级函数。记作T(n)=O(f(n)),称O(f(n)) 为算法的渐进时间复杂度,简称时间复杂度。(大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势)。

在长度为n的数组中,代码执行步骤大小指数为:
直接通过下标去访问元素,时间复杂度为O(1)。
需要遍历查找元素的时候,时间复杂度为O(n)。
需要遍历二维数组的时候,时间复杂度为O(n²)。

以此类推按数量级递增排列,常见的时间复杂度有:
- 常数阶O(1):无论问题规模多大执行时间不变.(hashMap.get)
- 对数阶O(log2n):将一个数据集分成两半,然后将分开的每一半再分成两半(二叉树)
- 线性阶O(n):执行时间随问题规模增长呈正比例增长(遍历算法)
- 线性对数阶O(nlog2n):可以看成n乘以logn:将一个数据集分成两半,然后将分开的每一半再分成两半,依此类推,在此过程中同时遍历每一半数据,以归并排序为例,可以把排序的过程看成一个倒立的二叉树。这个复杂度高于线性低于平方。归并排序就是O(nlogn)的时间复杂度。
- 平方阶O(n^2):O(n^ 2),就代表数据量增大n倍时,耗时增大n的平方倍,这是比线性更高的时间复杂度。比如冒泡排序,就是典型的O(n^2)的算法,对n个数排序,需要扫描n×n次。
- 立方阶O(n^3):n的立方
- n次方阶O(n^n):n的n次方
- 指数阶O(2^n): 2^n=2*2*2*……*2 (n有2个)
- O(n!):       n!=1*2*3*4*……*n(n个数字)
随着问题规模n的不断增大,上述时间复杂度不断增大,算法的执行效率越低。

大O阶推导
推导大O阶就是将算法的所有步骤转换为代数项,然后排除不会对问题的整体复杂度产生较大影响的较低阶常数和系数。
有条理的说,推导大O阶,按照下面的三个规则来推导,得到的结果就是大O表示法:
运行时间中所有的加减法常数用常数1代替
只保留最高阶项
去除最高项常数

  • 大O表示法O(f(n)中的f(n)的值可以为1、n、logn、n²等,因此我们可以将O(1)、O(n)、O(logn)、O(n²)分别可以称为常数阶、线性阶、对数阶和平方阶
  • T(n) =O(n) 的算法被称作“线性时间算法”,例如有O(n)葛罗佛搜索算法。
  • T(n) =O(M^n) 和M= O(T(n)) ,其中M≥n> 1 的算法被称作“指数时间算法”
  • T(n) =O(logn),则称其具有对数时间。常见的具有对数时间的算法有二叉树的相关操作和二分搜索。
  • T(n) = O((logn)),则称其具有幂对数时间。例如,矩阵链排序可以通过一个PRAM模型.被在幂对数时间内解决。

那么如何推导出f(n)的值呢?推导大O阶,我们可以按照如下的规则来进行推导,得到的结果就是大O表示法

------------------------------------------------------------
int do(void) {
    printf("xxx");      //  需要执行 1 次
    return 0;          // 需要执行 1 次
}
这个方法需要执行2次运算,该函数(算法)的时间复杂度为 O(n)。常系数2省略
------------------------------------------------------------
int do(int n) {
    for(int i = 0; i<n; i++) {          // 需要执行 (n + 1) 次
        printf("xxx");                  // 需要执行 n 次
    }
    return 0;                          // 需要执行 1 次
}
这个方法需要(n+1+n+1)=2n+2 次运算。,该函数(算法)的时间复杂度也为 O(n)------------------------------------------------------------

我们把 算法需要执行的运算次数用输入大小n 的函数 表示,即 T(n) 。
此时为了 估算算法需要的运行时间 和 简化算法分析,我们引入时间复杂度的概念。
定义:存在常数 c 和函数 f(N),使得当 N >= c 时 T(N) <= f(N),表示为 T(n) = O(f(n)) ,如图:
在这里插入图片描述

当 N >= 2 的时候,f(n) = n^2 总是大于 T(n) = n + 2 的,于是我们说 f(n) 的增长速度是大于或者等于 T(n) 的,也说 f(n) 是 T(n) 的上界,可以表示为 T(n) = O(f(n))。

因为f(n) 的增长速度是大于或者等于 T(n) 的,即T(n) = O(f(n)),所以我们可以用 f(n) 的增长速度来度量 T(n) 的增长速度,所以我们说这个算法的时间复杂度是 O(f(n))。

算法的时间复杂度,用来度量算法的运行时间,记作: T(n) = O(f(n))。它表示随着 输入大小n 的增大,算法执行需要的时间的增长速度可以用 f(n) 来描述。
显然如果 T(n) = n^2,那么 T(n) = O(n^2),T(n) = O(n^3),T(n) = O(n^4) 都是成立的,但是因为第一个 f(n) 的增长速度与 T(n) 是最接近的,所以第一个是最好的选择,所以我们说这个算法的复杂度是 O(n^2) 。

  • 常数项对函数的增长速度影响并不大,所以当 T(n) = c,c 为一个常数的时候,我们说这个算法的时间复杂度为 O(1);如果 T(n) 不等于一个常数项时,直接将常数项省略
  • 高次项对于函数的增长速度的影响是最大的。n^3 的增长速度是远超 n^2 的,同时 n^2 的增长速度是远超 n 的。 同时因为要求的精度不高,所以我们直接忽略低此项。
  • 因为函数的阶数对函数的增长速度的影响是最显著的,所以我们忽略与最高阶相乘的常数。
    1.用常数1来取代运行时间中所有加法常数。
    2.修改后的运行次数函数中,只保留最高阶项
    3.如果最高阶项存在且不是1,则去除与这个项相乘的常数。
    综合起来:如果一个算法的执行次数是 T(n),那么只保留最高次项,同时忽略最高项的系数后得到函数 f(n),此时算法的时间复杂度就是 O(f(n)),称此为 大O推导法。
O后面的括号中的函数,指明的是某个算法的耗时/耗空间与数据增长量之间的关系。
其中的n代表输入数据的量(排序则为数组的长度)。代码演示如下:
------------------------------------------------------------
let sum = 0,
    n = 100;                       // 执行一次
sum = (1+n)*n/2;                   // 执行一次
console.log(sum);                  // 执行一次 
上面算法的运行次数的函数是f(n)=3,则有O(f(n) = 3)O(3),
常数项用常数1表示,则最终的表示法为 ==O(1)常数阶==
常数阶:最低的时空复杂度,也就是耗时与输入数据大小无关,
无论输入数据增大多少倍,耗时/耗空间都不变。 哈希算法就是
典型的O(1)时间复杂度,无论数据规模多大,都可以在一次计算后找到目标。
------------------------------------------------------------
int binarySearch(int a[], int key) {
    int low = 0;
    int high = a.length - 1;
    while (low <= high) {
        int mid = low + (high - low) / 2;
        if (a[mid] > key)
            high = mid - 1;
        else if (a[mid] < key)
            low = mid + 1;
        else
            return mid;
    }
    return -1;
}
数组a每次都是对半查找,则mid=log₂n,通过mid下表再对半查找,
因此得到这个算法的时间复杂度为 ==O(logn)对数阶,也叫O(log2n)==
对数阶:当数据增大n倍时,耗时增大logn倍,二分查找就是O(logn)的算法,
每找一次排除一半的可能,256个数据中查找只要找8次就可以找到目标。
(这里的log是以2为底的,比如,当数据增大256倍时,耗时只增大8倍,
是比线性还要低的时间复杂度)。
------------------------------------------------------------
String str[] = {"","xx","xxx"}for(String string:str){
	if(string.equal("xx""){
	return string;
}
此时时间复杂度为 O(n × 1),即 ==O(n)线性阶==
线性阶:就代表数据量增大几倍,复杂度也增大几倍
例如当前数组长度为3,则为O(3),当数组长6时,则为O(6),复杂度增长一倍
------------------------------------------------------------
public void mergeSort(int[] arr, int p, int q){
    if(p >= q) {
		return
	};
    int mid = (p+q)/2;
    mergeSort(arr, p, mid);
    mergeSort(arr, mid+1,q);
    merge(arr, p, mid, q);
}
private void merge(int[] arr, int p, int mid, int q){
    int[] temp = new int[arr.length]; 
    int i = p, j = mid+1,iter = p;
    while(i <= mid && j <= q){
        if(arr[i] <= arr[j]) {
			temp[iter++] = arr[i++]} else{
			temp[iter++] = arr[j++];
		} 
    }
    
    while(i <= mid) {
		temp[iter++] = arr[i++];
	}
	
    while(j <= q){
 		temp[iter++] = arr[j++];
	}
	
    for(int t = p; t <= q; t++) {
		arr[t] = temp[t];
	}
}
时间复杂度O(nlogn)—线性对数阶,就是n乘以logn,当数据增大256倍时,耗时增大
256*8=2048倍。这个复杂度高于线性低于平方。归并排序就是O(nlogn)的时间复杂度。
------------------------------------------------------------
void do(int n) {
    for(int i = 0; i < n; i++) {           // 循环次数为 n
        for(int j = 0; j < n; j++) {       // 循环次数为 n
            printf("xxx");     // 循环体时间复杂度为 O(1)
        }
    }
}
此时时间复杂度为 O(n × n × 1),即 ==O(n^2)平方阶==
时间复杂度O(n^2)—平方阶, 就代表数据量增大n倍时,耗时增大n的平方倍,
这是比线性更高的时间复杂度。比如冒泡排序,就是典型的O(n x n)的算法,
对n个数排序,需要扫描n x n次
------------------------------------------------------------
void do(int n) {
    // 第一部分时间复杂度为 O(n^2)
    for(int i = 0; i < n; i++) {
        for(int j = 0; j < n; j++) {
            printf("xxx");
        }
    }
    // 第二部分时间复杂度为 O(n)
    for(int j = 0; j < n; j++) {
        printf("xxx");
    }
}
此时时间复杂度为 max(O(n^2), O(n)),即 O(n^2)。取影响最大的复杂度
------------------------------------------------------------
void do(int n) {
    if (n >= 0) {
        // 第一条路径时间复杂度为 O(n^2)
        for(int i = 0; i < n; i++) {
            for(int j = 0; j < n; j++) {
                printf("输入数据大于等于零\n");
            }
        }
    } else {
        // 第二条路径时间复杂度为 O(n)
        for(int j = 0; j < n; j++) {
            printf("输入数据小于零\n");
        }
    }
}
对于条件判断语句,总的时间复杂度等于其中 时间复杂度最大的路径的时间
复杂度,即此时时间复杂度为 max(O(n^2), O(n)),即 O(n^2)------------------------------------------------------------
其他常见复杂度
f(n)=nlogn时,时间复杂度为O(nlogn),可以称为nlogn阶。
f(n)=n³时,时间复杂度为O(),可以称为立方阶。
f(n)=2ⁿ时,时间复杂度为O(2),可以称为指数阶。
f(n)=n!时,时间复杂度为O(n!),可以称为阶乘阶。
f(n)=(√n时,时间复杂度为O(√n),可以称为平方根阶。
效率排序:O(1)<O(logn)<O(n)<O(nlogn)<O()<O()<O(2)<O(n!)
------------------------------------------------------------

O(n)、O(logn)、O(√n )、O(nlogn )随着n的增加,复杂度提升不大,因此这些复杂度属于效率高的算法,反观O(2ⁿ)和O(n!)当n增加到50时,复杂度就突破十位数了,这种效率极差的复杂度最好不要出现在程序中,因此在动手编程时要评估所写算法的最坏情况的复杂度。
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

空间复杂度

时间复杂度并不是表示一个程序解决问题需要花多少时间,而是当问题规模扩大后,程序需要的时间长度增长得有多快。
在这里插入图片描述

一个程序的空间复杂度是指运行完一个程序所需内存的大小。利用程序的空间复杂度,可以对程序的运行所需要的内存多少有个预先估计。一个程序执行时除了需要存储空间和存储本身所使用的指令、常数、变量和输入数据外,还需要一些对数据进行操作的工作单元和存储一些为现实计算所需信息的辅助空间。程序执行时所需存储空间包括以下两部分。  
(1)固定部分。这部分空间的大小与输入/输出的数据的个数多少、数值无关。主要包括指令空间(即代码空间)、数据空间(常量、简单变量)等所占的空间。这部分属于静态空间。
(2)可变空间,这部分空间的主要包括动态分配的空间,以及递归栈所需的空间等。这部分的空间大小与算法有关。
一个算法所需的存储空间用f(n)表示。S(n)=O(f(n))  其中n为问题的规模,S(n)表示空间复杂度。

算法的空间复杂度并不是计算实际占用的空间,而是计算整个算法的辅助空间单元的个数,与问题的规模没有关系。算法的空间复杂度S(n)定义为该算法所耗费空间的数量级。
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的一个量度,同样反映的是一个趋势,我们用 S(n) 来定义。
空间复杂度比较常用的有:O(1)、O(n)、O(n²)

S(n)=O(f(n)) 若算法执行时所需要的辅助空间相对于输入数据量n而言是一个常数,则称这个算法的辅助空间为O(1);
递归算法的空间复杂度:递归深度N*每次递归所要的辅助空间, 如果每次递归所需的辅助空间是常数,则递归的空间复杂度是 O(N).

  1. 空间复杂度 O(1)
    如果算法执行所需要的临时空间不随着某个变量n的大小而变化,即此算法空间复杂度为一个常量,可表示为 O(1)
int i = 1;
int j = 2;
++i;
j++;
int m = i + j;
------------------------------------------------------------------
代码中的 i、j、m 所分配的空间都不随着处理数据量变化,
因此它的空间复杂度 S(n) = O(1)
  1. 空间复杂度 O(n)
int[] m = new int[n]
for(i=1; i<=n; ++i)
{
   j = i;
   j++;
}
------------------------------------------------------------------
第一行new了一个数组出来,这个数据占用的大小为n,这段代码的2-6行,虽然有循环,
但没有再分配新的空间,因此,这段代码的空间复杂度主要看第一行即可,即 S(n) = O(n)

3.空间复杂度:O( log2 N )

template<typename T>
T* BinarySearch(T* left,T* right,const T& data)
{
       assert(left);
       assert(right);
       if (right >=left)
       {
              T* mid =left+(right-left)/2;
              if (*mid == data)
                     return mid;
              else
                     return *mid > data ? BinarySearch(left, mid - 1, data) : BinarySearch(mid + 1, right, data);
       }
       else
       {
              return NULL;
       }
}
------------------------------------------------------------------
递归的次数和深度都是 log2  N,每次所需要的辅助空间都是常数级别的:
时间复杂度:O( log2 N )
空间复杂度:O( log2 N )

概念算法

重用概念算法:
在这里插入图片描述

贪心算法&动态规划算法

贪心算法(greedy metho)是指在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,只做出在某种意义上的局部最优解。贪心算法不是对所有问题都能得到整体最优解,关键是贪心策略的选择,选择的贪心策略必须具备无后效性(即某个状态以前的过程不会影响以后的状态,只与当前状态有关,它想做的是:每次选择新的局部最优解,去逼近全局最优解
贪心算法每一步必须满足以下条件:
  1、可行的:即它必须满足问题的约束。
  2、局部最优:他是当前步骤中所有可行选择中最佳的局部选择。
  3、不可更改:即选择一旦做出,在算法的后面步骤就不可改变了。

动态规划的设计,其实就是利用最优子结构和重叠子问题性质对穷举法进行优化,通过将中间结果保存在数组中,实现用空间来换取时间交换,实现程序的快速运行。(动态规划求解时,一般都会转化为网格进行求解,而且因为是空间换时间(避免了子问题的重复计算),因此一般迭代求解)。

贪心算法的每一次操作都对结果产生直接影响,而动态规划则不是。
贪心算法对每个子问题的解决方案都做出选择,不能回退;动态规划则会根据以前的选择结果对当前进行选择,有回退功能。
能用贪心解决的问题,也可以用动态规划解决


从前有一只鹅,一天可以下两个金蛋,但是直接杀了他可以拿到二十个金蛋。问在21天内拿到尽量多的金蛋?
动态规划:前20天不杀,最后一天杀。(40个)
贪心算法:第一天下蛋,得到一个金蛋,第一天杀,得到20个金蛋,选择第一天杀得到20个金蛋。(21个)
在同样的条件,同样的选择下,贪心更偏爱局部最优,动态规划则按照某种规律寻找全局最优。主要表现在同样的选择。而把题目改成1天那么动态规划也会选择第一天杀。
在这里插入图片描述


  • 贪心算法解题思路
    1.建立数学模型来描述问题;
    2.把求解的问题分成若干个子问题;
    3.对每一子问题求解,得到子问题的局部最优解;
    4.把子问题的局部最优解合成原来问题的一个解。
/**
 * @desc 贪心算法
 * 思路分析
 * (1)使用穷举法,列出每个可能广播台集合,这被称为幂集。
 * (2)假设有n个广播台,则广播台的组合共有2^n-1个,假设每秒可以计算10个子集
 *      广播台数量   子集总数    需要的时间
 *      5               32          3.2秒
 *      10              1024        102.4秒
 *      ...
 *
 *  案例:集合覆盖问题
 *      假设存在下面需要付费的广播台,以及广播信号可以覆盖的地区,如何选择
 *      最少的广播台,让所有的地区都可以接收信息
 *      广播台       覆盖地区
 *      K1          "北京","上海","天津"
 *      K2          "广州","北京","深圳"
 *      K3          "成都","上海","杭州"
 *      K4          "上海","天津"
 *      K5          "杭州","大连"
 */
public class GreedyAlgorithm {
    public static void main(String[] args) {
        Map<String, Set<String>> broadcasts = new HashMap<>(); // 广播电台
        broadcasts.put("K1", Arrays.stream(new String[]{"北京", "上海", "天津"}).collect(Collectors.toSet()));
        broadcasts.put("K2", Arrays.stream(new String[]{"广州", "北京", "深圳"}).collect(Collectors.toSet()));
        broadcasts.put("K3", Arrays.stream(new String[]{"成都", "上海", "杭州"}).collect(Collectors.toSet()));
        broadcasts.put("K4", Arrays.stream(new String[]{"上海", "天津"}).collect(Collectors.toSet()));
        broadcasts.put("K5", Arrays.stream(new String[]{"杭州", "大连"}).collect(Collectors.toSet()));
        // [上海, 天津, 北京, 广州, 深圳, 成都, 杭州, 大连]
        List<String> allAreas = broadcasts.values().stream().flatMap(Collection::stream).distinct().collect(Collectors.toList()); // 表示所有需要覆盖的地区
        System.out.println("allAreas=" + allAreas);
        List<String> selects = new ArrayList<>(); // 选择的地区集合
        // 定义一个临时的集合,在遍历过程中,存放遍历过程中的电台覆盖的地区和当前还没有覆盖的地区的交集
        Set<String> tempSet = new HashSet<>();
        String maxKey; // 最大的电台,保存在一次遍历过程中,能够覆盖最大未覆盖的地区对应的电台key
        while (allAreas.size() != 0) {
            maxKey = null; // 置空
            // 遍历broadcasts,取出对应key
            for (String key : broadcasts.keySet()) {
                tempSet.clear(); // 清空
                Set<String> areas = broadcasts.get(key);
                tempSet.addAll(areas);
                tempSet.retainAll(allAreas); // tempSet = tempSet与allAreas的交集
                if (tempSet.size() > 0 && (maxKey == null
                        || tempSet.size() > broadcasts.get(maxKey).size())) {
                    maxKey = key;
                }
            }
            if (maxKey != null) {
                selects.add(maxKey);
                // 将maxKey指向的广播电台覆盖地区,从allAreas去掉
                System.out.println("maxKey=" + maxKey);
                allAreas.removeAll(broadcasts.get(maxKey));
            }
        }
        System.out.println("得到的选择结果是:" + selects);
    }
}

分治算法

就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序就是这种算法思想的实现)

当我们求解某些问题时,由于这些问题要处理的数据相当多,或求解过程相当复杂,使得直接求解法在时间上相当长,或者根本无法直接求出。对于这类问题,我们往往先把它分解成几个子问题,找到求出这几个子问题的解法后,再找到合适的方法,把它们组合成求整个问题的解法。如果这些子问题还较大,难以解决,可以再把它们分成几个更小的子问题,以此类推,直至可以直接求出解为止。这就是分治策略的基本思想。(这自然导致递归过程的产生。分治与递归像一对孪生兄弟,经常同时应用在算法设计之中,并由此产生许多高效算法)
在这里插入图片描述

分治法所能解决的问题一般具有以下几个特征:

1) 该问题的规模缩小到一定的程度就可以容易地解决

2) 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质。

3) 利用该问题分解出的子问题的解可以合并为该问题的解;

4) 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子子问题。

二分法:
利用分治策略求解时,所需时间取决于分解后子问题的个数、子问题的规模大小等因素,而二分法,由于其划分的简单和均匀的特点,是经常采用的一种有效的方法,例如二分法检索。

分治法解题的一般步骤:
(1)分解,将要解决的问题划分成若干规模较小的同类问题;
(2)求解,当子问题划分得足够小时,用较简单的方法解决;
(3)合并,按原问题的要求,将子问题的解逐层合并构成原问题的解。

在这里插入图片描述

分治算法案例:汉诺塔
在这里插入图片描述

1、一次只能移动一个圆盘

2、任何时候都不能将一个较大的圆盘压在较小的圆盘上面.

3、除了第二条限制,任何塔座的最上面的圆盘都可以移动到其他塔座上.
  • (1)基本概念
    分治算法是一种很重要的算法,字面上的解释是“分而治之”,就是把一个复杂的问题,分解成两个或更多的相同或相似的子问题…直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并,这个技巧就是很多高效算法的基础,如排序算法(快速排序,归并排序),傅里叶变换(快速傅里叶变换)…
  • (2)基本步骤
    分解:将原问题分解为若干个规模较小的问题,相互独立,与原问题形式相同的子问题
    解决:若子问题规模较小则直接解决,否则递归地解各个子问题
    合并:将各个子问题的解合并为原问题的解
  • (3)分治算法设计模式
    |P|:表示问题P的规模
    n0:表示阈值,表示当问题P的规模不超过n0时,问题已容易直接解出,不必再继续分解。
    ADHOC§:是该分治法中的基本子算法,用于直接解小规模的问题P。因此,当P的规模不超过n0时直接用算法 ADHOC§ 求解
    算法MERGE(y1,y2…yk):是该分治算法中的合并子算法,用于将P的子问题P1,P2…PK的相应的解y1,y2,…yk合并为P的解。
if |P|<=n0                                        then return (ADHOC(P))
将P分解为较小的问题P1,P2...PK
for i <- 1 to k ==do yi <- Divide-and-Conquer(Pi) 递归解决Pi
T <- MERGE(y1,y2...yk) 合并子问题                   return (T)
  • (4) 思路分析:
* 如果只有一个盘了,A->C
* n0=2
* if (n<=n0) {
* // 直接解出来
* }
* // 将P分解为较小的问题P1,P2...PK
* while(n>n0) {
*	分(n);
* n--;
* }
* T <- MERGE(y1,y2...yk) 合并子问题
//分治经典案例:汉罗塔
public class HanoiTower {
    public static void main(String[] args) {
        hanoiTower(3, 'A', 'B', 'C');
    }
    private static void hanoiTower(int num, char a, char b, char c) {
        if (num == 1) { // 只有一个盘,直接解出
            System.out.println("第1个盘从" + a + "->" + c);
        } else {
            // 如果n>=2的情况
            // 1.先把最上面的所有盘A->B,移动过程会使用C
            hanoiTower(num - 1, a, c, b);
            // 2.把最下边的盘A->C
            System.out.println("第" + num + "个盘从" + a + "->" + c);
            // 3.把B塔所有盘从B->C,移动过程使用到A
            hanoiTower(num - 1, b, a, c);
        }
    }
}

动态规划算法

一、基本概念

  1. 动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。
    动态规划过程:每次决策依赖于当前状态,又随即引起状态的转移。一个决策序列就是在变化的状态中产生出来的,所以,这种多阶段最优化决策解决问题的过程就称为动态规划。

  2. 动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。

二、基本思想与策略

  1. 基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了实用的信息。
    在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其它局部解。依次解决各子问题,最后一个子问题就是初始问题的解。

  2. 因为动态规划解决的问题多数有重叠子问题这个特点。为降低反复计算。对每个子问题仅仅解一次,将其不同阶段的不同状态保存在一个二维数组中。

  3. 与分治法最大的区别是:适合于用动态规划法求解的问题,经分解后得到的子问题往往不是互相独立的(即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解)。

三、适用的情况
能采用动态规划求解的问题的一般要具有3个性质:

(1)最优化原理:假设问题的最优解所包括的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。

(2)无后效性:即某阶段状态一旦确定。就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响曾经的状态。仅仅与当前状态有关;

(3)有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到(该性质并非动态规划适用的必要条件,可是假设没有这条性质。动态规划算法同其它算法相比就不具备优势)。

四、求解的基本步骤

动态规划所处理的问题是一个多阶段决策问题,一般由初始状态开始,通过对中间阶段决策的选择,达到结束状态。这些决策形成了一个决策序列,同时确定了完成整个过程的一条活动路线(通常是求最优的活动路线)。如图所示。动态规划的设计都有着一定的模式,一般要经历以下几个步骤。

初始状态→│决策1│→│决策2│→…→│决策n│→结束状态

                  图1 动态规划决策过程示意图

(1)划分阶段:按照问题的时间或空间特征,把问题分为若干个阶段。在划分阶段时,注意划分后的阶段一定要是有序的或者是可排序的,否则问题就无法求解。

(2)确定状态和状态变量:将问题发展到各个阶段时所处于的各种客观情况用不同的状态表示出来。当然,状态的选择要满足无后效性。

(3)确定决策并写出状态转移方程:因为决策和状态转移有着天然的联系,状态转移就是根据上一阶段的状态和决策来导出本阶段的状态。所以如果确定了决策,状态转移方程也就可写出。但事实上常常是反过来做,根据相邻两个阶段的状态之间的关系来确定决策方法和状态转移方程。

(4)寻找边界条件:给出的状态转移方程是一个递推式,需要一个递推的终止条件或边界条件。

一般,只要解决问题的阶段、状态和状态转移决策确定了,就可以写出状态转移方程(包括边界条件)。

实际应用中可以按以下几个简化的步骤进行设计:

(1)分析最优解的性质,并刻画其结构特征。

(2)递归的定义最优解。

(3)以自底向上或自顶向下的记忆化方式(备忘录法)计算出最优值。

(4)根据计算优值时得到的信息,构造问题的最优解。

五、 解题案例
有数组penny,penny中所有的值都为正数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个整数aim(小于等于1000)代表要找的钱数,求换钱有多少种方法。
给定数组penny及它的大小(小于等于50),同时给定一个整数aim,请返回有多少种方法可以凑成aim。

解析:设dp[n][m]为使用前n中货币凑成的m的种数,那么就会有两种情况:

使用第n种货币:dp[n-1][m]+dp[n-1][m-peney[n]]

不用第n种货币:dp[n-1][m],为什么不使用第n种货币呢,因为penney[n]>m。

这样就可以求出当m>=penney[n]时 dp[n][m] = dp[n-1][m]+dp[n][m-peney[n]],否则,dp[n][m] = dp[n-1][m]
import java.util.*;  
public class Exchange {  
    public int countWays(int[] penny, int n, int aim) {  
        if(n==0||penny==null||aim<0){  
         return 0;     
        }  
        int[][] pd = new int[n][aim+1];  
        for(int i=0;i<n;i++){  
         pd[i][0] = 1;     
        }  
        for(int i=1;penny[0]*i<=aim;i++){  
         pd[0][penny[0]*i] = 1;     
        }  
        for(int i=1;i<n;i++){  
            for(int j=0;j<=aim;j++){  
                if(j>=penny[i]){  
                    pd[i][j] = pd[i-1][j]+pd[i][j-penny[i]];  
                }else{  
                    pd[i][j] = pd[i-1][j];  
                }  
            }  
        }  
        return pd[n-1][aim];  
    }  
 测试样例:[1,2,4],3,3          返回:2

二分查找算法

二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法。但是,折半查找要求线性表必须采用顺序存储结构,而且表中元素按关键字有序排列

1. 如果待查序列为空,那么就返回-1,并退出算法;这表示查找不到目标元素。

2. 如果待查序列不为空,则将它的中间元素与要查找的目标元素进行匹配,看它们是否相等。

3. 如果相等,则返回该中间元素的索引,并退出算法;此时就查找成功了。

4. 如果不相等,就再比较这两个元素的大小。

5. 如果该中间元素大于目标元素,那么就将当前序列的前半部分作为新的待查序列;这是因为后半部分的所有元素都大于目标元素,它们全都被排除了。

6. 如果该中间元素小于目标元素,那么就将当前序列的后半部分作为新的待查序列;这是因为前半部分的所有元素都小于目标元素,它们全都被排除了。

7. 在新的待查序列上重新开始第1步的工作。

这种算法会使每次查找的范围减半,如下图所示:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
以此类推,直至找到目标元素52

时间复杂度:时间复杂度即是while循环的次数。
折半查找每次把搜索区域砍掉一半,很明显时间复杂度为O( log2n)(n代表元素的个数)

空间复杂度:O( 1 )

朴素算法&KMP算法

一、 基本概念
朴素算法又称暴力算法(Brute-Force)、BF算法,是一种字符串匹配算法。简单粗暴,简单地讲就是先从主串的第一个位置开始逐个对模式串进行匹配,若匹配失败,则从主串的第二个位置继续进行匹配,以此类推,直到匹配成功或主串的结尾。
在这里插入图片描述
朴素算法时间复杂度:(假设模式串的长度是m,目标串的长度是n)
最优:第一步就匹配成功。时间复杂度为O(n)
最差:最后一步匹配成功。时间复杂度为O(m-n+1)*n
由上也看出了暴力算法的缺陷:重复校验太多了。只要匹配不上,又要重头开始匹配。这就引出来KMP算法:KMP算法是改良的朴素算法(消除指针回溯),解决的问题就是暴力算法的重复校验问题。

KMP算法:KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)。KMP算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是通过一个next()函数实现,函数本身包含了模式串的局部匹配信息。KMP算法的时间复杂度O(m+n)

基于特征分析的快速模式匹配算法(KMP模式匹配算法)与朴素匹配算法类似,只是在每次匹配过程中发生某次失配时,不再单纯地把模式后移一位,而是根据当前字符的特征数来决定模式右移的位数
在这里插入图片描述

暴力破解示例:
    //one:长的"ABEDGDG"   other:短的"DG"
    public int BFArithmetic(String one,String other){
        byte[] oneBytes = one.getBytes();
        byte[] otherBytes = other.getBytes();
        outer:for(int i=0;i<oneBytes.length;i++){
            int k = i;
            inner:for(int j=0;j<otherBytes.length;j++){
                if(oneBytes[k] == otherBytes[j]){
                    k++;
                    if(j == otherBytes.length-1){
                        return i;
                    }
                }else if(oneBytes[k] != otherBytes[j]) {
                    break inner;
                }
            }
        }
        return -1;
    }
    

KMP算法就是利用之前判断过信息,通过一个next数组,保存模式串中前后最长公共序列的长度,每次回溯时
通过next数组找到, 前面匹配的位置,省去了大量的计算时间

var target = 'ababxababc'
var pattern = 'ababc'

function getKPMNext(str) {
    var i, j;
    var next = [];

    next[0] = -1;
    i = 0; j = -1;

    while(i < str.length - 1) {
        if (j == -1 || str[i] == str[j]) {
            next[++i] = ++j;
        } else {
            j = next[j];
        }
    }
    return next;
}
// j = next[j] next[j]的两侧子字符串相等,所以这时候str[i] == str[j] 倒数两位== 4 5位 == 1 2位
console.log(getKPMNext('abxabaabxabxa'))

//KPM算法:当失配时j回溯,相对于传统匹配省掉了不必要的匹配。
function KPMMatch(target, pattern) {
    var i = 0, j = 0;
    var next = getKPMNext(pattern);

    while (i < target.length && j < pattern.length) {
        if (j == -1 || target[i] == pattern[j]) {
            ++i;
            ++j;
        } else {
            j = next[j];
        }
    }

    if (j >= pattern.length) {
        return i - j;
    }
    return -1;
}

回溯算法

backtracking algorithm,又被称为“试探法”。解决问题时,每进行一步,都是抱着试试看的态度,如果发现当前选择并不是最好的,或者这么走下去肯定达不到目标,立刻做回退操作重新选择。这种走不通就回退再走的方法就是回溯法。
回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称

用回溯算法解决问题的一般步骤:
1、 针对所给问题,定义问题的解空间,它至少包含问题的一个(最优)解。
2 、确定易于搜索的解空间结构,使得能用回溯法方便地搜索整个解空间 。
3 、以深度优先的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索。

回溯算法的基本思想是:
从一条路往前走,能进则进,不能进则退回来,换一条路再试。
八皇后问题就是回溯算法的典型,第一步按照顺序放一个皇后,然后第二步符合要求放第2个皇后,如果没有位置符合要求,那么就要改变第一个皇后的位置,重新放第2个皇后的位置,直到找到符合条件的位置就可以了。回溯在迷宫搜索中使用很常见,就是这条路走不通,然后返回前一个路口,继续下一条路。回溯算法说白了就是穷举法。不过回溯算法使用剪枝函数,剪去一些不可能到达 最终状态(即答案状态)的节点,从而减少状态空间树节点的生成。回溯法是一个既带有系统性又带有跳跃性的的搜索算法。它在包含问题的所有解的解空间树中,按照深度优先的策略,从根结点出发搜索解空间树。算法搜索至解空间树的任一结点时,总是先判断该结点是否肯定不包含问题的解。如果肯定不包含,则跳过对以该结点为根的子树的系统搜索,逐层向其祖先结点回溯。否则,进入该子树,继续按深度优先的策略进行搜索。回溯法在用来求问题的所有解时,要回溯到根,且根结点的所有子树都已被搜索遍才结束。而回溯法在用来求问题的任一解时,只要搜索到问题的一个解就可以结束。这种以深度优先的方式系统地搜索问题的解的算法称为回溯法,它适用于解一些组合数较大的问题。
在这里插入图片描述
八皇后问题:在8×8格的国际象棋上摆放八个皇后,使其不能互相攻击,
即任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少种摆法。

#include <stdio.h>
int Queenes[8]={0},Counts=0;
int Check(int line,int list){
    //遍历该行之前的所有行
    for (int index=0; index<line; index++) {
        //挨个取出前面行中皇后所在位置的列坐标
        int data=Queenes[index];
        //如果在同一列,该位置不能放
        if (list==data) {
            return 0;
        }
        //如果当前位置的斜上方有皇后,在一条斜线上,也不行
        if ((index+data)==(line+list)) {
            return 0;
        }
        //如果当前位置的斜下方有皇后,在一条斜线上,也不行
        if ((index-data)==(line-list)) {
            return 0;
        }
    }
    //如果以上情况都不是,当前位置就可以放皇后
    return 1;
}
//输出语句
void print()
{
    for (int line = 0; line < 8; line++)
    {
        int list;
        for (list = 0; list < Queenes[line]; list++)
            printf("0");
        printf("#");
        for (list = Queenes[line] + 1; list < 8; list++){
            printf("0");
        }
        printf("\n");
    }
    printf("================\n");
}

void eight_queen(int line){
    //在数组中为0-7列
    for (int list=0; list<8; list++) {
        //对于固定的行列,检查是否和之前的皇后位置冲突
        if (Check(line, list)) {
            //不冲突,以行为下标的数组位置记录列数
            Queenes[line]=list;
            //如果最后一样也不冲突,证明为一个正确的摆法
            if (line==7) {
                //统计摆法的Counts加1
                Counts++;
                //输出这个摆法
                print();
                //每次成功,都要将数组重归为0
                Queenes[line]=0;
                return;
            }
            //继续判断下一样皇后的摆法,递归
            eight_queen(line+1);
            //不管成功失败,该位置都要重新归0,以便重复使用。
            Queenes[line]=0;
        }
    }
}
int main() {
    //调用回溯函数,参数0表示从棋盘的第一行开始判断
    eight_queen(0);
    printf("摆放的方式有%d种",Counts);
    return 0;
}

回溯VS递归

  1. 很多人认为回溯和递归是一样的,其实不然。在回溯法中可以看到有递归的身影,但是两者是有区别的。

  2. 回溯法从问题本身出发,寻找可能实现的所有情况。和穷举法的思想相近,不同在于穷举法是将所有的情况都列举出来以后再一一筛选,而回溯法在列举过程如果发现当前情况根本不可能存在,就停止后续的所有工作,返回上一步进行新的尝试。

  3. 递归是从问题的结果出发,例如求 n!,要想知道 n!的结果,就需要知道 n*(n-1)! 的结果,而要想知道 (n-1)! 结果,就需要提前知道 (n-1)*(n-2)!。这样不断地向自己提问,不断地调用自己的思想就是递归。

回溯和递归唯一的联系就是,回溯法可以用递归思想实现。

回溯VS树
使用回溯法解决问题的过程,实际上是建立一棵“状态树”的过程。
在这里插入图片描述
回溯法的求解过程实质上是先序遍历“状态树”的过程。树中每一个叶子结点,都有可能是问题的答案。图 1 中的状态树是满二叉树,得到的叶子结点全部都是问题的解。

在某些情况下,回溯法解决问题的过程中创建的状态树并不都是满二叉树,因为在试探的过程中,有时会发现此种情况下,再往下进行没有意义,所以会放弃这条死路,回溯到上一步。在树中的体现,就是在树的最后一层不是满的,即不是满二叉树,需要自己判断哪些叶子结点代表的是正确的结果。

普里姆算法和克鲁斯卡尔算法

图是一种基础又重要的数据结构,图的生成树是图的一个极小连通子图。最小生成树是无向连通网的所有生成树中边的权值之和最小的一棵生成树。求图的最小生成树可以牵引出很多经典的题目,例如在N个城市之间建立通讯网络,问怎样最省经费(不同城市之间的联系网的费用不同,也即是边上的权值不同)
Prim算法和KrusKal算法用作:构造某个图的最小生成树,这两种算法它们考虑问题的出发点是:为使生成树上边的权值之和达到最小,则应使生成树中每一条边的权值尽可能的小。
在这里插入图片描述

生成树:就是一个连通图的极小连通子图(它包含图中的所有顶点,并且只含有尽可能少的边)
最小生成树:
一个有 n 个结点的连通图的生成树是原图的 极小连通子图,且包含原图中的所有 n 个结点,
并且有保持图连通的最少的边。
由于最小生成树本身是一棵生成树,所以需要时刻满足以下两点:
生成树中任意顶点之间有且仅有一条通路,也就是说,生成树中不能存在回路;
对于具有 n 个顶点的连通网,其生成树中只能有 n-1 条边,这 n-1 条边连通着 n 个顶点。

在这里插入图片描述
在这里插入图片描述

1)克鲁斯卡尔(Kruskal)算法从另一途径求网的最小生成树。其基本思想是:假设连通网G=(V,E),令最小生成树的初始状态为只有n个顶点而无边的非连通图T=(V,{}),图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点分别在T中不同的连通分量上,则将此边加入到T中;否则,舍去此边而选择下一条代价最小的边。依此类推,直至T中所有顶点构成一个连通分量为止 [2] 。

  • 对于图7.18(a)所示的网,按照克鲁斯卡尔方法构造最小生成树的过程如图7.20所示
  • 从边的角度求网的最小生成树,时间复杂度为O(eloge)。和普里姆算法恰恰相反,更适合于求边稀疏的网的最小生成树
    连接 n 个顶点在不产生回路的情况下,只需要 n-1 条边。
    在这里插入图片描述
    图中按权值由小到大的顺序排列的编辑是:(各边由起点序号,终点序号,权值表示)
    1.(4,6,30) 2.(2,5,40) 3.(4,7,42) 4.(3,7,45)
    5.(1,2,50) 6.(4,5,50) 7.(3,4,52) 8.(1,3,60)
    9.(2,4,65) 10.(5, 6, 70)

所以克鲁斯卡尔算法的具体思路是:将所有边按照权值的大小进行升序排序,然后从小到大一一判断,条件为:如果这个边不会与之前选择的所有边组成回路,就可以作为最小生成树的一部分;反之,舍去。直到具有 n 个顶点的连通网筛选出来 n-1 条边为止。筛选出来的边和所有的顶点构成此连通网的最小生成树。
判断是否会产生回路的方法为:在初始状态下给每个顶点赋予不同的标记,对于遍历过程的每条边,其都有两个顶点,判断这两个顶点的标记是否一致,如果一致,说明它们本身就处在一棵树中,如果继续连接就会产生回路;如果不一致,说明它们之间还没有任何关系,可以连接。

假设遍历到一条由顶点 A 和 B 构成的边,而顶点 A 和顶点 B 标记不同,此时不仅需要将顶点 A 的标记更新为顶点 B 的标记,还需要更改所有和顶点 A 标记相同的顶点的标记,全部改为顶点 B 的标记。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

克鲁斯卡尔算法思想设计克鲁斯卡尔算法函数主要包括两个部分:首先是带权图G中e条边的权值的排序;其次是判断新选取的边的两个顶点是否属于同一个连通分量。对带权图G中e条边的权值的排序方法可以有很多种,各自的时间复杂度均不相同,对e条边的权值排序算法时间复杂度较好的算法有快速排序法、堆排序法等,这些排序算法的时间复杂度均可以达到O(elbe)。判断新选取的边的两个顶点是否属于同一个连通分量的问题是一个在最多有n个顶点的生成树中遍历寻找新选取的边的两个顶点是否存在的问题,此算法的时间复杂度最坏情况下为O(n)
在这里插入图片描述

/**
 * @desc 克鲁斯卡尔算法
 * 案例:公交车问题
 * 1. 某城市新增7个站点,A,B,C,D,E,F,G,现在需要修路7个站点连通
 * 2. 各个站点距离用连线表示,比如A-B距离12公里
 * 3. 问:如何修路保证各个站点都能连通,并且总的修建公路总里程最短
 * @Author xw
 * @Date 2019/10/8
 */
public class KruskalCase {
    private static final int INF = Integer.MAX_VALUE;
    private char[] vertexs;
    private int[][] matrix;
    private int edgeNums; // 边的数量
    public KruskalCase(char[] vertexs,int[][] matrix ) {
        this.vertexs = vertexs;
        this.matrix = matrix;
        // 统计边
        for (int i = 0; i < vertexs.length; i++) {
            for (int j = i + 1; j < vertexs.length; j++) { // 每次少一条边,所以是i+1
                if (this.matrix[i][j] != INF) {
                    edgeNums++;
                }
            }
        }
    }
    public static void main(String[] args) {
        char[] vertexs = {'A', 'B', 'C', 'D', 'E', 'F', 'G'};
        int[][] matrix = {
                     /*A*//*B*//*C*//*D*//*E*//*F*//*G*/
                /*A*/{ 0,   12, INF,  INF, INF, 16,  14 },
                /*B*/{ 12,  0,   10,  INF, INF, 7,   INF},
                /*C*/{ INF, 10,  0,   3,    5,  6,   INF },
                /*D*/{ INF, INF, 3,   0,    4,  INF, INF },
                /*E*/{ INF, INF, 5,   4,    0,  2,   8 },
                /*F*/{ 16,  7,   6,   INF,  2,  0,   9 },
                /*G*/{ 14,  INF, INF, INF,  8,  9,   0 }
        };
        // 创建KruskalCase对象实例
        KruskalCase kruskalCase = new KruskalCase(vertexs, matrix);
        //
        kruskalCase.print();
        kruskalCase.kruskal();
    }
}

2)普里姆算法(Prim算法),图论中的一种算法,可在加权连通图里搜索最小生成树。意即由此算法搜索到的边子集所构成的树中,不但包括了连通图里的所有顶点(英语:Vertex (graph theory)),且其所有边的权值之和亦为最小。普里姆算法是归并顶点的算法,与边数无关,所以适用于稠密图
在这里插入图片描述

算法描述编辑
1).输入:一个加权连通图,其中顶点集合为V,边集合为E;
2).初始化:Vnew = {x},其中x为集合V中的任一节点(起始点),Enew = {},为空;
3).重复下列操作,直到Vnew = V:
a.在集合E中选取权值最小的边<u, v>,其中u为集合Vnew中的元素,而v不在Vnew集合当中,并且v∈V(如果存在有多条满足前述条件即具有相同权值的边,则可任意选取其中之一);
b.将v加入集合Vnew中,将<u, v>边加入集合Enew中;
4).输出:使用集合Vnew和Enew来描述所得到的最小生成树。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

​ 普里姆算法是以某个顶点为起点,逐步找到每个顶点上最小权值的边来构建最小生成树。克鲁斯卡尔算法则换一种思路,从边出发,因为权值是在边上,所以直接去找最小权值的边来构建生成树。
区别:当边较多顶点较少时,用普里姆算法比较快;当边较少顶点较多时,用克鲁斯卡尔算法比较有优势。克鲁斯卡尔算法是求连通网的最小生成树的另一种方法。与普里姆算法不同,它的时间复杂度为O(eloge)(e为网中的边数),所以,适合于求边稀疏的网的最小生成树 [1] 。

迪杰斯特拉算法和弗洛伊德算法

1)迪杰斯特拉(Dijkstra)算法
Dijkstra算法是从一个顶点到其余各顶点的最短路径算法,解决的是有权图中最短路径问题。迪杰斯特拉算法主要特点是从起始点开始,采用贪心算法的策略,每次遍历到始点距离最近且未访问过的顶点的邻接节点,直到扩展到终点为止。Dijkstra算法的时间复杂度为O(N^2)。

实现思想:迪杰斯特拉最最朴素的思想就是按长度递增的次序产生最短路径。即每次对所有可见点的路径长度进行排序后,选择一条最短的路径,这条路径就是对应顶点到源点的最短路径。 可见点就是从源点开始按广度优先算法遍历顶点的过程中,搜索到的点。
在这里插入图片描述

//假设起点为src, 终点为dst, 图以二维矩阵的形式存储,
//若graph[i][j] == 0, 代表i,j不相连    
//visit[i] == 0,代表未访问,visit[0] == -1代表已访问    
public int Dijkstra(int src, int dst, int[][] graph,int[] visit){
    //节点个数        
    int n = graph.length;        
    PriorityQueue<Node> pq = new PriorityQueue<>(new Node());        
    //将起点加入pq        
    pq.add(new Node(src, 0));        
    while (!pq.isEmpty()){            
        Node t = pq.poll();            
        //当前节点是终点,即可返回最短路径            
        if(t.node == dst)                
            return t.cost;            
        //t节点表示还未访问            
        if (visit[t.node]==0){                
            //将节点设置为已访问                
            visit[t.node] = -1;                
            //将当前节点相连且未访问的节点遍历                
            for (int i = 0; i < n; i++) {                    
                if (graph[t.node][i]!=0 && visit[i]==0) {                        
                    pq.add(new Node(i, t.cost + graph[t.node][i]));                    
                }                
            }            
        }        
    }        
    return -1;    
}    
//定义一个存储节点和离起点相应距离的数据结构    
class Node implements Comparator<Node> {        
    public int node;        
    public int cost;
             
    public Node(){}
     
    public Node(int node, int cost){            
        this.node = node;            
        this.cost = cost;        
    }        
    @Override        
    public int compare(Node node1, Node node2){            
        return node1.cost-node2.cost;       
    }    
}

2)弗洛伊德(Floyd)算法
Floyd算法是解决任意两点间的最短路径的一种算法,可以正确处理有向图或负权的最短路径问题,同时也被用于计算有向图的传递闭包。Floyd算法的时间复杂度为O(N^3)。

基本思想
通过Floyd计算图G=(V,E)中各个顶点的最短路径时,需要引入一个矩阵S,矩阵S中的元素a[i][j]表示顶点i(第i个顶点)到顶点j(第j个顶点)的距离。
假设图G中顶点个数为N,则需要对矩阵S进行N次更新。初始时,矩阵S中顶点a[i][j]的距离为顶点i到顶点j的权值;如果i和j不相邻,则a[i][j]=∞。 接下来开始,对矩阵S进行N次更新。第1次更新时,如果"a[i][j]的距离" > “a[i][0]+a[0][j]”(a[i][0]+a[0][j]表示"i与j之间经过第1个顶点的距离"),则更新a[i][j]为"a[i][0]+a[0][j]"。 同理,第k次更新时,如果"a[i][j]的距离" > “a[i][k]+a[k][j]”,则更新a[i][j]为"a[i][k]+a[k][j]"。更新N次之后,操作完成!
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
我们来想一想,根据我们以往的经验,如果要让任意两点(例如从顶点a点到顶点b)之间的路程变短,只能引入第三个点(顶点k),并通过这个顶点k中转即a->k->b,才可能缩短原来从顶点a点到顶点b的路程。那么这个中转的顶点k是1~n中的哪个点呢?甚至有时候不只通过一个点,而是经过两个点或者更多点中转会更短,即a->k1->k2b->或者a->k1->k2…->k->i…->b。比如上图中从4号城市到3号城市(4->3)的路程e[4][3]原本是12。如果只通过1号城市中转(4->1->3),路程将缩短为11(e[4][1]+e[1][3]=5+6=11)。其实1号城市到3号城市也可以通过2号城市中转,使得1号到3号城市的路程缩短为5(e[1][2]+e[2][3]=2+3=5)。所以如果同时经过1号和2号两个城市中转的话,从4号城市到3号城市的路程会进一步缩短为10。通过这个的例子,我们发现每个顶点都有可能使得另外两个顶点之间的路程变短。好,下面我们将这个问题一般化。

    当任意两点之间不允许经过第三个点时,这些城市之间最短路程就是初始路程,如下。

在这里插入图片描述
Floyd算法与Dijkstra算法区别:
1)Floyd算法是求任意两点之间的距离,是多源最短路,而Dijkstra(迪杰斯特拉)算法是求一个顶点到其他所有顶点的最短路径,是单源最短路。
2)Floyd算法属于动态规划,我们在写核心代码时候就是相当于推dp状态方程,Dijkstra(迪杰斯特拉)算法属于贪心算法。
3)Dijkstra(迪杰斯特拉)算法时间复杂度一般是o(n2),Floyd算法时间复杂度是o(n3),Dijkstra(迪杰斯特拉)算法比Floyd算法块。
4)Floyd算法可以算带负权的,而Dijkstra(迪杰斯特拉)算法是不可以算带负权的。并且Floyd算法不能算负权回路。

马踏棋盘算法

马踏棋盘问题就是在一个8X8的棋盘上,马按照日字形规则在棋盘上欢快的跳跃,如何才能将每个格子都走到,而且每个格子只能跳一次。
抽象来看,就是一个搜索问题,如果用二叉树来表示,就是一个深度为64,每个树杈有不多于7种分支的树(自行脑补,懒得画图了)。那么最直观的方法就是用遍历的方法,也就是所谓的深度搜索(听着好牛),但是这样的效率据说比较低(我也没验证,有时间再说),还有比较常用的就是贪心算法,也就是本文所用方法。
这里大概说一下贪心算法的内涵,就是在每次决策的时候选取局部最优(就是这么简单)。对于马踏棋盘的问题来说,每次选取出口最少的路径。至于为什么这么选择,我看到有人说是因为出口少就代表选择性小,遍历的也就会快,也就越容易先排除,这样就增加了算法速度。但是,如果按照这个理论也就意味着,在决策的时候要去找局部最差,显然这个解释不太合理(不过,我目前也没想明白)。先不管为啥了,就这么做吧。

再说说这里面要用到的回溯和递归算法,这两个算法的意思我就不解释了。我要说的是在设计递归的时候,要把任务拆分成​循环任务。同时,要设计出成功通道,也就是什么情况下递归结束。对于该问题,递归函数要完成的有以下几点:1.查找可用的子节点;2.记录当前结点位置;3.进入一个子节点;4.判定递归成功,并从成功通道跳出递归。

对于回溯算法,就是如果不成功,要从子节点回到父节点。对于本问题,其实并不需要设置回到父节点的​程序,只需要将已经记录的位置清零(也就是相当于没走这条路),并且不再进行下一步的递归,跳出当前程序,也就是回溯了。

在编程的时候,为了调代码方便,看到有人用5X5的棋盘做实验,我也就照猫画虎。​但是,要注意的是5X5的棋盘并不是所有点都有解(就没人说过!!导致我还老以为我的代码出问题了,8X8的棋盘是所有点都有解的)。

在这里插入图片描述
在这里插入图片描述

程序用文字描述如下:

【输出棋盘号,标志位】=查找路径【节点位置,输入棋盘号】

if 是否走完了所有位置

标志位置1;return;

end

查找所有可能子节点

将子节点按照贪心算法排序

for 每个可能节点

     递归查找路径函数

     if 标志位为1

          记录当前棋盘号;​

          给该曾函数标识1;return;

     else

          给当前棋盘号置0;%这就是回溯

     end​​

end

标识0;

end
  1. 马踏棋盘经典算法描述:马踏棋盘是经典的程序设计问题之一,主要的解决方案有两种:一种是基于深度优先搜索的方法,另一种是基于贪婪算法的方法。第一种基于深度优先搜索的方法是比较常用的算法。
  2. 深度优先搜索算法也是数据结构中的经典算法之一,主要是采用递归的思想,一级一级的寻找,遍历出所有的结果,最后找到合适的解。
  3. 而基于贪婪的算法则是制定贪心准则,一旦设定不能修改,他只关心局部最优解,但不一定能得到最优解。
  4. 【算法分析】在四角,马踏日走只有两个选择;在其余部分,马踏日走有四、六、八不等的选择。
    在这里插入图片描述
package com.atguigu.horse;

import java.awt.Point;
import java.util.ArrayList;
import java.util.Comparator;

public class HorseChessboard {

    private static int X; // 列
    private static int Y; // 行
    
    private static boolean visited[];
    private static boolean finished; 
    
    public static void main(String[] args) {
        X = 8;
        Y = 8;
        int row = 1; 
        int column = 1; 
        int[][] chessboard = new int[X][Y];
        visited = new boolean[X * Y];
        long start = System.currentTimeMillis();
        traversalChessboard(chessboard, row - 1, column - 1, 1);
        long end = System.currentTimeMillis();
        System.out.println("时间: " + (end - start));
        for(int[] rows : chessboard) {
            for(int step: rows) {
                System.out.print(step + "\t");
            }
            System.out.println();
        }
    }
    
    public static void traversalChessboard(int[][] chessboard, int row, int column, int step) {
        chessboard[row][column] = step;
        visited[row * X + column] = true; 
        ArrayList<Point> ps = next(new Point(column, row));
        sort(ps);
        while(!ps.isEmpty()) {
            Point p = ps.remove(0);
            if(!visited[p.y * X + p.x]) {
                traversalChessboard(chessboard, p.y, p.x, step + 1);
            }
        }
        if(step < X * Y && !finished ) {
            chessboard[row][column] = 0;
            visited[row * X + column] = false;
        } else {
            finished = true;
        }
    }
    

    public static ArrayList<Point> next(Point curPoint) {
        ArrayList<Point> ps = new ArrayList<Point>();
        Point p1 = new Point();
        if((p1.x = curPoint.x - 2) >= 0 && (p1.y = curPoint.y -1) >= 0) {
            ps.add(new Point(p1));
        }
        if((p1.x = curPoint.x - 1) >=0 && (p1.y=curPoint.y-2)>=0) {
            ps.add(new Point(p1));
        }
        if ((p1.x = curPoint.x + 1) < X && (p1.y = curPoint.y - 2) >= 0) {
            ps.add(new Point(p1));
        }
        if ((p1.x = curPoint.x + 2) < X && (p1.y = curPoint.y - 1) >= 0) {
            ps.add(new Point(p1));
        }
        if ((p1.x = curPoint.x + 2) < X && (p1.y = curPoint.y + 1) < Y) {
            ps.add(new Point(p1));
        }
        if ((p1.x = curPoint.x + 1) < X && (p1.y = curPoint.y + 2) < Y) {
            ps.add(new Point(p1));
        }
        if ((p1.x = curPoint.x - 1) >= 0 && (p1.y = curPoint.y + 2) < Y) {
            ps.add(new Point(p1));
        }
        if ((p1.x = curPoint.x - 2) >= 0 && (p1.y = curPoint.y + 1) < Y) {
            ps.add(new Point(p1));
        }
        return ps;
    }

    //排序
    public static void sort(ArrayList<Point> ps) {
        ps.sort(new Comparator<Point>() {
            @Override
            public int compare(Point o1, Point o2) {
                int count1 = next(o1).size();
                int count2 = next(o2).size();
                if(count1 < count2) {
                    return -1;
                } else if (count1 == count2) {
                    return 0;
                } else {
                    return 1;
                }
            }
        });
    }
}


排序算法

在这里插入图片描述

稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面。
不稳定:如果a原本在b的前面,而a=b,排序之后 a 可能会出现在 b 的后面。
时间复杂度:对排序数据的总的操作次数。反映当n变化时,操作次数呈现什么规律。
空间复杂度:是指算法在计算机内执行时所需存储空间的度量,它也是数据规模n的函数。 

在这里插入图片描述

冒泡排序

从第一个开始和后面的进行比较,如果后面的比较小就交换位置。比较size-1轮,并且每一轮都保证最后一个是当前轮最大的元素。名字的来由是因为最小的元素会慢慢浮到前面来。
在这里插入图片描述
冒泡规则:
1.外轮循环次数=size-1,因为最后一个不用排
2.内部循环次数为size-外部循环次数,最后的已经排过了

private static void buddleSort(int[] sort){
    for(int i=1;i<sort.length;i++){
        for(int j=0;j<sort.length-i;j++){
            if(sort[j]>sort[j+1]){
                int temp = sort[j+1];
                sort[j+1] = sort[j];
                sort[j] = temp;
            }
        }
    }
}
------------------------------------------------------------------
时间复杂度:O(n)*O(n) 即平方n,最优为O(n),刚好一次冒泡排完(可能吗?)

选择排序

在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。 记录的是下标位置
在这里插入图片描述

int temp;
for(int i=0;i<arr.length-1;i++){       //比较的轮数 0(n)
	int index = i;
	for(int j=i;j<arr.length-1;j++){ //每一轮比较的内容 O(n)
		if(arr[j]<arr[index]){
			index = j;
		}
	}
	temp = arr[index];
	arr[index] = arr[i];
	arr[i] = temp;
}
-----------------------------------------------------------------
最稳定的排序算法之一,因为无论什么数据进去都是O(n*n)的时间复杂度,所以
用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了

插入排序

构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找相应位置并插入
从第一个元素开始,该元素默认为已经被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后;
重复 -操作

在这里插入图片描述

int temp; 
for(int i=0;i<arr.length-1;i++){
	inner:for(int j=i+1;j>-1;j--){
		if(arr[i+1]<arr[j]){
			if(arr[i+1]>arr[j-1]){
				arr[j+1] = arr[j];
				arr[j] = temp;
				break inner;
			}
			temp = arr[i+1];
			arr[j+1] = arr[j];
		} 
	}
}
------------------------------------------------------------------
插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),
从后向前扫描过程中,需要反复把已排序元素逐步向后挪,为最新元素提供插入空间

希尔排序

Shell Sort于1959年发明,是第一个突破O(n*n)的排序算法,是插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序。
在这里插入图片描述

   public class ShellSort implements IArraySort {

   @Override
   public int[] sort(int[] sourceArray) throws Exception {
       //拷贝参数对象
       int[] arr = Arrays.copyOf(sourceArray, sourceArray.length);
       int gap = 1;
       
       while (gap < arr.length) {
           gap = gap * 3 + 1;
       }

       while (gap > 0) {
           for (int i = gap; i < arr.length; i++) {
               int tmp = arr[i];
               int j = i - gap;
               while (j >= 0 && arr[j] > tmp) {
                   arr[j + gap] = arr[j];
                   j -= gap;
               }
               arr[j + gap] = tmp;
           }
           gap = (int) Math.floor(gap / 3);
       }
       return arr;
   }
 }

归并排序

Merge Sort是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
在这里插入图片描述

   public class MergeSort implements IArraySort {
   @Override
   public int[] sort(int[] sourceArray) throws Exception {
       int[] arr = Arrays.copyOf(sourceArray, sourceArray.length);

       if (arr.length < 2) {
           return arr;
       }
       int middle = (int) Math.floor(arr.length / 2);

       int[] left = Arrays.copyOfRange(arr, 0, middle);
       int[] right = Arrays.copyOfRange(arr, middle, arr.length);

       return merge(sort(left), sort(right));
   }

   protected int[] merge(int[] left, int[] right) {
       int[] result = new int[left.length + right.length];
       int i = 0;
       while (left.length > 0 && right.length > 0) {
           if (left[0] <= right[0]) {
               result[i++] = left[0];
               left = Arrays.copyOfRange(left, 1, left.length);
           } else {
               result[i++] = right[0];
               right = Arrays.copyOfRange(right, 1, right.length);
           }
       }

       while (left.length > 0) {
           result[i++] = left[0];
           left = Arrays.copyOfRange(left, 1, left.length);
       }

       while (right.length > 0) {
           result[i++] = right[0];
           right = Arrays.copyOfRange(right, 1, right.length);
       }

       return result;
   }

 }
 -----------------------------------------------------------------
1. 把长度为n的输入序列分成两个长度为n/2的子序列;
2. 对这两个子序列分别采用归并排序;
3. 将两个排序好的子序列合并成一个最终的排序序列。

快速排序

通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
在这里插入图片描述

   public class QuickSort implements IArraySort {

   @Override
   public int[] sort(int[] sourceArray) throws Exception {
       // 对 arr 进行拷贝,不改变参数内容
       int[] arr = Arrays.copyOf(sourceArray, sourceArray.length);

       return quickSort(arr, 0, arr.length - 1);
   }

   private int[] quickSort(int[] arr, int left, int right) {
       if (left < right) {
           int partitionIndex = partition(arr, left, right);
           quickSort(arr, left, partitionIndex - 1);
           quickSort(arr, partitionIndex + 1, right);
       }
       return arr;
   }

   private int partition(int[] arr, int left, int right) {
       // 设定基准值(pivot)
       int pivot = left;
       int index = pivot + 1;
       for (int i = index; i <= right; i++) {
           if (arr[i] < arr[pivot]) {
               swap(arr, i, index);
               index++;
           }
       }
       swap(arr, pivot, index - 1);
       return index - 1;
   }

   private void swap(int[] arr, int i, int j) {
       int temp = arr[i];
       arr[i] = arr[j];
       arr[j] = temp;
   }
 }
 -----------------------------------------------------------------
1. 从数列中挑出一个元素,称为 “基准”(pivot);
2. 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的
摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数
列的中间位置。这个称为分区(partition)操作;
3.递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序

堆排序

Heap sort是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
在这里插入图片描述

   public class HeapSort implements IArraySort {

   @Override
   public int[] sort(int[] sourceArray) throws Exception {
   
       int[] arr = Arrays.copyOf(sourceArray, sourceArray.length);
       int len = arr.length;
       buildMaxHeap(arr, len);

       for (int i = len - 1; i > 0; i--) {
           swap(arr, 0, i);
           len--;
           heapify(arr, 0, len);
       }
       return arr;
   }

   private void buildMaxHeap(int[] arr, int len) {
       for (int i = (int) Math.floor(len / 2); i >= 0; i--) {
           heapify(arr, i, len);
       }
   }

   private void heapify(int[] arr, int i, int len) {
       int left = 2 * i + 1;
       int right = 2 * i + 2;
       int largest = i;

       if (left < len && arr[left] > arr[largest]) {
           largest = left;
       }

       if (right < len && arr[right] > arr[largest]) {
           largest = right;
       }

       if (largest != i) {
           swap(arr, i, largest);
           heapify(arr, largest, len);
       }
   }

   private void swap(int[] arr, int i, int j) {
       int temp = arr[i];
       arr[i] = arr[j];
       arr[j] = temp;
   }
 }
 ------------------------------------------------------------------
1. 将初始待排序关键字序列(R1,R2….Rn)构建成大顶堆,此堆为初始的无序区;
2. 将堆顶元素R[1]与最后一个元素R[n]交换,此时得到新的无序区
(R1,R2,……Rn-1)和新的有序区(Rn),且满足R[1,2…n-1]<=R[n]3. 由于交换后新的堆顶R[1]可能违反堆的性质,因此需要对当前无序区
(R1,R2,……Rn-1)调整为新堆,然后再次将R[1]与无序区最后一个元素交换,
得到新的无序区(R1,R2….Rn-2)和新的有序区(Rn-1,Rn)。不断重复此过程
直到有序区的元素个数为n-1,则整个排序过程完成。

计数排序

Counting Sort不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
在这里插入图片描述

   public class CountingSort implements IArraySort {

   @Override
   public int[] sort(int[] sourceArray) throws Exception {
       int[] arr = Arrays.copyOf(sourceArray, sourceArray.length);

       int maxValue = getMaxValue(arr);

       return countingSort(arr, maxValue);
   }

   private int[] countingSort(int[] arr, int maxValue) {
       int bucketLen = maxValue + 1;
       int[] bucket = new int[bucketLen];

       for (int value : arr) {
           bucket[value]++;
       }

       int sortedIndex = 0;
       for (int j = 0; j < bucketLen; j++) {
           while (bucket[j] > 0) {
               arr[sortedIndex++] = j;
               bucket[j]--;
           }
       }
       return arr;
   }

   private int getMaxValue(int[] arr) {
       int maxValue = arr[0];
       for (int value : arr) {
           if (maxValue < value) {
               maxValue = value;
           }
       }
       return maxValue;
   }
 }
 -----------------------------------------------------------------
1. 找出待排序的数组中最大和最小的元素;
2. 统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
3. 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
4. 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1
-------------------------------------------------------------------
计数排序是一个稳定的排序算法。当输入的元素是 n 个 0到 k 之间的整数时,
时间复杂度是O(n+k),空间复杂度也是O(n+k),其排序速度快于任何比较排序算法。
当k不是很大并且序列比较集中时,计数排序是一个很有效的排序算法。

桶排序

Bucket Sort是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)
在这里插入图片描述

  1. 首先,设置固定数量的空桶,在这里为了方便演示,设置桶的数量为 5 个空桶
    遍历整个数列,找到最大值为 56 ,最小值为 2 ,每个桶的范围为 ( 56 - 2 + 1 )/ 5 = 11
  2. 再次遍历整个数列,按照公式 floor((数字 – 最小值) / 11) 将数字放到对应的桶中
  3. 比如,数字 7 代入公式 floor (( 7 – 2 ) / 11 ) = 0 放入 0 号桶
    数字 12 代入公式 floor((12 – 2) / 11) = 0 放入 0 号桶
    数字 56 代入公式 floor((56 – 2) / 11) = 4 放入 4 号桶
  4. 当向同一个索引的桶,第二次插入数据时,判断桶中已存在的数字与新插入数字的大小,按照左到右,从小到大的顺序插入(可以使用前面讲解的插入排序)实现
  5. 比如,插入数字 19 时, 1 号桶中已经有数字 23 ,在这里使用插入排序,让 19 排在 23 前面
  6. 遍历完整个数列后,合并非空的桶,按从左到右的顺序合并 0 ,1 ,2 ,3 ,4 桶。
  7. 这样就完成了 桶排序
function bucketSort(arr, bucketSize) {
    if(arr.length === 0) {
      returnarr;
    }
 
    vari;
    varminValue = arr[0];
    varmaxValue = arr[0];
    for(i = 1; i < arr.length; i++) {
      if(arr[i] < minValue) {
          minValue = arr[i];                // 输入数据的最小值
      } elseif(arr[i] > maxValue) {
          maxValue = arr[i];                // 输入数据的最大值
      }
    }
 
    // 桶的初始化
    varDEFAULT_BUCKET_SIZE = 5;            // 设置桶的默认数量为5
    bucketSize = bucketSize || DEFAULT_BUCKET_SIZE;
    varbucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1;  
    varbuckets = newArray(bucketCount);
    for(i = 0; i < buckets.length; i++) {
        buckets[i] = [];
    }
 
    // 利用映射函数将数据分配到各个桶中
    for(i = 0; i < arr.length; i++) {
        buckets[Math.floor((arr[i] - minValue) / bucketSize)].push(arr[i]);
    }
 
    arr.length = 0;
    for(i = 0; i < buckets.length; i++) {
        insertionSort(buckets[i]);                      // 对每个桶进行排序,这里使用了插入排序
        for(varj = 0; j < buckets[i].length; j++) {
            arr.push(buckets[i][j]);                     
        }
    }
 
    returnarr;
}
------------------------------------------------------------------
桶排序最好情况下使用线性时间O(n),桶排序的时间复杂度,取决与对各个桶之间
数据进行排序的时间复杂度,因为其它部分的时间复杂度都为O(n)。很显然,桶划
分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗
就会增大。

基数排序

Radix Sort是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。
在这里插入图片描述
取得数组中的最大数,并取得位数;
arr为原始数组,从最低位开始取每个位组成radix数组;
对radix进行计数排序(利用计数排序适用于小范围数的特点);

varcounter = [];
function radixSort(arr, maxDigit) {
    varmod = 10;
    vardev = 1;
    for(vari = 0; i < maxDigit; i++, dev *= 10, mod *= 10) {
        for(varj = 0; j < arr.length; j++) {
            varbucket = parseInt((arr[j] % mod) / dev);
            if(counter[bucket]==null) {
                counter[bucket] = [];
            }
            counter[bucket].push(arr[j]);
        }
        varpos = 0;
        for(varj = 0; j < counter.length; j++) {
            varvalue = null;
            if(counter[j]!=null) {
                while((value = counter[j].shift()) != null) {
                      arr[pos++] = value;
                }
          }
        }
    }
    returnarr;
}
-------------------------------------------------------------------
基数排序基于分别排序,分别收集,所以是稳定的。但基数排序的性能比桶排序要略差,
每一次关键字的桶分配都需要O(n)的时间复杂度,而且分配之后得到新的关键字序列
又需要O(n)的时间复杂度。假如待排数据可以分为d个关键字,则基数排序的时间复
杂度将是O(d*2n) ,当然d要远远小于n,因此基本上还是线性级别的。

基数排序的空间复杂度为O(n+k),其中k为桶的数量。一般来说n>>k,因此额外空间需要大概n个左右。

哈希算法、摘要算法、加密算法

对称密码算法
支持 SM1、SM4、DES、3DES、AES
非对称密码算法
支持 SM2、RSA(1024-2048)
摘要算法
支持 SM3、SHA1、SHA256、SHA384

简介:
Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。(百度百科)
用途:

  • 哈希函数的(广义)抗碰撞性使得哈希函数可以用于数据的完整性验证。举例来说,某个用户上传一个文件供其他用户下载。用户在网络上公开了文件的哈希函数输出结果。下载用户完成下载后,计算下载结果的哈希函数输出结果。如果结果相等,则可以认为下载的软件就是官方发布的软件,没有遭到第三方的篡改。
  • 哈希函数第二个常见的应用场景是密码存储。在用户进行网站登录时,如果服务器直接存储用户密码,则如果服务器被攻击者所攻击,用户的密码就会遭到泄露。最典型的事件就是CSDN的密码明文存储事件了。为了解决这个问题,服务器可以仅存储用户密码的哈希结果。当用户输入登录信息后,服务器端可以计算密码的哈希结果,并与存储的哈希结果进行对比,如果结果相同,则允许用户登录。由于服务器不直接存储用户密码,因此即使服务器被攻击者攻击,用户的密码也不会被泄露。这也是为什么我们在使用【找回密码】功能时,服务器直接请求输入新的密码,而不是把原始密码发送给我们。毕竟,它自己也不知道用户的密码是什么… 至于彩虹表攻击等,属于讨论范畴之外,在此不多做叙述
  • 希函数第三个重要的应用是数字签名。实际上, 数字签名算法对消息的长度和格式是有要求的 ,要求数据满足一定的条件。为了解决这个问题,学者们指出可以对数据的哈希结果签名。由于哈希结果的长度是固定的,一般来说容易构造一种方法,让这个长度固定的结果进一步满足数字签名算法对数据输入的要求。而由于哈希函数的抗碰撞性,如果原始数据被篡改,那么哈希结果也会变化,同样会导致签名无法通过验证。更为重要的是,先哈希后签名的方法可以让签名变得更安全。实际上可以证明,如果先哈希,再执行RSA数字签名,则此算法是可证明安全的。
  • 哈希函数还有一个重要的用途就是在哈希列表(hashMap),对存值key进行hash运行来决定存储的位置。在数据的查找时,时间复杂度可以达到常数项。

特征:

  1. Hash算法可以将一个数据转换为一个标志,这个标志和源数据的每一个字节都有十分紧密的关系(哪怕源数据修改一bit,hash值也大不同)。Hash算法还具有一个特点,就是很难找到逆向规律(只有加密过程,没有解密过程)。
  2. Hash算法要求任意长度内容计算得到的hash值长度保持一致,并且计算的过程也比较简单快捷,给定数据m,容易算出哈希值x,而从哈希输出无法倒推输入的原始数值m,散列冲突(对不同的关键字可能得到同一散列地址,即key1≠key2,而f(key1)=f(key2),这种现象称碰撞)的概率要很小,对于不同的原始数据,哈希值相同的概率非常小。
  3. Hash算法也被称为散列算法,Hash算法虽然被称为算法,但实际上它更像是一种思想。Hash算法没有一个固定的公式,只要符合散列思想的算法都可以被称为是Hash算法。
  4. Hash作为一种消息摘要,在计算的时候丢失了原始数据的精度,所以是不可能进行倒推运算计算出原值。唯一的“破解”方案是:对比较常见的key进行Hash计算与Hash值进行比较,相同的则认为是源数据(穷举)。
  5. 综上,一个好的Hash算法应满足下列要求:
    确定性:哈希函数的算法是确定性算法,算法执行过程不引入任何随机量。这意味着相同消息的哈希结果一定相同。
    高效性:给定任意一个消息[m] ,可以快速计算[hash(m)] 。
    目标抗碰撞性:给定任意一个消息[m] ,很难找到另一个消息[n] ,使得[hash(m)=hash(n)] .
    广义抗碰撞性:很难找到两个消息[m!=n] ,使得[公式hash(m)=hash(n)] 。

在密码学上,一般认为如果第4个条件不满足,那么此哈希函数就不再安全。在实际中,一般认为如果在某种程度上第3个条件不满足,那么此哈希函数就不再安全。当然了,如果第3个条件完全不满足,那么此哈希函数已经彻底不安全,应该被直接弃用

抽屉原理:假设有4个抽屉,而有5个苹果。我们要把这5个苹果放在4个抽屉里,那么必然至少有2个苹果被放进了同一个抽屉。就算前4个苹果均放在了不同的抽屉中,由于所有抽屉都被占满了,而第5个苹果也要占一个抽屉,因此这个苹果一定和前面4个苹果中的某个苹果放在了同一个抽屉里面。(hash算法求出的值有限,数据无限,所以hash值肯定存在冲突)。

Hash算法是如何实现的?
密码学和信息安全发展到现在,各种加密算法和散列算法已经不是只言片语所能解释得了的。在这里我们仅提供几个简单的概念供大家参考。

作为散列算法,首要的功能就是要使用一种算法把原有的体积很大的文件信息用若干个字符来记录,还要保证每一个字节都会对最终结果产生影响。那么大家也许已经想到了,求模这种算法就能满足我们的需要。

事实上,求模算法作为一种不可逆的计算方法,已经成为了整个现代密码学的根基。只要是涉及到计算机安全和加密的领域,都会有模计算的身影。散列算法也并不例外,一种最原始的散列算法就是单纯地选择一个数进行模运算,比如以下程序。

#  构造散列函数
def hash(a):
    return a % 8
    
#  测试散列函数功能
print(hash(233))
print(hash(234))
print(hash(235))

# 输出结果
- 1
- 2
- 3

很显然,上述的程序完成了一个散列算法所应当实现的初级目标:用较少的文本量代表很长的内容(求模之后的数字肯定小于8)。但也许你已经注意到了,单纯使用求模算法计算之后的结果带有明显的规律性,这种规律将导致算法将能难保证不可逆性。所以我们将使用另外一种手段,那就是异或。

再来看下面一段程序,我们在散列函数中加入一个异或过程。

#  构造散列函数
def hash(a):
    return (a % 8) ^ 5

#  测试散列函数功能
print(hash(233))
print(hash(234))
print(hash(235))

# 输出结果
- 4
- 7
- 6

很明显的,加入一层异或过程之后,计算之后的结果规律性就不是那么明显了。
当然,大家也许会觉得这样的算法依旧很不安全,如果用户使用连续变化的一系列文本与计算结果相比对,就很有可能找到算法所包含的规律。但是我们还有其他的办法。比如在进行计算之前对原始文本进行修改,或是加入额外的运算过程(如移位),比如以下程序。

#  构造散列函数
def hash(a):
    return (a + 2 + (a << 1)) % 8 ^ 5

#  测试散列函数功能
print(hash(233))
print(hash(234))
print(hash(235))

# 输出结果
- 0
- 5
- 6

这样处理得到的散列算法就很难发现其内部规律,也就是说,我们并不能很轻易地给出一个数,让它经过上述散列函数运算之后的结果等于4——除非我们去穷举测试。

上面的算法是不是很简单?事实上,下面我们即将介绍的常用算法MD5和SHA1,其本质算法就是这么简单,只不过会加入更多的循环和计算,来加强散列函数的可靠性。

常用hash算法的介绍:
在这里插入图片描述

(1)MD5
MD5(RFC 1321)是 Rivest 于1991年对MD4的改进版本。最多能表示 2^128 个数据(21282128),它对输入仍以512位分组,其输出是4个32位字的级联(128位),与 MD4 相同。MD5比MD4来得复杂,并且速度较之要慢一点,但更安全,在抗分析和抗差分方面表现更好,但MD5 已被证明不具备”强抗碰撞性”。MD5在数年前就已经不被推荐作为应用中的散列算法方案,取代它的是SHA家族算法

MD5("version1") = "966634ebf2fc135707d6753692bf4b1e";
MD5("version2") = "2e0e95285f08a07dea17e7ee111b21c8";

(2)SHA-1及其他
SHA (Secure Hash Algorithm)是一个 Hash 函数族,也叫作安全散列算法(较MD5),SHA-1 在 1995 年面世,它的输出为长度 160 位的 hash 值,因此抗穷举(brute-force)性更好。安全散列算法与MD5算法本质上的算法是类似的,但安全性要领先很多——这种领先型更多的表现在碰撞攻击的时间开销更大,当然相对应的计算时间也会慢一点。SHA-1 设计时基于和 MD4 相同原理,并且模仿了该算法。SHA-1 已被证明不具”强抗碰撞性”。为了提高安全性,NIST 还设计出了 SHA-224、SHA-256、SHA-384,和 SHA-512 算法(统称为 SHA-2),跟 SHA-1 算法原理类似。
CWI和Google的研究人员们成功找到了一例SHA1碰撞,而且很厉害的是,发生碰撞的是两个真实的、可阅读的PDF文件。所以,对于一些大的商业机构来说, MD5 和 SHA1 已经不够安全,推荐至少使用 SHA2-256 算法。

  • MD5和SHA,最重要的两条性质,就是不可逆和无冲突。所谓不可逆,就是当你知道x的HASH值,无法求出x;所谓无冲突,就是当你知道x,无法求出一个y, 使x与y的HASH值相同。这两条性质在数学上都是不成立的。因为一个函数必然可逆,且由于HASH函数的值域有限,理论上会有无穷多个不同的原始值,它们的hash值都相同。MD5和SHA做到的,是求逆和求冲突在计算上不可能,也就是正向计算很容易,而反向计算即使穷尽人类所有的计算资源都做不到

一致性哈希算法

consistent hashing是一种特殊的哈希算法,目的是解决分布式缓存的问题。 在移除或者添加一个服务器时,能够尽可能小地改变已存在的服务请求与处理请求服务器之间的映射关系。一致性哈希解决了简单哈希算法在分布式哈希表( Distributed Hash Table,DHT) 中存在的动态伸缩等问题。
一致性哈希算法将整个哈希值空间映射成一个虚拟的圆环,整个哈希空间的取值范围为0~2^32-1。整个空间按顺时针方向组织
0~2^32-1在零点中方向重合。接下来使用如下算法对服务请求进行映射,将服务请求使用哈希算法算出对应的hash值,然后根据hash值的位置沿圆环顺时针查找,第一台遇到的服务器就是所对应的处理请求服务器。当增加一台新的服务器,受影响的数据仅仅是新添加的服务器到其环空间中前一台的服务器(也就是顺着逆时针方向遇到的第一台服务器)之间的数据,其他都不会受到影响。综上所述,一致性哈希算法对于节点的增减都只需重定位环空间中的一小部分数据,具有较好的容错性和可扩展性

举个栗子:
有一批信息需要缓存,目前有3台服务器。我们希望这批信息能均匀的缓存到这三台服务器,以减轻数据库压力。如果我们没有任何规律的将这些信息存放入三台服务器,那么我们在查找这些信息的时候就会遍历所有的这三台服务器,遍历的效率太低,时间太长,也就失去了缓存的意义(缓存的目的就是提高速度,改善用户体验,减轻后端服务器压力,如果每次访问一个缓存项都需要遍历所有缓存服务器的所有缓存项,想想就觉得很累)。那我们可以怎么做呢?

原始的做法是对缓存项的键进行哈希,将hash后的结果对缓存服务器的数量进行取模操作,
通过取模后的结果,决定缓存项将会缓存在哪一台服务器上.
例如:hash(信息名)% 3=1则放入第一台机器

同理,那么,当我们访问任意一个信息的时候,只要再次对信息名称进行上述运算,即可得出对应的信息应该存放在哪一台缓存服务器上,我们只要在这一台服务器上查找信息即可,如果信息在对应的服务器上不存在,则证明对应的信息没有被缓存,也不用再去遍历其他缓存服务器了,通过这样的方法,即可将信息随机的分布到3台缓存服务器上了,而且下次访问某个信息时,直接能够判断出该信息应该存在于哪台缓存服务器上,这样就能满足我们的需求了,我们暂时称上述算法为HASH算法或者取模算法。

但是,使用上述HASH算法进行缓存时,会出现一些缺陷,试想一下,如果3台缓存服务器已经不能满足我们的缓存需求,那么我们应该怎么做呢?没错,很简单,多增加两台缓存服务器不就行了,假设,我们增加了一台缓存服务器,那么缓存服务器的数量就由3台变成了4台,此时,如果仍然使用上述方法对同一信息进行缓存,那么这个信息所在的服务器编号必定与原来3台服务器时所在的服务器编号不同,因为除数由3变为了4,被除数不变的情况下,余数肯定不同,这种情况带来的结果就是当服务器数量变动时,所有缓存的位置都要发生改变,换句话说,当服务器数量发生改变时,所有缓存在一定时间内是失效的,当应用无法从缓存中获取数据时,则会向后端服务器请求数据,同理,假设3台缓存中突然有一台缓存服务器出现了故障,无法进行缓存,那么我们则需要将故障机器移除,但是如果移除了一台缓存服务器,那么缓存服务器数量从3台变为2台,如果想要访问其中之一,这条信息的缓存位置必定会发生改变,以前缓存的信息也会失去缓存的作用与意义,由于大量缓存在同一时间失效,造成了缓存的雪崩,此时前端缓存已经无法起到承担部分压力的作用,后端服务器将会承受巨大的压力,整个系统很有可能被压垮,所以,我们应该想办法不让这种情况发生,但是由于上述HASH算法本身的缘故,使用取模法进行缓存时,这种情况是无法避免的,为了解决这些问题,一致性哈希算法诞生了。

问题1:当缓存服务器数量发生变化时,会引起缓存的雪崩,可能会引起整体系统压力过大而崩溃
     (大量缓存同一时间失效)。

问题2:当缓存服务器数量发生变化时,几乎所有缓存的位置都会发生改变,
	  怎样才能尽量减少受影响的缓存呢?
  1. 一致性哈希算法也是使用取模的方法,只是,刚才描述的取模法是对服务器的数量进行取模,而一致性哈希算法是对2^32取模。
    我们把二的三十二次方想象成一个圆,就像钟表一样,钟表的圆可以理解成由60个点组成的圆,而此处我们把这个圆想象成由2^32个点组成的圆。
    在这里插入图片描述
    圆环的正上方的点代表0,0点右侧的第一个点代表1,以此类推,2、3、4、5、6……直到232-1,也就是说0点左侧的第一个点代表232-1 ,我们把这个由2的32次方个点组成的圆环称为hash环。

那么,一致性哈希算法与上图中的圆环有什么关系呢?我们继续聊,仍然以之前描述的场景为例,假设我们有3台缓存服务器,服务器A、服务器B、服务器C,那么,在生产环境中,这三台服务器肯定有自己的IP地址,我们使用它们各自的IP地址进行哈希计算,使用哈希后的结果对2^32取模,可以使用如下公式示意。

hash(服务器A的IP地址) %  2^32

通过上述公式算出的结果一定是一个0到232-1之间的一个整数,我们就用算出的这个整数,代表服务器A,既然这个整数肯定处于0到232-1之间,那么,上图中的hash环上必定有一个点与这个整数对应,而我们刚才已经说明,使用这个整数代表服务器A,那么,服务器A就可以映射到这个环上,用下图示意
在这里插入图片描述
同理,服务器B与服务器C也可以通过相同的方法映射到上图中的hash环中

hash(服务器B的IP地址) %  2^32
hash(服务器C的IP地址) %  2^32

在这里插入图片描述
2.好了,到目前为止,我们已经把缓存服务器与hash环联系在了一起,我们通过上述方法,把缓存服务器映射到了hash环上,那么使用同样的方法,我们也可以将需要缓存的对象映射到hash环上。

假设,我们需要使用缓存服务器缓存消息,而且我们仍然使用消息的名称作为找到消息的key,那么我们使用如下公式可以将图片映射到上图中的hash环上。

hash(消息名) %  2^32

映射后的示意图如下,下图中的橘黄色圆形表示消息本身:
在这里插入图片描述
一致性哈希算法就是通过这种方法,判断一个对象应该被缓存到哪台服务器上的,将缓存服务器与被缓存对象都映射到hash环上以后,从被缓存对象的位置出发,沿顺时针方向遇到的第一个服务器,就是当前对象将要缓存于的服务器,由于被缓存对象与服务器hash后的值是固定的,所以,在服务器不变的情况下,一条信息必定会被缓存到固定的服务器上,那么,当下次想要访问这条信息时,只要再次使用相同的算法进行计算,即可算出该信息被缓存在哪个服务器上,直接去对应的服务器查找对应的信息即可。
3. 那么一致性hash可以解决上述提出的问题吗?

问题1:当缓存服务器数量发生变化时,会引起缓存的雪崩,可能会引起整体系统压力过大而崩溃
     (大量缓存同一时间失效)。

问题2:当缓存服务器数量发生变化时,几乎所有缓存的位置都会发生改变,
	  怎样才能尽量减少受影响的缓存呢?

假设,服务器B出现了故障,我们现在需要将服务器B移除,那么,我们将上图中的服务器B从hash环上移除即可,移除服务器B以后示意图如下。
在这里插入图片描述
在服务器B未移除时,信息3应该被缓存到服务器B中,可是当服务器B移除以后,按照之前描述的一致性哈希算法的规则,信息3应该被缓存到服务器C中,因为从信息3的位置出发,沿顺时针方向遇到的第一个缓存服务器节点就是服务器C,也就是说,如果服务器B出现故障被移除时,信息3的缓存位置会发生改变
在这里插入图片描述
但是,信息4仍然会被缓存到服务器C中,信息1与信息2仍然会被缓存到服务器A中,这与服务器B移除之前并没有任何区别,这就是一致性哈希算法的优点,如果使用之前的hash算法,服务器数量发生改变时,所有服务器的所有缓存在同一时间失效了,而使用一致性哈希算法时,服务器的数量如果发生改变,并不是所有缓存都会失效,而是只有部分缓存会失效,前端的缓存仍然能分担整个系统的压力,而不至于所有压力都在同一时间集中到后端服务器上。(不会雪崩)
4. 虚拟节点:在介绍一致性哈希的概念时,我们理想化的将3台服务器均匀的映射到了hash环上。然后我还加了特别大的几个字:这是特别理想的情况下。所以,理想很丰满,现实…
在这里插入图片描述
聪明如你一定想到了,如果服务器被映射成上图中的模样,那么被缓存的对象很有可能大部分集中缓存在某一台服务器上,如下图所示。
在这里插入图片描述
,如果出现上图中的情况,A、B、C三台服务器并没有被合理的平均的充分利用,缓存分布的极度不均匀,而且,如果此时服务器A出现故障,那么失效缓存的数量也将达到最大值,在极端情况下,仍然有可能引起系统的崩溃,上图中的情况则被称之为hash环的偏斜。

为了解决hash环的倾斜问题,我们引入了虚拟节点这个概念
如果想要均衡的将缓存分布到3台服务器上,最好能让这3台服务器尽量多的、均匀的出现在hash
环上,但是,真实的服务器资源只有3台,我们怎样凭空的让它们多起来呢,没错,就是凭空的让
服务器节点多起来,既然没有多余的真正的物理服务器节点,我们就只能将现有的物理节点通过
虚拟的方法复制出来,这些由实际节点虚拟复制而来的节点被称为"虚拟节点"。

"虚拟节点"是"实际节点"(实际的物理服务器)在hash环上的复制品,
											一个实际节点可以对应多个虚拟节点。

在这里插入图片描述
加入虚拟节点之后,如上图:A缓存5和4、 B缓存6和2 、C缓存3和1,是不是就均衡多了?
注意:真实节点不放置到哈希环上,只有虚拟节点才会放上去。当真实节点服务器挂了之后,它对应的所有虚拟节点其数据并没有全部分配给某一个节点,而是被分到了多个节点。

哈希槽算法

Redis 集群没有使用一致性hash, 而是引入了哈希槽的概念。(一致性哈希算法对于数据分布、节点位置的控制并不是很友好。)
首先哈希槽其实是两个概念,第一个是哈希算法。redis cluster 的 hash 算法不是简单的 hash(),而是 crc16 算法,一种校验算法。另外一个就是槽位的概念,空间分配的规则。其实哈希槽的本质和一致性哈希算法非常相似,不同点就是对于哈希空间的定义。一致性哈希的空间是一个圆环,节点分布是基于圆环的,无法很好的控制数据分布。而 redis cluster 的槽位空间是自定义分配的,类似于 windows 盘分区的概念。这种分区是可以自定义大小,自定义位置的。
Redis 集群中内置了 16384 (2^14)个哈希槽,当需要在 Redis 集群中放置一个 key-value时,redis 先对 key 使用 crc16 算法(CRC-16(key)%16384)算出一个结果,然后把结果对 16384 求余数,这样每个 key 都会对应一个编号在 0-16383 之间的哈希槽,redis 会根据节点数量大致均等的将哈希槽映射到不同的节点。这些槽点由用户自己定义分配。例如机器硬盘小的,可以分配少一点槽位,硬盘大的可以分配多一点。如果节点硬盘都差不多则可以平均分配。所以哈希槽这种概念很好地解决了一致性哈希的弊端。对于槽位的转移和分派,redis 集群是不会自动进行的,而是需要人工配置的。所以 redis 集群的高可用是依赖于节点的主从复制与主从间的自动故障转移。(存储在Redis Cluster中的所有键都会被映射到这些slot中,redis cluster执行读写操作的都是master节点)

每一个哈希槽中存的key 和 value是什么?
当你往Redis Cluster中加入一个Key时,会根据crc16(key) mod 16384计算这个key应该分布到哪个hash slot中,一个hash slot中会有很多key和value。你可以理解成表的分区,使用单节点时的redis时只有一个表,所有的key都放在这个表里;改用Redis Cluster以后会自动为你生成16384个分区表,你insert数据时会根据上面的简单算法来决定你的key应该存在哪个分区,每个分区里有很多key。

使用哈希槽的好处就在于可以方便的添加或移除节点。(比如我现在有ABC三个节点):
如果我想新添加个节点D, 我需要从节点 A, B, C中得部分槽到D上. 如果我像移除节点A,
需要将A中得槽移到B和C节点上,然后将没有任何槽的A节点从集群中移除即可. 由于从一个节
点将哈希槽移动到另一个节点并不会停止服务,所以无论添加删除或者改变某个节点的哈希槽
的数量都不会造成集群不可用的状态.

==哈希槽与一致性哈希区别 ==

  1. 哈希槽key的定位规则是根据CRC-16(key)%16384的值来判断属于哪个槽区,从而判断该key属于哪个节点,而一致性哈希是根据hash(key)的值来顺时针找第一个hash(ip)的节点,从而确定key存储在哪个节点。
  2. 一致性哈希是创建虚拟节点来实现节点宕机后的数据转移并保证数据的安全性和集群的可用性的。redis cluster是采用master节点有多个slave节点机制来保证数据的完整性的,master节点写入数据,slave节点同步数据。当master节点挂机后,slave节点会通过选举机制选举出一个节点变成master节点,实现高可用。但是这里有一点需要考虑,如果master节点存在热点缓存,某一个时刻某个key的访问急剧增高,这时该mater节点可能操劳过度而死,随后从节点选举为主节点后,同样宕机,以此类推,造成缓存雪崩
  3. 在容错性和扩展性上,表象与一致性哈希一样,都是对受影响的数据进行转移。而哈希槽本质上是对槽位的转移,把故障节点负责的槽位转移到其他正常的节点上。扩展节点也是一样,把其他节点上的槽位转移到新的节点上

对称加密算法、公私钥

现代密码分为对称(私钥)与非对称(公钥)密码
在这里插入图片描述

  1. 对称加密简介:
    (Symmetric Cipher),也叫私钥加密,指加密和解密使用相同密钥的加密算法。有时又叫传统密码算法,就是加密密钥能够从解密密钥中推算出来,同时解密密钥也可以从加密密钥中推算出来。而在大多数的对称算法中,加密密钥和解密密钥是相同的,所以也称这种加密算法为秘密密钥算法或单密钥算法。它要求发送方和接收方在安全通信之前,商定一个密钥。对称算法的安全性依赖于密钥,泄漏密钥就意味着任何人都可以对他们发送或接收的消息解密,所以密钥的保密性对通信性至关重要。
    在这里插入图片描述
在对称加密算法中,数据发信方将明文(原始数据)和
            (加密密钥)一起经过特殊加密算法处理后,使其变成复杂的加密密文发送出去

特点:
对称加密算法的特点是算法公开、计算量小、加密速度快、加密效率高。
不足之处是,交易双方都使用同样钥匙,安全性得不到保证。此外,每对用户每次使用对称加密算法时,都需要使用其他人不知道的惟一钥匙,这会使得发收信双方所拥有的钥匙数量呈几何级数增长,密钥管理成为用户的负担。对称加密算法在分布式网络系统上使用较为困难,主要是因为密钥管理困难,使用成本较高。而与公开密钥加密算法比起来,对称加密算法能够提供加密和认证却缺乏了签名功能,使得使用范围有所缩小。在计算机专网系统中广泛使用的对称加密算法有DES和IDEA等。美国国家标准局倡导的AES即将作为新标准取代DES。

对称加密算法有DES、DESede、AES、Blowfish,以及RC2和RC4算法,还有其他第三方提供的软件包提供的Bouncy Castle 提供的IDEA算法
这里面DES算是最经典的算法,DESede是DES算法的变种,AES算是DES算法的替代者;
在这里插入图片描述
java 支持DES算法,Java提供的仅支持56位密匙长度,作为补充Bouncy Castle提供64位的长度支持,再吃基础上配合不同的填充方式用来显著提供系统的安全性。如下:
在这里插入图片描述

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.DESKeySpec;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;

public class DESTest {

    /**
     * 密匙算法
     */
    private static final String KEY_ALGORITHM = "DES";
    /**
     * 加密/解密算法  工作模式  填充方式
     */
    private static final String CIPHER_ALGORITHM ="DES/ECB/PKCS5Padding";

    //测试
    public static void main(String[] args) throws Exception {
        String str = "hello vison";
        byte[] key = initKey();
        byte[] encrypt = encrypt(str.getBytes(), key);
        System.out.println("encrypt data: "+ Base64.getEncoder().encode(encrypt));
        byte[] decrypt = decrypt(encrypt, key);
        System.out.println("decrypt data: "+ new String(decrypt));
    }

    /**
     * 解密
     * @param data 待解密数据
     * @param key 密匙
     * @return byte[] 解密数据
     * @throws Exception
     */
    public static byte[] decrypt(byte[] data ,byte[] key) throws Exception{
        Key k = toKey(key);
        Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
        cipher.init(Cipher.DECRYPT_MODE,k);
        return  cipher.doFinal(data);

    }

    /**
     * 加密
     * @param data 待加密数据
     * @param key  密匙
     * @return byte[] 加密数据
     * @throws Exception
     */
    public static byte[] encrypt(byte[] data,byte[] key) throws Exception{
        //还原密匙
        Key k = toKey(key);
        //实例化
        Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
        //初始化,设置为加密模式
        cipher.init(Cipher.ENCRYPT_MODE,k);
        //执行加密操作
        return cipher.doFinal(data);
    }

    /**
     * 转换密匙
     * @param key
     * @return
     * @throws Exception
     */
    private static Key toKey(byte[] key) throws Exception {
        //实例化DES密匙材料
        DESKeySpec desKeySpec = new DESKeySpec(key);
        //实例化密匙工厂
        SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(KEY_ALGORITHM);
        //生成密匙
        SecretKey secretKey = secretKeyFactory.generateSecret(desKeySpec);
        return secretKey;
    }

    /**
     * 生成密匙
     * Java支持56位密匙
     * Bouncy Castle支持64位密匙
     * @return
     * @throws NoSuchAlgorithmException
     */
    public static byte[] initKey() throws NoSuchAlgorithmException {
        //实例化密匙生成器
        KeyGenerator keyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM);
        //初始化密匙生成器,DES长度是56位
        // 也可以这样生成默认的长度 keyGenerator.init(new SecureRandom());
        keyGenerator.init(56);
        //生成密匙
        SecretKey secretKey = keyGenerator.generateKey();
        //获取密匙的二进制编码形式
        return  secretKey.getEncoded();

    }
}

  1. 非对称加密:
    非对称加密算法实现机密信息交换的基本过程是:甲方生成一对密钥并将其中的一把作为公用密钥向其它方公开;得到该公用密钥的乙方使用该密钥对机密信息进行加密后再发送给甲方;甲方再用自己保存的另一把专用密钥对加密后的信息进行解密。甲方只能用其专用密钥解密由其公用密钥加密后的任何信息。发送密文的一方要使用对方的公开密钥进行加密,对方收到信息之后,使用自己的私有密钥进行解密,这种方式不需要传输用来解密的私钥了,也就不必担心私钥被截获
    非对称加密算法:RSA,DSA/DSS
    在这里插入图片描述

在这里插入图片描述

如上图所示,客户端用公钥对请求内容加密,服务器使用私钥对内容解密,
反之亦然,但上述过程也存在缺点: 公钥是公开的(也就是黑客也会有公钥
所以第 ④ 步私钥加密的信息,如果被黑客截获,其可以使用公钥进行解密,获取其中的内容。
  1. 对称加密和非对称加密的结合
    1、HTTP 协议(HyperText Transfer Protocol,超文本传输协议):是客户端浏览器或其他程序与Web服务器之间的应用层通信协议 。
    2、HTTPS 协议(HyperText Transfer Protocol over Secure Socket Layer):可以理解为HTTP+SSL/TLS, 即 HTTP 下加入 SSL 层,HTTPS 的安全基础是 SSL,因此加密的详细内容就需要 SSL,用于安全的 HTTP 数据传输。 如果整个网站都是走HTTPS的,那服务器返回的内容都是被加密的,你能看到网页内容是因为浏览器已经解密了
    在这里插入图片描述

为什么需要加密?
因为http的内容是明文传输的,明文数据会经过中间代理服务器、路由器、wifi热点、通信服务运营商等多个物理节点,如果信息在传输过程中被劫持,传输的内容就完全暴露了,他还可以篡改传输的信息且不被双方察觉,这就是中间人攻击。所以我们才需要对信息进行加密。最简单容易理解的就是对称加密 。

什么是对称加密?
就是有一个密钥,它可以对一段内容加密,加密后只能用它才能解密看到原本的内容,和我们日常生活中用的钥匙作用差不多。

用对称加密可行吗?
如果通信双方都各自持有同一个密钥,且没有别人知道,这两方的通信安全当然是可以被保证的(除非密钥被破解)。
然而最大的问题就是这个密钥怎么让传输的双方知晓,同时不被别人知道。如果由服务器生成一个密钥并传输给浏览器,那这个传输过程中密钥被别人劫持弄到手了怎么办?之后他就能用密钥解开双方传输的任何内容了,所以这么做当然不行。
换种思路?试想一下,如果浏览器内部就预存了网站A的密钥,且可以确保除了浏览器和网站A,不会有任何外人知道该密钥,那理论上用对称加密是可以的,这样浏览器只要预存好世界上所有HTTPS网站的密钥就行啦!这么做显然不现实。
怎么办?所以我们就需要神奇的非对称加密

什么是非对称加密?
有两把密钥,通常一把叫做公钥、一把叫做私钥,用公钥加密的内容必须用私钥才能解开,同样,私钥加密的内容只有公钥能解开。
用非对称加密可行吗?
鉴于非对称加密的机制,我们可能会有这种思路:服务器先把公钥直接明文传输给浏览器,之后浏览器向服务器传数据前都先用这个公钥加密好再传,这条数据的安全似乎可以保障了!因为只有服务器有相应的私钥能解开这条数据。
然而由服务器到浏览器的这条路怎么保障安全?如果服务器用它的的私钥加密数据传给浏览器,那么浏览器用公钥可以解密它,而这个公钥是一开始通过明文传输给浏览器的,这个公钥被谁劫持到的话,他也能用该公钥解密服务器传来的信息了。所以目前似乎只能保证由浏览器向服务器传输数据时的安全性(其实仍有漏洞,下文会说),那利用这点你能想到什么解决方案吗?

改良的非对称加密方案,似乎可以?
我们已经理解通过一组公钥私钥,已经可以保证单个方向传输的安全性,那用两组公钥私钥,是不是就能保证双向传输都安全了?请看下面的过程:

某网站拥有用于非对称加密的公钥A、私钥A’;浏览器拥有用于非对称加密的公钥B、私钥B’。
浏览器像网站服务器请求,服务器把公钥A明文给传输浏览器。
浏览器把公钥B明文传输给服务器。
之后浏览器向服务器传输的所有东西都用公钥A加密,服务器收到后用私钥A’解密。由于只有服务器拥有这个私钥A’可以解密,所以能保证这条数据的安全。
服务器向浏览器传输的所有东西都用公钥B加密,浏览器收到后用私钥B’解密。同上也可以保证这条数据的安全。
的确可以!抛开这里面仍有的漏洞不谈(下文会讲),HTTPS的加密却没使用这种方案,为什么?最主要的原因是非对称加密算法非常耗时,特别是加密解密一些较大数据的时候有些力不从心,而对称加密快很多,看来必须得用对称加密,那我们能不能运用非对称加密的特性解决前面提到的对称加密的问题?

非对称加密+对称加密?
既然非对称加密耗时,非对称加密+对称加密结合可以吗?而且得尽量减少非对称加密的次数。当然是可以的,而且非对称加密、解密各只需用一次即可。
请看一下这个过程:

某网站拥有用于非对称加密的公钥A、私钥A’。
浏览器像网站服务器请求,服务器把公钥A明文给传输浏览器。
浏览器随机生成一个用于对称加密的密钥X,用公钥A加密后传给服务器。
服务器拿到后用私钥A’解密得到密钥X。
这样双方就都拥有密钥X了,且别人无法知道它。之后双方所有数据都用密钥X加密解密。
完美!HTTPS基本就是采用了这种方案。完美?还是有漏洞的。

中间人攻击
中间人的确无法得到浏览器生成的密钥B,这个密钥本身被公钥A加密了,只有服务器才有私钥A’解开拿到它呀!然而中间人却完全不需要拿到密钥A’就能干坏事了。请看:

某网站拥有用于非对称加密的公钥A、私钥A’。
浏览器向网站服务器请求,服务器把公钥A明文给传输浏览器。
中间人劫持到公钥A,保存下来,把数据包中的公钥A替换成自己伪造的公钥B(它当然也拥有公钥B对应的私钥B’)。
浏览器随机生成一个用于对称加密的密钥X,用公钥B(浏览器不知道公钥被替换了)加密后传给服务器。
中间人劫持后用私钥B’解密得到密钥X,再用公钥A加密后传给服务器。
服务器拿到后用私钥A’解密得到密钥X。
这样在双方都不会发现异常的情况下,中间人得到了密钥B。根本原因是浏览器无法确认自己收到的公钥是不是网站自己的。那么下一步就是解决下面这个问题:

如何证明浏览器收到的公钥一定是该网站的公钥?
现实生活中,如果想证明某身份证号一定是小明的,怎么办?看身份证。这里政府机构起到了“公信”的作用,身份证是由它颁发的,它本身的权威可以对一个人的身份信息作出证明。互联网中能不能搞这么个公信机构呢?给网站颁发一个“身份证”?

数字证书
网站在使用HTTPS前,需要向“CA机构”申请颁发一份数字证书,数字证书里有证书持有者、证书持有者的公钥等信息,服务器把证书传输给浏览器,浏览器从证书里取公钥就行了,证书就如身份证一样,可以证明“该公钥对应该网站”。然而这里又有一个显而易见的问题了,证书本身的传输过程中,如何防止被篡改?即如何证明证书本身的真实性?身份证有一些防伪技术,数字证书怎么防伪呢?解决这个问题我们就基本接近胜利了!

如何放防止数字证书被篡改?
我们把证书内容生成一份“签名”,比对证书内容和签名是否一致就能察觉是否被篡改。这种技术就叫数字签名:

数字签名
这部分内容建议看下图并结合后面的文字理解,图中左侧是数字签名的制作过程,右侧是验证过程
在这里插入图片描述
数字签名的制作过程:

CA拥有非对称加密的私钥和公钥。
CA对证书明文信息进行hash。
对hash后的值用私钥加密,得到数字签名。
明文和数字签名共同组成了数字证书,这样一份数字证书就可以颁发给网站了。
那浏览器拿到服务器传来的数字证书后,如何验证它是不是真的?(有没有被篡改、掉包)

浏览器验证过程:

拿到证书,得到明文T,数字签名S。
用CA机构的公钥对S解密(由于是浏览器信任的机构,所以浏览器保有它的公钥。详情见下文),得到S’。
用证书里说明的hash算法对明文T进行hash得到T’。
比较S’是否等于T’,等于则表明证书可信。
为什么这样可以证明证书可信呢?我们来仔细想一下。

中间人有可能篡改该证书吗?
假设中间人篡改了证书的原文,由于他没有CA机构的私钥,所以无法得到此时加密后签名,无法相应地篡改签名。浏览器收到该证书后会发现原文和签名解密后的值不一致,则说明证书已被篡改,证书不可信,从而终止向服务器传输信息,防止信息泄露给中间人。
既然不可能篡改,那整个证书被掉包呢?

中间人有可能把证书掉包吗?
假设有另一个网站B也拿到了CA机构认证的证书,它想搞垮网站A,想劫持网站A的信息。于是它成为中间人拦截到了A传给浏览器的证书,然后替换成自己的证书,传给浏览器,之后浏览器就会错误地拿到B的证书里的公钥了,会导致上文提到的漏洞。
其实这并不会发生,因为证书里包含了网站A的信息,包括域名,浏览器把证书里的域名与自己请求的域名比对一下就知道有没有被掉包了。

为什么制作数字签名时需要hash一次?
我初学HTTPS的时候就有这个问题,似乎以上过程中hash有点多余,把hash过程去掉也能保证证书没有被篡改。
最显然的是性能问题,前面我们已经说了非对称加密效率较差,证书信息一般较长,比较耗时。而hash后得到的是固定长度的信息(比如用md5算法hash后可以得到固定的128位的值),这样加密解密就会快很多。

怎么证明CA机构的公钥是可信的?
你们可能会发现上文中说到CA机构的公钥,我几乎一笔带过,“浏览器保有它的公钥”,这是个什么保有法?怎么证明这个公钥是否可信?
让我们回想一下数字证书到底是干啥的?没错,为了证明某公钥是可信的,即“该公钥是否对应该网站/机构等”,那这个CA机构的公钥是不是也可以用数字证书来证明?没错,操作系统、浏览器本身会预装一些它们信任的根证书,如果其中有该CA机构的根证书,那就可以拿到它对应的可信公钥了。
实际上证书之间的认证也可以不止一层,可以A信任B,B信任C,以此类推,我们把它叫做信任链或数字证书链,也就是一连串的数字证书,由根证书为起点,透过层层信任,使终端实体证书的持有者可以获得转授的信任,以证明身份。
另外,不知你们是否遇到过网站访问不了、提示要安装证书的情况?这里安装的就是跟证书。说明浏览器不认给这个网站颁发证书的机构,那么没有该机构的根证书,你就得手动下载安装(风险自己承担XD)。安装该机构的根证书后,你就有了它的公钥,就可以用它验证服务器发来的证书是否可信了。

HTTPS必须在每次请求中都要先在SSL/TLS层进行握手传输密钥吗?
这也是我当时的困惑之一,显然每次请求都经历一次密钥传输过程非常耗时,那怎么达到只传输一次呢?用session就行。
服务器会为每个浏览器(或客户端软件)维护一个session ID,在TSL握手阶段传给浏览器,浏览器生成好密钥传给服务器后,服务器会把该密钥存到相应的session ID下,之后浏览器每次请求都会携带session ID,服务器会根据session ID找到相应的密钥并进行解密加密操作,这样就不必要每次重新制作、传输密钥了!
在这里插入图片描述
HTTPS简单大致过程:
1 你访问HTTPS网站,网站把公钥给你。
2你验证公钥,然后生成一个串随机AES_128密码(假如是用AES加密),并把这个密码用刚才那个公钥加密,发给服务端。
3服务端用私钥解密你的发送的数据,得到你随机生成的AES_128密码,并把网页内容全部用AES_128加密器起来,发会给你。
5浏览器用刚刚的AES_128密码解密 服务器返回的数据,得到你可读的内容。
6 之后你发出的请求数据,也是用AES_128密码来加密。
浏览器返回的网站内容都是用对称加密(比如AES128)加密起来的,而这个密码又是你临时生成了,所以第三方无法知道你访问的内容是什么。

1、对称加密

有流式、分组两种,加密和解密都是使用的同一个密钥。

例如:DES、AES-GCM、ChaCha20-Poly1305等

2、非对称加密

加密使用的密钥和解密使用的密钥是不相同的,分别称为:公钥、私钥,公钥和算法都是公开的,私钥是保密的。非对称加密算法性能较低,但是安全性超强,由于其加密特性,非对称加密算法能加密的数据长度也是有限的。

例如:RSA、DSA、ECDSA、 DH、ECDHE

3、哈希算法

将任意长度的信息转换为较短的固定长度的值,通常其长度要比信息小得多,且算法不可逆。

例如:MD5、SHA-1、SHA-2、SHA-256 等

4、数字签名

签名就是在信息的后面再加上一段内容(信息经过hash后的值),可以证明信息没有被修改过。hash值一般都会加密后(也就是签名)再和信息一起发送,以保证这个hash值不被修改。
  • 2
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值