【数据结构】数组知识汇总/力扣题目整理

数组基础知识

数组,是一种将数据按照次序一个一个线性排列的数据结构,通常在内存中占用一块连续的存储空间,依靠首地址+地址偏移量即可访问到每一个元素。

数组的类型

根据实现形式分为普通静态数组和c++中的动态数组
静态数组 :通过定义变量来使用

//一维数组
type name[size] = {element0,element1,element2...}
//二维数组
type name[M][N] = 
{{row1_column1,row1_column2,......row0_columnN},
{row2_column1,row2_column2,......row1_columnN},
......
{rowM_column1,rowM_column2,......rowM_columnN}}

在程序运行期间每个数组所占用的内存空间是预先确定好的,在数组初始化时其元素个数必须是常量,运行时也不能改变空间大小。

索引数据的方式: 对于一维数组,变量名name本身存放的是数组首个元素的地址,类似一个指针变量。对于二维数组,name[row]存放的是第row行的首元素地址。

//一维数组
1.name[index]
2.*(name+index)

//二维数组
1.name[row][column]
2.*(name[row]+column)

c++中的动态数组: 如vector、deque,实质上是封装的一个类,可以动态向内存中申请空间来扩容数组。

数组占据的内存

对于普通数组:

存储的数据在堆上还是在栈上由是否new决定:
变量名本身代表数组首个元素的地址,因此如果使用new,数组就在堆上开辟内存空间,否则则在栈上开辟空间。

对于动态数组:

存储的数据在固定上:
如vector vec,vec本身是在栈上存储,而存放的元素则是在堆中存储,也就是说vec本身占据的内存是固定的(size、capacity等),与存放多少元素无关。

数组存放数据的方式

对于已知数据data,可以是基本数据类型,也可以是自定义的类,将其存放在数组中涉及到是想存放一份拷贝数据,还是源始数据?

存放拷贝数据:
为数据另外开辟一片内存,放入数组的时候拷贝值,之后这两个元素就相互独立,有两份内存,非指针的形式下数据都是以拷贝数据的形式放入数组的。
例如:此时a和vec[0]是两块内存

vector<int> vec;
int a = 1;
vec.push_back(a);

存放本体数据:
只是存放源始数据的内存地址,这样之后操作数组都是操作的原始数据,这种方式可以通过存放指针来实现。
例如:此时vec[0]就是a的地址,他们是同一份数据

vector<int*> vec;
int a = 1;
vec.push_back(&a);

数组的操作

1.添加元素

1.添加元素
由于数组结构中各元素在内存中的有序性,向末尾添加元素只需要在末尾地址处放入数据即可,而向中间位置插入元素就需要大量数据在内存中整体平移。
对于静态数组,其所占内存空间是预先确定好的,只能操作有效范围内的地址空间。
对于动态数组vector,当前空间已满时,会重新申请一块1.5/2倍原空间大小的内存,将源数据拷贝到新空间后,再释放原来的内存。

向 vector 添加元素:
插入元素可以使用 push_back() 和 emplace_back(),在这个过程涉及到了右值引用模板推导移动构造new原地构造等基础概念,为了方便解释,以具体的例子来说明。

测试说明: 向vector中存放testclass类
testclass类中使用成员指针变量content* pointer,来指向在堆中实际存放资源content类。

class content {
public:
    content(int a,int b) : val1(a),val2(b){};
    content(const content& val) : val1(val.val1), val2(val.val2) {};
    int val1 = 0;
    int val2 = 0;
};
class testclass
{
    public:
    testclass() : a(0),b(0){
            pointer = nullptr;
    };
    testclass(int v1) : a(v1),b(v1){
        pointer = new content(v1, v1);
    };
    testclass(int v1,int v2):a(v1),b(v2) {
        pointer = new content(v1, v2);
    }
    //拷贝构造函数必须是const class & name
    //1.const
    //2.&
    testclass(const testclass& val) :a(val.a), b(val.b) {
        if (val.pointer) {
            pointer = new content(*val.pointer);
        }
    }
    //移动构造函数:移动资源所有权,源数据失效
    testclass(testclass&& val) :a(val.a), b(val.b) {
        if (val.pointer) {
            this->pointer = val.pointer;
            val.pointer = nullptr;
        }
    }
    int a;
    int b;
    content* pointer = nullptr;
    ~testclass(){
        if (pointer) {
            delete pointer;
            pointer = nullptr;
        }
    }
};

其中:

左值/右值/引用/完美转发

