补充大话数据结构的没讲到的一些知识点(如动态规划等)


计数排序(Counting Sort)

​  计数排序(Counting Sort)是一种 稳定的 线性时间(即 O(n) )的排序算法。它是一个 非基于比较 的排序算法,元素从未排序状态变为已排序状态的过程,是由额外空间的辅助和元素本身的值决定的。
​  该算法于1954年由 Harold H. Seward 提出。它的优势在于在对一定范围内的整数排序时,它的复杂度为 Ο(n+k) (其中k是整数的范围),快于任何比较排序算法。当然这是 一种牺牲空间换取时间 的做法,而且当 O(k)>O(n*log(n)) 的时候其效率反而不如其它基于比较的排序,因为基于比较的排序的时间复杂度在理论上的下限是 O(n*log(n)) ,比如堆排序、归并排序等。

基本思想

  1. 计数排序使用一个额外的数组 B 进行辅助,而数组 B 中下标 i 中的值是待排序数组 A 中值等于 i 的元素的个数
  2. 计数排序的核心在于将输入的数据值转化为键,存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求待排序的序列中的数据必须是有确定范围的整数
  3. 用来计数的数组 B 的长度取决于待排序数组中数据的大小范围,然后进行分配、收集处理
    分配:扫描一遍原始数组,以 当前值-minValue 作为下标,将该下标的计数器增1(即将数组 B 中该下标的值递增)。
    收集:扫描一遍计数器数组,按顺序把值收集起来。

实现逻辑

  1. 找出待排序的数组中最大和最小的元素;
  2. 统计数组中每个值为 i 的元素出现的次数,存入数组 C 的第 i 项;
  3. 对所有的计数累加(从 C 中的第一个元素开始,每一项和前一项相加);
  4. 反向填充目标数组:将每个元素i放在新数组的第 C(i) 项,每放一个元素就将 C(i) 减去1。

动图演示

计数排序动图演示

复杂度分析

时间复杂度

平均时间复杂度:O(n + k)
最佳时间复杂度:O(n + k)
最差时间复杂度:O(n + k)

当输入的元素是 n 个 0~k 之间的整数时,它的时间复杂度是 O(n+k) ,即遍历一遍长度为 n 的输入数组,遍历一遍长度为 k+1 的辅助数组。在实际工作中,当 k=O(n) 时,一般会采用计数排序,这时的运行时间为 O(n),因为 O(2n) 中2被省略。

空间复杂度

空间复杂度:O(k+n)

计数排序需要两个额外的数组用来对元素进行计数和保存排序的输出结果,所以空间复杂度为 O(k+n) ,其中 k 表示用于辅助计数的数组长度, n 表示用于保存排序结果的数组长度。

稳定性

稳定性:稳定

计数排序的一个重要性质是它是稳定的:具有相同值的元素在输出数组中的相对次序与它们在输入数组中的相对次序是相同的。也就是说,对两个相同的数来说,在输入数组中先出现的数,在输出数组中也位于前面。

计数排序的稳定性很重要的一个原因是:计数排序经常会被用于基数排序算法的一个子过程。为了保证使用计数排序的基数排序能够正确运行,计数排序必须是稳定的。

代码实现

// arr 表示输入数组, count 表示输入数组的元素个数, max 表示输入数组中元素的最大值
int * countingSort(int arr[], int count, int max) {
	int index = 0;	// 表示 result 的下标
	int *tmpArr = new int[max+1];	// 临时数组用于计数
	int *result = new int[count];	// 保存排序结果的数组

	// 初始化计数数组中的值为0
	for(int i = 0; i < max+1; i++)
		tmpArr[i]=0;
	// 扫描一遍输入数组并计数
	for(int i = 0; i < count; i++){
		tmpArr[arr[i]]++;	// 计数数组的下标表示输入数组的值,而其中存储的值表示输入数组中该值的个数
	}
	// 再次扫描一遍计数数组,按照计数结果对输入数组中的元素进行排序并保存结果
	for(int i = 0; i < max+1; i++){
		while(tmpArr[i]){
			result[index++] = i;
			tmpArr[i]--;
		}
	}

    return result;
}

优化改进

对于上面代码还有可以优化的地方,当输入数组的最小值并不是0且范围过大时,辅助计数的数组空间就会变得很大,但是输入数组的长度却不一定会很大,所以可以通过减小辅助计数的数组空间来优化代码。

int * countingSort(int arr[], int count) {
        int *result = new int[count];	//保存排序结果的数组
        int max = arr[0], min = arr[0];
        for(int i = 0; i < count; i++){
            if(arr[i] > max){
                max = arr[i];
            }
            if(arr[i] < min){
                min = arr[i];
            }
        }
        
        // 优化的地方,减小了辅助计数数组的大小
        int k = max - min + 1;		// k 的大小是待排序的输入数组中的元素大小的极值差+1
        int *tmpArr = new int[k];	// 辅助计数的数组
        // 扫描输入数组并计数
        for(int i = 0; i < count; ++i){
            tmpArr[arr[i]-min] += 1;// 优化过的地方,减小了数组c的大小
        }
        for(int i = 1; i < k; ++i){
            tmpArr[i] = tmpArr[i] + tmpArr[i-1];
        }
        for(int i = count-1; i >= 0; --i){
        	// 按存取的方式取出 arr 的元素
            result[--tmpArr[arr[i]-min]] = arr[i];
        }
        return result;
}

现在让我们从优化的地方开始重新阅读这段代码:

  1. 创建一个长度为 max-min+1 的辅助数组 tmpArr 用来计数;
  2. 扫描输入数组并计数,这里以 arr[i]-min 作为辅助数组的下标,从而实现减少其 min 的长度;
  3. 接下来的 for 循环遍历一遍辅助数组 tmpArr 并将前驱元素的值与当前元素的值相加,以获得当前元素的新值(这一步的目的在后面会说明);
  4. 按对 arr 计数的方式(即以 arr[i]-min 作为辅助数组的下标),取出输入数组 arr 的元素并排序存入 result 数组中,这里的排序顺序是从小到大。

那么如何进行排序呢?这里就要说明第三步的作用了。
​  在第三步的 for 循环遍历中,我们将得到下图中第二段的 tmpArr 数组。此时,下标中的值表示辅助数组 tmpArr 下标对应 arr 中的元素在存储结果的数组 result 中的 下标+1 ,所以当每次存储 arr 的元素到 result 中时,都要先前序递减再存放
​  因为是遍历输入数组 arr 并按对 arr 计数的方式取出 arr 的元素。所以不用担心辅助数组中其它的无关数据。
​  由于在数组中的下标都是有序的,所以其实在计数时,就已经将 arr 的元素进行了排序,第三步的目的只是为了获取 arr 元素存储到 result 的哪个下标
在这里插入图片描述

总结

  • 计数排序算法只适用于已知序列中的元素在 0~k 之间,且要求排序的复杂度在线性效率上的情况下。
  • 计数排序和基数排序很类似,都是非比较型排序算法。但是,它们的核心思想是不同的,基数排序主要是按照进制位对整数进行依次排序,而计数排序主要侧重于对有限范围内对象的统计
  • 基数排序可以采用计数排序来实现

