【剑指Offer】03.数组中的重复数字(cpp求解)

文章详细介绍了如何使用C++的unordered_map和vector解决《剑指Offer》中关于找出数组重复数字的问题。文中讲解了vector的基本操作,如访问、添加元素,以及unordered_map的创建和使用方法,同时讨论了迭代器和增强型for循环的差异,并简述了哈希表的概念和哈希函数的种类。
摘要由CSDN通过智能技术生成

《剑指Offer》 03.数组中的重复数字(cpp求解)

原题:

找出数组中重复的数字。

​ 在一个长度为 n 的数组 nums 里的所有数字都在 0~n-1 的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。请找出数组中任意一个重复的数字。

求解代码:

思想:遍历数组nums中的每一个元素,挨个传入num,刚开始肯定是跳过if判断,将map[num]标记为true,直到遇见了第一个重复的数字就返回;

其实就是将每个num都保存在对应的num下标里,判断map[num]中有没有存放值,存放了就是有重复的,没存放就存放进去。

class Solution {
public:
    int findRepeatNumber(vector<int>& nums) {
        unordered_map<int,bool> map;
        for(int num:nums)
        {
            if(map[num])
                return num;
            map[num] = true;
        }
        return -1;
    }
};

代码详解:

一、vector动态数组

1、什么是vector?

​vector是种容器,类似数组一样,但它的size可以动态改变。vector的元素在内存中连续排列,这一点跟数组一样。这意味着我们元素的索引将非常快,而且也可以通过指针的偏移来获取vector中的元素。

但连续排列也带来了弊端,当我们向vector中间插入一个数据时,整个vector的size变大,在内存中就需要重新分配空间,常规的做法是直接申请一个新的array,并将所有元素拷贝过去;但这么做的话,无疑太浪费时间,因此vector采用的做法是:vector会分配额外的空间,以适应size的动态增长。

因此,包含同样数量元素的vector和数组相比,占用的空间会更大。而且在vector最后增加或者删除一个元素,消耗的时间是一个常数值,与vector的size无关。

与其他容器(deques、lists、forward_lists)相比,vector在获取元素和对最后一个元素的操作效率上更高;但对于中间元素的操作,性能则相对较差。

​ vector是C++标准模板库中的部分内容,和 string对象一样,标准库将负责管理与存储元素相关的内存。我们把 vector称为容器,是因为它可以包含其他对象,但并不准确。它是一个多功能的,能够操作多种数据结构和算法的模板类函数库。vector之所以被认为是一个容器,是因为它能够像容器一样存放各种类型的对象,简单地说,vector是一个能够存放任意类型的动态数组,能够增加和压缩数据。


vector 是同一种类型的对象的集合,每个对象都有一个对应的整数索引值。


vector 是一个类模板(class template)。使用模板可以编写一个类定义或函数定义,而用于多个不同的数据类型。因此,我们可以定义保存 string 对象的 vector,或保存 int 值的 vector,又或是保存自定义的类类型对象(如Sales_items对象)的 vector。vector 不是一种数据类型,而只是一个类模板,可用来定义任意多种数据类型。vector 类型的每一种都指定了其保存元素的类型。

2、简单的使用方法

为了可以使用vector,必须在头文件中加入以下代码:
#include <vector>

vector属于std命名域,因此需要通过命名限定:

using std::vector;

vector<int> vInts;

或者连在一起使用全名:

std::vector<int> vInts;

建议在代码量不大,并且使用的命名空间不多的情况下,使用全局的命名域方式:

using namespace std;

常用的几种方法:
//创建一个int型的空的动态数组
vector<int> a;
a.push_back(1);
a.push_back(2);	//把1和2压入vector,这样a[0]就是1,a[1]就是2

//将a中的元素复制到b中
vector<int >b(a);                          

//创建一个包含100个int类型数据的动态数组,默认初始值都为0
vetcor<int>a(100);

//创建一个包含100个int类型数据的vector,并且都初始化为6:
vector<int>a(100,6);   //vector<int> a(100,a(6));                    
//定义10个值为null的元素
vector<string>a(10,"null");              

//定义10个值为hello的元素
vector<string>a(10,"hello");             

//将动态数组a的元素值复制到b中
vector<string>b(a.begin(),a.end()); 
访问vector中的数据:
1.可以使用at访问:
vector<int> test;	//建立一个vector
test.push_back(1);
test.push_back(2);	//把1和2压入vector,这样test[0]就是1,test[1]就是2
int i = test.at(1);	//i为2
2.operator[]访问:

​ operator[]主要是为了与C语言进行兼容。它可以像C语言数组一样操作。但at()是我们的首选,因为at()进行了边界检查,如果访问超过了vector的范围,将抛出一个例外。由于operator[]容易造成一些错误,所以我们很少用它。

向vector中添加一个数据:

​ vector添加数据的缺省方法是push_back()。push_back()函数表示将数据添加到vector的尾部,并按需要来分配内存。

例如:向vector;中添加10个数据,需要如下编写代码:

for(int i = 0; i < 10; i++)
{
    test.push_back(int(i));
}
获取vector中指定位置的数据:

​ vector里面的数据是动态分配的,使用push_back()的一系列分配空间常常决定于文件或一些数据源

​ 如果想知道vector是否为空,可以使用empty(),空返回true,否则返回false。

​ 获取vector的大小,可以使用size()。例如,如果想获取一个vector v的大小,但不知道它是否为空,或者已经包含了数据,如果为空时想设置为 -1,你可以使用下面的代码实现:

int nSize = v.empty() ? -1 : static_cast<int>(v.size());

3.属性及操作

Iterators
NameDescription
begin返回指向迭代器第一个元素的指针
end返回指向迭代器最后一个元素的指针
rbegin返回迭代器逆序第一个元素的指针
rend返回迭代器逆序最后一个元素的指针
cbegin返回常量迭代器的第一个元素的指针
cend返回常量迭代器的最后一个元素的指针
crbegin返回常量迭代器逆序的第一个元素的指针
crenf返回常量迭代器逆序的最后一个元素的指针
Capacity
NameDescription
size返回当前vector使用数据量的大小
max_size返回vector最大可用的数据量
resize调整vector中的元素个数
capacity返回vector中总共可以容纳的元素个数
empty测试vector是否是空的
reserve控制vector的预留空间
shrink_to_fit减少capacity到size的大小
Element access
NameDescription
operator[]在[]中可以做运算
atvector.at(i)相当于vector[i]
front返回第一个元素的值
back返回最后一个元素的值
data返回指向vector内存数据的指针
Modifiers
NameDescription
assign指定vector内容
push_back在容器的最后一个位置插入元素x
pop_back删除最后一个元素
insert插入元素
erase擦除元素
swap交换两个容器的内容
clear将容器里的内容清空,size值为0,但是存储空间没有改变
emplace插入元素(与insert有区别)
emplace_back在容器的最后一个位置插入元素x(与push_back有区别)
Allocator
NameDescription
get_allocator返回vector的内存分配器

具体可以参考C++参考手册

二、unordered_map函数

​ unordered_map 容器,直译过来就是"无序 map 容器"的意思。所谓“无序”,指的是unordered_map 容器不会像 map 容器那样对存储的数据进行排序。换句话说,unordered_map 容器和 map 容器仅有一点不同,即 map 容器中存储的数据是有序的,而 unordered_map 容器中是无序的。

​ unordered_map 容器以键值对(pair类型)的形式存储数据,存储的各个键值对的键互不相同且不允许被修改。但由于 unordered_map 容器底层采用的是哈希表存储结构,该结构本身不具有对数据的排序功能,所以此容器内部不会自行对存储的键值对进行排序。

​ 如果想使用该容器,代码中应包含如下语句:

#include <unordered_map>
using namespace std;

unordered_map容器模板的定义如下:

template < class Key,                        //键值对中键的类型
           class T,                          //键值对中值的类型
           class Hash = hash<Key>,           //容器内部存储键值对所用的哈希函数
           class Pred = equal_to<Key>,       //判断各个键值对键相同的规则
           class Alloc = allocator< pair<const Key,T> >  // 指定分配器对象的类型
           > class unordered_map;

以上 5 个参数中,必须显式给前 2 个参数传值,并且除特殊情况外,最多只需要使用前 4 个参数,各自的含义和功能如下表所示:

参数含义
<key,T>前 2 个参数分别用于确定键值对中键和值的类型,也就是存储键值对的类型
Hash=hash用于指明容器在存储各个键值对时要使用的哈希函数,默认使用 STL 标准库提供的 hash 哈希函数。注意,默认哈希函数只适用于基本数据类型(包括 string 类型),而不适用于自定义的结构体或者类。
Pred=equal_to要知道,unordered_map 容器中存储的各个键值对的键是不能相等的,而判断是否相等的规则,就由此参数指定。默认情况下,使用 STL 标准库中提供的 equal_to 规则,该规则仅支持可直接用 == 运算符做比较的数据类型。