左值和右值是编译器对一个变量符号的属性定义,简单来说,

  • 左值:如果一个变量名在实际内存地址中开辟了空间,那么他是属于左值。
  • 右值:而右值则是"asdda"、1、1.5f等这种抽象意义上的一个数值,在一个表达式中,这个数值本身并不在内存中开辟空间,(临时对象会开辟,表达式结束释放)。
  • 引用:引用实质上是一个指针常量(* const),在内存中占据8个字节,内容则是指向资源的地址值,所以引用本身显然是一个左值。
  • 左值引用(type &):引用的内容指向一个左值,初始化时也只能使用左值来初始化。
  • 常量左值引用(const type &):比较特殊,既可以指向左值,也可以指向右值。
  • 右值引用(type &&):本身是一个左值,但指向了一个右值,这个右值其实只是编辑器所定义的一种属性,并不一定真的是右值,一个左值也可能转化为右值,如使用std::move(左值 val),这个move函数返回值就被认定为是一个右值。
    事实上std::move函数所做的事情只是把输入用static_cast强行转为一个右值(属性)
_EXPORT_STD template <class _Ty>
_NODISCARD _MSVC_INTRINSIC constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept {
    return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}

如果初始化右值引用为一个真实的右值,会将此右值储存在内存的常量区,而将引用指向此地址,此时右值引用可以当作一个普通变量使用。
下方代码中,1会被存储在常量区中的4字节,right_refer 本身会存储在栈区的8个字节中,指向常量区的1所在的地址,right_refer 可以看作是一个int val 来使用。

int && right_refer = 1;
right_refer  = 2;//ok

如果初始化右值引用为一个虚假的右值(由左值cast为右值),那么就会将此引用指向这个左值的地址,此时右值引用就相当于这个左值。
下方代码中,使用std::move()将左值a转换为右值,用来初始化right_refer ,则right_refer 本身在栈区的8个字节中存放的是a的地址,right_refer 等价于 a;

int a = 1;
int && right_refer = std::move(a);
right_refer = 2;//则 a = 2
  • 临时对象::临时对象的属性是右值,运行到该行代码时,临时对象会短暂的分配到一块内存(通过构造函数在栈中分配,有new的在堆中分配),在该行代码结束后会调用类的析构函数,释放所分配的内存。

  • 左值/右值属性的丢失:函数传递参数的过程中,有时会发生参数类型的丢失,比如把一个右值属性传递为左值属性。这会造成对象在赋值拷贝过程中的资源浪费。举例来说,会比较直观:

对于一个入口参数为右值引用的函数right_refer_caller,我们希望传递进来一个右值,当然right_refer_caller(testclass(1,2))这种使用临时变量传参的,是可以通过编译的。

void right_refer_caller(testclass&& arg) {
    std::cout << &arg << std::endl;
}

但是,对于右值引用right_refer ,其本身是一个左值,绑定了一个右值上(临时对象testclass (1, 2)),我们希望能在right_refer_caller函数中传递right_refer 绑定的右值,但是直接传递right_refer是错误的,因为right_refer本身是一个左值,编译出错:因为函数形参testclass&& arg是一个右值引用右值引用只能绑定到右值,因此解决方案是:使用std::move函数,将right_refer这个右值引用强行转换成一个右值属性。

    testclass&& right_refer = testclass (1, 2);
    right_refer_caller(right_refer);//编译出错,right_refer本身是一个左值
    right_refer_caller(std::move(right_refer));//编译成功

属性丢失 的 现象 初见端倪,我们再看下面这种普通的重载函数refer_caller,
入口参数分为&&右值引用和&左值引用的形式两个版本

void refer_caller(testclass&& arg) {
    std::cout << &arg << std::endl;
}
void refer_caller(testclass& arg) {
    std::cout << &arg << std::endl;
}
  • 传递右值引用right_refer时,调用的是左值引用版本函数refer_caller(testclass& arg),参数被当成了左值对待。
    造成的结果: 如果使用该参数进行类的构造工作,就会调用拷贝构造函数,触发了深拷贝机制,对于临时对象内new出来的资源进行了二次拷贝,而这部分资源我们是可以利用直接转移到新对象中的。

  • 传递右值引用right_refer move后的右值时,调用的是右值引用版本函数refer_caller(testclass&& arg),参数被当成右值对待。
    造成的结果: 这样在类的构造函数中,会正确的调用移动构造函数,节省一部分性能损失。

 testclass&& right_refer = testclass(1, 2);
 refer_caller(std::move(right_refer));//成功,调用右值引用版本
 refer_caller(right_refer);//成功,调用左值引用版本
  • 模板中的通用引用&&:
    对于模板函数tem_func,将其入口参数类型写为T&& arg,这种引用不是左值引用,也不是右值引用,而是通用引用,可以根据函数的输入类型动态调整参数的类型是左值引用还是右值引用。amazing…
    还是举例来说:tem_func模板函数里调用转发函数refer_caller,有两个重载版本,一个接收左值,一个接收右值。