桶排序(Bucket Sort)

​  桶排序(Bucket Sort) 或所谓的 箱排序,是一个排序算法,工作的原理是将数组分到有限数量的桶子里。每个桶子再个别排序(再使用别的排序算法或是以递归方式继续使用桶排序进行排序,最后依次把各个桶中的记录列出来来得到有序序列。
​  桶排序是鸽巢排序的一种归纳结果。当要被排序的数组内的数值是均匀分配的时候,桶排序的时间复杂度为线性时间 O(n)。但桶排序是非基于比较排序,他不受到 O(n*log(n) 下限的影响。

基本思想

桶排序的思想近乎彻底的分治思想

桶排序假设待排序的序列中的元素均匀独立地分布在一个范围中。然后

  1. 将这一范围划分成几个子范围(每个子范围代表一个桶);
  2. 然后基于某种映射函数 f ,将待排序列的关键字 k 映射到第 i 个桶中(即辅助数组 B 的下标 i ),那么该关键字 k 就作为 B[i] 中的元素(数组 B 中的每个桶 B[i] 都有一组大小为 N/M 的序列,其中 N 是待排序序列的数据个数,而 M 是桶的个数,也就是辅助数组 B 的长度)
  3. 接着将各个桶中的数据有序的合并起来:即分别对每个桶 B[i] 中的所有元素进行比较排序(比如快速排序)。然后依次枚举输出 B[0]….B[M] 中的全部内容,即生成一个有序序列。

为了使桶排序更加高效,我们需要做到这两点

1、在额外空间充足的情况下,尽量增大桶的数量,即划分更细的子范围,使得每个桶中的排序耗时更短;
2、使用的映射函数能够将输入的 N 个数据均匀的分配到 M 个桶中;

同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。

实现逻辑

  1. 设置一个定量的数组当作空桶子;
  2. 寻访序列,并且把数据一个一个放到对应的桶子去;
  3. 对每个不是空的桶子进行排序;
  4. 从不是空的桶子里把数据再放回原来的序列中。

分步骤图示说明:设有数组 array = [63, 157, 189, 51, 101, 47, 141, 121, 157, 156, 194, 117, 98, 139, 67, 133, 181, 13, 28, 109],对其进行桶排序:
在这里插入图片描述

动图演示

请添加图片描述

复杂度分析

​  对 N 个关键字进行桶排序的时间复杂度分为两个部分:
​  (1) 循环计算每个关键字的桶映射函数,这个时间复杂度是 O(N)
​  (2) 利用优秀的比较排序算法对每个桶内的所有数据进行排序,其时间复杂度为 ∑ O ( N i ∗ log ⁡ N i ) ∑O(Ni * \log Ni) O(NilogNi),。其中 Ni 为第 i 个桶的数据个数(因为基于比较排序的最好平均时间复杂度只能达到 O ( N ∗ log ⁡ N ) O(N*\log N) O(NlogN) 了)。

​  桶排序的时间复杂度,取决于对各个桶之间数据进行排序的时间复杂度,因为其它部分的时间复杂度为 O(n)。很显然,只有桶划分的越多,各个桶之间的数据越少,桶排序所用的时间才会越少。但相应的空间消耗就会增大

  1. 桶排序的最好情况下的时间复杂度为线性时间,即 O(n),即极限情况下每个桶只有一个数据时
  2. 桶排序的最差情况下的时间复杂度为 O(n^2)。即所有元素都位于同一存储桶中,且使用的排序算法(不一定是快速排序算法)的时间复杂度为最坏情况的 O(n^2)

有一个疑问:每个桶内再用其他的排序算法进行排序(比如快速排序),这样子时间复杂度不还是 O(nlogn) 吗?请看下面这段分析:
​  如果待排序的数据有 n 个,假设它们都均匀地分在 m 个桶中,这样每个桶里的数据就都是 k = n / m 。每个桶内排序的时间复杂度就为 O(k * log(k)) 。m个桶就是 m * O(k * log(k)) = m * O( (n / m) * log(n / m) ) = O( n * log(n / m) )。当桶的个数 m 接近数据个数 n 时,log(n / m)就是一个较小的常数,所以时间复杂度接近 O(n) 。

时间复杂度

最差时间复杂度:O(n ^ 2)
最佳时间复杂度:O(n)
平均时间复杂度:O(n + k)

n 是待排序的数据个数, m 是桶个数, k 是 n * (log(n/m))

空间复杂度

空间复杂度:O(n + m)

n 是待排序的元素个数, m 是桶个数。这是因为开辟了 n 个空间用于存储待排序的元素个数到桶中,即创建了 n 个链表,不是指输入数据所占的空间,那不算空间复杂度。

稳定性

稳定性:稳定

代码实现

假设数据分布在[0,100)之间,每个桶内部用链表表示,在数据入桶的同时插入排序。然后把各个桶中的数据合并。

#include<iterator>
#include<iostream>
#include<vector>
using namespace std;
const int BUCKET_NUM = 10;	// 桶的个数

// 链表结点用来存储数据,因为在数据入桶时要进行插入,所以选择单链表
// 当 ListNode 表示桶中的数据时,mData 表示数据的值
struct ListNode{
	explicit ListNode(int i = 0):mData(i),mNext(NULL){}	// 显式构造函数,采用直接初始化的方式
	ListNode* mNext;
	int mData;
};

// 插入数据到桶中
// head 表示头指针, val 表示要插入的值
ListNode* insert(ListNode* head, int val){
	ListNode dummyNode;		// 头结点,方便插入操作
	dummyNode.mNext = head;	// 哑结点的 mNext 指向 head
	ListNode *newNode = new ListNode(val);	// 创建新结点
	ListNode *pre, *curr;	// 快慢结点指针(快 curr ,慢 pre)
	pre = &dummyNode;
	curr = head;
	// 遍历桶中元素,寻找 val 应该插入的位置
	while(curr != nullptr && curr->mData <= val){
		pre = curr;
		curr = curr->mNext;
	}
	newNode->mNext = curr;
	pre->mNext = newNode;
	return dummyNode.mNext;
}

// 合并
ListNode* Merge(ListNode *head1, ListNode *head2){
	ListNode dummyNode;
	ListNode *dummy = &dummyNode;
	while(head1 != nullptr && head2 != nullptr){
		if(head1->mData <= head2->mData){
			dummy->mNext = head1;
			head1 = head1->mNext;
		}else{
			dummy->mNext = head2;
			head2 = head2->mNext;
		}
		dummy = dummy->mNext;
	}
	if(head1 != nullptr) 
		dummy->mNext = head1;
	if(head2 != nullptr) 
		dummy->mNext = head2;
	
	return dummyNode.mNext;
}

// n 表示待排序数据的个数,arr 是待排序序列
void bucketSort(int n,int arr[]){
	// 创建 BUCKET_NUM 个桶,每个桶指向桶中最小的值
	vector<ListNode*> buckets(BUCKET_NUM, (ListNode*)(0));	
	
	// 将数据放入桶
	for(int i = 0; i < n; ++i){
		int index = arr[i] / BUCKET_NUM;	// index 表示 arr[i] 要放入的桶的下标
		ListNode *head = buckets.at(index);	// 访问下标为 index 的元素
		buckets.at(index) = insert(head, arr[i]);	// 插入新元素,并返回新的头指针
	}
	
	// 合并排序结果
	ListNode *head = buckets.at(0);	// head 指向第一个桶
	for(int i = 1; i < BUCKET_NUM; ++i){
		head = Merge(head,buckets.at(i));	// 合并排序结果
	}
	
	// 将排序结果存入原线性表中
	for(int i = 0; i < n; ++i){
		arr[i] = head->mData;
		head = head->mNext;
	}
}

总结

  • 桶排序有相当的限制。因为桶的个数和大小都是人为设置的。而每个桶又要避免空桶的情况。
    ​  所以在使用桶排序的时候的要求如下:
      1.待排序数列要求偏均匀
      2.桶的设计(即映射函数)兼顾效率和空间,要让数据能在桶中均匀分配。如待排序数据有1000个,划分10个桶,则每个桶中的元素约100个最好。

  • 既然桶排序时间复杂度为线性,是不是就能替代例如快排、归并这种时间复杂度为 O(nlogn) 的排序算法呢?

答案是否定的,桶排序的应用场景十分严苛,首先,数据应该分布比较均匀。讲一种较坏的情况,如果数据全部都被分到一个桶里,那么桶排序的时间复杂度是不是就退化到O(nlogn)了呢?其次,要排序的数据应该很容易分成m个桶,每个桶也应该有大小顺序。

  • 桶排序的适用场景:

​​  从桶的生成可以看出,桶排序只适合于数据数值大小均匀分布的情况,即最大值和最小值之间的跨度不会太大时,其排序效率是较高的
​​  如果跨度过大,如数组 [1,2,3,1000],虽然只有4个元素,却需要创建1000个桶结构,极大的浪费空间。虽然桶排序理解实现都极为简单,但除非是在要求特别高(比如时间复杂度要低于O(NlogN)),或确定数组元素最值跨度会小于等于数组长度,否则一般不会选择桶排序算法,即使如此,也更倾向于考虑计数排序算法

桶排序、计数排序和基数排序一样,是非比较排序,排序过程中并没有发生元素的比较。


基数排序(Radix Sort)

​  基数排序(Radix Sort)是桶排序的扩展,属于 “分配式排序”(distribution sort),又称 “桶子法”(bucket sort)bin sort,它是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。

​  基数(radix):基数,也就是“进制”的意思,所谓的“每一位”是该进制下的位,现在我们以10为基数作为例子,即10进制,最大有10种可能,即最多需要10个桶(0~9)来映射数组元素。

​  由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数

基本思想

​  基数排序的基本思想是:将整数按位数切割成不同的数字,然后按每个位数分别比较

​  其中基数排序的方式可以采用 LSD(低位优先排序,Least significant digital)MSD(高位优先排序,Most significant digital),两者唯一的区别就是 LSD 是先从高位开始进行排序,而 MSD 是先从低位开始进行排序。
​  显然,不在同一个分组里的数,他们的大小顺序已经是已知的了,也就是说,不同分组之间的关键字大小已经是确定的了,所有在接下来的个位数排序里,我们只需要进行“各部分”单独排序就可以了,每一小部分都类似于原问题的一个子问题,做的时候可以采用递归的形式来处理。

采用 LSD 和 MSD 的基数排序的区别:

LSD 的基数排序:先从低位开始进行排序,并依次采用计数排序
MSD 的基数排序:先从高位开始进行排序,并依次采用桶排序

LSD 的基数排序的具体做法

  1. 将所有待排序的数值统一为同样的数位长度,将数位较短的数的高位补零
  2. 然后,从最低位或最高位开始,依次进行一次排序
  3. 这样从最低位(最高位)排序一直到最高位(最低位)排序完成以后,待排序序列就变成一个有序序列

MSD 的基数排序的具体做法

  1. 从最高位开始,根据每一位的值,将待排序的数值分配进桶中
  2. 但是 MSD 与 LSD 不同的是,在分配之后并不立刻将所有数值合并回一个数组中,而是在每个桶中建立“子桶”,将每个桶中的数值按照下一数位的值分配到“子桶”中
  3. 一直循环第2步,在每个桶中的数据都进行完最低位数的分配后再合并回单一的数组中。即先按 k1 排序分组,同一组中记录,关键码 k1 相等,再对各组按 k2 排序分成子组,之后,对后面的关键码继续这样的排序分组,直到按最次位关键码 kd 对各子组排序后。再将各组连接起来,便得到一个有序序列。所以, MSD 方法用递归的思想实现最为直接

实现逻辑

采用 LSD 的基数排序方式

  1. 申请一个大小为10个元素的基数序列的线性表数组,因为单个十进制数字有10种(0~9);
  2. 从低位开始,取单一位数作为基数序列下标,将待排序序列的全部数据存入对应下标表示的基数序列容器中;
  3. 在全部存入后,依次将基数序列各元素容器中的元素追加到原数组;
  4. 然后从下一位开始,重复2、3步骤,直到最大元素的最高位处理完毕,这里需要注意的是,数位较短的元素要补0——其实就是放到基数序列下标为0的元素容器中

采用 MSD 的基数排序方式

  1. 查询待排序序列的最大值,并获取它的最高位的基数(比如2230和2910的最高位基数都是2000);
  2. 根据基数来按位分组,存入桶内。
  3. 桶内元素数量>1,下一位递归分桶;
  4. 桶内元素数量=1,收集元素,即合并元素进数组中。

按位分割小技巧

arr[i] / digit % radix;	//其中 digit 为 基数^n ,n>=0,radix 为基数(即进制)

动图演示

LSD 的基数排序方式:
在这里插入图片描述
在这里插入图片描述

MSD 的基数排序方式(找不到动图):
在这里插入图片描述
在这里插入图片描述

复杂度分析

​  需要说明的是,基数排序也并非比快速排序慢,这得看具体情况。而且,数据量越大的话,基数排序会越有优势。

​  基数排序基于分别排序,分别收集,所以是稳定的

时间复杂度

d 指的是最大数值的位数,n 指待排序元素个数,k 表示基数即进制。

最差时间复杂度:O(d*(n+k))
最佳时间复杂度:O(d*(n+k))
平均时间复杂度:O(d*(n+k))

  • 因为 k 通常是一个常数,所以可以被忽略。即基数排序的时间复杂度为 O(d*n)。
  • ​而在满足 n >> d 条件的情况下,基数排序是最好的排序算法,时间复杂度能达到 O(n),即忽略掉 d。

简化后的时间复杂度:

平均时间复杂度:O(d*n)

注意: d 不一定小于 log(N),即基数排序不一定比快速排序等时间复杂度为 O(n * log(n)) 的算法快。但是大部分情况下,基数排序还是快于快速排序

空间复杂度

该​​算法的空间复杂度就是在分配元素时,使用的桶空间。即 O(n+k) ,其中 k 为桶的数量,n 为待排序的数据个数

空间复杂度:O(n+k)

​  又因为 LSD 的基数排序使用的是 0~9 数组下标作为桶的关键字,所以 LSD 的基数排序的 k 为10。它的空间复杂度为:O(n+10) ,即 O(n)。

注意:
​  有的实现方式是创建一个二维数组,此时基数排序的空间复杂度为 O(n * k)

稳定性

稳定性:稳定

因为基数排序是基于计数排序或桶排序的非比较、非跳转的排序,所以它是稳定的。

代码实现

假定待排序的数据是整型,且是十进制:

(1)获取特定位上的数:

// 获取特定位上的数,pos 表示从最低位开始第几位
template <typename T>
int _RadixSort_GetNumInPos(T nData,const int pos)
{
	unsigned int* pData = (unsigned int*)&nData;
	int nTemp = 1;
	for(int i = 0;i < (pos - 1);i++)
	{
		nTemp *= 10;
	}
	return ((*pData) / nTemp) % 10;
}

(2)计数排序:

//计数排序模板
template <typename T>
bool CountingSortWithRadixArray(T* pFirst,T* pLast,int* pRadixFirst,const int nMaxValue)
{
	bool bIsReverse = false;	// 判断输入数组指针是否逆序
	T* pTemp = NULL;
	if((pFirst == NULL) || (pLast == NULL))	// 判断输入参数的正确性
	{
		cout<<"Input parameters error!"<<endl;
		return false;
	}
	if(pFirst > pLast)//若输入数组指针逆序,则首尾指针交换
	{
		bIsReverse = true;
		pTemp = pFirst;
		pFirst = pLast;
		pLast = pTemp;
	}
	
	const int nLength = pLast - pFirst + 1;	// 计算输入数据长度
	int* pCountArray = new int[nMaxValue];	// 对数据值进行计数,根据最大数据值进行数组申请
	T* pTempArray = new T[nLength];	// 辅助数组
	pTemp = pRadixFirst;
	for(int i=0;i<nMaxValue;i++)
	{
		pCountArray[i] = 0;		// 计数器清零
	}
	for(int i=0;i<nLength;i++)	// 对数据计数
	{
		pCountArray[*pTemp] = pCountArray[*pTemp] + 1;
		++pTemp;
	}
	for(int i=1;i<nMaxValue;i++)	// 统计特定数据前的数据数目
	{
		pCountArray[i] = pCountArray[i] + pCountArray[i-1];
	}
	for(int i=nLength-1;i>=0;i--)	// 对数据进行重新排列
	{
		pTempArray[pCountArray[pRadixFirst[i]]-1] = pFirst[i];
		--pCountArray[pRadixFirst[i]];
	}
	for(int i=0;i<nLength;i++)	// 将重新排列的数据重新赋值给原数组
	{
		pFirst[i] = pTempArray[i];
	}
	if(bIsReverse)	// 若输入数组倒序,则重新进行逆序
	{
		while(pFirst < pLast)
		{
			Swap(*pFirst,*pLast);
			++pFirst;
			--pLast;
		}
	}
	delete []pCountArray;
	delete []pTempArray;
	return true;
}

(3)LSD 的基数排序的代码实现:

// 基于计数排序的LSD实现模板
template <typename T>
bool RadixSortLSD(T* pFirst,T* pLast)
{
	bool bIsReverse = false;	// 同上
	T* pTemp = NULL;
	if((pFirst == NULL) || (pLast == NULL))	// 同上
	{
		cout<<"Input parameters error!"<<endl;
		return false;
	}
	if(pFirst > pLast)
	{
		bIsReverse = true;
		pTemp = pFirst;
		pFirst = pLast;
		pLast = pTemp;
	}
	int nLength = pLast - pFirst + 1;	// 计算数组长度
	int* pRadixArray = new T[nLength];	// 记录每位数据
	T* pTempArray = new T[nLength];		// 辅助数组
	pTemp = pFirst;
	bool bIsOK = false;	// 判断是否已经到达最高位
	for(int i=0;i<nLength;i++)
	{
		pRadixArray[i] = 0;
	}
	int nPos = 1;
	while(!bIsOK)
	{
		bIsOK = true;
		for(int i=0;i<nLength;i++)
		{
			int nPosNum = _RadixSort_GetNumInPos(pTemp[i],nPos);	// 获取特定位数值
			pRadixArray[i] = nPosNum;
			if(pRadixArray[i] > 0)
				bIsOK = false;
		}
		++nPos;
		if(bIsOK)	// 若已对排序完最高位,则退出循环
			break;
		// 这里采用稳定的计数排序按数组中每位数字进行排序
		CountingSortWithRadixArray(pFirst,pLast,pRadixArray,RADIX_NUM);
	}
	if(bIsReverse)	// 逆序数组
	{
		while(pFirst < pLast)
		{
			Swap(*pFirst,*pLast);
			++pFirst;
			--pLast;
		}
	}
	delete []pRadixArray;
	delete []pTempArray;
	return true;
}

(4)MSD 的基数排序的代码实现:

// MSD实现模板
template <typename T>
bool RadixSortMSD(T* pFirst,T* pLast,const int nPos)
{
	bool bIsReverse = false;
	T* pTemp = NULL;
	if((pFirst == NULL) || (pLast == NULL))
	{
		cout<<"Input parameters error!"<<endl;
		return false;
	}
	if(pFirst > pLast)
	{
		bIsReverse = true;
		pTemp = pFirst;
		pFirst = pLast;
		pLast = pTemp;
	}
	int nLength = pLast - pFirst + 1;
	T*  pBucket = new T[nLength];
	int* pCount = new int[RADIX_NUM];
	for(int i=0;i<RADIX_NUM;i++)
	{
		pCount[i] = 0;
	}
	for(pTemp=pFirst;pTemp<=pLast;pTemp++)	//稳定计数排序
	{
		pCount[_RadixSort_GetNumInPos(*pTemp,nPos)]++;
	}
	for(int i=1;i<RADIX_NUM;i++)
	{
		pCount[i] = pCount[i] + pCount[i-1];
	}
	for(pTemp = pLast;pTemp>=pFirst;pTemp--)
	{
		int j = _RadixSort_GetNumInPos(*pTemp,nPos);
		pBucket[pCount[j]-1] = *pTemp;
		--pCount[j];
	}
	for(int i=0;i<nLength;i++)				//将数组放入子数组中
	{
		pFirst[i] = pBucket[i];
	}
	delete []pBucket;
	for(int i=0;i<RADIX_NUM;i++)
	{
			int nLeft = pCount[i];
			int nRight = pCount[i+1] - 1;
			if(nLeft < nRight && nPos > 1)
			{
				RadixSortMSD(pFirst+nLeft,pFirst+nRight,nPos-1);//对子数组递归进行MSD排序
			}
	}
	delete []pCount;
	if(bIsReverse)
	{
		while(pFirst < pLast)
		{
			Swap(*pFirst,*pLast);
			++pFirst;
			--pLast;
		}
	}
	return true;
}

总结

基数排序与计数排序、桶排序这三种排序算法都利用了桶(或组)的概念,但对桶的使用方法上有明显差异:

  • 基数排序:根据键值的每位数字来分配桶;
  • 计数排序:每个桶只存储单一键值;
  • 桶排序:每个桶存储一定范围的数值;

​  基数排序不是直接根据元素整体的大小进行元素比较,而是将原始列表元素分成多个部分,对每一部分按一定的规则进行排序,进而形成最终的有序列表。
​  相比于桶排序和计数排序,基数排序拥有更优的空间复杂度,但是牺牲了一定的时间复杂度。如果一定要选择一种非选择类型的排序,但数组元素最值跨度很大,又或者同时对空间复杂度有所要求,那么基数排序是个不错的选择。基数排序,和桶排序以及计数排序一样,是一种非比较类型的排序,排序过程中并没有发生元素的比较。

LSD 的基数排序适用于位数少的数列;
如果位数多的话,使用MSD的效率会比较好。

分治法(Divide and Conquer)

​  分治法(Divide and Conquer):分治法也称为分解法、分治策略等。分治法​将一个规模为 N 的问题分解为 K 个规模较小的子问题,这些子问题 互相独立(即子问题之间不包含公共的子子问题)且与原问题类型相同。递归地解决这些问题,然后将各个子问题的解合并来得到原问题的解。

如归并排序里,将数组 A 中下标1到 n 的排序划分为对数组 A 中下标1到 n/2 和数组 A 中下标 (n/2)+1 到 n 的归并排序问题,很明显,对这两个子数组归并排序问题互相独立。

算法导论把分治法描述为以下3步:

1、Divide the problem into a number of subproblems that are smaller instances of the same problem.
2、Conquer the subproblems by solving them recursively. If the subproblem sizes are small enough, however, just solve the subproblems in a straightforward manner.
3、Combine the solutions to the subproblems into the solution for the original problem.
​  
分解(Divide):归并排序处理的相对比较简单,但是快速排序的核心就在于划分子问题;
解决(Conquer):都是利用递归的理念求解;
合并(Combine):快速排序处理的比较简单,但是归并算法的核心就在于归并子问题的解。

分治法的算法思想

分治法算法思想如下
1.将一个问题划分同一类型、规模较小、互相独立的若干子问题,子问题之间最好规模相同;
2.通过递归解决子问题来克服它们(一般使用递归方法,但在问题规模足够小时,有时也会利用另一个算法直接解决问题);
3.合并这些子问题的解,以得到原始问题的答案。

在这里插入图片描述

递归式

​  对于每个算法来说,我们都必须要分析它的时间复杂度,而 递归式可以很清晰明了地说明分治算法的运行时间 。下面是递归式的通式:

​  假设把原问题分解成 a 个子问题,每个子问题的规模都是原问题的 1 b \frac{1}{b} b1,求解一个规模为 n b \frac{n}{b} bn 的问题需要的时间为 T ( n b ) T(\frac{n}{b}) T(bn),因此需要 a T ( n b ) aT(\frac{n}{b}) aT(bn) 的时间来求解 a 个子问题。如果分解问题所需的时间为 D ( n ) D(n) D(n),合并子问题的解为原问题的解需要的时间为 C ( n ) C(n) C(n),那么递归式为:

T ( n ) = a T ( n b ) + D ( n ) + C ( n ) T(n)=aT(\frac{n}{b})+D(n)+C(n) T(n)=aT(bn)+D(n)+C(n)

而对于归并排序来说,我们每次都是把原问题一分为二并且每个子问题的规模都是原来的 1 2 \frac{1}{2} 21,所以 a = b = 2。分解问题的时间为 Θ ( 1 ) \Theta(1) Θ(1),这是因为你只是求解数组索引的中间值;合并子问题所需要的时间为 Θ ( n ) \Theta(n) Θ(n)

得到了递归式以后,我们如何得到算法的渐近时间呢?比如我们得到了归并排序的递归式,如何求解它的渐近时间呢?当然了,我们都知道归并排序的渐近时间是 Θ ( n l g n ) \Theta(nlgn) Θ(nlgn)。下面将要介绍三种求解递归式的方法。

求解递归式的三种方法

​  这三种方法分别是:代入法递归树法,和主方法。代入法是一种十分强大的方法,它几乎可以求解所有的递归式。然而,对于一些特定类型的递归式(具体类型在主方法的小节中会介绍),用主方法可以用更少的时间得到一个更确切的界。

(1)代入法:猜测一个解,然后用数学归纳法证明这个解是正确的;
(2)递归树法:将递归式转换为一棵树,其其结点表示不同层次的递归调用产生的代价。然后采用边界和技术来求解递归式;
(3)主方法:可求解公式的递归式的界。

代入法

The substitution method for solving recurrences comprises two steps:
1、Guess the form of the solution.
2、Use mathematical induction to find the constants and show that the solution works.
​代入法的步骤:
(1)猜测一个解;
(2)用数学归纳法找出常数并证明解是正确的。

​  如上面的步骤所示,代入法虽然很强大,但是我们必须首先猜测出一个解,然后用数学归纳法来证明这个解是正确的。由于如何猜测并没有一个通用的步骤,我们可以依靠经验或者用下面介绍的递归树法来进行猜测。有一点需要注意的就是,归纳法的演绎步骤一定要用到前面的假设,否则你的归纳法就有问题了。

递归树法

​  在递归树中,每个结点表示一个单一子问题的代价。创建递归树之后,对树的每层的各子问题的代价进行求和,得到每一层的代价,然后将所有层的代价加起来,得到整棵递归树的总代价。而这个总代价就是递归式的解
​  当然,递归树方法是一种粗略的方法,因为递归树会引入一些“不精确”因素(比如将递归树中的 Θ ( n 2 ) Θ(n^2) Θ(n2) 这一渐近符号项替换为一个代数式 c n 2 cn^2 cn2)。如果要精确求解递归式,还需要用代入法对递归树得到的解进行验证。

使用递归树法分析归并排序和求斐波那契序列的时间复杂度

​  下面是算法导论中关于归并排序的伪代码,其中 MERGE 的时间为 O ( n ) O(n) O(n)。假设输入规模为 N,我们来看看各个部分所需要的时间:

  • 第2行代码用于分解问题:时间为 O ( 1 ) O(1) O(1)
  • 第3、4行代码是2个相同规模的子问题:总时间为 2 T ( N 2 ) 2T(\frac{N}{2}) 2T(2N)
  • 第5行代码是合并2个已经有序的子问题:时间为 O ( N ) O(N) O(N)

由于 O(1) 和 O(N) 做加法,所以相比之下,O(1) 可以被省略。因此,递归式为: T ( N ) = 2 T ( N 2 ) + O ( N ) T(N) = 2T(\frac{N}{2}) + O(N) T(N)=2T(2N)+O(N)

在这里插入图片描述

​  基于上面得到的递归式,可以得到下图所示的递归树。树为什么是这样的呢?可以从上面的伪码来分析。规模为 N 的输入进算法,经过了第2、3、4行代码以后,就得到了2个有序的数组,然后合并这2个数组的时间是 O ( N ) O(N) O(N)。所以,树的根结点正是合并2个规模为 N/2 的有序数组的时间,它为 O ( N ) O(N) O(N)。也就是说,经过2、3、4行代码以后,得到了2个规模为 N/2 的子问题,它们是根结点的2个孩子,然后合并这2个孩子所需要的时间为 O ( N ) O(N) O(N)。因此,把每一层的工作量汇总起来的时间是 O ( N ) O(N) O(N),总共有 logN 层,所以总的时间复杂度为 O ( N l o g N ) O(NlogN) O(NlogN)

在这里插入图片描述

​  接下来,我来谈谈关于求斐波那契序列的递归树。斐波那契序列的递归式如下:

T ( N ) = T ( N − 1 ) + T ( N − 2 ) + O ( 1 ) T(N) = T(N - 1) + T(N - 2) + O(1) T(N)=T(N1)+T(N2)+O(1)

​  上式中之所以有 O ( 1 ) O(1) O(1),是因为一旦你知道了 T ( N − 1 ) T(N - 1) T(N1) T ( N − 2 ) T(N - 2) T(N2) 的值,合并它们的时间为常量,也就是一个加法运算。这个递归式的递归树如下图所示。

在这里插入图片描述

​  假设输入规模为 N 的情况,从上图不难看出,树的高度为 N。由于合并2个孩子结点,得到父结点的解所需的时间为 O ( 1 ) O(1) O(1),因此我们只需要算出整个树有多少结点就知道时间复杂度了。用满二叉树来估算树中的结点数为 2 N − 1 2^N -1 2N1,因此用递归的方法求斐波那契序列的时间复杂度为 O ( 2 N ) O(2^N) O(2N)。当然,用动态规划的方法可以得到更好的时间。

下面是另外2个例子:

  1. T ( n ) = 2 T ( n 2 ) + n 2 T(n) = 2T(\frac{n}{2}) + n^2 T(n)=2T(2n)+n2

在这里插入图片描述

  1. T ( n ) = T ( n 3 ) + T ( 2 n 3 ) + n T(n) = T(\frac{n}{3}) + T(\frac{2n}{3}) + n T(n)=T(3n)+T(32n)+n

在这里插入图片描述

资料来源:Recursion Trees

主方法

​  上面已经给出了通用的递归式,把上面的公式重写一下,得到了如下图所示的公式,每个变量所代表的含义也都已经清晰的给出了:

​  假设把原问题分解成 a 个子问题,每个子问题的规模都是原问题的 1 b \frac{1}{b} b1,求解一个规模为 n b \frac{n}{b} bn 的问题需要的时间为 T ( n b ) T(\frac{n}{b}) T(bn),因此需要 a T ( n b ) aT(\frac{n}{b}) aT(bn) 的时间来求解 a 个子问题。如果分解问题所需的时间为 D ( n ) D(n) D(n),合并子问题的解为原问题的解需要的时间为 C ( n ) C(n) C(n),那么递归式为:
T ( n ) = a T ( n b ) + D ( n ) + C ( n ) T(n)=aT(\frac{n}{b})+D(n)+C(n) T(n)=aT(bn)+D(n)+C(n)
​  这是算法导论中的原文,其中的 f ( n ) f(n) f(n) 相当于上面的 D ( n ) + C ( n ) D(n)+C(n) D(n)+C(n) ,即在递归调用之外完成的工作的成本,包括将问题分解的成本和合并子问题的解决方案的成本。
在这里插入图片描述

​  那么如何应用主方法呢?其实很简单,就是简单地应用主定理,它类似于数学中的分段函数,不同的实例会得到不同的结果。对于主定理来说,它有3个实例(case):

​  所以,我们只需要找出a,b,和 c,然后比较大小,就能决定出是哪个 case 了。Master theorem 中给出了每个 case 的例子。关于主定理的证明,算法导论中用递归树给出了相应的证明。上面我已经说过了,主定理只针对特定类型的递归式子有效,如果出现下图中的情况,就不能应用主定理了:

在这里插入图片描述

经典使用场景

接下来将从实践出发,在实际场景中使用分治算法。

归并排序

请添加图片描述

​  归并排序,默认指二路归并排序。归并排序是使用分治法实现的排序之一。假设初始序列含有 n 个元素,则可看成 n 个有序的子序列,每个子序列的长度是1。然后将前后相邻的两个有序序列归并为一个有序序列。这与分治法将一个问题划分为同一类型的若干子问题,对这些子问题求解,合并这些子问题的解的思路是一致的。

快速排序

​  和归并排序一样,快速排序也是基于分治技术实现的算法。与归并排序按照元素在列表中的位置进行划分不同,快速排序是按照元素的值进行划分。快速排序的基本思想是,通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据比另一部分的所有数据要小,再按这种方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,使整个数据变成有序序列。

二叉树问题

​  因为二叉树可以划分为同样类型的两个更小的组成部分——左子树和右子树,所以许多关于二叉树的问题都可以使用分治法来解决。二叉树是由三个基本单元组成:根结点、左子树和右子树。因此,若能依次遍历这三部分,就能遍历整个二叉树。


动态规划(Dynamic Programming,简称 DP)

​  《算法导论》中是这样介绍 DP 算法的:动态规划与分治方法类似,都是通过组合子问题的解来来求解原问题的(即划分的子问题都是最优子结构)。再来了解一下什么是分治方法,以及这两者之间的差别,分治方法将问题划分为互相独立的、相同类型的子问题,然后递归地求解子问题,再将它们的解组合起来,求出原问题的解。
  而动态规划与之相反,动态规划应用于不同的子问题具有重叠的子子问题的情况下
  在这种情况下,分治法会做许多不必要的工作,它会反复求解那些公共子子问题。而动态规划对于每一个重叠的子子问题只求解一次,并将其解保存,从而无需每次遇到已解决的子子问题时都重新计算,避免了不必要的计算工作

​  动态规划方法一般用来求解最优化问题(optimization problem)。这类问题可以有很多可行解,每个解都有一个值,我们希望找到具有最优值的解,我们称这样的解为问题的一个最优解,而不是最优解,因为可能有多个解都达到最优值。

​  所以在DP的实践中最重要的就是递推关系和边界条件。所谓边界条件就是最简单的情况,所谓递推关系就是状态转移方程。
​  说一个最最最简单的例子,找出一个数组中的最大值。这个问题边界条件是什么呢,就是如果只有一个元素,那最大值就是他;递推关系是什么,就是你已经知道当下最大的数,再多给你一个数你怎么办。你会拿这个数和当下最大的数去比,其中较大的那个就是新的最大的数。这就是典型dp的思想。

动态规划的算法思想

​  动态规划的实质是分治思想解决冗余。因此动态规划是一种将问题实例分析为更小的、相似的子问题,并存储子问题的解而避免计算重复的子问题用空间换取时间 ,以解决最优化问题的算法策略。

动态规划的核心:由一个状态转移到另一状态所需的计算时间也是常数,故线性增加的状态,其总的时间复杂度也是线性的。动态规划并不是单纯的空间换时间,因为它其实只跟状态有关。所以我们只需要保存和当前状态有关的上一阶段的所有状态即可。

解决动态规划问题的过程一般分为四步:

  1. 首先判断问题是否可用动态规划解决;
  2. 如果是可用动态规划解决的问题,进行这个问题子问题的定义,有时需要重新定义一下这个问题以方便划分子问题;
  3. 接着从以下4个方面分析这个问题:
    ​  1)这个问题的状态是什么?
    ​  2)这个问题的状态转移方程是什么?
    ​  3)这个问题的状态的初始值是什么?
    ​  4)这个问题要求的最后答案是什么?
    ​每个步骤分析完成后,只需要按照状态转移方程实现代码,基本上就可用解决整个动态规划问题了。
    在上面步骤中,我们通过拆分问题进行了问题(子问题)的重定义(状态的定义)。通过状态的定义,再结合状态的边界情况,我们写出了状态与状态之间转移即状态转移方程的定义。最后只需要用记忆化地求解递推式的方法来解决即可。

