算法设计与分析 期末复习 北邮BUPT

以下内容以“算法设计与分析-2022”王晓茹老师的ppt为大纲
问题、要求也均为老师课堂上的口述要求和ppt上的要求

1 算法复杂性分析和渐进性原理

1.1 算法复杂性的概念

  • 渐进上界、渐进下界
    请添加图片描述请添加图片描述
  • 证明 O ( f ( n ) ) + O ( g ( n ) ) = O ( m a x { f ( n ) , g ( n ) } ) O(f(n))+O(g(n)) = O(max\{f(n),g(n)\}) O(f(n))+O(g(n))=O(max{f(n),g(n)})
    请添加图片描述
    (最后一行注意一下)

1.2 用特征方程解递归方程的通解

  • ppt作业题
    在这里插入图片描述
  • 考试题
    请添加图片描述
  • ppt上的特殊情况(有两个特征根相等)
    请添加图片描述

2 分治

2.1 快速排序

注意1:如何解决退化?(参考另一个博主的内容)
快速排序的优化

  • 三数取中法(下方黄色字体“区分2”有详细代码)
  • 当待排序序列的长度分割到一定大小后,使用插入排序
  • 在一次分割结束后,把与key相等的元素聚在一起,继续下次分割时,不用再对与key相等元素分割

注意2:如何设计稳定排序?

  • 插入排序
    利用有序表的插入操作进行排序(有序表的插入: 将一个记录插入到已排好序的有序表中,从而得到一个新的有序表。)
  • 冒泡排序
    实现将n个数从小到大排序,两两比较,将最大的数放右边,一趟比较完后最右边的数即为最大数,然后n-1个数继续两两比较,将最大的数放在最右边,这是第二趟,共需要进行n-1趟。
  • 合并排序
    实现一组数从小到大排序,核心即拆合,先将这组数拆成两两一组,按从小到大排序好,即有序,再将各个数组两两合并。

区分1:挖坑法与hoare法的区别

  • 下方主代码为挖坑法(思路:取最左或最右边的元素为key,假设把这个位置“挖空”,让最右边的q向左走,直到遇到比key小的数,将其放到key的位置,自己的位置变“空”。直到pq相遇,那么这个位置就是最终的坑位,再将key填入这个坑位,就完成了一趟排序。)
  • 另一种hoare方法见“线性时间选择”中的partition,区别在于,挖坑法进行一趟排序的时候key需要被保存,因为最开始就被a[j]给替代了;而hoare法则是key一直被保留在第一个,进行一趟排序的过程中a[i]和a[j]不断交换,最后退出while循环后进行a[key]和a[i]的交换。

区分2:基准值的三种选取

  • 固定位置选择(以第一个元素为例)
int Normal(int a[],int low,int high)  
{  
    return a[low];  
}  
  • 随机选择
int Random(int a[],int low,int high)  
{  
    //产生枢轴的位置
    srand((unsigned)time(NULL));  
    int temp = rand()%(high - low) + low;  
    //若要生成 a 与 b 之间的随机实数,应使用:rand()*(b-a)+a
    //把枢轴位置的元素和low位置元素互换,即可像普通的快排一样调用划分函数  
    swap(a[temp], a[low]);  
    return a[low];  
}  
  • 三数取中
/*函数作用:取待排序序列中low、mid、high三个位置上数据,选取他们中间的那个数据作为枢轴*/  
int Median(int a[],int low,int high)  
{  
    int mid = low + (high - low) / 2;//计算数组中间的元素的下标  
    if (a[mid] > a[high])//目标: a[mid] <= a[high]  
    {  
        swap(a[mid], a[high]);  
    }  
    if (a[low] > a[high])//目标: a[low] <= a[high]  
    {  
        swap(a[low], a[high]);  
    }  
    if (a[mid] > a[low]) //目标: a[low] >= a[mid]  
    {  
        swap(a[mid], a[low]);  
    }  
    //此时,a[mid] <= a[low] <= a[high]  
    return a[low];  
    //low的位置上保存这三个位置中间的值,分割时可以直接使用low位置的元素作为枢轴,而不用改变分割函数了  
} 

考试题1:分析划分元素的选取对算法性能的影响?

  • 快速排序算法的性能取决于划分的对称性。通过修改算法partition,可以设计出采用随机选择策略的快速排序算法。在快速排序算法的每一步中,当数组还没有被划分时,可以在a[p:r]中随机选出一个元素作为划分基准,这样可以使划分基准的选择是随机的,从而可以期望划分是较对称的。(来自ppt)
  • 基准元素的选取对于快速排序的性能影响很大
    • 最好的情况是选取的基准元素是序列的中位数,这样划分以后左子序列和右子序列的大小就相同;
    • 如果选取的基准元素是最小值或最大值,那么划分以后左子序列和右子序列有一个几乎为空,这样算法的效率就退化为 n 2 n^2 n2。比如,原来的序列已经几乎是有序的,那么上述选取基准值的方法就不可靠了。
  • 在对于序列没有任何先验假设的情况下,一个比较稳妥的方法是选取左边界值,中间值,右边界值的中位数作为基准元素。

考试题2:快速排序的基本思想

  • 选取一个基准,一趟排序确定两个区间,一个区间全部比基准值小,另一个区间全部比基准值大,接着再选取一个基准值来进行排序,以此类推,最后得到一个有序的数列。

其他问题:快速排序的步骤

  • 选取基准值,通过不同的方式挑选出基准值。
  • 用分治的思想进行分割,通过该基准值在序列中的位置,将序列分成两个区间,在准值左边的区间里的数都比基准值小(默认以升序排序),在基准值右边的区间里的数都比基准值大。
  • 递归调用快速排序的函数对两个区间再进行上两步操作,直到调用的区间为空或是只有一个数。