void refer_caller(testclass& x) {
    std::cout << &x << std::endl;
}
void refer_caller(testclass&& x) {
    std::cout << &x << std::endl;
}
template <typename T>
void tem_func(T&& arg) {
	refer_caller(arg);
}

left_value 是一个左值,向模板函数传左值时,会推断出arg为左值引用类型,也就是 testclass &
std::move(left_value)是一个右值,向模板函数传右值时,会推断出arg为右值引用类型,也就是也就是 testclass &&

testclass left_value = testclass(1, 2);
tem_func(left_value);
tem_func(std::move(left_value));
  • 完美转发:有了通用引用的概念后,模板函数中如果涉及到参数转发,也就是函数嵌套函数传参的过程,那就会遇到参数的完美转发与不完美转发。
    举例来说:一个左值left_value ,一个右值引用right_refer ,一个右值std::move(right_refer)
    testclass left_value = testclass(1, 2);
    testclass&& right_refer = testclass(1, 2);
    perfect_caller(left_value);
    perfect_caller(std::move(right_refer));

调用模板函数perfect_caller的过程中:

  • 传递 左值 时函数参数 x 会被推导成左值引用,这时候在函数转发中不受影响,能够正确传递左值。
  • 传递 右值 时函数参数 x 会被推导成右值引用,在函数转发中,例如一个重载版本的perfect_forwarder函数中:
    如果直接调用perfect_forwarder(arg),那么会调用左值引用版本的perfect_forwarder,与我们希望传递一个右值类型的数据的初衷相违背。于是为了实现完美转发,也就是不丢失传递参数的右值属性,我们可以使用move函数包裹参数,但是这样会无脑转右值,原本人家就是左值,也给人家转成右值了,不合理。

std::forward函数被用来实现有条件的右值转换:

  • 当参数为左值引用时,不转换,仍然返回左值引用。
  • 当参数为右值引用时,将其转换为右值属性,类似std::move

于是在模板函数中涉及到参数转发(调用别的函数并传递本函数的参数)常用std::forward函数包裹参数,从而保证参数的右值属性不丢失这样就能调用类的移动拷贝函数啦! 就能节省资源啦!棒(๑•̀ㅂ•́)و✧(就为这点醋包的这顿饺子)

void perfect_forwarder(testclass& x) {
    std::cout << "perfect_forwarder testclass& x arr: " << &x << std::endl;
}
void perfect_forwarder(testclass&& x) {
    std::cout << "perfect_forwarder testclass&& x arr: " << &x << std::endl;
}
template <typename T>
void perfect_caller(T&& arg) {
    std::cout << "perfect_forwarder T&& arg arr: " << &arg << std::endl;
    perfect_forwarder(std::forward<T>(arg));//完美转发
    perfect_forwarder(arg);//不完美转发
}

C++ 完美转发深度解析:从入门到精通

类的构造函数
  • 有参构造函数:使用new 在堆中开辟内存,存放content资源,并初始化pointer 指针
  • 拷贝构造函数:入口参数为const &,意为不能修改源数据。使用深拷贝的方式,在拷贝一份源数据pointer 指向的content资源
  • 移动构造函数:入口参数为右值引用&&,只能接收右值。使用浅拷贝的方式,通过移动源数据pointer 对资源的所有权,也就是初始化this.pointer指向源数据content地址,再将源数据pointer制空,避免了堆中内存的再次拷贝,此时源数据资源引用指针失效。常用来通过右值构造对象的时候,节省一次临时变量的拷贝操作,保留临时对象中用new开辟的堆内存,直接将其转交给新对象。
  • 析构函数:需要释放pointer指向的堆中内存。注意当有继承关系时要写为虚析构。