举两个小例子来方便理解动态规划:
1.计算1+2+3……+100+1+2+3+……+101
​  我们可以先将这个问题划分为两个子问题:求1+2+3+……+100的值和求1+2+3+……+101的值。假设我们已经知道了1+2+3+……+99的值为4950,那么我们很容易就得到上面两个子问题分别为5050和5051。其中的求值过程只需要4950分别加上100和101即可,这是因为这两个子问题都具有一个重叠的子子问题,即求1+2+3+……+99。所以我们不需要重新计算1+2+3+…+99的值。
​  那么是如何知道1+2+3+…+99=4950的呢?因为1+2+3+…+98=4851是已知的,记住答案再加99即可,而不需要自己先从1加到98。那么这个4851又是怎么知道的呢?依次向前推,最后就变成了:1+2=? 是3。因为 1=1。
​  
2.计算数组A [1,5,2,4,3] 中的最长递增子数组的长度为多少
  我们通过计算可以知道这个数组的最长递增子数组是 [1,2,4] 和 [1,2,3] ,即最长递增子数组的长度为3。那么在计算机中该如何查找到这个子序列呢?简单的答案是从第1个下标开始遍历数组以查找最长递增子数组的长度,并且每一次找到更长的长度时,都将长度和其对应路径都保存起来,这里可以用哈希表来保存。但是这会导致出现大量的重复计算。比如在计算 [1,2,4] 之后再计算 [1,4] 时就会发现,从 A[0] 到 A[3] 的最长递增子数组的长度为3。所以就不需要再计算 [1,4] 了。

  所以我们可以采用 L(n) 来表示从下标 n 开始的最长递增子数组的长度。并从数组的最后一个下标来往前求最长递增子数组的长度。以避免出现重复运算。
