第一章:绪论

第一章:绪论

大O记号

T(n) = O(f(n))等价于存在c>0,当n达到一定值之后,有T(n) < c * f(n);

通俗而言,O(f(n))表示f(n)是T(n)的上界。

大O记号的两个特点:

其一,常系数可以忽略。即O(c*f(n)) = O(f(n)).

其二,低次项可忽略。比如O(n^2 + logn + n! + n^n) = O(n^n)。

其他记号:

时间复杂度比较:

常数 < 对数 < 幂函数 < 指数。

1.常数复杂度O(1)

注意:含有循环、分治、递归的代码段依旧可能是O(1)级别的复杂度

因为可能循环之类的条件无法触发。

2.对数复杂度O(logn)

注意:

常底数无所谓:由换底公式知logaN = logab * logbN,记logab=C,故都是一个级别的。

常数次幂无所谓:logn^c = clogn

(logn)^M = O((logn)^N),其中M > N.

3.多项式复杂度O(n^k)

大部分循环和递归的时间复杂度

4.指数复杂度O(a^n)

比如TSP问题和DFS中常见的子集生成问题

 

算法分析
主要任务:正确性+复杂度。

复杂度分析

复杂度分析的主要方法:

迭代:级数求和

递归:递归跟踪+递推方程

猜测+验证

级数:

算术级数:与末项平方同阶

幂方级数:比幂次高出一阶

几何级数:与末项同阶

收敛级数:O(1)

调和级数:O(logn)

对数级数:O(nlogn)

大部分的证明比较简单:

下面以调和级数的证明为例:h(n) = 1 + 1/2 + 1/3 +…+1/n

1/2 < 1/x在1到2上的定积分,1/3小于1/x在2到3上定积分…,1/n小于1/x在n-1到n上的定积分,故h(n) < 1 + 1/x在1到n上的定积分=1 + lnn = O(logn)。

利用级数求迭代的时间复杂度:

简单的二重循环可以将内层循环表示层外层循环变量的函数用级数表示:

而对于几何级数

外层:i:1到n-1,i<<=1;

内层:j:0到i-1,j++。

表示为1 + 2 + 4 +… +2^(log2(n-1)).

几何级数与末项同阶,故时间复杂度为O(n).

对于上面的级数,很明显是等比乘等差,错位相减即可求和。

T=1 + 2*2 + 3*4 + 4*8 + logn*2^(logn-1)。

2T=  1*2 + 2*4 + 3*8 + (logn-1)*2^(logn-1) + nlogn.

相减得-T=1 + 2 + 4 + 8 + 2^(logn-1) – nlogn.

即T = nlogn – O(n) = O(nlogn).

正确性

冒泡排序

顺序序列:任意一对相邻元素顺序;

无序序列:至少存在一对相邻元素逆序;

原理:基于扫描交换,每次扫描比较每一对相邻元素,逆序则交换之。

结束:某趟扫描中,没有发生一次逆序对的交换,说明序列已有序。

算法:

void bubblesort1B ( int A[], int n ) {
   for ( bool sorted = false; sorted = !sorted; n-- ) { //在尚未确认已全局排序之前,逐趟进行扫描交换
      for ( int i = 1; i < n; i++ ) { //自左向右逐对检查当前范围A[0, n)内的各相邻元素
         if ( A[i-1] > A[i] ) { //一旦A[i-1]与A[i]逆序,则
            swap ( A[i-1], A[i] ); //交换之,并
            sorted = false; //因整体排序不能保证,需要清除排序标志
         }
      }
   }
}

算法优缺点:

优点:借助布尔型标志位sorted,可及时提前退出,而不致蛮力地做n-1趟扫描交换

缺点:对尾部有序(或接近有序)的输入,算法依然亦步亦趋地收敛,导致元素比较次数过多

算法正确性的证明:

证明不变性(能够实现目的)+单调性(问题规模在缩减)

性能分析:

最坏情况:完全逆序,时间复杂度O(n^2)

最好情况:初始序列有序,外循环仅一次,做n-1次比较和0次交换。时间复杂度:O(n).

改进思路:第i趟扫描仅需扫描前n-i+1个元素,因为后面的元素已经有序,无须比较。

封底估算

比如一般算法题的时间限制为1s,数据范围在10^4以内则所使用算法的时间复杂度不能超过平方级别,否则会超时。

迭代与递归

To iterate is human,to recurse,divine.

引例:Sum-数组求和

迭代形式算法:

int sumI ( int A[], int n ) { //数组求和算法(迭代版)
   int sum = 0; //初始化累计器,O(1)
   for ( int i = 0; i < n; i++ ) //对全部共O(n)个元素,逐一
      sum += A[i]; //累计,O(1)
   return sum; //返回累计值,O(1)
} //O(1) + O(n)*O(1) + O(1) = O(n+2) = O(n)

空间时间复杂度:一般不把输入元素的存储算在内,仅计算算法消耗的额外空间,在上面算法中,也就只有sum和i是额外的空间消耗。

减而治之:将原问题划分为一个平凡的子问题和一个规模缩减的子问题,分别求解子问题,最后将子问题的解合并为原问题的解。

平凡的子问题:一般可以直接求解。

规模缩减的子问题:比原问题的规模略有减小,但解的结果与原问题一致。

线性递归版数组求和算法:

int sum ( int A[], int n ) { //数组求和算法(线性递归版)
   if ( 1 > n ) //平凡情况,递归基
      return 0; //直接(非递归式)计算
   else //一般情况
      return sum ( A, n - 1 ) + A[n - 1]; //递归:前n - 1项之和,再累计第n - 1项
} //O(1)*递归深度 = O(1)*(n + 1) = O(n)

原问题sum(A,n)被划分为规模缩减的子问题sum(A,n-1)和平凡的子问题A[n-1],原问题的解等于两个子问题解的和,递归调用到平凡的情况也就是递归基时开始返回。

递归算法复杂度分析:可以采用递归跟踪分析的方法和递推方程的方法。

递归跟踪:用直观的图形描述出递归调用的过程,适用于简单的递归算法。

递归调用语句本身的时间复杂度为O(1),可忽略,故每次递归调用实例消耗O(1)的时间,n+1个实例消耗O(n)的时间。

递推方程:用递推式求解递归算法的时间复杂度,适用于复杂的递归算法。

求解规模为n的原问题的时间复杂度等于求解规模为n-1的时间复杂度加上解一个平凡子问题的时间复杂度,用递推方程表示就是T(n)=T(n-1) + O(1),T(0)=O(1).因为这里平凡的子问题的解可以直接得到,消耗常数的时间复杂度,递归基也就是T(0)的求解也是直接返回0,同样是常数的时间复杂度,不妨先将O(1)视为1,则T(n)=T(n-1)+1,等式两边同时减去n得到T(n) – n = T(n-1) – (n – 1)=…=T(1) – 1 = T(0) = O(1),即T(n)= O(1)+ n = O(n)。

数组倒置问题

void reverse ( int* A, int lo, int hi ) { //数组倒置(多递归基递归版)
   if ( lo < hi ) {
      swap ( A[lo], A[hi] ); //交换A[lo]和A[hi]
      reverse ( A, lo + 1, hi - 1 ); //递归倒置A(lo, hi)
   } //else隐含了两种递归基
} //O(hi - lo + 1)

首先分析下这里所说的隐含了两种递归基:

奇数情况:1 2 3,初始lo指向1,hi指向3,lo<hi,交换1,3得到3 1 2,lo和hi都指向了2,lo不小于hi,递归终止。

偶数情况:1 2 3 4,初始lo指向1,hi指向4,lo<hi,交换1,4得到4 2 3 1,lo指向2,hi指向3,lo下标小于hi下标,交换2,3得到4 3 2 1,lo指向2,hi指向3,lo的下标超过了hi,递归终止。

故在问题规模为奇数和偶数的情况下,该递归算法都能在递归基的情况下返回并且返回时已实现数组元素的倒置。

正确性证明:

不变性:每次交换,数组中必有两个元素被交换到最终的位置上。

单调性:每次递归,lo++,hi--,问题规模减少了2,第k层递归中问题规模缩减至n – 2k。

正确性:至多经过n / 2次交换,算法必然终止,且能给出正确的解答。

复杂度分析:

递推方程:T(n) = T(n-2) + O(1),递归基T(1) = T(0) = O(1)。

不妨将O(n)视为2,可以得到T(n) – n = T(n) – (n – 2) = … =T(1) – 1或者T(0) = O(1).

T(n) = O(n),这里的n = hi – lo + 1,故时间复杂度也可写成O(hi-lo+1).

两个迭代版本的算法:

迭代原始版:

void reverse ( int* A, int lo, int hi ) { //数组倒置(直接改造而得的迭代版)
next: //算法起始位置添加跳转标志
   if ( lo < hi ) {
      swap ( A[lo], A[hi] ); //交换A[lo]和A[hi]
      lo++; hi--; //收缩待倒置区间
      goto next; //跳转至算法体的起始位置,迭代地倒置A(lo, hi)
   } //else隐含了迭代的终止
} //O(hi - lo + 1)