以下面代码为例

    testclass(1,2);
    testclass val1(1, 2);
    testclass val2(testclass(1, 2));
    testclass val3 = 1;
    testclass val4 = testclass(1, 2);
    testclass val5 = val2;
    testclass val6(val5);
    testclass val7(std::move(val6));
  • 第一行testclass(1,2):是一个临时对象
  • testclass val1(1, 2):调用有参构造初始化val
  • testclass val2(testclass(1, 2)):
    先构造临时对象,此时临时对象存在在栈内存中
    ② 再用这个临时对象调用拷贝构造函数初始化val2,此时堆中有两份content的内存。
    ③ 此行结束后会调用临时对象的析构函数释放其刚刚开辟的内存,包括堆上new的。
    从这个过程也可以发现临时对象new的资源其实我们可以利用的,可以将其指针所有权交给新对象,这也是移动构造函数的原理。
  • testclass val3 = 1:
    ① 首先 = 1 触发了类的有参构造函数的隐式调用,也就是先调用了testclass (1),生成了一个临时变量。可以同过在构造函数前头加上一个explict禁止这种隐式类型转换,来禁止这种自动调用构造函数的行为。
    ② = 运算符是调用了一个自动生成的函数,我们可以用operator = 来重载该运算符; = 操作类似于拷贝构造函数,完全复制一份新的内存。
    ③ 释放临时对象。
  • testclass val4 = testclass(1, 2) : 和上面情况类似
  • testclass val5 = val2 : 类似于调用拷贝构造函数
  • testclass val6(val5) : 直接调用拷贝构造函数
  • testclass val7(std::move(val6)): 调用移动构造函数,移动之后,val6的pointer被制空,原来指向的资源现在由val7的pointer管理,所以理论上,这样操作之后val6相当于被遗弃不能再用了。
vector构造元素的过程

优先考虑emplace_back而不是push_back

为什么说push_back的性能没有emplace_back性能好?

先来看一下push_back() 和 emplace_back() 底层都是啥:

push_back()

  1. 两个重载版本,一个接收左值,一个接受右值
  2. 只允许接收一个参数
  3. 会将输入参数隐式转换为vector<_Ty>定义的类型
  4. 在输入参数为右值版本中,使用了_STD move(_Val)来传递参数,从而确保在函数转发中参数始终是右值属性。
  5. 如果输入的是有参构造的参数,会在内部产生一个临时对象
    _CONSTEXPR20 void push_back(const _Ty& _Val) { // insert element at end, provide strong guarantee
        _Emplace_one_at_back(_Val);
    }

    _CONSTEXPR20 void push_back(_Ty&& _Val) {
        // insert by moving into element at end, provide strong guarantee
        _Emplace_one_at_back(_STD move(_Val));
    }

emplace_back()

  1. 使用模板编程,输入参数类型_Valty&& 可实现模板推导出,输入的数据是左值还是右值类型。
  2. 输入参数可多个: (_Valty&&… _Val)… 可不限制输入参数个数。
  3. 使用_STD forward<_Valty>(_Val)函数来达到完美转发的目的,也就是在函数嵌套函数转发参数时,能确保参数类型不变,主要是确保左值/右值的属性不发生改变。
  4. 不会隐式地产生临时对象: 允许输入有参构造的多个参数,以完美转发的形式传递到真正new 对象的时候。
public:
    template <class... _Valty>
    _CONSTEXPR20 decltype(auto) emplace_back(_Valty&&... _Val) {
        // insert by perfectly forwarding into element at end, provide strong guarantee
        _Ty& _Result = _Emplace_one_at_back(_STD forward<_Valty>(_Val)...);
#if _HAS_CXX17
        return _Result;
#else // ^^^ _HAS_CXX17 / !_HAS_CXX17 vvv
        (void) _Result;
#endif // _HAS_CXX17
    }

push_back和emplace_back 的不同体现在:

  1. push_back接收参数后会发生隐式类型转换,且只能接收一个有参构造中的参数,不支持带有多个参数的有参构造的类型转换。
  2. emplace_back接收参数后不会发生隐式类型转换,且可以接收多个参数

如何使用push_back和emplace_back 来优化性能

  1. 如果放入的构造函数的参数,使用emplace_back,避免生成临时对象
  2. 如果放入的是右值/临时对象,使用std::move包裹参数,以使用移动构造,这只对类内有new资源的情况有效,且两者都能实现
  3. 如果放入的是左值,两者效果一致,传递参数时使用左值引用传递,都使用拷贝构造new

分析一下vector是如何开辟内存的:
首先是_Emplace_one_at_back()函数,可以看到里面都是用的模板编程+完美转发的方法。

    template <class... _Valty>
    _CONSTEXPR20 _Ty& _Emplace_one_at_back(_Valty&&... _Val) {
        // insert by perfectly forwarding into element at end, provide strong guarantee
        auto& _My_data   = _Mypair._Myval2;
        pointer& _Mylast = _My_data._Mylast;

        if (_Mylast != _My_data._Myend) {
            return _Emplace_back_with_unused_capacity(_STD forward<_Valty>(_Val)...);
        }

        return *_Emplace_reallocate(_Mylast, _STD forward<_Valty>(_Val)...);
    }