在这里插入图片描述

动态规划适用于什么情况?

​  动态规划适用于将问题拆成若干个子问题,而子问题具有无后效性最优子结构的性质

经典使用场景

青蛙跳台阶

题目描述

一只青蛙一次可以跳上1级台阶,也可以跳上2级……它也可以跳上n级。求该青蛙跳上一个n级的台阶总共有多少种跳法。

题解

在这里插入图片描述

代码实现
  1. 递归实现中使用递归栈来存储每一次的子问题的结果并使用。
  2. 迭代实现中使用局部函数 total 来存储每一次子问题的结果并使用。
// 递归实现
class Solution {
public:
    int jumpFloorII(int n) {
        if (n <= 0) return -1;
        if (n == 1) return 1;
        return 2 * jumpFloorII(n - 1);
    }
};
// 迭代实现
class Solution {
public:
	int jumpFloorII(int n) {
		if (n <= 0) return 0;
		// 初始化
		int total = 1;
		for(int i = 1; i < n; i++)
		{	// 递推
			total = 2 * total;
		}
		return total;
	}
};

斐波那契数列

题目

现在要求输入一个整数 n ,请你输出斐波那契数列的第 n 项(从0开始,第0项为0,第1项是1,n <= 39)

题解
class Solution {
public:
    int Fibonacci(int n) {
        if (n == 0) return 0;
        if (n == 1) return 1;
        int pre_1 = 0;
        int pre_2 = 1;
        int cur = 0;
        n -= 1;
        while(n--) {
            cur = pre_1 + pre_2;
            pre_1 = pre_2;
            pre_2 = cur;
        }
        return cur;
    }
};