void quicksort(int a[10000], int left, int right)
{
    int i = left, j = right, pivotkey = a[left];
    if (left >= right) return;
    while (i < j)
    {
        while (i < j && a[j] >= pivotkey)//从右向左扫描
            j--;
        a[i] = a[j];//碰到更小的数则停下来放到基准位置左边(基准位置会变)
        while (i < j && a[i] <= pivotkey)//从左向右扫描
            i++;
        a[j] = a[i];//碰到更大的数则停下来放到基准位置右边
    }
    a[i] = pivotkey;
    quicksort(a, left, i - 1);//对基准位置以左的区间做子任务
    quicksort(a, i + 1, right);//对基准位置以右的区间做子任务
}

含有partition、三数取中的版本(另一个博主的代码)
partion+qsort

//交换子表的记录,使枢轴记录到位,并返回枢轴所在的位置
int Partition(int a[], int low, int high){
 
	/*三数中值分割法*/
	int mid = low + (high - low) / 2;//计算数组中间的元素的下标  
    if (a[mid] > a[high])//目标: a[mid] <= a[high]  
    {  
        swap(a[mid], a[high]);  
    }  
    if (a[low] > a[high])//目标: a[low] <= a[high]  
    {  
        swap(a[low], a[high]);  
    }  
    if (a[mid] > a[low]) //目标: a[low] >= a[mid]  
    {  
        swap(a[mid], a[low]);  
    }  
	int pivotkey = a[low];
	
	/*随机基准元
	int randomIndex = rand() % (high - low) + low;//取数组中随机下标
	swap(array, randomIndex, low);                //与第一个数交换
	int pivotkey = array[low];
	*/
 
	int i = low, j = high;
	while(i < j) //从表的两端交替向中间扫描,当没有相遇
	{
		while (a[j] >= pivotkey && i<j){
			j--;
		}
		while (a[i] <= pivotkey && i<j){
			i++;
		}
		if (i < j)
		{
			swap(a, i, j);
		}	
	}
	//最终将基准数归位
	swap(a, low, i);
	return i;              //返回枢轴所在的位置
}
void QSort(int array[], int low, int high){
	int pivot;
	if (low < high)
	{
		pivot = Partition(array, low, high);//算出枢轴值
		QSort(array, low, pivot - 1);       //对低子表递归排序
		QSort(array, pivot + 1, high);      //对高子表递归排序
	}
}
//对array做快速排序
void QuickSort(int array[], int n){
	QSort(array, 0, n - 1);
}

2.2 合并排序

注意1:合并过程较为复杂,对于分解和合并的函数要掌握好
注意2:合并排序的空间复杂度和辅助空间

  • 临时的数组和递归时压入栈的数据占用的空间:n + logn;所以空间复杂度为: O ( n ) O(n) O(n)

注意3:合并排序的两种方式(消除分解与否?)

  • 方法一基本思想:将待排序元素分成大小大致相同的2个子集合,分别对2个子集合进行排序,最终将排好序的子集合并成为所要求的排好序的集合。
  • 方法二具体过程:(算法mergeSort的递归过程可以消去)
    • 首先将长度为1的n个数组相邻元素两两合并,构成了长度为2的n/2个数组,合并时比较算法对这每个子数组中元素进行排序
    • 再将这些长度为2的n/2个数组两两合并,构成了长度为4的n/4的子数组,合并时比较算法对每个子数组中元素进行排序
    • 继续,直到形成长度为n,子数组个数=1的整个数组为止。

注意4:合并排序算法的稳定性

  • 合并排序是把序列递归地分成短序列,递归出口是短序列只有1个元素(认为直接有序)或者2个序列(1次比较和交换),然后把各个有序的段序列合并成一个有序的长序列,不断合并直到原序列全部排好序。
  • 在1个或2个元素的序列中,1个元素不会交换,2个元素如果大小相等也没有人故意交换,这不会破坏稳定性。
  • 那么,在短的有序序列合并的过程中,稳定是是否受到破坏?没有,合并过程中可以保证如果两个当前元素相等处在前面的序列的元素会被保存在结果序列的前面,这样就保证了稳定性。

注意5:时间复杂度
在这里插入图片描述

版本1:赋值给原数组的版本

void merge(int a[10000], int left, int right)
{
    int i = left, j = (left + right) / 2 + 1, point = 0;
    int b[10000];
    memset(b, 0, sizeof(b));
    while (i <= (left + right) / 2 && j <= right)//在两个对半的区间进行合并处理
    {
        if (a[i] < a[j])//左侧区间的数字更小
            b[point++] = a[i++];//同时进行临时数组的更新和指针的移动
        else//右侧区间的数字更小(或者相等)
            b[point++] = a[j++];//同时进行临时数组的更新和指针的移动
    }
    while (i <= (left + right) / 2)//左半区间如果还有剩下的则放入临时数组b中
        b[point++] = a[i++];
    while (j <= right)//右半区间同理,此二循环只有一个循环会被执行
        b[point++] = a[j++];
    point = 0;
    for (j = left; j <= right; j++)//临时数组中的内容复制回原数组中
        a[j] = b[point++];
}
void mergesort(int a[10000], int left, int right)
{
    if (left >= right) return;//当前区间长度小于等于1结束递归
    int mid = (left + right) / 2;
    mergesort(a, left, mid);//划分出左半区间做子任务
    mergesort(a, mid + 1, right);//划分出右半区间做子任务
    merge(a, left, right);//子任务的关键是合并
}

版本2:有返回值的版本(python)

def merge(leftarr, rightarr):
    i = 0
    j = 0
    b = []
    while i <= len(leftarr) - 1 and j <= len(rightarr) - 1:
        print(i, j)
        if leftarr[i] < rightarr[j]:
            b.append(leftarr[i])
            i += 1
        else:
            b.append(rightarr[j])
            j += 1
    while i <= len(leftarr) - 1:
        b.append(leftarr[i])
        i += 1
    while j <= len(rightarr) - 1:
        b.append(rightarr[j])
        j += 1
    return b