当内存足够时,直接在size处调用_Emplace_back_with_unused_capacity()

    template <class... _Valty>
    _CONSTEXPR20 _Ty& _Emplace_back_with_unused_capacity(_Valty&&... _Val) {
        // insert by perfectly forwarding into element at end, provide strong guarantee
        auto& _My_data   = _Mypair._Myval2;
        pointer& _Mylast = _My_data._Mylast;
        _STL_INTERNAL_CHECK(_Mylast != _My_data._Myend); // check that we have unused capacity
        if constexpr (conjunction_v<is_nothrow_constructible<_Ty, _Valty...>,
                          _Uses_default_construct<_Alloc, _Ty*, _Valty...>>) {
            _ASAN_VECTOR_MODIFY(1);
            _Construct_in_place(*_Mylast, _STD forward<_Valty>(_Val)...);
        } else {
            _ASAN_VECTOR_EXTEND_GUARD(static_cast<size_type>(_Mylast - _My_data._Myfirst) + 1);
            _Alty_traits::construct(_Getal(), _Unfancy(_Mylast), _STD forward<_Valty>(_Val)...);
            _ASAN_VECTOR_RELEASE_GUARD;
        }

        _Orphan_range(_Mylast, _Mylast);
        _Ty& _Result = *_Mylast;
        ++_Mylast;

        return _Result;
    }

没啥异常的话就调用_Construct_in_place(*_Mylast, _STD forward<_Valty>(_Val)…),这里会走else分支。

template <class _Ty, class... _Types>
_CONSTEXPR20 void _Construct_in_place(_Ty& _Obj, _Types&&... _Args) noexcept(
    is_nothrow_constructible_v<_Ty, _Types...>) {
#if _HAS_CXX20
    if (_STD is_constant_evaluated()) {
        _STD construct_at(_STD addressof(_Obj), _STD forward<_Types>(_Args)...);
    } else
#endif // _HAS_CXX20
    {
        ::new (_Voidify_iter(_STD addressof(_Obj))) _Ty(_STD forward<_Types>(_Args)...);
    }
}

这里面的::new (_Voidify_iter(_STD addressof(_Obj))) _Ty(_STD forward<_Types>(_Args)…);就是利用new中的placement new函数,在指定地址构造元素(vector的机制已经确保此地址处有足够大小内存)

在指定地址处构造元素:
::new (地址值) type(参数) 可以在指定地址处构造元素,前提是内存已经分配好了,也就是说,placement new 并不分配内存。

其实new的用法也有多种,new的机制也值得在这里整理一下:

new/delete:如何分配内存

C++ 内存分配(new,operator new)详解
C/C++——new和delete的实现原理(详解)

new是一个操作符,底层涉及三个操作:

  1. 分配内存:调用operator new函数(此函数可以自己重载,来实现自定义管理内存),这个函数又会调用c库malloc函数,根据类型计算类大小,在堆中开辟一块内存。

    这两个函数都会返回一个void*指针,区别在于,malloc不会抛出异常,operator new默认会进行检查,内存不足时抛出异常。

    此时只是在内存中开辟了一块指定大小的内存,并返回一个首地址指针,内存是未初始化的。

malloc(sizeof(testclass));
operator new(sizeof(testclass));
  1. 调用构造函数:在内存处,调用构造函数初始化内存的值
    这个行为是隐式发生的,但是我们可以手动调用构造函数
    testclass* point = (testclass*)malloc(sizeof(testclass));
    point->testclass::testclass(1, 2);

也可以使用placement new 在指定内存地址处原地构造

    testclass* point= (testclass*)malloc(sizeof(testclass));
    new(point) testclass(3, 4);
  1. 最终new会返回一个类型指针
 testclass* point = new testclass();

delete 也是一个操作符,底层涉及两个操作:

  1. 首先按次序调用析构函数,释放类内new的空间。
  2. 调用free库函数,回收这块内存。

2.删除元素

在数组中删除元素并不会删除那块内存,而是需要将其他内存位置的元素顺序平移。
删除vector的末尾元素,只需要将size-1,不需移动元素,删除其他位置,需要移动元素。

3.查找元素

通过下标或者算法。

4.修改元素

没啥说的。