贪心算法(Greedy Algorithm)

​  贪心算法(又称贪婪算法):是寻找最优解问题的常用方法。这种方法一般将求解过程分成若干个步骤,但每个步骤都应用贪心原则——即选取当前状态下最优的选择(局部最有利的选择),并以此希望最后堆叠出的结果也是最优的解(所以贪心算法不一定能获得问题的一个最优解,需要数学证明)

贪心算法适用前提:局部最优策略能导致产生全局最优解。(需要数学证明)

贪心策略的选择:选择的贪心策略必须具备无后效性

贪心算法的思想

​  当前的选择可能要依赖于已经做出的选择,但不依赖于有待于做出的选择和子问题。因此贪心法是自顶向下,一步一步地做出贪心的选择

  1. 把求解的问题分成若干个子问题,制定贪心策略;
  2. 根据贪心策略对每一子问题求解,得到子问题的局部最优解;
  3. 把子问题的局部最优解合成原问题的一个解。

经典使用场景


分治法、动态规划与贪心算法的区别

几个重要概念

阶段

​  对于一个完整的问题过程,适当的切分为若干个相互联系的子问题(即子问题具有重叠子子问题)。每次在求解一个子问题,则对应一个阶段,整个问题的求解转化为按照阶段次序去求解。

状态