def mergesort(a, left, right):
    if left >= right:
        return [a[left]]
    mid = (left + right) // 2
    left_arr = mergesort(a, left, mid)
    right_arr = mergesort(a, mid + 1, right)
    merged_arr = merge(left_arr, right_arr)
    return merged_arr

2.3 线性时间选择

  • 题目:
    • 选择问题:给定线性序集中n个元素和一个整数k,1≤k≤n,要求找出这n个元素中第k小的元素
    • 线性时间选择问题:如果能在线性时间内找到一个划分基准,使得按这个基准所划分出的2个子数组的长度都至少为原数组长度的ε倍(0<ε<1是某个正常数),那么就可以在最坏情况下用 O ( n ) O(n) O(n)时间完成选择任务。
      • 例如,若ε=9/10,算法递归调用所产生的子数组的长度至少缩短1/10。所以,在最坏情况下,算法所需的计算时间T(n)满足递归式 T ( n ) ≤ T ( 9 n / 10 ) + O ( n ) T(n)≤T(9n/10)+O(n) T(n)T(9n/10)+O(n)
        由此可得 T ( n ) = O ( n ) T(n)=O(n) T(n)=O(n)
  • 考试题目:
    (1)(5分)说明用分治法设计相关算法的过程;(2)(3)略
    在这里插入图片描述
    • Pivot/划分基准的选择:
      i)将n个输入元素划分成 n/5 个组,每组5个元素,只可能有一个组不是5个元素。用任意一种排序算法,将每组中的元素排好序,并取出每组的中位数,共 n/5 个。
      ii)递归调用select来找出这 n/5 个元素的中位数。如果 n/5 是偶数,就找它的2个中位数中较大的一个。
      iii)以这个元素作为划分基准。
    • 设所有元素互不相同。在这种情况下,找出的基准x至少比3(n-5)/10个元素大,因为在每一组中有2个元素小于本组的中位数,而n/5个中位数中又有(n-5)/10个小于基准x。同理,基准x也至少比3(n-5)/10个元素小。而当n≥75时,3(n-5)/10≥n/4所以按此基准划分所得的2个子数组的长度都至少缩短1/4。
      (需要-5的原因:要考虑n不是5的倍数的情况)
  • 时间复杂度分析
    (考试题目)对该算法最坏情况下的时间复杂度(比较次数)进行分析,注意尽可能给出最坏情况下的分析时相关的准确比较次数。
    • 最坏情况时间复杂度
      若ε=9/10,算法递归调用所产生的子数组的长度至少缩短1/10。所以,在最坏情况下,算法所需的计算时间T(n)满足递归式T(n)≤T(9n/10)+O(n) 。由此可得T(n)=O(n)。
    • 代码时间复杂度
      在这里插入图片描述
      T ( n ) = O ( n ) T(n)=O(n) T(n)=O(n)

注意1:partition函数(选择第n小),存在退化
注意2:ppt代码未写出来,需要补全

#include<iostream>
#include<cstdlib>
#include<cstdio>
using namespace std;

void bubbleSort(int a[],int p,int r)
{
    for (int i = p; i < r; i++)
    {
        for (int j = i + 1; j <= r; j++)
        {
            if (a[j] < a[i])
                swap(a[i], a[j]);
        }
    }
}
int Partition(int a[], int p, int r, int val)//单趟排序(hoare法)
{
    int pos = p, t = a[pos];
    int i = p, j = r;
    while (i < j)
    {
        while (i < j && a[j] >= t)//从右向左扫描
            j--;
        while (i < j && a[i] <= t)//从左向右扫描
            i++;
        swap(a[i], a[j]);
    }
    swap(a[pos], a[i]);
    return j;
}
int Select(int a[], int p, int r, int k)
{
    if (r - p < 75)
    {
        bubbleSort(a, p, r);//用某个简单排序算法对数组a[p:r]排序;
        return a[p + k - 1];
    }
    //找中位数的中位数,r-p-4即上面所说的n-5
    for (int i = 0; i <= (r - p - 4) / 5; i++)//把每个组的中位数交换到区间[p,p+(r-p-4)/4]
    {
        //将a[p+5*i]至a[p+5*i+4]的第3小元素与a[p+i]交换位置;
        bubbleSort(a, p + 5 * i, p + 5 * i + 4);
        swap(a[p + i], a[p + 5 * i + 2]);//交换每组中的中位数到前面
    }
    //找所有中位数的中位数,r-p-4即上面所说的n-5
    int x = Select(a, p, p + (r - p - 4) / 5, (r - p - 4) / 10);
    /* (r-p+1)/10 = (p+(r+p-4)/5-p+1)/2 */
    //以x为基准做一次快排
    int i = Partition(a, p, r, x), j = i - p + 1;
    //判断k属于哪个部分
    if (k <= j) return Select(a, p, i, k);
    else return Select(a, i + 1, r, k - j);
}
int main()
{
    int x;
    //数组a存了0-79
    int a[80]= {3,1,7,6,5,9,8,2,0,4,13,11,17,16,15,19,18,12,10,14,23,21,27,26,25,29,28,22,20,24,33,31,37,36,35,39,38,32,30,34,43,41,47,46,45,49,48,42,40,44,53,51,57,56,55,59,58,52,50,54,63,61,67,66,65,69,68,62,60,64,73,71,77,76,75,79,78,72,70,74};
    cin >> x;
    cout << "第" << x << "小的数是" << Select(a, 0, 79, x) << endl;
}

3 动态规划

  • 要求:
    • 分析是否满足最优子结构,证明方法是用反证法来进行证明的,子问题的最优解和原问题的最优解在这一部分是重叠的。
    • 写出递推式
    • 自底向上求解,避免重复
    • 构造最优解