迭代精简版:

void reverse ( int* A, int lo, int hi ) { //数组倒置(规范整理之后的迭代版)
   while ( lo < hi ) //用while替换跳转标志和if,完全等效
      swap ( A[lo++], A[hi--] ); //交换A[lo]和A[hi],收缩待倒置区间
} //O(hi - lo + 1)

分而治之

为求解一个大规模的问题,将其划分为若干个(通常是两个)子问题,规模大致相当,分别求解子问题,最后将子问题的解合并得到原问题的解。

例:数组求和-二分递归版

int sum ( int A[], int lo, int hi ) { //数组求和算法(二分递归版,入口为sum(A, 0, n - 1))
   if ( lo == hi ) //如遇递归基(区间长度已降至1),则
      return A[lo]; //直接返回该元素
   else { //否则(一般情况下lo < hi),则
      int mi = ( lo + hi ) >> 1; //以居中单元为界,将原区间一分为二
      return sum ( A, lo, mi ) + sum ( A, mi + 1, hi ); //递归对各子数组求和,然后合计
   }
} //O(hi - lo + 1),线性正比于区间的长度

时间复杂度分析:

递归跟踪:

同样,每个递归实例的时间复杂度都是O(1),总的时间复杂度需要求出递归跟踪数总的结点数,第一层1个结点,第二层2个,第i层2^i个,成几何级数的形式。在递归树的最后一层,一共n个实例,根据几何级数的复杂度与末项同阶的原理,可以得出时间复杂度为O(n)。

递推方程:T(n) = 2T(n/2) + O(1),递归基:T(1) = O(1)。

T(n) + c = 2(T(n/2) + c) =4(T(n/4) + c) = … =n(T(1) + c) = O(n).

例:Max2

从数组区间[lo,hi]中找出最大的两个数A[x1],A[x2],要求比较次数尽可能少。

迭代算法一:

先扫描一遍数组,找到最大元素的下标x1;(比较次数n - 1)

然后依次扫描x1的左边元素和右边元素,找到次大元素的下标;(比较次数n - 2)

总的比较次数固定为2n – 3.

void max2(int A[],int lo,int hi,int &x1,int &x2){
         for(x1 = lo,int i = lo + 1;i < hi;i++)
                            if(A[x1] < A[i])   x1 = i;
         for(x2 = lo,int i = lo + 1;i < x1;i++)
                            if(A[x2] < A[i])   x2 = i;
         for(int i = x1 + 1;i < hi;i++)
                            if(A[x2] < A[i])   x2 = i;
}

迭代算法二:

初始情况,将开始的两个元素中的较大者作为x1的初值,次大者作为x2的初值。

从第三个元素开始扫描数组,首先与x2所在元素比较,大于A[x2],则更新x2,同时与A[x1]比较,大于x1,则交换x1,x2.

最好情况下x1和x2初始值就是max2,则不用交换,从lo + 3到末尾的n – 2个元素只用执行n – 2次比较;

最坏情况下:x2的初始值就是数组的最小值,每次比较都要比较两次,需要进行2n – 4次比较。

再加上最开始的一次比较得到最好情况下比较次数为n – 1,最好情况下的比较次数为2n – 3.

void max2(int A[],int lo,int hi,int &x1,int &x2){
         if(A[x1 = lo] < A[x2 = lo + 1])     swap(x1,x2);
         for(int i = lo + 2;i < hi;i++)
                   if(A[x2] < A[i])
                            if(A[x1] < A[x2 = i])   swap(x1,x2);
}

递归+迭代算法:

将n个数一分为二,分别找到左边的最大值位置x1L,次大值位置x2L,右边的最大值位置x1R,次大值位置x2R,比较两边的最大值大小得到最大值位置,再将两边最大值的较小者和另一边的次大值比较,得到整体次大值的位置。同样的子问题也进行二分递归求解,递归基的情况为不超过三个元素的情况,这里统一进行比较,找出前两个元素的较大者和次大者,再将次大者与最后一个元素进行比较,超过了次大者再与之前的较大者进行比较。