​  状态表示每个阶段开始时所处的客观条件,即在求解子问题时的已知条件。状态描述了研究的问题过程中的状况。

决策

​  决策表示当求解过程处于某一阶段的某一状态时,可以根据当前条件作出不同的选择,从而确定下一个阶段的状态,这种选择称为决策。

策略

​  由所有阶段的决策组成的决策序列称为全过程策略,简称策略。

最优策略

​  在所有的策略中,找到代价最小,性能最优的策略,此策略称为最优策略。

状态转移方程

​  状态转移方程是确定两个相邻阶段状态的演变过程,描述了状态之间是如何演变的。(也就是找出求解问题时的已知条件和求解它的子问题时的已知条件之间的关系式)

无后效性

如果给定某一阶段的状态,则在这一阶段后过程的发展不受这阶段以前各段状态的影响。

最优子结构和重叠子问题

  • 最优子结构如果问题的一个最优解中包含了子问题的最优解,则该问题具有最优子结构
  • 重叠子问题:用来求解原问题的递归算法反复地解同样的子问题,而不是总是在产生新的子问题。换句话说,有两个子问题,如果它们确实是相同的子问题,只是作为不同问题的子问题出现的话,则它们是重叠的

贪心选择性质

  1. 问题整体的最优解可通过一系列子问题的局部最优解合成
  2. 每次的选择可依赖以前作出的选择,但不依赖(考虑)后续选择。(贪心只考虑现在,而不考虑整体,即不考虑现在做出的选择对后面的影响)。