​ 总的来说,当无序容器中存储键值对的键为自定义类型时,默认的哈希函数 hash 以及比较函数 equal_to 将不再适用,只能自己设计适用该类型的哈希函数和比较函数,并显式传递给 Hash 参数和 Pred 参数。

创建C++中unordered_map容器的方法

  1. 通过调用 unordered_map 模板类的默认构造函数,可以创建空的 unordered_map 容器:

    std::unordered_map<std::string, std::string> umap;
    
  2. 在创建 unordered_map 容器的同时,可以完成初始化操作:

    std::unordered_map<std::string, std::string> umap{
        {"C++reference","https://zh.cppreference.com/w/%E9%A6%96%E9%A1%B5"},
        {"C++bagu","https://www.xiaolincoding.com/"},
        {"C++STL","https://cplusplus.com/reference/unordered_map/unordered_map/"} };
    //通过此方法创建的 umap 容器中,就包含有 3 个键值对元素
    
  3. 还可以调用 unordered_map 模板中提供的复制(拷贝)构造函数,将现有 unordered_map 容器中存储的键值对,复制给新建 unordered_map 容器:

    //例如,在第二种方式创建好 umap 容器的基础上,再创建并初始化一个 umap2 容器:
    std::unordered_map<std::string, std::string> umap2(umap);
    //由此,umap2 容器中就包含有 umap 容器中所有的键值对。
    
  4. C++ 11 标准中还向 unordered_map 模板类增加了移动构造函数,即以右值引用的方式将临时 unordered_map 容器中存储的所有键值对,全部复制给新建容器。例如:

    //返回临时 unordered_map 容器的函数
    std::unordered_map <std::string, std::string > retUmap(){
        std::unordered_map<std::string, std::string>tempUmap{
            {"C++reference","https://zh.cppreference.com/w/%E9%A6%96%E9%A1%B5"},
        {"C++bagu","https://www.xiaolincoding.com/"},
        {"C++STL","https://cplusplus.com/reference/unordered_map/unordered_map/"} };
        return tempUmap;
    }
    //调用移动构造函数,创建 umap2 容器
    std::unordered_map<std::string, std::string> umap2(retUmap());
    
  5. 如果不想全部拷贝,可以使用 unordered_map 类模板提供的迭代器,在现有 unordered_map 容器中选择部分区域内的键值对,为新建 unordered_map 容器初始化。例如:

    //传入 2 个迭代器
    std::unordered_map<std::string, std::string> umap2(++umap.begin(),umap.end());
    //通过此方式创建的 umap2 容器,其内部就包含 umap 容器中除第 1 个键值对外的所有其它键值对。
    

C++中unordered_map容器的成员方法:

可自行查阅
迭代器库
标准库标头<unordered_map>

三、迭代器遍历

for(int num:nums){}
//上述代码对与数组来说等同于
for(int i = 0; i < nums.length; i++)
{
    int num = nums[i];
}

增强型for循环:

1、什么是增强型for循环

增强型for循环,也称for each循环,是迭代器遍历方法的一个“简化版”,专门用来遍历数组和集合

其内部原理是一个Iteration迭代器,在遍历数组/集合的过程中,不能对集合中的元素进行增删操作。

2、增强型for循环的使用
  1. 使用范围:用来遍历集合和数组(必须有遍历目标,目标只能是集合或者数组),所有单列表集合都可以使用增强for循环;

  2. 格式如下:

    for(ElementType element: arrayName) 
    { 
    //集合或数组的数据类型 变量名:集合名/数组名
    	System.out.println(变量名)};
    

    上述for循环也可被读为:

    for each element in arrayName do {...}

例如:

  • 使用普通for循环

    int[] num = {1,2,3,4,5,6};
    for(int i =  0 ; i<num.length ; i++)
    { 
        System.out.println("元素:"+ num[i]); 
    } 
    
  • 使用增强型for循环

    int[] num = {1,2,3,4,5,6};
    for(int i :num)
    {   
    //集合或数组的数据类型int 变量名i:集合名/数组名num
        System.out.println("元素:"+ i); 
    } 
    