void max2(int A[],int lo,int hi,int &x1,int &x2){
         if(hi <= lo + 3){
                   if(A[lo] > A[lo + 1])    x1 = lo,x2 = lo + 1;
                   else  x1 = lo + 1,x2 = lo;
                   if(A[hi - 1] > A[x2])
                            if(A[x2 = hi - 1] > A[x1])     swap(x1,x2);
         }
         int mi = (lo + hi) / 2;
         int x1L,x2L;
         max2(A,lo,mi,x1L,x2L);
         int x1R,x2R;
         max2(A,mi,hi,x1R,x2R);
         if(A[x1L] > A[x1R]){
                   x1 = x1L;
                   x2 = A[x2L] > A[x1R] ? x2L : x1R;
         }
         else{
                   x1 = x1R;
                   x2 = A[x1L] > A[x2R] ? x1L : x2R;
         }
}

时间复杂度分析:

T(n) = 2 * T(n / 2) + 2,T(2) = T(3) = 3;

T(n) + 2 = 2(T(n / 2) + 2) = …= n / 3 * (T(3) + 2) = 5n / 3.

推出T(n) = 5n / 3 – 2,即使在最坏情况下的比较次数依旧优于2n – 3.

注意上面代码n = 2和n = 3的情况可以分开写,对于n = 2时的递归基仅需要一次比较,T(2) = 1,也就是n / 2 * (T(2) + 2) = 1.5n > 5n / 3的,所以最坏情况写成5n / 3表示n / 3恰为2的整数次幂时的比较次数。

动态规划

通俗意义上讲,动态规划就是先用递归的思想找出问题的本质,再将其等效地转化成迭代的写法。

例:fib();

fib(n) = fib(n-1) + fib(n-2);

递归版本:

__int64 fib ( int n ) { //计算Fibonacci数列的第n项(二分递归版):O(2^n)
   return ( 2 > n ) ?
          ( __int64 ) n //若到达递归基,直接取值
          : fib ( n - 1 ) + fib ( n - 2 ); //否则,递归计算前两项,其和即为正解
}

时间复杂度:以黄金分割数为底数的指数

递归版低效的根源在于大量重复实例的计算和调用。

fib迭代版

法一:记忆化搜索,指标备查,时空间复杂度均为O(n).

法二:动态规划,时间复杂度O(n),空间复杂度O(1)。

__int64 fibI ( int n ) { //计算Fibonacci数列的第n项(迭代版):O(n)
   __int64 f = 1, g = 0; //初始化:fib(-1)、fib(0)
   while ( 0 < n-- ) { g += f; f = g - f; } //依据原始定义,通过n次加法和减法计算fib(n)
   return g; //返回
}

最长公共子序列LCS

比如abcfe和bcdef的最长公共子序列为bce或者bcf

递归的思想:

对于子序列A[0,n],B[0,m],LCS(A,B)有三种情况:

若n = -1或者m = -1说明LCS为空;

若A[n] = B[m] = x,则LCS(A[0,n],B(0,m]) = LCS(A[0,n),B(0,m) ) + x.

若A[n] != B[m],说明A或者B中的末尾元素起码有一个未对LCS起作用,即不再最长公共子序列中,因为如果A,B的最后一个元素都在LCS中,又在末尾,则必相等。此时LCS(A[0,n],B(0,m]) = LCS(A[0,n],B(0,m) )和LCS(A[0,n),B(0,m])中的较长者。

复杂度分析:

最好情况下,即不会出现第二种情况,A和B的末元素一直相等,只需要O(n + m)时间。

最坏情况下,在第二种情况下,一个问题被划分为两个规模相当的子问题,而且子问题进一步导出的子问题还可能雷同。

比如abcde和abcfg,e!=g,转化为两个子问题abcde,abcf和abcd,abcfg,这两个子问题都将导出子问题abcd,abcf,出现了重复的子问题。在最坏情况下,LCS(A[0,a],B[0,b])出现的次数为在n + m – a – b个数中去n – a个数的组合数,当n = m时,LCS(A[0],B[0])出现的次数可多达2^n个。

LCS迭代版

不重复的实例最多有n * m个,利用一个二维数组存储下来即可。

int f[N][N];
int LCS(int a[],int b[]){
         for(int i = 1;i <= n;i++)
        for(int j = 1;j <= m;j++)
            if(a[i] == b[j])    f[i][j] = f[i-1][j-1]+1;
            else    f[i][j] = max(f[i][j-1],f[i-1][j]);
         return f[n][m];
}

总结:

递归:设计出可行且正确的解。

动态规划:消除重复计算,提高效率。

循环位移

使用就地算法(空间复杂度为O(1))将数组A[0,n]中的元素向左循环移动k个单元。

蛮力版算法:

int shift0 ( int* A, int n, int k ) { //蛮力地将数组循环左移k位,O(nk)
   if ( k < 1 ) return 0; int mov = 0;
   while ( k-- ) { //反复以1为间隔循环左移,共迭代k次
      mov += shift ( A, n, 0, 1 );       
   }
   return mov;
}
int shift ( int* A, int n, int s, int k ) { //从A[s]出发,以k为间隔循环左移,O(n / GCD(n, k))
   int bak = A[s]; //备份起始元素
   int i = s, j = ( s + k ) % n; //从该元素出发
   int mov = 0; //移动次数
   while ( s != j ) { //以k为间隔
      A[i] = A[j]; //依次左移k位
      i = j; j = ( j + k ) % n; mov++;
   }
   A[i] = bak; //将起始元素转入对应位置
   return mov + 1;
}

上面的shift函数仅实现了以s为起点,k为间距的序列进行左移k位的操作,蛮力版算法每次移动一位,k次后实现左移k位的功能。

比如1 2 3 4 5,左移2位,首先以开头元素为起点左移一位得到2 3 4 5 1,再次以开头元素为起点移动一位得到3 4 5 1 2,从而实现了左移k位的功能。

设有n个元素,起始下标为0,那么上面的shift函数的循环终止条件s!=j,即首次s与j指针重合表示该趟移动结束设一共需要遍历数组x次才能结束循环,结束时有(n * x)% k = 0

此时,n * x恰好等于lcm(n,k),而每次遍历数组需要移动n / k次,则一共要移动的次数是x * n / k = lcm(n,k) / k,而lcm(n,k) * gcd(n,k) = n * k,从而得到x * n / k = n / gcd(n,k);

迭代版算法:

int shift1 ( int* A, int n, int k ) { //通过GCD(n, k)轮迭代,将数组循环左移k位,O(n)
   if ( k < 1 ) return 0;
   int mov = 0, s = 0;
   while ( mov < n ) { //O(GCD(n, k)) = O(n*k/LCM(n, k))
      mov += shift ( A, n, s++, k );                                      }
   return mov;
}

由于shift函数每次返回的mov等于n / gcd(n,k),而shift1函数中循环会执行n / mov次,所以总的时间复杂度是O(n)。

倒置版算法:

较为常见的算法,先将前k个元素逆置,再将后面的n-k个元素逆置,最后逆置所有元素。

int shift2 ( int* A, int n, int k ) { //借助倒置算法,将数组循环左移k位,O(3n)
   k %= n; //确保k <= n
   reverse ( A, k ); //将区间A[0, k)倒置:O(3k/2)次操作
   reverse ( A + k, n - k ); //将区间A[k, n)倒置:O(3(n - k)/2)次操作
   reverse ( A, n ); //倒置整个数组A[0, n):O(3n/2)次操作
   return 3 * n; //返回累计操作次数,以便与其它算法比较:3/2 * (k + (n - k) + n) = 3n
}

字宽

求幂函数2^n

蛮力做法

__int64 powerBF ( __int64 a, int n ) { //a^n算法(蛮力版):n >= 0
   __int64 pow = 1; //O(1)
   while ( 0 < n ) { //O(n)
      pow *= a; //O(1)
      n--; //O(1)
   }
   return pow; //O(1)
} //powerBF()

按照n的字宽r作为输入规模的衡量标准,r = log2(n + 1),则时间复杂度为O(2^r).

快速幂算法:

__int64 power ( __int64 a, int n ) { //a^n算法:n >= 0
   __int64 pow = 1; //O(1)
   __int64 p = a; //O(1)
   while ( 0 < n ) { //O(logn)
      if ( n & 1 ) //O(1)
         pow *= p; //O(1)
      n >>= 1; //O(1)
      p *= p; //O(1)
   }
   return pow; //O(1)
} //power()

时间复杂度O(r)。

下界

代数判定树

基于比较的算法CBA(comparision-based algorithm):算法执行的进程,取决于一系列的数值(关键码)比对结果。

任何CBA在最坏情况下,都需要以nlogn为下界的时间才能完成排序。

关键词:CBA + 最坏情况 + 下界。

代数判定树ADT(Algebraic Decision Tree)

每个CBA算法都对应于一棵判定树,比较树是一棵三叉树,每匹叶子对应于一个输出,而树高则表示最坏情况下所需的比较次数,树高的下界也即CBA时间复杂度的下界。

对n个元素进行排序的任何一棵ADT,高度的下界是nlogn,树的叶子结点>=可能的输出=n!

  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值