3.1 矩阵连乘

  • 问题描述:
    • 给定n个矩阵{ A 1 A_1 A1, A 2 A_2 A2, …, A n A_n An},其中Ai与 A i + 1 A_{i+1} Ai+1是可乘的,i=1,2,…,n-1。
    • 考察这n个矩阵的连乘积 A 1 A 2 . . . A n A_1A_2...A_n A1A2...An
    • 由于矩阵乘法满足结合律,所以计算矩阵的连乘可以有许多不同的计算次序。这种计算次序可以用加括号的方式来确定。
    • 若一个矩阵连乘积的计算次序完全确定,也就是说该连乘积已完全加括号,则可以依此次序反复调用2个矩阵相乘的标准算法计算出矩阵连乘积
      在这里插入图片描述
  • 递归关系式
    在这里插入图片描述
    其中k的位置只有j-i种可能
  • 代码
#include <iostream>
using namespace std;
int A[100][100], s[100][100], p[100];
void traceback(int i, int j)
{
    if (i == j) return;
    traceback(i, s[i][j]);
    traceback(s[i][j] + 1, j);
    cout << i << ' ' << s[i][j] << ' ';
    cout << s[i][j] + 1 << ' ' << j << "    ";
    cout << p[i] << ' ' << p[s[i][j]] << ' ';
    cout << p[s[i][j] + 1] << ' ' << p[j] << endl;
}
int main() {
    int n;
    int i, j, r, k;
    cin >> n;
    for (i = 0; i <= n; i++)
        cin >> p[i];
    for (i = 1; i <= n; i++)
        A[i][i] = 0;
    for (r = 2; r <= n; r++)
        for (i = 1; i <= n - r + 1; i++)//不算第n行
        {
            j = i + r - 1; //比如最开始算的是(1,2)
            A[i][j] = A[i + 1][j] + p[i - 1] * p[i] * p[j];
            s[i][j] = i;
            for (k = i + 1; k <= j; k++)
                if (A[i][k] + A[k + 1][j] + p[i - 1] * p[k] * p[j] <= A[i][j])
                {
                    A[i][j] = A[i][k] + A[k + 1][j] + p[i - 1] * p[k] * p[j];
                    s[i][j] = k;
                }
        }
    traceback(1, n);
    return 0;
}
/*
6
30 35 15 5 10 20 25
 */

3.2 最长公共子序列

  • 问题描述:给定两个序列 X = { x 1 , x 2 , … . . , x m } X=\{ x_1, x_2, ….., x_m\} X={x1,x2,..,xm} Y = { y 1 , y 2 , … … , y n } Y=\{y_1, y_2, ……, y_n\} Y={y1,y2,……,yn},找出X和Y的一个最长公共子序列。
  • 最优子结构性质:
    • 设序列 X = { x 1 , x 2 , … . . , x m } X=\{ x_1, x_2, ….., x_m\} X={x1,x2,..,xm} Y = { y 1 , y 2 , … … , y n } Y=\{y_1, y_2, ……, y_n\} Y={y1,y2,……,yn}的最长公共子序列为 Z = { z 1 , z 2 , … … , z k } Z=\{z_1, z_2, ……, z_k\} Z={z1,z2,……,zk},则
    1. x m = y n x_m=y_n xm=yn,则 z k = x m = y n z_k=x_m=y_n zk=xm=yn,且 Z k − 1 Z_{k-1} Zk1 X m − 1 X_{m-1} Xm1 Y n − 1 Y_{n-1} Yn1的最长公共子序列。
    2. x m ≠ y n x_m≠y_n xm=yn z k ≠ x m z_k≠x_m zk=xm,则 Z k Z_k Zk X m − 1 X_{m-1} Xm1 Y n Y_n Yn的最长公共子序列。
    3. x m ≠ y n x_m≠y_n xm=yn z k ≠ y n z_k≠y_n zk=yn,则 Z k Z_k Zk X n X_n Xn Y n − 1 Y_{n-1} Yn1的最长公共子序列。
  • 子问题的递归结构
    在这里插入图片描述
  • 代码
void LCSLength(int m, int n, char x[100], char y[100], int c[100][100], int b[100][100])//求长度
{
    int i, j;
    for (i = 1; i <= m; i++) c[i][0] = 0;
    for (i = 1; i <= n; i++) c[0][i] = 0;
    c[0][0] = 0;
    for (i = 1; i <= m; i++)
        for (j = 1; j <= n; j++)
        {
            if (x[i] == y[j])
            {
                c[i][j] = c[i - 1][j - 1] + 1; b[i][j] = 1;
            }
            else if (c[i - 1][j] >= c[i][j - 1])
            {
                c[i][j] = c[i - 1][j]; b[i][j] = 2;
            }
            else
            {
                c[i][j] = c[i][j - 1]; b[i][j] = 3;
            }
        }
}

void LCS(int i, int j, char x[100], int b[100][100])//构造公共子序列
{
      if (i == 0 || j == 0) return;
      if (b[i][j] == 1)
      {
          LCS(i - 1, j - 1, x, b);
          cout << x[i];
      }
      else if (b[i][j] == 2)
          LCS(i - 1, j, x, b);
      else LCS(i, j - 1, x, b);
}

3.3 最大字段和

  • 问题描述
    在这里插入图片描述
    在这里插入图片描述
  • 数组b[j]:表示以下标j结束的最大子数组的和的值。
  • 思考:以下标1为结束的数组{1,-5}的最大子数组和是什么?(注意:这里指的最大子数组必须以下标1(或者说j)结束)
  • 答案:
    • 如果以下标0结束的最大子数组的和为正数,那么b[1]就是b[0]+a[1];
    • 如果如果以下标0结束的最大子数组的和为负数,那么b[1]就是a[1]。所以b[1]=b[0]+a[1]=-4。
  • 递归关系式
    在这里插入图片描述
  • 代码(原始版本)