​  比如收银找零时,目标是凑出某个金额 n ,并需要用到尽量少的钞票。假设面值是1、5、11。如果采用贪心,那么贪心策略就是选择最大面值,因为我们依据生活经验都知道。
​  如果 n 为15,那么根据贪心策略,我们第一个选择是1张11,第二个选择是4张1。这很明显就不是最优解3张5。
​  这里就是我们提到的贪心选择性质的第二点,第二个选择4张1以来了以前做出的选择,但是第一个选择1张11并不考虑后续选择。

分治法与动态规划的共同点和不同点

共同点
​  动态规划法和分治法类似,都是将问题分解成多个子问题求解。
不同点
​  如果子问题中有很多是相同的,分治法会将相同的子问题求解多次,会很影响效率;而动态规划会保存已解决的子问题的答案,再有相同的子问题直接用保存的答案就行了,节省了很多计算时间。

动态规划与贪心算法的共同点和不同点

用动态规划解决的问题应该具有的性质
1)重叠子问题
2)最优子结构
用贪心算法解决的问题应该具有的性质
1)贪心选择性质
2)最优子结构

共同点
​  动态规划与贪心算法类似,都是将问题实例归纳为更小的、相似的子问题。都要求原问题必须有最优子结构,并通过求解子问题合成一个全局最优解。
不同点
​  贪心法每个阶段都根据贪心策略选择当前(局部)最优解,它不考虑整体,最后合成的不一定是整体的最优解,需要数学证明。
​  而动态规划根据状态来求解子问题,以获得局部最优解,最后合成达到全局最优解。