3、与迭代器的区别
  1. 增强for循环底层也是使用了迭代器获取的,在使用增强for循环遍历元素的过程中不准使用集合对象对集合的元素个数进行修改
  2. 迭代器与增强for循环遍历元素的区别:使用增强for循环遍历集合的元素时,不能调用迭代器的remove方法删除元素,而使用迭代器遍历集合的元素时可以删除集合的元素
4、与普通for循环的区别
  1. 增强for循环和普通for循环的区别:普通for循环可以没有遍历的目标,而增强for循环一定要有遍历的目标
  2. 写起来简单容易;
  3. 遍历集合、数组比较简单。

四、哈希表

键值对的概念:键-值对(key- value pair)是编程语言对数学概念中映射的实现。键(key)用作元素的索引,值(value)则表示所存储和读取的数据

1.什么是哈希表

哈希表:也叫做散列表。是根据关键字和值(Key-Value)直接进行访问的数据结构。也就是说,它通过关键字 key 和一个映射函数 Hash(key) 计算出对应的值 value,然后把键值对映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做哈希函数(散列函数)用于存放记录的数组叫做 哈希表(散列表)。 哈希表的关键思想是使用哈希函数,将键 key值 value 映射到对应表的某个区块中。可以将算法思想分为两个部分:

  • 向哈希表中插入一个关键字:哈希函数决定该关键字的对应值应该存放到表中的哪个区块,并将对应值存放到该区块中(插入关键字:哈希函数对关键字进行哈希,得到哈希值后插入到哈希表对应的地方)

  • 在哈希表中搜索一个关键字:使用相同的哈希函数从哈希表中查找对应的区块,并在特定的区块搜索该关键字对应的值(搜索关键字:哈希函数对关键字进行哈希,基于哈希值去哈希表中进行查询)

2.什么是哈希函数

哈希函数:将哈希表中元素的关键键值映射为元素存储位置的函数。 哈希函数是哈希表中最重要的部分。一般来说,哈希函数会满足以下几个条件:

  • 哈希函数应该易于计算,并且尽量使计算出来的索引值均匀分布,这能减少哈希冲突
  • 哈希函数计算得到的哈希值是一个固定长度的输出值
  • 如果 Hash(key1) 不等于 Hash(key2),那么 key1、key2 一定不相等
  • 如果 Hash(key1) 等于 Hash(key2),那么 key1、key2 可能相等,也可能不相等(会发生哈希碰撞)

下面介绍几个常用的哈希函数的方法:

2.1直接定址法

直接定址法取关键字或者关键字的某个线性函数值为哈希地址。即Hash(key)=key或者Hash(key)=a*key+b ,其中 a 和 b 为常数。

这种方法计算最简单,且不会产生冲突适合于关键字分布基本连续的情况如果关键字分布不连续,空位较多,则会造成存储空间的浪费

举一个例子,假设有一个记录了从 1 岁到 100 岁的人口数字统计表。其中年龄为关键字,哈希函数取关键字自身,如下表所示。

img

比如想要查询 25 岁的人有多少,则只要查询表中第 25 项即可。

2.2除留余数法

除留余数法:假设哈希表的表长为 m,取一个不大于 m 但接近或等于 m 的质数 p利用取模运算,将关键字转换为哈希地址。即:Hash(key)=key模p,其中 p 为不大于 m 的质数。

这也是一种简单且常用的哈希函数方法。其关键点在于 p 的选择。根据经验而言,一般 p 取素数或者 m,这样可以尽可能的减少冲突。

比如我们需要将 7 个数 [432, 5, 128, 193, 92, 111, 88] 存储在 11 个区块中(长度为 11 的数组),通过除留余数法将这 7 个数应分别位于如下地址:

img

比如432,对11取余数,余数为3,放在03位置

3.哈希冲突

哈希冲突不同的关键字通过同一个哈希函数可能得到同一哈希地址,即 key1 ≠ key2,而 Hash(key1) = Hash(key2),这种现象称为哈希冲突。

理想状态下,我们的哈希函数是完美的一对一映射,即一个关键字(key)对应一个值(value),不需要处理冲突。但是一般情况下,不同的关键字 key 可能对应了同一个值 value,这就发生了哈希冲突。设计再好的哈希函数也无法完全避免哈希冲突。所以就需要通过一定的方法来解决哈希冲突问题。常用的哈希冲突解决方法主要是两类:「开放地址法」和「链地址法」

3.1开放地址法:

开放地址法:指的是将哈希表中的「空地址」向处理冲突开放。当哈希表未满时,处理冲突时需要尝试另外的单元,直到找到空的单元为止。