int maxSum(int n, int a[100])
{
    int sum = 0, b = 0;
    for (int i = 1; i <= n; i++)
    {
        if (b > 0) b += a[i];
        else b = a[i];
        if (b > sum) sum = b;
    }
    return sum;
}

上面的代码中,也对空间复杂度做了小小的优化,b[]这个数组没有必要开,原因是b[j]只和b[j-1]有关系,所以用一个变量b即可。从代码中也可以看到,只有一层循环i,用来枚举结束的下标。

  • 代码(构造了最优解的版本)
int maxSum(int n, int a[100])
{
    int sum = 0, b = 0;
    for (int i = 1; i <= n; i++)
    {
        if (b > 0) b += a[i];
        else
        {
            b = a[i];
            tempbegin = i;
        }
        if (b > bestsum)
        {
            bestsum = b;
            bestbegin = tempbegin;
            bestend = i;
        }
    }
    return sum;
}
  • ppt上的代码
    在这里插入图片描述
int getmax(int a[100], int n)
{
    int sum = 0, sumMin = 0, max = a[0];
    for (int i = 0; i < n; i++)
    {
        if (sumMin > sum)
            sumMin = sum;
        sum = sum + a[i];
        if (sum - sumMin > Max)
            Max = sum - sumMin;
    }
    return Max;

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

3.4 01背包问题(优化问题不考)

  • 递推关系式
    在这里插入图片描述
  • 代码
#include<iostream>
#include<iomanip>
using namespace std;
int n, c;
int m[100][100], weight[100], value[100], x[100];
void Traceback()//构造最优解
{
    memset(x, 0, sizeof(x));
    for (int i = 1; i < n; i++)
        if (m[i][c] == m[i + 1][c])
            x[i] = 0;
        else
        {
            x[i] = 1;
            c -= weight[i];
        }
    if (m[n][c] != 0)
        x[n] = 1;
    else m[n][c] = 0;
}
int main()
{
    int i, j;
    cin >> n >> c;//n件物品 c的容量
    for (i = 1; i <= n; i++)
        cin >> weight[i] >> value[i];
    for (j = 0; j <= c; j++)//注意从0开始
        if (j < weight[n]) m[n][j] = 0;  //不要忘记对m赋初始值
        else m[n][j] = value[n];
    for (i = n - 1; i >= 1; i--)
        for (j = 0; j <= c; j++)
        {
            if (j >= weight[i])
            {
                if (m[i + 1][j] > m[i + 1][j - weight[i]] + value[i])
                    m[i][j] = m[i + 1][j];
                else
                    m[i][j] = m[i + 1][j - weight[i]] + value[i];
            }
            else m[i][j] = m[i + 1][j];
        }
    cout << m[1][c] << endl;
    for (i = 1; i <= n; i++)
    {
        for (j = 0; j <= c; j++)
            cout << setw(3) << m[i][j];
        cout << endl;
    }
    Traceback();
    for (i = 1; i <= n; i++)
        cout << setw(3) << x[i];
    cout << endl;
}

4 贪心

  • 贪心和动态规划的使用范围:
    • 如果问题的最优解包含两个(或更多)子问题的最优解,且子问题多有重叠,则考虑使用动态规划算法。
    • 如果问题经过贪心选择后,只剩下一个子问题,且具有最优子结构,那么可以使用贪心算法。
  • 注意1:证明当前问题用贪心法可以得到最优解(包括最优子结构+贪心选择的证明)
  • 注意2:复习复杂性(要包括排序的时间复杂度)

4.1 活动安排

  • 问题描述

设有n个活动的集合E={1,2,…,n},其中每个活动都要求使用同一资源,如演讲会场等,而在同一时间内只有一个活动能使用这一资源。
每个活动i都有一个要求使用该资源的起始时间si和一个结束时间fi,且si<fi。如果选择了活动i,则它在半开时间区间[si ,fi)内占用资源。若区间[si ,fi)与区间[sj,fj)不相交,则称活动i与活动j是相容的。当 si ≥ fj 或 sj ≥ fi时,活动i与活动j相容。 活动安排问题就是在所给的活动集合中选出最大的相容活动子集合。

  • 贪心选择
    在这里插入图片描述在这里插入图片描述在这里插入图片描述
  • 最优子结构
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
  • 贪心策略和时间复杂度
    在这里插入图片描述
  • 过程
    • 将各个活动按照活动结束时间fi排序,且f1<=f2<=f3…
    • 选择结束时间最早的活动1
    • 从2开始按顺序比较各个活动,选择第一个与活动1相容的活动i
    • 从i+1开始按顺序考察各个活动,选择第一个与活动 i 相容的活动 j
  • 代码
void greedy(int n, int s[100], int f[100], bool a[100])
{
    A[1] = true;
    int j = 1;
    for (int i = 2; i <= n; i++){
        if (s[i] >= f[j])
        {
            A[i] = true;
            j = i;
        }
        else A[i] = false;
}

4.2 背包问题

  • 问题描述

与01背包问题类似,所不同的是在选择物品i装入背包时,可以选择物品i的一部分,而不一定要全部装入背包,1<=i<=n。

  • 背包问题和01背包问题的区分
    • 与0-1背包问题类似,不同的是在选择物品i装入背包时,可以选择物品的一部分,即0<= x i x_i xi<=1
    • 这两类问题极为相似,都具有最优子结构性质:对n种物品E={1, 2, 3, … , n},其最优解为{ x 1 x_1 x1, x 2 x_2 x2, x 3 x_3 x3, … , x n − 1 x_{n-1} xn1, x n x_n xn}。则 X j X_j Xj=X-{ x j x_j xj}={ x 1 x_1 x1, x 2 x_2 x2, … , x j − 1 x_{j-1} xj1, x j + 1 x_{j+1} xj+1, … , x n − 1 x_{n-1} xn1, x n x_n xn}是n-1个物品的子问题E’={1, 2, … , j-1, j+1, … , n-1, n}的最优解——去掉第j个物品。
    • 这两类问题都具有最优子结构性质;但是背包问题具有贪心选择性质,可以用贪心算法求解;而0-1背包问题不能用贪心算法求解。
  • 背包问题有三种看似合理的贪心策略
    • 选择价值最大的物品
    • 选择重量最轻的物品
    • 选择单位重量价值最大的物品
  • 01背包问题不能用贪心算法的原因
    在这里插入图片描述
  • 考试题:
    (1) (4分)给出此优化问题的整数规划数学公式,即问题的形式化描述。
    在这里插入图片描述
    (2) (4分)给出该问题贪心算法求解的贪心策略。
    在这里插入图片描述
    在这里插入图片描述
    (3) (6分)基于C/C++/Java/Python等高级编程语言写出贪心算法的伪代码。
    见下方代码
    (4) (3分)分析(3)中给出的贪心算法的时间复杂性。
    O ( n l o g n ) O(nlogn) O(nlogn)
    (5) (3分)给定4种物品重量分别为{10, 40, 55, 20} 价值分别为{20, 120, 55, 100}, 背包容量是100,求背包的最大价值以及对应的放入背包的物品重量。
  • 过程
    • 计算每种物品的单位重量的价值
    • 排序,装入背包;
    • 尾部处理
  • 代码
void package(int n, float M, float v[], float w[], float x[])
{
    sort(n, v, w);//对n个物品计算单位重量的价值v[i]/w[i],从大到小排序
    int i;
    for (i = 1; i <= n; i++)
        x[i] = 0;
    float c = M;
    for (i = 1; i <= n; i++)
    {
        if (w[i] > c) break;//物品i的重量超出当前可用背包容量,算法停止
        x[i] = 1;
        c -= w[i];
    }
    if (i <= n && c > 0) x[i] = c / w[i];
    //如果背包还有剩余容量(C>0),将剩余容量分配给物品i
}

4.3 最优装载(另一个老师复习ppt上的重点内容)

  • 问题描述

有n个集装箱要装上一艘载重量为c的轮船。其中集装箱i的重量为Wi。
最优装载问题要求确定在装载体积不受限制的情况下,将尽可能多的集装箱装上轮船—装载的集装箱数目极大化,且装入的总重量不超过c(装载重量受限)。

最优装载问题看作是0-1背包问题的1个特例:集装箱=物品,每种物品价值函数v[i]=1

  • 数学化形式
    在这里插入图片描述
  • 贪心策略
    在这里插入图片描述
  • 贪心选择
    设集装箱已按重量从小到大排序,{x1, x2, …, xn} 是最优装载问题的一个最优解。
    k为最先装入箱中的最轻货物,即
    在这里插入图片描述
    当问题有最优解(有可能是非贪心策略解)时,
    (1)当k=1,即最轻的第1个货物被装入,货物选择的放入顺序符合贪心策略, {x1, x2, …, xn} 是满足贪心选择性质的一个最优解
    (2)当k>1时,最轻的货物1没有被装入
    按如下方式从{x1, x2, …, xn}构造出一个满足贪心策略的最优解{y1, y2, …,yk, …, yn}, y1=0:
    i) 对k, 其中1<k<n, yk=1**,取y1=1, yk=0**,将第1个货物放入,第k个货物拿出,即用第1个货物替换原方案中最轻的货物,而第1个货物比第k个货物更轻,即w1≤wk
    ii) yi=xi, 1≤i≤n, i≠k, 其它货物是否被放入仍保持不变。
    新方案仍然满足容量约束条件:
    在这里插入图片描述
  • 最优子结构
    假设货物{1,2,…,n}已按重量从小到大排序。
    对原问题:待装货物为{1,2,…, n}、容量为C,{x1, x2, …, xn}是其满足贪心策略的1个最优解, 且x1=1。
    e.g. {x1, x2, …, xn}={1, 0, 1, 0, 1},k=1
    则对子问题:待装货物为{2,…,n}、容量为C-w1, {x2, …, xn}是一个最优解, e.g. {x2, …, xn}={0, 1, 0, 1}
    e.g. {x2, …, xn}={0, 1, 0, 1}
  • 代码