5.增大内存空间

vector中的push_back(涉及到动态分配),resize,reserve,swap方法解析
vector中表示内存大小的基本概念:
capacity:表示用于存放真实数据的开辟在堆上的内存空间的大小(用个数来表示,实际大小为 个数 * sizeof(type));
size:表示堆中内存已经被放入数据的个数

  • 增大内存空间:当size超过capacity或者我们想手动增大capacity时,可以使用reserve(N),改变的是capacity,只能变大,改小没用(不能通过此收回已申请的内存)。内存增大的时候会重新申请一块内存,并拷贝原始数据,再释放原始内存空间。
  • 增大存储数据个数:使用resize(N),改变的是size

对于一个vector,如果这样声明:

vector<int> vec;
//或者
vector<int>vec(10);

初始化时vector开辟的内存空间是很小的,如果在之后已经确定要往此vector中添加非常大量的数据比如10000个,那么在这个过程中就会触发多次的vector自动扩容操作(新申请一块1.5倍内存,再拷贝),就会有大块内存需要整块复制到另一块内存,且频繁进行这个操作,非常浪费性能。

因此reserve()函数有一个非常重要的应用:避免vector过多的自动扩容导致的内存搬运操作
在初始化vector后,立即扩容一个接近最终存放大小的内存,这样就能避免在添加元素的过程中因为capacity不够频繁扩容的操作。

vector<int> vec;
vec.reserve(10000);

6.缩减内存空间

reserve()只能增大内存空间,并不能收回没有被使用的内存空间,这样会产生一个弊端:

  • 如果数组本来很大,后面只保留了几个元素,那么未使用的内存很多被浪费掉了,能不能手动把这部分内存释放掉呢?

方法:使用临时对象拷贝原数组再交换彼此,妙哇!

这一过程的原理是临时对象vector(src)它的capacity是 = size的,也就是临时对象只申请了size大小的内存,没有空闲空间。

这样临时对象的swap()操作,交换了双方的资源指针、size/capacity这些变量,临时对象销毁的时候就释放掉了原来vector的空间,原来的vector就变成了刚才的临时对象。

vector<testclass> src(10000);
vector<testclass>(src).swap(src);

7.数组排序

C++ vector 自定义排序规则(vector<vector<int>>、vector<pair<int,int>>)

数组相关算法

查找元素

二分法

1.寻找重复数

力扣链接
题目描述:
给定一个包含 n + 1 个整数的数组 nums ,其数字都在 [1, n] 范围内(包括 1 和 n),可知至少存在一个重复的整数

假设 nums 只有 一个重复的整数 ,返回 这个重复的数 。

你设计的解决方案必须 不修改 数组 nums 且只用常量级 O(1) 的额外空间
方法一:二分法
nums满足的条件:

  1. 只有一个重复数字,但是他可能重复了很多次
  2. 如果排序好,nums中的数除了重复数之外都是连续的
    可发现本题中nums中的数组成的区间,满足以下性质:(用重复数补足空缺的数的位置)
    在这里插入图片描述

因此可以使用二分法,对于[0-n]这个区间,每次选取中点判断该处数字是处于左区间还是右区间,一点点逼近重复数字;
注意我们是把数组下标0-n看成是目标值区间

class Solution {
public:
    int findDuplicate(vector<int>& nums) {
        int left = 0, right = nums.size()-1;
        // 循环条件改为left <= right,后面right = mid - 1;也是可以的
        while(left < right){
            int mid = left + (right - left) / 2;
            if(isInLeftSection(nums,mid)){
                left = mid + 1;
            }
            else {
                right = mid;
            }
        }
        return left;
    }

    int CountOfLess_Equal(vector<int>& nums,int target){
        int count = 0;
        for_each(nums.begin(),nums.end(),[&](int val){
            if(val <= target)++count;
        });
        return count;
    }
    bool isInLeftSection(vector<int>& nums,int target){
        return CountOfLess_Equal(nums,target) <= target;
    }
};

方法二:双指针法
将其归纳到双指针法区域,见下文。

前缀/后缀和法

1.寻找数组的中心下标

力扣链接
题目描述:
给你一个整数数组 nums ,请计算数组的 中心下标 。

数组 中心下标 是数组的一个下标,其左侧所有元素相加的和等于右侧所有元素相加的和

如果中心下标位于数组最左端,那么左侧数之和视为 0 ,因为在下标的左侧不存在元素。这一点对于中心下标位于数组最右端同样适用。