当发生冲突时,开放地址法按照下面的方法求得后继哈希地址

H(i)=(Hash(key)+F(i))%m,i=1,2,3,...,n(n<m-1)

  • H(i) 是在处理冲突中得到的地址序列。即在第 1 次冲突(i = 1)时经过处理得到一个新地址 H(1),如果在 H(1) 处仍然发生冲突(i = 2)时经过处理时得到另一个新地址 H(2) …… 如此下去,直到求得的 H(n) 不再发生冲突

  • Hash(key) 是哈希函数,m 是哈希表表长,取余目的是为了使得到的下一个地址一定落在哈希表中

  • F(i) 是冲突解决方法,取法可以有以下几种:

    • 线性探测法F(i)=1,2,3,...,m-1
    • 二次探测法F(i)=1²,-1²,2²,-2²,...,n²(n≤m/2)
    • 伪随机数序列F(i) = 伪随机数序列
开放地址法举例

举例说明一下如何用以上三种冲突解决方法处理冲突,并得到新地址 H(i)。例如,在长度为 11 的哈希表中已经填有关键字分别为 28、49、18 的记录(哈希函数为 Hash(key) = key % 11)。

现在将插入关键字为 38 的新纪录,根据哈希函数得到的哈希地址为 5,产生冲突。接下来分别使用这三种冲突解决方法处理冲突。

img

  • 使用线性探测法:得到下一个地址H(1)=(5+1)%11=6,仍然冲突;继续求 出 H(2)=(5+2)%11=7,仍然冲突;继续求出 H(3)=(5+3)%11=8,8 对应的地址为空,处理冲突过程结束,记录填入哈希表中序号为 8 的位置。

img

  • 使用二次探测法:得到下一个地址 H(1)=(5+1∗1) ,仍然冲突;继续 求出 H(2)=(5−1∗1) ,4 对应的地址为空,处理冲突过程结束,记录 填入哈希表中序号为 4 的位置。

img

  • 使用伪随机数序列:假设伪随机数为 9,则得到下一个地址H(1)=(9+5),3 对应的地址为空,处理冲突过程结束,记录填入哈希表中序号为 3 的位置。

img

3.2链地址法:

链地址法:将具有相同哈希地址的元素(或记录)存储在同一个线性链表中。 链地址法是一种更加常用的哈希冲突解决方法。相比于开放地址法,链地址法更加简单。 假设哈希函数产生的哈希地址区间为 [0, m - 1],哈希表的表长为 m。则可以将哈希表定义为一个有 m 个头节点组成的链表指针数组 T

  • 这样在插入关键字的时候,只需要通过哈希函数 Hash(key) 计算出对应的哈希地址 i,然后将其以链表节点的形式插入到以 T[i] 为头节点的单链表中。在链表中插入位置可以在表头或表尾,也可以在中间。如果每次插入位置为表头,则插入操作的时间复杂度为 O(1)。
  • 而在在查询关键字的时候,只需要通过哈希函数 Hash(key) 计算出对应的哈希地址 i,然后将对应位置上的链表整个扫描一遍,比较链表中每个链节点的键值与查询的键值是否一致。查询操作的时间复杂度跟链表的长度 k 成正比,也就是 O(k)。对于哈希地址比较均匀的哈希函数来说,理论上讲,k=n/m,其中 n 为关键字的个数,m 为哈希表的表长。

相对于开放地址法,采用链地址法处理冲突要多占用一些存储空间(主要是链节点占用空间)。但它可以减少在进行插入和查找具有相同哈希地址的关键字的操作过程中的平均查找长度。这是因为在链地址法中,待比较的关键字都是具有相同哈希地址的元素,而在开放地址法中,待比较的关键字不仅包含具有相同哈希地址的元素,而且还包含哈希地址不相同的元素。

链地址法举例

举例来说明如何使用链地址法处理冲突。

假设现在要存入的关键字集合 keys = [88, 60, 65, 69, 90, 39, 07, 06, 14, 44, 52, 70, 21, 45, 19, 32]。再假定哈希函数为Hash(key)=key%13,哈希表的表长 m = 13,哈希地址范围为 [0, m - 1]

将这些关键字使用链地址法处理冲突,并按顺序加入哈希表中(图示为插入链表表尾位置),最终得到的哈希表如下图所示。

keys=[88,60,65,69,90,39,07,06,14,44,52,70,21,45,19,32]

img

哈希表学习参考文章:

参考文章

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值