我的理解:
​  贪心算法和动态规划最重要的不同之处:在某个阶段对子问题求解时,贪心算法使用贪心策略直接选择,而动态规划根据状态和状态转移方程求出局部最优解
​  由于贪心算法具有贪心选择性质,保证每个阶段只会根据贪心策略选择一次,因此贪心算法是最简单的动态规划。
  由于动态规划是根据若干个状态,通过状态转移方程来求出局部最优解。
  实际求解时,贪心是自顶向下求解,而动态规划是自底向上求解

如果把对问题的求解看成一张图,每一个结点表示对子问题的求解。

  • 贪心算法 就相当于根据贪心策略在每一处结点选择最优解,而不考虑整体路径长度,最后找出的路径不一定是最优路径,需要数学证明;
  • 暴力枚举 相当于将所有的路径都走过一遍,最后才选择最优路径;
  • 动态规划 就相当于一步步求出起点到它们之间顶点的最短路径,过程中都是基于求出的最短路径的基础上,求得更远顶点得最短路径。

问题:假设您是个土豪,身上带了足够的1、5、10、20、50、100元面值的钞票。现在您的目标是凑出某个金额 w ,需要用到尽量少的钞票。
​  
贪心:
​  依据生活经验,我们显然可以采取这样的 贪心策略 :选择使用面值最大的纸币。
  在这种策略下,666=6×100+1×50+1×10+1×5+1×1,共使用了10张钞票。这种策略称为“贪心”:假设我们面对的局面是“需要凑出 w”,贪心策略会尽快让 w 变得更小。能让 w 少100就尽量让它少100,这样我们接下来面对的局面就是凑出 w - 100。长期的生活经验表明,贪心策略是正确的。但是,如果我们换一组钞票的面值,贪心策略就也许不成立了。
  如果一个奇葩国家的钞票面额分别是1、5、11,那么我们在凑出15的时候,贪心策略会出错:15=1×11+4×1 (贪心策略使用了5张钞票)15=3×5(正确的策略,只用3张钞票)为什么会这样呢?贪心策略错在了哪里?“尽量使接下来面对的 w 更小”。这样,贪心策略在w=15的局面时,会优先使用11来把 w 降到4;但是在这个问题中,凑出4的代价是很高的,必须使用4×1。如果使用了5,w 会降为10,虽然没有4那么小,但是凑出10只需要两张5元。
  在这里我们发现,贪心是一种只考虑眼前情况的策略。
​  
动态规划:
​  重新分析刚刚的例子。w =15时,我们如果取11,接下来就面对 w = 4 的情况;如果取5,则接下来面对 w = 10 的情况。我们发现这些问题都有相同的形式:“给定 w,凑出 w 所用的最少钞票是多少张?”接下来,我们用 f(n) 来表示“凑出 n 所需的最少钞票数量”。
  那么,如果我们取了11,最后的代价(用掉的钞票总数)是多少呢?
  明显 ,它的意义是:利用11来凑出15,付出的代价等于f(4)加上自己这一张钞票。现在我们暂时不管f(4)怎么求出来。依次类推,马上可以知道:如果我们用5来凑出15,cost就是 f(10)+1=2+1=3。
  那么,现在 w=15 的时候,我们该取那种钞票呢?当然是各种方案中,cost值最低的那一个!

  • 取11:cost=f(4)+1=4+1=5
  • 取5:cost=f(10)+1=2+1=3
  • 取1:cost=f(14)+1=4+1=5

显而易见,cost值最低的是取5的方案。我们通过上面三个式子,做出了正确的决策!
  这给了我们一个至关重要的启示—— f(n) 只与 f(n-1)、f(n-5) 和 f(n-11) 相关;更确切地说:
f ( n ) = m i n { f ( n − 1 ) , f ( n − 5 ) , f ( n − 11 ) } + 1 f(n)=min\left\{f(n-1), f(n-5), f(n-11)\right\}+1 f(n)=min{f(n1),f(n5),f(n11)}+1  
  这个式子就是所谓的 状态转移方程 。我们要求出 f(n),只需要求出几个更小的 f 值;既然如此,我们从小到大把所有的 f(i) 求出来不就好了?注意一下边界情况即可。
在这里插入图片描述
  上面这两个事实,保证了我们做法的正确性。它比起贪心策略,会分别算出取1、5、11的代价,从而做出一个正确决策,这样就避免掉了“鼠目寸光”!
  它与暴力的区别在哪里?我们的暴力枚举了“使用的硬币”,然而这属于冗余信息。我们要的是答案,根本不关心这个答案是怎么凑出来的。譬如,要求出f(15),只需要知道f(14)、f(10)、f(4)的值。其他信息并不需要。我们舍弃了冗余信息。我们只记录了对解决问题有帮助的信息—— f(n)。
  我们能这样干,取决于问题的具有 最优子结构 的性质:求出 f(n),只需要知道几个更小的 f(a)。我们将求解 f(a) 称作求解 f(n) 的“子问题”。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值