void loading(int x[], int w[], int c, int n)
{
    sort(w, t, n);
    for (int i = 1; i <= n; i++) x[i] = 0;
    for (int i = 1; i <= n && w[t[i]] <= c; i++)//t[i]存储的是物品标号(从小到大排列)
    {
        x[t[i]] = 1;
        c -= w[t[i]];
    }
}

4.4 哈弗曼编码

(此处贪心选择和最优子结构写的不全,参考ppt)

  • 贪心选择
    设字符集C’={c | f©},x和y是其中具有最小频率f(x)和f(y)的2个字符,则存在C’的最优前缀码,使x和y具有相同码长,且只有最后一位编码不相同。
    e.g. f: 1100, e:1101
    如果能证明上述命题,就说明构造算法是正确的——全局最优
  • 最优子结构
    需要证明:
    给定字符集C和其对应的最优前缀码T,可以从中得到子问题C’ (C的子集)及其对应的最优前缀子树T‘请添加图片描述
  • 时间复杂度
    T = O ( n l o g n ) T = O(nlogn) T=O(nlogn)
  • 构建哈夫曼树的详细过程参考:哈夫曼树算法、示例
  • 伪代码
void Huffman(PriorityQueue Q[ ], int n)
{
    //Let Q be the priority queue.
    Q = {initialize priority queue with frequencies of all symbol or message}
    for (i = 1; i < n; i++)
    { 
        create a new node z;
        /* be greedy here */
        x = extract_min(Q);
    	y = extract_min(Q);
    	Frequency(z) = Frequency(x) +Frequency(y);
        /* weight of a tree = sum of the frequencies of its leaves */
        Insert(z, Q);
    }
}