如果数组有多个中心下标,应该返回 最靠近左边 的那一个。如果数组不存在中心下标,返回 -1 。
解法分析:
两种方法:
方法一:后缀和,开辟一个数组专门存放每个位置的后缀和,然后从前往后遍历;
方法二:求和数组,从前往后遍历,每次从和中减去当前元素,并计算前缀和,比较。
方法一:后缀和:

class Solution{
public:
int pivotIndex(vector<int>& nums) {
		vector<int> right_sum(nums.size(),0);
		for(int i = nums.size() - 2; i >= 0; --i){
			right_sum[i] = right_sum[i+1] + nums[i+1];	
		}
		int left_sum = 0;
		for(int i = 0; i < nums.size(); ++i){
			if(left_sum == right_sum[i])return i;
			left_sum += nums[i];
		}
		return -1;
	}
};

方法二:前缀和:

class Solution {
public:
    int pivotIndex(vector<int>& nums) {
    	// 计算数组的和
        int right_sum = accumulate(nums.begin(),nums.end(),0);
        // 前缀和
        int left_sum = 0;
        for(int i = 0; i < nums.size(); ++i){
            right_sum -= nums[i];
            if(left_sum == right_sum)return i;
            left_sum += nums[i];
        }
        return -1;
    }
};

滑动窗口法

1.长度最小的连续子数组

力扣链接
题目描述:
给定一个含有 n 个正整数的数组和一个正整数 target 。

