本系列文章源自左程云(左神)的课程笔记,部分节选自清华大学姜海老师的数据结构与算法课堂,在次系列文章中,既对姜老师和左神所讲的知识点进行了全面的梳理、又针对每一类算法使用C++进行实践,并给出完整案例。希望能够对大家有所帮助。若对课程感兴趣,也欢迎在b站上搜索“一周刷爆LeetCode”,获取左神原版课程。
一、复杂度
1、时间复杂度
1.1 通俗表述
复杂度,通俗的讲,就是在一个算法实现过程中,进行了多少次常数操作。常数操作,就是看了多少眼,进行了多少次比较,进行了多少次交换(诸如此类),将这些相加,只看高阶项,就形成了我们的时间复杂度。
1.2 最好/最坏/平均时间复杂度
最好时间复杂度,顾名思义,就是在最理想条件下程序运行的时间复杂度。而最坏时间复杂度,是程序在最不理想的条件下运行时的复杂度。
然而,在评价一段代码优劣的时候,最好/最坏时间复杂度都是极为少见的。我们一般采用平均复杂度。他把每种情况下的复杂度加起来,然后除以情况的个数,所得的值就是平均复杂度,类似于数学上的均值。
至于更深刻的部分,若有补充,我将在下学期上完我们学校的《数据结构与算法》课程后,对此进行添加,但目前为止,理解这些就已经足够。
1.3 时间复杂度一样,如何比较两个算法孰优孰劣?
若两个算法的时间复杂度相同,我们想要比较其运行时间。对于一些算法,你可以找到线上的OJ平台进行测评,但是这并不保准,不仅如此,对于某些新算法,线上的OJ平台根本没有对应的题目,所以这时候我们需要一种更加普适的方法,让我们能够测评所有想测评的算法。
这时候我们就引入了“对数器”,关于对数器,左神是这样定义的:
1.有一个你想要测的方法a;
2.有一个绝对正确但是复杂度不好的方法b;
3.先做一个随机样本产生器;
4.用产生的随机样本实现对比算法a和b的方法;
5.把方法a和方法b比对多次来验证方法a是否正确;
6.如果有一个样本使得比对出错,打印样本分析是哪个方法出错;
7.当样本数量很多时比对测试依然正确,可以确定方法a已经正确
在实操层面,应该是这样的:
//有一个想测的方法a,正常的方法b;
//生成一个随机样本产生器(可控长度),在a里跑一遍,在b里跑一遍,比较是否一样
//生成随机数——数组长度随机——copy arr1成为arr2——对arr1和arr2分别用两种方法——使用一个is true判断是否所有结果都一样
对数器的架构如下,参考“日子总要往前走”的文章可知:
compareNumGeneratorSort.h
#ifndef __COMPARE_GENERATOR__
#define __COMPARE_GENERATOR__
#include<iostream>
#include<vector>
#include<ctime>
#include<cstdlib>
#include<algorithm>
#include<random>
#define random() rand()/double(RAND_MAX)
using namespace std;
struct Array{
vector<int> ivec;
};
class compareGenerator{
public:
Array generateRandomArray(int,int);
void rightMathod(Array&);
const Array& copyArray(const Array&);
bool isEqual(const Array&,const Array&);
void printArray(const Array&);
};
#endif
compareNumGeneratorSort.cpp
#include"compareNumGeneratorSort.h"
Array compareGenerator::generateRandomArray(int size,int value){
static default_random_engine e;
static uniform_int_distribution<unsigned> u1(0,size);
static uniform_int_distribution<signed> u2(-value,value);
int length = u1(e);
Array temp;
for(int i=0 ; i<length ; i++){
temp.ivec.push_back(u2(e));
}
return temp;
}
void compareGenerator::rightMathod(Array& array){
sort(array.ivec.begin(),array.ivec.end());
}
const Array& compareGenerator::copyArray(const Array& array)
{
return array;
}
bool compareGenerator::isEqual(const Array &array1,const Array &array2){
if((array1.ivec.size() == 0 && array2.ivec.size() != 0) ||
(array1.ivec.size() != 0 && array2.ivec.size() == 0))
{
return false;
}
if(array1.ivec.size() == 0 && array2.ivec.size() == 0)
{
return true;
}
if(array1.ivec.size() != array2.ivec.size())
{
return false;
}
for(int i = 0; i < array1.ivec.size(); i++)
{
if(array1.ivec[i] != array2.ivec[i])
{
return false;
}
}
return true;
}
void compareGenerator::printArray(const Array& array){
for(auto i:array.ivec)
{
cout<<i<<" ";
}
cout<<endl;
}
那就让我们以冒泡排序为例,练习一下对数器的使用吧:
2、空间复杂度与额外空间复杂度
问题的规模:占据的空间是多少byte/bit。要注意资源的消耗和问题的规模是什么关系,以此来评判算法的优劣。
空间复杂度,是指一个算法运行的过程占用的空间,这个空间包括输入参数的占用空间和额外申请的空间。
额外空间复杂度,是指一个算法运行过程中额外申请的空间。(一般也用来比较算法的优劣)
如果程序运行过程中,不需要额外的数据结构,只是使用了几个变量。那么额外空间复杂度为O(1);
如果要申请一个和原数组大小成比例关系的数组,那额外空间复杂度为O(n);
二、异或操作
排序离不开swap(对两数字进行交换操作),在进行对排序操作的介绍之前,我们先简要介绍一个有趣的swap两数的方法,那就是异或操作。
1、异或操作基本规则
若要想讲到异或操作的底层原理,最好要从晶体管的连接方式与逻辑门讲起。但是在算法应用层面,我们只需要了解异或操作的规则和性质即可。
对于异或操作,可以用一句话概括,那就是:(相同为0,不同为1)
如果a、b两个值不相同,则异或结果为1。如果a、b两个值相同,异或结果为0。
异或运算,就是无进位二进制加法~
2、异或操作的一些性质
异或操作满足交换律、也满足结合律,就是说
1)交换律:a ^ b = b ^ a。
2)结合律:a ^ b ^ c = a ^ (b ^ c) = (a ^ b) ^ c;
不仅如此,异或操作还有:
0^N=N;N^N=0;
3、异或操作的应用
异或操作神奇在哪儿?就神奇在他奇妙的运算律上,我们将列举两个例子,来为大家详解异或操作的应用场景。
3.1 swap函数与交换操作
正常的swap函数,应该是这样的
int swap(int a, int b){
cout<<a<<b;
int temp;
temp = a;
a = b;
b = temp;
cout<<a<<b;
}
但是若使用异或操作,则可不需要使用temp作为中间变量
int swap(int a, int b) {
cout << a << b;
a = a ^ b;
b = b ^ a;//b= b^(a^b)=a^(b^b)=a^0=a
a = a ^ b;//a= (a^b)^a=b^(a^a)=b^0=b
cout << a << b;
}
//前提:a和b在内存中是两块独立的区域,就是在array中可以交换,但是i位置不能等于j位置
3.2 一道LeetCode算法题
一个整形数组中只有一种数出现了奇数次,其他的所有数都出现了偶数次,如何找到出现了奇数次的数?要求时间复杂度O(N)。
一个初步思路其实是,定义一个变量eor,用eor逐个与数组中的每一个数进行异或运算,若出现了偶数次,偶数次个数字一起做异或运算,结果为零,所以留下来的就只是出现奇数次的那个数。(原因:异或运算顺序无所谓,偶数个相同数字异或完就是零)。所以我们将这一思路的算法进行实现:(在实际实现过程中,我们使用数组中的第一个元素与后续元素逐个进行异或操作,最后输出整体异或完成后的数字)
class Solution {
public:
int singleNumber(vector<int>& nums) {
for(int i =0;i < nums.size()-1;++i)
{
nums[0] ^= nums[i+1];
}
return nums[0];
}
};
如果有两种数出现了奇数次,找这两个数。
解题思路:设两个出现奇数次的数是a和b,剩下的都出现了偶数次,若给我们一个eor,让他对整个数组中的所有元素进行异或,异或一遍后,结果为eor=a^b;而且a!=b,既eor!=0;
从二进制数字形式的表示来看,eor的某一位上,至少有一位上不等于零,,现在假设eor在第八位上不是零而是1,,说明a和b在第八位上是不一样的,则此时我们再引入一个变量lowbit,这个lowbit再次去异或数组中的数,但是此时,我们只让lowbit异或上第八位不是1的那些数,则lowbit得到a或者b了。
也可以定义一个函数f=lowbit(x),这个函数的值是x的二进制表达式中最低位的1所对应的值。(lowbit的原理是简单的原码补码的相互转换,在这里不过多叙述)
class Solution {
public:
vector<int> singleNumber(vector<int>& nums)
{
unsigned int eor = 0;
// 一对相同的数字异或为0
// 所以eor为两个只出现了一次的元素的异或
for (auto n: nums) //注意for遍历vector也可以这样写
{
eor ^= n;
}
// 因为两数不同,lowbit必然不为0
// 物理意义就是两个不同的数字不同的最低的位在哪
int lowbit = eor & (-eor);
int a = 0;
int b = 0;
// 重复上述的过程,但是将nums按照lowbit为1或者为0分类
// 则两个数必然被分到不同的类目;而相同的数字一定在同一个类目
// 所以按类目分别异或就可以得到两个不同的数字
for (auto n: nums)
{
if (n & lowbit)
{
a ^= n;
}
else
{
b ^= n;
}
}
vector<int> ans;
ans.push_back(a);
ans.push_back(b);
return ans;
}
};
三、排序操作
百度百科上说,算法中的排序方法主要有八种:(1)冒泡排序;(2)选择排序;(3)插入排序;(4)希尔排序;(5)归并排序;(6)快速排序;(7)基数排序;(8)堆排序;(9)计数排序;(10)桶排序。接下来我们将逐一介绍并逐一进行实操(本章笔记先介绍其中三个):
2.选择排序(selection sort)
n个整数的选择排序的size:2nbytes,想法是:遍历所有的数,找到最小的数,放到第一位,再把余下的再看一遍,挑出最小的,一直进行直到所有小数都挑选完。
选择排序实例:
leetcode:给一个数组nums,要求升序排列。
class Solution {
public:
vector<int> sortArray(vector<int>& nums) {
// selectSort 选择排序
//遍历数组,找到最大的数,直到所有大数都挑选完
int minIndex;//定义一个最小值所在的数组下标
int n = nums.size();//将n代替数组大小,便于记忆
for (int i = 0; i < n - 1; ++i) {
minIndex = i;
for (int j = i + 1; j < n; ++j) {
if (nums[j] < nums[minIndex]) {
minIndex = j;
}//内层循环,如果数组中元素小于最小值,则把他命名为最小值
}//内层循环结束后,我们已经找到了所有元素中的最小值
swap(nums[i], nums[minIndex]);//在i=0时,将零号位置元素与最小值交换
}//外层循环是一遍遍挑出最大的数,直到没有可挑的为止
return nums;
}
};
分析run time:赋值次数、判断次数、循环次数一起进行四则运算。
关于数据的存储,可以使用数组或者容器,但是还有一种可行的方法,考虑一颗树
3.插入排序(insertion sort)
在第i次循环时,确保前i个数是升序排列的
9.鸽巢排序
鸽巢排序(Pigeonhole sort),也被称作基数分类,是一种时间复杂度为O(n)(大O符号)且在不可避免遍历每一个元素并且排序的情况下效率最好的一种排序算法
给定一个数组,假设他最大的数字是m,那么自己再开一个大小为m的新数组,原数组中有什么数,就在新数组下面划线,最后依次遍历新数组(看哪个格子不是零,则开始对外输出啦)
8.堆排序(heap sort)
step1:10最大,拿走10
step2:把底下的6放在10的位置,然后把6和【7和9中最大的数(9)】进行交换
堆排序实例:
leetcode:给一个数组nums,要求升序排列。
class Solution {
void buildMaxHeap(vector<int>& nums) {
int n = nums.size();
for (int i = (n - 1) / 2; i >= 0; --i) {
maxHeapify(nums, i, n);
}
}
void maxHeapify(vector<int>& nums, int i, int n) {
while (i * 2 + 1 < n) {
// 代表当前 i 节点的左右儿子;
// 超出数组大小则代表当前 i 节点为叶子节点,不需要移位
int lSon = 2 * i + 1;
int rSon = 2 * i + 2;
int large = i;
if (lSon < n && nums[lSon] > nums[i]) large = lSon;
if (rSon < n && nums[rSon] > nums[large]) large = rSon;
if (large != i) {
swap(nums[i], nums[large]);
// 迭代判断对应子节点及其儿子节点的大小关系
i = large;
} else {
break;
}
}
}
public:
vector<int> sortArray(vector<int>& nums) {
// heapSort 堆排序
int n = nums.size();
// 将数组整理成大根堆
buildMaxHeap(nums);
for (int i = n - 1; i >= 1; --i) {
// 依次弹出最大元素,放到数组最后,当前排序对应数组大小 - 1
swap(nums[0], nums[i]);
--n;
maxHeapify(nums, 0, n);
}
return nums;
}
};
5.归并排序(merge sort)
持续分治,把大任务平均多次变成小任务,可以多次调用mergesort函数
归并排序实例:
leetcode:给一个数组nums,要求升序排列。
class Solution {
vector<int> tmp;//准备一个辅助空间
void mergeSort (vector<int>& nums, int l, int r) {
//l和r的意思就是相当于数组中最小序号为l,最大序号为r
if (l >= r) return; // 最小序号大于等于最大序号,不需进行排列
int mid = l + (r - l) / 2;//这样写中点,可以防止溢出。l+((r-l)>>1)也可以且更快
// 自底向上
mergeSort(nums, l, mid);
mergeSort(nums, mid + 1, r);
//相当于是通过序号将整个数组进行二分,对二分后的每一半进行mergesort递归
// 排序当前数组
int i = l, j = mid + 1, pos = l;
while (i <= mid && j <= r) {
if (nums[i] <= nums[j]) {
tmp[pos] = nums[i];
++i;
} else {
tmp[pos] = nums[j];
++j;
}
++pos;
}
for (int k = i; k <= mid; ++k) {
tmp[pos++] = nums[k];
}//如果左边没越界,那就把左边剩下的东西拷贝到tmp里去
for (int k = j; k <= r; ++k) {
tmp[pos++] = nums[k];
}//如果右边没越界,那就把右边剩下的东西拷贝到tmp里去
copy(tmp.begin() + l, tmp.begin() + r + 1, nums.begin() + l);
}
public:
vector<int> sortArray(vector<int>& nums) {
int n = nums.size();
tmp = nums;
mergeSort(nums, 0, n - 1);//重复上述操作许多次
return nums;
}
};
四、做乘法也可以简化
step1:第一个数是奇数时,把第二个数字抄到第三列,然后把第一个数除二向下取整。
把每一个四位数拆成两位数,对应两位数分别相乘,然后错位即可。
这个算法对应的实际上,就是上一个解法中的先拆分再错位。
但是由于(100w+x)(100y+z)=10000wy+100wz+100xy+xz,可以用(w+x)(y+z)=wy+wz+xy+xz与原式子进行多次加减,则只需进行三次数据运算(求出wy,xz,(wz+xy))即可实现。
五、算法效率问题
1.Best Case和Worst Case
最好的情况:只遍历,不交换,都不进入while循环
最差的情况:所有的元素都要进入for循环,每一个while都要走到最后一步
关注worst case:对于response time有很高要求的(核反应堆)
关注average case:在不同地方不停运用(导航)//更难分析,需要了解instance分布
2. Principle of Invariance
当我们对算法进行修改时,运行时间会发生变化,且这种变化会随着程序规模的变化而逐渐变得显著。
只需要考虑n和n^2和nlogn....不必care其常数,因为我们只需要考虑算法在规模足够大的场景下的表现,(有时在实际中要考虑常数)
hidden constant 不能被无脑忽略 ep:n^2days and n^3seconds
elementary operation 不带n的与n无关的固定值,所以加法乘法比较都是elementary operation,但是for循环不是。
barometer instruction: 一段算法中执行次数最多的指令,