4.5 最小生成树(略)

4.6 单源最短路径

  • 考试题:

(1)(5分)说明Dijkstra求解该问题的贪心策略,并给出求解过程。
每次从V-S中取出具有(只经过S中顶点)的最短特殊路长度dist[u]的顶点u,将u添加到S中,同时对数组dist作必要的修改。
在这里插入图片描述
请添加图片描述
(2)(6分)证明该贪心算法的正确性,即该贪心算法能够获得最优解(参考ppt)

A.贪心策略

在每步迭代时,从V-S中选择具有最短特殊路径dist[u]的顶点u,加入S

B.贪心选择

需证明对任意顶点u,从v开始、经过G中任意顶点到达u的全局最短路径的长度d(v, u) =从v开始、只经过S中顶点到达u的最短路径的长度dist(u)。
即:不存在另一条v到u的最短路径,该路径上某些节点x不在V-S中,且该路径的长度d(v,u) < dist[u]。

反证法
a.假设:
i.在迭代求解过程中,顶点u是遇到的第1个满足: d(v,u) < dist[u], 即 d(v,u)≠dist[u] 的顶点
ii.从v到u的全局最短路径上,第1个属于V-S的顶点为x

b.首先,因为u是第一个满足全局最短路径不完全在S集合中的顶点, 即d(v, u) < dist[u]

c.而x是在u之前遇到的顶点,x的最短路径完全在S中,即dist[x] = d(v, x)

d.对v到u的全局最短路径,有d(v, x) + distance(x, u) = d(v ,u) < dist[u](根据假设)

e.由于distance(x, u) >0,因此dist[x] = d(v, x) ≤ d(v ,u) < dist[u],即dist[x]< dist[u]

f.但是根据路径p构造方法,在下图所示情况下,u、x都在集合S之外,即u、x都属于V-S,但u被选中时,并没有选x,根据扩展S的原则——选dist最小的顶点加入S,说明此时dist[u] ≤ dist[x]
请添加图片描述
这与前面推出的dist[x]< dist[u]相矛盾

c.最优子结构

对顶点u,考察将u加到S之前和之后,**dist[u]**的变化,添加u之前的S称为老S,加入u之后的S称为新S。
要求:u加到S中后,dist[u]不增加。

对另外1个节点i,考察u的加入对**dist[i]**的影响:
1)假设添加u后, 出现1条从v到i的新路,该路径先由v经过老S中的顶点到达u,再从u经过一条直接边到达i
如果dist[u] + c[u][i] <原来的dist[i],则算法用dist[u] + c[u][i] 替代dist[i],得到新的dist[i]。否则, dist[i]不更新。
请添加图片描述

2)如果新路径如下图所示,先经过u,再回到S中的x,由x直接到达i。
x处于老的S中, 故dist[x]已经是最短路径x比u先加入S,因此
dist[x] ≤dist[u] + d(u,x)
此时,从原v到i的最短路径dist[i]小于路径(v, u, x, i)的长度,因此算法更新dist[i]时不需要考虑该路径,u的加入对dist[i]无影响。
请添加图片描述

因此,无论算法中dist[u]的值是否变化,它总是关于当前顶点集合S的到顶点u的最短路径。
也就是说:对于加入u之前、之后的新老S所对应的2个子问题,算法执行过程保证了dist[u]始终是u的最优解

(3)(4分)分析所写算法的复杂性
用带权邻接矩阵表示具有n个顶点和e条边的带权有向图G(V, E)。
Dijkstra算法的主循环体需要 O ( n ) O(n) O(n)时间。这个循环需要执行n-1次,所以完成循环需要 O ( n 2 ) O(n^2) O(n2)时间。
算法的其余部分所需要时间不超过 O ( n 2 ) O(n^2) O(n2)

  • 代码
bool visited[MaxVerNum];//访问标记数组,用于遍历时的标记
//最短路径 - Dijkstra算法 参数:图G、源点v
void Dijkstra(Graph G, int v)
{
    //初始化:是否在S中、距离D、前驱结点标号Pr
    int n = G.vexnum;//n为图的顶点个数
    for (int i = 0; i < n; i++)
    {
        S[i] = false;
        D[i] = G.Edge[v][i];
        if (D[i] < INF)Pr[i] = v; //v与i连接,v为前驱
        else Pr[i] = -1;
    }
    S[v] = true;
    D[v] = 0;
    //初始化结束,求最短路径,并加入S集
    for (int i = 1; i < n; i++)
    {
        int min = INF;
        int temp;
        for (int w = 0; w < n; w++)
            if (!S[w] && D[w] < min) //某点temp未加入s集,且为当前最短路径
            {
                temp = w;
                min = D[w];
            }
        S[temp] = true;
        //更新从源点出发至其余点的最短路径 通过temp
        for (int w = 0; w < n; w++)
            if (!S[w] && D[temp] + G.Edge[temp][w] < D[w])
            {
                D[w] = D[temp] + G.Edge[temp][w];
                Pr[w] = temp;
            }
    }
}
//两次更新,对于每次更新都是两个条件+两个项的更新

5 回溯

  • 回溯法的基本思想
    在这里插入图片描述
    在这里插入图片描述
  • 子集树和排列树
    • 子集树
      • 当所给的问题是从n个元素的集合S中找出满足某种性质的子集时,相应的解空间称为子集树。
      • 遍历子集树需 O ( 2 n ) O(2^n) O(2n)计算时间
        在这里插入图片描述
      • 伪代码
      void backtrack(int t)
      {
          if (t > n) output(x);
          else
              for (int i = 0; i <= 1; i++)
              {
                  x[t] = i;
                  if (legal(t)) backtrack(t + 1);
              }
      }
      
    • 排列树
      • 当所给的问题是确定n个元素满足某种性质的排列时,相应的解空间树成为排列树。
      • 遍历排列树需要 O ( n ! ) O(n!) O(n!)计算时间
        在这里插入图片描述
      • 伪代码
      void backtrack(int t)
      {
          if (t > n) output(x);
      	else
      	    for (int i = t; i <= n; i++)
      	    {
      	        swap(x[t], x[i]);
      	        if (legal(t)) backtrack(t + 1);
      	        swap(x[t], x[i]);
      	    }
      } 
      

