2-3 栈 - 图1

1. 栈的定义

关于栈这种数据结构,我们有一个生动形象的比喻。
我们把栈空间比作一摞盘子,每次放盘子的时候只能在盘顶放,取得时候也只能在盘顶取,不能从中间任意抽取。
事实上,满足这种只在一端进行操作,后进先出,先进后出的数据结构,就称为栈。


我们再仔细想一想,若想满足上述情况,我们用链表/数组也可以实现,为什么还要发明栈这种数据结构?
确实!数组和链表都可以满足上述条件,但是除了满足上述条件,其暴露了更多的操作接口,这样就使得用户可以随意更改我们的结构,也就违反了我们设计的初衷。因此 ,当实际应用场景满足先进后出,后进先出的条件时,我们优先选用栈这种数据结构。

2. 栈的分类

在第一小节中,我们提到数组和链表都可以实现栈,那么想对应的,就会有两种形式的栈。
使用数组实现的栈称为顺序栈,使用链表实现的栈称为链式栈。

2.1 顺序栈

当我们选用数组实现栈时,我们需要考虑,数组的哪一端作为栈顶呢?答案显而易见,要使用数组尾部作为栈顶,这样就可以避免大量的数据搬移工作。
下面我们看一下代码实现:

  1. using data_type = int;
  2. class CStack
  3. {
  4. public:
  5. CStack(int ncount):m_ncount(ncount)
  6. {
  7. if (nullptr != m_data)
  8. {
  9. delete m_data;
  10. m_data = nullptr;
  11. }
  12. m_data = new data_type[n];
  13. if (nullptr == m_data)
  14. {
  15. return;
  16. }
  17. }
  18. bool push(const data_type &elem)
  19. {
  20. if (m_ncount == m_nindex)
  21. {
  22. return false;
  23. }
  24. m_data[m_nindex] = elem;
  25. ++m_nindex;
  26. }
  27. bool pop(data_type &elem)
  28. {
  29. if (0 == m_nindex)
  30. {
  31. return false;
  32. }
  33. elem = m_data[m_nindex - 1];
  34. --m_nindex;
  35. }
  36. private:
  37. data_type *m_data = nullptr; // 栈元素
  38. int m_ncount = 0; // 栈容量
  39. int m_nindex = 0; // 栈顶指针(指向当前栈顶的下一个)
  40. }

通过上述代码,我们很简单就可以判断出时间复杂度为2-3 栈 - 图2,空间复杂度为2-3 栈 - 图3


上述代码中,栈的大小是固定不变的,当栈满以后,就会出现入栈失败的情况,那么如何解决这种问题呢?
很简单,我们会想到使用动态数组,当栈空间不够时,申请一块新的内存,将旧数据搬运进去即可。过程示意图如下:
b193adf5db4356d8ab35a1d32142b3da.webp
具体代码实现如下:

  1. class CStackArray
  2. {
  3. public:
  4. CStackArray(const int &nCount = STATCK_DEFAULT_COUNT)
  5. {
  6. if (nullptr == m_data)
  7. {
  8. m_data = new std::string[nCount];
  9. if (nullptr == m_data)
  10. {
  11. std::cout << "malloc data failed!" << std::endl;
  12. return;
  13. }
  14. m_count = nCount;
  15. m_index = 0;
  16. }
  17. }
  18. ~CStackArray()
  19. {
  20. if (nullptr != m_data)
  21. {
  22. delete[] m_data;
  23. m_data = nullptr;
  24. }
  25. }
  26. bool empty()
  27. {
  28. return 0 == m_index;
  29. }
  30. bool full()
  31. {
  32. return m_count == m_index;
  33. }
  34. int size()
  35. {
  36. return m_index;
  37. }
  38. bool push(const std::string &str)
  39. {
  40. if (full())
  41. {
  42. // 需要扩容
  43. std::string *temp = new std::string[2 * m_count];
  44. if (nullptr == temp)
  45. {
  46. std::cout << "fail to malloc!" << std::endl;
  47. return false;
  48. }
  49. for (int i = 0; i < size(); ++i)
  50. {
  51. temp[i] = m_data[i];
  52. }
  53. if (nullptr != m_data)
  54. {
  55. delete[] m_data;
  56. m_data = nullptr;
  57. }
  58. m_data = temp;
  59. }
  60. // 简单入栈
  61. m_data[m_index++] = str;
  62. return true;
  63. }
  64. bool pop(std::string &str)
  65. {
  66. if (empty())
  67. {
  68. std::cout << "stack is empty!" << std::endl;
  69. return false;
  70. }
  71. str = m_data[--m_index];
  72. return true;
  73. }
  74. private:
  75. std::string *m_data = nullptr; // 数据内容
  76. int m_count = 0; // 栈的容量
  77. int m_index = 0; // 入栈指针
  78. };

