2.剑指Offer --- 面试需要的基础知识
Posted enlyhua
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了2.剑指Offer --- 面试需要的基础知识相关的知识,希望对你有一定的参考价值。
第2章 面试需要的基础知识
2.1 面试官谈基础知识
2.2 编程语言
要么问语法,要么让应聘者用一种编程语言解决一个问题。
2.2.1 C++
通常语言面试有3种类型。
a) 第一种是直接询问对C++概念的理解
例如,在C++中,有哪4类与类型转换相关的关键字?这些关键字有什么特点,应该在什么场景下使用?
Q:定义一个空的类型,里面没有任何成员变量和成员函数,对该类型求 sizeof,得到的结果是多少?
A:答案是1
Q:为什么不是0
A:空类型的实例中不包含任何信息,本来求sizeof应该是0,但是我们声明该类型实例的时候,它必须在内存中占有一定的空间,否则无法使用这些实例。至于占多少,
由编译器决定。VS中每个空类型的实例占用1字节的空间。
Q:如果在该类型中添加一个构造函数和析构函数,再对该类型sizeof,得到的结果是多少?
A:还是1.调用构造函数和析构函数只需要函数的地址即可,而这些函数的地址只与类型有关,而与类型的实例无关,编译器也不会因为这2个函数而在实例的内存中添加
任何额外的信息。
Q:如果把析构函数标记为虚函数呢?
A:C++编译器一旦发现一个类型中有虚拟函数,就会为该类型生成虚函数表,并在该类型的每个实例中添加一个指向虚函数表的指针。在32位机器上,一个指针占用4字节的
空间,因此sizeof是4;如果是64位,则是8.
b) 第二种面试官直接拿出事先准备好的代码,让应聘者分析代码的运行结果,这种代码通常包含比较微妙的语言特性。
c) 第三种是要求应聘者写代码定义一个类型或者实现类型中的成员函数。
面试题1:赋值运算符
题目:如下为类型CMyString的声明,为该类型添加赋值运算符函数。
class CMyString
{
public:
CMyString(char* pData = NULL);
CMyString(const CMyString& str);
~CMyString(void);
private:
char* m_pdata;
};
考察点:
1.是否把返回值的类型声明为该类型的引用,并在函数结束前返回实例自身的引用(即*this)。只有返回一个引用,才可以允许连续赋值。
2.是否把传入的参数的类型声明为常量引用。如果传入的参数不是引用而是实例,那么从形参到实参会调用一次复制构造函数。把参数声明为引用可以避免这样的无谓消耗,提高代码的
效率。同时,我们在赋值运算符函数内不会改变传入的实例的状态,因此应该为传入的引用参数加上 const 关键字。
3.是否释放实例自身已有的内存。
4.是否判断传入的参数和当前的实例(*this)是不是同一个实例。如果是同一个,则不能进行赋值操作,直接返回。
经典解法,适用于初级程序员:
CMyString& CMyString::operator = (const CMyString &str)
{
if (this == &str) {
return *this;
}
delete []m_pData;
m_pData = NULL;
m_pData = new char[strlen(str.m_pData) + 1];
strcpy(m_pData, str.m_pData);
return *this;
}
考虑异常安全性的解法,高级程序员必备:
在起那么的函数中,我们在分配内存之前先用 delete 释放了实例 m_pData 的内存。如果此时内存不足导致 new char 抛出异常,m_pData 将是一个空指针,这样
程序非常容易崩溃。也就是说一旦在赋值运算符函数内部抛出一个异常,CMyString的实例不再保持有效的状态,就违背了 异常安全性 原则。
有两种方法:
1.我们先用new分配新内容再用delete释放已有内存。这样只在分配内容成功之后再释放原有内容,也就是当分配内存失败时我们能确保CMyString的实例不会被修改。
2.先创建一个临时实例,再交换临时实例和原来的实例。
CMyString& CMyString::operator = (const CMyString &str)
{
if (this != &str) {
CMyString strTemp(str);
char* pTemp = strTemp.m_pData;
strTemp.m_pData = m_pData;
m_pData = pTemp;
}
return *this;
}
2.2.2 C#
Q:C++中可以用 struct和 class 来定义类型,区别是什么?
A:如果没有标明成员函数或成员变量的访问权限级别,在struct 中默认是public,在 class中默认是 private。
Q:那在C#中呢?
A:C#和C++不一样,在C#中如果没有标明成员函数或者成员变量的访问权限级别,struct和class中都是private。struct和class的区别是struct定义的是值类型,
值类型的实例是在栈上分配内存的;而class定义的是引用类型,引用类型的实例是在堆上分配内存的。
面试题2:实现Singleton模式
题目:设计一个类,我们只能生成该类的一个实例。
1.不好的解法一:只能适用于单线程环境
由于要求只能生成一个实例,因此我们必须把构造函数设为私有函数以禁止他人创建实例。
public sealed class Singleton1
{
private Singleton1()
{
}
private static Singleton1 instance = null;
public static Singoleton1 Instance
{
get
{
if (instance == null) {
instance = new Singleton1();
}
return instance;
}
}
}
缺点:
设想如果两个线程同时去判断instance是否为null的if语句,并且instance的确没有创建时,那么两个线程都会创建一个实例,此时类型Singleton1就不再满足
单例模式的要求了。为了保证多线程环境下我们还能只得到类型的一个实例,需要加上一个同步锁。
2.不好的解法二:虽然在多线程环境中能工作但效率不高
public sealed class Singleton2
{
private Singleton2
{
}
private static readonly object syncObj = new object();
private static Singleton2 instance = null;
public static Singleton2 Instance
{
get
{
lock (syncObj)
{
if (instance == null) {
instance = new Singleton2();
}
}
return instance;
}
}
}
缺点:
每次通过属性 Instance 得到 Singleton2 的实例,都会试图加上一个同步锁,而加锁是一个比较耗时的操作,尽量避免。
3.可行的解法:加同步锁前后两次判断实例是否存在
public sealed class Singleton3
{
private Singleton3()
{
}
private static object syncObj = new object();
private static Singleton3 instance = null;
public static Singleton3 Instance
{
get
{
if (instance == null) {
lock (syncObj) {
if (instance == null) {
instance = new Singleton3();
}
}
}
return instance;
}
}
}
优点:
只有当 instance 为 null的时候,需要加锁操作。
缺点:
Singleton3 用加锁机制来确保多线程环境下只创造一个实例,并且用2个if判断来提高效率。这样代码实现比较复杂,容易出错。
4.强烈推荐的解法一:利用静态构造函数
C# 中有一个函数能够确保只调用一次,那就是静态构造函数。
public sealed class Singleton4
{
private Singleton4
{
}
private static Singleton4 instance = new Singleton4();
public static Singleton4 Instance
{
get
{
return instance;
}
}
}
优点:
Singleton4 的实现代码非常简洁。我们在初始化静态变量 instance 的时候创建一个实例。由于C#是在调用静态构造函数时初始化静态变量,.net 运行时能够确保
只调用一次静态构造函数,这样我们就能确保只初始化一次 instance。
缺点:
C#中调用静态构造函数的时机不是程序员掌控的,而是 .net 运行时发现第一次使用一个类型的时候自动调用该类型的静态构造函数。因此在 Singleton4 中,实例
instance 并不是第一次调用属性 Singleton4.Instance 的时候创建的,而是第一次用到 Singleton4 的时候就被创建。假设我们在 Singleton4 中添加了一个静态
方法,调用该静态方法是不需要创建一个实例的,但如果按照 Singleton4 的方式实现单例模式,仍然会过早的创建实例,从而降低内存的使用效率。
5.强烈推荐的解法二:实现按需创建实例
public sealed class Singleton5
{
Singleton5()
{
}
public static Singleton5 Instance
{
get
{
return Nested.instance;
}
}
class Nested
{
static Nested()
{
}
internal static readonly Singleton5 instance = new Singleton5();
}
}
优点:(私有嵌套类型的特性)
上述 Singleton5 中,我们在内部定义了一个私有类型 Nested。当第一次用到这个嵌套类型的时候,会调用静态构造函数创建Singleton5的实例instance。
类型Nested只在属性 Singleton5.Instance中被用到,由于其私有属性他人无法使用 Nested类型。因此,当我们第一次试图通过属性 Singleton5.Instance
得到 Singleton5 的实例时,会自动调用 Nested 的静态构造函数创建实例 instance。如果我们不调用属性 Singleton5.Instance,那么就不会触发 .net
运行时调用 Nested,也不会创建实例,这样就真正做到按需创建。
sealed 表示它们不能作为其他类型的基类。
2.3 数据结构
大多数面试围绕着 数组,字符串,链表,树,栈及队列展开。
数字和字符串是常见的数据结构,它们用连续内存分别存储数字和字符;
链表和树是面试中出现频率最高的,常常需要大量操作指针,需要注意代码的鲁棒性;
栈是和递归紧密相关的数据结构;
队列与广度优先遍历紧密相关。
2.3.1 数组
数组它占据一块连续的内存并按照顺序存储数据。创建数组的时候,我们需要首先按照数组的容量大小,然后根据大小分配内存。因此数组的空间效率不是很高。
由于数组中的内存是连续的,于是可以根据下标在O(1)时间 读/写 任何元素,因此它的时间效率是很高的。
为了解决数组空间效率不是很高的问题,人们又设计了多种动态数组。比如C++的STL中的 vector。
面试题3:二维数组中的查找
题目:在一个二维数组中,每一行都是按照从左往右递增的顺序排序,每一列都是按照从上到下递增的顺序排序。请完成一个函数,输入这样的一个二维数组和一个整数,
判断数组中是否含有该整数。如下,查找7为true,查找5为false
1 2 8 9
2 4 9 12
4 7 10 13
6 8 11 15
规律:
首先选取数组右上角的数组。如果该数字等于要查找的数字,查找结束;如果该数字大于要查找的数字,剔除这个数字所在的列;如果该数字小于要查找的数字,剔除
这个数字所在的行。我们也可以选左下角来查找,但不能选择左上角或者右下角。
bool Find(int* matrix, int rows, int columns, int number)
{
bool fund = false;
if (matrix == null && rows > 0 && columns > 0) {
int row = 0;
int column = columns - 1;
while (row < rows && column >= 0) {
if (matrix[row * columns + column] == number) {
found = true;
break;
} else if (matrix[row * columns * column] > number) {
--column;
} else {
++row;
}
}
}
return found;
}
2.3.2 字符串
面试题4:替换空格
题目:请实现一个函数,把字符串中的每个空格替换成"%20"。例如,输入"We are hayyp.",则输出"We20%are20%happy."
先问清楚是在原来的基础上替换,还是在新字符串上替换。
1.时间复杂度为O(n^2)的解法,不足以拿到offer
假设字符串的长度是n。对每个空格字符,需要移动后面O(n)个字符,因此对含有O(n)个空格字符的字符串而言总的时间效率就是O(n^2)。
换一种思路,把从前向后替换成从后向前。
2.时间复杂度为O(n)的解法
我们可以先遍历一遍字符串,这样就能统计出字符串中空格的总数,并由此计算出替换后字符串的总长度。每替换一个空格,长度增加2,因此
替换后字符串的长度等于原来的长度加上 2 * 空格数目。
我们从字符串的后面开始复制和替换。首先准备2个指针,P1和P2。P1指向原始字符串的末尾,而P2指向替换之后的字符串末尾。接下来我们移动
指针P1,逐个把它指向的字符串复制到P2指向的位置,直到碰到第一个空格为止。
void ReplaceBlank(char string[], int length)
{
if (string == null || length <= 0) {
return ;
}
//originalLength 为字符串string的实际长度
int originalLength = 0;
int numberOfBlank = 0;
int i = 0;
while (string[i] != '\\0') {
++originalLength;
if (string[i] == ' ') {
++numberOfBlank = 0;
}
++i;
}
//newLength 为把空格替换成 '%20' 之后的长度
int newLength = originalLength + numberOfBlank * 2;
if (newLength > length) {
return ;
}
int indexOfOriginal = originalLength;
int indexOfNew = newLength;
while (indexOfOriginal >= 0 && indexOfNew > indexOfOriginal) {
if (stirng[indexOfOriginal] == ' ') {
string[indexOfNew --] = '0';
string[indexOfNew --] = '2';
string[indexOfNew --] = '%';
} else {
string[indexOfNew --] = string[indexOfOriginal];
}
-- indexOfOriginal;
}
}
2.3.3 链表
单向链表的节点定义如下:
struct ListNode {
int m_nValue;
ListNode* m_pNext;
};
面试题5:从尾到头打印链表
题目:输入一个链表的头结点,从尾到头反过来打印每个节点的值。
链表节点定义如下:
struct ListNode {
int m_nKey;
ListNode* m_pNext;
};
可以用栈实现。
void PrintListReversingly_Iteratively(ListNode* pHead)
{
std::stack<ListNode*> nodes;
ListNode* pNode = pHead;
while (pNode != null) {
nodes.push(pNode);
pNode = pNode->m_pNext;
}
while (!nodes.top()) {
pNode = nodes.top();
printf("%d \\t", pNode->m_nValue);
nodes.pop();
}
}
既然用到栈来实现,而递归的本质就是一个栈结构,于是很自然的想到了用递归实现。要实现反过来输出链表,我们每访问到一个节点的时候,先递归
输出它后面的节点,再输出该节点自身,这样的链表输出结果就反过来了。
void PrintListReversingly_Iteratively(ListNode* pHead)
{
if (pHead != null) {
if (pHead->m_pNext != null) {
PrintListReversingly_Iteratively(pHead->m_pNext);
}
printf("%d \\t", pHead->m_nValue);
}
}
2.3.4 树
树的遍历:
1.先序遍历
2.中序遍历
3.后序遍历
这3种遍历都有递归和循环两种不同的实现方法。
面试题6:重建二叉树
题目:输入某二叉树的前序遍历和中序遍历结果,请重建出该二叉树。假设输入的前序遍历和中序遍历的结果中都不包含重复的数字。例如输入的
前序遍历{1,2,4,7,3,5,6,8} 和 中序遍历序列{4,7,2,1,5,3,8,6}。二叉树节点的定义如下:
struct BinaryTreeNode {
int m_nValue;
BinaryTreeNode* m_pLeft;
BinaryTreeNode* m_pRight;
};
在二叉树的前序遍历中,第一个数字总是树的根结点的值。但在中序遍历中,根结点的值在序列的中间,左子树的节点的值位于根结点的值的左边,
而右子树的节点的值位于根结点的值的右边。因此我们需要扫码中序遍历序列,才能找到根结点的位置。
BinaryTreeNode* Construct(int* preorder, int* inorder, int length)
{
if (preorder == null || inorder == null || length <= 0) {
return null;
}
return ConstructCore(preorder, preorder + length - 1, inorder, inorder + length - 1);
}
BinaryTreeNode* ConstructCore(int* startPreorder,int* endPreorder, int* startInorder, int* endInorder)
{
//1.前序遍历的第一个数字是根节点的值
int rootValue = startPreorder[0];
BinaryTreeNode* root = new BinaryTreeNode();
root->m_nValue = rootValue;
root->m_pLeft = root->m_pRight = null;
if (startPreorder == endPreorder) {
if (startInorder == endInorder && *startPreorder == *startInorder) {
return root;
} else {
throw std::exception("Invalid input.");
}
}
//2.在中序遍历中找到根结点的值
int* rootInorder = startInorder;
while (rootInorder <= endInorder && *rootInorder != rootValue) {
++rootInorder;
}
if (rootInorder == endInorder && *rootInorder != rootValue) {
throw std::exception("Invalid input.");
}
int leftLength = rootInorder - startInorder;
int* leftPreorderEnd = startPreorder + lefthLength;
if (leftLength > 0) {
//3.构建左子树
root->m_pLeft = ConstructCore(startPreorder + 1, leftPreorderEnd, startInorder, rootInorder - 1);
}
//总长度减去 左子树长度
if (leftLength < endPreorder - startPreorder) {
//4.构建右子树
root->m_pRight = ConstructCore(leftPreorderEnd + 1, endPreorder, rootInorder + 1, endInorder);
}
return root;
}
2.3.5 栈和队列
面试题7:用两个栈实现队列
题目:用2个栈实现一个队列。队列声明如下,请实现它的两个函数 appendTail和 deleteHead,分别完成在队列尾部插入结点和在队列头部删除
结点的功能。
template <template T> class CQueue
{
public:
CQueue(void);
~CQueue(void);
void appendTail(const T& node);
T deleteHead();
private:
stack<T> stack1;
stack<T> stack2;
}
template <typename T> void CQueue<T>::appendTail(const T& element)
{
stack1.push(element);
}
template <typename T> T CQueue<T>::deleteHead()
{
if (stack2.size() <= 0) {
while (stack1.size() > 0) {
T& data = stack1.top();
stack1.pop();
stack2.push(data);
}
}
if (stack2.size() == 0) {
throw new exception("queue is empty");
}
T head = stack2.top();
stack2.pop();
return head;
}
2.4 算法和数据操作
查找和排序是考察算法的重点,其中二分查找,归并排序和快速排序。
2.4.1 查找和排序
查找相对简单,不外乎 顺序查找,二分查找,哈希表查找和二叉排序树查找。
提示:
如果面试中要求在排序的数组中(或者部分排序的数组)中查找一个数字或者某个数字出现的个数,我们都可以尝试二分查找。
哈希表和二叉排序树查找的重点是在于考查对应的数据结构而不是算法。哈希表的优点是能在O(1)时间查找某个元素,是效率最高的查找方式。
但其缺点是需要额外的空间来实现哈希表。
排序:插入排序,冒泡排序,归并排序,快速排序。
实现快速排序的关键在于在数组中找到一个数字,接下来把数组中的数字分为两部分,比选择的数字小的数字移动数组的左边,比选择的数字大的
移到数字的右边。
int Partition(int data[], int length, int start, int end)
{
if (data == null || length <= 0 || start < 0 || end >= length) {
throw new std::exception("Invalid Parameters");
}
int index = RandomInRange(start, end);
Swap(&data[index], &data[end]);
int small = start - 1;
for (index = start; index < end; ++ index) {
if (data[index] < data[end]) {
++ small;
if (small != index) {
Swap(&data[index], &data[small]);
}
}
}
++ small;
Swap(&data[small], &data[end]);
return small;
}
递归实现:
void QuickSort(int data[], int length, int start, int end)
{
if (start == end) {
return
}
int index = Partition(data, length, start, end);
if (index > start) {
QuickSort(data, length, start, index - 1);
}
if (index < end) {
QuickSort(data, length, index + 1, end);
}
}
对公司所有员工的年龄排序(类似于 桶排序):
void SortAges(int ages[], int length)
{
if (ages == null || length <= 0) {
return;
}
const int oldestAge = 99;
int timesOfAge[oldestAge + 1];
for (int i = 0; i <= oldestAge; ++i) {
timesOfAge[i] = 0;
}
for (int i = 0; i < length; ++i) {
int age = ages[i];
if (age < 0 || age > oldestAge) {
throw new std::exception("age out of range.");
}
++ timesOfAge[age];
}
int index = 0;
for (int i = 0; i <= timesOfAge[i]; ++j) {
for (int j = 0; j < timesOfAge[i]; ++j) {
ages[index] = i;
++ index;
}
}
}
题目8:旋转数组的最小数字
题目:把一个数组最开始的若干元素搬到数组的末尾,我们称之为数组的旋转。输入一个递增排序的数组的一个旋转,输出旋转数组的最小元素。
例如数组 {3,4,5,1,2} 为 {1,2,3,4,5} 的一个旋转,该数组的最小值为1。
从头遍历一遍,时间复杂度是O(n)。但这个思路没有利用旋转数组的特性,达不到面试官的要求。
我们注意到旋转数组之后的数组实际上可以划分为两个排序的子数组,而且前面的子数组的元素都大于等于后面子数组的元素。我们还注意到最小的元素
刚好是两个子数组的分界线。在排序的数组中我们可以用二分查找法实现O(logn)的查找。
和二分查找法一样,我们利用两个指针分别指向数组的第一个元素和最后一个元素。
接着我们可以找到数组中间的元素。如果该中间元素位于前面的递增子数组,那么它应该大于或者等于第一个指针指向的元素。此时数组中最小的元素应该
位于该中间元素的后面。我们可以把第一个指针指向该中间元素,这样可以缩小寻找的范围。移动之后的第一个指针仍然位于前面的递增子数组之中。
同样,如果中间元素位于后面的递增子数组,那么它应该小于或者等于第二个指针指向的元素。此时数组中最小的元素应该位于该中间元素的前面。我们可以
把第二个指针指向该中间元素,这样也可以缩小寻找的范围。移动之后的第二个指针仍然位于后面的递增子数组之中。
不管是移动第一个指针还是第二个指针,寻找范围都会缩小到原来的一半。接下来我们再用更新之后的两个指针,重复做新一轮的查找。
按照上面的思路,第一个指针总是指向前面递增数组的元素,而第二个指针总是指向后面递增数组的元素。最终第一个指针指向前面子数组的最后一个元素,
而第二个指针指向子数组的第一个元素。也就是它们最终指向两个相邻的元素,而第二个指针指向的刚好是最小的元素。这就是循环结束的条件。
int Min(int* numbers, int length)
{
if (numbers == null || length<= 0) {
throw new std::exception("Invalid parameters.");
}
int index1 = 0;
int index2 = length - 1;
int indexMid = index1;
while (numbers[index1] >= numbers[index2]) {
if (index2 - index1 == 1) {
indexMid = index2;
break;
}
indexMid = (index1 + index2)/2;
if (numbers[indexMid] >= numbers[index1]) {
index1 = indexMid;
} else if (numbers[indexMid] <= numbers[index2]) {
index2 = indexMid;
}
}
return numbers[indexMid];
}
前面提到在旋转数组中,由于是把递增数组前面的若干个数字搬到数组后面,因此第一个数字总是大于最后一个,但还是有特例:如果把排序数组的前面的0个
元素搬到最后,即排序数组本身,这仍然是数组的一个旋转,代码需要支持这种情况。此时,数组中的第一个数字就是最小的数字,可以直接返回。这就是上面代码中
把 indexMix 初始化为 index1 的原因。
还有一种情况,第一个数字,最后一个数字和中间数字都是1.因此,需要优化下:
int Min(int* numbers, int lentgh)
{
if (numbers == null || length <= 0) {
throw new std::exception("Invalid parameters.");
}
int index1 = 0;
int index2 = length - 1;
int indexMid = index1;
while (numbers[index1] >= numbers[index2]) {
if (index2 - index1 == 1) {
indexMid = index2;
break;
}
indexMid = (index1 + index2) / 2;
if (numbers[index1] == numbers[index2] && numbers[indexMid] == numbers[index1]) {
return MinInOrder(numbers, index1, index2);
}
if (numbers[indexMid] >= numbers[[index1]]) {
index1 = indexMid;
} else if (numbers[indexMid] <= numbers[index2]) {
index2 = indexMid;
}
}
return numbers[indexMid];
}
int MinInOrder(int* numbers, int index1, int index2)
{
int result = numbers[index1];
for (int i = index1 + 1; i <= index2; i++) {
if (result > numbers[i]) {
result = numbers[i];
}
}
return result;
}
2.4.2 递归和循环
如果我们需要重复的多次计算相同的问题,通常可以选择递归或者循环两种不同的方法。
面试题9:斐波那契数列
题目一:写一个函数,输入n,求斐波那契数列的第n项。斐波那契数列的定义如下:
f(n) = {
0 n = 0;
1 n = 1;
f(n-1)+f(n-1) n > 1;
}
效率很低的写法:
long long Fibonacci(unsigned int n)
{
if (n <= 0) {
return 0;
}
if (n == 1) {
reuturn 1;
}
return Fibonacci(n-1) + Fibonacci(n-2);
}
面试官期待的实用解法:
long long Fibonacci(unsigned n)
{
int result[2] = {0,1};
if (n < 2) {
return result[n];
}
long long fibNMinusOne = 1;
long long fibNMinusOne = 0;
long long fibN = 0;
for (unsigned int i = 2; i <= n; ++i) {
fibN = fibNMinusOne + fibNMinusTwo;
fibNMinusTwo = fibNMinusOne;
fibNMinusOne = fibN;
}
return fibN;
}
时间复杂度O(logN)但不够实用的算法:
把斐波那契数列转换成求矩阵的乘方。
题目二:一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
首先我们考虑最简单的情况。如果只有1级台阶,那显然只有一种跳法。如果有2级台阶,那就有两种跳法:一种是分两次跳,每次跳1级;另外一种
就是一次跳2级。
接着我们再来讨论另外一种情况。我们把n级台阶时的跳法看成是n的函数,记为f(n)。当n>2时,第一次跳的时候有两种不同的选择:一是第一次只
跳1级,此时跳法数目等于后面剩下的 n-1 级台阶的跳法数目,即为f(n-1);另外一种选择是第一次跳2级,此时跳法数目等于后面剩下的 n-2 级台阶
的跳法数,即为f(n-2)。因此,n级台阶的不同跳法的总数 f(n) = f(n-1) + f(n-2)。实际上就是斐波那契。
题目三:我们可以用 2*1 的小矩形横着或者竖着去覆盖更大的矩形。请问8个 2*1 的小矩形无重叠的覆盖 2*8 的大矩形,总共有多少种方法?
我们先把 2*8 的覆盖方法记为 f(8)。用第一个 1*2 小矩形去覆盖大矩形的最左边时有两个选择,横着放或者竖着放。当竖着放的时候,右边还
剩下 2*7 的区域,这种情况下的覆盖方法记为 f(7)。接下来考虑横着放的情况,当1*2 的小矩形横着放在左上角的时候,左下角必须也横着放一个1*2
的小矩形,而右下角还剩下2*6的区域,这种情况下的覆盖方法记为f(6),因此 f(8)=f(7)+f(6),仍然是一个斐波那契。
2.4.3 位运算
面试题10:二进制中1的个数
题目:请实现一个函数,输入一个整数,输出该数二进制表示中的1的个数。例如把9表示成二进制是1001,有2位是1。因此输入9,该函数输出2.
可能引起死循环的解法:
int NumberOf1(int n)
{
int count = 0;
while (n) {
if (n & 1) {
count++;
}
n = n >> 1;
}
return count;
}
缺点:上面的函数,如果输入一个负数,移位后仍然需要保证是一个负数。如果一直做右移运算,最终这个数字就会变成0xFFFFFFFF而陷入死循环。
常规解法:
为了避免死循环,我们可以不右移输入的数字n。首先把 n 和 1 做与运算,判断n的最低位是不是1.接着把1左移移位得到2,再和n做与运算,
就能判断n的次低位是不是1。。。这样反复左移,每次都能判断n的其中一位是不是1.
int NumberOf1(int n)
{
int count = 0;
unsigned int flag = 1;
while (flag) {
if (n & flag) {
count++;
}
flag = flag << 1;
}
return count;
}
缺点:这个解法中的循环次数等于整数二进制的位数。下面介绍一种算法,有几个1,就循环几次。
能给面试官带来惊喜的解法:
把一个整数减去1,再和原来的整数做与运算,会把该整数最右边一个1变成0。那么一个整数的二进制表示中有多少个1,就可以进行多次这样的操作。
如,1100 减去 1的结果是 1011,与 1100 做与运算,得到 1000。是把1100最右边的1变成了0.
int NumberOf1(int n)
{
int count = 0;
while (n) {
++count;
n = (n - 1) & n;
}
return count;
}
相关题目:
1.用一条语句判断一个整数是不是2的整数次方。一个整数如果是2的整数次方,那么它的二进制表示中有且只有一位是1,而其他所有位都是0.
根据前面的分析,把这个整数减去1后再和它自己做与运算,这个整数中唯一的1就会变成0.
2.输入2个整数m和n,计算需要改变m的二进制表示中的多少位才能得到n。比如10的二进制表示为 1010,13的二进制表示为1101,需要改变
1010中的3位才能得到1101。我们可以分为两步解决这个问题:第一步求这两个数的异或,第二步统计异或结果中的1的位数。
举一反三:
把一个整数减去1之后再和原来的整数做与运算,得到的结果相当于是把整数的二进制表示中最右边一个1变成0.很多二进制的问题都可以用这个思路解决。
以上是关于2.剑指Offer --- 面试需要的基础知识的主要内容,如果未能解决你的问题,请参考以下文章