5.1 轮船装载问题

  • 问题描述:
    有一批共n个集装箱要装上2艘载重量分别为c1和c2的轮船,其中集装箱i的重量为wi,且 ∑ n i = 1 w i ≤ c 1 + c 2 \displaystyle \sum_{n}^{i=1}w_{i}≤c_1 + c_2 ni=1wic1+c2
  • 时间复杂度:
    由于bestx可能被更新 2 n 2^n 2n次,算法的时间复杂度为 O ( 2 n ) O(2^n) O(2n)

注意1:子集树

void loading(int i)
{
    if (i > n)
    {
        for (int j = 1; j <= n; j++)
            cout << x[j] << ' ';
        cout << endl;
        if (cw > bestw)
        {
            for (int j = 1; j <= n; j++)
                bestx[j] = x[j];
            bestw = cw;
        }
        return;
    }
    //r -= w[i];
    if (cw + w[i] <= c)
    {
        x[i] = 1;
        cw += w[i];
        loading(i + 1);
        x[i] = 0;//无论进入下一层与否都会执行,这是更保险的位置
        cw -= w[i];
    }
    //if (cw + r > bestw)
    //{
        loading(i + 1);
    //}
    //r += w[i];
}

对于样例n=3, c1=50, w={20, 40, 40}

  • 剪枝下的情况
    请添加图片描述
  • 未剪枝下的情况
    请添加图片描述

5.2 旅行售货员问题

注意1:排列树

  • 作业题(非ppt上代码)
  • 函数说明:
    • inX:判断当前结点是否被访问过
    • TSPrecursion:回溯
  • 伪代码:
    • 边界条件
      • 更新路径和最优解
      • 更新路径最优解
      • 路径和回溯
    • 循环递归
      • 可行性:剩余可选城市
      • 剪枝:加入当前城市会小于最短路径
        • 路径选中
        • 路径和选中
        • 递归下一层
        • 路径回溯
        • 路径和回溯
  • 剪枝/约束条件
    • 1:如果当前正在考虑的顶点j与当前路径中的末端结点i没有边相连,即w[i, j]= ∞, 则不必搜索j所在分支
    • 2:如果B(i) ≥ bestw, 则停止搜索x[i]分支及其下面的层 ,
      其中,bestw代表到目前为止,在前面的搜索中,从其它已经搜索过的路径中,找到的最佳完整回路的权和(总长度)
  • 时间复杂度:求排列问题。确认n个元素的满足某种性质的排列。其搜索树称为排列树,通常有n!个叶结点,因此遍历排列树需要时间为 O ( n ! ) O(n!) O(n!)
  • 解空间树(包括剪枝)练习
    请添加图片描述
    请添加图片描述
bool inX(int i)//判断当前结点i是否被访问过
{
    for (int j = 0; j <= i - 1; j++)
        if (i == x[j]) return true;
    return false;
}
void TSPrecursion(int i)//回溯法计算
{
    int u;
    if (i == n) // 结束
    {
        cv = cv + w[x[n - 1]][1];
        if (cv <= v_best) // 小于最短路径
        {
            v_best = cv;// 更新最优解
        }
        for (int j = 0; j <= n - 1; j++)
        {
            x_best[j] = x[j];
            cout << x_best[j]<<' ';
        }
        cv = cv - w[x[n - 1]][1];//回溯(最大值回溯)
        return;
    }
    for (u = 2; u <= n; u++)
    {
        if (inX(u) == false && w[u][x[i - 1]] != -1)// 剩余可选城市
        {
            if (cv + w[x[i - 1]][u] <= v_best) // 加入后小于最短路径
            {
                x[i] = u; // 加入
                cv = cv + w[x[i - 1]][u]; // 更新当前解
                TSPrecursion(i + 1); // 搜索下一层
                x[i] = 0; // 回溯层数
                //因为需要回头来判断当前结点是否被访问过,所以需要回溯
                cv = cv - w[x[i - 1]][u];//回溯路径长度
            }
        }
    }
}

5.3 作业调度问题

  • 题目描述:
    在这里插入图片描述

注意1:排列树

请添加图片描述
这道题在复习的时候用代码调试一下


在这里插入图片描述

  • 解空间树
    请添加图片描述在这里插入图片描述
  • 遍历顺序
    123
    132 123
    213
    231 213 123
    321
    312 321

5.4 N皇后问题

注意1:排列树

  • is_ok:判断当前位置是否可以放置
  • queen:
    • 边界条件
    • 循环递归
  • 时间复杂度:求排列问题。确认n个元素的满足某种性质的排列。其搜索树称为排列树,通常有n!个叶结点,因此遍历排列树需要时间为 O ( n ! ) O(n!) O(n!)
bool is_ok(int row)
{
    for (int j = 0; j != row; j++)
    {
        if (c[row] == c[j] || row - c[row] == j - c[j] || row + c[row] == j + c[j])//保证不在同一列&保证不在同一斜线
            return false;
    }
    return true;
}
void queen(int row){
    if (row == n)
    {
        total++;
        for (int j = 0; j < n; j++)
            cout << c[j] << ' ';
    }
    else
        for (int col = 0; col < n; col++)
        {
            c[row] = col;//row+1的递归过程已经保证不在同一排
            if (is_ok(row))
                queen(row + 1);
        }
}
  • 8
    点赞
  • 37
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值