我们分析上述代码,发现出栈的时间复杂度依然为2-3 栈 - 图5,但入栈的时间复杂度却发生了变化:

  • 当栈有空闲空间时,入栈的时间复杂度为2-3 栈 - 图6。<最好时间复杂度>
  • 当栈没有空闲空间时,入栈的时间复杂度为2-3 栈 - 图7。<最坏时间复杂度>

上述我们分析了最好、最坏时间复杂度,那么平均时间复杂度是怎么样的呢?
假设栈容量为n个,当进行了n次简单入栈操作(时间复杂度为2-3 栈 - 图8)之后,此时栈满需要扩容,时间复杂度变为2-3 栈 - 图9,后续入栈都会重复这一过程,我们用一张图描述这一过程:
c936a39ad54a9fdf526e805dc18cf6bb.webp
通过上述分析可知,时间复杂度仅仅会在第k次退化为2-3 栈 - 图11,其他时间均为2-3 栈 - 图12,将第k次的时间复杂度均摊到其他入栈操作上,就可以得出平均时间复杂度为2-3 栈 - 图13

3.栈的应用

3.1 函数调用

在函数调用过程,每个函数都会分配一个栈帧,函数执行过程中会将临时变量入栈,执行结束后,会将临时变量出栈,清空相应栈帧。
下面我们看一个例子:

  1. int main() {
  2. int a = 1;
  3. int ret = 0;
  4. int res = 0;
  5. ret = add(3, 5);
  6. res = a + ret;
  7. printf("%d", res);
  8. reuturn 0;
  9. }
  10. int add(int x, int y) {
  11. int sum = 0;
  12. sum = x + y;
  13. return sum;
  14. }

上述代码中,main函数执行过程中,会将临时变量a,ret,res分别压入栈中,调用add函数时,将y,x先后压入栈中。为方便理解,我画了一张示意图:
17b6c6711e8d60b61d65fb0df5559a1c.webp

3.2 四则运算表达式计算

例如:3+5*8-6表达式计算,我们可以快速的口算出来,那么计算机是如何进行计算的呢?
计算机会准备两个栈,一个用来存储操作数,一个用来存储运算符,当从左到右遍历运算表达式时,若遇到数字,则将其入栈到操作数栈,当遇到运算符时,若当前运算符优先级高于运算符栈栈顶元素,则直接入栈,否则,取出操作数栈栈顶两元素与运算符栈栈顶元素进行运算,将结果压入操作数栈,将待入栈操作符与操作符栈顶元素比较,继续执行上述过程。

以3+5*8-6为例,下图表示了其运算过程:
bc77c8d33375750f1700eb7778551600.webp

4. 思考题:为什么函数调用要使用栈这一数据结构,用其他的数据结构可以吗?

当然是可以的,只是栈先进后出、后进先出这一特性完美契合函数调用过程,顺理成章函数调用就使用了这一数据结构。


函数调用,本质上作用域的切换。需要满足调用函数后,作用域切换到被调用函数,调用结束后,作用域切换到调用函数。
所谓作用域,本质上是临时变量存在的地方。当调用函数时,被调用函数的临时变量被压入栈中,此时栈顶指针指向被调用函数,完成了作用域的切换;当函数调用结束后,被调用函数的变量释放、出栈,此时栈顶指针指向调用函数的变量,作用域重新回到调用函数。怎么样?是不是很完美!!!!