找出该数组中满足其总和大于等于 target 的长度最小的
子数组
[numsl, numsl+1, …, numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。
方法一:滑动窗口法:
注意数组满足的以下特性

  • 全为正整数,对于一个窗口来说,加入任何一个元素都会使得和变大
  • 右窗口边界向右扩充以添加元素,左窗口向右扩充以剔除元素
class Solution {
public:
    int minSubArrayLen(int target, vector<int>& nums) {
        int left = 0, right = 0, sum = 0, length = INT_MAX;
        while(right != nums.size()){
            sum += nums[right];
            while(sum >= target){
                length = std::min(length, right - left + 1);
                sum -= nums[left++];
            }
            ++right;
        }
        return length != INT_MAX ? length : 0;
    }
};

方法二:前缀和+二分查找法:
思路:创建一个前缀和数组,遍历每一个元素,利用二分查找找到以该元素为起点的子数组右边界。感觉不如方法一,又费空间又费时间。

class Solution {
public:
   int minSubArrayLen(int target, vector<int>& nums) {
       int res = INT_MAX;
       vector<int> presum(nums.size()+1, 0);
       for(int i = 1; i < nums.size()+1; ++i){
           presum[i] = presum[i-1] + nums[i-1];
       }

       for(int i = 0; i < nums.size(); ++i){
          
           auto bound = lower_bound(presum.begin(), presum.end(), target+presum[i]);
           if(bound != presum.end()){
               res = min(static_cast<int>(bound-presum.begin() - i), res);
           }
       }
       return res == INT_MAX ? 0 : res;
   }
  }

2.最长重复子数组

力扣链接
题目描述:
给两个整数数组 nums1 和 nums2 ,返回 两个数组中 公共的 、长度最长的子数组的长度 。
法一:动态规划法
法二:二分查找+哈希法
法三:滑动窗口法
固定一个数组,以此为窗口大小在另一个数组上滑动,这样可以在一次滑动中直接计算出该窗口内最长子数组的长度。
在这里插入图片描述

class Solution {
public:
    int findLength(vector<int>& nums1, vector<int>& nums2) {
        int length1 = nums1.size();
        int length2 = nums2.size();
        int maxlength = 0;
        //第一种对齐方式,用数组2挨个对齐数组1,遍历数组1
        //在数组1上滑动,数组2每次从起点开始
        for(int i = 0; i < length1; ++i){
            int tempLength = maxLengthStartWithSameLocation
            (nums1,nums2,i,length1,length2);
            maxlength = std::max(maxlength,tempLength);
        }
        //第二种对齐方式,用数组1挨个对齐数组2,遍历数组2
        //在数组2上滑动,数组1每次从起点开始
        for(int i = 0; i < length2; ++i){
            int tempLength = maxLengthStartWithSameLocation
            (nums2,nums1,i,length2,length1);
            maxlength = std::max(maxlength,tempLength);
        }
        return maxlength;
    }
    int maxLengthStartWithSameLocation(
        vector<int>& slideArray,
        vector<int>& lockedArray, 
        int startLocation,
        int length1,
        int length2){
        int result = 0, index = 0, count = 0;
        while(index + startLocation < length1
            && index < length2){
            if(slideArray[startLocation+index] == lockedArray[index]){
                ++count;
            }
            else{
                count = 0;
            }
            result = std::max(result,count);
            ++index;
        }
        return result;
    }
};

双指针法

1.寻找重复数

力扣链接
题目描述:
给定一个包含 n + 1 个整数的数组 nums ,其数字都在 [1, n] 范围内(包括 1 和 n),可知至少存在一个重复的整数

假设 nums 只有 一个重复的整数 ,返回 这个重复的数 。

你设计的解决方案必须 不修改 数组 nums 且只用常量级 O(1) 的额外空间
方法一:二分法
方法二:双指针法
如果把数组中的数转化为一个有环的链表,那么这道题成了找到有环链表的环的入口。
在这里插入图片描述
判断链表有环:非常简单,可以使用经典的龟兔赛跑法,也就是使用双指针(快慢指针)
慢指针一次走一个节点,快指针一次走两个节点;
这样快指针一定最先到达链表尾部。

  • 如果无环,则快指针指向nullptr,判断结束
  • 如果有环,则快指针会进入环中,无限循环,知道慢指针也进入环中,并最终相遇,此时即可判断出有环。

那么如何找出环的入口呢?
使用数学方法推导:
在这里插入图片描述
考虑快慢指针相遇时:
有如下事实:

  1. 快指针走两步,慢指针走一步
  2. 快慢指针行进速度一致
  3. 因此快指针走过的结点数 = 2 * (慢指针走过的结点数)

慢指针走过的结点数: c + k 2 ( a + b ) + a c + k_2(a+b) + a c+k2(a+b)+a
快指针走过的结点数: c + k 1 ( a + b ) + a c + k_1(a+b) + a c+k1(a+b)+a
所以有 2 ( c + k 2 ( a + b ) + a ) 2(c + k_2(a+b) + a) 2(c+k2(a+b)+a) = c + k 1 ( a + b ) + a c + k_1(a+b) + a c+k1(a+b)+a
也即 c + a = ( k 1 − 2 k 2 ) ( a + b ) c+a = (k_1-2k_2)(a+b) c+a=(k12k2)(a+b)
整理得 c = ( k 1 − 2 k 2 − 1 ) a + ( k 1 − 2 k 2 ) b c=(k_1-2k_2-1)a+(k_1-2k_2)b c=(k12k21)a+(k12k2)b
换句话说 c = Q ( a + b ) + b c=Q(a+b) + b c=Q(a+b)+b
这个式子意味着,如果快指针从相遇点开始,慢指针从起点开始,两者每次都走一个节点,那么当慢指针从起点走到环的入口时,快指针也正好走到环的入口,两者相遇在环的入口。

因此,想要找出链表中还的入口位置:

  1. 使用快慢指针找到环中相遇点
  2. 此时让慢指针移动到起点
  3. 两者每次移动一个节点,找到相遇点,即为入口。
class Solution {
public:
    int findDuplicate(vector<int>& nums) {
        int fast = 0, slow = 0;
        while(true){
            fast = nums[nums[fast]];
            slow = nums[slow];
            if(fast == slow)break;
        }
        slow = 0;
        while(fast != slow){
            slow = nums[slow];
            fast = nums[fast];
        }
        return slow;
    }
};

2.原地移除元素

力扣链接
题目描述:
给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素。元素的顺序可能发生改变。然后返回 nums 中与 val 不同的元素的数量。

假设 nums 中不等于 val 的元素数量为 k,要通过此题,您需要执行以下操作:

更改 nums 数组,使 nums 的前 k 个元素包含不等于 val 的元素。nums 的其余元素和 nums 的大小并不重要。
方法:双指针
使用快指针指向原始数组,慢指针指向新数组
快指针每次遍历+1
慢指针只有在遇到非移除值时+1,并将快指针指向元素拷贝给慢指针所在位置

class Solution {
public:
    int removeElement(vector<int>& nums, int val) {
        int newArrayEnd = 0;
        for(int i = 0; i <= nums.size(); ++i){
        	if(val == nums[i[){
				nums[newArrayEnd++] = nums[i];
			}
		}
    }
    return newArrayEnd;
};

动态规划法

排序算法

时间复杂度O(n^2)级排序算法

冒泡排序
选择排序
插入排序

时间复杂度O(nlogn)级排序算法

快速排序
归并排序
堆排序
希尔排序

时间复杂度O(n)级排序算法

桶排序
计数排序
基数排序
  • 18
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值